Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

STCOR-952 break QueryStateUpdates out to their own class-based component. #1604

Merged
merged 9 commits into from
Mar 4, 2025
2 changes: 2 additions & 0 deletions src/RootWithIntl.js
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,7 @@ import {
AppCtxMenuProvider,
SessionEventContainer,
AppOrderProvider,
QueryStateUpdater
} from './components';
import StaleBundleWarning from './components/StaleBundleWarning';
import { StripesContext } from './StripesContext';
Expand Down Expand Up @@ -64,6 +65,7 @@ const RootWithIntl = ({ stripes, token = '', isAuthenticated = false, disableAut
<Router history={history}>
{ isAuthenticated || token || disableAuth ?
<>
<QueryStateUpdater stripes={connectedStripes} queryClient={queryClient} />
<MainContainer>
<AppCtxMenuProvider>
<AppOrderProvider>
Expand Down
1 change: 1 addition & 0 deletions src/RootWithIntl.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ jest.mock('./components/ModuleContainer', () => ({ children }) => children);
jest.mock('./components/MainContainer', () => ({ children }) => children);
jest.mock('./components/StaleBundleWarning', () => () => '<StaleBundleWarning>');
jest.mock('./components/SessionEventContainer', () => () => '<SessionEventContainer>');
jest.mock('./components/MainNav/QueryStateUpdater', () => () => null);

const defaultHistory = createMemoryHistory();

Expand Down
55 changes: 1 addition & 54 deletions src/components/MainNav/MainNav.js
Original file line number Diff line number Diff line change
@@ -1,19 +1,8 @@
import React, { useEffect, useRef, useState } from 'react';
import { isEqual } from 'lodash';
import { useIntl } from 'react-intl';
import { useLocation, useHistory } from 'react-router-dom';
import { useQueryClient } from 'react-query';

import { branding } from 'stripes-config';
import { Icon } from '@folio/stripes-components';
import {
updateQueryResource,
getLocationQuery,
updateLocation,
getCurrentModule,
isQueryResourceModule,
getQueryResourceState,
} from '../../locationService';

import css from './MainNav.css';
import NavButton from './NavButton';
Expand All @@ -22,65 +11,23 @@ import { CurrentAppGroup } from './CurrentApp';
import ProfileDropdown from './ProfileDropdown';
import AppList from './AppList';
import { SkipLink } from './components';

import { useAppOrderContext } from './AppOrderProvider';

import { useStripes } from '../../StripesContext';
import { useModules } from '../../ModulesContext';

const MainNav = () => {
const {
apps,
} = useAppOrderContext();
const queryClient = useQueryClient();
const stripes = useStripes();
const location = useLocation();
const modules = useModules();
const history = useHistory();
const intl = useIntl();

const [curModule, setCurModule] = useState(getCurrentModule(modules, location));
const [selectedApp, setSelectedApp] = useState(apps.find(entry => entry.active));
const helpUrl = useRef(stripes.config.helpUrl ?? 'https://docs.folio.org').current;

useEffect(() => {
let curQuery = getLocationQuery(location);
const prevQueryState = {};

const { store } = stripes;
const _unsubscribe = store.subscribe(() => {
const module = curModule;

if (module && isQueryResourceModule(module, location)) {
const { moduleName } = module;
const queryState = getQueryResourceState(module, stripes.store);

// only update location if query state has changed
if (!isEqual(queryState, prevQueryState[moduleName])) {
curQuery = updateLocation(module, curQuery, stripes.store, history, location);
prevQueryState[moduleName] = queryState;
}
}
});

// remove QueryProvider cache to be 100% sure we're starting from a clean slate.
queryClient.removeQueries();

return () => {
_unsubscribe();
};
}, [location]); // eslint-disable-line

// if the location changes, we need to update the current module/query resource.
// This logic changes the visible current app at the starting side of the Main Navigation.
useEffect(() => {
setSelectedApp(apps.find(entry => entry.active));
const nextCurModule = getCurrentModule(modules, location);
if (nextCurModule) {
setCurModule(getCurrentModule(modules, location));
updateQueryResource(location, nextCurModule, stripes.store);
}
}, [modules, location, stripes.store, apps]);
}, [apps]);

return (
<header className={css.navRoot} style={branding?.style?.mainNav ?? {}}>
Expand Down
95 changes: 95 additions & 0 deletions src/components/MainNav/QueryStateUpdater.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,95 @@
import React from 'react';
import PropTypes from 'prop-types';
import { compose } from 'redux';
import { injectIntl } from 'react-intl';
import { withRouter } from 'react-router';
import { isEqual } from 'lodash';

import { withModules } from '../Modules';

import {
updateQueryResource,
getLocationQuery,
updateLocation,
getCurrentModule,
isQueryResourceModule,
getQueryResourceState,
} from '../../locationService';

// onMount of stripes, sync the query state to the location.
class QueryStateUpdater extends React.Component {
static propTypes = {
history: PropTypes.shape({
listen: PropTypes.func.isRequired,
replace: PropTypes.func.isRequired,
push: PropTypes.func.isRequired,
}).isRequired,
location: PropTypes.shape({
pathname: PropTypes.string,
}).isRequired,
modules: PropTypes.shape({
app: PropTypes.arrayOf(PropTypes.object),
}),
queryClient: PropTypes.shape({
removeQueries: PropTypes.func.isRequired,
}).isRequired,
stripes: PropTypes.shape({
store: PropTypes.shape({
subscribe: PropTypes.func.isRequired,
}),
}),
children: PropTypes.oneOfType([
PropTypes.arrayOf(PropTypes.node),
PropTypes.node,
]),
}

constructor(props) {
super(props);
this.store = props.stripes.store;
}

componentDidMount() {
let curQuery = getLocationQuery(this.props.location);
const prevQueryState = {};

this._unsubscribe = this.store.subscribe(() => {
const { history, location } = this.props;
const module = this.curModule;

if (module && isQueryResourceModule(module, location)) {
const { moduleName } = module;
const queryState = getQueryResourceState(module, this.store);

// only update location if query state has changed
if (!isEqual(queryState, prevQueryState[moduleName])) {
curQuery = updateLocation(module, curQuery, this.store, history, location);
prevQueryState[moduleName] = queryState;
}
}
});

// remove QueryProvider cache to be 100% sure we're starting from a clean slate.
this.props.queryClient.removeQueries();
}

componentDidUpdate(prevProps) {
const { modules, location } = this.props;
this.curModule = getCurrentModule(modules, location);
if (this.curModule && !isEqual(location, prevProps.location)) {
updateQueryResource(location, this.curModule, this.store);
}
}

componentWillUnmount() {
this._unsubscribe();
}

render = () => this.props.children;
}

export default compose(
injectIntl,
withRouter,
withModules,
)(QueryStateUpdater);
90 changes: 90 additions & 0 deletions src/components/MainNav/QueryStateUpdater.test.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,90 @@

import { render } from '@folio/jest-config-stripes/testing-library/react';
import { QueryClientProvider, QueryClient } from 'react-query';
import { MemoryRouter as Router, Route } from 'react-router-dom';
import QueryStateUpdater from './QueryStateUpdater';

const mockUpdateQueryResource = jest.fn();
jest.mock('../../locationService', () => ({
...jest.requireActual('../../locationService'),
updateLocation: jest.fn(),
updateQueryResource: jest.fn(() => mockUpdateQueryResource()),
getCurrentModule: jest.fn(() => true)
}));

const mockUnsubscribe = jest.fn();
const stripes = {
store: {
subscribe: jest.fn(() => mockUnsubscribe)
}
};

describe('QueryStateUpdater', () => {
let qc;
let wrapper;
beforeAll(() => {
qc = new QueryClient();
wrapper = (testLocation = { pathname: '/initial' }) => ({ children }) => {
qc = new QueryClient();
return (
<Router initialEntries={['/initial']}>
<QueryClientProvider client={qc}>
<Route
path="*"
location={testLocation}
>
{children}
</Route>
</QueryClientProvider>
</Router>
);
};
});
it('renders', () => {
expect(() => render(
<QueryStateUpdater queryClient={qc} stripes={stripes} />,
{ wrapper: wrapper() }
)).not.toThrow();
});

it('updatesQuery on mount', () => {
let testVal = {
hash: '',
key: 'f727ww',
pathname: '/initial',
search: '',
state: undefined
};
const { rerender } = render(
<QueryStateUpdater
stripes={stripes}
queryClient={qc}
/>,
{ wrapper: wrapper(testVal) }
);
testVal = {
hash: '',
key: 'f727w2',
pathname: '/updated',
search: '',
state: undefined
};
rerender(
<QueryStateUpdater
stripes={stripes}
queryClient={qc}
location={testVal}
/>,
{ wrapper: wrapper(testVal) }
);

expect(() => rerender(
<QueryStateUpdater
stripes={stripes}
queryClient={qc}
location={testVal}
/>,
{ wrapper: wrapper(testVal) }
)).not.toThrow();
});
});
1 change: 1 addition & 0 deletions src/components/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ export { default as Login } from './Login';
export { default as Logout } from './Logout';
export { default as MainContainer } from './MainContainer';
export { default as MainNav } from './MainNav';
export { default as QueryStateUpdater } from './MainNav/QueryStateUpdater';
export { AppOrderProvider } from './MainNav/AppOrderProvider';
export { default as ModuleContainer } from './ModuleContainer';
export { withModule, withModules } from './Modules';
Expand Down