Skip to content

Commit

Permalink
STCOR-936 - implement App-reordering, user preference management in s…
Browse files Browse the repository at this point in the history
…tripes-core. (#1584)

## [STCOR-936](https://folio-org.atlassian.net/browse/STCOR-936)

This PR Implements 2 things -

1. A centralized mechanism for managing user preferences and interacting
with `mod-settings`.
2. A context that reads/writes a persisted user preference for the order
of application icons in the main navigation of the UI.

1 is fairly straightforward. A hook - `usePreferences` - that accepts an
object of options with the key and scope of a user preference. It will
automatically apply the logged in user's id as well as maintain the id
of an existing preference in its internal state. As per typical, it
try/catches the requests. TODO: Better error handling than
`console.log`.

`usePreferences` supplies methods `getPreference`, `setPreference`, and
`deletePreference`.

`getPreference/setPreference` queries the preferences with the `userId`,
`scope`, and `key`. If the preference is found, it will grab the id and
store that in local state to use when `setPreference` is called. If a
preference is not found, then the `id` remains `null` and when
`setPreference` is called, a new preference is created according to
`mod-settings`' API. `deletePreference` removes the preference and
resets the found preference id if there is any.

2 involved setting up a context with a Provider - `AppOrderProvider`-
that wraps the FOLIO UI at a high level - outside of the main navigation
and around the ui-module container. It access the user preference and
provides an ordered list of apps filtered by user permissions (named
`apps`). It also provides the persisted preference value - an array
containing objects with app information - only `name` as well as an
`isNew` field if a platform app does not have a corresponding list order
value.

The app list -building logic was lifted from `MainNav.js` and
`<MainNav>` refactored as a functional component, trading out HOC's for
hooks. One detail that's subject to further inspection is line 58 where
it uses `logout` instead of an apparently undefined `returnToLogin`
function.

`ResizeContainer` and `AppListDropdown` were two components that had to
be touched as they were forcing alphabetical order of the list -
switching from `Object.keys` to using the order of the array of
apps/preference.


## Still Remaining:
- [x] Tests!
  • Loading branch information
JohnC-80 authored Feb 18, 2025
1 parent 66132af commit 3570eea
Show file tree
Hide file tree
Showing 15 changed files with 946 additions and 242 deletions.
5 changes: 5 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,11 @@
* Provide `useModuleInfo(path)` hook that maps paths to their implementing modules. Refs STCOR-932.
* Remove remaining references to stripes.isEureka. Refs STCOR-938.
* *BREAKING* Upgrade `@folio/stripes-*` dependencies.
* *BREAKING* add dependency to `settings` interface. Part of STCOR-936.
* Implement user-preferred app re-ordering in the main navigation. Refs STCOR-936.
* Add permissions `mod-settings.owner.read.stripes-core.prefs.manage` and `mod-settings.owner.write.stripes-core.prefs.manage`
* Implement an `AppOrderProvider` - to provide the app listing to the `MainNav` and app-level settings implementation.
* Implement `usePreferences` hook to be exposed later for interaction with `mod-settings` user preferences.
* *BREAKING* Upgrade `react-intl` to `^v7`. Refs STCOR-945.
* Change Help icon aria label to just Help in MainNav component. Refs STCOR-931.
* *BREAKING* remove token-based authentication code. Refs STCOR-918.
Expand Down
1 change: 1 addition & 0 deletions index.js
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ export { default as useOkapiKy } from './src/useOkapiKy';
export { default as withOkapiKy } from './src/withOkapiKy';
export { default as useCustomFields } from './src/useCustomFields';
export { default as createReactQueryClient } from './src/createReactQueryClient';
export { useAppOrderContext } from './src/components/MainNav/AppOrderProvider';

/* components */
export { default as AppContextMenu } from './src/components/MainNav/CurrentApp/AppContextMenu';
Expand Down
13 changes: 12 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,8 @@
"okapiInterfaces": {
"users-bl": "5.0 6.0",
"authtoken": "1.0 2.0",
"configuration": "2.0"
"configuration": "2.0",
"settings": "1.0"
},
"optionalOkapiInterfaces": {
"consortia": "1.0",
Expand All @@ -38,6 +39,16 @@
{
"permissionName": "settings.enabled",
"displayName": "UI: settings area is enabled"
},
{
"permissionName": "mod-settings.owner.read.stripes-core.prefs.manage",
"displayName": "UI: read the user's own central preferences, such as order of links in the main navigation.",
"visible": false
},
{
"permissionName": "mod-settings.owner.write.stripes-core.prefs.manage",
"displayName": "UI: update the user's own central preferences, such as order of links in the main navigation.",
"visible": false
}
]
},
Expand Down
101 changes: 52 additions & 49 deletions src/RootWithIntl.js
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,7 @@ import {
ForgotUserNameCtrl,
AppCtxMenuProvider,
SessionEventContainer,
AppOrderProvider,
} from './components';
import StaleBundleWarning from './components/StaleBundleWarning';
import { StripesContext } from './StripesContext';
Expand Down Expand Up @@ -65,55 +66,57 @@ const RootWithIntl = ({ stripes, token = '', isAuthenticated = false, disableAut
<>
<MainContainer>
<AppCtxMenuProvider>
<MainNav stripes={connectedStripes} queryClient={queryClient} />
{typeof connectedStripes?.config?.staleBundleWarning === 'object' && <StaleBundleWarning />}
<HandlerManager
event={events.LOGIN}
stripes={connectedStripes}
/>
{ (typeof connectedStripes.okapi !== 'object' || connectedStripes.discovery.isFinished) && (
<ModuleContainer id="content">
<OverlayContainer />
<SessionEventContainer history={history} queryClient={queryClient} />
<Switch>
<TitledRoute
name="home"
path="/"
key="root"
exact
component={<Front stripes={connectedStripes} />}
/>
<TitledRoute
name="ssoRedirect"
path="/sso-landing"
key="sso-landing"
component={<SSORedirect stripes={connectedStripes} />}
/>
<TitledRoute
name="oidcRedirect"
path="/oidc-landing"
key="oidc-landing"
component={<OIDCRedirect stripes={stripes} />}
/>
<TitledRoute
name="logoutTimeout"
path="/logout-timeout"
component={<Logout />}
/>
<TitledRoute
name="settings"
path="/settings"
component={<Settings stripes={connectedStripes} />}
/>
<TitledRoute
name="logout"
path="/logout"
component={<Logout />}
/>
<ModuleRoutes stripes={connectedStripes} />
</Switch>
</ModuleContainer>
)}
<AppOrderProvider>
<MainNav stripes={connectedStripes} queryClient={queryClient} />
{typeof connectedStripes?.config?.staleBundleWarning === 'object' && <StaleBundleWarning />}
<HandlerManager
event={events.LOGIN}
stripes={connectedStripes}
/>
{ (typeof connectedStripes.okapi !== 'object' || connectedStripes.discovery.isFinished) && (
<ModuleContainer id="content">
<OverlayContainer />
<SessionEventContainer history={history} queryClient={queryClient} />
<Switch>
<TitledRoute
name="home"
path="/"
key="root"
exact
component={<Front stripes={connectedStripes} />}
/>
<TitledRoute
name="ssoRedirect"
path="/sso-landing"
key="sso-landing"
component={<SSORedirect stripes={connectedStripes} />}
/>
<TitledRoute
name="oidcRedirect"
path="/oidc-landing"
key="oidc-landing"
component={<OIDCRedirect stripes={stripes} />}
/>
<TitledRoute
name="logoutTimeout"
path="/logout-timeout"
component={<Logout />}
/>
<TitledRoute
name="settings"
path="/settings"
component={<Settings stripes={connectedStripes} />}
/>
<TitledRoute
name="logout"
path="/logout"
component={<Logout />}
/>
<ModuleRoutes stripes={connectedStripes} />
</Switch>
</ModuleContainer>
)}
</AppOrderProvider>
</AppCtxMenuProvider>
</MainContainer>
<Callout ref={setCalloutDomRef} />
Expand Down
1 change: 1 addition & 0 deletions src/RootWithIntl.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import Stripes from './Stripes';
jest.mock('./components/AuthnLogin', () => () => '<AuthnLogin>');
jest.mock('./components/Login', () => () => '<Login>');
jest.mock('./components/MainNav', () => () => '<MainNav>');
jest.mock('./components/MainNav/AppOrderProvider', () => ({ AppOrderProvider: ({ children }) => children }));
jest.mock('./components/OverlayContainer', () => () => '<OverlayContainer>');
jest.mock('./components/ModuleContainer', () => ({ children }) => children);
jest.mock('./components/MainContainer', () => ({ children }) => children);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,6 @@

import React from 'react';
import PropTypes from 'prop-types';
import sortBy from 'lodash/sortBy';

import { NavListItem, NavListSection } from '@folio/stripes-components';

Expand All @@ -20,7 +19,7 @@ const AppListDropdown = ({ toggleDropdown, apps, listRef, selectedApp }) => (
striped
>
{
sortBy(apps, app => app.displayName.toLowerCase()).map(app => (
apps.map(app => (
<NavListItem
key={app.id}
data-test-app-list-dropdown-item
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@
import React from 'react';
import classnames from 'classnames';
import debounce from 'lodash/debounce';
import isEqual from 'lodash/isEqual';
import PropTypes from 'prop-types';
import css from './ResizeContainer.css';

Expand Down Expand Up @@ -40,9 +41,12 @@ class ResizeContainer extends React.Component {
}

componentDidUpdate(prevProps) {
const { currentAppId, items } = this.props;
// Update hidden items when the current app ID changes
// to make sure that no items are hidden behind the current app label
if (this.props.currentAppId && this.props.currentAppId !== prevProps.currentAppId) {
if (currentAppId !== prevProps.currentAppId ||
!isEqual(items, prevProps.items)
) {
this.updateHiddenItems();
}
}
Expand Down Expand Up @@ -79,7 +83,7 @@ class ResizeContainer extends React.Component {
* Determine hidden items on mount and resize
*/
updateHiddenItems = (callback) => {
const { hideAllWidth, offset } = this.props;
const { hideAllWidth, offset, items } = this.props;
const { cachedItemWidths } = this.state;
const shouldHideAll = window.innerWidth <= hideAllWidth;
const wrapperEl = this.wrapperRef.current;
Expand All @@ -92,7 +96,7 @@ class ResizeContainer extends React.Component {
shouldHideAll ? Object.keys(cachedItemWidths) :

// Find items that should be hidden
Object.keys(cachedItemWidths).reduce((acc, id) => {
items.reduce((acc, { id }) => {
const itemWidth = cachedItemWidths[id];
const shouldBeHidden = (itemWidth + acc.accWidth + offset) > wrapperWidth;
const hidden = shouldBeHidden ? acc.hidden.concat(id) : acc.hidden;
Expand All @@ -101,7 +105,8 @@ class ResizeContainer extends React.Component {
hidden,
accWidth: acc.accWidth + itemWidth,
};
}, {
},
{
hidden: [],
accWidth: 0,
}).hidden;
Expand Down
Loading

0 comments on commit 3570eea

Please sign in to comment.