From 6da29373d932050189d9052749e3409565cbb9d9 Mon Sep 17 00:00:00 2001 From: Michal Kuklis Date: Tue, 25 Apr 2023 09:56:14 -0400 Subject: [PATCH 01/59] STRIPES-861: Setup federation --- src/init.js => bootstrap.js | 3 +- src/AppRoutes.js | 4 +- src/CalloutContext.js | 4 +- src/ModulesContext.js | 7 +- src/Pluggable.js | 3 +- src/RootWithIntl.js | 7 +- src/StripesContext.js | 4 +- .../LastVisited/LastVisitedContext.js | 4 +- .../MainNav/CurrentApp/AppCtxMenuContext.js | 3 +- .../ModuleHierarchy/ModuleHierarchyContext.js | 4 +- .../ModuleTranslator/ModuleTranslator.js | 48 ------------- src/components/ModuleTranslator/index.js | 1 - src/components/RegistryLoader.js | 69 +++++++++++++++++++ src/components/index.js | 2 - src/loadRemoteComponent.js | 21 ++++++ src/locationService.js | 2 +- src/translateModules.js | 0 17 files changed, 117 insertions(+), 69 deletions(-) rename src/init.js => bootstrap.js (90%) delete mode 100644 src/components/ModuleTranslator/ModuleTranslator.js delete mode 100644 src/components/ModuleTranslator/index.js create mode 100644 src/components/RegistryLoader.js create mode 100644 src/loadRemoteComponent.js delete mode 100644 src/translateModules.js diff --git a/src/init.js b/bootstrap.js similarity index 90% rename from src/init.js rename to bootstrap.js index ebf891cce..2227a0edc 100644 --- a/src/init.js +++ b/bootstrap.js @@ -1,9 +1,10 @@ import 'core-js/stable'; import 'regenerator-runtime/runtime'; + import React from 'react'; import { createRoot } from 'react-dom/client'; -import App from './App'; +import App from './src/App'; export default function init() { const container = document.getElementById('root'); diff --git a/src/AppRoutes.js b/src/AppRoutes.js index d2cf919b4..e284af4d3 100644 --- a/src/AppRoutes.js +++ b/src/AppRoutes.js @@ -11,6 +11,7 @@ import { getEventHandlers } from './handlerService'; import { packageName } from './constants'; import { ModuleHierarchyProvider } from './components'; import events from './events'; +import loadRemoteComponent from './loadRemoteComponent'; // Process and cache "app" type modules and render the routes const AppRoutes = ({ modules, stripes }) => { @@ -22,11 +23,12 @@ const AppRoutes = ({ modules, stripes }) => { const perm = `module.${name}.enabled`; if (!stripes.hasPerm(perm)) return null; + const RemoteComponent = React.lazy(() => loadRemoteComponent(module.url, module.name)); const connect = connectFor(module.module, stripes.epics, stripes.logger); let ModuleComponent; try { - ModuleComponent = connect(module.getModule()); + ModuleComponent = connect(RemoteComponent); } catch (error) { console.error(error); // eslint-disable-line throw Error(error); diff --git a/src/CalloutContext.js b/src/CalloutContext.js index b2b0f180c..66665e70f 100644 --- a/src/CalloutContext.js +++ b/src/CalloutContext.js @@ -1,6 +1,8 @@ import React, { useContext } from 'react'; -export const CalloutContext = React.createContext(); +import { CalloutContext } from '@folio/stripes-shared-context'; + +export { CalloutContext }; export const useCallout = () => { return useContext(CalloutContext); diff --git a/src/ModulesContext.js b/src/ModulesContext.js index 2ffed1213..863526877 100644 --- a/src/ModulesContext.js +++ b/src/ModulesContext.js @@ -1,7 +1,6 @@ -import React, { useContext } from 'react'; -import { modules } from 'stripes-config'; +import { useContext } from 'react'; +import { ModulesContext } from '@folio/stripes-shared-context'; -export const ModulesContext = React.createContext(modules); +export { ModulesContext }; export default ModulesContext; export const useModules = () => useContext(ModulesContext); -export { modules as originalModules }; diff --git a/src/Pluggable.js b/src/Pluggable.js index 6d1a27baa..75c07cd31 100644 --- a/src/Pluggable.js +++ b/src/Pluggable.js @@ -1,10 +1,11 @@ import React, { useMemo } from 'react'; import PropTypes from 'prop-types'; -import { modules } from 'stripes-config'; +import { useModules } from './ModulesContext'; import { withStripes } from './StripesContext'; import { ModuleHierarchyProvider } from './components'; const Pluggable = (props) => { + const modules = useModules(); const plugins = modules.plugin || []; const cachedPlugins = useMemo(() => { const cached = []; diff --git a/src/RootWithIntl.js b/src/RootWithIntl.js index 1dcf29c63..143387034 100644 --- a/src/RootWithIntl.js +++ b/src/RootWithIntl.js @@ -40,6 +40,7 @@ import StaleBundleWarning from './components/StaleBundleWarning'; import { StripesContext } from './StripesContext'; import { CalloutContext } from './CalloutContext'; import AuthnLogin from './components/AuthnLogin'; +import RegistryLoader from './components/RegistryLoader'; const RootWithIntl = ({ stripes, token = '', isAuthenticated = false, disableAuth, history = {}, queryClient }) => { const connect = connectFor('@folio/core', stripes.epics, stripes.logger); @@ -53,7 +54,8 @@ const RootWithIntl = ({ stripes, token = '', isAuthenticated = false, disableAut return ( - + + - + + ); diff --git a/src/StripesContext.js b/src/StripesContext.js index 835262268..16c062389 100644 --- a/src/StripesContext.js +++ b/src/StripesContext.js @@ -2,7 +2,9 @@ import React, { useContext } from 'react'; import PropTypes from 'prop-types'; import hoistNonReactStatics from 'hoist-non-react-statics'; -export const StripesContext = React.createContext(); +import { StripesContext } from '@folio/stripes-shared-context'; + +export { StripesContext }; function getDisplayName(WrappedComponent) { return WrappedComponent.displayName || WrappedComponent.name || 'Component'; diff --git a/src/components/LastVisited/LastVisitedContext.js b/src/components/LastVisited/LastVisitedContext.js index 166e08b4a..e7c9f0f84 100644 --- a/src/components/LastVisited/LastVisitedContext.js +++ b/src/components/LastVisited/LastVisitedContext.js @@ -1,3 +1,3 @@ -import React from 'react'; +import { LastVisitedContext } from '@folio/stripes-shared-context'; -export default React.createContext({}); +export default LastVisitedContext; diff --git a/src/components/MainNav/CurrentApp/AppCtxMenuContext.js b/src/components/MainNav/CurrentApp/AppCtxMenuContext.js index 791887881..9327f1769 100644 --- a/src/components/MainNav/CurrentApp/AppCtxMenuContext.js +++ b/src/components/MainNav/CurrentApp/AppCtxMenuContext.js @@ -1,7 +1,8 @@ import React from 'react'; import hoistNonReactStatics from 'hoist-non-react-statics'; +import { AppCtxMenuContext } from '@folio/stripes-shared-context'; -export const AppCtxMenuContext = React.createContext(); +export { AppCtxMenuContext }; export function withAppCtxMenu(Component) { const WrappedComponent = (props) => { diff --git a/src/components/ModuleHierarchy/ModuleHierarchyContext.js b/src/components/ModuleHierarchy/ModuleHierarchyContext.js index 265f1101b..1a5c4d177 100644 --- a/src/components/ModuleHierarchy/ModuleHierarchyContext.js +++ b/src/components/ModuleHierarchy/ModuleHierarchyContext.js @@ -1,5 +1,3 @@ -import React from 'react'; - -const ModuleHierarchyContext = React.createContext(); +import { ModuleHierarchyContext } from '@folio/stripes-shared-context'; export default ModuleHierarchyContext; diff --git a/src/components/ModuleTranslator/ModuleTranslator.js b/src/components/ModuleTranslator/ModuleTranslator.js deleted file mode 100644 index 3253cc042..000000000 --- a/src/components/ModuleTranslator/ModuleTranslator.js +++ /dev/null @@ -1,48 +0,0 @@ -import React from 'react'; -import PropTypes from 'prop-types'; -import { injectIntl } from 'react-intl'; - -import { ModulesContext, originalModules } from '../../ModulesContext'; - -class ModuleTranslator extends React.Component { - static propTypes = { - children: PropTypes.node, - intl: PropTypes.object, - } - - constructor(props) { - super(props); - - this.state = { - modules: this.translateModules(), - }; - } - - translateModules = () => { - return { - app: (originalModules.app || []).map(this.translateModule), - plugin: (originalModules.plugin || []).map(this.translateModule), - settings: (originalModules.settings || []).map(this.translateModule), - handler: (originalModules.handler || []).map(this.translateModule), - }; - } - - translateModule = (module) => { - const { formatMessage } = this.props.intl; - - return { - ...module, - displayName: module.displayName ? formatMessage({ id: module.displayName }) : undefined, - }; - } - - render() { - return ( - - { this.props.children } - - ); - } -} - -export default injectIntl(ModuleTranslator); diff --git a/src/components/ModuleTranslator/index.js b/src/components/ModuleTranslator/index.js deleted file mode 100644 index fe476e5a9..000000000 --- a/src/components/ModuleTranslator/index.js +++ /dev/null @@ -1 +0,0 @@ -export { default } from './ModuleTranslator'; diff --git a/src/components/RegistryLoader.js b/src/components/RegistryLoader.js new file mode 100644 index 000000000..0eb3a5399 --- /dev/null +++ b/src/components/RegistryLoader.js @@ -0,0 +1,69 @@ +import React, { useEffect, useState } from 'react'; +import PropTypes from 'prop-types'; +import { useIntl } from 'react-intl'; + +import { ModulesContext } from '../ModulesContext'; + +// TODO: should this be handled by registry? +const parseModules = (remotes) => { + const modules = { app: [], plugin: [], settings: [], handler: [] }; + + remotes.forEach(remote => { + const { actsAs, ...rest } = remote; + actsAs.forEach(type => modules[type].push(rest)); + }); + + return modules; +}; + +// TODO: pass it via stripes config +const registryUrl = 'http://localhost:3001/registry'; + +const RegistryLoader = ({ children }) => { + const { formatMessage } = useIntl(); + const [modules, setModules] = useState(); + + useEffect(() => { + const translateModule = (module) => ({ + ...module, + displayName: module.displayName ? formatMessage({ id: module.displayName }) : undefined, + }); + + const translateModules = ({ app, plugin, settings, handler }) => ({ + app: app.map(translateModule), + plugin: plugin.map(translateModule), + settings: settings.map(translateModule), + handler: handler.map(translateModule), + }); + + const fetchRegistry = async () => { + const response = await fetch(registryUrl); + const registry = await response.json(); + const remotes = Object.entries(registry.remotes).map(([name, metadata]) => ({ name, ...metadata })); + const parsedModules = translateModules(parseModules(remotes)); + + setModules(parsedModules); + }; + + fetchRegistry(); + // We know what we are doing here so just ignore the dependency warning about 'formatMessage' + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []); + + return ( + + {modules ? children : null} + + ); +}; + +RegistryLoader.propTypes = { + children: PropTypes.oneOfType([ + PropTypes.arrayOf(PropTypes.node), + PropTypes.node, + PropTypes.func, + ]) +}; + + +export default RegistryLoader; diff --git a/src/components/index.js b/src/components/index.js index 015f3867a..a8ba4dee3 100644 --- a/src/components/index.js +++ b/src/components/index.js @@ -10,10 +10,8 @@ export { default as MainContainer } from './MainContainer'; export { default as MainNav } from './MainNav'; export { default as ModuleContainer } from './ModuleContainer'; export { withModule, withModules } from './Modules'; -export { default as ModuleTranslator } from './ModuleTranslator'; export { default as OrganizationLogo } from './OrganizationLogo'; export { default as OverlayContainer } from './OverlayContainer'; -export { default as Root } from './Root'; export { default as SSOLogin } from './SSOLogin'; export { default as SystemSkeleton } from './SystemSkeleton'; export { default as TitledRoute } from './TitledRoute'; diff --git a/src/loadRemoteComponent.js b/src/loadRemoteComponent.js new file mode 100644 index 000000000..687e6ee7a --- /dev/null +++ b/src/loadRemoteComponent.js @@ -0,0 +1,21 @@ +// https://webpack.js.org/concepts/module-federation/#dynamic-remote-containers +export default async function loadRemoteComponent(remoteUrl, remoteName) { + const container = await fetch(remoteUrl) + .then((res) => res.text()) + .then((source) => { + const script = document.createElement('script'); + script.textContent = source; + document.body.appendChild(script); + return window[remoteName]; + }); + + // eslint-disable-next-line no-undef + await __webpack_init_sharing__('default'); + + // eslint-disable-next-line no-undef + await container.init(__webpack_share_scopes__.default); + + const factory = await container.get('./MainEntry'); + const Module = await factory(); + return Module; +} diff --git a/src/locationService.js b/src/locationService.js index 7ad0fb755..d3dcab3bd 100644 --- a/src/locationService.js +++ b/src/locationService.js @@ -35,7 +35,7 @@ export function isQueryResourceModule(module, location) { } export function getCurrentModule(modules, location) { - const { app, settings } = modules; + const { app, settings } = modules ?? { app: [], settings: [] }; return app.concat(settings).find(m => isQueryResourceModule(m, location)); } diff --git a/src/translateModules.js b/src/translateModules.js deleted file mode 100644 index e69de29bb..000000000 From eac40b82089d321135699c43d8181aea2a4ca88f Mon Sep 17 00:00:00 2001 From: Michal Kuklis Date: Tue, 25 Apr 2023 21:08:19 -0400 Subject: [PATCH 02/59] Cleanup --- src/AppRoutes.js | 1 + src/Pluggable.js | 4 +++- src/RootWithIntl.js | 1 - src/components/Root/Root.js | 8 -------- src/components/Settings/Settings.js | 6 +++--- src/gatherActions.js | 12 ------------ 6 files changed, 7 insertions(+), 25 deletions(-) diff --git a/src/AppRoutes.js b/src/AppRoutes.js index e284af4d3..4996ba727 100644 --- a/src/AppRoutes.js +++ b/src/AppRoutes.js @@ -27,6 +27,7 @@ const AppRoutes = ({ modules, stripes }) => { const connect = connectFor(module.module, stripes.epics, stripes.logger); let ModuleComponent; + try { ModuleComponent = connect(RemoteComponent); } catch (error) { diff --git a/src/Pluggable.js b/src/Pluggable.js index 75c07cd31..f411434d1 100644 --- a/src/Pluggable.js +++ b/src/Pluggable.js @@ -3,6 +3,7 @@ import PropTypes from 'prop-types'; import { useModules } from './ModulesContext'; import { withStripes } from './StripesContext'; import { ModuleHierarchyProvider } from './components'; +import loadRemoteComponent from './loadRemoteComponent'; const Pluggable = (props) => { const modules = useModules(); @@ -23,7 +24,8 @@ const Pluggable = (props) => { } if (best) { - const Child = props.stripes.connect(best.getModule()); + const RemoteComponent = React.lazy(() => loadRemoteComponent(best.url, best.name)); + const Child = props.stripes.connect(RemoteComponent); cached.push({ Child, diff --git a/src/RootWithIntl.js b/src/RootWithIntl.js index 143387034..c821a2621 100644 --- a/src/RootWithIntl.js +++ b/src/RootWithIntl.js @@ -17,7 +17,6 @@ import { MainContainer, MainNav, ModuleContainer, - ModuleTranslator, TitledRoute, Front, OIDCRedirect, diff --git a/src/components/Root/Root.js b/src/components/Root/Root.js index caf9680dd..9e4252bde 100644 --- a/src/components/Root/Root.js +++ b/src/components/Root/Root.js @@ -9,7 +9,6 @@ import { QueryClientProvider } from 'react-query'; import { ApolloProvider } from '@apollo/client'; import { ErrorBoundary } from '@folio/stripes-components'; -import { metadata, icons } from 'stripes-config'; import { ConnectContext } from '@folio/stripes-connect'; import initialReducers from '../../initialReducers'; @@ -29,11 +28,6 @@ import './Root.css'; import { withModules } from '../Modules'; import { FFetch } from './FFetch'; -if (!metadata) { - // eslint-disable-next-line no-console - console.error('No metadata harvested from package files, so you will not get app icons. Probably the stripes-core in your Stripes CLI is too old. Try `yarn global upgrade @folio/stripes-cli`'); -} - class Root extends Component { constructor(...args) { super(...args); @@ -150,8 +144,6 @@ class Root extends Component { locale, timezone, currency, - metadata, - icons, setLocale: (localeValue) => { loadTranslations(store, localeValue, defaultTranslations); }, setTimezone: (timezoneValue) => { store.dispatch(setTimezone(timezoneValue)); }, setCurrency: (currencyValue) => { store.dispatch(setCurrency(currencyValue)); }, diff --git a/src/components/Settings/Settings.js b/src/components/Settings/Settings.js index 3d687ae30..b56763f82 100644 --- a/src/components/Settings/Settings.js +++ b/src/components/Settings/Settings.js @@ -32,6 +32,7 @@ import AppIcon from '../AppIcon'; import { packageName } from '../../constants'; import RouteErrorBoundary from '../RouteErrorBoundary'; import { ModuleHierarchyProvider } from '../ModuleHierarchy'; +import loadRemoteComponent from '../../loadRemoteComponent'; import css from './Settings.css'; @@ -60,11 +61,10 @@ const Settings = ({ stripes }) => { .map((m) => { try { const connect = connectFor(m.module, stripes.epics, stripes.logger); - const module = m.getModule(); - + const RemoteComponent = React.lazy(() => loadRemoteComponent(m.url, m.name)); return { module: m, - Component: connect(module), + Component: connect(RemoteComponent), moduleStripes: stripes.clone({ connect }), }; } catch (error) { diff --git a/src/gatherActions.js b/src/gatherActions.js index abdc874e7..47c825fe9 100644 --- a/src/gatherActions.js +++ b/src/gatherActions.js @@ -1,6 +1,3 @@ -// Gather actionNames from all registered modules for hot-key mapping - -import { modules } from 'stripes-config'; import stripesComponents from '@folio/stripes-components/package'; function addKeys(moduleName, register, list) { @@ -15,15 +12,6 @@ function addKeys(moduleName, register, list) { export default function gatherActions() { const allActions = {}; - - for (const key of Object.keys(modules)) { - const set = modules[key]; - for (const key2 of Object.keys(set)) { - const module = set[key2]; - addKeys(module.module, allActions, module.actionNames); - } - } - addKeys('stripes-components', allActions, (stripesComponents.stripes || {}).actionNames); return Object.keys(allActions); From b6aa8f94a276376c38932edac520311ee4c16a42 Mon Sep 17 00:00:00 2001 From: Michal Kuklis Date: Tue, 25 Apr 2023 21:40:31 -0400 Subject: [PATCH 03/59] Cache remote components --- src/loadRemoteComponent.js | 18 ++++++++++-------- 1 file changed, 10 insertions(+), 8 deletions(-) diff --git a/src/loadRemoteComponent.js b/src/loadRemoteComponent.js index 687e6ee7a..3d13a064f 100644 --- a/src/loadRemoteComponent.js +++ b/src/loadRemoteComponent.js @@ -1,13 +1,14 @@ // https://webpack.js.org/concepts/module-federation/#dynamic-remote-containers export default async function loadRemoteComponent(remoteUrl, remoteName) { - const container = await fetch(remoteUrl) - .then((res) => res.text()) - .then((source) => { - const script = document.createElement('script'); - script.textContent = source; - document.body.appendChild(script); - return window[remoteName]; - }); + if (!window[remoteName]) { + const response = await fetch(remoteUrl); + const source = await response.text(); + const script = document.createElement('script'); + script.textContent = source; + document.body.appendChild(script); + } + + const container = window[remoteName]; // eslint-disable-next-line no-undef await __webpack_init_sharing__('default'); @@ -17,5 +18,6 @@ export default async function loadRemoteComponent(remoteUrl, remoteName) { const factory = await container.get('./MainEntry'); const Module = await factory(); + return Module; } From ff5e9a3ebe0ec78a6bc7ad5b396d7a2e835bbed2 Mon Sep 17 00:00:00 2001 From: Michal Kuklis Date: Tue, 25 Apr 2023 22:16:41 -0400 Subject: [PATCH 04/59] Prefetch handlers --- src/components/RegistryLoader.js | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/src/components/RegistryLoader.js b/src/components/RegistryLoader.js index 0eb3a5399..c17bf73a8 100644 --- a/src/components/RegistryLoader.js +++ b/src/components/RegistryLoader.js @@ -3,6 +3,7 @@ import PropTypes from 'prop-types'; import { useIntl } from 'react-intl'; import { ModulesContext } from '../ModulesContext'; +import loadRemoteComponent from '../loadRemoteComponent'; // TODO: should this be handled by registry? const parseModules = (remotes) => { @@ -41,6 +42,15 @@ const RegistryLoader = ({ children }) => { const registry = await response.json(); const remotes = Object.entries(registry.remotes).map(([name, metadata]) => ({ name, ...metadata })); const parsedModules = translateModules(parseModules(remotes)); + const { handler: handlerModules } = parsedModules; + + // prefetch all handlers so they can be executed in a sync way. + if (handlerModules) { + await Promise.all(handlerModules.map(async (module) => { + const component = await loadRemoteComponent(module.url, module.name); + module.getModule = () => component?.default; + })); + } setModules(parsedModules); }; From 5ab48375c41ac89ec83c8871557e1e0350a1bd41 Mon Sep 17 00:00:00 2001 From: Michal Kuklis Date: Thu, 27 Apr 2023 14:10:05 -0400 Subject: [PATCH 05/59] Align stripes-shared-context correctly --- package.json | 1 + 1 file changed, 1 insertion(+) diff --git a/package.json b/package.json index 05dc18b3a..579f7cfbf 100644 --- a/package.json +++ b/package.json @@ -73,6 +73,7 @@ }, "dependencies": { "@apollo/client": "^3.2.1", + "@folio/stripes-shared-context": "1.0.0", "classnames": "^2.2.5", "core-js": "^3.26.1", "final-form": "^4.18.2", From 9fd0420860be7749883ac8f5b967cc6d7e54549b Mon Sep 17 00:00:00 2001 From: Michal Kuklis Date: Thu, 27 Apr 2023 14:17:36 -0400 Subject: [PATCH 06/59] Update @folio/stripes-shared-context version --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index 579f7cfbf..479e15f84 100644 --- a/package.json +++ b/package.json @@ -73,7 +73,7 @@ }, "dependencies": { "@apollo/client": "^3.2.1", - "@folio/stripes-shared-context": "1.0.0", + "@folio/stripes-shared-context": "^1.0.0", "classnames": "^2.2.5", "core-js": "^3.26.1", "final-form": "^4.18.2", From 3b4fca0e9f4b65cb7e61a5cb82483497cafdd792 Mon Sep 17 00:00:00 2001 From: John Coburn Date: Thu, 25 May 2023 09:57:29 -0500 Subject: [PATCH 07/59] wrap Pluggable's rendered Child in suspense to isolate react-dom's hiding of ui-elements --- src/Pluggable.js | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/src/Pluggable.js b/src/Pluggable.js index f411434d1..73d976a2b 100644 --- a/src/Pluggable.js +++ b/src/Pluggable.js @@ -1,5 +1,6 @@ -import React, { useMemo } from 'react'; +import React, { useMemo, Suspense } from 'react'; import PropTypes from 'prop-types'; +import { Icon } from '@folio/stripes-components'; import { useModules } from './ModulesContext'; import { withStripes } from './StripesContext'; import { ModuleHierarchyProvider } from './components'; @@ -35,12 +36,14 @@ const Pluggable = (props) => { } return cached; - }, [plugins]); + }, [plugins, props.type]); if (cachedPlugins.length) { return cachedPlugins.map(({ plugin, Child }) => ( - + }> + + )); } From 86b32735cf9df8e1d15b284126aff70d6c57f4dc Mon Sep 17 00:00:00 2001 From: Zak Burke Date: Thu, 8 Jun 2023 17:13:00 -0400 Subject: [PATCH 08/59] STCOR-718 load remote translations (#1309) Draft: load translations when loading remote modules Note: QueryClientProvider must be explicitly shared See https://tanstack.com/query/v3/docs/react/reference/QueryClientProvider Refs STCOR-718, STRIPES-861 --- src/RootWithIntl.js | 246 +++++++++++++++---------------- src/components/RegistryLoader.js | 92 ++++++++++-- src/components/Root/Root.js | 2 +- src/okapiReducer.js | 2 +- 4 files changed, 205 insertions(+), 137 deletions(-) diff --git a/src/RootWithIntl.js b/src/RootWithIntl.js index c821a2621..baa2a1e26 100644 --- a/src/RootWithIntl.js +++ b/src/RootWithIntl.js @@ -53,130 +53,130 @@ const RootWithIntl = ({ stripes, token = '', isAuthenticated = false, disableAut return ( - + - - - - - { isAuthenticated || token || disableAuth ? - <> - - - - {typeof connectedStripes?.config?.staleBundleWarning === 'object' && } - - { (typeof connectedStripes.okapi !== 'object' || connectedStripes.discovery.isFinished) && ( - - - {connectedStripes.config.useSecureTokens && } - - } - /> - } - /> - } - /> - } - /> - } - /> - } - /> - - - - )} - - - - : - - {/* The ? after :token makes that part of the path optional, so that token may optionally - be passed in via URL parameter to avoid length restrictions */} - } - /> - } - key="sso-landing" - /> - } - key="oidc-landing" - /> - } - /> - } - /> - } - /> - } - /> - } - /> - } - /> - - } - - - - + + + + + { isAuthenticated || token || disableAuth ? + <> + + + + {typeof connectedStripes?.config?.staleBundleWarning === 'object' && } + + { (typeof connectedStripes.okapi !== 'object' || connectedStripes.discovery.isFinished) && ( + + + {connectedStripes.config.useSecureTokens && } + + } + /> + } + /> + } + /> + } + /> + } + /> + } + /> + + + + )} + + + + : + + {/* The ? after :token makes that part of the path optional, so that token may optionally + be passed in via URL parameter to avoid length restrictions */} + } + /> + } + key="sso-landing" + /> + } + key="oidc-landing" + /> + } + /> + } + /> + } + /> + } + /> + } + /> + } + /> + + } + + + + diff --git a/src/components/RegistryLoader.js b/src/components/RegistryLoader.js index c17bf73a8..41501e2ed 100644 --- a/src/components/RegistryLoader.js +++ b/src/components/RegistryLoader.js @@ -2,6 +2,7 @@ import React, { useEffect, useState } from 'react'; import PropTypes from 'prop-types'; import { useIntl } from 'react-intl'; +import { setTranslations } from '../okapiActions'; import { ModulesContext } from '../ModulesContext'; import loadRemoteComponent from '../loadRemoteComponent'; @@ -20,28 +21,94 @@ const parseModules = (remotes) => { // TODO: pass it via stripes config const registryUrl = 'http://localhost:3001/registry'; -const RegistryLoader = ({ children }) => { +const appTranslations = []; + +/** + * loadTranslations + * return a promise that fetches translations for the given module and then + * dispatches the translations. + * @param {object} stripes + * @param {object} module info read from the registry + * + * @returns {Promise} + */ +const loadTranslations = (stripes, module) => { + const url = `${module.host}:${module.port}`; + + const parentLocale = stripes.locale.split('-')[0]; + // Since moment.js don't support translations like it or it-IT-u-nu-latn + // we need to build string like it_IT for fetch call + const loadedLocale = stripes.locale.replace('-', '_').split('-')[0]; + + // react-intl provides things like pt-BR. + // lokalise provides things like pt_BR. + // so we have to translate '-' to '_' because the translation libraries + // don't know how to talk to each other. sheesh. + const region = stripes.locale.replace('-', '_'); + + // Here we put additional condition because languages + // like Japan we need to use like ja, but with numeric system + // Japan language builds like ja_u, that incorrect. We need to be safe from that bug. + if (!appTranslations.includes(url)) { + appTranslations.push(url); + return fetch(`${url}/translations/${region}.json`) + .then((response) => { + if (response.ok) { + return response.json().then((translations) => { + // translation entries look like "key: val" + // but we want "ui-${app}.key: val" + const prefix = module.name.replace('folio_', 'ui-'); + const keyed = []; + Object.keys(translations).forEach(key => { + keyed[`${prefix}.${key}`] = translations[key]; + }); + + // I thought dispatch was synchronous, but without a return + // statement here the calling function's invocations of + // formatMessage() don't see the updated values in the store + return stripes.store.dispatch(setTranslations({ ...stripes.okapi.translations, ...keyed })); + }); + } else { + throw new Error(`Could not load translations for ${module}`); + } + }); + } else { + return Promise.resolve(); + } +}; + + +const RegistryLoader = ({ stripes, children }) => { const { formatMessage } = useIntl(); const [modules, setModules] = useState(); useEffect(() => { - const translateModule = (module) => ({ - ...module, - displayName: module.displayName ? formatMessage({ id: module.displayName }) : undefined, - }); + const translateModule = (module) => { + return loadTranslations(stripes, module) + .then(() => { + return { + ...module, + displayName: module.displayName ? formatMessage({ id: module.displayName }) : undefined, + }; + }) + .catch(e => { + // eslint-disable-next-line no-console + console.error(e); + }); + }; - const translateModules = ({ app, plugin, settings, handler }) => ({ - app: app.map(translateModule), - plugin: plugin.map(translateModule), - settings: settings.map(translateModule), - handler: handler.map(translateModule), + const translateModules = async ({ app, plugin, settings, handler }) => ({ + app: await Promise.all(app.map(translateModule)), + plugin: await Promise.all(plugin.map(translateModule)), + settings: await Promise.all(settings.map(translateModule)), + handler: await Promise.all(handler.map(translateModule)), }); const fetchRegistry = async () => { const response = await fetch(registryUrl); const registry = await response.json(); const remotes = Object.entries(registry.remotes).map(([name, metadata]) => ({ name, ...metadata })); - const parsedModules = translateModules(parseModules(remotes)); + const parsedModules = await translateModules(parseModules(remotes)); const { handler: handlerModules } = parsedModules; // prefetch all handlers so they can be executed in a sync way. @@ -72,7 +139,8 @@ RegistryLoader.propTypes = { PropTypes.arrayOf(PropTypes.node), PropTypes.node, PropTypes.func, - ]) + ]), + stripes: PropTypes.object.isRequired, }; diff --git a/src/components/Root/Root.js b/src/components/Root/Root.js index 9e4252bde..a0d93f361 100644 --- a/src/components/Root/Root.js +++ b/src/components/Root/Root.js @@ -164,7 +164,7 @@ class Root extends Component { - + Date: Fri, 9 Jun 2023 10:16:51 -0400 Subject: [PATCH 09/59] STCOR-725 load remote icons (#1317) Load remote icons, and clean up the translation loading a bit; it was still very much in draft form, and still is, but at least it doesn't throw lint errors everywhere now. Refs STCOR-725, STRIPES-861 --- src/components/RegistryLoader.js | 122 +++++++++++++++++-------------- src/components/Root/Root.js | 13 +++- src/okapiActions.js | 9 +++ src/okapiReducer.js | 3 + 4 files changed, 90 insertions(+), 57 deletions(-) diff --git a/src/components/RegistryLoader.js b/src/components/RegistryLoader.js index 41501e2ed..f150142c9 100644 --- a/src/components/RegistryLoader.js +++ b/src/components/RegistryLoader.js @@ -1,8 +1,9 @@ import React, { useEffect, useState } from 'react'; import PropTypes from 'prop-types'; import { useIntl } from 'react-intl'; +import { okapi } from 'stripes-config'; -import { setTranslations } from '../okapiActions'; +import { addIcon, setTranslations } from '../okapiActions'; import { ModulesContext } from '../ModulesContext'; import loadRemoteComponent from '../loadRemoteComponent'; @@ -19,9 +20,7 @@ const parseModules = (remotes) => { }; // TODO: pass it via stripes config -const registryUrl = 'http://localhost:3001/registry'; - -const appTranslations = []; +const registryUrl = okapi.registryUrl; /** * loadTranslations @@ -33,57 +32,75 @@ const appTranslations = []; * @returns {Promise} */ const loadTranslations = (stripes, module) => { - const url = `${module.host}:${module.port}`; - - const parentLocale = stripes.locale.split('-')[0]; - // Since moment.js don't support translations like it or it-IT-u-nu-latn - // we need to build string like it_IT for fetch call - const loadedLocale = stripes.locale.replace('-', '_').split('-')[0]; - - // react-intl provides things like pt-BR. - // lokalise provides things like pt_BR. - // so we have to translate '-' to '_' because the translation libraries - // don't know how to talk to each other. sheesh. - const region = stripes.locale.replace('-', '_'); - - // Here we put additional condition because languages - // like Japan we need to use like ja, but with numeric system - // Japan language builds like ja_u, that incorrect. We need to be safe from that bug. - if (!appTranslations.includes(url)) { - appTranslations.push(url); - return fetch(`${url}/translations/${region}.json`) - .then((response) => { - if (response.ok) { - return response.json().then((translations) => { - // translation entries look like "key: val" - // but we want "ui-${app}.key: val" - const prefix = module.name.replace('folio_', 'ui-'); - const keyed = []; - Object.keys(translations).forEach(key => { - keyed[`${prefix}.${key}`] = translations[key]; - }); - - // I thought dispatch was synchronous, but without a return - // statement here the calling function's invocations of - // formatMessage() don't see the updated values in the store - return stripes.store.dispatch(setTranslations({ ...stripes.okapi.translations, ...keyed })); + // construct a fully-qualified URL to load. + // + // locale strings include a name plus optional region and numbering system. + // we only care about the name and region. this stripes the numberin system + // and converts from kebab-case (the IETF standard) to snake_case (which we + // somehow adopted for our files in Lokalise). + const locale = stripes.locale.split('-u-nu-')[0].replace('-', '_'); + const url = `${module.host}:${module.port}/translations/${locale}.json`; + stripes.logger.log('core', `loading ${locale} translations for ${module.name}`); + + return fetch(url) + .then((response) => { + if (response.ok) { + return response.json().then((translations) => { + // 1. translation entries look like "key: val"; we want "ui-${app}.key: val" + // 2. module.name is snake_case (I have no idea why); we want kebab-case + const prefix = module.name.replace('folio_', 'ui-').replaceAll('_', '-'); + const keyed = []; + Object.keys(translations).forEach(key => { + keyed[`${prefix}.${key}`] = translations[key]; }); - } else { - throw new Error(`Could not load translations for ${module}`); + + const tx = { ...stripes.okapi.translations, ...keyed }; + + stripes.store.dispatch(setTranslations(tx)); + + // const tx = { ...stripes.okapi.translations, ...keyed }; + // console.log(`filters.status.active: ${tx['ui-users.filters.status.active']}`) + return stripes.setLocale(stripes.locale, tx); + }); + } else { + throw new Error(`Could not load translations for ${module}`); + } + }); +}; + +/** + * loadIcons + * Register remotely-hosted icons with stripes by dispatching addIcon + * for each element of the module's icons array. + * + * @param {object} stripes + * @param {object} module info read from the registry + * + * @returns {void} + */ +const loadIcons = (stripes, module) => { + if (module.icons && module.icons.length) { + stripes.logger.log('core', `loading icons for ${module.module}`); + module.icons.forEach(i => { + const icon = { + [i.name]: { + src: `${module.host}:${module.port}/icons/${i.name}.svg`, + alt: i.title, } - }); - } else { - return Promise.resolve(); + }; + stripes.store.dispatch(addIcon(module.module, icon)); + }); } }; - const RegistryLoader = ({ stripes, children }) => { const { formatMessage } = useIntl(); const [modules, setModules] = useState(); useEffect(() => { - const translateModule = (module) => { + const loadModuleAssets = (module) => { + loadIcons(stripes, module); + return loadTranslations(stripes, module) .then(() => { return { @@ -97,18 +114,17 @@ const RegistryLoader = ({ stripes, children }) => { }); }; - const translateModules = async ({ app, plugin, settings, handler }) => ({ - app: await Promise.all(app.map(translateModule)), - plugin: await Promise.all(plugin.map(translateModule)), - settings: await Promise.all(settings.map(translateModule)), - handler: await Promise.all(handler.map(translateModule)), + const loadModules = async ({ app, plugin, settings, handler }) => ({ + app: await Promise.all(app.map(loadModuleAssets)), + plugin: await Promise.all(plugin.map(loadModuleAssets)), + settings: await Promise.all(settings.map(loadModuleAssets)), + handler: await Promise.all(handler.map(loadModuleAssets)), }); const fetchRegistry = async () => { - const response = await fetch(registryUrl); - const registry = await response.json(); + const registry = await fetch(registryUrl).then((response) => response.json()); const remotes = Object.entries(registry.remotes).map(([name, metadata]) => ({ name, ...metadata })); - const parsedModules = await translateModules(parseModules(remotes)); + const parsedModules = await loadModules(parseModules(remotes)); const { handler: handlerModules } = parsedModules; // prefetch all handlers so they can be executed in a sync way. diff --git a/src/components/Root/Root.js b/src/components/Root/Root.js index a0d93f361..2d032d9fc 100644 --- a/src/components/Root/Root.js +++ b/src/components/Root/Root.js @@ -15,7 +15,7 @@ import initialReducers from '../../initialReducers'; import enhanceReducer from '../../enhanceReducer'; import createApolloClient from '../../createApolloClient'; import createReactQueryClient from '../../createReactQueryClient'; -import { setSinglePlugin, setBindings, setIsAuthenticated, setOkapiToken, setTimezone, setCurrency, updateCurrentUser } from '../../okapiActions'; +import { addIcon, setSinglePlugin, setBindings, setIsAuthenticated, setOkapiToken, setTimezone, setCurrency, updateCurrentUser } from '../../okapiActions'; import { loadTranslations, checkOkapiSession } from '../../loginServices'; import { getQueryResourceKey, getCurrentModule } from '../../locationService'; import Stripes from '../../Stripes'; @@ -111,7 +111,7 @@ class Root extends Component { } render() { - const { logger, store, epics, config, okapi, actionNames, token, isAuthenticated, disableAuth, currentUser, currentPerms, locale, defaultTranslations, timezone, currency, plugins, bindings, discovery, translations, history, serverDown } = this.props; + const { logger, store, epics, config, okapi, actionNames, token, isAuthenticated, disableAuth, currentUser, currentPerms, icons, locale, defaultTranslations, timezone, currency, plugins, bindings, discovery, translations, history, serverDown } = this.props; if (serverDown) { // note: this isn't i18n'ed because we haven't rendered an IntlProvider yet. return
Error: server is forbidden, unreachable or down. Clear the cookies? Use incognito mode? VPN issue?
; @@ -144,7 +144,9 @@ class Root extends Component { locale, timezone, currency, - setLocale: (localeValue) => { loadTranslations(store, localeValue, defaultTranslations); }, + icons, + addIcon: (key, icon) => { store.dispatch(addIcon(key, icon)); }, + setLocale: (localeValue, tx) => { return loadTranslations(store, localeValue, { ...defaultTranslations, ...tx }); }, setTimezone: (timezoneValue) => { store.dispatch(setTimezone(timezoneValue)); }, setCurrency: (currencyValue) => { store.dispatch(setCurrency(currencyValue)); }, updateUser: (userValue) => { store.dispatch(updateCurrentUser(userValue)); }, @@ -164,7 +166,7 @@ class Root extends Component { - + Date: Fri, 4 Aug 2023 16:08:46 -0400 Subject: [PATCH 10/59] STCOR-718 correctly set apps' localized displayName Correctly set each apps' localized `displayName` attribute. It isn't totally clear to me why this doesn't work via `formattedMessage`. It seems that something is happening asynchronously that we don't realize is async, and therefore don't await, and then we end up calling `formatMessage()` before the translations have been pushed to the store. In any case, pulling the value straight from the translations array works fine. Refs STCOR-718 --- src/components/RegistryLoader.js | 125 +++++++++++++++++++++---------- src/loginServices.js | 8 +- 2 files changed, 90 insertions(+), 43 deletions(-) diff --git a/src/components/RegistryLoader.js b/src/components/RegistryLoader.js index f150142c9..665b05d6e 100644 --- a/src/components/RegistryLoader.js +++ b/src/components/RegistryLoader.js @@ -1,13 +1,19 @@ import React, { useEffect, useState } from 'react'; import PropTypes from 'prop-types'; -import { useIntl } from 'react-intl'; import { okapi } from 'stripes-config'; -import { addIcon, setTranslations } from '../okapiActions'; import { ModulesContext } from '../ModulesContext'; import loadRemoteComponent from '../loadRemoteComponent'; -// TODO: should this be handled by registry? +/** + * parseModules + * Map the list of applications to a hash keyed by acts-as type (app, plugin, + * settings, handler) where the value of each is an array of corresponding + * applications. + * + * @param {array} remotes + * @returns {app: [], plugin: [], settings: [], handler: []} + */ const parseModules = (remotes) => { const modules = { app: [], plugin: [], settings: [], handler: [] }; @@ -19,13 +25,11 @@ const parseModules = (remotes) => { return modules; }; -// TODO: pass it via stripes config -const registryUrl = okapi.registryUrl; - /** * loadTranslations - * return a promise that fetches translations for the given module and then - * dispatches the translations. + * return a promise that fetches translations for the given module, + * dispatches setLocale, and then returns the translations. + * * @param {object} stripes * @param {object} module info read from the registry * @@ -56,11 +60,12 @@ const loadTranslations = (stripes, module) => { const tx = { ...stripes.okapi.translations, ...keyed }; - stripes.store.dispatch(setTranslations(tx)); + // stripes.store.dispatch(setTranslations(tx)); // const tx = { ...stripes.okapi.translations, ...keyed }; // console.log(`filters.status.active: ${tx['ui-users.filters.status.active']}`) - return stripes.setLocale(stripes.locale, tx); + stripes.setLocale(stripes.locale, tx); + return tx; }); } else { throw new Error(`Could not load translations for ${module}`); @@ -82,52 +87,92 @@ const loadIcons = (stripes, module) => { if (module.icons && module.icons.length) { stripes.logger.log('core', `loading icons for ${module.module}`); module.icons.forEach(i => { + stripes.logger.log('core', ` > ${i.name}`); + const icon = { [i.name]: { src: `${module.host}:${module.port}/icons/${i.name}.svg`, alt: i.title, } }; - stripes.store.dispatch(addIcon(module.module, icon)); + stripes.addIcon(module.module, icon); }); } }; +/** + * loadModuleAssets + * Load a module's icons, translations, and sounds. + * @param {object} stripes + * @param {object} module info read from the registry + * @returns {} copy of the module, plus the key `displayName` containing its localized name + */ +const loadModuleAssets = (stripes, module) => { + // register icons + loadIcons(stripes, module); + + // register sounds + // TODO loadSounds(stripes, module); + + // register translations + return loadTranslations(stripes, module) + .then((tx) => { + return { + ...module, + // tx[module.displayName] instead of formatMessage({ id: module.displayName}) + // because ... I'm not sure exactly. I suspect the answer is that we're doing + // something async somewhere but not realizing it, and therefore not returning + // a promise. thus, loadTranslations returns before it's actually done loading + // translations, and calling formatMessage(...) here executes before the new + // values are loaded. + // + // TODO: update when modules are served with compiled translations + displayName: module.displayName ? tx[module.displayName] : module.module, + }; + }) + .catch(e => { + // eslint-disable-next-line no-console + console.error(e); + }); +}; + +/** + * loadModules + * NB: this means multi-type modules, i.e. those like `actsAs: [app, settings]` + * will be loaded multiple times. I'm not sure that's right. + * @param {props} + * @returns Promise + */ +const loadModules = async ({ app, plugin, settings, handler, stripes }) => ({ + app: await Promise.all(app.map(i => loadModuleAssets(stripes, i))), + plugin: await Promise.all(plugin.map(i => loadModuleAssets(stripes, i))), + settings: await Promise.all(settings.map(i => loadModuleAssets(stripes, i))), + handler: await Promise.all(handler.map(i => loadModuleAssets(stripes, i))), +}); + + +/** + * Registry Loader + * @param {object} stripes + * @param {*} children + * @returns + */ const RegistryLoader = ({ stripes, children }) => { - const { formatMessage } = useIntl(); const [modules, setModules] = useState(); + // read the list of registered apps from the registry, useEffect(() => { - const loadModuleAssets = (module) => { - loadIcons(stripes, module); - - return loadTranslations(stripes, module) - .then(() => { - return { - ...module, - displayName: module.displayName ? formatMessage({ id: module.displayName }) : undefined, - }; - }) - .catch(e => { - // eslint-disable-next-line no-console - console.error(e); - }); - }; - - const loadModules = async ({ app, plugin, settings, handler }) => ({ - app: await Promise.all(app.map(loadModuleAssets)), - plugin: await Promise.all(plugin.map(loadModuleAssets)), - settings: await Promise.all(settings.map(loadModuleAssets)), - handler: await Promise.all(handler.map(loadModuleAssets)), - }); - const fetchRegistry = async () => { - const registry = await fetch(registryUrl).then((response) => response.json()); + // read the list of registered apps + const registry = await fetch(okapi.registryUrl).then((response) => response.json()); + + // remap registry from an object shaped like { key1: app1, key2: app2, ...} + // to an array shaped like [ { name: key1, ...app1 }, { name: key2, ...app2 } ...] const remotes = Object.entries(registry.remotes).map(([name, metadata]) => ({ name, ...metadata })); - const parsedModules = await loadModules(parseModules(remotes)); - const { handler: handlerModules } = parsedModules; + const parsedModules = await loadModules({ stripes, ...parseModules(remotes) }); // prefetch all handlers so they can be executed in a sync way. + const { handler: handlerModules } = parsedModules; if (handlerModules) { await Promise.all(handlerModules.map(async (module) => { const component = await loadRemoteComponent(module.url, module.name); @@ -139,8 +184,8 @@ const RegistryLoader = ({ stripes, children }) => { }; fetchRegistry(); - // We know what we are doing here so just ignore the dependency warning about 'formatMessage' - // eslint-disable-next-line react-hooks/exhaustive-deps + // We know what we are doing here so just ignore the dependency warning about 'formatMessage' + // eslint-disable-next-line react-hooks/exhaustive-deps }, []); return ( diff --git a/src/loginServices.js b/src/loginServices.js index 5a38dc465..96dc17f4f 100644 --- a/src/loginServices.js +++ b/src/loginServices.js @@ -220,14 +220,16 @@ export function loadTranslations(store, locale, defaultTranslations = {}) { // Here we put additional condition because languages // like Japan we need to use like ja, but with numeric system // Japan language builds like ja_u, that incorrect. We need to be safe from that bug. - return fetch(translations[region] ? translations[region] : - translations[loadedLocale] || translations[[parentLocale]]) + const translationsUrl = translations[region] ?? (translations[loadedLocale] || translations[parentLocale]); + return fetch(translationsUrl) .then((response) => { if (response.ok) { - response.json().then((stripesTranslations) => { + return response.json().then((stripesTranslations) => { store.dispatch(setTranslations(Object.assign(stripesTranslations, defaultTranslations))); store.dispatch(setLocale(locale)); }); + } else { + return Promise.reject(new Error(`Could not load translations from ${translationsUrl}`)); } }); } From f66ad620f7d5e62a5b6cf09d3efea32a7c3a2244 Mon Sep 17 00:00:00 2001 From: Zak Burke Date: Fri, 4 Aug 2023 16:11:19 -0400 Subject: [PATCH 11/59] STCOR-725 correctly load multiple icons per app Correctly handle multiple icons per application. Refs STCOR-725 --- src/okapiReducer.js | 30 ++++++++++++++++++++++++++++-- 1 file changed, 28 insertions(+), 2 deletions(-) diff --git a/src/okapiReducer.js b/src/okapiReducer.js index 05f17ea4e..ecf6a30fb 100644 --- a/src/okapiReducer.js +++ b/src/okapiReducer.js @@ -134,8 +134,34 @@ export default function okapiReducer(state = {}, action) { case OKAPI_REDUCER_ACTIONS.TOGGLE_RTR_MODAL: { return { ...state, rtrModalIsVisible: action.isVisible }; } - case OKAPI_REDUCER_ACTIONS.ADD_ICON: - return { ...state, icons: { ...state.icons, [action.key]: action.icon } }; + + /** + * state.icons looks like + * { + * "@folio/some-app": { + * app: { alt, src}, + * otherIcon: { alt, src } + * }, + * "@folio/other-app": { app: ...} + * } + * + * action.key looks like @folio/some-app or @folio/other-app + * action.icon looks like { alt: ... } or { otherIcon: ... } + */ + case OKAPI_REDUCER_ACTIONS.ADD_ICON: { + let val = action.icon; + + // if there are already icons defined for this key, + // add this payload to them + if (state.icons?.[action.key]) { + val = { + ...state.icons[action.key], + ...action.icon, + }; + } + + return { ...state, icons: { ...state.icons, [action.key]: val } }; + } default: return state; From 44bd6ddc9cd7004fa6e91a06924029ff1087bb78 Mon Sep 17 00:00:00 2001 From: Zak Burke Date: Wed, 4 Dec 2024 14:56:30 -0500 Subject: [PATCH 12/59] refactor catch up Major refactoring in stripes-core between this branch's initial work and the present lead to some discrepancies. The only change of note here, I think, is the relocation of `` from ModuleRoutes down into AppRoutes. It isn't clear to me why that was necessary or why it worked. It was just a hunch that I tried ... and it worked. Prior to that change, AppRoutes would get stuck in a render loop, infinitely reloading (yes, even the memoized functions). I don't have a good explanation for the bug or the fix. --- bootstrap.js | 9 +- index.js | 1 - src/AppRoutes.js | 63 +++---- src/ModuleRoutes.js | 6 +- src/Pluggable.js | 2 + src/RootWithIntl.js | 248 +++++++++++++------------- src/components/About/WarningBanner.js | 4 +- src/loginServices.js | 2 +- 8 files changed, 165 insertions(+), 170 deletions(-) diff --git a/bootstrap.js b/bootstrap.js index 2227a0edc..bdb96b828 100644 --- a/bootstrap.js +++ b/bootstrap.js @@ -1,13 +1,10 @@ import 'core-js/stable'; import 'regenerator-runtime/runtime'; - import React from 'react'; import { createRoot } from 'react-dom/client'; import App from './src/App'; -export default function init() { - const container = document.getElementById('root'); - const root = createRoot(container); - root.render(); -} +const container = document.getElementById('root'); +const root = createRoot(container); +root.render(); diff --git a/index.js b/index.js index 3a9510d40..eb5c7e073 100644 --- a/index.js +++ b/index.js @@ -49,7 +49,6 @@ export { supportedNumberingSystems } from './src/loginServices'; export { userLocaleConfig } from './src/loginServices'; export * from './src/consortiaServices'; export { default as queryLimit } from './src/queryLimit'; -export { default as init } from './src/init'; /* localforage wrappers hide the session key */ export { getOkapiSession, getTokenExpiry, setTokenExpiry } from './src/loginServices'; diff --git a/src/AppRoutes.js b/src/AppRoutes.js index 4996ba727..f01cc26a4 100644 --- a/src/AppRoutes.js +++ b/src/AppRoutes.js @@ -1,8 +1,9 @@ -import React, { useMemo } from 'react'; +import React, { useMemo, Suspense } from 'react'; import { Route } from 'react-router-dom'; import PropTypes from 'prop-types'; import { connectFor } from '@folio/stripes-connect'; +import { LoadingView } from '@folio/stripes-components'; import { StripesContext } from './StripesContext'; import TitleManager from './components/TitleManager'; @@ -50,37 +51,39 @@ const AppRoutes = ({ modules, stripes }) => { }, [modules.app, stripes]); return cachedModules.map(({ ModuleComponent, connect, module, name, moduleStripes, stripes: propsStripes, displayName }) => ( - { - const data = { displayName, name }; + }> + { + const data = { displayName, name }; - // allow SELECT_MODULE handlers to intervene - const handlerComponents = getEventHandlers(events.SELECT_MODULE, moduleStripes, modules.handler, data); - if (handlerComponents.length) { - return handlerComponents.map(Handler => ()); - } + // allow SELECT_MODULE handlers to intervene + const handlerComponents = getEventHandlers(events.SELECT_MODULE, moduleStripes, modules.handler, data); + if (handlerComponents.length) { + return handlerComponents.map(Handler => ()); + } - return ( - - -
- - - - - -
-
-
- ); - }} - /> + return ( + + +
+ + + + + +
+
+
+ ); + }} + /> +
)); }; diff --git a/src/ModuleRoutes.js b/src/ModuleRoutes.js index d1dc65ce0..08046067f 100644 --- a/src/ModuleRoutes.js +++ b/src/ModuleRoutes.js @@ -71,11 +71,7 @@ function ModuleRoutes({ stripes }) { ); } - return ( - }> - - - ); + return ; }} ); diff --git a/src/Pluggable.js b/src/Pluggable.js index 73d976a2b..6685366e1 100644 --- a/src/Pluggable.js +++ b/src/Pluggable.js @@ -1,6 +1,8 @@ import React, { useMemo, Suspense } from 'react'; import PropTypes from 'prop-types'; + import { Icon } from '@folio/stripes-components'; + import { useModules } from './ModulesContext'; import { withStripes } from './StripesContext'; import { ModuleHierarchyProvider } from './components'; diff --git a/src/RootWithIntl.js b/src/RootWithIntl.js index baa2a1e26..118da5bd4 100644 --- a/src/RootWithIntl.js +++ b/src/RootWithIntl.js @@ -53,131 +53,129 @@ const RootWithIntl = ({ stripes, token = '', isAuthenticated = false, disableAut return ( - - - - - - - { isAuthenticated || token || disableAuth ? - <> - - - - {typeof connectedStripes?.config?.staleBundleWarning === 'object' && } - - { (typeof connectedStripes.okapi !== 'object' || connectedStripes.discovery.isFinished) && ( - - - {connectedStripes.config.useSecureTokens && } - - } - /> - } - /> - } - /> - } - /> - } - /> - } - /> - - - - )} - - - - : - - {/* The ? after :token makes that part of the path optional, so that token may optionally - be passed in via URL parameter to avoid length restrictions */} - } - /> - } - key="sso-landing" - /> - } - key="oidc-landing" - /> - } - /> - } - /> - } - /> - } - /> - } - /> - } - /> - - } - - - - - + + + + + + { isAuthenticated || token || disableAuth ? + <> + + + + {typeof connectedStripes?.config?.staleBundleWarning === 'object' && } + + { (typeof connectedStripes.okapi !== 'object' || connectedStripes.discovery.isFinished) && ( + + + {connectedStripes.config.useSecureTokens && } + + } + /> + } + /> + } + /> + } + /> + } + /> + } + /> + + + + )} + + + + : + + {/* The ? after :token makes that part of the path optional, so that token may optionally + be passed in via URL parameter to avoid length restrictions */} + } + /> + } + key="sso-landing" + /> + } + key="oidc-landing" + /> + } + /> + } + /> + } + /> + } + /> + } + /> + } + /> + + } + + + + diff --git a/src/components/About/WarningBanner.js b/src/components/About/WarningBanner.js index 1ff3867b2..528f806cc 100644 --- a/src/components/About/WarningBanner.js +++ b/src/components/About/WarningBanner.js @@ -48,7 +48,7 @@ const WarningBanner = ({ {missingModulesMsg} @@ -61,7 +61,7 @@ const WarningBanner = ({ {incompatibleModuleMsg} diff --git a/src/loginServices.js b/src/loginServices.js index 96dc17f4f..17023704a 100644 --- a/src/loginServices.js +++ b/src/loginServices.js @@ -252,7 +252,7 @@ function dispatchLocale(url, store, tenant) { }) .then((response) => { if (response.ok) { - response.json().then((json) => { + return response.json().then((json) => { if (json.configs?.length) { const localeValues = JSON.parse(json.configs[0].value); const { locale, timezone, currency } = localeValues; From 72a3f544b2118a2a020382b030237942c4740286 Mon Sep 17 00:00:00 2001 From: John Coburn Date: Fri, 14 Nov 2025 15:07:00 -0600 Subject: [PATCH 13/59] add registry to token util's set of paths for creds --- src/components/Root/token-util.js | 1 + 1 file changed, 1 insertion(+) diff --git a/src/components/Root/token-util.js b/src/components/Root/token-util.js index 2ea9097e5..4aa5ed519 100644 --- a/src/components/Root/token-util.js +++ b/src/components/Root/token-util.js @@ -90,6 +90,7 @@ export const isAuthenticationRequest = (resource, oUrl) => { '/bl-users/login-with-expiry', '/bl-users/_self', '/users-keycloak/_self', + '/registry' ]; return !!permissible.find(i => string.startsWith(`${oUrl}${i}`)); From 3cf47fd49765825674bdaeabc10cc89d3b42f7ff Mon Sep 17 00:00:00 2001 From: John Coburn Date: Fri, 14 Nov 2025 15:09:16 -0600 Subject: [PATCH 14/59] ship context out to stripes-shared-context --- src/App.js | 2 +- src/ModulesContext.js | 7 +------ src/components/MainNav/AppOrderProvider.js | 16 +++++++++++----- 3 files changed, 13 insertions(+), 12 deletions(-) diff --git a/src/App.js b/src/App.js index db89ff325..6d7e7429c 100644 --- a/src/App.js +++ b/src/App.js @@ -2,6 +2,7 @@ import React, { Component, StrictMode } from 'react'; import PropTypes from 'prop-types'; import { okapi as okapiConfig, config } from 'stripes-config'; import merge from 'lodash/merge'; +import { modulesInitialState } from '@folio/stripes-shared-context'; import AppConfigError from './components/AppConfigError'; import connectErrorEpic from './connectErrorEpic'; @@ -11,7 +12,6 @@ import configureStore from './configureStore'; import gatherActions from './gatherActions'; import { destroyStore } from './mainActions'; import { getModules } from './entitlementService'; -import { modulesInitialState } from './ModulesContext'; import css from './components/SessionEventContainer/style.css'; import Root from './components/Root'; diff --git a/src/ModulesContext.js b/src/ModulesContext.js index 863526877..0c5b57d15 100644 --- a/src/ModulesContext.js +++ b/src/ModulesContext.js @@ -1,6 +1 @@ -import { useContext } from 'react'; -import { ModulesContext } from '@folio/stripes-shared-context'; - -export { ModulesContext }; -export default ModulesContext; -export const useModules = () => useContext(ModulesContext); +export { ModulesContext, modulesInitialState, useModules } from '@folio/stripes-shared-context'; diff --git a/src/components/MainNav/AppOrderProvider.js b/src/components/MainNav/AppOrderProvider.js index 4b790da10..8eeaa6bfe 100644 --- a/src/components/MainNav/AppOrderProvider.js +++ b/src/components/MainNav/AppOrderProvider.js @@ -2,10 +2,9 @@ import { createContext, useContext, useMemo } from 'react'; import { useLocation } from 'react-router-dom'; import { useIntl } from 'react-intl'; import { useQuery } from 'react-query'; +import isArray from 'lodash/isArray'; -import { useStripes } from '../../StripesContext'; -import { useModules } from '../../ModulesContext'; -import { LastVisitedContext } from '../LastVisited'; +import { LastVisitedContext, useModules, useStripes } from '@folio/stripes-shared-context'; import usePreferences from '../../hooks/usePreferences'; import { packageName } from '../../constants'; import settingsIcon from './settings.svg'; @@ -39,11 +38,11 @@ export const AppOrderContext = createContext({ * Function to update the preference. Accepts an list of objects with shape: * { name: string - the module package name, sans scope and `ui-` prefix } */ - updateList: () => {}, + updateList: () => { }, /** * Function to delete any the app order preference and reset the list. */ - reset: () => {}, + reset: () => { }, }); // hook for AppOrderContext consumption. @@ -103,6 +102,13 @@ function getAllowedApps(appModules, stripes, pathname, lastVisited, formatMessag route: SETTINGS_ROUTE }); } + + // use translated displayName rather that ast object; + apps.forEach((app) => { + if (isArray(app.displayName)) { + app.displayName = app.displayName[0].value; + } + }); return apps.toSorted((a, b) => a.displayName.localeCompare(b.displayName)); } From 21dd6adbc6aad646851d06c76c6bc6c3e1a8eff1 Mon Sep 17 00:00:00 2001 From: John Coburn Date: Fri, 14 Nov 2025 15:09:46 -0600 Subject: [PATCH 15/59] preload all the things --- src/components/RegistryLoader.js | 120 ++++++++++++++++++++++++------- 1 file changed, 93 insertions(+), 27 deletions(-) diff --git a/src/components/RegistryLoader.js b/src/components/RegistryLoader.js index 665b05d6e..c99905e46 100644 --- a/src/components/RegistryLoader.js +++ b/src/components/RegistryLoader.js @@ -14,13 +14,50 @@ import loadRemoteComponent from '../loadRemoteComponent'; * @param {array} remotes * @returns {app: [], plugin: [], settings: [], handler: []} */ -const parseModules = (remotes) => { +const parseModules = async (remotes) => { const modules = { app: [], plugin: [], settings: [], handler: [] }; - remotes.forEach(remote => { - const { actsAs, ...rest } = remote; - actsAs.forEach(type => modules[type].push(rest)); - }); + // TODO finish prefetching modules here.... + try { + const loaderArray = []; + remotes.forEach(async remote => { + const { name, url } = remote; + // setting getModule for backwards compatibility with parts of stripes that call it.. + loaderArray.push(loadRemoteComponent(url, name)); + }); + await Promise.all(loaderArray); + remotes.forEach((remote, i) => { + const { actsAs, name, url, ...rest } = remote; + const getModule = () => loaderArray[i].default; + actsAs.forEach(type => modules[type].push({ actsAs, name, url, getModule, ...rest })); + }); + } catch (e) { + console.error('Error parsing modules from registry', e); + } + + return modules; +}; + +const preloadModules = async (remotes) => { + const modules = { app: [], plugin: [], settings: [], handler: [] }; + + // TODO finish prefetching modules here.... + try { + const loaderArray = []; + remotes.forEach(async remote => { + const { name, url } = remote; + // setting getModule for backwards compatibility with parts of stripes that call it.. + loaderArray.push(loadRemoteComponent(url, name)); + }); + await Promise.all(loaderArray); + remotes.forEach((remote, i) => { + const { actsAs, name, url, ...rest } = remote; + const getModule = () => loaderArray[i].default; + actsAs.forEach(type => modules[type].push({ actsAs, name, url, getModule, ...rest })); + }); + } catch (e) { + console.error('Error parsing modules from registry', e); + } return modules; }; @@ -52,13 +89,13 @@ const loadTranslations = (stripes, module) => { return response.json().then((translations) => { // 1. translation entries look like "key: val"; we want "ui-${app}.key: val" // 2. module.name is snake_case (I have no idea why); we want kebab-case - const prefix = module.name.replace('folio_', 'ui-').replaceAll('_', '-'); - const keyed = []; - Object.keys(translations).forEach(key => { - keyed[`${prefix}.${key}`] = translations[key]; - }); + // const prefix = module.name.replace('folio_', 'ui-').replaceAll('_', '-'); + // const keyed = []; + // Object.keys(translations).forEach(key => { + // keyed[`${prefix}.${key}`] = translations[key]; + // }); - const tx = { ...stripes.okapi.translations, ...keyed }; + const tx = { ...stripes.okapi.translations, ...translations }; // stripes.store.dispatch(setTranslations(tx)); @@ -117,17 +154,29 @@ const loadModuleAssets = (stripes, module) => { // register translations return loadTranslations(stripes, module) .then((tx) => { + // tx[module.displayName] instead of formatMessage({ id: module.displayName}) + // because ... I'm not sure exactly. I suspect the answer is that we're doing + // something async somewhere but not realizing it, and therefore not returning + // a promise. thus, loadTranslations returns before it's actually done loading + // translations, and calling formatMessage(...) here executes before the new + // values are loaded. + // + // when translations are compiled, the value of the tx[module.displayName] is an array + // containing a single object with shape { type: 'messageFormatPattern', value: 'the actual string' } + // so we have to extract the value from that structure. + let newDisplayName; + if (module.displayName) { + if (typeof tx[module.displayName] === 'string') { + newDisplayName = tx[module.displayName]; + } else { + newDisplayName = tx[module.displayName][0].value; + } + } + return { ...module, - // tx[module.displayName] instead of formatMessage({ id: module.displayName}) - // because ... I'm not sure exactly. I suspect the answer is that we're doing - // something async somewhere but not realizing it, and therefore not returning - // a promise. thus, loadTranslations returns before it's actually done loading - // translations, and calling formatMessage(...) here executes before the new - // values are loaded. - // - // TODO: update when modules are served with compiled translations - displayName: module.displayName ? tx[module.displayName] : module.module, + displayName: module.displayName ? + newDisplayName : module.module, }; }) .catch(e => { @@ -150,6 +199,9 @@ const loadModules = async ({ app, plugin, settings, handler, stripes }) => ({ handler: await Promise.all(handler.map(i => loadModuleAssets(stripes, i))), }); +const loadAllModuleAssets = async (stripes, remotes) => { + return Promise.all(remotes.map((r) => loadModuleAssets(stripes, r))); +}; /** * Registry Loader @@ -169,17 +221,31 @@ const RegistryLoader = ({ stripes, children }) => { // remap registry from an object shaped like { key1: app1, key2: app2, ...} // to an array shaped like [ { name: key1, ...app1 }, { name: key2, ...app2 } ...] const remotes = Object.entries(registry.remotes).map(([name, metadata]) => ({ name, ...metadata })); - const parsedModules = await loadModules({ stripes, ...parseModules(remotes) }); + // load module assets, then load modules... + const remotesWithLoadedAssets = await loadAllModuleAssets(stripes, remotes); + // const parsedModules = await loadModules({ stripes, ...parseModules(remotes) }); + const parsedModules = await preloadModules(remotesWithLoadedAssets); // prefetch all handlers so they can be executed in a sync way. - const { handler: handlerModules } = parsedModules; - if (handlerModules) { - await Promise.all(handlerModules.map(async (module) => { - const component = await loadRemoteComponent(module.url, module.name); - module.getModule = () => component?.default; - })); + // const { handler: handlerModules } = parsedModules; + // if (handlerModules) { + // await Promise.all(handlerModules.map(async (module) => { + // const component = await loadRemoteComponent(module.url, module.name); + // module.getModule = () => component?.default; + // })); + // } + + // preload all modules... + for (const type in parsedModules) { + if (parsedModules[type]) { + parsedModules[type].forEach(async (module) => { + const loadedModule = await loadRemoteComponent(module.url, module.name); + module.getModule = () => loadedModule?.default; + }); + } } + // prefetch setModules(parsedModules); }; From 3deb33c8916ed52b3e3d7a8af15e4c65590f44cf Mon Sep 17 00:00:00 2001 From: John Coburn Date: Fri, 14 Nov 2025 15:10:53 -0600 Subject: [PATCH 16/59] go back to memo'd version --- src/Pluggable.js | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/src/Pluggable.js b/src/Pluggable.js index e0f61e8d2..91bce4e53 100644 --- a/src/Pluggable.js +++ b/src/Pluggable.js @@ -6,7 +6,6 @@ import { Loading } from '@folio/stripes-components'; import { useModules } from './ModulesContext'; import { withStripes } from './StripesContext'; import { ModuleHierarchyProvider } from './components'; -import loadRemoteComponent from './loadRemoteComponent'; const Pluggable = (props) => { const modules = useModules(); @@ -27,8 +26,7 @@ const Pluggable = (props) => { } if (best) { - const RemoteComponent = React.lazy(() => loadRemoteComponent(best.url, best.name)); - const Child = props.stripes.connect(RemoteComponent); + const Child = props.stripes.connect(best.getModule()); cached.push({ Child, From c7563335bdb6783bc8de662efaee721155e79d16 Mon Sep 17 00:00:00 2001 From: John Coburn Date: Fri, 14 Nov 2025 15:53:18 -0600 Subject: [PATCH 17/59] clean up --- src/components/RegistryLoader.js | 88 +++++++------------------------- 1 file changed, 18 insertions(+), 70 deletions(-) diff --git a/src/components/RegistryLoader.js b/src/components/RegistryLoader.js index c99905e46..c0426e5bd 100644 --- a/src/components/RegistryLoader.js +++ b/src/components/RegistryLoader.js @@ -1,4 +1,4 @@ -import React, { useEffect, useState } from 'react'; +import { useEffect, useState } from 'react'; import PropTypes from 'prop-types'; import { okapi } from 'stripes-config'; @@ -6,7 +6,8 @@ import { ModulesContext } from '../ModulesContext'; import loadRemoteComponent from '../loadRemoteComponent'; /** - * parseModules + * preloadModules + * Loads each module code and sets up its getModule function. * Map the list of applications to a hash keyed by acts-as type (app, plugin, * settings, handler) where the value of each is an array of corresponding * applications. @@ -14,46 +15,23 @@ import loadRemoteComponent from '../loadRemoteComponent'; * @param {array} remotes * @returns {app: [], plugin: [], settings: [], handler: []} */ -const parseModules = async (remotes) => { - const modules = { app: [], plugin: [], settings: [], handler: [] }; - - // TODO finish prefetching modules here.... - try { - const loaderArray = []; - remotes.forEach(async remote => { - const { name, url } = remote; - // setting getModule for backwards compatibility with parts of stripes that call it.. - loaderArray.push(loadRemoteComponent(url, name)); - }); - await Promise.all(loaderArray); - remotes.forEach((remote, i) => { - const { actsAs, name, url, ...rest } = remote; - const getModule = () => loaderArray[i].default; - actsAs.forEach(type => modules[type].push({ actsAs, name, url, getModule, ...rest })); - }); - } catch (e) { - console.error('Error parsing modules from registry', e); - } - - return modules; -}; const preloadModules = async (remotes) => { const modules = { app: [], plugin: [], settings: [], handler: [] }; - // TODO finish prefetching modules here.... try { const loaderArray = []; remotes.forEach(async remote => { const { name, url } = remote; - // setting getModule for backwards compatibility with parts of stripes that call it.. - loaderArray.push(loadRemoteComponent(url, name)); + loaderArray.push(loadRemoteComponent(url, name) + .then((module) => { + remote.getModule = () => module.default; + })); }); await Promise.all(loaderArray); - remotes.forEach((remote, i) => { - const { actsAs, name, url, ...rest } = remote; - const getModule = () => loaderArray[i].default; - actsAs.forEach(type => modules[type].push({ actsAs, name, url, getModule, ...rest })); + remotes.forEach((remote) => { + const { actsAs } = remote; + actsAs.forEach(type => modules[type].push({ ...remote })); }); } catch (e) { console.error('Error parsing modules from registry', e); @@ -155,11 +133,7 @@ const loadModuleAssets = (stripes, module) => { return loadTranslations(stripes, module) .then((tx) => { // tx[module.displayName] instead of formatMessage({ id: module.displayName}) - // because ... I'm not sure exactly. I suspect the answer is that we're doing - // something async somewhere but not realizing it, and therefore not returning - // a promise. thus, loadTranslations returns before it's actually done loading - // translations, and calling formatMessage(...) here executes before the new - // values are loaded. + // because updating store is async and we don't have the updated values quite yet... // // when translations are compiled, the value of the tx[module.displayName] is an array // containing a single object with shape { type: 'messageFormatPattern', value: 'the actual string' } @@ -186,19 +160,11 @@ const loadModuleAssets = (stripes, module) => { }; /** - * loadModules - * NB: this means multi-type modules, i.e. those like `actsAs: [app, settings]` - * will be loaded multiple times. I'm not sure that's right. + * loadAllModuleAssets + * Loads icons, translations, and sounds for all modules. Inserts the correct 'displayName' for each module. * @param {props} * @returns Promise */ -const loadModules = async ({ app, plugin, settings, handler, stripes }) => ({ - app: await Promise.all(app.map(i => loadModuleAssets(stripes, i))), - plugin: await Promise.all(plugin.map(i => loadModuleAssets(stripes, i))), - settings: await Promise.all(settings.map(i => loadModuleAssets(stripes, i))), - handler: await Promise.all(handler.map(i => loadModuleAssets(stripes, i))), -}); - const loadAllModuleAssets = async (stripes, remotes) => { return Promise.all(remotes.map((r) => loadModuleAssets(stripes, r))); }; @@ -222,35 +188,17 @@ const RegistryLoader = ({ stripes, children }) => { // to an array shaped like [ { name: key1, ...app1 }, { name: key2, ...app2 } ...] const remotes = Object.entries(registry.remotes).map(([name, metadata]) => ({ name, ...metadata })); - // load module assets, then load modules... + // load module assets (translations, icons), then load modules... const remotesWithLoadedAssets = await loadAllModuleAssets(stripes, remotes); - // const parsedModules = await loadModules({ stripes, ...parseModules(remotes) }); - const parsedModules = await preloadModules(remotesWithLoadedAssets); - // prefetch all handlers so they can be executed in a sync way. - // const { handler: handlerModules } = parsedModules; - // if (handlerModules) { - // await Promise.all(handlerModules.map(async (module) => { - // const component = await loadRemoteComponent(module.url, module.name); - // module.getModule = () => component?.default; - // })); - // } - - // preload all modules... - for (const type in parsedModules) { - if (parsedModules[type]) { - parsedModules[type].forEach(async (module) => { - const loadedModule = await loadRemoteComponent(module.url, module.name); - module.getModule = () => loadedModule?.default; - }); - } - } + // load module code - this loads each module only once and up `getModule` so that it can be used sychronously. + const cachedModules = await preloadModules(remotesWithLoadedAssets); // prefetch - setModules(parsedModules); + setModules(cachedModules); }; fetchRegistry(); - // We know what we are doing here so just ignore the dependency warning about 'formatMessage' + // no, we don't want to refetch the registry if stripes changes // eslint-disable-next-line react-hooks/exhaustive-deps }, []); From 7dc0e1d22e76593b17aba2de465bbdc143bfe890 Mon Sep 17 00:00:00 2001 From: John Coburn Date: Wed, 19 Nov 2025 08:25:53 -0600 Subject: [PATCH 18/59] only import/re-export context/initial values from shared-context --- src/ModulesContext.js | 6 +++++- src/components/MainNav/AppOrderProvider.js | 4 +++- 2 files changed, 8 insertions(+), 2 deletions(-) diff --git a/src/ModulesContext.js b/src/ModulesContext.js index 0c5b57d15..2de5ea159 100644 --- a/src/ModulesContext.js +++ b/src/ModulesContext.js @@ -1 +1,5 @@ -export { ModulesContext, modulesInitialState, useModules } from '@folio/stripes-shared-context'; +import { useContext } from 'react'; +import { ModulesContext } from '@folio/stripes-shared-context'; + +export const useModules = () => useContext(ModulesContext); +export { ModulesContext, modulesInitialValue } from '@folio/stripes-shared-context'; diff --git a/src/components/MainNav/AppOrderProvider.js b/src/components/MainNav/AppOrderProvider.js index 8eeaa6bfe..8116d110a 100644 --- a/src/components/MainNav/AppOrderProvider.js +++ b/src/components/MainNav/AppOrderProvider.js @@ -4,7 +4,9 @@ import { useIntl } from 'react-intl'; import { useQuery } from 'react-query'; import isArray from 'lodash/isArray'; -import { LastVisitedContext, useModules, useStripes } from '@folio/stripes-shared-context'; +import { LastVisitedContext } from '@folio/stripes-shared-context'; +import { useStripes } from '../../StripesContext'; +import { useModules } from '../../ModulesContext'; import usePreferences from '../../hooks/usePreferences'; import { packageName } from '../../constants'; import settingsIcon from './settings.svg'; From a3f0b49aa6e376bce2f4e4ba0a4362ea7bbd3ee7 Mon Sep 17 00:00:00 2001 From: John Coburn Date: Wed, 3 Dec 2025 11:24:44 -0600 Subject: [PATCH 19/59] module-federation logic is conditional on entitlementUrl from stripes-config --- package.json | 3 +- src/App.js | 2 +- src/CalloutContext.js | 6 +-- src/ModulesContext.js | 13 ++++-- src/RootWithIntl.js | 2 +- src/StripesContext.js | 6 +-- .../LastVisited/LastVisitedContext.js | 4 +- src/components/MainNav/AppOrderProvider.js | 2 +- .../MainNav/CurrentApp/AppCtxMenuContext.js | 3 +- .../ModuleHierarchy/ModuleHierarchyContext.js | 4 +- src/components/RegistryLoader.js | 46 ++++++++++--------- 11 files changed, 49 insertions(+), 42 deletions(-) diff --git a/package.json b/package.json index 0108dbd62..8c7ea637d 100644 --- a/package.json +++ b/package.json @@ -93,7 +93,6 @@ }, "dependencies": { "@apollo/client": "^3.2.1", - "@folio/stripes-shared-context": "^1.0.0", "classnames": "^2.2.5", "core-js": "^3.26.1", "final-form": "^4.18.2", @@ -137,4 +136,4 @@ "redux-observable": "^1.2.0", "rxjs": "^6.6.3" } -} +} \ No newline at end of file diff --git a/src/App.js b/src/App.js index 6d7e7429c..23e5eb919 100644 --- a/src/App.js +++ b/src/App.js @@ -2,7 +2,7 @@ import React, { Component, StrictMode } from 'react'; import PropTypes from 'prop-types'; import { okapi as okapiConfig, config } from 'stripes-config'; import merge from 'lodash/merge'; -import { modulesInitialState } from '@folio/stripes-shared-context'; +import { modulesInitialState } from './ModulesContext'; import AppConfigError from './components/AppConfigError'; import connectErrorEpic from './connectErrorEpic'; diff --git a/src/CalloutContext.js b/src/CalloutContext.js index 66665e70f..27fa2ca44 100644 --- a/src/CalloutContext.js +++ b/src/CalloutContext.js @@ -1,8 +1,6 @@ -import React, { useContext } from 'react'; +import { useContext, createContext } from 'react'; -import { CalloutContext } from '@folio/stripes-shared-context'; - -export { CalloutContext }; +export const CalloutContext = createContext(); export const useCallout = () => { return useContext(CalloutContext); diff --git a/src/ModulesContext.js b/src/ModulesContext.js index 2de5ea159..b10ad0539 100644 --- a/src/ModulesContext.js +++ b/src/ModulesContext.js @@ -1,5 +1,12 @@ -import { useContext } from 'react'; -import { ModulesContext } from '@folio/stripes-shared-context'; +import { useContext, createContext } from 'react'; + +export const modulesInitialValue = { + app: [], + handler: [], + plugin: [], + settings: [], +}; + +export const ModulesContext = createContext(modulesInitialValue); export const useModules = () => useContext(ModulesContext); -export { ModulesContext, modulesInitialValue } from '@folio/stripes-shared-context'; diff --git a/src/RootWithIntl.js b/src/RootWithIntl.js index f814fb511..e3989beb7 100644 --- a/src/RootWithIntl.js +++ b/src/RootWithIntl.js @@ -71,7 +71,7 @@ const RootWithIntl = ({ stripes, token = '', isAuthenticated = false, disableAut return ( - + - {stripes => } + {stripes => } ); } diff --git a/src/components/LastVisited/LastVisitedContext.js b/src/components/LastVisited/LastVisitedContext.js index e7c9f0f84..166e08b4a 100644 --- a/src/components/LastVisited/LastVisitedContext.js +++ b/src/components/LastVisited/LastVisitedContext.js @@ -1,3 +1,3 @@ -import { LastVisitedContext } from '@folio/stripes-shared-context'; +import React from 'react'; -export default LastVisitedContext; +export default React.createContext({}); diff --git a/src/components/MainNav/AppOrderProvider.js b/src/components/MainNav/AppOrderProvider.js index 8116d110a..38cb1c924 100644 --- a/src/components/MainNav/AppOrderProvider.js +++ b/src/components/MainNav/AppOrderProvider.js @@ -4,7 +4,7 @@ import { useIntl } from 'react-intl'; import { useQuery } from 'react-query'; import isArray from 'lodash/isArray'; -import { LastVisitedContext } from '@folio/stripes-shared-context'; +import { LastVisitedContext } from '../LastVisited'; import { useStripes } from '../../StripesContext'; import { useModules } from '../../ModulesContext'; import usePreferences from '../../hooks/usePreferences'; diff --git a/src/components/MainNav/CurrentApp/AppCtxMenuContext.js b/src/components/MainNav/CurrentApp/AppCtxMenuContext.js index 9327f1769..791887881 100644 --- a/src/components/MainNav/CurrentApp/AppCtxMenuContext.js +++ b/src/components/MainNav/CurrentApp/AppCtxMenuContext.js @@ -1,8 +1,7 @@ import React from 'react'; import hoistNonReactStatics from 'hoist-non-react-statics'; -import { AppCtxMenuContext } from '@folio/stripes-shared-context'; -export { AppCtxMenuContext }; +export const AppCtxMenuContext = React.createContext(); export function withAppCtxMenu(Component) { const WrappedComponent = (props) => { diff --git a/src/components/ModuleHierarchy/ModuleHierarchyContext.js b/src/components/ModuleHierarchy/ModuleHierarchyContext.js index 1a5c4d177..265f1101b 100644 --- a/src/components/ModuleHierarchy/ModuleHierarchyContext.js +++ b/src/components/ModuleHierarchy/ModuleHierarchyContext.js @@ -1,3 +1,5 @@ -import { ModuleHierarchyContext } from '@folio/stripes-shared-context'; +import React from 'react'; + +const ModuleHierarchyContext = React.createContext(); export default ModuleHierarchyContext; diff --git a/src/components/RegistryLoader.js b/src/components/RegistryLoader.js index c0426e5bd..c206b56e4 100644 --- a/src/components/RegistryLoader.js +++ b/src/components/RegistryLoader.js @@ -1,7 +1,7 @@ import { useEffect, useState } from 'react'; import PropTypes from 'prop-types'; import { okapi } from 'stripes-config'; - +import { useStripes } from '../StripesContext'; import { ModulesContext } from '../ModulesContext'; import loadRemoteComponent from '../loadRemoteComponent'; @@ -175,29 +175,35 @@ const loadAllModuleAssets = async (stripes, remotes) => { * @param {*} children * @returns */ -const RegistryLoader = ({ stripes, children }) => { - const [modules, setModules] = useState(); +const RegistryLoader = ({ children }) => { + const stripes = useStripes(); + const [modules, setModules] = useState(stripes.modules); - // read the list of registered apps from the registry, + // if platform is configured for module federation, read the list of registered apps from + // localstorage, okapi, direct call to registry endpoint? useEffect(() => { - const fetchRegistry = async () => { - // read the list of registered apps - const registry = await fetch(okapi.registryUrl).then((response) => response.json()); + if (okapi.entitlementUrl) { + const fetchRegistry = async () => { + // read the list of registered apps + const registry = await fetch(okapi.entitlementUrl).then((response) => response.json()); + + // remap registry from an object shaped like { key1: app1, key2: app2, ...} + // to an array shaped like [ { name: key1, ...app1 }, { name: key2, ...app2 } ...] + const remotes = Object.entries(registry.remotes).map(([name, metadata]) => ({ name, ...metadata })); - // remap registry from an object shaped like { key1: app1, key2: app2, ...} - // to an array shaped like [ { name: key1, ...app1 }, { name: key2, ...app2 } ...] - const remotes = Object.entries(registry.remotes).map(([name, metadata]) => ({ name, ...metadata })); + // load module assets (translations, icons), then load modules... + const remotesWithLoadedAssets = await loadAllModuleAssets(stripes, remotes); + // load module code - this loads each module only once and up `getModule` so that it can be used sychronously. + const cachedModules = await preloadModules(remotesWithLoadedAssets); - // load module assets (translations, icons), then load modules... - const remotesWithLoadedAssets = await loadAllModuleAssets(stripes, remotes); - // load module code - this loads each module only once and up `getModule` so that it can be used sychronously. - const cachedModules = await preloadModules(remotesWithLoadedAssets); + const combinedModules = { ...stripes.modules, ...cachedModules }; - // prefetch - setModules(cachedModules); - }; + // prefetch + setModules(combinedModules); + }; - fetchRegistry(); + fetchRegistry(); + } // no, we don't want to refetch the registry if stripes changes // eslint-disable-next-line react-hooks/exhaustive-deps }, []); @@ -214,9 +220,7 @@ RegistryLoader.propTypes = { PropTypes.arrayOf(PropTypes.node), PropTypes.node, PropTypes.func, - ]), - stripes: PropTypes.object.isRequired, + ]) }; - export default RegistryLoader; From 231e81283e5c0c3ee3cd09e4ae7ed66a157cd943 Mon Sep 17 00:00:00 2001 From: John Coburn Date: Wed, 3 Dec 2025 13:31:15 -0600 Subject: [PATCH 20/59] remove 'prefetch' comment --- src/components/RegistryLoader.js | 1 - 1 file changed, 1 deletion(-) diff --git a/src/components/RegistryLoader.js b/src/components/RegistryLoader.js index c206b56e4..75d4e15a1 100644 --- a/src/components/RegistryLoader.js +++ b/src/components/RegistryLoader.js @@ -198,7 +198,6 @@ const RegistryLoader = ({ children }) => { const combinedModules = { ...stripes.modules, ...cachedModules }; - // prefetch setModules(combinedModules); }; From 17c42d25be4b39d51dd6383e79621b5c8ff59e61 Mon Sep 17 00:00:00 2001 From: John Coburn Date: Wed, 3 Dec 2025 13:38:56 -0600 Subject: [PATCH 21/59] sync changes to context with main branch --- src/CalloutContext.js | 4 +- src/ModulesContext.js | 8 +- src/RootWithIntl.js | 253 +++++++++++++++++++++--------------------- src/StripesContext.js | 2 +- 4 files changed, 135 insertions(+), 132 deletions(-) diff --git a/src/CalloutContext.js b/src/CalloutContext.js index 27fa2ca44..b2b0f180c 100644 --- a/src/CalloutContext.js +++ b/src/CalloutContext.js @@ -1,6 +1,6 @@ -import { useContext, createContext } from 'react'; +import React, { useContext } from 'react'; -export const CalloutContext = createContext(); +export const CalloutContext = React.createContext(); export const useCallout = () => { return useContext(CalloutContext); diff --git a/src/ModulesContext.js b/src/ModulesContext.js index b10ad0539..d113f8343 100644 --- a/src/ModulesContext.js +++ b/src/ModulesContext.js @@ -1,12 +1,12 @@ -import { useContext, createContext } from 'react'; +import React, { useContext } from 'react'; -export const modulesInitialValue = { +export const modulesInitialState = { app: [], handler: [], plugin: [], settings: [], }; -export const ModulesContext = createContext(modulesInitialValue); - +export const ModulesContext = React.createContext(modulesInitialState); +export default ModulesContext; export const useModules = () => useContext(ModulesContext); diff --git a/src/RootWithIntl.js b/src/RootWithIntl.js index e3989beb7..b15f2f086 100644 --- a/src/RootWithIntl.js +++ b/src/RootWithIntl.js @@ -18,6 +18,7 @@ import { MainContainer, MainNav, ModuleContainer, + ModuleTranslator, TitledRoute, Front, OIDCRedirect, @@ -72,132 +73,134 @@ const RootWithIntl = ({ stripes, token = '', isAuthenticated = false, disableAut - - - - - {isAuthenticated || token || disableAuth ? - <> - - - - - - {typeof connectedStripes?.config?.staleBundleWarning === 'object' && } - - {(typeof connectedStripes.okapi !== 'object' || connectedStripes.discovery.isFinished) && ( - - - - - } - /> - } - /> - } - /> - } - /> - } - /> - } - /> - - - - )} - - - - - : - - {/* The ? after :token makes that part of the path optional, so that token may optionally + + + + + + {isAuthenticated || token || disableAuth ? + <> + + + + + + {typeof connectedStripes?.config?.staleBundleWarning === 'object' && } + + {(typeof connectedStripes.okapi !== 'object' || connectedStripes.discovery.isFinished) && ( + + + + + } + /> + } + /> + } + /> + } + /> + } + /> + } + /> + + + + )} + + + + + : + + {/* The ? after :token makes that part of the path optional, so that token may optionally be passed in via URL parameter to avoid length restrictions */} - } - /> - } - key="sso-landing" - /> - } - key="oidc-landing" - /> - } - /> - } - /> - } - /> - } - /> - } - /> - } - /> - - } - - - - + } + /> + } + key="sso-landing" + /> + } + key="oidc-landing" + /> + } + /> + } + /> + } + /> + } + /> + } + /> + } + /> + + } + + + + + diff --git a/src/StripesContext.js b/src/StripesContext.js index 93b7f852a..835262268 100644 --- a/src/StripesContext.js +++ b/src/StripesContext.js @@ -19,7 +19,7 @@ export function withStripes(WrappedComponent) { render() { return ( - {stripes => } + {stripes => } ); } From aff451337ea6038e1d3319e78645892fee99f5b3 Mon Sep 17 00:00:00 2001 From: John Coburn Date: Wed, 3 Dec 2025 13:47:09 -0600 Subject: [PATCH 22/59] import order sync with main branch --- src/components/About/WarningBanner.js | 4 ++-- src/components/MainNav/AppOrderProvider.js | 14 +++----------- src/components/ModuleTranslator/index.js | 1 + src/components/Root/Root.js | 19 +++++++++++-------- src/components/index.js | 2 ++ 5 files changed, 19 insertions(+), 21 deletions(-) create mode 100644 src/components/ModuleTranslator/index.js diff --git a/src/components/About/WarningBanner.js b/src/components/About/WarningBanner.js index 528f806cc..1ff3867b2 100644 --- a/src/components/About/WarningBanner.js +++ b/src/components/About/WarningBanner.js @@ -48,7 +48,7 @@ const WarningBanner = ({ {missingModulesMsg} @@ -61,7 +61,7 @@ const WarningBanner = ({ {incompatibleModuleMsg} diff --git a/src/components/MainNav/AppOrderProvider.js b/src/components/MainNav/AppOrderProvider.js index 38cb1c924..4b790da10 100644 --- a/src/components/MainNav/AppOrderProvider.js +++ b/src/components/MainNav/AppOrderProvider.js @@ -2,11 +2,10 @@ import { createContext, useContext, useMemo } from 'react'; import { useLocation } from 'react-router-dom'; import { useIntl } from 'react-intl'; import { useQuery } from 'react-query'; -import isArray from 'lodash/isArray'; -import { LastVisitedContext } from '../LastVisited'; import { useStripes } from '../../StripesContext'; import { useModules } from '../../ModulesContext'; +import { LastVisitedContext } from '../LastVisited'; import usePreferences from '../../hooks/usePreferences'; import { packageName } from '../../constants'; import settingsIcon from './settings.svg'; @@ -40,11 +39,11 @@ export const AppOrderContext = createContext({ * Function to update the preference. Accepts an list of objects with shape: * { name: string - the module package name, sans scope and `ui-` prefix } */ - updateList: () => { }, + updateList: () => {}, /** * Function to delete any the app order preference and reset the list. */ - reset: () => { }, + reset: () => {}, }); // hook for AppOrderContext consumption. @@ -104,13 +103,6 @@ function getAllowedApps(appModules, stripes, pathname, lastVisited, formatMessag route: SETTINGS_ROUTE }); } - - // use translated displayName rather that ast object; - apps.forEach((app) => { - if (isArray(app.displayName)) { - app.displayName = app.displayName[0].value; - } - }); return apps.toSorted((a, b) => a.displayName.localeCompare(b.displayName)); } diff --git a/src/components/ModuleTranslator/index.js b/src/components/ModuleTranslator/index.js new file mode 100644 index 000000000..fe476e5a9 --- /dev/null +++ b/src/components/ModuleTranslator/index.js @@ -0,0 +1 @@ +export { default } from './ModuleTranslator'; diff --git a/src/components/Root/Root.js b/src/components/Root/Root.js index bfde65e98..243430dd3 100644 --- a/src/components/Root/Root.js +++ b/src/components/Root/Root.js @@ -9,13 +9,14 @@ import { QueryClientProvider } from 'react-query'; import { ApolloProvider } from '@apollo/client'; import { ErrorBoundary } from '@folio/stripes-components'; +import { metadata, icons } from 'stripes-config'; import { ConnectContext } from '@folio/stripes-connect'; import initialReducers from '../../initialReducers'; import enhanceReducer from '../../enhanceReducer'; import createApolloClient from '../../createApolloClient'; import createReactQueryClient from '../../createReactQueryClient'; -import { addIcon, setSinglePlugin, setBindings, setIsAuthenticated, setOkapiToken, setTimezone, setCurrency, updateCurrentUser } from '../../okapiActions'; +import { setSinglePlugin, setBindings, setIsAuthenticated, setOkapiToken, setTimezone, setCurrency, updateCurrentUser } from '../../okapiActions'; import { loadTranslations, checkOkapiSession } from '../../loginServices'; import { getQueryResourceKey, getCurrentModule } from '../../locationService'; import Stripes from '../../Stripes'; @@ -28,6 +29,11 @@ import './Root.css'; import { FFetch } from './FFetch'; +if (!metadata) { + // eslint-disable-next-line no-console + console.error('No metadata harvested from package files, so you will not get app icons. Probably the stripes-core in your Stripes CLI is too old. Try `yarn global upgrade @folio/stripes-cli`'); +} + class Root extends Component { constructor(...args) { super(...args); @@ -127,7 +133,7 @@ class Root extends Component { } render() { - const { logger, store, epics, config, okapi, actionNames, token, isAuthenticated, disableAuth, currentUser, currentPerms, icons, locale, defaultTranslations, timezone, currency, plugins, bindings, discovery, translations, history, serverDown } = this.props; + const { logger, store, epics, config, okapi, actionNames, token, isAuthenticated, disableAuth, currentUser, currentPerms, locale, defaultTranslations, timezone, currency, plugins, bindings, discovery, translations, history, serverDown } = this.props; if (serverDown) { // note: this isn't i18n'ed because we haven't rendered an IntlProvider yet. return
Error: server is forbidden, unreachable or down. Clear the cookies? Use incognito mode? VPN issue?
; @@ -163,9 +169,9 @@ class Root extends Component { locale, timezone, currency, + metadata, icons, - addIcon: (key, icon) => { store.dispatch(addIcon(key, icon)); }, - setLocale: (localeValue, tx) => { return loadTranslations(store, localeValue, { ...defaultTranslations, ...tx }); }, + setLocale: (localeValue) => { loadTranslations(store, localeValue, defaultTranslations); }, setTimezone: (timezoneValue) => { store.dispatch(setTimezone(timezoneValue)); }, setCurrency: (currencyValue) => { store.dispatch(setCurrency(currencyValue)); }, updateUser: (userValue) => { store.dispatch(updateCurrentUser(userValue)); }, @@ -185,7 +191,7 @@ class Root extends Component { - + Date: Wed, 3 Dec 2025 14:41:37 -0600 Subject: [PATCH 23/59] inject remote modules into build-time modules --- src/RootWithIntl.js | 10 +++++----- .../{RegistryLoader.js => EntitlementLoader.js} | 13 +++++++------ src/components/Root/Root.js | 5 +++-- 3 files changed, 15 insertions(+), 13 deletions(-) rename src/components/{RegistryLoader.js => EntitlementLoader.js} (95%) diff --git a/src/RootWithIntl.js b/src/RootWithIntl.js index b15f2f086..3c7317550 100644 --- a/src/RootWithIntl.js +++ b/src/RootWithIntl.js @@ -43,7 +43,7 @@ import StaleBundleWarning from './components/StaleBundleWarning'; import { StripesContext } from './StripesContext'; import { CalloutContext } from './CalloutContext'; import AuthnLogin from './components/AuthnLogin'; -import RegistryLoader from './components/RegistryLoader'; +import EntitlementLoader from './components/EntitlementLoader'; const RootWithIntl = ({ stripes, token = '', isAuthenticated = false, disableAuth, history = {}, queryClient }) => { const connect = connectFor('@folio/core', stripes.epics, stripes.logger); @@ -72,8 +72,8 @@ const RootWithIntl = ({ stripes, token = '', isAuthenticated = false, disableAut return ( - - + + - - + + ); diff --git a/src/components/RegistryLoader.js b/src/components/EntitlementLoader.js similarity index 95% rename from src/components/RegistryLoader.js rename to src/components/EntitlementLoader.js index 75d4e15a1..b7960366e 100644 --- a/src/components/RegistryLoader.js +++ b/src/components/EntitlementLoader.js @@ -2,7 +2,7 @@ import { useEffect, useState } from 'react'; import PropTypes from 'prop-types'; import { okapi } from 'stripes-config'; import { useStripes } from '../StripesContext'; -import { ModulesContext } from '../ModulesContext'; +import { ModulesContext, useModules } from '../ModulesContext'; import loadRemoteComponent from '../loadRemoteComponent'; /** @@ -175,9 +175,10 @@ const loadAllModuleAssets = async (stripes, remotes) => { * @param {*} children * @returns */ -const RegistryLoader = ({ children }) => { +const EntitlementLoader = ({ children }) => { const stripes = useStripes(); - const [modules, setModules] = useState(stripes.modules); + const configModules = useModules(); + const [modules, setModules] = useState(configModules); // if platform is configured for module federation, read the list of registered apps from // localstorage, okapi, direct call to registry endpoint? @@ -196,7 +197,7 @@ const RegistryLoader = ({ children }) => { // load module code - this loads each module only once and up `getModule` so that it can be used sychronously. const cachedModules = await preloadModules(remotesWithLoadedAssets); - const combinedModules = { ...stripes.modules, ...cachedModules }; + const combinedModules = { ...configModules, ...cachedModules }; setModules(combinedModules); }; @@ -214,7 +215,7 @@ const RegistryLoader = ({ children }) => { ); }; -RegistryLoader.propTypes = { +EntitlementLoader.propTypes = { children: PropTypes.oneOfType([ PropTypes.arrayOf(PropTypes.node), PropTypes.node, @@ -222,4 +223,4 @@ RegistryLoader.propTypes = { ]) }; -export default RegistryLoader; +export default EntitlementLoader; diff --git a/src/components/Root/Root.js b/src/components/Root/Root.js index 243430dd3..e98833560 100644 --- a/src/components/Root/Root.js +++ b/src/components/Root/Root.js @@ -16,7 +16,7 @@ import initialReducers from '../../initialReducers'; import enhanceReducer from '../../enhanceReducer'; import createApolloClient from '../../createApolloClient'; import createReactQueryClient from '../../createReactQueryClient'; -import { setSinglePlugin, setBindings, setIsAuthenticated, setOkapiToken, setTimezone, setCurrency, updateCurrentUser } from '../../okapiActions'; +import { addIcon, setSinglePlugin, setBindings, setIsAuthenticated, setOkapiToken, setTimezone, setCurrency, updateCurrentUser } from '../../okapiActions'; import { loadTranslations, checkOkapiSession } from '../../loginServices'; import { getQueryResourceKey, getCurrentModule } from '../../locationService'; import Stripes from '../../Stripes'; @@ -133,7 +133,7 @@ class Root extends Component { } render() { - const { logger, store, epics, config, okapi, actionNames, token, isAuthenticated, disableAuth, currentUser, currentPerms, locale, defaultTranslations, timezone, currency, plugins, bindings, discovery, translations, history, serverDown } = this.props; + const { logger, store, epics, config, okapi, actionNames, token, isAuthenticated, disableAuth, currentUser, currentPerms, icons, locale, defaultTranslations, timezone, currency, plugins, bindings, discovery, translations, history, serverDown } = this.props; if (serverDown) { // note: this isn't i18n'ed because we haven't rendered an IntlProvider yet. return
Error: server is forbidden, unreachable or down. Clear the cookies? Use incognito mode? VPN issue?
; @@ -171,6 +171,7 @@ class Root extends Component { currency, metadata, icons, + addIcon: (key, icon) => { store.dispatch(addIcon(key, icon)); }, setLocale: (localeValue) => { loadTranslations(store, localeValue, defaultTranslations); }, setTimezone: (timezoneValue) => { store.dispatch(setTimezone(timezoneValue)); }, setCurrency: (currencyValue) => { store.dispatch(setCurrency(currencyValue)); }, From 4139c2ffa4ef29e613a1dab403b536cc64401107 Mon Sep 17 00:00:00 2001 From: John Coburn Date: Wed, 3 Dec 2025 14:51:29 -0600 Subject: [PATCH 24/59] re-add store icons to props on Root --- src/components/Root/Root.js | 3 +++ 1 file changed, 3 insertions(+) diff --git a/src/components/Root/Root.js b/src/components/Root/Root.js index e98833560..6031922a0 100644 --- a/src/components/Root/Root.js +++ b/src/components/Root/Root.js @@ -266,6 +266,7 @@ Root.propTypes = { push: PropTypes.func.isRequired, replace: PropTypes.func.isRequired, }), + icons: PropTypes.object, okapiReady: PropTypes.bool, serverDown: PropTypes.bool, }; @@ -276,6 +277,7 @@ Root.defaultProps = { currency: 'USD', okapiReady: false, serverDown: false, + icons: {}, }; function mapStateToProps(state) { @@ -285,6 +287,7 @@ function mapStateToProps(state) { currentPerms: state.okapi.currentPerms, currentUser: state.okapi.currentUser, discovery: state.discovery, + icons: state.okapi.icons, isAuthenticated: state.okapi.isAuthenticated, locale: state.okapi.locale, okapi: state.okapi, From 256e9f1cfc8c468bc828bda28c9128edde29eaf7 Mon Sep 17 00:00:00 2001 From: John Coburn Date: Fri, 5 Dec 2025 08:13:06 -0600 Subject: [PATCH 25/59] add setTranslations function to stripes --- src/AppRoutes.js | 6 ++---- src/components/EntitlementLoader.js | 19 ++++++++++++------- src/components/Root/Root.js | 3 ++- src/components/Settings/Settings.js | 4 +--- 4 files changed, 17 insertions(+), 15 deletions(-) diff --git a/src/AppRoutes.js b/src/AppRoutes.js index da24cbbdb..e886c88af 100644 --- a/src/AppRoutes.js +++ b/src/AppRoutes.js @@ -12,7 +12,6 @@ import { getEventHandlers } from './handlerService'; import { packageName } from './constants'; import { ModuleHierarchyProvider } from './components'; import events from './events'; -import loadRemoteComponent from './loadRemoteComponent'; // Process and cache "app" type modules and render the routes const AppRoutes = ({ modules, stripes }) => { @@ -24,13 +23,12 @@ const AppRoutes = ({ modules, stripes }) => { const perm = `module.${name}.enabled`; if (!stripes.hasPerm(perm)) return null; - const RemoteComponent = React.lazy(() => loadRemoteComponent(module.url, module.name)); const connect = connectFor(module.module, stripes.epics, stripes.logger); let ModuleComponent; try { - ModuleComponent = connect(RemoteComponent); + ModuleComponent = connect(module.getModule()); } catch (error) { console.error(error); // eslint-disable-line throw Error(error); @@ -51,7 +49,7 @@ const AppRoutes = ({ modules, stripes }) => { }, [modules.app, stripes]); return cachedModules.map(({ ModuleComponent, connect, module, name, moduleStripes, stripes: propsStripes, displayName }) => ( - }> + }> { const tx = { ...stripes.okapi.translations, ...translations }; // stripes.store.dispatch(setTranslations(tx)); - + stripes.setTranslations(tx); // const tx = { ...stripes.okapi.translations, ...keyed }; // console.log(`filters.status.active: ${tx['ui-users.filters.status.active']}`) stripes.setLocale(stripes.locale, tx); @@ -192,14 +192,19 @@ const EntitlementLoader = ({ children }) => { // to an array shaped like [ { name: key1, ...app1 }, { name: key2, ...app2 } ...] const remotes = Object.entries(registry.remotes).map(([name, metadata]) => ({ name, ...metadata })); - // load module assets (translations, icons), then load modules... - const remotesWithLoadedAssets = await loadAllModuleAssets(stripes, remotes); - // load module code - this loads each module only once and up `getModule` so that it can be used sychronously. - const cachedModules = await preloadModules(remotesWithLoadedAssets); + if (remotes) { + // load module assets (translations, icons), then load modules... + const remotesWithLoadedAssets = await loadAllModuleAssets(stripes, remotes); + + if (remotesWithLoadedAssets[0]) { + // load module code - this loads each module only once and up `getModule` so that it can be used sychronously. + const cachedModules = await preloadModules(remotesWithLoadedAssets); - const combinedModules = { ...configModules, ...cachedModules }; + const combinedModules = { ...configModules, ...cachedModules }; - setModules(combinedModules); + setModules(combinedModules); + } + } }; fetchRegistry(); diff --git a/src/components/Root/Root.js b/src/components/Root/Root.js index 6031922a0..93ddf5e4f 100644 --- a/src/components/Root/Root.js +++ b/src/components/Root/Root.js @@ -16,7 +16,7 @@ import initialReducers from '../../initialReducers'; import enhanceReducer from '../../enhanceReducer'; import createApolloClient from '../../createApolloClient'; import createReactQueryClient from '../../createReactQueryClient'; -import { addIcon, setSinglePlugin, setBindings, setIsAuthenticated, setOkapiToken, setTimezone, setCurrency, updateCurrentUser } from '../../okapiActions'; +import { addIcon, setSinglePlugin, setBindings, setIsAuthenticated, setOkapiToken, setTimezone, setCurrency, updateCurrentUser, setTranslations } from '../../okapiActions'; import { loadTranslations, checkOkapiSession } from '../../loginServices'; import { getQueryResourceKey, getCurrentModule } from '../../locationService'; import Stripes from '../../Stripes'; @@ -174,6 +174,7 @@ class Root extends Component { addIcon: (key, icon) => { store.dispatch(addIcon(key, icon)); }, setLocale: (localeValue) => { loadTranslations(store, localeValue, defaultTranslations); }, setTimezone: (timezoneValue) => { store.dispatch(setTimezone(timezoneValue)); }, + setTranslations: (nextTranslations) => { store.dispatch(setTranslations(nextTranslations)); }, setCurrency: (currencyValue) => { store.dispatch(setCurrency(currencyValue)); }, updateUser: (userValue) => { store.dispatch(updateCurrentUser(userValue)); }, plugins: plugins || {}, diff --git a/src/components/Settings/Settings.js b/src/components/Settings/Settings.js index b56763f82..3ef057ba0 100644 --- a/src/components/Settings/Settings.js +++ b/src/components/Settings/Settings.js @@ -32,7 +32,6 @@ import AppIcon from '../AppIcon'; import { packageName } from '../../constants'; import RouteErrorBoundary from '../RouteErrorBoundary'; import { ModuleHierarchyProvider } from '../ModuleHierarchy'; -import loadRemoteComponent from '../../loadRemoteComponent'; import css from './Settings.css'; @@ -61,10 +60,9 @@ const Settings = ({ stripes }) => { .map((m) => { try { const connect = connectFor(m.module, stripes.epics, stripes.logger); - const RemoteComponent = React.lazy(() => loadRemoteComponent(m.url, m.name)); return { module: m, - Component: connect(RemoteComponent), + Component: connect(m.getModule()), moduleStripes: stripes.clone({ connect }), }; } catch (error) { From 2270e9f56563488ff4ab1e5c15392b42d8b90281 Mon Sep 17 00:00:00 2001 From: John Coburn Date: Fri, 5 Dec 2025 09:09:47 -0600 Subject: [PATCH 26/59] sync okapiReducer --- src/components/EntitlementLoader.js | 37 +++++++++++++---------------- src/loginServices.js | 8 ++++--- src/okapiReducer.js | 2 +- 3 files changed, 22 insertions(+), 25 deletions(-) diff --git a/src/components/EntitlementLoader.js b/src/components/EntitlementLoader.js index 02726eef0..c0a6b8642 100644 --- a/src/components/EntitlementLoader.js +++ b/src/components/EntitlementLoader.js @@ -16,7 +16,7 @@ import loadRemoteComponent from '../loadRemoteComponent'; * @returns {app: [], plugin: [], settings: [], handler: []} */ -const preloadModules = async (remotes) => { +const preloadModules = async (stripes, remotes) => { const modules = { app: [], plugin: [], settings: [], handler: [] }; try { @@ -26,15 +26,19 @@ const preloadModules = async (remotes) => { loaderArray.push(loadRemoteComponent(url, name) .then((module) => { remote.getModule = () => module.default; - })); + }) + .catch((e) => { throw new Error(`Error loading code for remote module: ${name}: ${e}`); })); }); + await Promise.all(loaderArray); + + // once the all the code for the modules are loaded, populate the `modules` structure based on `actsAs` keys. remotes.forEach((remote) => { const { actsAs } = remote; actsAs.forEach(type => modules[type].push({ ...remote })); }); } catch (e) { - console.error('Error parsing modules from registry', e); + stripes.logger.log('core', `Error preloading modules from entitlement response: ${e}`); } return modules; @@ -65,20 +69,10 @@ const loadTranslations = (stripes, module) => { .then((response) => { if (response.ok) { return response.json().then((translations) => { - // 1. translation entries look like "key: val"; we want "ui-${app}.key: val" - // 2. module.name is snake_case (I have no idea why); we want kebab-case - // const prefix = module.name.replace('folio_', 'ui-').replaceAll('_', '-'); - // const keyed = []; - // Object.keys(translations).forEach(key => { - // keyed[`${prefix}.${key}`] = translations[key]; - // }); - const tx = { ...stripes.okapi.translations, ...translations }; - // stripes.store.dispatch(setTranslations(tx)); stripes.setTranslations(tx); - // const tx = { ...stripes.okapi.translations, ...keyed }; - // console.log(`filters.status.active: ${tx['ui-users.filters.status.active']}`) + stripes.setLocale(stripes.locale, tx); return tx; }); @@ -192,18 +186,19 @@ const EntitlementLoader = ({ children }) => { // to an array shaped like [ { name: key1, ...app1 }, { name: key2, ...app2 } ...] const remotes = Object.entries(registry.remotes).map(([name, metadata]) => ({ name, ...metadata })); - if (remotes) { + try { // load module assets (translations, icons), then load modules... const remotesWithLoadedAssets = await loadAllModuleAssets(stripes, remotes); - if (remotesWithLoadedAssets[0]) { - // load module code - this loads each module only once and up `getModule` so that it can be used sychronously. - const cachedModules = await preloadModules(remotesWithLoadedAssets); + // load module code - this loads each module only once and up `getModule` so that it can be used sychronously. + const cachedModules = await preloadModules(stripes, remotesWithLoadedAssets); - const combinedModules = { ...configModules, ...cachedModules }; + const combinedModules = { ...configModules, ...cachedModules }; - setModules(combinedModules); - } + setModules(combinedModules); + } catch (e) { + // eslint-disable-next-line no-console + stripes.logger.log('core', `error loading remote modules: ${e}`); } }; diff --git a/src/loginServices.js b/src/loginServices.js index 8c6356216..526e3c974 100644 --- a/src/loginServices.js +++ b/src/loginServices.js @@ -302,8 +302,9 @@ export async function loadTranslations(store, locale, defaultTranslations = {}) // Here we put additional condition because languages // like Japan we need to use like ja, but with numeric system // Japan language builds like ja_u, that incorrect. We need to be safe from that bug. - const translationsUrl = translations[region] ?? (translations[loadedLocale] || translations[parentLocale]); - return fetch(translationsUrl) + const translationsUrl = translations[region] ? translations[region] : + translations[loadedLocale] || translations[[parentLocale]]; + const res = await fetch(translationsUrl) .then((response) => { if (response.ok) { return response.json().then((stripesTranslations) => { @@ -311,9 +312,10 @@ export async function loadTranslations(store, locale, defaultTranslations = {}) store.dispatch(setLocale(locale)); }); } else { - return Promise.reject(new Error(`Could not load translations from ${translationsUrl}`)); + throw new Error(`Could not load translations from ${translationsUrl}`); } }); + return res; } /** diff --git a/src/okapiReducer.js b/src/okapiReducer.js index ecf6a30fb..6b5d90b59 100644 --- a/src/okapiReducer.js +++ b/src/okapiReducer.js @@ -89,7 +89,7 @@ export default function okapiReducer(state = {}, action) { case OKAPI_REDUCER_ACTIONS.SET_AUTH_FAILURE: return Object.assign({}, state, { authFailure: action.message }); case OKAPI_REDUCER_ACTIONS.SET_TRANSLATIONS: - return { ...state, translations: { ...state.translations, ...action.translations } }; + return Object.assign({}, state, { translations: action.translations }); case OKAPI_REDUCER_ACTIONS.CHECK_SSO: return Object.assign({}, state, { ssoEnabled: action.ssoEnabled }); case OKAPI_REDUCER_ACTIONS.OKAPI_READY: From d7d102448f1e24a995d52eaba57d43845c6768bd Mon Sep 17 00:00:00 2001 From: John Coburn Date: Fri, 5 Dec 2025 10:01:05 -0600 Subject: [PATCH 27/59] okapi ranslations reducer should accumulate --- src/AppRoutes.js | 64 +++++++++++++++++++++----------------------- src/gatherActions.js | 9 +++++++ src/loginServices.js | 3 +-- src/okapiReducer.js | 2 +- 4 files changed, 42 insertions(+), 36 deletions(-) diff --git a/src/AppRoutes.js b/src/AppRoutes.js index e886c88af..366d0ca11 100644 --- a/src/AppRoutes.js +++ b/src/AppRoutes.js @@ -49,41 +49,39 @@ const AppRoutes = ({ modules, stripes }) => { }, [modules.app, stripes]); return cachedModules.map(({ ModuleComponent, connect, module, name, moduleStripes, stripes: propsStripes, displayName }) => ( - }> - { - const data = { displayName, name }; + { + const data = { displayName, name }; - // allow SELECT_MODULE handlers to intervene - const handlerComponents = getEventHandlers(events.SELECT_MODULE, moduleStripes, modules.handler, data); - if (handlerComponents.length) { - return handlerComponents.map(Handler => ()); - } + // allow SELECT_MODULE handlers to intervene + const handlerComponents = getEventHandlers(events.SELECT_MODULE, moduleStripes, modules.handler, data); + if (handlerComponents.length) { + return handlerComponents.map(Handler => ()); + } - return ( - - -
- - - }> - - - - -
-
-
- ); - }} - /> -
+ return ( + + +
+ + + }> + + + + +
+
+
+ ); + }} + /> )); }; diff --git a/src/gatherActions.js b/src/gatherActions.js index b794027ef..f98e6516b 100644 --- a/src/gatherActions.js +++ b/src/gatherActions.js @@ -14,6 +14,15 @@ function addKeys(moduleName, register, list) { export default function gatherActions(modules) { const allActions = {}; + + for (const key of Object.keys(modules)) { + const set = modules[key]; + for (const key2 of Object.keys(set)) { + const module = set[key2]; + addKeys(module.module, allActions, module.actionNames); + } + } + addKeys('stripes-components', allActions, (stripesComponents.stripes || {}).actionNames); return Object.keys(allActions); diff --git a/src/loginServices.js b/src/loginServices.js index 526e3c974..22c784301 100644 --- a/src/loginServices.js +++ b/src/loginServices.js @@ -304,7 +304,7 @@ export async function loadTranslations(store, locale, defaultTranslations = {}) // Japan language builds like ja_u, that incorrect. We need to be safe from that bug. const translationsUrl = translations[region] ? translations[region] : translations[loadedLocale] || translations[[parentLocale]]; - const res = await fetch(translationsUrl) + await fetch(translationsUrl) .then((response) => { if (response.ok) { return response.json().then((stripesTranslations) => { @@ -315,7 +315,6 @@ export async function loadTranslations(store, locale, defaultTranslations = {}) throw new Error(`Could not load translations from ${translationsUrl}`); } }); - return res; } /** diff --git a/src/okapiReducer.js b/src/okapiReducer.js index 6b5d90b59..1a6338948 100644 --- a/src/okapiReducer.js +++ b/src/okapiReducer.js @@ -89,7 +89,7 @@ export default function okapiReducer(state = {}, action) { case OKAPI_REDUCER_ACTIONS.SET_AUTH_FAILURE: return Object.assign({}, state, { authFailure: action.message }); case OKAPI_REDUCER_ACTIONS.SET_TRANSLATIONS: - return Object.assign({}, state, { translations: action.translations }); + return Object.assign({}, state, { translations: { ...state.translations, ...action.translations } }); case OKAPI_REDUCER_ACTIONS.CHECK_SSO: return Object.assign({}, state, { ssoEnabled: action.ssoEnabled }); case OKAPI_REDUCER_ACTIONS.OKAPI_READY: From 561160e1162fac7ffb058609a3631c3cabf13e3f Mon Sep 17 00:00:00 2001 From: John Coburn Date: Fri, 5 Dec 2025 10:28:10 -0600 Subject: [PATCH 28/59] more sync with main, sonar guidance --- package.json | 2 +- src/App.js | 2 +- src/AppRoutes.js | 3 +-- src/components/EntitlementLoader.js | 4 ++-- src/components/Root/Root.js | 2 +- src/loginServices.js | 11 +++++------ src/okapiReducer.js | 2 +- src/translateModules.js | 0 8 files changed, 12 insertions(+), 14 deletions(-) create mode 100644 src/translateModules.js diff --git a/package.json b/package.json index 8c7ea637d..80bdf610f 100644 --- a/package.json +++ b/package.json @@ -136,4 +136,4 @@ "redux-observable": "^1.2.0", "rxjs": "^6.6.3" } -} \ No newline at end of file +} diff --git a/src/App.js b/src/App.js index 23e5eb919..db89ff325 100644 --- a/src/App.js +++ b/src/App.js @@ -2,7 +2,6 @@ import React, { Component, StrictMode } from 'react'; import PropTypes from 'prop-types'; import { okapi as okapiConfig, config } from 'stripes-config'; import merge from 'lodash/merge'; -import { modulesInitialState } from './ModulesContext'; import AppConfigError from './components/AppConfigError'; import connectErrorEpic from './connectErrorEpic'; @@ -12,6 +11,7 @@ import configureStore from './configureStore'; import gatherActions from './gatherActions'; import { destroyStore } from './mainActions'; import { getModules } from './entitlementService'; +import { modulesInitialState } from './ModulesContext'; import css from './components/SessionEventContainer/style.css'; import Root from './components/Root'; diff --git a/src/AppRoutes.js b/src/AppRoutes.js index 366d0ca11..f24755a57 100644 --- a/src/AppRoutes.js +++ b/src/AppRoutes.js @@ -1,4 +1,4 @@ -import React, { useMemo, Suspense } from 'react'; +import { Suspense, useMemo } from 'react'; import { Route } from 'react-router-dom'; import PropTypes from 'prop-types'; @@ -26,7 +26,6 @@ const AppRoutes = ({ modules, stripes }) => { const connect = connectFor(module.module, stripes.epics, stripes.logger); let ModuleComponent; - try { ModuleComponent = connect(module.getModule()); } catch (error) { diff --git a/src/components/EntitlementLoader.js b/src/components/EntitlementLoader.js index c0a6b8642..3bc5679dd 100644 --- a/src/components/EntitlementLoader.js +++ b/src/components/EntitlementLoader.js @@ -77,7 +77,7 @@ const loadTranslations = (stripes, module) => { return tx; }); } else { - throw new Error(`Could not load translations for ${module}`); + throw new Error(`Could not load translations for ${module.name}`); } }); }; @@ -93,7 +93,7 @@ const loadTranslations = (stripes, module) => { * @returns {void} */ const loadIcons = (stripes, module) => { - if (module.icons && module.icons.length) { + if (module.icons?.length) { stripes.logger.log('core', `loading icons for ${module.module}`); module.icons.forEach(i => { stripes.logger.log('core', ` > ${i.name}`); diff --git a/src/components/Root/Root.js b/src/components/Root/Root.js index 93ddf5e4f..84bbf5941 100644 --- a/src/components/Root/Root.js +++ b/src/components/Root/Root.js @@ -9,7 +9,7 @@ import { QueryClientProvider } from 'react-query'; import { ApolloProvider } from '@apollo/client'; import { ErrorBoundary } from '@folio/stripes-components'; -import { metadata, icons } from 'stripes-config'; +import { metadata } from 'stripes-config'; import { ConnectContext } from '@folio/stripes-connect'; import initialReducers from '../../initialReducers'; diff --git a/src/loginServices.js b/src/loginServices.js index 22c784301..275ab29de 100644 --- a/src/loginServices.js +++ b/src/loginServices.js @@ -302,19 +302,18 @@ export async function loadTranslations(store, locale, defaultTranslations = {}) // Here we put additional condition because languages // like Japan we need to use like ja, but with numeric system // Japan language builds like ja_u, that incorrect. We need to be safe from that bug. - const translationsUrl = translations[region] ? translations[region] : - translations[loadedLocale] || translations[[parentLocale]]; - await fetch(translationsUrl) + const res = await fetch(translations[region] ? translations[region] : + translations[loadedLocale] || translations[[parentLocale]]) .then((response) => { if (response.ok) { - return response.json().then((stripesTranslations) => { + response.json().then((stripesTranslations) => { store.dispatch(setTranslations(Object.assign(stripesTranslations, defaultTranslations))); store.dispatch(setLocale(locale)); }); - } else { - throw new Error(`Could not load translations from ${translationsUrl}`); } }); + + return res; } /** diff --git a/src/okapiReducer.js b/src/okapiReducer.js index 1a6338948..ecf6a30fb 100644 --- a/src/okapiReducer.js +++ b/src/okapiReducer.js @@ -89,7 +89,7 @@ export default function okapiReducer(state = {}, action) { case OKAPI_REDUCER_ACTIONS.SET_AUTH_FAILURE: return Object.assign({}, state, { authFailure: action.message }); case OKAPI_REDUCER_ACTIONS.SET_TRANSLATIONS: - return Object.assign({}, state, { translations: { ...state.translations, ...action.translations } }); + return { ...state, translations: { ...state.translations, ...action.translations } }; case OKAPI_REDUCER_ACTIONS.CHECK_SSO: return Object.assign({}, state, { ssoEnabled: action.ssoEnabled }); case OKAPI_REDUCER_ACTIONS.OKAPI_READY: diff --git a/src/translateModules.js b/src/translateModules.js new file mode 100644 index 000000000..e69de29bb From 1b7d16211f09c8cb5c8c8930c9cca841e146eafa Mon Sep 17 00:00:00 2001 From: John Coburn Date: Fri, 5 Dec 2025 10:42:25 -0600 Subject: [PATCH 29/59] re-add init, remove bootstrap so that main branch of stripes-ui is okay --- bootstrap.js | 10 ---------- index.js | 1 + src/init.js | 12 ++++++++++++ 3 files changed, 13 insertions(+), 10 deletions(-) delete mode 100644 bootstrap.js create mode 100644 src/init.js diff --git a/bootstrap.js b/bootstrap.js deleted file mode 100644 index bdb96b828..000000000 --- a/bootstrap.js +++ /dev/null @@ -1,10 +0,0 @@ -import 'core-js/stable'; -import 'regenerator-runtime/runtime'; -import React from 'react'; -import { createRoot } from 'react-dom/client'; - -import App from './src/App'; - -const container = document.getElementById('root'); -const root = createRoot(container); -root.render(); diff --git a/index.js b/index.js index f9f1ea443..8eeb5d2cc 100644 --- a/index.js +++ b/index.js @@ -57,6 +57,7 @@ export { tenantLocaleConfig } from './src/loginServices'; export { getFullLocale } from './src/loginServices'; export * from './src/consortiaServices'; export { default as queryLimit } from './src/queryLimit'; +export { default as init } from './src/init'; /* localforage wrappers hide the session key */ export { getOkapiSession, getTokenExpiry, setTokenExpiry } from './src/loginServices'; diff --git a/src/init.js b/src/init.js new file mode 100644 index 000000000..ebf891cce --- /dev/null +++ b/src/init.js @@ -0,0 +1,12 @@ +import 'core-js/stable'; +import 'regenerator-runtime/runtime'; +import React from 'react'; +import { createRoot } from 'react-dom/client'; + +import App from './App'; + +export default function init() { + const container = document.getElementById('root'); + const root = createRoot(container); + root.render(); +} From c7c82c1b8909aa88669aa3bd44f31450116a76a2 Mon Sep 17 00:00:00 2001 From: John Coburn Date: Fri, 5 Dec 2025 13:26:05 -0600 Subject: [PATCH 30/59] remove entitlement request from permissible in rtr logic --- src/components/Root/token-util.js | 1 - 1 file changed, 1 deletion(-) diff --git a/src/components/Root/token-util.js b/src/components/Root/token-util.js index 4aa5ed519..2ea9097e5 100644 --- a/src/components/Root/token-util.js +++ b/src/components/Root/token-util.js @@ -90,7 +90,6 @@ export const isAuthenticationRequest = (resource, oUrl) => { '/bl-users/login-with-expiry', '/bl-users/_self', '/users-keycloak/_self', - '/registry' ]; return !!permissible.find(i => string.startsWith(`${oUrl}${i}`)); From 5de79ed5cd77190ab73bb543466a75435c44480b Mon Sep 17 00:00:00 2001 From: John Coburn Date: Fri, 12 Dec 2025 14:20:41 -0600 Subject: [PATCH 31/59] add test for EntitlementLoader --- src/components/EntitlementLoader.js | 20 +- src/components/EntitlementLoader.test.js | 431 +++++++++++++++++++++++ src/components/loadEntitlement.js | 3 + 3 files changed, 447 insertions(+), 7 deletions(-) create mode 100644 src/components/EntitlementLoader.test.js create mode 100644 src/components/loadEntitlement.js diff --git a/src/components/EntitlementLoader.js b/src/components/EntitlementLoader.js index 3bc5679dd..ad2713ce0 100644 --- a/src/components/EntitlementLoader.js +++ b/src/components/EntitlementLoader.js @@ -4,7 +4,7 @@ import { okapi } from 'stripes-config'; import { useStripes } from '../StripesContext'; import { ModulesContext, useModules } from '../ModulesContext'; import loadRemoteComponent from '../loadRemoteComponent'; - +import { loadEntitlement } from './loadEntitlement'; /** * preloadModules * Loads each module code and sets up its getModule function. @@ -16,7 +16,7 @@ import loadRemoteComponent from '../loadRemoteComponent'; * @returns {app: [], plugin: [], settings: [], handler: []} */ -const preloadModules = async (stripes, remotes) => { +export const preloadModules = async (stripes, remotes) => { const modules = { app: [], plugin: [], settings: [], handler: [] }; try { @@ -116,7 +116,7 @@ const loadIcons = (stripes, module) => { * @param {object} module info read from the registry * @returns {} copy of the module, plus the key `displayName` containing its localized name */ -const loadModuleAssets = (stripes, module) => { +export const loadModuleAssets = (stripes, module) => { // register icons loadIcons(stripes, module); @@ -141,15 +141,16 @@ const loadModuleAssets = (stripes, module) => { } } - return { + const adjustedModule = { ...module, displayName: module.displayName ? newDisplayName : module.module, }; + return adjustedModule; }) .catch(e => { // eslint-disable-next-line no-console - console.error(e); + stripes.logger.log('core', `Error loading assets for ${module.name}: ${e.message || e}`); }); }; @@ -180,7 +181,11 @@ const EntitlementLoader = ({ children }) => { if (okapi.entitlementUrl) { const fetchRegistry = async () => { // read the list of registered apps - const registry = await fetch(okapi.entitlementUrl).then((response) => response.json()); + const registry = await loadEntitlement(okapi.entitlementUrl).then((response) => response.json()).catch((e) => { + const errorMsg = `Error fetching entitlement registry from ${okapi.entitlementUrl}: ${e}`; + stripes.logger.log('core', errorMsg); + throw new Error(errorMsg); + }); // remap registry from an object shaped like { key1: app1, key2: app2, ...} // to an array shaped like [ { name: key1, ...app1 }, { name: key2, ...app2 } ...] @@ -193,7 +198,8 @@ const EntitlementLoader = ({ children }) => { // load module code - this loads each module only once and up `getModule` so that it can be used sychronously. const cachedModules = await preloadModules(stripes, remotesWithLoadedAssets); - const combinedModules = { ...configModules, ...cachedModules }; + const combinedModules = {}; + Object.keys(configModules).forEach(key => { combinedModules[key] = [...configModules[key], ...cachedModules[key]]; }); setModules(combinedModules); } catch (e) { diff --git a/src/components/EntitlementLoader.test.js b/src/components/EntitlementLoader.test.js new file mode 100644 index 000000000..1571a90f6 --- /dev/null +++ b/src/components/EntitlementLoader.test.js @@ -0,0 +1,431 @@ +import React from 'react'; +import { render, screen, waitFor } from '@folio/jest-config-stripes/testing-library/react'; +import { okapi } from 'stripes-config'; +import EntitlementLoader, { preloadModules, loadModuleAssets } from './EntitlementLoader'; +import { StripesContext } from '../StripesContext'; +import { ModulesContext, useModules, modulesInitialState as mockModuleInitialState } from '../ModulesContext'; +import loadRemoteComponent from '../loadRemoteComponent'; +import { loadEntitlement } from './loadEntitlement'; + +jest.mock('stripes-config'); +jest.mock('./loadEntitlement', () => ({ + loadEntitlement: jest.fn() +})); +jest.mock('../loadRemoteComponent'); + +class ErrorBoundary extends React.Component { + constructor(props) { + super(props); + this.state = { + errorMessage: null, + }; + } + + componentDidCatch(error) { + this.setState({ errorMessage: error.toString() }); + } + + render() { + const { errorMessage } = this.state; + if (errorMessage) { + // You can render any custom fallback UI + return ({errorMessage}); + } + + return this.props.children; + } +} + +describe('EntitlementLoader', () => { + const mockStripes = { + logger: { + log: jest.fn((msg, err) => console.log(err)), + }, + locale: 'en-US', + okapi: { + translations: {}, + }, + setTranslations: jest.fn(), + setLocale: jest.fn(), + addIcon: jest.fn(), + }; + + const mockRegistry = { + remotes: { + 'app-module': { + url: 'http://localhost:3000/remoteEntry.js', + host: 'localhost', + port: 3000, + module: 'app-module', + displayName: 'appModule.label', + actsAs: ['app'], + }, + 'plugin-module': { + url: 'http://localhost:3001/remoteEntry.js', + host: 'localhost', + port: 3001, + module: 'plugin-module', + displayName: 'pluginModule.label', + actsAs: ['plugin'], + }, + }, + }; + + const translations = { + 'testModule.label': 'Test Module Display', + 'appModule.label': 'App Module Display', + 'pluginModule.label': 'Plugin Module Display', + }; + // const mockLoadedModules = { + // app: [ + // { + // name: 'app-module', + // url: 'http://localhost:3000/remoteEntry.js', + // host: 'localhost', + // port: 3000, + // module: 'app-module', + // displayName: 'App Module', + // actsAs: ['app'], + // getModule: jest.fn(() => ({ default: {} })), + // }, + // ], + // plugin: [ + // { + // name: 'plugin-module', + // url: 'http://localhost:3001/remoteEntry.js', + // host: 'localhost', + // port: 3001, + // module: 'plugin-module', + // displayName: 'Plugin Module', + // actsAs: ['plugin'], + // getModule: jest.fn(() => ({ default: {} })), + // }, + // ], + // settings: [], + // handler: [], + // }; + + const configModules = { + app: [ + { + name: 'config-app', + module: 'config-app', + displayName: 'Config App', + }, + ], + plugin: [], + settings: [], + handler: [], + }; + + const TestComponent = ({ children }) => { + const modules = useModules(); + const noModules = modules === undefined || Object.keys(modules).every(key => modules[key].length === 0); + if (noModules) { + return (
No Modules
); + } + return ( + <> +
Modules Loaded
+
    + {Object.keys(modules).map(key => ( +
  • + {key} +
      + {modules[key].map((mod, idx) => ( +
    • {mod.name}
    • + ))} +
    +
  • + ))} +
+ {children} + ); + }; + + const TestHarness = ({ children, testStripes = mockStripes, testModulesContext = mockModuleInitialState }) => ( + + + + + + {children} + + + + + + ); + + beforeEach(() => { + global.fetch = jest.fn(); + loadEntitlement.mockResolvedValueOnce({ + ok: true, + json: jest.fn().mockResolvedValue(mockRegistry) + }); + loadRemoteComponent.mockResolvedValue({ default: {} }); + }); + + afterEach(() => { + jest.clearAllMocks(); + }); + + afterAll(() => { + jest.restoreAllMocks(); + }); + + describe('when entitlementUrl is configured', () => { + let capturedModules = null; + const TestContextComponent = () => { + capturedModules = useModules(); + return null; + }; + + beforeEach(() => { + capturedModules = null; + okapi.entitlementUrl = 'http://localhost:8000/entitlement'; + global.fetch = jest.fn(); + // two modules in mock, two calls to fetch translations... + global.fetch.mockResolvedValueOnce({ + ok: true, + json: jest.fn().mockResolvedValueOnce(translations) + }).mockResolvedValueOnce({ + ok: true, + json: jest.fn().mockResolvedValueOnce(translations) + }); + }); + + it('fetches the registry and loads modules dynamically', async () => { + render(); + + await waitFor(() => { + expect(loadEntitlement).toHaveBeenCalledWith('http://localhost:8000/entitlement'); + }); + }); + + it('passes dynamic modules to ModulesContext.Provider', async () => { + render(); + + await waitFor(() => { + expect(screen.queryByText('No Modules')).not.toBeInTheDocument(); + expect(screen.getByText('Modules Loaded')).toBeInTheDocument(); + expect(screen.getByText('app')).toBeInTheDocument(); + }); + }); + + it('merges config modules with dynamically loaded modules', async () => { + render( + + + + ); + + await waitFor(() => { + expect(capturedModules).not.toBeNull(); + expect(capturedModules.app).toBeDefined(); + }); + }); + + it('handles errors during module loading gracefully', async () => { + global.fetch.mockRejectedValueOnce(new Error('Network error')); + + try { + render( + +
Content
+
+ ); + } catch (e) { + await waitFor(() => { + expect(mockStripes.logger.log).toHaveBeenCalled(); + }); + } + }); + }); + + describe('when entitlementUrl is not configured', () => { + let capturedModules = null; + const ContextTestComponent = () => { + capturedModules = React.useContext(ModulesContext); + return null; + }; + + beforeEach(() => { + capturedModules = null; + okapi.entitlementUrl = undefined; + }); + + it('does not fetch the registry', async () => { + render( + +
Content
+
+ ); + + await waitFor(() => { + expect(loadEntitlement).not.toHaveBeenCalled(); + }); + }); + + it('passes through configModules to ModulesContext when no entitlementUrl', async () => { + render( + + + + ); + + await waitFor(() => { + expect(capturedModules).toEqual(configModules); + }); + }); + }); + + describe('children rendering', () => { + it('renders children when modules are available', async () => { + okapi.entitlementUrl = undefined; + + render( + +
Test Content
+
+ ); + + await waitFor(() => { + expect(screen.getByText('Test Content')).toBeInTheDocument(); + }); + }); + }); + + describe('preloadModules', () => { + it('loads remote components and builds module structure', async () => { + const remotes = [ + { + name: 'app-module', + url: 'http://localhost:3000/remoteEntry.js', + actsAs: ['app'], + }, + { + name: 'plugin-module', + url: 'http://localhost:3001/remoteEntry.js', + actsAs: ['plugin'], + }, + ]; + + const result = await preloadModules(mockStripes, remotes); + + expect(loadRemoteComponent).toHaveBeenCalledTimes(2); + expect(result).toHaveProperty('app'); + expect(result).toHaveProperty('plugin'); + expect(result.app.length).toBe(1); + expect(result.plugin.length).toBe(1); + }); + + it('assigns getModule function to loaded modules', async () => { + const remotes = [ + { + name: 'app-module', + url: 'http://localhost:3000/remoteEntry.js', + actsAs: ['app'], + }, + ]; + + const result = await preloadModules(mockStripes, remotes); + + expect(result.app[0]).toHaveProperty('getModule'); + expect(typeof result.app[0].getModule).toBe('function'); + }); + + it('handles loading errors gracefully', async () => { + const remotes = [ + { + name: 'app-module', + url: 'http://localhost:3000/remoteEntry.js', + actsAs: ['app'], + }, + ]; + + loadRemoteComponent.mockRejectedValueOnce(new Error('Load failed')); + + await preloadModules(mockStripes, remotes); + + expect(mockStripes.logger.log).toHaveBeenCalledWith( + 'core', + expect.stringContaining('Error preloading modules') + ); + }); + }); + + describe('loadModuleAssets', () => { + const module = { + name: 'test-module', + host: 'localhost', + port: 3000, + module: 'test-module', + displayName: 'testModule.label', + }; + + beforeEach(() => { + jest.clearAllMocks(); + global.fetch = jest.fn(); + }); + + it('loads translations for a module', async () => { + global.fetch.mockResolvedValueOnce({ + ok: true, + json: jest.fn().mockResolvedValueOnce(translations), + }); + + const result = await loadModuleAssets(mockStripes, module); + + expect(global.fetch).toHaveBeenCalledWith( + 'localhost:3000/translations/en_US.json' + ); + expect(result.displayName).toBe('Test Module Display'); + }); + + it('handles array translation values with messageFormatPattern', async () => { + const msgFormatTranslations = { + 'testModule.label': [ + { type: 'messageFormatPattern', value: 'Test Module Pattern' }, + ], + }; + + global.fetch.mockResolvedValueOnce({ + ok: true, + json: jest.fn().mockResolvedValueOnce(msgFormatTranslations), + }); + + const result = await loadModuleAssets(mockStripes, module); + + expect(result.displayName).toBe('Test Module Pattern'); + }); + + it('handles translation fetch errors', async () => { + global.fetch.mockResolvedValueOnce({ + ok: false, + }); + + const result = await loadModuleAssets(mockStripes, module); + + expect(mockStripes.logger.log).toHaveBeenCalledWith('core', 'Error loading assets for test-module: Could not load translations for test-module'); + }); + + it('converts kebab-case locale to snake_case for translations', async () => { + const stripesWithLocale = { + ...mockStripes, + locale: 'en-US-u-nu-latn', + }; + + global.fetch.mockResolvedValueOnce({ + ok: true, + json: jest.fn().mockResolvedValueOnce({ 'testModule.label': 'Test' }), + }); + + await loadModuleAssets(stripesWithLocale, module); + + expect(global.fetch).toHaveBeenCalledWith( + expect.stringContaining('en_US') + ); + }); + }); +}); diff --git a/src/components/loadEntitlement.js b/src/components/loadEntitlement.js new file mode 100644 index 000000000..74703f5bf --- /dev/null +++ b/src/components/loadEntitlement.js @@ -0,0 +1,3 @@ +export const loadEntitlement = (entitlementUrl) => { + return fetch(entitlementUrl); +}; From 72131bd4945798ee1bae154fea5b1c84507f9aec Mon Sep 17 00:00:00 2001 From: John Coburn Date: Mon, 15 Dec 2025 14:40:46 -0600 Subject: [PATCH 32/59] add test for loadRemoteComponent --- .../bigtest/tests/LoadRemoteComponent-test.js | 39 +++++++++++++++++++ 1 file changed, 39 insertions(+) create mode 100644 test/bigtest/tests/LoadRemoteComponent-test.js diff --git a/test/bigtest/tests/LoadRemoteComponent-test.js b/test/bigtest/tests/LoadRemoteComponent-test.js new file mode 100644 index 000000000..c2fdf1fad --- /dev/null +++ b/test/bigtest/tests/LoadRemoteComponent-test.js @@ -0,0 +1,39 @@ +import { beforeEach, it, describe } from 'mocha'; +import { expect } from 'chai'; +import setupApplication from '../helpers/setup-application-components'; +import loadRemoteComponent from '../../../src/loadRemoteComponent'; + +describe('loadRemoteComponent', () => { + setupApplication(); + const mockRemoteUrl = 'http://example.com/testRemote/remoteEntry.js'; + const mockErrorUrl = 'http://example.com/nonexistent/remoteEntry.js'; + + const mockRemoteName = 'testComponent'; + + beforeEach(async function () { + this.server.get(mockRemoteUrl, () => { + const mockScriptContent = `window['${mockRemoteName}'] = { + init: function() { console.log("Component initialized"); }, + get: function() { return function() { return { default: 'I am a module' }; }} + }; + `; + + return mockScriptContent; + }); + + this.server.get(mockErrorUrl, () => new Response(404)); + }); + + it('should load and evaluate the remote script', async () => { + await loadRemoteComponent(mockRemoteUrl, mockRemoteName); + expect(window[mockRemoteName]).to.be.an('object'); + }); + + it('should return the component from the remote script', async () => { + try { + await loadRemoteComponent(mockErrorUrl, mockRemoteName); + } catch (error) { + expect(error.message).to.equal('Failed to load remote component'); + } + }); +}); From 5bfba3e5764813d3d11a057062171fb1a974b25e Mon Sep 17 00:00:00 2001 From: John Coburn Date: Mon, 15 Dec 2025 14:55:00 -0600 Subject: [PATCH 33/59] add icon to registry mock --- src/components/EntitlementLoader.test.js | 1 + 1 file changed, 1 insertion(+) diff --git a/src/components/EntitlementLoader.test.js b/src/components/EntitlementLoader.test.js index 1571a90f6..f0e9c3a05 100644 --- a/src/components/EntitlementLoader.test.js +++ b/src/components/EntitlementLoader.test.js @@ -59,6 +59,7 @@ describe('EntitlementLoader', () => { module: 'app-module', displayName: 'appModule.label', actsAs: ['app'], + icons: [{ name: 'icon', title: 'icon title' }], }, 'plugin-module': { url: 'http://localhost:3001/remoteEntry.js', From 795ad2c030da0233ad3779ddc5314ba14d00ac98 Mon Sep 17 00:00:00 2001 From: John Coburn Date: Mon, 15 Dec 2025 15:11:04 -0600 Subject: [PATCH 34/59] add test for loadEntitlement --- src/components/EntitlementLoader.test.js | 38 +++++++----------------- 1 file changed, 10 insertions(+), 28 deletions(-) diff --git a/src/components/EntitlementLoader.test.js b/src/components/EntitlementLoader.test.js index f0e9c3a05..a3a1c9d77 100644 --- a/src/components/EntitlementLoader.test.js +++ b/src/components/EntitlementLoader.test.js @@ -77,34 +77,6 @@ describe('EntitlementLoader', () => { 'appModule.label': 'App Module Display', 'pluginModule.label': 'Plugin Module Display', }; - // const mockLoadedModules = { - // app: [ - // { - // name: 'app-module', - // url: 'http://localhost:3000/remoteEntry.js', - // host: 'localhost', - // port: 3000, - // module: 'app-module', - // displayName: 'App Module', - // actsAs: ['app'], - // getModule: jest.fn(() => ({ default: {} })), - // }, - // ], - // plugin: [ - // { - // name: 'plugin-module', - // url: 'http://localhost:3001/remoteEntry.js', - // host: 'localhost', - // port: 3001, - // module: 'plugin-module', - // displayName: 'Plugin Module', - // actsAs: ['plugin'], - // getModule: jest.fn(() => ({ default: {} })), - // }, - // ], - // settings: [], - // handler: [], - // }; const configModules = { app: [ @@ -428,5 +400,15 @@ describe('EntitlementLoader', () => { expect.stringContaining('en_US') ); }); + + it('loadEntitlement calls fetch', async () => { + const actualLoadEntitlement = jest.requireActual('./loadEntitlement').loadEntitlement; + global.fetch.mockResolvedValueOnce({ + ok: true, + json: jest.fn().mockResolvedValueOnce(mockRegistry), + }); + await actualLoadEntitlement(okapi.entitlementUrl); + expect(fetch).toHaveBeenCalledWith(okapi.entitlementUrl); + }); }); }); From 81125bdbf6043fc2db02ef9d4ff4663152ae5b1a Mon Sep 17 00:00:00 2001 From: John Coburn Date: Mon, 15 Dec 2025 15:53:38 -0600 Subject: [PATCH 35/59] modify test for setTranslations reducer to handle merge logic --- src/okapiReducer.test.js | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/okapiReducer.test.js b/src/okapiReducer.test.js index 424597c48..52974bd63 100644 --- a/src/okapiReducer.test.js +++ b/src/okapiReducer.test.js @@ -305,10 +305,10 @@ describe('okapiReducer', () => { }); it('SET_TRANSLATIONS', () => { - const state = { translations: 'fred' }; - const translations = 'george'; + const state = { translations: { 'fred': 'Fredrick' } }; + const translations = { 'george': 'George', 'fred': 'Freddy' }; const o = okapiReducer(state, setTranslations(translations)); - expect(o).toMatchObject({ translations }); + expect(o).toMatchObject({ ...state.translations, ...translations }); }); it('CHECK_SSO', () => { From 5d975903293c1f90aac46c0bdd98c8fd058a23cd Mon Sep 17 00:00:00 2001 From: John Coburn Date: Mon, 15 Dec 2025 16:08:30 -0600 Subject: [PATCH 36/59] correct okapiReducer test --- src/okapiReducer.test.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/okapiReducer.test.js b/src/okapiReducer.test.js index 52974bd63..2d7b121f5 100644 --- a/src/okapiReducer.test.js +++ b/src/okapiReducer.test.js @@ -308,7 +308,7 @@ describe('okapiReducer', () => { const state = { translations: { 'fred': 'Fredrick' } }; const translations = { 'george': 'George', 'fred': 'Freddy' }; const o = okapiReducer(state, setTranslations(translations)); - expect(o).toMatchObject({ ...state.translations, ...translations }); + expect(o.translations).toMatchObject({ ...state.translations, ...translations }); }); it('CHECK_SSO', () => { From 62bdaf43612335a982c83649889dccfb74b50b73 Mon Sep 17 00:00:00 2001 From: John Coburn Date: Tue, 16 Dec 2025 09:15:22 -0600 Subject: [PATCH 37/59] merge config and remote modules at render so updates to configModules are accounted for. --- src/components/EntitlementLoader.js | 16 ++++++++-------- 1 file changed, 8 insertions(+), 8 deletions(-) diff --git a/src/components/EntitlementLoader.js b/src/components/EntitlementLoader.js index ad2713ce0..6a0111a18 100644 --- a/src/components/EntitlementLoader.js +++ b/src/components/EntitlementLoader.js @@ -2,7 +2,7 @@ import { useEffect, useState } from 'react'; import PropTypes from 'prop-types'; import { okapi } from 'stripes-config'; import { useStripes } from '../StripesContext'; -import { ModulesContext, useModules } from '../ModulesContext'; +import { ModulesContext, useModules, modulesInitialState } from '../ModulesContext'; import loadRemoteComponent from '../loadRemoteComponent'; import { loadEntitlement } from './loadEntitlement'; /** @@ -173,7 +173,7 @@ const loadAllModuleAssets = async (stripes, remotes) => { const EntitlementLoader = ({ children }) => { const stripes = useStripes(); const configModules = useModules(); - const [modules, setModules] = useState(configModules); + const [remoteModules, setRemoteModules] = useState(modulesInitialState); // if platform is configured for module federation, read the list of registered apps from // localstorage, okapi, direct call to registry endpoint? @@ -198,10 +198,7 @@ const EntitlementLoader = ({ children }) => { // load module code - this loads each module only once and up `getModule` so that it can be used sychronously. const cachedModules = await preloadModules(stripes, remotesWithLoadedAssets); - const combinedModules = {}; - Object.keys(configModules).forEach(key => { combinedModules[key] = [...configModules[key], ...cachedModules[key]]; }); - - setModules(combinedModules); + setRemoteModules(cachedModules); } catch (e) { // eslint-disable-next-line no-console stripes.logger.log('core', `error loading remote modules: ${e}`); @@ -214,9 +211,12 @@ const EntitlementLoader = ({ children }) => { // eslint-disable-next-line react-hooks/exhaustive-deps }, []); + const combinedModules = {}; + Object.keys(configModules).forEach(key => { combinedModules[key] = [...configModules[key], ...remoteModules[key]]; }); + return ( - - {modules ? children : null} + + {combinedModules ? children : null} ); }; From bd109580afe27b5a8803accf9e1d1ca15ad6d033 Mon Sep 17 00:00:00 2001 From: John Coburn Date: Tue, 16 Dec 2025 09:47:14 -0600 Subject: [PATCH 38/59] import icons from stripes-config if there are any --- src/components/Root/Root.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/components/Root/Root.js b/src/components/Root/Root.js index 84bbf5941..3e76d55dd 100644 --- a/src/components/Root/Root.js +++ b/src/components/Root/Root.js @@ -9,7 +9,7 @@ import { QueryClientProvider } from 'react-query'; import { ApolloProvider } from '@apollo/client'; import { ErrorBoundary } from '@folio/stripes-components'; -import { metadata } from 'stripes-config'; +import { metadata, icons as configIcons } from 'stripes-config'; import { ConnectContext } from '@folio/stripes-connect'; import initialReducers from '../../initialReducers'; From 680e2595093255405811d7488821df4ea28700a7 Mon Sep 17 00:00:00 2001 From: John Coburn Date: Tue, 16 Dec 2025 09:51:39 -0600 Subject: [PATCH 39/59] combine app icons from configuration and props in Root --- src/components/Root/Root.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/components/Root/Root.js b/src/components/Root/Root.js index 3e76d55dd..36d9eef36 100644 --- a/src/components/Root/Root.js +++ b/src/components/Root/Root.js @@ -170,7 +170,7 @@ class Root extends Component { timezone, currency, metadata, - icons, + icons: { ...configIcons, ...icons }, addIcon: (key, icon) => { store.dispatch(addIcon(key, icon)); }, setLocale: (localeValue) => { loadTranslations(store, localeValue, defaultTranslations); }, setTimezone: (timezoneValue) => { store.dispatch(setTimezone(timezoneValue)); }, From 0ea08b863fd6362eae522b58ccf858a105f38c4c Mon Sep 17 00:00:00 2001 From: John Coburn Date: Tue, 16 Dec 2025 12:18:25 -0600 Subject: [PATCH 40/59] wrap expected errors in try/catch --- src/components/EntitlementLoader.test.js | 46 ++++++++++++------------ 1 file changed, 24 insertions(+), 22 deletions(-) diff --git a/src/components/EntitlementLoader.test.js b/src/components/EntitlementLoader.test.js index a3a1c9d77..778f2174e 100644 --- a/src/components/EntitlementLoader.test.js +++ b/src/components/EntitlementLoader.test.js @@ -36,6 +36,19 @@ class ErrorBoundary extends React.Component { } } +const mockConfigModules = { + app: [ + { + name: 'config-app', + module: 'config-app', + displayName: 'Config App', + }, + ], + plugin: [], + settings: [], + handler: [], +}; + describe('EntitlementLoader', () => { const mockStripes = { logger: { @@ -78,19 +91,6 @@ describe('EntitlementLoader', () => { 'pluginModule.label': 'Plugin Module Display', }; - const configModules = { - app: [ - { - name: 'config-app', - module: 'config-app', - displayName: 'Config App', - }, - ], - plugin: [], - settings: [], - handler: [], - }; - const TestComponent = ({ children }) => { const modules = useModules(); const noModules = modules === undefined || Object.keys(modules).every(key => modules[key].length === 0); @@ -180,15 +180,15 @@ describe('EntitlementLoader', () => { render(); await waitFor(() => { - expect(screen.queryByText('No Modules')).not.toBeInTheDocument(); + // expect(screen.queryByText('No Modules')).not.toBeInTheDocument(); expect(screen.getByText('Modules Loaded')).toBeInTheDocument(); expect(screen.getByText('app')).toBeInTheDocument(); - }); + }, { timeout: 1000 }); }); it('merges config modules with dynamically loaded modules', async () => { render( - + ); @@ -242,13 +242,13 @@ describe('EntitlementLoader', () => { it('passes through configModules to ModulesContext when no entitlementUrl', async () => { render( - + ); await waitFor(() => { - expect(capturedModules).toEqual(configModules); + expect(capturedModules).toEqual(mockConfigModules); }); }); }); @@ -258,7 +258,7 @@ describe('EntitlementLoader', () => { okapi.entitlementUrl = undefined; render( - +
Test Content
); @@ -378,9 +378,11 @@ describe('EntitlementLoader', () => { ok: false, }); - const result = await loadModuleAssets(mockStripes, module); - - expect(mockStripes.logger.log).toHaveBeenCalledWith('core', 'Error loading assets for test-module: Could not load translations for test-module'); + try { + await loadModuleAssets(mockStripes, module); + } catch (e) { + expect(mockStripes.logger.log).toHaveBeenCalledWith('core', 'Error loading assets for test-module: Could not load translations for test-module'); + } }); it('converts kebab-case locale to snake_case for translations', async () => { From a8f3a6df7f1c61f6c3b38f9b2f45866ee5edb0a1 Mon Sep 17 00:00:00 2001 From: John Coburn Date: Tue, 16 Dec 2025 12:18:42 -0600 Subject: [PATCH 41/59] convert to async/await --- src/components/EntitlementLoader.js | 132 +++++++++++++++------------- 1 file changed, 71 insertions(+), 61 deletions(-) diff --git a/src/components/EntitlementLoader.js b/src/components/EntitlementLoader.js index 6a0111a18..f27e6a0e8 100644 --- a/src/components/EntitlementLoader.js +++ b/src/components/EntitlementLoader.js @@ -1,4 +1,4 @@ -import { useEffect, useState } from 'react'; +import { useEffect, useState, useMemo } from 'react'; import PropTypes from 'prop-types'; import { okapi } from 'stripes-config'; import { useStripes } from '../StripesContext'; @@ -21,7 +21,7 @@ export const preloadModules = async (stripes, remotes) => { try { const loaderArray = []; - remotes.forEach(async remote => { + remotes.forEach(remote => { const { name, url } = remote; loaderArray.push(loadRemoteComponent(url, name) .then((module) => { @@ -54,32 +54,26 @@ export const preloadModules = async (stripes, remotes) => { * * @returns {Promise} */ -const loadTranslations = (stripes, module) => { +const loadTranslations = async (stripes, module) => { // construct a fully-qualified URL to load. // // locale strings include a name plus optional region and numbering system. - // we only care about the name and region. this stripes the numberin system + // we only care about the name and region. This strips off any numbering system // and converts from kebab-case (the IETF standard) to snake_case (which we // somehow adopted for our files in Lokalise). const locale = stripes.locale.split('-u-nu-')[0].replace('-', '_'); const url = `${module.host}:${module.port}/translations/${locale}.json`; stripes.logger.log('core', `loading ${locale} translations for ${module.name}`); - return fetch(url) - .then((response) => { - if (response.ok) { - return response.json().then((translations) => { - const tx = { ...stripes.okapi.translations, ...translations }; - - stripes.setTranslations(tx); - - stripes.setLocale(stripes.locale, tx); - return tx; - }); - } else { - throw new Error(`Could not load translations for ${module.name}`); - } - }); + const res = await fetch(url); + if (res.ok) { + const fetchedTranslations = await res.json(); + const tx = { ...stripes.okapi.translations, ...fetchedTranslations }; + stripes.setTranslations(tx); + return tx; + } else { + throw new Error(`Could not load translations for ${module.name}`); + } }; /** @@ -116,7 +110,7 @@ const loadIcons = (stripes, module) => { * @param {object} module info read from the registry * @returns {} copy of the module, plus the key `displayName` containing its localized name */ -export const loadModuleAssets = (stripes, module) => { +export const loadModuleAssets = async (stripes, module) => { // register icons loadIcons(stripes, module); @@ -124,34 +118,27 @@ export const loadModuleAssets = (stripes, module) => { // TODO loadSounds(stripes, module); // register translations - return loadTranslations(stripes, module) - .then((tx) => { - // tx[module.displayName] instead of formatMessage({ id: module.displayName}) - // because updating store is async and we don't have the updated values quite yet... - // - // when translations are compiled, the value of the tx[module.displayName] is an array - // containing a single object with shape { type: 'messageFormatPattern', value: 'the actual string' } - // so we have to extract the value from that structure. - let newDisplayName; - if (module.displayName) { - if (typeof tx[module.displayName] === 'string') { - newDisplayName = tx[module.displayName]; - } else { - newDisplayName = tx[module.displayName][0].value; - } + try { + const tx = await loadTranslations(stripes, module); + let newDisplayName; + if (module.displayName) { + if (typeof tx[module.displayName] === 'string') { + newDisplayName = tx[module.displayName]; + } else { + newDisplayName = tx[module.displayName][0].value; } + } - const adjustedModule = { - ...module, - displayName: module.displayName ? - newDisplayName : module.module, - }; - return adjustedModule; - }) - .catch(e => { - // eslint-disable-next-line no-console - stripes.logger.log('core', `Error loading assets for ${module.name}: ${e.message || e}`); - }); + const adjustedModule = { + ...module, + displayName: module.displayName ? + newDisplayName : module.module, + }; + return adjustedModule; + } catch (e) { + stripes.logger.log('core', `Error loading assets for ${module.name}: ${e.message || e}`); + throw new Error(`Error loading assets for ${module.name}: ${e.message || e}`); + } }; /** @@ -164,6 +151,18 @@ const loadAllModuleAssets = async (stripes, remotes) => { return Promise.all(remotes.map((r) => loadModuleAssets(stripes, r))); }; +/** + * handleRemoteModuleError + * @param {*} stripes + * @param {*} errorMsg + * logs error to stripes and throws the error. + */ +const handleRemoteModuleError = (stripes, errorMsg) => { + stripes.logger.log('core', errorMsg); + throw new Error(errorMsg); +}; + + /** * Registry Loader * @param {object} stripes @@ -181,28 +180,36 @@ const EntitlementLoader = ({ children }) => { if (okapi.entitlementUrl) { const fetchRegistry = async () => { // read the list of registered apps - const registry = await loadEntitlement(okapi.entitlementUrl).then((response) => response.json()).catch((e) => { - const errorMsg = `Error fetching entitlement registry from ${okapi.entitlementUrl}: ${e}`; - stripes.logger.log('core', errorMsg); - throw new Error(errorMsg); - }); + let registry; + + try { + const res = await loadEntitlement(okapi.entitlementUrl); + registry = await res.json(); + } catch (e) { + handleRemoteModuleError(stripes, `Error fetching entitlement registry from ${okapi.entitlementUrl}: ${e}`); + } // remap registry from an object shaped like { key1: app1, key2: app2, ...} // to an array shaped like [ { name: key1, ...app1 }, { name: key2, ...app2 } ...] - const remotes = Object.entries(registry.remotes).map(([name, metadata]) => ({ name, ...metadata })); + const remotes = Object.entries(registry?.remotes).map(([name, metadata]) => ({ name, ...metadata })); + + let cachedModules = modulesInitialState; + let remotesWithLoadedAssets = []; try { // load module assets (translations, icons), then load modules... - const remotesWithLoadedAssets = await loadAllModuleAssets(stripes, remotes); + remotesWithLoadedAssets = await loadAllModuleAssets(stripes, remotes); + } catch (e) { + handleRemoteModuleError(stripes, `Error loading remote module assets (icons, translations, sounds): ${e}`); + } + try { // load module code - this loads each module only once and up `getModule` so that it can be used sychronously. - const cachedModules = await preloadModules(stripes, remotesWithLoadedAssets); - - setRemoteModules(cachedModules); + cachedModules = await preloadModules(stripes, remotesWithLoadedAssets); } catch (e) { - // eslint-disable-next-line no-console - stripes.logger.log('core', `error loading remote modules: ${e}`); + handleRemoteModuleError(stripes, `error loading remote modules: ${e}`); } + setRemoteModules(cachedModules); }; fetchRegistry(); @@ -211,12 +218,15 @@ const EntitlementLoader = ({ children }) => { // eslint-disable-next-line react-hooks/exhaustive-deps }, []); - const combinedModules = {}; - Object.keys(configModules).forEach(key => { combinedModules[key] = [...configModules[key], ...remoteModules[key]]; }); + const combinedModules = useMemo(() => { + const baseModules = {}; + Object.keys(modulesInitialState).forEach(key => { baseModules[key] = [...configModules[key], ...remoteModules[key]]; }); + return baseModules; + }, [configModules, remoteModules]); return ( - {combinedModules ? children : null} + {children} ); }; From ceafdff4c8cf3d6c8026268c989059fbeb673f06 Mon Sep 17 00:00:00 2001 From: John Coburn Date: Tue, 16 Dec 2025 14:12:54 -0600 Subject: [PATCH 42/59] add test for ADD_ICON okapiReducer --- src/okapiReducer.test.js | 15 +++++++++++++++ 1 file changed, 15 insertions(+) diff --git a/src/okapiReducer.test.js b/src/okapiReducer.test.js index 2d7b121f5..324d392ac 100644 --- a/src/okapiReducer.test.js +++ b/src/okapiReducer.test.js @@ -1,4 +1,5 @@ import { + addIcon, checkSSO, clearCurrentUser, clearOkapiToken, @@ -325,6 +326,20 @@ describe('okapiReducer', () => { expect(o).toMatchObject({ okapiReady }); }); + it('ADD_ICON', () => { + const state = { icons: { 'iconKey': 'icon1' } }; + const newIcon = { name: 'icon2' }; + const o = okapiReducer(state, addIcon('iconKey2', newIcon)); + expect(o.icons).toMatchObject({ ...state.icons, 'iconKey2': newIcon }); + }); + + it('ADD_ICON, key present', () => { + const state = { icons: { 'iconKey': { name: 'icon1' } } }; + const newIcon = { new: 'icon2' }; + const o = okapiReducer(state, addIcon('iconKey', newIcon)); + expect(o.icons).toMatchObject({ 'iconKey': { new: 'icon2', name: 'icon1' } }); + }); + it('SERVER_DOWN', () => { const state = { serverDown: false }; const serverDown = true; From 779b6ecc5587cd21e115be5b711aca21138c6809 Mon Sep 17 00:00:00 2001 From: John Coburn Date: Tue, 16 Dec 2025 15:10:45 -0600 Subject: [PATCH 43/59] add error handling to loadRemoteComponent, update loadRemoteComponent tests --- src/loadRemoteComponent.js | 39 ++++++++++++------- .../bigtest/tests/LoadRemoteComponent-test.js | 23 +++++++---- 2 files changed, 39 insertions(+), 23 deletions(-) diff --git a/src/loadRemoteComponent.js b/src/loadRemoteComponent.js index 3d13a064f..cfc742eb1 100644 --- a/src/loadRemoteComponent.js +++ b/src/loadRemoteComponent.js @@ -1,23 +1,32 @@ // https://webpack.js.org/concepts/module-federation/#dynamic-remote-containers export default async function loadRemoteComponent(remoteUrl, remoteName) { - if (!window[remoteName]) { - const response = await fetch(remoteUrl); - const source = await response.text(); - const script = document.createElement('script'); - script.textContent = source; - document.body.appendChild(script); - } + const errorMessage = `Failed to fetch remote module from ${remoteUrl}`; + try { + if (!window[remoteName]) { + const response = await fetch(remoteUrl); + if (!response.ok) { + throw new Error(errorMessage); + } + const source = await response.text(); + const script = document.createElement('script'); + script.textContent = source; + document.body.appendChild(script); + } - const container = window[remoteName]; + const container = window[remoteName]; - // eslint-disable-next-line no-undef - await __webpack_init_sharing__('default'); + // eslint-disable-next-line no-undef + await __webpack_init_sharing__('default'); - // eslint-disable-next-line no-undef - await container.init(__webpack_share_scopes__.default); + // eslint-disable-next-line no-undef + await container.init(__webpack_share_scopes__.default); - const factory = await container.get('./MainEntry'); - const Module = await factory(); + const factory = await container.get('./MainEntry'); + const Module = await factory(); - return Module; + return Module; + } catch (error) { + console.error(`${errorMessage}`, error); + throw error; + } } diff --git a/test/bigtest/tests/LoadRemoteComponent-test.js b/test/bigtest/tests/LoadRemoteComponent-test.js index c2fdf1fad..1e58001b6 100644 --- a/test/bigtest/tests/LoadRemoteComponent-test.js +++ b/test/bigtest/tests/LoadRemoteComponent-test.js @@ -1,17 +1,18 @@ -import { beforeEach, it, describe } from 'mocha'; +import { beforeEach, it, afterEach, describe } from 'mocha'; import { expect } from 'chai'; -import setupApplication from '../helpers/setup-application-components'; +import startMirage from '../network/start'; import loadRemoteComponent from '../../../src/loadRemoteComponent'; -describe('loadRemoteComponent', () => { - setupApplication(); +describe.only('loadRemoteComponent', () => { + let server; const mockRemoteUrl = 'http://example.com/testRemote/remoteEntry.js'; const mockErrorUrl = 'http://example.com/nonexistent/remoteEntry.js'; const mockRemoteName = 'testComponent'; beforeEach(async function () { - this.server.get(mockRemoteUrl, () => { + server = startMirage(); + server.get(mockRemoteUrl, () => { const mockScriptContent = `window['${mockRemoteName}'] = { init: function() { console.log("Component initialized"); }, get: function() { return function() { return { default: 'I am a module' }; }} @@ -21,7 +22,13 @@ describe('loadRemoteComponent', () => { return mockScriptContent; }); - this.server.get(mockErrorUrl, () => new Response(404)); + server.get(mockErrorUrl, () => (server.serialize({ ok: false }))); + }); + + afterEach(function () { + server?.shutdown(); + server = null; + delete window[mockRemoteName]; }); it('should load and evaluate the remote script', async () => { @@ -29,11 +36,11 @@ describe('loadRemoteComponent', () => { expect(window[mockRemoteName]).to.be.an('object'); }); - it('should return the component from the remote script', async () => { + it('should handle errors when loading the remote script', async () => { try { await loadRemoteComponent(mockErrorUrl, mockRemoteName); } catch (error) { - expect(error.message).to.equal('Failed to load remote component'); + expect(error.message).to.equal(`Failed to fetch remote module from ${mockErrorUrl}`); } }); }); From 2cb6e5b66536a08994f56e968cd4fe8e91ef9533 Mon Sep 17 00:00:00 2001 From: John Coburn Date: Tue, 16 Dec 2025 15:12:05 -0600 Subject: [PATCH 44/59] remove only from lrc test --- test/bigtest/tests/LoadRemoteComponent-test.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/test/bigtest/tests/LoadRemoteComponent-test.js b/test/bigtest/tests/LoadRemoteComponent-test.js index 1e58001b6..7a7a0597c 100644 --- a/test/bigtest/tests/LoadRemoteComponent-test.js +++ b/test/bigtest/tests/LoadRemoteComponent-test.js @@ -3,7 +3,7 @@ import { expect } from 'chai'; import startMirage from '../network/start'; import loadRemoteComponent from '../../../src/loadRemoteComponent'; -describe.only('loadRemoteComponent', () => { +describe('loadRemoteComponent', () => { let server; const mockRemoteUrl = 'http://example.com/testRemote/remoteEntry.js'; const mockErrorUrl = 'http://example.com/nonexistent/remoteEntry.js'; From 7a4ef262dbe36eed83a0b9fc45dcf475a8cce867 Mon Sep 17 00:00:00 2001 From: John Coburn Date: Wed, 17 Dec 2025 15:37:11 -0600 Subject: [PATCH 45/59] split out more of the loadEntitlement logic from EntitlementLoader --- src/components/EntitlementLoader.js | 10 ++-------- src/components/EntitlementLoader.test.js | 10 +++++----- src/components/loadEntitlement.js | 11 +++++++++-- 3 files changed, 16 insertions(+), 15 deletions(-) diff --git a/src/components/EntitlementLoader.js b/src/components/EntitlementLoader.js index f27e6a0e8..5b942601a 100644 --- a/src/components/EntitlementLoader.js +++ b/src/components/EntitlementLoader.js @@ -180,19 +180,13 @@ const EntitlementLoader = ({ children }) => { if (okapi.entitlementUrl) { const fetchRegistry = async () => { // read the list of registered apps - let registry; - + let remotes; try { - const res = await loadEntitlement(okapi.entitlementUrl); - registry = await res.json(); + remotes = await loadEntitlement(okapi.entitlementUrl); } catch (e) { handleRemoteModuleError(stripes, `Error fetching entitlement registry from ${okapi.entitlementUrl}: ${e}`); } - // remap registry from an object shaped like { key1: app1, key2: app2, ...} - // to an array shaped like [ { name: key1, ...app1 }, { name: key2, ...app2 } ...] - const remotes = Object.entries(registry?.remotes).map(([name, metadata]) => ({ name, ...metadata })); - let cachedModules = modulesInitialState; let remotesWithLoadedAssets = []; diff --git a/src/components/EntitlementLoader.test.js b/src/components/EntitlementLoader.test.js index 778f2174e..e43917b56 100644 --- a/src/components/EntitlementLoader.test.js +++ b/src/components/EntitlementLoader.test.js @@ -85,6 +85,8 @@ describe('EntitlementLoader', () => { }, }; + const mockRemotes = Object.entries(mockRegistry?.remotes).map(([name, metadata]) => ({ name, ...metadata })); + const translations = { 'testModule.label': 'Test Module Display', 'appModule.label': 'App Module Display', @@ -132,10 +134,7 @@ describe('EntitlementLoader', () => { beforeEach(() => { global.fetch = jest.fn(); - loadEntitlement.mockResolvedValueOnce({ - ok: true, - json: jest.fn().mockResolvedValue(mockRegistry) - }); + loadEntitlement.mockResolvedValueOnce(mockRemotes); loadRemoteComponent.mockResolvedValue({ default: {} }); }); @@ -409,8 +408,9 @@ describe('EntitlementLoader', () => { ok: true, json: jest.fn().mockResolvedValueOnce(mockRegistry), }); - await actualLoadEntitlement(okapi.entitlementUrl); + const remotes = await actualLoadEntitlement(okapi.entitlementUrl); expect(fetch).toHaveBeenCalledWith(okapi.entitlementUrl); + expect(remotes).toEqual(mockRemotes); }); }); }); diff --git a/src/components/loadEntitlement.js b/src/components/loadEntitlement.js index 74703f5bf..ba990df97 100644 --- a/src/components/loadEntitlement.js +++ b/src/components/loadEntitlement.js @@ -1,3 +1,10 @@ -export const loadEntitlement = (entitlementUrl) => { - return fetch(entitlementUrl); +export const loadEntitlement = async (entitlementUrl) => { + const res = await fetch(entitlementUrl); + const registry = await res.json(); + + // process the registry data and return the remotes array + // remap registry from an object shaped like { key1: app1, key2: app2, ...} + // to an array shaped like [ { name: key1, ...app1 }, { name: key2, ...app2 } ...] + const remotes = Object.entries(registry?.remotes).map(([name, metadata]) => ({ name, ...metadata })); + return Promise.resolve(remotes); }; From 2a6dc5127146196182ebbebf258fae007ff633ea Mon Sep 17 00:00:00 2001 From: John Coburn Date: Wed, 14 Jan 2026 15:27:58 -0600 Subject: [PATCH 46/59] loadRemoteComponent does script/src injection, translation loading prep for stripes-hub --- src/loadRemoteComponent.js | 51 ++++++++++++++++++++++---------------- src/loginServices.js | 12 +++++++-- 2 files changed, 39 insertions(+), 24 deletions(-) diff --git a/src/loadRemoteComponent.js b/src/loadRemoteComponent.js index cfc742eb1..6c9c7a053 100644 --- a/src/loadRemoteComponent.js +++ b/src/loadRemoteComponent.js @@ -1,32 +1,39 @@ -// https://webpack.js.org/concepts/module-federation/#dynamic-remote-containers -export default async function loadRemoteComponent(remoteUrl, remoteName) { - const errorMessage = `Failed to fetch remote module from ${remoteUrl}`; - try { - if (!window[remoteName]) { - const response = await fetch(remoteUrl); - if (!response.ok) { - throw new Error(errorMessage); - } - const source = await response.text(); - const script = document.createElement('script'); - script.textContent = source; - document.body.appendChild(script); - } +// injects a script tag to load a remote module. +// This has to be performed in this way for publicPath of the federated remote +// to be automatically discovered since it works based on document.currentScript.src. +// Once the script is loaded, it executes webpack module federation API +// to initialize sharing and retrieve the exposed module. - const container = window[remoteName]; +function injectScript(remoteUrl, remoteName) { + return new Promise((resolve, reject) => { + const script = document.createElement('script'); + script.src = remoteUrl; + script.onload = async () => { + const container = window[remoteName]; - // eslint-disable-next-line no-undef - await __webpack_init_sharing__('default'); + // eslint-disable-next-line no-undef + await __webpack_init_sharing__('default'); - // eslint-disable-next-line no-undef - await container.init(__webpack_share_scopes__.default); + // eslint-disable-next-line no-undef + await container.init(__webpack_share_scopes__.default); - const factory = await container.get('./MainEntry'); - const Module = await factory(); + const factory = await container.get('./MainEntry'); + const Module = await factory(); + resolve(Module); + }; + script.onerror = () => { + reject(new Error(`Failed to load remote script from ${remoteUrl}`)); + }; + document.body.appendChild(script); + }); +} +export default async function loadRemoteComponent(remoteUrl, remoteName) { + try { + const Module = await injectScript(remoteUrl, remoteName); return Module; } catch (error) { - console.error(`${errorMessage}`, error); + console.error(error); throw error; } } diff --git a/src/loginServices.js b/src/loginServices.js index 275ab29de..17fae0242 100644 --- a/src/loginServices.js +++ b/src/loginServices.js @@ -302,8 +302,16 @@ export async function loadTranslations(store, locale, defaultTranslations = {}) // Here we put additional condition because languages // like Japan we need to use like ja, but with numeric system // Japan language builds like ja_u, that incorrect. We need to be safe from that bug. - const res = await fetch(translations[region] ? translations[region] : - translations[loadedLocale] || translations[[parentLocale]]) + const translationName = translations[region] ? translations[region] : + translations[loadedLocale] || translations[[parentLocale]]; + + // if stripes-core is served from a different origin (module-federation) then + // we need to fetch translations from that origin as well rather than a relative path. + // const stripsesCoreOrigin = 'http://localhost:3000'; + // const translationUrl = new URL(translationName, stripsesCoreOrigin); + + const translationUrl = new URL(translationName, window.location.origin); + const res = await fetch(translationUrl.href) .then((response) => { if (response.ok) { response.json().then((stripesTranslations) => { From 16e460d41da76b68251f6038ac43eb8b6d83db9c Mon Sep 17 00:00:00 2001 From: John Coburn Date: Thu, 22 Jan 2026 00:21:40 -0600 Subject: [PATCH 47/59] pull stripes-hub api info from localforage --- src/App.js | 17 +++++++++++++++-- src/components/Root/Root.js | 34 ++++++++++++++++++++++++++++++++-- src/constants/index.js | 1 + src/constants/stripesHubAPI.js | 8 ++++++++ src/loginServices.js | 10 ++++++---- 5 files changed, 62 insertions(+), 8 deletions(-) create mode 100644 src/constants/stripesHubAPI.js diff --git a/src/App.js b/src/App.js index db89ff325..4147b3849 100644 --- a/src/App.js +++ b/src/App.js @@ -2,7 +2,7 @@ import React, { Component, StrictMode } from 'react'; import PropTypes from 'prop-types'; import { okapi as okapiConfig, config } from 'stripes-config'; import merge from 'lodash/merge'; - +import localforage from 'localforage'; import AppConfigError from './components/AppConfigError'; import connectErrorEpic from './connectErrorEpic'; import configureEpics from './configureEpics'; @@ -15,9 +15,10 @@ import { modulesInitialState } from './ModulesContext'; import css from './components/SessionEventContainer/style.css'; import Root from './components/Root'; -import { eventsPortal } from './constants'; +import { eventsPortal, stripesHubAPI } from './constants'; import { getLoginTenant } from './loginServices'; + const StrictWrapper = ({ children }) => { if (config.disableStrictMode) { return children; @@ -102,11 +103,21 @@ export default class StripesCore extends Component { if (this.state.isStorageEnabled) { try { const modules = await getModules(); + + const entitlementUrl = await localforage.getItem(stripesHubAPI.ENTITLEMENT_URL_KEY); + const hostLocation = await localforage.getItem(stripesHubAPI.HOST_LOCATION_KEY); + const remotesList = await localforage.getItem(stripesHubAPI.REMOTE_LIST_KEY); + const actionNames = gatherActions(modules); this.setState({ actionNames, modules, + stripesHub: { + entitlementUrl, + hostLocation, + remotesList, + } }); } catch (error) { console.error('Failed to gather actions:', error); // eslint-disable-line no-console @@ -122,6 +133,7 @@ export default class StripesCore extends Component { const { actionNames, modules, + stripesHub, } = this.state; // Stripes requires cookies (for login) and session and local storage // (for session state and all manner of things). If these are not enabled, @@ -148,6 +160,7 @@ export default class StripesCore extends Component { actionNames={actionNames} modules={modules} disableAuth={(config?.disableAuth) || false} + stripesHub={stripesHub} {...props} /> diff --git a/src/components/Root/Root.js b/src/components/Root/Root.js index 36d9eef36..81168cf68 100644 --- a/src/components/Root/Root.js +++ b/src/components/Root/Root.js @@ -133,7 +133,32 @@ class Root extends Component { } render() { - const { logger, store, epics, config, okapi, actionNames, token, isAuthenticated, disableAuth, currentUser, currentPerms, icons, locale, defaultTranslations, timezone, currency, plugins, bindings, discovery, translations, history, serverDown } = this.props; + const { + logger, + store, + epics, + config, + okapi, + actionNames, + token, + isAuthenticated, + disableAuth, + currentUser, + currentPerms, + icons, + locale, + defaultTranslations, + timezone, + currency, + plugins, + bindings, + discovery, + translations, + history, + serverDown, + stripesHub, + } = this.props; + if (serverDown) { // note: this isn't i18n'ed because we haven't rendered an IntlProvider yet. return
Error: server is forbidden, unreachable or down. Clear the cookies? Use incognito mode? VPN issue?
; @@ -156,12 +181,16 @@ class Root extends Component { // time, but still, props are props so technically it's possible. config.rtr = configureRtr(this.props.config.rtr); + // if we have a stripesHub entitlementUrl, pass it to stripes... + + const stripesOkapi = stripesHub?.entitlementUrl ? { ...okapi, entitlementUrl: stripesHub.entitlementUrl } : okapi; + const stripes = new Stripes({ logger, store, epics, config, - okapi, + okapi: stripesOkapi, withOkapi: this.withOkapi, setToken: (val) => { store.dispatch(setOkapiToken(val)); }, setIsAuthenticated: (val) => { store.dispatch(setIsAuthenticated(val)); }, @@ -187,6 +216,7 @@ class Root extends Component { perms: currentPerms, }, connect(X) { return X; }, + stripesHub, }); return ( diff --git a/src/constants/index.js b/src/constants/index.js index 029445af2..8583440a9 100644 --- a/src/constants/index.js +++ b/src/constants/index.js @@ -7,3 +7,4 @@ export { default as packageName } from './packageName'; export { default as delimiters } from './delimiters'; export { default as eventsPortal } from './eventsPortal'; export { default as settings } from './settings'; +export { default as stripesHubAPI } from './stripesHubAPI'; diff --git a/src/constants/stripesHubAPI.js b/src/constants/stripesHubAPI.js new file mode 100644 index 000000000..681685fbb --- /dev/null +++ b/src/constants/stripesHubAPI.js @@ -0,0 +1,8 @@ +// Collection of keys stored via localforage by stripes-hub. +// These function to allow a remotely loaded host app to +// load translations and refresh the list of entitled modules. +export default { + HOST_LOCATION_KEY: 'hostLocation', + REMOTE_LIST_KEY: 'entitlements', + ENTITLEMENT_URL_KEY: 'entitlementUrl' +}; diff --git a/src/loginServices.js b/src/loginServices.js index 935f8d64c..120166589 100644 --- a/src/loginServices.js +++ b/src/loginServices.js @@ -307,11 +307,13 @@ export async function loadTranslations(store, locale, defaultTranslations = {}) translations[loadedLocale] || translations[[parentLocale]]; // if stripes-core is served from a different origin (module-federation) then - // we need to fetch translations from that origin as well rather than a relative path. - // const stripsesCoreOrigin = 'http://localhost:3000'; - // const translationUrl = new URL(translationName, stripsesCoreOrigin); + // we need to fetch translations from that origin as well rather than the current location. + let translationOrigin = await localforage.getItem('hostLocation'); + if (!translationOrigin) { + translationOrigin = window.location.origin; + } - const translationUrl = new URL(translationName, window.location.origin); + const translationUrl = new URL(translationName, translationOrigin); const res = await fetch(translationUrl.href) .then((response) => { if (response.ok) { From 885cf5ceeb63e27720ce7db4d1c6cdf333a46a07 Mon Sep 17 00:00:00 2001 From: John Coburn Date: Thu, 22 Jan 2026 00:22:11 -0600 Subject: [PATCH 48/59] sync with actual entitlement response --- src/components/EntitlementLoader.js | 10 +++++----- src/components/loadEntitlement.js | 14 ++++++++++++-- 2 files changed, 17 insertions(+), 7 deletions(-) diff --git a/src/components/EntitlementLoader.js b/src/components/EntitlementLoader.js index 5b942601a..8940c8265 100644 --- a/src/components/EntitlementLoader.js +++ b/src/components/EntitlementLoader.js @@ -1,6 +1,5 @@ import { useEffect, useState, useMemo } from 'react'; import PropTypes from 'prop-types'; -import { okapi } from 'stripes-config'; import { useStripes } from '../StripesContext'; import { ModulesContext, useModules, modulesInitialState } from '../ModulesContext'; import loadRemoteComponent from '../loadRemoteComponent'; @@ -22,8 +21,8 @@ export const preloadModules = async (stripes, remotes) => { try { const loaderArray = []; remotes.forEach(remote => { - const { name, url } = remote; - loaderArray.push(loadRemoteComponent(url, name) + const { name, location } = remote; + loaderArray.push(loadRemoteComponent(location, name) .then((module) => { remote.getModule = () => module.default; }) @@ -62,7 +61,7 @@ const loadTranslations = async (stripes, module) => { // and converts from kebab-case (the IETF standard) to snake_case (which we // somehow adopted for our files in Lokalise). const locale = stripes.locale.split('-u-nu-')[0].replace('-', '_'); - const url = `${module.host}:${module.port}/translations/${locale}.json`; + const url = `${module.origin}/translations/${locale}.json`; stripes.logger.log('core', `loading ${locale} translations for ${module.name}`); const res = await fetch(url); @@ -94,7 +93,7 @@ const loadIcons = (stripes, module) => { const icon = { [i.name]: { - src: `${module.host}:${module.port}/icons/${i.name}.svg`, + src: `${module.origin}/icons/${i.name}.svg`, alt: i.title, } }; @@ -177,6 +176,7 @@ const EntitlementLoader = ({ children }) => { // if platform is configured for module federation, read the list of registered apps from // localstorage, okapi, direct call to registry endpoint? useEffect(() => { + const { okapi } = stripes; if (okapi.entitlementUrl) { const fetchRegistry = async () => { // read the list of registered apps diff --git a/src/components/loadEntitlement.js b/src/components/loadEntitlement.js index ba990df97..77e155318 100644 --- a/src/components/loadEntitlement.js +++ b/src/components/loadEntitlement.js @@ -5,6 +5,16 @@ export const loadEntitlement = async (entitlementUrl) => { // process the registry data and return the remotes array // remap registry from an object shaped like { key1: app1, key2: app2, ...} // to an array shaped like [ { name: key1, ...app1 }, { name: key2, ...app2 } ...] - const remotes = Object.entries(registry?.remotes).map(([name, metadata]) => ({ name, ...metadata })); - return Promise.resolve(remotes); + // const remotes = Object.entries(registry?.discovery).map(([name, metadata]) => ({ name, ...metadata })); + // split location into host, port for asset loading. + registry.discovery.forEach(remote => { + if (!remote?.location?.startsWith('http')) { + remote.location = `${window.location.protocol}//${remote.location}`; + } + const url = new URL(remote.location); + remote.host = url.hostname; + remote.port = url.port; + remote.origin = url.origin; + }); + return Promise.resolve(registry.discovery); }; From d9c47d897368a5f96d6158baa90f70adb5af4254 Mon Sep 17 00:00:00 2001 From: John Coburn Date: Thu, 22 Jan 2026 09:43:43 -0600 Subject: [PATCH 49/59] add a constant for the name of stripes in the host app bundle, shape discovery results/pull discovery results from localForage --- src/components/loadEntitlement.js | 25 ++++++++++++++++++------- src/constants/stripesHubAPI.js | 3 ++- 2 files changed, 20 insertions(+), 8 deletions(-) diff --git a/src/components/loadEntitlement.js b/src/components/loadEntitlement.js index 77e155318..4851c0fdd 100644 --- a/src/components/loadEntitlement.js +++ b/src/components/loadEntitlement.js @@ -1,12 +1,23 @@ +import localforage from 'localforage'; +import { stripesHubAPI } from '../constants'; + export const loadEntitlement = async (entitlementUrl) => { - const res = await fetch(entitlementUrl); - const registry = await res.json(); + let registry; + const discovery = await localforage.getItem(stripesHubAPI.REMOTE_LIST_KEY); + if (discovery) { + registry = { discovery }; + } else { + const res = await fetch(entitlementUrl); + const registryData = await res.json(); + + // strip out the host app if it's present... + registry.discovery = registryData.filter((entry) => entry.name !== stripesHubAPI.HOST_APP_NAME); + } + + // Take the location information for each remote in the response and split out its origin... + // i.e. 'http://localhost:3002/remoteEntry.js -> 'http://localhost:3002' + // this origin is where stripes-core will attempt to fetch translations and assets from. - // process the registry data and return the remotes array - // remap registry from an object shaped like { key1: app1, key2: app2, ...} - // to an array shaped like [ { name: key1, ...app1 }, { name: key2, ...app2 } ...] - // const remotes = Object.entries(registry?.discovery).map(([name, metadata]) => ({ name, ...metadata })); - // split location into host, port for asset loading. registry.discovery.forEach(remote => { if (!remote?.location?.startsWith('http')) { remote.location = `${window.location.protocol}//${remote.location}`; diff --git a/src/constants/stripesHubAPI.js b/src/constants/stripesHubAPI.js index 681685fbb..9a98d1c77 100644 --- a/src/constants/stripesHubAPI.js +++ b/src/constants/stripesHubAPI.js @@ -4,5 +4,6 @@ export default { HOST_LOCATION_KEY: 'hostLocation', REMOTE_LIST_KEY: 'entitlements', - ENTITLEMENT_URL_KEY: 'entitlementUrl' + ENTITLEMENT_URL_KEY: 'entitlementUrl', + HOST_APP_NAME: 'folio_stripes', }; From fedb1cc756110bb59d706f1f99e8917f06e08a17 Mon Sep 17 00:00:00 2001 From: John Coburn Date: Thu, 22 Jan 2026 16:12:00 -0600 Subject: [PATCH 50/59] properly set up registry if fetched from the entitlement URL --- src/components/EntitlementLoader.js | 14 ++++++++++++-- src/components/loadEntitlement.js | 21 ++++++++++++++------- 2 files changed, 26 insertions(+), 9 deletions(-) diff --git a/src/components/EntitlementLoader.js b/src/components/EntitlementLoader.js index 8940c8265..f568edf0e 100644 --- a/src/components/EntitlementLoader.js +++ b/src/components/EntitlementLoader.js @@ -178,11 +178,14 @@ const EntitlementLoader = ({ children }) => { useEffect(() => { const { okapi } = stripes; if (okapi.entitlementUrl) { + const controller = new AbortController(); + const signal = controller.signal; + let noFetch = false; const fetchRegistry = async () => { // read the list of registered apps let remotes; try { - remotes = await loadEntitlement(okapi.entitlementUrl); + remotes = await loadEntitlement(okapi.entitlementUrl, signal); } catch (e) { handleRemoteModuleError(stripes, `Error fetching entitlement registry from ${okapi.entitlementUrl}: ${e}`); } @@ -203,10 +206,17 @@ const EntitlementLoader = ({ children }) => { } catch (e) { handleRemoteModuleError(stripes, `error loading remote modules: ${e}`); } - setRemoteModules(cachedModules); + if (!noFetch) { + setRemoteModules(cachedModules); + } }; fetchRegistry(); + + return () => { + controller.abort(); + noFetch = true; + }; } // no, we don't want to refetch the registry if stripes changes // eslint-disable-next-line react-hooks/exhaustive-deps diff --git a/src/components/loadEntitlement.js b/src/components/loadEntitlement.js index 4851c0fdd..f7b29853f 100644 --- a/src/components/loadEntitlement.js +++ b/src/components/loadEntitlement.js @@ -1,17 +1,24 @@ import localforage from 'localforage'; import { stripesHubAPI } from '../constants'; -export const loadEntitlement = async (entitlementUrl) => { - let registry; +export const loadEntitlement = async (entitlementUrl, signal) => { + let registry = {}; const discovery = await localforage.getItem(stripesHubAPI.REMOTE_LIST_KEY); - if (discovery) { + if (discovery && entitlementUrl) { registry = { discovery }; } else { - const res = await fetch(entitlementUrl); - const registryData = await res.json(); + try { + const res = await fetch(entitlementUrl, { signal }); + if (!res.ok) throw new Error('Unable to fetch entitlement Url') + const registryData = await res.json(); - // strip out the host app if it's present... - registry.discovery = registryData.filter((entry) => entry.name !== stripesHubAPI.HOST_APP_NAME); + // strip out the host app if it's present... + registry.discovery = registryData?.discovery.filter((entry) => entry.name !== stripesHubAPI.HOST_APP_NAME); + } catch (e) { + if (e.name !== 'AbortError') { + console.error('Entitlement fetch error:', e); + } + } } // Take the location information for each remote in the response and split out its origin... From edfcca79e099b82cf8d49e88de70585dc65739d9 Mon Sep 17 00:00:00 2001 From: John Coburn Date: Thu, 22 Jan 2026 16:39:04 -0600 Subject: [PATCH 51/59] use AbortController for handling remote fetching in useEffect --- src/components/EntitlementLoader.js | 41 +++++++++++++++-------------- src/components/loadEntitlement.js | 8 +++--- 2 files changed, 25 insertions(+), 24 deletions(-) diff --git a/src/components/EntitlementLoader.js b/src/components/EntitlementLoader.js index f568edf0e..ed02a7658 100644 --- a/src/components/EntitlementLoader.js +++ b/src/components/EntitlementLoader.js @@ -173,16 +173,17 @@ const EntitlementLoader = ({ children }) => { const configModules = useModules(); const [remoteModules, setRemoteModules] = useState(modulesInitialState); - // if platform is configured for module federation, read the list of registered apps from - // localstorage, okapi, direct call to registry endpoint? + // fetching data in useEffect onMount using an AbortController. The cleanup function will abort the first call if the component is unmounted + // or useEffect re-fires as a result of strict mode. useEffect(() => { const { okapi } = stripes; + const controller = new AbortController(); + const signal = controller.signal; if (okapi.entitlementUrl) { - const controller = new AbortController(); - const signal = controller.signal; - let noFetch = false; + // fetches the list of registered apps/metadata, + // loads icons and translations, then module code, + // ultimately stores the result in the modules state to pass down into the modules context. const fetchRegistry = async () => { - // read the list of registered apps let remotes; try { remotes = await loadEntitlement(okapi.entitlementUrl, signal); @@ -193,20 +194,21 @@ const EntitlementLoader = ({ children }) => { let cachedModules = modulesInitialState; let remotesWithLoadedAssets = []; - try { - // load module assets (translations, icons), then load modules... - remotesWithLoadedAssets = await loadAllModuleAssets(stripes, remotes); - } catch (e) { - handleRemoteModuleError(stripes, `Error loading remote module assets (icons, translations, sounds): ${e}`); - } + if (!signal.aborted) { + try { + // load module assets (translations, icons)... + remotesWithLoadedAssets = await loadAllModuleAssets(stripes, remotes); + } catch (e) { + handleRemoteModuleError(stripes, `Error loading remote module assets (icons, translations, sounds): ${e}`); + } + + try { + // load module code - this loads each module only once and up `getModule` so that it can be used sychronously. + cachedModules = await preloadModules(stripes, remotesWithLoadedAssets); + } catch (e) { + handleRemoteModuleError(stripes, `error loading remote modules: ${e}`); + } - try { - // load module code - this loads each module only once and up `getModule` so that it can be used sychronously. - cachedModules = await preloadModules(stripes, remotesWithLoadedAssets); - } catch (e) { - handleRemoteModuleError(stripes, `error loading remote modules: ${e}`); - } - if (!noFetch) { setRemoteModules(cachedModules); } }; @@ -215,7 +217,6 @@ const EntitlementLoader = ({ children }) => { return () => { controller.abort(); - noFetch = true; }; } // no, we don't want to refetch the registry if stripes changes diff --git a/src/components/loadEntitlement.js b/src/components/loadEntitlement.js index f7b29853f..6b01ed710 100644 --- a/src/components/loadEntitlement.js +++ b/src/components/loadEntitlement.js @@ -4,9 +4,9 @@ import { stripesHubAPI } from '../constants'; export const loadEntitlement = async (entitlementUrl, signal) => { let registry = {}; const discovery = await localforage.getItem(stripesHubAPI.REMOTE_LIST_KEY); - if (discovery && entitlementUrl) { + if (discovery) { registry = { discovery }; - } else { + } else if (entitlementUrl) { try { const res = await fetch(entitlementUrl, { signal }); if (!res.ok) throw new Error('Unable to fetch entitlement Url') @@ -25,7 +25,7 @@ export const loadEntitlement = async (entitlementUrl, signal) => { // i.e. 'http://localhost:3002/remoteEntry.js -> 'http://localhost:3002' // this origin is where stripes-core will attempt to fetch translations and assets from. - registry.discovery.forEach(remote => { + registry?.discovery?.forEach(remote => { if (!remote?.location?.startsWith('http')) { remote.location = `${window.location.protocol}//${remote.location}`; } @@ -34,5 +34,5 @@ export const loadEntitlement = async (entitlementUrl, signal) => { remote.port = url.port; remote.origin = url.origin; }); - return Promise.resolve(registry.discovery); + return Promise.resolve(registry?.discovery); }; From 4268490b78d1a1549e2293b1f6f812f8480c8dd9 Mon Sep 17 00:00:00 2001 From: John Coburn Date: Thu, 22 Jan 2026 16:43:14 -0600 Subject: [PATCH 52/59] semicolon on entitlement request error handling --- src/components/loadEntitlement.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/components/loadEntitlement.js b/src/components/loadEntitlement.js index 6b01ed710..c3d233504 100644 --- a/src/components/loadEntitlement.js +++ b/src/components/loadEntitlement.js @@ -9,7 +9,7 @@ export const loadEntitlement = async (entitlementUrl, signal) => { } else if (entitlementUrl) { try { const res = await fetch(entitlementUrl, { signal }); - if (!res.ok) throw new Error('Unable to fetch entitlement Url') + if (!res.ok) throw new Error('Unable to fetch entitlement Url'); const registryData = await res.json(); // strip out the host app if it's present... From 4282ea818ab22ac48c16f12fbbcbe138716977ab Mon Sep 17 00:00:00 2001 From: John Coburn Date: Thu, 22 Jan 2026 16:52:09 -0600 Subject: [PATCH 53/59] braces on entitlementLoader useEffect --- src/components/EntitlementLoader.js | 15 +++++++-------- 1 file changed, 7 insertions(+), 8 deletions(-) diff --git a/src/components/EntitlementLoader.js b/src/components/EntitlementLoader.js index ed02a7658..f7a8a7eb7 100644 --- a/src/components/EntitlementLoader.js +++ b/src/components/EntitlementLoader.js @@ -163,10 +163,10 @@ const handleRemoteModuleError = (stripes, errorMsg) => { /** - * Registry Loader - * @param {object} stripes + * Entitlement Loader + * fetches/preloads all remote modules on mount. + * Passes the dynamically loaded modules into the modules context. * @param {*} children - * @returns */ const EntitlementLoader = ({ children }) => { const stripes = useStripes(); @@ -194,6 +194,7 @@ const EntitlementLoader = ({ children }) => { let cachedModules = modulesInitialState; let remotesWithLoadedAssets = []; + // if the signal is aborted, avoid all subsequent fetches, state updates... if (!signal.aborted) { try { // load module assets (translations, icons)... @@ -212,13 +213,11 @@ const EntitlementLoader = ({ children }) => { setRemoteModules(cachedModules); } }; - fetchRegistry(); - - return () => { - controller.abort(); - }; } + return () => { + controller.abort(); + }; // no, we don't want to refetch the registry if stripes changes // eslint-disable-next-line react-hooks/exhaustive-deps }, []); From 62fb1ef9072e175754721b6d7e20f189d11ed5f3 Mon Sep 17 00:00:00 2001 From: John Coburn Date: Tue, 27 Jan 2026 16:05:53 -0600 Subject: [PATCH 54/59] handle subdirectory hosting of ui-modules --- src/components/EntitlementLoader.js | 4 ++-- src/components/loadEntitlement.js | 4 ++++ 2 files changed, 6 insertions(+), 2 deletions(-) diff --git a/src/components/EntitlementLoader.js b/src/components/EntitlementLoader.js index f7a8a7eb7..b5b526fca 100644 --- a/src/components/EntitlementLoader.js +++ b/src/components/EntitlementLoader.js @@ -61,7 +61,7 @@ const loadTranslations = async (stripes, module) => { // and converts from kebab-case (the IETF standard) to snake_case (which we // somehow adopted for our files in Lokalise). const locale = stripes.locale.split('-u-nu-')[0].replace('-', '_'); - const url = `${module.origin}/translations/${locale}.json`; + const url = `${module.assetPath}/translations/${locale}.json`; stripes.logger.log('core', `loading ${locale} translations for ${module.name}`); const res = await fetch(url); @@ -93,7 +93,7 @@ const loadIcons = (stripes, module) => { const icon = { [i.name]: { - src: `${module.origin}/icons/${i.name}.svg`, + src: `${module.assetPath}/icons/${i.name}.svg`, alt: i.title, } }; diff --git a/src/components/loadEntitlement.js b/src/components/loadEntitlement.js index c3d233504..69d9a8aad 100644 --- a/src/components/loadEntitlement.js +++ b/src/components/loadEntitlement.js @@ -33,6 +33,10 @@ export const loadEntitlement = async (entitlementUrl, signal) => { remote.host = url.hostname; remote.port = url.port; remote.origin = url.origin; + let segments = url.href.split('/'); + segments.pop(); + let hrefWithoutFilename = segments.join('/') + remote.assetPath = hrefWithoutFilename; }); return Promise.resolve(registry?.discovery); }; From 50469e7c0c682c157aa88c8438efc3ce05b0c075 Mon Sep 17 00:00:00 2001 From: John Coburn Date: Sun, 1 Feb 2026 08:08:12 -0600 Subject: [PATCH 55/59] fix tests for EntitlementLoader and LoadRemoteComponent --- src/components/EntitlementLoader.js | 2 +- src/components/EntitlementLoader.test.js | 33 +++++++++++-------- .../bigtest/tests/LoadRemoteComponent-test.js | 23 ++++++++----- 3 files changed, 35 insertions(+), 23 deletions(-) diff --git a/src/components/EntitlementLoader.js b/src/components/EntitlementLoader.js index b5b526fca..9c72ef5f3 100644 --- a/src/components/EntitlementLoader.js +++ b/src/components/EntitlementLoader.js @@ -179,7 +179,7 @@ const EntitlementLoader = ({ children }) => { const { okapi } = stripes; const controller = new AbortController(); const signal = controller.signal; - if (okapi.entitlementUrl) { + if (okapi?.entitlementUrl) { // fetches the list of registered apps/metadata, // loads icons and translations, then module code, // ultimately stores the result in the modules state to pass down into the modules context. diff --git a/src/components/EntitlementLoader.test.js b/src/components/EntitlementLoader.test.js index e43917b56..95c889154 100644 --- a/src/components/EntitlementLoader.test.js +++ b/src/components/EntitlementLoader.test.js @@ -64,9 +64,11 @@ describe('EntitlementLoader', () => { }; const mockRegistry = { - remotes: { - 'app-module': { - url: 'http://localhost:3000/remoteEntry.js', + discovery: [ + { + name: 'app_module', + id: 'app_module-1.0.0', + location: 'http://localhost:3000/remoteEntry.js', host: 'localhost', port: 3000, module: 'app-module', @@ -74,18 +76,20 @@ describe('EntitlementLoader', () => { actsAs: ['app'], icons: [{ name: 'icon', title: 'icon title' }], }, - 'plugin-module': { - url: 'http://localhost:3001/remoteEntry.js', + { + name: 'plugin_module', + location: 'http://localhost:3001/remoteEntry.js', + id: 'plugin_module-4.0.0', host: 'localhost', port: 3001, module: 'plugin-module', displayName: 'pluginModule.label', actsAs: ['plugin'], }, - }, + ], }; - const mockRemotes = Object.entries(mockRegistry?.remotes).map(([name, metadata]) => ({ name, ...metadata })); + const mockRemotes = mockRegistry.discovery; const translations = { 'testModule.label': 'Test Module Display', @@ -168,15 +172,16 @@ describe('EntitlementLoader', () => { }); it('fetches the registry and loads modules dynamically', async () => { - render(); + const entitlementUrl = 'http://localhost:8000/entitlement'; + render(); await waitFor(() => { - expect(loadEntitlement).toHaveBeenCalledWith('http://localhost:8000/entitlement'); + expect(loadEntitlement).toHaveBeenCalledWith(entitlementUrl, new AbortController().signal); }); }); it('passes dynamic modules to ModulesContext.Provider', async () => { - render(); + render(); await waitFor(() => { // expect(screen.queryByText('No Modules')).not.toBeInTheDocument(); @@ -334,6 +339,8 @@ describe('EntitlementLoader', () => { port: 3000, module: 'test-module', displayName: 'testModule.label', + assetPath: 'localhost:3000/path', + location: 'localhost:3000' }; beforeEach(() => { @@ -350,7 +357,7 @@ describe('EntitlementLoader', () => { const result = await loadModuleAssets(mockStripes, module); expect(global.fetch).toHaveBeenCalledWith( - 'localhost:3000/translations/en_US.json' + 'localhost:3000/path/translations/en_US.json' ); expect(result.displayName).toBe('Test Module Display'); }); @@ -408,8 +415,8 @@ describe('EntitlementLoader', () => { ok: true, json: jest.fn().mockResolvedValueOnce(mockRegistry), }); - const remotes = await actualLoadEntitlement(okapi.entitlementUrl); - expect(fetch).toHaveBeenCalledWith(okapi.entitlementUrl); + const remotes = await actualLoadEntitlement('okapi:3000'); + expect(fetch).toHaveBeenCalledWith('okapi:3000', { signal: undefined }); expect(remotes).toEqual(mockRemotes); }); }); diff --git a/test/bigtest/tests/LoadRemoteComponent-test.js b/test/bigtest/tests/LoadRemoteComponent-test.js index 7a7a0597c..396190418 100644 --- a/test/bigtest/tests/LoadRemoteComponent-test.js +++ b/test/bigtest/tests/LoadRemoteComponent-test.js @@ -1,17 +1,18 @@ import { beforeEach, it, afterEach, describe } from 'mocha'; import { expect } from 'chai'; -import startMirage from '../network/start'; +import { createServer, Response } from 'miragejs'; + import loadRemoteComponent from '../../../src/loadRemoteComponent'; -describe('loadRemoteComponent', () => { +describe.only('loadRemoteComponent', () => { let server; - const mockRemoteUrl = 'http://example.com/testRemote/remoteEntry.js'; - const mockErrorUrl = 'http://example.com/nonexistent/remoteEntry.js'; + const mockRemoteUrl = '/example/testRemote/remoteEntry.js'; + const mockErrorUrl = 'https://example.com/nonexistent/remoteEntry.js'; const mockRemoteName = 'testComponent'; beforeEach(async function () { - server = startMirage(); + server = createServer({ environment: 'test' }); server.get(mockRemoteUrl, () => { const mockScriptContent = `window['${mockRemoteName}'] = { init: function() { console.log("Component initialized"); }, @@ -19,6 +20,7 @@ describe('loadRemoteComponent', () => { }; `; + // return mockScriptContent; return mockScriptContent; }); @@ -31,16 +33,19 @@ describe('loadRemoteComponent', () => { delete window[mockRemoteName]; }); - it('should load and evaluate the remote script', async () => { - await loadRemoteComponent(mockRemoteUrl, mockRemoteName); - expect(window[mockRemoteName]).to.be.an('object'); + it('should inject the script tag with the requested src attribute', async () => { + try { + await loadRemoteComponent(mockRemoteUrl, mockRemoteName); + } catch (error) { + expect(Array.from(document.querySelectorAll('script')).find(scr => scr.src === mockRemoteUrl)).to.not.be.null; + } }); it('should handle errors when loading the remote script', async () => { try { await loadRemoteComponent(mockErrorUrl, mockRemoteName); } catch (error) { - expect(error.message).to.equal(`Failed to fetch remote module from ${mockErrorUrl}`); + expect(error.message).to.equal(`Failed to load remote script from ${mockErrorUrl}`); } }); }); From a1130d19981b4b604dc4efbb30cd608f214ffa6e Mon Sep 17 00:00:00 2001 From: John Coburn Date: Sun, 1 Feb 2026 08:09:44 -0600 Subject: [PATCH 56/59] add default mock for localforage/hostLocation (null), add test for loading translations when hostLocation is present in localforage. --- src/loginServices.test.js | 1957 +++++++++++++++++++------------------ 1 file changed, 987 insertions(+), 970 deletions(-) diff --git a/src/loginServices.test.js b/src/loginServices.test.js index 8853aeee3..4e62da6ef 100644 --- a/src/loginServices.test.js +++ b/src/loginServices.test.js @@ -56,7 +56,7 @@ import { updateCurrentUser, } from './okapiActions'; -import { defaultErrors } from './constants'; +import { defaultErrors, stripesHubAPI } from './constants'; jest.mock('./loginServices', () => ({ ...jest.requireActual('./loginServices'), @@ -68,8 +68,15 @@ jest.mock('./discoverServices', () => ({ discoverServices: jest.fn().mockResolvedValue([]), })); +const mockStripesHubAPI = stripesHubAPI; + jest.mock('localforage', () => ({ - getItem: jest.fn(() => Promise.resolve({ user: {} })), + getItem: jest.fn((str) => { + if (str === mockStripesHubAPI.HOST_LOCATION_KEY) { + return Promise.resolve(null); + } + return Promise.resolve({ user: {} }); + }), setItem: jest.fn(() => Promise.resolve()), removeItem: jest.fn(() => Promise.resolve()), })); @@ -84,7 +91,7 @@ jest.mock('stripes-config', () => ({ okapi: { authnUrl: 'https://authn.url', }, - translations: {} + translations: { cs_CZ: 'cs-CZ', cs: 'cs-CZ', fr: 'fr', ar: 'ar', en_US: 'en-US', en_GB: 'en-GB' } })); // fetch success: resolve promise with ok == true and $data in json() @@ -110,872 +117,1053 @@ const mockFetchCleanUp = () => { delete global.fetch; }; -describe('createOkapiSession', () => { - it('clears authentication errors', async () => { - const store = { - dispatch: jest.fn(), - getState: () => ({ - okapi: { - currentPerms: [], - url: 'okapiUrl' - } - }), - }; +describe('loginServices', () => { + afterEach(() => { + jest.resetAllMocks(); + }); - const te = { - accessTokenExpiration: '2023-11-06T18:05:33Z', - refreshTokenExpiration: '2023-10-30T18:15:33Z', - }; + describe('createOkapiSession', () => { + it('clears authentication errors', async () => { + const store = { + dispatch: jest.fn(), + getState: () => ({ + okapi: { + currentPerms: [], + url: 'okapiUrl' + } + }), + }; - const data = { - user: { - id: 'user-id', - }, - permissions: { - permissions: [{ permissionName: 'a' }, { permissionName: 'b' }] - }, - tokenExpiration: te, - }; - const permissionsMap = { a: true, b: true }; - mockFetchSuccess([]); + const te = { + accessTokenExpiration: '2023-11-06T18:05:33Z', + refreshTokenExpiration: '2023-10-30T18:15:33Z', + }; - await createOkapiSession(store, 'tenant', 'token', data); - expect(store.dispatch).toHaveBeenCalledWith(setIsAuthenticated(true)); - expect(store.dispatch).toHaveBeenCalledWith(setAuthError(null)); - expect(store.dispatch).toHaveBeenCalledWith(setLoginData(data)); - expect(store.dispatch).toHaveBeenCalledWith(setCurrentPerms(permissionsMap)); + const data = { + user: { + id: 'user-id', + }, + permissions: { + permissions: [{ permissionName: 'a' }, { permissionName: 'b' }] + }, + tokenExpiration: te, + }; + const permissionsMap = { a: true, b: true }; + mockFetchSuccess([]); - mockFetchCleanUp(); - }); -}); + await createOkapiSession(store, 'tenant', 'token', data); + expect(store.dispatch).toHaveBeenCalledWith(setIsAuthenticated(true)); + expect(store.dispatch).toHaveBeenCalledWith(setAuthError(null)); + expect(store.dispatch).toHaveBeenCalledWith(setLoginData(data)); + expect(store.dispatch).toHaveBeenCalledWith(setCurrentPerms(permissionsMap)); -describe('handleLoginError', () => { - it('dispatches setOkapiReady', async () => { - const dispatch = jest.fn(); - await handleLoginError(dispatch, {}); - expect(dispatch).toHaveBeenCalledWith(setOkapiReady()); - expect(dispatch).toHaveBeenCalledWith(setAuthError([defaultErrors.DEFAULT_LOGIN_CLIENT_ERROR])); + mockFetchCleanUp(); + }); }); -}); - -describe('loadTranslations', () => { - it('dispatches setLocale', async () => { - const store = { - dispatch: jest.fn(), - }; - const locale = 'cs-CZ'; - mockFetchSuccess({}); - await loadTranslations(store, locale, {}); - expect(store.dispatch).toHaveBeenCalledWith(setLocale(locale)); - mockFetchCleanUp(); + describe('handleLoginError', () => { + it('dispatches setOkapiReady', async () => { + const dispatch = jest.fn(); + await handleLoginError(dispatch, {}); + expect(dispatch).toHaveBeenCalledWith(setOkapiReady()); + expect(dispatch).toHaveBeenCalledWith(setAuthError([defaultErrors.DEFAULT_LOGIN_CLIENT_ERROR])); + }); }); - describe('sets document attributes correctly', () => { - it('sets lang given region', async () => { - const store = { + describe('loadTranslations', () => { + let store; + beforeEach(() => { + store = { dispatch: jest.fn(), }; + mockFetchSuccess({}); + }); + + afterEach(() => { + jest.restoreAllMocks(); + }); + + it('dispatches setLocale', async () => { const locale = 'cs-CZ'; - mockFetchSuccess({}); await loadTranslations(store, locale, {}); - expect(document.documentElement.lang).toMatch('cs'); - mockFetchCleanUp(); + expect(store.dispatch).toHaveBeenCalledWith(setLocale(locale)); + }); + + describe('sets document attributes correctly', () => { + it('sets lang given region', async () => { + const locale = 'cs-CZ'; + await loadTranslations(store, locale, {}); + expect(document.documentElement.lang).toMatch('cs'); + }); + + it('sets lang without region', async () => { + const locale = 'cs'; + await loadTranslations(store, locale, {}); + expect(document.documentElement.lang).toMatch('cs'); + }); + + it('sets dir (LTR)', async () => { + const locale = 'fr'; + await loadTranslations(store, locale, {}); + expect(document.dir).toMatch('ltr'); + }); + + it('sets dir (RTL)', async () => { + const locale = 'ar'; + await loadTranslations(store, locale, {}); + expect(document.dir).toMatch('rtl'); + }); }); - it('sets lang without region', async () => { + describe('when localforage contains a hostLocation', () => { + it('fetches using the hostLocation from localforage', async () => { + const hostLocation = 'http://my-app-here'; + const locale = 'cs-CZ'; + localforage.getItem.mockResolvedValue(hostLocation); + await loadTranslations(store, locale, { cs: 'cs-CZ' }); + expect(global.fetch).toHaveBeenCalledWith(`${hostLocation}/cs-CZ`); + }); + }); + }); + + describe('processOkapiSession', () => { + it('handles success', async () => { const store = { dispatch: jest.fn(), + getState: () => ({ + okapi: { + currentPerms: [], + authnUrl: 'keycloakURL' + } + }), }; - const locale = 'cs'; - mockFetchSuccess({}); - await loadTranslations(store, locale, {}); - expect(document.documentElement.lang).toMatch('cs'); + const resp = { + headers: { + get: jest.fn(), + }, + ok: true, + json: () => Promise.resolve({ + user: { id: 'id' }, + permissions: { + permissions: [{ permissionName: 'a' }, { permissionName: 'b' }] + } + }), + }; + + mockFetchSuccess(); + + await processOkapiSession(store, 'tenant', resp); + expect(store.dispatch).toHaveBeenCalledWith(setAuthError(null)); + expect(store.dispatch).toHaveBeenCalledWith(setOkapiReady()); + mockFetchCleanUp(); }); - it('sets dir (LTR)', async () => { + it('handles error', async () => { const store = { - dispatch: jest.fn(), + dispatch: jest.fn() + }; + const resp = { + headers: { + get: jest.fn(), + } }; - const locale = 'fr'; - mockFetchSuccess({}); - await loadTranslations(store, locale, {}); - expect(document.dir).toMatch('ltr'); - mockFetchCleanUp(); + await processOkapiSession(store, 'tenant', resp); + + expect(store.dispatch).toHaveBeenCalledWith(setOkapiReady()); + expect(store.dispatch).toHaveBeenCalledWith(setAuthError([defaultErrors.DEFAULT_LOGIN_CLIENT_ERROR])); + }); + }); + + describe('supportedLocales', () => { + it('is an array of strings', () => { + expect(Array.isArray(supportedLocales)).toBe(true); + expect(typeof supportedLocales[0]).toBe('string'); + }); + }); + + describe('supportedNumberingSystems', () => { + it('is an array of strings', () => { + expect(Array.isArray(supportedNumberingSystems)).toBe(true); + expect(typeof supportedNumberingSystems[0]).toBe('string'); }); + }); - it('sets dir (RTL)', async () => { + describe('validateUser', () => { + it('handles fetch failure from "_self"', async () => { const store = { dispatch: jest.fn(), + getState: () => ({ okapi: { tenant: 'monkey', url: 'monkeyUrl' } }), }; - const locale = 'ar'; + const handleError = jest.fn().mockReturnValue(Promise.resolve()); - mockFetchSuccess({}); - await loadTranslations(store, locale, {}); - expect(document.dir).toMatch('rtl'); + mockFetchError(); + + const res = await validateUser('url', store, 'tenant', {}, handleError); + expect(handleError).toHaveBeenCalled(); + expect(res).toBeUndefined(); mockFetchCleanUp(); }); - }); -}); -describe('processOkapiSession', () => { - it('handles success', async () => { - const store = { - dispatch: jest.fn(), - getState: () => ({ - okapi: { - currentPerms: [], - authnUrl: 'keycloakURL' - } - }), - }; + it('handles valid user with empty tenant in session', async () => { + const store = { + dispatch: jest.fn(), + getState: () => ({ okapi: { tenant: 'monkey', url: 'monkeyUrl', currentPerms: { 'configuration.entries.collection.get': true } } }), + }; - const resp = { - headers: { - get: jest.fn(), - }, - ok: true, - json: () => Promise.resolve({ - user: { id: 'id' }, - permissions: { - permissions: [{ permissionName: 'a' }, { permissionName: 'b' }] - } - }), - }; + const tenant = 'tenant'; + const data = { monkey: 'bagel' }; + const user = { id: 'id' }; + const perms = []; + const session = { + user, + perms, + }; - mockFetchSuccess(); + mockFetchSuccess(data); - await processOkapiSession(store, 'tenant', resp); - expect(store.dispatch).toHaveBeenCalledWith(setAuthError(null)); - expect(store.dispatch).toHaveBeenCalledWith(setOkapiReady()); + // set a fixed system time so date math is stable + const now = new Date('2023-10-30T19:34:56.000Z'); + jest.useFakeTimers().setSystemTime(now); - mockFetchCleanUp(); - }); + await validateUser('url', store, tenant, session); - it('handles error', async () => { - const store = { - dispatch: jest.fn() - }; - const resp = { - headers: { - get: jest.fn(), - } - }; + expect(store.dispatch).toHaveBeenNthCalledWith(1, setAuthError(null)); + expect(store.dispatch).toHaveBeenNthCalledWith(2, setLoginData(data)); - await processOkapiSession(store, 'tenant', resp); + mockFetchCleanUp(); + }); - expect(store.dispatch).toHaveBeenCalledWith(setOkapiReady()); - expect(store.dispatch).toHaveBeenCalledWith(setAuthError([defaultErrors.DEFAULT_LOGIN_CLIENT_ERROR])); - }); -}); + it('handles valid user with tenant in session', async () => { + const store = { + dispatch: jest.fn(), + getState: () => ({ okapi: { tenant: 'monkey', url: 'monkeyUrl' } }), + }; -describe('supportedLocales', () => { - it('is an array of strings', () => { - expect(Array.isArray(supportedLocales)).toBe(true); - expect(typeof supportedLocales[0]).toBe('string'); - }); -}); + const tenant = 'tenant'; + const sessionTenant = 'sessionTenant'; + const data = { monkey: 'bagel' }; + const user = { id: 'id' }; + const perms = []; + const session = { + user, + perms, + tenant: sessionTenant, + }; -describe('supportedNumberingSystems', () => { - it('is an array of strings', () => { - expect(Array.isArray(supportedNumberingSystems)).toBe(true); - expect(typeof supportedNumberingSystems[0]).toBe('string'); - }); -}); + mockFetchSuccess(data); -describe('validateUser', () => { - it('handles fetch failure from "_self"', async () => { - const store = { - dispatch: jest.fn(), - getState: () => ({ okapi: { tenant: 'monkey', url: 'monkeyUrl' } }), - }; - const handleError = jest.fn().mockReturnValue(Promise.resolve()); + await validateUser('url', store, tenant, session); + expect(store.dispatch).toHaveBeenNthCalledWith(1, setAuthError(null)); + expect(store.dispatch).toHaveBeenNthCalledWith(2, setLoginData(data)); - mockFetchError(); + mockFetchCleanUp(); + }); - const res = await validateUser('url', store, 'tenant', {}, handleError); - expect(handleError).toHaveBeenCalled(); - expect(res).toBeUndefined(); - mockFetchCleanUp(); - }); + it('overwrites session data with new values from _self', async () => { + const store = { + dispatch: jest.fn(), + getState: () => ({ okapi: { tenant: 'monkey', url: 'monkeyUrl' } }), + }; - it('handles valid user with empty tenant in session', async () => { - const store = { - dispatch: jest.fn(), - getState: () => ({ okapi: { tenant: 'monkey', url: 'monkeyUrl', currentPerms: { 'configuration.entries.collection.get': true } } }), - }; + const tenant = 'tenant'; + const sessionTenant = 'sessionTenant'; + const data = { + user: { + id: 'ego', + username: 'superego', + }, + permissions: { + permissions: [{ permissionName: 'ask' }, { permissionName: 'tell' }], + } + }; - const tenant = 'tenant'; - const data = { monkey: 'bagel' }; - const user = { id: 'id' }; - const perms = []; - const session = { - user, - perms, - }; + const session = { + user: { + id: 'id', + username: 'username', + storageOnlyValue: 'is still persisted', + }, + perms: { foo: true }, + tenant: sessionTenant, + token: 'token', + }; - mockFetchSuccess(data); + mockFetchSuccess(data); - // set a fixed system time so date math is stable - const now = new Date('2023-10-30T19:34:56.000Z'); - jest.useFakeTimers().setSystemTime(now); + await validateUser('url', store, tenant, session); - await validateUser('url', store, tenant, session); + const updatedSession = { + user: { ...session.user, ...data.user }, + isAuthenticated: true, + perms: { ask: true, tell: true }, + tenant: session.tenant, + token: session.token, + }; - expect(store.dispatch).toHaveBeenNthCalledWith(1, setAuthError(null)); - expect(store.dispatch).toHaveBeenNthCalledWith(2, setLoginData(data)); + expect(store.dispatch).toHaveBeenNthCalledWith(1, setAuthError(null)); + expect(store.dispatch).toHaveBeenNthCalledWith(2, setLoginData(data)); + expect(store.dispatch).toHaveBeenNthCalledWith(3, setCurrentPerms({ ask: true, tell: true })); + expect(store.dispatch).toHaveBeenNthCalledWith(4, setSessionData(updatedSession)); - mockFetchCleanUp(); - }); + mockFetchCleanUp(); + }); - it('handles valid user with tenant in session', async () => { - const store = { - dispatch: jest.fn(), - getState: () => ({ okapi: { tenant: 'monkey', url: 'monkeyUrl' } }), - }; + it('handles invalid user', async () => { + const store = { + dispatch: jest.fn(), + getState: () => ({ okapi: { tenant: 'monkey', url: 'monkeyUrl' } }), + }; + const handleError = jest.fn().mockReturnValue(Promise.resolve()); - const tenant = 'tenant'; - const sessionTenant = 'sessionTenant'; - const data = { monkey: 'bagel' }; - const user = { id: 'id' }; - const perms = []; - const session = { - user, - perms, - tenant: sessionTenant, - }; + global.fetch = jest.fn().mockImplementation(() => { + return Promise.resolve({ ok: false, text: () => Promise.resolve('boom') }); + }); - mockFetchSuccess(data); + const res = await validateUser('url', store, 'tenant', {}, handleError); + expect(handleError).toHaveBeenCalled(); + expect(res).toBeUndefined(); + mockFetchCleanUp(); + }); + }); - await validateUser('url', store, tenant, session); - expect(store.dispatch).toHaveBeenNthCalledWith(1, setAuthError(null)); - expect(store.dispatch).toHaveBeenNthCalledWith(2, setLoginData(data)); + describe('updateUser', () => { + it('dispatches updateCurrentUser', async () => { + const store = { + dispatch: jest.fn(), + }; - mockFetchCleanUp(); + const session = { + user: { + id: 'id', + username: 'username', + storageOnlyValue: 'is still persisted', + }, + perms: { foo: true }, + tenant: 'testTenant', + token: 'token', + }; + const data = { thunder: 'chicken' }; + localforage.getItem.mockResolvedValue(session); + await updateUser(store, data); + expect(store.dispatch).toHaveBeenCalledWith(updateCurrentUser(data)); + }); }); - it('overwrites session data with new values from _self', async () => { - const store = { - dispatch: jest.fn(), - getState: () => ({ okapi: { tenant: 'monkey', url: 'monkeyUrl' } }), + describe('updateTenant', () => { + const okapi = { + currentPerms: {}, }; - - const tenant = 'tenant'; - const sessionTenant = 'sessionTenant'; + const tenant = 'test'; const data = { user: { - id: 'ego', - username: 'superego', + id: 'userId', + username: 'testuser', }, permissions: { - permissions: [{ permissionName: 'ask' }, { permissionName: 'tell' }], - } - }; - - const session = { - user: { - id: 'id', - username: 'username', - storageOnlyValue: 'is still persisted', + permissions: [{ permissionName: 'test.permissions' }], }, - perms: { foo: true }, - tenant: sessionTenant, - token: 'token', }; - mockFetchSuccess(data); - - await validateUser('url', store, tenant, session); - - const updatedSession = { - user: { ...session.user, ...data.user }, - isAuthenticated: true, - perms: { ask: true, tell: true }, - tenant: session.tenant, - token: session.token, - }; + beforeEach(() => { + localforage.setItem.mockClear(); + }); - expect(store.dispatch).toHaveBeenNthCalledWith(1, setAuthError(null)); - expect(store.dispatch).toHaveBeenNthCalledWith(2, setLoginData(data)); - expect(store.dispatch).toHaveBeenNthCalledWith(3, setCurrentPerms({ ask: true, tell: true })); - expect(store.dispatch).toHaveBeenNthCalledWith(4, setSessionData(updatedSession)); + it('should set tenant and updated user in session', async () => { + mockFetchSuccess(data); + await updateTenant(okapi, tenant); + mockFetchCleanUp(); - mockFetchCleanUp(); + expect(localforage.setItem).toHaveBeenCalledWith('okapiSess', { + ...spreadUserWithPerms(data), + tenant, + }); + }); }); - it('handles invalid user', async () => { - const store = { - dispatch: jest.fn(), - getState: () => ({ okapi: { tenant: 'monkey', url: 'monkeyUrl' } }), - }; - const handleError = jest.fn().mockReturnValue(Promise.resolve()); + describe('localforage wrappers', () => { + describe('getOkapiSession', () => { + it('retrieves a session object', async () => { + const o = { + margo: 'timmins', + margot: 'margot with a t looks better', + also: 'i thought we were talking about margot robbie?', + tokenExpiration: 'time out of mind', + }; - global.fetch = jest.fn().mockImplementation(() => { - return Promise.resolve({ ok: false, text: () => Promise.resolve('boom') }); - }); + localforage.getItem.mockResolvedValue(o); - const res = await validateUser('url', store, 'tenant', {}, handleError); - expect(handleError).toHaveBeenCalled(); - expect(res).toBeUndefined(); - mockFetchCleanUp(); - }); -}); + const s = await getOkapiSession(); + expect(s).toMatchObject(o); + }); + }); -describe('updateUser', () => { - it('dispatches updateCurrentUser', async () => { - const store = { - dispatch: jest.fn(), - }; - const data = { thunder: 'chicken' }; - await updateUser(store, data); - expect(store.dispatch).toHaveBeenCalledWith(updateCurrentUser(data)); - }); -}); + describe('getTokenExpiry', () => { + it('finds tokenExpiration', async () => { + const o = { tokenExpiration: { trinity: 'cowboy junkies' } }; + localforage.getItem = jest.fn(() => Promise.resolve(o)); -describe('updateTenant', () => { - const okapi = { - currentPerms: {}, - }; - const tenant = 'test'; - const data = { - user: { - id: 'userId', - username: 'testuser', - }, - permissions: { - permissions: [{ permissionName: 'test.permissions' }], - }, - }; - - beforeEach(() => { - localforage.setItem.mockClear(); - }); + const s = await getTokenExpiry(); + expect(s).toMatchObject(o.tokenExpiration); + }); - it('should set tenant and updated user in session', async () => { - mockFetchSuccess(data); - await updateTenant(okapi, tenant); - mockFetchCleanUp(); + it('handles missing tokenExpiration', async () => { + const o = { nobody: 'here but us chickens' }; + localforage.getItem = jest.fn(() => Promise.resolve(o)); - expect(localforage.setItem).toHaveBeenCalledWith('okapiSess', { - ...spreadUserWithPerms(data), - tenant, + const s = await getTokenExpiry(); + expect(s).toBeFalsy(); + }); }); - }); -}); -describe('localforage wrappers', () => { - describe('getOkapiSession', () => { - it('retrieves a session object', async () => { - const o = { - margo: 'timmins', - margot: 'margot with a t looks better', - also: 'i thought we were talking about margot robbie?', - tokenExpiration: 'time out of mind', - }; + describe('setTokenExpiry', () => { + describe('rejects invalid input', () => { + it('missing values', async () => { + const te = { + trinity: 'cowboy junkies', + sweet: 'james', + }; - localforage.getItem = jest.fn(() => Promise.resolve(o)); + await expect(setTokenExpiry(te)).rejects.toThrow(TypeError); + }); - const s = await getOkapiSession(); - expect(s).toMatchObject(o); - }); - }); + describe('non-integer values', () => { + it('atExpires', async () => { + const te = { + atExpires: 3.1415926, + }; + await expect(setTokenExpiry(te)).rejects.toThrow(TypeError); + }); + + it('rtExpires', async () => { + const te = { + atExpires: 9_007_199_254_740_991, + rtExpires: 2.71828, + }; + await expect(setTokenExpiry(te)).rejects.toThrow(TypeError); + }); + }); + }); - describe('getTokenExpiry', () => { - it('finds tokenExpiration', async () => { - const o = { tokenExpiration: { trinity: 'cowboy junkies' } }; - localforage.getItem = jest.fn(() => Promise.resolve(o)); + it('returns updated session data', async () => { + const storage = { + margo: 'timmins', + margot: 'margot with a t looks better', + also: 'i thought we were talking about margot robbie?', + tokenExpiration: 'time out of mind', + }; - const s = await getTokenExpiry(); - expect(s).toMatchObject(o.tokenExpiration); - }); + localforage.getItem = jest.fn(() => Promise.resolve(storage)); + localforage.setItem = jest.fn((k, v) => Promise.resolve(v)); - it('handles missing tokenExpiration', async () => { - const o = { nobody: 'here but us chickens' }; - localforage.getItem = jest.fn(() => Promise.resolve(o)); - const s = await getTokenExpiry(); - expect(s).toBeFalsy(); + const te = { + atExpires: 1, + rtExpires: 2, + }; + const res = await setTokenExpiry(te); + expect(res).toMatchObject({ ...storage, tokenExpiration: te }); + }); }); }); - describe('setTokenExpiry', () => { - describe('rejects invalid input', () => { - it('missing values', async () => { - const te = { - trinity: 'cowboy junkies', - sweet: 'james', + describe('logout', () => { + describe('when logout has started in this window', () => { + it('returns immediately', async () => { + const store = { + dispatch: jest.fn(), }; + window.sessionStorage.clear(); + window.sessionStorage.setItem(IS_LOGGING_OUT, 'true'); + + let res; + await logout('', store) + .then(() => { + res = true; + }); + expect(res).toBe(true); + expect(store.dispatch).not.toHaveBeenCalled(); + }); + }); - await expect(setTokenExpiry(te)).rejects.toThrow(TypeError); + describe('when logout has not started in this window', () => { + afterEach(() => { + mockFetchCleanUp(); }); - describe('non-integer values', () => { - it('atExpires', async () => { - const te = { - atExpires: 3.1415926, - }; - await expect(setTokenExpiry(te)).rejects.toThrow(TypeError); - }); + it('clears the redux store', async () => { + global.fetch = jest.fn().mockImplementation(() => Promise.resolve()); + const store = { + dispatch: jest.fn(), + getState: jest.fn(), + }; + window.sessionStorage.clear(); + + let res; + await logout('', store) + .then(() => { + res = true; + }); + expect(res).toBe(true); + + // expect(setItemSpy).toHaveBeenCalled(); + expect(store.dispatch).toHaveBeenCalledWith(setIsAuthenticated(false)); + expect(store.dispatch).toHaveBeenCalledWith(clearCurrentUser()); + expect(store.dispatch).toHaveBeenCalledWith(clearOkapiToken()); + }); - it('rtExpires', async () => { - const te = { - atExpires: 9_007_199_254_740_991, - rtExpires: 2.71828, - }; - await expect(setTokenExpiry(te)).rejects.toThrow(TypeError); - }); + it('calls fetch() when other window is not logging out', async () => { + global.fetch = jest.fn().mockImplementation(() => Promise.resolve()); + localStorage.setItem(SESSION_NAME, 'true'); + const store = { + dispatch: jest.fn(), + getState: jest.fn(), + }; + window.sessionStorage.clear(); + + let res; + await logout('', store) + .then(() => { + res = true; + }); + + expect(res).toBe(true); + expect(global.fetch).toHaveBeenCalled(); + }); + + it('does not call fetch() when other window is logging out', async () => { + global.fetch = jest.fn().mockImplementation(() => Promise.resolve()); + localStorage.clear(); + const store = { + dispatch: jest.fn(), + }; + window.sessionStorage.clear(); + + let res; + await logout('', store) + .then(() => { + res = true; + }); + + expect(res).toBe(true); + expect(global.fetch).not.toHaveBeenCalled(); }); }); - it('returns updated session data', async () => { - const storage = { - margo: 'timmins', - margot: 'margot with a t looks better', - also: 'i thought we were talking about margot robbie?', - tokenExpiration: 'time out of mind', - }; + describe('react-query client', () => { + afterEach(() => { + mockFetchCleanUp(); + }); - localforage.getItem = jest.fn(() => Promise.resolve(storage)); - localforage.setItem = jest.fn((k, v) => Promise.resolve(v)); + it('calls removeQueries given valid client', async () => { + global.fetch = jest.fn().mockImplementation(() => Promise.resolve()); + const store = { + dispatch: jest.fn(), + }; + const rqc = { + removeQueries: jest.fn(), + }; + let res; + await logout('', store, rqc) + .then(() => { + res = true; + }); - const te = { - atExpires: 1, - rtExpires: 2, - }; - const res = await setTokenExpiry(te); - expect(res).toMatchObject({ ...storage, tokenExpiration: te }); + expect(res).toBe(true); + expect(rqc.removeQueries).toHaveBeenCalled(); + }); }); }); -}); -describe('logout', () => { - describe('when logout has started in this window', () => { - it('returns immediately', async () => { + describe('getLocale', () => { + it('dispatches setTimezone, setCurrency', async () => { + const value = { timezone: 'America/New_York', currency: 'USD' }; + mockFetchSuccess({ configs: [{ value: JSON.stringify(value) }] }); const store = { dispatch: jest.fn(), + getState: () => ({ okapi: {} }), }; - window.sessionStorage.clear(); - window.sessionStorage.setItem(IS_LOGGING_OUT, 'true'); - - let res; - await logout('', store) - .then(() => { - res = true; - }); - expect(res).toBe(true); - expect(store.dispatch).not.toHaveBeenCalled(); + await getLocale('url', store, 'tenant'); + expect(store.dispatch).toHaveBeenCalledWith(setTimezone(value.timezone)); + expect(store.dispatch).toHaveBeenCalledWith(setCurrency(value.currency)); + mockFetchCleanUp(); }); }); - describe('when logout has not started in this window', () => { - afterEach(() => { + describe('getUserLocale', () => { + it('dispatches setTimezone, setCurrency', async () => { + const value = { locale: 'en-US', timezone: 'America/New_York', currency: 'USD' }; + mockFetchSuccess({ configs: [{ value: JSON.stringify(value) }] }); + const store = { + dispatch: jest.fn(), + getState: () => ({ okapi: {} }), + }; + await getUserLocale('url', store, 'tenant'); + expect(store.dispatch).toHaveBeenCalledWith(setTimezone(value.timezone)); + expect(store.dispatch).toHaveBeenCalledWith(setCurrency(value.currency)); mockFetchCleanUp(); }); + }); - it('clears the redux store', async () => { - global.fetch = jest.fn().mockImplementation(() => Promise.resolve()); + describe('getPlugins', () => { + it('dispatches setPlugins', async () => { + const configs = [ + { configName: 'find-user', value: '@folio/plugin-hello-waldo' }, + { configName: 'find-water', value: '@folio/plugin-dowsing-rod' }, + ]; + mockFetchSuccess({ configs }); const store = { dispatch: jest.fn(), - getState: jest.fn(), + getState: () => ({ okapi: {} }), }; - window.sessionStorage.clear(); - - let res; - await logout('', store) - .then(() => { - res = true; - }); - expect(res).toBe(true); + await getPlugins('url', store, 'tenant'); - // expect(setItemSpy).toHaveBeenCalled(); - expect(store.dispatch).toHaveBeenCalledWith(setIsAuthenticated(false)); - expect(store.dispatch).toHaveBeenCalledWith(clearCurrentUser()); - expect(store.dispatch).toHaveBeenCalledWith(clearOkapiToken()); + const mappedConfigs = configs.reduce((acc, val) => ({ + ...acc, + [val.configName]: val.value, + }), {}); + expect(store.dispatch).toHaveBeenCalledWith(setPlugins(mappedConfigs)); + mockFetchCleanUp(); }); + }); - it('calls fetch() when other window is not logging out', async () => { - global.fetch = jest.fn().mockImplementation(() => Promise.resolve()); - localStorage.setItem(SESSION_NAME, 'true'); + describe('getBindings', () => { + it('dispatches setBindings', async () => { + const value = { key: 'value' }; + mockFetchSuccess({ configs: [{ value: JSON.stringify(value) }] }); const store = { dispatch: jest.fn(), - getState: jest.fn(), + getState: () => ({ okapi: {} }), }; + await getBindings('url', store, 'tenant'); + expect(store.dispatch).toHaveBeenCalledWith(setBindings(value)); + mockFetchCleanUp(); + }); + }); + + describe('unauthorizedPath functions', () => { + beforeEach(() => { window.sessionStorage.clear(); + }); - let res; - await logout('', store) - .then(() => { - res = true; - }); + afterEach(() => { + window.sessionStorage.clear(); + }); - expect(res).toBe(true); - expect(global.fetch).toHaveBeenCalled(); + describe('removeUnauthorizedPathFromSession', () => { + it('clears the value', () => { + setUnauthorizedPathToSession('monkey'); + removeUnauthorizedPathFromSession(); + expect(getUnauthorizedPathFromSession()).toBe(null); + }); }); - it('does not call fetch() when other window is logging out', async () => { - global.fetch = jest.fn().mockImplementation(() => Promise.resolve()); - localStorage.clear(); - const store = { - dispatch: jest.fn(), - }; - window.sessionStorage.clear(); + describe('setUnauthorizedPathToSession', () => { + it('stores the given value', () => { + const value = 'monkey'; + setUnauthorizedPathToSession(value); + expect(getUnauthorizedPathFromSession()).toBe(value); + }); + + it('stores the current location given no value', () => { + window.location.pathname = '/some-path'; + window.location.search = '?monkey=bagel'; + setUnauthorizedPathToSession(); + expect(getUnauthorizedPathFromSession()).toBe(`${window.location.pathname}${window.location.search}`); + }); - let res; - await logout('', store) - .then(() => { - res = true; + describe('refuses to set locations beginning with "/logout"', () => { + it('with an argument', () => { + const monkey = '/logout-timeout'; + setUnauthorizedPathToSession(monkey); + expect(getUnauthorizedPathFromSession()).toBeFalsy(); }); - expect(res).toBe(true); - expect(global.fetch).not.toHaveBeenCalled(); + it('without an argument', () => { + window.location.pathname = '/logout-timeout'; + setUnauthorizedPathToSession(); + expect(getUnauthorizedPathFromSession()).toBeFalsy(); + }); + }); }); - }); - describe('react-query client', () => { - afterEach(() => { - mockFetchCleanUp(); + describe('getUnauthorizedPathFromSession', () => { + it('retrieves the value', () => { + const value = 'monkey'; + setUnauthorizedPathToSession(value); + expect(getUnauthorizedPathFromSession()).toBe(value); + }); }); - it('calls removeQueries given valid client', async () => { - global.fetch = jest.fn().mockImplementation(() => Promise.resolve()); - const store = { - dispatch: jest.fn(), - }; - const rqc = { - removeQueries: jest.fn(), - }; - - let res; - await logout('', store, rqc) - .then(() => { - res = true; - }); + describe('getLogoutTenant', () => { + afterEach(() => { + localStorage.clear(); + }); - expect(res).toBe(true); - expect(rqc.removeQueries).toHaveBeenCalled(); + it('retrieves the value from localstorage', () => { + const value = { tenantId: 'diku' }; + localStorage.setItem('tenant', JSON.stringify(value)); + const parsedTenant = getLogoutTenant(); + expect(parsedTenant).toStrictEqual(value); + }); }); - }); -}); -describe('getLocale', () => { - it('dispatches setTimezone, setCurrency', async () => { - const value = { timezone: 'America/New_York', currency: 'USD' }; - mockFetchSuccess({ configs: [{ value: JSON.stringify(value) }] }); - const store = { - dispatch: jest.fn(), - getState: () => ({ okapi: {} }), - }; - await getLocale('url', store, 'tenant'); - expect(store.dispatch).toHaveBeenCalledWith(setTimezone(value.timezone)); - expect(store.dispatch).toHaveBeenCalledWith(setCurrency(value.currency)); - mockFetchCleanUp(); - }); -}); + describe('getOIDCRedirectUri', () => { + it('should return encoded return_uri', () => { + window.location.protocol = 'http'; + window.location.host = 'localhost'; -describe('getUserLocale', () => { - it('dispatches setTimezone, setCurrency', async () => { - const value = { locale: 'en-US', timezone: 'America/New_York', currency: 'USD' }; - mockFetchSuccess({ configs: [{ value: JSON.stringify(value) }] }); - const store = { - dispatch: jest.fn(), - getState: () => ({ okapi: {} }), - }; - await getUserLocale('url', store, 'tenant'); - expect(store.dispatch).toHaveBeenCalledWith(setTimezone(value.timezone)); - expect(store.dispatch).toHaveBeenCalledWith(setCurrency(value.currency)); - mockFetchCleanUp(); - }); -}); + const tenant = 'tenant'; + const clientId = 'client_id'; -describe('getPlugins', () => { - it('dispatches setPlugins', async () => { - const configs = [ - { configName: 'find-user', value: '@folio/plugin-hello-waldo' }, - { configName: 'find-water', value: '@folio/plugin-dowsing-rod' }, - ]; - mockFetchSuccess({ configs }); - const store = { - dispatch: jest.fn(), - getState: () => ({ okapi: {} }), - }; - await getPlugins('url', store, 'tenant'); - - const mappedConfigs = configs.reduce((acc, val) => ({ - ...acc, - [val.configName]: val.value, - }), {}); - expect(store.dispatch).toHaveBeenCalledWith(setPlugins(mappedConfigs)); - mockFetchCleanUp(); - }); -}); + expect(getOIDCRedirectUri(tenant, clientId)).toEqual('http%3A%2F%2Flocalhost%2Foidc-landing%3Ftenant%3Dtenant%26client_id%3Dclient_id'); + }); + }); -describe('getBindings', () => { - it('dispatches setBindings', async () => { - const value = { key: 'value' }; - mockFetchSuccess({ configs: [{ value: JSON.stringify(value) }] }); - const store = { - dispatch: jest.fn(), - getState: () => ({ okapi: {} }), - }; - await getBindings('url', store, 'tenant'); - expect(store.dispatch).toHaveBeenCalledWith(setBindings(value)); - mockFetchCleanUp(); - }); -}); + describe('requestLogin', () => { + afterEach(() => { + mockFetchCleanUp(); + }); -describe('unauthorizedPath functions', () => { - beforeEach(() => { - window.sessionStorage.clear(); - }); + it('should authenticate and create session when valid credentials provided', async () => { + const mockStore = { + getState: () => ({ + okapi: {}, + }), + dispatch: jest.fn() + }; + mockFetchSuccess({}); - afterEach(() => { - window.sessionStorage.clear(); - }); + await requestLogin( + 'http://okapi-url', + mockStore, + 'test-tenant', + { username: 'testuser', password: 'testpass' } + ); - describe('removeUnauthorizedPathFromSession', () => { - it('clears the value', () => { - setUnauthorizedPathToSession('monkey'); - removeUnauthorizedPathFromSession(); - expect(getUnauthorizedPathFromSession()).toBe(null); + expect(global.fetch).toHaveBeenCalledWith( + 'http://okapi-url/bl-users/login-with-expiry?expandPermissions=true&fullPermissions=true', + expect.objectContaining({ + method: 'POST', + headers: expect.objectContaining({ + 'X-Okapi-Tenant': 'test-tenant', + 'Content-Type': 'application/json' + }) + }) + ); + }); }); - }); - describe('setUnauthorizedPathToSession', () => { - it('stores the given value', () => { - const value = 'monkey'; - setUnauthorizedPathToSession(value); - expect(getUnauthorizedPathFromSession()).toBe(value); - }); + describe('requestUserWithPerms', () => { + afterEach(() => { + mockFetchCleanUp(); + jest.clearAllMocks(); + }); + it('should authenticate and create session when valid credentials provided', async () => { + mockFetchSuccess({ tenant: 'tenant', originalTenantId: 'originalTenantId', ok: true }); + const mockStore = { + getState: () => ({ + okapi: {}, + }), + dispatch: jest.fn() + }; - it('stores the current location given no value', () => { - window.location.pathname = '/some-path'; - window.location.search = '?monkey=bagel'; - setUnauthorizedPathToSession(); - expect(getUnauthorizedPathFromSession()).toBe(`${window.location.pathname}${window.location.search}`); - }); + await requestUserWithPerms( + 'http://okapi-url', + mockStore, + 'test-tenant', + 'token' + ); - describe('refuses to set locations beginning with "/logout"', () => { - it('with an argument', () => { - const monkey = '/logout-timeout'; - setUnauthorizedPathToSession(monkey); - expect(getUnauthorizedPathFromSession()).toBeFalsy(); + expect(global.fetch).toHaveBeenCalledWith('http://okapi-url/users-keycloak/_self?expandPermissions=true&fullPermissions=true&overrideUser=true', + { + headers: expect.objectContaining({ + 'X-Okapi-Tenant': 'test-tenant', + 'X-Okapi-Token': 'token', + 'Content-Type': 'application/json', + }), + 'rtrIgnore': false + }); }); - it('without an argument', () => { - window.location.pathname = '/logout-timeout'; - setUnauthorizedPathToSession(); - expect(getUnauthorizedPathFromSession()).toBeFalsy(); + it('should reject with an error object when response is not ok', async () => { + const mockError = { message: 'Permission denied' }; + const mockStore = { + getState: () => ({ + okapi: {}, + }), + dispatch: jest.fn() + }; + const mockResponse = { + ok: false, + json: jest.fn().mockResolvedValue(mockError), // Ensure `json()` is async + }; + global.fetch = jest.fn().mockImplementation(() => ( + Promise.resolve({ + ok: false, + status: 404, + json: () => Promise.resolve('Reject message'), + headers: new Map(), + }))); + fetchOverriddenUserWithPerms.mockResolvedValue(mockResponse); + + await expect(requestUserWithPerms('okapiUrl', mockStore, 'tenant', true)).rejects.toEqual('Reject message'); + mockFetchCleanUp(); }); }); }); - describe('getUnauthorizedPathFromSession', () => { - it('retrieves the value', () => { - const value = 'monkey'; - setUnauthorizedPathToSession(value); - expect(getUnauthorizedPathFromSession()).toBe(value); - }); - }); - - describe('getLogoutTenant', () => { - afterEach(() => { - localStorage.clear(); - }); + describe('loadResources', () => { + let store; + let loadResourcesResult; - it('retrieves the value from localstorage', () => { - const value = { tenantId: 'diku' }; - localStorage.setItem('tenant', JSON.stringify(value)); - const parsedTenant = getLogoutTenant(); - expect(parsedTenant).toStrictEqual(value); - }); - }); - - describe('getOIDCRedirectUri', () => { - it('should return encoded return_uri', () => { - window.location.protocol = 'http'; - window.location.host = 'localhost'; + const tenantLocaleData = { + items: [{ + id: 'tenantDataId', + value: { + locale: 'en-US', + numberingSystem: 'latn', + timezone: 'America/New_York', + currency: 'USD', + }, + }], + }; - const tenant = 'tenant'; - const clientId = 'client_id'; + const userLocaleData = { + items: [ + { + id: 'userDataId', + value: { + locale: 'en-GB', + timezone: 'Europe/London', + currency: 'GBP', + }, + }, + ], + }; - expect(getOIDCRedirectUri(tenant, clientId)).toEqual('http%3A%2F%2Flocalhost%2Foidc-landing%3Ftenant%3Dtenant%26client_id%3Dclient_id'); - }); - }); + const getResponseData = (url) => { + if (url?.includes('key=="tenantLocaleSettings"')) return tenantLocaleData; + if (url?.includes('key=="localeSettings"')) return userLocaleData; - describe('requestLogin', () => { - afterEach(() => { - mockFetchCleanUp(); - }); + return { url }; + }; - it('should authenticate and create session when valid credentials provided', async () => { - const mockStore = { - getState: () => ({ - okapi: {}, + beforeEach(() => { + store = { + dispatch: jest.fn(), + getState: jest.fn().mockReturnValue({ + okapi: { + url: 'http://okapi-url', + currentPerms: {} + } }), - dispatch: jest.fn() }; - mockFetchSuccess({}); - await requestLogin( - 'http://okapi-url', - mockStore, - 'test-tenant', - { username: 'testuser', password: 'testpass' } - ); - - expect(global.fetch).toHaveBeenCalledWith( - 'http://okapi-url/bl-users/login-with-expiry?expandPermissions=true&fullPermissions=true', - expect.objectContaining({ - method: 'POST', - headers: expect.objectContaining({ - 'X-Okapi-Tenant': 'test-tenant', - 'Content-Type': 'application/json' - }) - }) - ); + discoverServices.mockResolvedValue({ url: 'discoverServices' }); }); - }); - describe('requestUserWithPerms', () => { afterEach(() => { mockFetchCleanUp(); + discoverServices.mockRestore(); jest.clearAllMocks(); }); - it('should authenticate and create session when valid credentials provided', async () => { - mockFetchSuccess({ tenant: 'tenant', originalTenantId: 'originalTenantId', ok: true }); - const mockStore = { - getState: () => ({ - okapi: {}, - }), - dispatch: jest.fn() - }; - await requestUserWithPerms( - 'http://okapi-url', - mockStore, - 'test-tenant', - 'token' - ); + describe('when there are permissions to read mod-settings and mod-configuration', () => { + beforeEach(() => { + store.getState.mockReturnValue({ + okapi: { + url: 'http://okapi-url', + currentPerms: { + 'mod-settings.entries.collection.get': true, + 'mod-settings.owner.read.stripes-core.prefs.manage': true, + 'configuration.entries.collection.get': true, + }, + }, + }); + }); - expect(global.fetch).toHaveBeenCalledWith('http://okapi-url/users-keycloak/_self?expandPermissions=true&fullPermissions=true&overrideUser=true', - { - headers: expect.objectContaining({ - 'X-Okapi-Tenant': 'test-tenant', - 'X-Okapi-Token': 'token', - 'Content-Type': 'application/json', - }), - 'rtrIgnore': false + describe('when the user and tenant locale settings are present in mod-settings', () => { + beforeEach(() => { + global.fetch = jest.fn().mockImplementation((url) => Promise.resolve({ + url, + json: () => Promise.resolve(getResponseData(url)), + })); }); - }); - it('should reject with an error object when response is not ok', async () => { - const mockError = { message: 'Permission denied' }; - const mockStore = { - getState: () => ({ - okapi: {}, - }), - dispatch: jest.fn() - }; - const mockResponse = { - ok: false, - json: jest.fn().mockResolvedValue(mockError), // Ensure `json()` is async - }; - global.fetch = jest.fn().mockImplementation(() => ( - Promise.resolve({ - ok: false, - status: 404, - json: () => Promise.resolve('Reject message'), - headers: new Map(), - }))); - fetchOverriddenUserWithPerms.mockResolvedValue(mockResponse); + it('should fetch the tenant and user locale settings from mod-settings', async () => { + await loadResources(store, 'tenant', 'userId'); - await expect(requestUserWithPerms('okapiUrl', mockStore, 'tenant', true)).rejects.toEqual('Reject message'); - mockFetchCleanUp(); - }); - }); -}); + expect(global.fetch).toHaveBeenCalledWith( + 'http://okapi-url/settings/entries?query=(scope=="stripes-core.prefs.manage" and key=="tenantLocaleSettings")', + expect.anything(), + ); + expect(global.fetch).toHaveBeenCalledWith( + 'http://okapi-url/settings/entries?query=(userId=="userId" and scope=="stripes-core.prefs.manage" and key=="localeSettings")', + expect.anything(), + ); + }); -describe('loadResources', () => { - let store; - let loadResourcesResult; - - const tenantLocaleData = { - items: [{ - id: 'tenantDataId', - value: { - locale: 'en-US', - numberingSystem: 'latn', - timezone: 'America/New_York', - currency: 'USD', - }, - }], - }; + it('should not fetch the tenant and user locale settings from mod-configuration', async () => { + await loadResources(store, 'tenant', 'userId'); - const userLocaleData = { - items: [ - { - id: 'userDataId', - value: { - locale: 'en-GB', - timezone: 'Europe/London', - currency: 'GBP', - }, - }, - ], - }; - - const getResponseData = (url) => { - if (url?.includes('key=="tenantLocaleSettings"')) return tenantLocaleData; - if (url?.includes('key=="localeSettings"')) return userLocaleData; - - return { url }; - }; - - beforeEach(() => { - store = { - dispatch: jest.fn(), - getState: jest.fn().mockReturnValue({ - okapi: { - url: 'http://okapi-url', - currentPerms: {} - } - }), - }; + expect(global.fetch).not.toHaveBeenCalledWith( + 'http://okapi-url/configurations/entries?query=(module==ORG AND configName == localeSettings AND (cql.allRecords=1 NOT userId="" NOT code=""))', + expect.anything(), + ); + expect(global.fetch).not.toHaveBeenCalledWith( + 'http://okapi-url/configurations/entries?query=("configName"=="localeSettings" AND "module"=="@folio/stripes-core" and userId=="userId")', + expect.anything(), + ); + }); - discoverServices.mockResolvedValue({ url: 'discoverServices' }); - }); + it('should fetch the plugins and bindings', async () => { + await loadResources(store, 'tenant', 'userId'); - afterEach(() => { - mockFetchCleanUp(); - discoverServices.mockRestore(); - jest.clearAllMocks(); - }); + expect(global.fetch).toHaveBeenCalledWith( + 'http://okapi-url/configurations/entries?query=(module==PLUGINS)', + expect.anything(), + ); + expect(global.fetch).toHaveBeenCalledWith( + 'http://okapi-url/configurations/entries?query=(module==ORG and configName==bindings)', + expect.anything(), + ); + }); - describe('when there are permissions to read mod-settings and mod-configuration', () => { - beforeEach(() => { - store.getState.mockReturnValue({ - okapi: { - url: 'http://okapi-url', - currentPerms: { - 'mod-settings.entries.collection.get': true, - 'mod-settings.owner.read.stripes-core.prefs.manage': true, - 'configuration.entries.collection.get': true, - }, - }, + it('should retrieve tenant-locale and user-locale from settings, plugins and bindings from configurations', async () => { + loadResourcesResult = await loadResources(store, 'tenant', 'userId'); + + expect(loadResourcesResult.map(({ url }) => url)).toEqual([ + 'http://okapi-url/settings/entries?query=(scope=="stripes-core.prefs.manage" and key=="tenantLocaleSettings")', + 'http://okapi-url/settings/entries?query=(userId=="userId" and scope=="stripes-core.prefs.manage" and key=="localeSettings")', + 'http://okapi-url/configurations/entries?query=(module==PLUGINS)', + 'http://okapi-url/configurations/entries?query=(module==ORG and configName==bindings)', + 'discoverServices', + ]); + }); + + describe('when both the tenant and user locale settings are present', () => { + it('should apply user locale settings', async () => { + const timezone = userLocaleData.items[0].value.timezone; + const currency = userLocaleData.items[0].value.currency; + + await loadResources(store, 'tenant', 'userId'); + + expect(store.dispatch).toHaveBeenCalledWith(setTimezone(timezone)); + expect(store.dispatch).toHaveBeenCalledWith(setCurrency(currency)); + }); + }); + + describe('when the user locale settings are missing something other than the locale', () => { + it('should take it from tenant locale settings', async () => { + await loadResources(store, 'tenant', 'userId'); + + expect(document.documentElement.lang).toBe('en-GB-u-nu-latn'); + }); + }); + }); + + describe('when the user or tenant locale settings are not present in mod-settings', () => { + const getData = (url) => { + // if mod-settings API + if (url?.includes('key=="tenantLocaleSettings"') || url?.includes('key=="localeSettings"')) { + return { url, items: [] }; + } + + // if mod-configuration API + if (url?.includes('configName == localeSettings') || url?.includes('"configName"=="localeSettings"')) { + return { + url, + configs: [{ + value: JSON.stringify({ + locale: 'en-GB-u-nu-latn', + timezone: 'UTC', + currency: 'USD' + }), + }], + }; + } + + return { url }; + }; + + beforeEach(() => { + global.fetch = jest.fn().mockImplementation((url) => Promise.resolve({ + url, + json: () => Promise.resolve(getData(url)), + ok: true, + })); + }); + + it('should fetch the tenant and user locale settings from mod-settings and mod-configuration', async () => { + await loadResources(store, 'tenant', 'userId'); + + expect(global.fetch).toHaveBeenCalledWith( + 'http://okapi-url/settings/entries?query=(scope=="stripes-core.prefs.manage" and key=="tenantLocaleSettings")', + expect.anything(), + ); + expect(global.fetch).toHaveBeenCalledWith( + 'http://okapi-url/settings/entries?query=(userId=="userId" and scope=="stripes-core.prefs.manage" and key=="localeSettings")', + expect.anything(), + ); + expect(global.fetch).toHaveBeenCalledWith( + 'http://okapi-url/configurations/entries?query=(module==ORG AND configName == localeSettings AND (cql.allRecords=1 NOT userId="" NOT code=""))', + expect.anything(), + ); + expect(global.fetch).toHaveBeenCalledWith( + 'http://okapi-url/configurations/entries?query=("configName"=="localeSettings" AND "module"=="@folio/stripes-core" and userId=="userId")', + expect.anything(), + ); + }); + + it('should apply locale settings from mod-configuration', async () => { + await loadResources(store, 'tenant', 'userId'); + + expect(store.dispatch).toHaveBeenCalledWith(setTimezone('UTC')); + expect(store.dispatch).toHaveBeenCalledWith(setCurrency('USD')); + expect(document.documentElement.lang).toBe('en-GB-u-nu-latn'); + }); + + it('should retrieve tenant-locale, user-locale, plugins, and bindings from configurations', async () => { + loadResourcesResult = await loadResources(store, 'tenant', 'userId'); + + expect(loadResourcesResult.map(({ url }) => url)).toEqual([ + 'http://okapi-url/configurations/entries?query=(module==ORG AND configName == localeSettings AND (cql.allRecords=1 NOT userId="" NOT code=""))', + 'http://okapi-url/configurations/entries?query=("configName"=="localeSettings" AND "module"=="@folio/stripes-core" and userId=="userId")', + 'http://okapi-url/configurations/entries?query=(module==PLUGINS)', + 'http://okapi-url/configurations/entries?query=(module==ORG and configName==bindings)', + 'discoverServices', + ]); + }); }); }); - describe('when the user and tenant locale settings are present in mod-settings', () => { + describe('when there is permission to only read tenant settings from mod-settings', () => { beforeEach(() => { - global.fetch = jest.fn().mockImplementation((url) => Promise.resolve({ - url, - json: () => Promise.resolve(getResponseData(url)), - })); + store.getState.mockReturnValue({ + okapi: { + url: 'http://okapi-url', + currentPerms: { + 'mod-settings.entries.collection.get': true, + 'mod-settings.global.read.stripes-core.prefs.manage': true, + }, + }, + }); + + global.fetch = jest.fn().mockImplementation(url => { + if (url?.includes('key=="localeSettings"')) { + return Promise.reject(new Error('Request failed')); + } + + return Promise.resolve({ + url, + json: () => Promise.resolve(getResponseData(url)), + }); + }); }); it('should fetch the tenant and user locale settings from mod-settings', async () => { @@ -991,90 +1179,70 @@ describe('loadResources', () => { ); }); - it('should not fetch the tenant and user locale settings from mod-configuration', async () => { + it('should not fetch the plugins and bindings', async () => { await loadResources(store, 'tenant', 'userId'); expect(global.fetch).not.toHaveBeenCalledWith( - 'http://okapi-url/configurations/entries?query=(module==ORG AND configName == localeSettings AND (cql.allRecords=1 NOT userId="" NOT code=""))', + 'http://okapi-url/configurations/entries?query=(module==PLUGINS)', expect.anything(), ); expect(global.fetch).not.toHaveBeenCalledWith( - 'http://okapi-url/configurations/entries?query=("configName"=="localeSettings" AND "module"=="@folio/stripes-core" and userId=="userId")', + 'http://okapi-url/configurations/entries?query=(module==ORG and configName==bindings)', expect.anything(), ); }); - it('should fetch the plugins and bindings', async () => { + it('should apply tenant locale settings', async () => { + const timezone = tenantLocaleData.items[0].value.timezone; + const currency = tenantLocaleData.items[0].value.currency; + await loadResources(store, 'tenant', 'userId'); - expect(global.fetch).toHaveBeenCalledWith( - 'http://okapi-url/configurations/entries?query=(module==PLUGINS)', - expect.anything(), - ); - expect(global.fetch).toHaveBeenCalledWith( - 'http://okapi-url/configurations/entries?query=(module==ORG and configName==bindings)', - expect.anything(), - ); + expect(store.dispatch).toHaveBeenCalledWith(setTimezone(timezone)); + expect(store.dispatch).toHaveBeenCalledWith(setCurrency(currency)); + expect(document.documentElement.lang).toBe('en-US-u-nu-latn'); }); - it('should retrieve tenant-locale and user-locale from settings, plugins and bindings from configurations', async () => { + it('should retrieve tenant locale from settings', async () => { loadResourcesResult = await loadResources(store, 'tenant', 'userId'); - expect(loadResourcesResult.map(({ url }) => url)).toEqual([ + expect(loadResourcesResult.map(({ url } = {}) => url)).toEqual([ 'http://okapi-url/settings/entries?query=(scope=="stripes-core.prefs.manage" and key=="tenantLocaleSettings")', - 'http://okapi-url/settings/entries?query=(userId=="userId" and scope=="stripes-core.prefs.manage" and key=="localeSettings")', - 'http://okapi-url/configurations/entries?query=(module==PLUGINS)', - 'http://okapi-url/configurations/entries?query=(module==ORG and configName==bindings)', + undefined, // rejected request for user locale 'discoverServices', ]); }); - - describe('when both the tenant and user locale settings are present', () => { - it('should apply user locale settings', async () => { - const timezone = userLocaleData.items[0].value.timezone; - const currency = userLocaleData.items[0].value.currency; - - await loadResources(store, 'tenant', 'userId'); - - expect(store.dispatch).toHaveBeenCalledWith(setTimezone(timezone)); - expect(store.dispatch).toHaveBeenCalledWith(setCurrency(currency)); - }); - }); - - describe('when the user locale settings are missing something other than the locale', () => { - it('should take it from tenant locale settings', async () => { - await loadResources(store, 'tenant', 'userId'); - - expect(document.documentElement.lang).toBe('en-GB-u-nu-latn'); - }); - }); }); - describe('when the user or tenant locale settings are not present in mod-settings', () => { - const getData = (url) => { - // if mod-settings API - if (url?.includes('key=="tenantLocaleSettings"') || url?.includes('key=="localeSettings"')) { - return { url, items: [] }; - } - - // if mod-configuration API - if (url?.includes('configName == localeSettings') || url?.includes('"configName"=="localeSettings"')) { - return { - url, - configs: [{ - value: JSON.stringify({ - locale: 'en-GB-u-nu-latn', - timezone: 'UTC', - currency: 'USD' - }), - }], - }; - } + describe('when there is permission to only read mod-configuration', () => { + beforeEach(() => { + store.getState = jest.fn().mockReturnValue({ + okapi: { + url: 'http://okapi-url', + currentPerms: { + 'configuration.entries.collection.get': true, + }, + }, + }); - return { url }; - }; + const getData = (url) => { + // mod-configuration locales + if (url?.includes('configName == localeSettings') || url?.includes('"configName"=="localeSettings"')) { + return { + url, + configs: [{ + value: JSON.stringify({ + locale: 'en-GB-u-nu-latn', + timezone: 'UTC', + currency: 'USD' + }), + }], + }; + } + + return { url }; + }; - beforeEach(() => { global.fetch = jest.fn().mockImplementation((url) => Promise.resolve({ url, json: () => Promise.resolve(getData(url)), @@ -1082,17 +1250,22 @@ describe('loadResources', () => { })); }); - it('should fetch the tenant and user locale settings from mod-settings and mod-configuration', async () => { + it('should not fetch the tenant and user locale settings from mod-settings', async () => { await loadResources(store, 'tenant', 'userId'); - expect(global.fetch).toHaveBeenCalledWith( + expect(global.fetch).not.toHaveBeenCalledWith( 'http://okapi-url/settings/entries?query=(scope=="stripes-core.prefs.manage" and key=="tenantLocaleSettings")', expect.anything(), ); - expect(global.fetch).toHaveBeenCalledWith( + expect(global.fetch).not.toHaveBeenCalledWith( 'http://okapi-url/settings/entries?query=(userId=="userId" and scope=="stripes-core.prefs.manage" and key=="localeSettings")', expect.anything(), ); + }); + + it('should fetch the tenant and user locale settings from mod-configuration', async () => { + await loadResources(store, 'tenant', 'userId'); + expect(global.fetch).toHaveBeenCalledWith( 'http://okapi-url/configurations/entries?query=(module==ORG AND configName == localeSettings AND (cql.allRecords=1 NOT userId="" NOT code=""))', expect.anything(), @@ -1103,7 +1276,20 @@ describe('loadResources', () => { ); }); - it('should apply locale settings from mod-configuration', async () => { + it('should fetch the plugins and bindings', async () => { + await loadResources(store, 'tenant', 'userId'); + + expect(global.fetch).toHaveBeenCalledWith( + 'http://okapi-url/configurations/entries?query=(module==PLUGINS)', + expect.anything(), + ); + expect(global.fetch).toHaveBeenCalledWith( + 'http://okapi-url/configurations/entries?query=(module==ORG and configName==bindings)', + expect.anything(), + ); + }); + + it('should apply locale settings', async () => { await loadResources(store, 'tenant', 'userId'); expect(store.dispatch).toHaveBeenCalledWith(setTimezone('UTC')); @@ -1123,243 +1309,74 @@ describe('loadResources', () => { ]); }); }); - }); - describe('when there is permission to only read tenant settings from mod-settings', () => { - beforeEach(() => { - store.getState.mockReturnValue({ - okapi: { - url: 'http://okapi-url', - currentPerms: { - 'mod-settings.entries.collection.get': true, - 'mod-settings.global.read.stripes-core.prefs.manage': true, - }, - }, - }); - - global.fetch = jest.fn().mockImplementation(url => { - if (url?.includes('key=="localeSettings"')) { - return Promise.reject(new Error('Request failed')); - } - - return Promise.resolve({ - url, - json: () => Promise.resolve(getResponseData(url)), - }); - }); - }); - - it('should fetch the tenant and user locale settings from mod-settings', async () => { + it('should fetch discoverServices when okapi is available', async () => { await loadResources(store, 'tenant', 'userId'); - expect(global.fetch).toHaveBeenCalledWith( - 'http://okapi-url/settings/entries?query=(scope=="stripes-core.prefs.manage" and key=="tenantLocaleSettings")', - expect.anything(), - ); - expect(global.fetch).toHaveBeenCalledWith( - 'http://okapi-url/settings/entries?query=(userId=="userId" and scope=="stripes-core.prefs.manage" and key=="localeSettings")', - expect.anything(), - ); - }); - - it('should not fetch the plugins and bindings', async () => { - await loadResources(store, 'tenant', 'userId'); - - expect(global.fetch).not.toHaveBeenCalledWith( - 'http://okapi-url/configurations/entries?query=(module==PLUGINS)', - expect.anything(), - ); - expect(global.fetch).not.toHaveBeenCalledWith( - 'http://okapi-url/configurations/entries?query=(module==ORG and configName==bindings)', - expect.anything(), - ); - }); - - it('should apply tenant locale settings', async () => { - const timezone = tenantLocaleData.items[0].value.timezone; - const currency = tenantLocaleData.items[0].value.currency; - - await loadResources(store, 'tenant', 'userId'); - - expect(store.dispatch).toHaveBeenCalledWith(setTimezone(timezone)); - expect(store.dispatch).toHaveBeenCalledWith(setCurrency(currency)); - expect(document.documentElement.lang).toBe('en-US-u-nu-latn'); - }); - - it('should retrieve tenant locale from settings', async () => { - loadResourcesResult = await loadResources(store, 'tenant', 'userId'); - - expect(loadResourcesResult.map(({ url } = {}) => url)).toEqual([ - 'http://okapi-url/settings/entries?query=(scope=="stripes-core.prefs.manage" and key=="tenantLocaleSettings")', - undefined, // rejected request for user locale - 'discoverServices', - ]); + expect(discoverServices).toHaveBeenCalledWith(store); }); }); - describe('when there is permission to only read mod-configuration', () => { - beforeEach(() => { - store.getState = jest.fn().mockReturnValue({ - okapi: { - url: 'http://okapi-url', - currentPerms: { - 'configuration.entries.collection.get': true, - }, - }, - }); - - const getData = (url) => { - // mod-configuration locales - if (url?.includes('configName == localeSettings') || url?.includes('"configName"=="localeSettings"')) { - return { - url, - configs: [{ - value: JSON.stringify({ - locale: 'en-GB-u-nu-latn', - timezone: 'UTC', - currency: 'USD' - }), - }], - }; - } - - return { url }; - }; + describe('getLoginTenant', () => { + it('uses URL values when present', () => { + const search = { tenant: 't', client_id: 'c' }; + Object.defineProperty(window, 'location', { value: { search } }); - global.fetch = jest.fn().mockImplementation((url) => Promise.resolve({ - url, - json: () => Promise.resolve(getData(url)), - ok: true, - })); + const res = getLoginTenant({}, {}); + expect(res.tenant).toBe(search.tenant); + expect(res.clientId).toBe(search.client_id); }); - it('should not fetch the tenant and user locale settings from mod-settings', async () => { - await loadResources(store, 'tenant', 'userId'); - - expect(global.fetch).not.toHaveBeenCalledWith( - 'http://okapi-url/settings/entries?query=(scope=="stripes-core.prefs.manage" and key=="tenantLocaleSettings")', - expect.anything(), - ); - expect(global.fetch).not.toHaveBeenCalledWith( - 'http://okapi-url/settings/entries?query=(userId=="userId" and scope=="stripes-core.prefs.manage" and key=="localeSettings")', - expect.anything(), - ); - }); - - it('should fetch the tenant and user locale settings from mod-configuration', async () => { - await loadResources(store, 'tenant', 'userId'); - - expect(global.fetch).toHaveBeenCalledWith( - 'http://okapi-url/configurations/entries?query=(module==ORG AND configName == localeSettings AND (cql.allRecords=1 NOT userId="" NOT code=""))', - expect.anything(), - ); - expect(global.fetch).toHaveBeenCalledWith( - 'http://okapi-url/configurations/entries?query=("configName"=="localeSettings" AND "module"=="@folio/stripes-core" and userId=="userId")', - expect.anything(), - ); - }); - - it('should fetch the plugins and bindings', async () => { - await loadResources(store, 'tenant', 'userId'); - - expect(global.fetch).toHaveBeenCalledWith( - 'http://okapi-url/configurations/entries?query=(module==PLUGINS)', - expect.anything(), - ); - expect(global.fetch).toHaveBeenCalledWith( - 'http://okapi-url/configurations/entries?query=(module==ORG and configName==bindings)', - expect.anything(), - ); - }); + describe('single-tenant', () => { + it('uses config.tenantOptions values when URL values are absent', () => { + const config = { + tenantOptions: { + denzel: { name: 'denzel', clientId: 'nolan' } + } + }; - it('should apply locale settings', async () => { - await loadResources(store, 'tenant', 'userId'); + const res = getLoginTenant({}, config); + expect(res.tenant).toBe(config.tenantOptions.denzel.name); + expect(res.clientId).toBe(config.tenantOptions.denzel.clientId); + }); - expect(store.dispatch).toHaveBeenCalledWith(setTimezone('UTC')); - expect(store.dispatch).toHaveBeenCalledWith(setCurrency('USD')); - expect(document.documentElement.lang).toBe('en-GB-u-nu-latn'); - }); + it('uses okapi.tenant and okapi.clientId when config.tenantOptions is missing', () => { + const okapi = { + tenant: 't', + clientId: 'c', + }; - it('should retrieve tenant-locale, user-locale, plugins, and bindings from configurations', async () => { - loadResourcesResult = await loadResources(store, 'tenant', 'userId'); + const res = getLoginTenant(okapi, {}); + expect(res.tenant).toBe(okapi.tenant); + expect(res.clientId).toBe(okapi.clientId); + }); - expect(loadResourcesResult.map(({ url }) => url)).toEqual([ - 'http://okapi-url/configurations/entries?query=(module==ORG AND configName == localeSettings AND (cql.allRecords=1 NOT userId="" NOT code=""))', - 'http://okapi-url/configurations/entries?query=("configName"=="localeSettings" AND "module"=="@folio/stripes-core" and userId=="userId")', - 'http://okapi-url/configurations/entries?query=(module==PLUGINS)', - 'http://okapi-url/configurations/entries?query=(module==ORG and configName==bindings)', - 'discoverServices', - ]); + it('returns undefined when all options are exhausted', () => { + const res = getLoginTenant(); + expect(res.tenant).toBeUndefined(); + expect(res.clientId).toBeUndefined(); + }); }); - }); - - it('should fetch discoverServices when okapi is available', async () => { - await loadResources(store, 'tenant', 'userId'); - - expect(discoverServices).toHaveBeenCalledWith(store); - }); -}); -describe('getLoginTenant', () => { - it('uses URL values when present', () => { - const search = { tenant: 't', client_id: 'c' }; - Object.defineProperty(window, 'location', { value: { search } }); - - const res = getLoginTenant({}, {}); - expect(res.tenant).toBe(search.tenant); - expect(res.clientId).toBe(search.client_id); - }); - - describe('single-tenant', () => { - it('uses config.tenantOptions values when URL values are absent', () => { - const config = { + describe('multi-tenant', () => { + const stripesConfig = { tenantOptions: { - denzel: { name: 'denzel', clientId: 'nolan' } + tenant1: { name: 'tenant1', clientId: 'client1' }, + tenant2: { name: 'tenant2', clientId: 'client2' }, } }; - - const res = getLoginTenant({}, config); - expect(res.tenant).toBe(config.tenantOptions.denzel.name); - expect(res.clientId).toBe(config.tenantOptions.denzel.clientId); - }); - - it('uses okapi.tenant and okapi.clientId when config.tenantOptions is missing', () => { - const okapi = { - tenant: 't', - clientId: 'c', - }; - - const res = getLoginTenant(okapi, {}); - expect(res.tenant).toBe(okapi.tenant); - expect(res.clientId).toBe(okapi.clientId); - }); - - it('returns undefined when all options are exhausted', () => { - const res = getLoginTenant(); - expect(res.tenant).toBeUndefined(); - expect(res.clientId).toBeUndefined(); - }); - }); - - describe('multi-tenant', () => { - const stripesConfig = { - tenantOptions: { - tenant1: { name: 'tenant1', clientId: 'client1' }, - tenant2: { name: 'tenant2', clientId: 'client2' }, - } - }; - describe('when URL contains tenant and no client_id', () => { - it('should take tenant from URL', () => { - // URL: /reset-password?resetToken=token1&tenant=tenant1 - const search = { tenant: 'tenant1' }; - Object.defineProperty(window, 'location', { value: { search } }); - - const res = getLoginTenant({}, stripesConfig); - expect(res.tenant).toBe(search.tenant); + describe('when URL contains tenant and no client_id', () => { + it('should take tenant from URL', () => { + // URL: /reset-password?resetToken=token1&tenant=tenant1 + const search = { tenant: 'tenant1' }; + Object.defineProperty(window, 'location', { value: { search } }); + + const res = getLoginTenant({}, stripesConfig); + expect(res.tenant).toBe(search.tenant); + }); }); }); - }); - describe('ECS', () => { }); + describe('ECS', () => { }); + }); }); - From c8daff7ecae1d1a0b46a364b6be15df4713a2477 Mon Sep 17 00:00:00 2001 From: John Coburn Date: Mon, 2 Feb 2026 11:18:51 -0600 Subject: [PATCH 57/59] isolate localforage wrapper mocks and clean up after loadTranslations test. --- src/loginServices.test.js | 1962 ++++++++++++++++++------------------- 1 file changed, 978 insertions(+), 984 deletions(-) diff --git a/src/loginServices.test.js b/src/loginServices.test.js index 4e62da6ef..b3cf30207 100644 --- a/src/loginServices.test.js +++ b/src/loginServices.test.js @@ -69,7 +69,6 @@ jest.mock('./discoverServices', () => ({ })); const mockStripesHubAPI = stripesHubAPI; - jest.mock('localforage', () => ({ getItem: jest.fn((str) => { if (str === mockStripesHubAPI.HOST_LOCATION_KEY) { @@ -117,1053 +116,878 @@ const mockFetchCleanUp = () => { delete global.fetch; }; -describe('loginServices', () => { - afterEach(() => { - jest.resetAllMocks(); - }); +describe('createOkapiSession', () => { + it('clears authentication errors', async () => { + const store = { + dispatch: jest.fn(), + getState: () => ({ + okapi: { + currentPerms: [], + url: 'okapiUrl' + } + }), + }; - describe('createOkapiSession', () => { - it('clears authentication errors', async () => { - const store = { - dispatch: jest.fn(), - getState: () => ({ - okapi: { - currentPerms: [], - url: 'okapiUrl' - } - }), - }; + const te = { + accessTokenExpiration: '2023-11-06T18:05:33Z', + refreshTokenExpiration: '2023-10-30T18:15:33Z', + }; - const te = { - accessTokenExpiration: '2023-11-06T18:05:33Z', - refreshTokenExpiration: '2023-10-30T18:15:33Z', - }; + const data = { + user: { + id: 'user-id', + }, + permissions: { + permissions: [{ permissionName: 'a' }, { permissionName: 'b' }] + }, + tokenExpiration: te, + }; + const permissionsMap = { a: true, b: true }; + mockFetchSuccess([]); - const data = { - user: { - id: 'user-id', - }, - permissions: { - permissions: [{ permissionName: 'a' }, { permissionName: 'b' }] - }, - tokenExpiration: te, - }; - const permissionsMap = { a: true, b: true }; - mockFetchSuccess([]); + await createOkapiSession(store, 'tenant', 'token', data); + expect(store.dispatch).toHaveBeenCalledWith(setIsAuthenticated(true)); + expect(store.dispatch).toHaveBeenCalledWith(setAuthError(null)); + expect(store.dispatch).toHaveBeenCalledWith(setLoginData(data)); + expect(store.dispatch).toHaveBeenCalledWith(setCurrentPerms(permissionsMap)); - await createOkapiSession(store, 'tenant', 'token', data); - expect(store.dispatch).toHaveBeenCalledWith(setIsAuthenticated(true)); - expect(store.dispatch).toHaveBeenCalledWith(setAuthError(null)); - expect(store.dispatch).toHaveBeenCalledWith(setLoginData(data)); - expect(store.dispatch).toHaveBeenCalledWith(setCurrentPerms(permissionsMap)); + mockFetchCleanUp(); + }); +}); - mockFetchCleanUp(); - }); +describe('handleLoginError', () => { + it('dispatches setOkapiReady', async () => { + const dispatch = jest.fn(); + await handleLoginError(dispatch, {}); + expect(dispatch).toHaveBeenCalledWith(setOkapiReady()); + expect(dispatch).toHaveBeenCalledWith(setAuthError([defaultErrors.DEFAULT_LOGIN_CLIENT_ERROR])); }); +}); - describe('handleLoginError', () => { - it('dispatches setOkapiReady', async () => { - const dispatch = jest.fn(); - await handleLoginError(dispatch, {}); - expect(dispatch).toHaveBeenCalledWith(setOkapiReady()); - expect(dispatch).toHaveBeenCalledWith(setAuthError([defaultErrors.DEFAULT_LOGIN_CLIENT_ERROR])); - }); +describe('loadTranslations', () => { + let store; + beforeEach(() => { + store = { + dispatch: jest.fn(), + }; + mockFetchSuccess({}); }); - describe('loadTranslations', () => { - let store; - beforeEach(() => { - store = { - dispatch: jest.fn(), - }; - mockFetchSuccess({}); - }); + it('dispatches setLocale', async () => { + const locale = 'cs-CZ'; - afterEach(() => { - jest.restoreAllMocks(); - }); + await loadTranslations(store, locale, {}); + expect(store.dispatch).toHaveBeenCalledWith(setLocale(locale)); + }); - it('dispatches setLocale', async () => { + describe('sets document attributes correctly', () => { + it('sets lang given region', async () => { const locale = 'cs-CZ'; - await loadTranslations(store, locale, {}); - expect(store.dispatch).toHaveBeenCalledWith(setLocale(locale)); + expect(document.documentElement.lang).toMatch('cs'); }); - describe('sets document attributes correctly', () => { - it('sets lang given region', async () => { - const locale = 'cs-CZ'; - await loadTranslations(store, locale, {}); - expect(document.documentElement.lang).toMatch('cs'); - }); - - it('sets lang without region', async () => { - const locale = 'cs'; - await loadTranslations(store, locale, {}); - expect(document.documentElement.lang).toMatch('cs'); - }); + it('sets lang without region', async () => { + const locale = 'cs'; + await loadTranslations(store, locale, {}); + expect(document.documentElement.lang).toMatch('cs'); + }); - it('sets dir (LTR)', async () => { - const locale = 'fr'; - await loadTranslations(store, locale, {}); - expect(document.dir).toMatch('ltr'); - }); + it('sets dir (LTR)', async () => { + const locale = 'fr'; + await loadTranslations(store, locale, {}); + expect(document.dir).toMatch('ltr'); + }); - it('sets dir (RTL)', async () => { - const locale = 'ar'; - await loadTranslations(store, locale, {}); - expect(document.dir).toMatch('rtl'); - }); + it('sets dir (RTL)', async () => { + const locale = 'ar'; + await loadTranslations(store, locale, {}); + expect(document.dir).toMatch('rtl'); }); + }); - describe('when localforage contains a hostLocation', () => { - it('fetches using the hostLocation from localforage', async () => { - const hostLocation = 'http://my-app-here'; - const locale = 'cs-CZ'; - localforage.getItem.mockResolvedValue(hostLocation); - await loadTranslations(store, locale, { cs: 'cs-CZ' }); - expect(global.fetch).toHaveBeenCalledWith(`${hostLocation}/cs-CZ`); - }); + describe('when localforage contains a hostLocation', () => { + it('fetches using the hostLocation from localforage', async () => { + const hostLocation = 'http://my-app-here'; + const locale = 'cs-CZ'; + localforage.getItem.mockResolvedValueOnce(hostLocation); + await loadTranslations(store, locale, { cs: 'cs-CZ' }); + expect(global.fetch).toHaveBeenCalledWith(`${hostLocation}/cs-CZ`); }); }); +}); - describe('processOkapiSession', () => { - it('handles success', async () => { - const store = { - dispatch: jest.fn(), - getState: () => ({ - okapi: { - currentPerms: [], - authnUrl: 'keycloakURL' - } - }), - }; +describe('processOkapiSession', () => { + it('handles success', async () => { + const store = { + dispatch: jest.fn(), + getState: () => ({ + okapi: { + currentPerms: [], + authnUrl: 'keycloakURL' + } + }), + }; - const resp = { - headers: { - get: jest.fn(), - }, - ok: true, - json: () => Promise.resolve({ - user: { id: 'id' }, - permissions: { - permissions: [{ permissionName: 'a' }, { permissionName: 'b' }] - } - }), - }; + const resp = { + headers: { + get: jest.fn(), + }, + ok: true, + json: () => Promise.resolve({ + user: { id: 'id' }, + permissions: { + permissions: [{ permissionName: 'a' }, { permissionName: 'b' }] + } + }), + }; - mockFetchSuccess(); + mockFetchSuccess(); - await processOkapiSession(store, 'tenant', resp); - expect(store.dispatch).toHaveBeenCalledWith(setAuthError(null)); - expect(store.dispatch).toHaveBeenCalledWith(setOkapiReady()); + await processOkapiSession(store, 'tenant', resp); + expect(store.dispatch).toHaveBeenCalledWith(setAuthError(null)); + expect(store.dispatch).toHaveBeenCalledWith(setOkapiReady()); - mockFetchCleanUp(); - }); + mockFetchCleanUp(); + }); - it('handles error', async () => { - const store = { - dispatch: jest.fn() - }; - const resp = { - headers: { - get: jest.fn(), - } - }; + it('handles error', async () => { + const store = { + dispatch: jest.fn() + }; + const resp = { + headers: { + get: jest.fn(), + } + }; - await processOkapiSession(store, 'tenant', resp); + await processOkapiSession(store, 'tenant', resp); - expect(store.dispatch).toHaveBeenCalledWith(setOkapiReady()); - expect(store.dispatch).toHaveBeenCalledWith(setAuthError([defaultErrors.DEFAULT_LOGIN_CLIENT_ERROR])); - }); + expect(store.dispatch).toHaveBeenCalledWith(setOkapiReady()); + expect(store.dispatch).toHaveBeenCalledWith(setAuthError([defaultErrors.DEFAULT_LOGIN_CLIENT_ERROR])); }); +}); - describe('supportedLocales', () => { - it('is an array of strings', () => { - expect(Array.isArray(supportedLocales)).toBe(true); - expect(typeof supportedLocales[0]).toBe('string'); - }); +describe('supportedLocales', () => { + it('is an array of strings', () => { + expect(Array.isArray(supportedLocales)).toBe(true); + expect(typeof supportedLocales[0]).toBe('string'); }); +}); - describe('supportedNumberingSystems', () => { - it('is an array of strings', () => { - expect(Array.isArray(supportedNumberingSystems)).toBe(true); - expect(typeof supportedNumberingSystems[0]).toBe('string'); - }); +describe('supportedNumberingSystems', () => { + it('is an array of strings', () => { + expect(Array.isArray(supportedNumberingSystems)).toBe(true); + expect(typeof supportedNumberingSystems[0]).toBe('string'); }); +}); - describe('validateUser', () => { - it('handles fetch failure from "_self"', async () => { - const store = { - dispatch: jest.fn(), - getState: () => ({ okapi: { tenant: 'monkey', url: 'monkeyUrl' } }), - }; - const handleError = jest.fn().mockReturnValue(Promise.resolve()); - - mockFetchError(); - - const res = await validateUser('url', store, 'tenant', {}, handleError); - expect(handleError).toHaveBeenCalled(); - expect(res).toBeUndefined(); - mockFetchCleanUp(); - }); - - it('handles valid user with empty tenant in session', async () => { - const store = { - dispatch: jest.fn(), - getState: () => ({ okapi: { tenant: 'monkey', url: 'monkeyUrl', currentPerms: { 'configuration.entries.collection.get': true } } }), - }; +describe('validateUser', () => { + it('handles fetch failure from "_self"', async () => { + const store = { + dispatch: jest.fn(), + getState: () => ({ okapi: { tenant: 'monkey', url: 'monkeyUrl' } }), + }; + const handleError = jest.fn().mockReturnValue(Promise.resolve()); - const tenant = 'tenant'; - const data = { monkey: 'bagel' }; - const user = { id: 'id' }; - const perms = []; - const session = { - user, - perms, - }; + mockFetchError(); - mockFetchSuccess(data); + const res = await validateUser('url', store, 'tenant', {}, handleError); + expect(handleError).toHaveBeenCalled(); + expect(res).toBeUndefined(); + mockFetchCleanUp(); + }); - // set a fixed system time so date math is stable - const now = new Date('2023-10-30T19:34:56.000Z'); - jest.useFakeTimers().setSystemTime(now); + it('handles valid user with empty tenant in session', async () => { + const store = { + dispatch: jest.fn(), + getState: () => ({ okapi: { tenant: 'monkey', url: 'monkeyUrl', currentPerms: { 'configuration.entries.collection.get': true } } }), + }; - await validateUser('url', store, tenant, session); + const tenant = 'tenant'; + const data = { monkey: 'bagel' }; + const user = { id: 'id' }; + const perms = []; + const session = { + user, + perms, + }; - expect(store.dispatch).toHaveBeenNthCalledWith(1, setAuthError(null)); - expect(store.dispatch).toHaveBeenNthCalledWith(2, setLoginData(data)); + mockFetchSuccess(data); - mockFetchCleanUp(); - }); + // set a fixed system time so date math is stable + const now = new Date('2023-10-30T19:34:56.000Z'); + jest.useFakeTimers().setSystemTime(now); - it('handles valid user with tenant in session', async () => { - const store = { - dispatch: jest.fn(), - getState: () => ({ okapi: { tenant: 'monkey', url: 'monkeyUrl' } }), - }; + await validateUser('url', store, tenant, session); - const tenant = 'tenant'; - const sessionTenant = 'sessionTenant'; - const data = { monkey: 'bagel' }; - const user = { id: 'id' }; - const perms = []; - const session = { - user, - perms, - tenant: sessionTenant, - }; + expect(store.dispatch).toHaveBeenNthCalledWith(1, setAuthError(null)); + expect(store.dispatch).toHaveBeenNthCalledWith(2, setLoginData(data)); - mockFetchSuccess(data); + mockFetchCleanUp(); + }); - await validateUser('url', store, tenant, session); - expect(store.dispatch).toHaveBeenNthCalledWith(1, setAuthError(null)); - expect(store.dispatch).toHaveBeenNthCalledWith(2, setLoginData(data)); + it('handles valid user with tenant in session', async () => { + const store = { + dispatch: jest.fn(), + getState: () => ({ okapi: { tenant: 'monkey', url: 'monkeyUrl' } }), + }; - mockFetchCleanUp(); - }); + const tenant = 'tenant'; + const sessionTenant = 'sessionTenant'; + const data = { monkey: 'bagel' }; + const user = { id: 'id' }; + const perms = []; + const session = { + user, + perms, + tenant: sessionTenant, + }; - it('overwrites session data with new values from _self', async () => { - const store = { - dispatch: jest.fn(), - getState: () => ({ okapi: { tenant: 'monkey', url: 'monkeyUrl' } }), - }; + mockFetchSuccess(data); - const tenant = 'tenant'; - const sessionTenant = 'sessionTenant'; - const data = { - user: { - id: 'ego', - username: 'superego', - }, - permissions: { - permissions: [{ permissionName: 'ask' }, { permissionName: 'tell' }], - } - }; + await validateUser('url', store, tenant, session); + expect(store.dispatch).toHaveBeenNthCalledWith(1, setAuthError(null)); + expect(store.dispatch).toHaveBeenNthCalledWith(2, setLoginData(data)); - const session = { - user: { - id: 'id', - username: 'username', - storageOnlyValue: 'is still persisted', - }, - perms: { foo: true }, - tenant: sessionTenant, - token: 'token', - }; + mockFetchCleanUp(); + }); - mockFetchSuccess(data); + it('overwrites session data with new values from _self', async () => { + const store = { + dispatch: jest.fn(), + getState: () => ({ okapi: { tenant: 'monkey', url: 'monkeyUrl' } }), + }; - await validateUser('url', store, tenant, session); + const tenant = 'tenant'; + const sessionTenant = 'sessionTenant'; + const data = { + user: { + id: 'ego', + username: 'superego', + }, + permissions: { + permissions: [{ permissionName: 'ask' }, { permissionName: 'tell' }], + } + }; - const updatedSession = { - user: { ...session.user, ...data.user }, - isAuthenticated: true, - perms: { ask: true, tell: true }, - tenant: session.tenant, - token: session.token, - }; + const session = { + user: { + id: 'id', + username: 'username', + storageOnlyValue: 'is still persisted', + }, + perms: { foo: true }, + tenant: sessionTenant, + token: 'token', + }; - expect(store.dispatch).toHaveBeenNthCalledWith(1, setAuthError(null)); - expect(store.dispatch).toHaveBeenNthCalledWith(2, setLoginData(data)); - expect(store.dispatch).toHaveBeenNthCalledWith(3, setCurrentPerms({ ask: true, tell: true })); - expect(store.dispatch).toHaveBeenNthCalledWith(4, setSessionData(updatedSession)); + mockFetchSuccess(data); - mockFetchCleanUp(); - }); + await validateUser('url', store, tenant, session); - it('handles invalid user', async () => { - const store = { - dispatch: jest.fn(), - getState: () => ({ okapi: { tenant: 'monkey', url: 'monkeyUrl' } }), - }; - const handleError = jest.fn().mockReturnValue(Promise.resolve()); + const updatedSession = { + user: { ...session.user, ...data.user }, + isAuthenticated: true, + perms: { ask: true, tell: true }, + tenant: session.tenant, + token: session.token, + }; - global.fetch = jest.fn().mockImplementation(() => { - return Promise.resolve({ ok: false, text: () => Promise.resolve('boom') }); - }); + expect(store.dispatch).toHaveBeenNthCalledWith(1, setAuthError(null)); + expect(store.dispatch).toHaveBeenNthCalledWith(2, setLoginData(data)); + expect(store.dispatch).toHaveBeenNthCalledWith(3, setCurrentPerms({ ask: true, tell: true })); + expect(store.dispatch).toHaveBeenNthCalledWith(4, setSessionData(updatedSession)); - const res = await validateUser('url', store, 'tenant', {}, handleError); - expect(handleError).toHaveBeenCalled(); - expect(res).toBeUndefined(); - mockFetchCleanUp(); - }); + mockFetchCleanUp(); }); - describe('updateUser', () => { - it('dispatches updateCurrentUser', async () => { - const store = { - dispatch: jest.fn(), - }; + it('handles invalid user', async () => { + const store = { + dispatch: jest.fn(), + getState: () => ({ okapi: { tenant: 'monkey', url: 'monkeyUrl' } }), + }; + const handleError = jest.fn().mockReturnValue(Promise.resolve()); - const session = { - user: { - id: 'id', - username: 'username', - storageOnlyValue: 'is still persisted', - }, - perms: { foo: true }, - tenant: 'testTenant', - token: 'token', - }; - const data = { thunder: 'chicken' }; - localforage.getItem.mockResolvedValue(session); - await updateUser(store, data); - expect(store.dispatch).toHaveBeenCalledWith(updateCurrentUser(data)); + global.fetch = jest.fn().mockImplementation(() => { + return Promise.resolve({ ok: false, text: () => Promise.resolve('boom') }); }); + + const res = await validateUser('url', store, 'tenant', {}, handleError); + expect(handleError).toHaveBeenCalled(); + expect(res).toBeUndefined(); + mockFetchCleanUp(); }); +}); - describe('updateTenant', () => { - const okapi = { - currentPerms: {}, +describe('updateUser', () => { + it('dispatches updateCurrentUser', async () => { + const store = { + dispatch: jest.fn(), }; - const tenant = 'test'; - const data = { + + const session = { user: { - id: 'userId', - username: 'testuser', - }, - permissions: { - permissions: [{ permissionName: 'test.permissions' }], + id: 'id', + username: 'username', + storageOnlyValue: 'is still persisted', }, + perms: { foo: true }, + tenant: 'testTenant', + token: 'token', }; - - beforeEach(() => { - localforage.setItem.mockClear(); - }); - - it('should set tenant and updated user in session', async () => { - mockFetchSuccess(data); - await updateTenant(okapi, tenant); - mockFetchCleanUp(); - - expect(localforage.setItem).toHaveBeenCalledWith('okapiSess', { - ...spreadUserWithPerms(data), - tenant, - }); - }); + const data = { thunder: 'chicken' }; + localforage.getItem.mockResolvedValueOnce(session); + await updateUser(store, data); + expect(store.dispatch).toHaveBeenCalledWith(updateCurrentUser(data)); }); +}); - describe('localforage wrappers', () => { - describe('getOkapiSession', () => { - it('retrieves a session object', async () => { - const o = { - margo: 'timmins', - margot: 'margot with a t looks better', - also: 'i thought we were talking about margot robbie?', - tokenExpiration: 'time out of mind', - }; +describe('updateTenant', () => { + const okapi = { + currentPerms: {}, + }; + const tenant = 'test'; + const data = { + user: { + id: 'userId', + username: 'testuser', + }, + permissions: { + permissions: [{ permissionName: 'test.permissions' }], + }, + }; + + beforeEach(() => { + localforage.setItem.mockClear(); + }); - localforage.getItem.mockResolvedValue(o); + it('should set tenant and updated user in session', async () => { + mockFetchSuccess(data); + await updateTenant(okapi, tenant); + mockFetchCleanUp(); - const s = await getOkapiSession(); - expect(s).toMatchObject(o); - }); + expect(localforage.setItem).toHaveBeenCalledWith('okapiSess', { + ...spreadUserWithPerms(data), + tenant, }); + }); +}); - describe('getTokenExpiry', () => { - it('finds tokenExpiration', async () => { - const o = { tokenExpiration: { trinity: 'cowboy junkies' } }; - localforage.getItem = jest.fn(() => Promise.resolve(o)); +describe('localforage wrappers', () => { + afterEach(() => { + jest.resetAllMocks(); + }); - const s = await getTokenExpiry(); - expect(s).toMatchObject(o.tokenExpiration); - }); + describe('getOkapiSession', () => { + it('retrieves a session object', async () => { + const o = { + margo: 'timmins', + margot: 'margot with a t looks better', + also: 'i thought we were talking about margot robbie?', + tokenExpiration: 'time out of mind', + test: 'okapiSess', + }; - it('handles missing tokenExpiration', async () => { - const o = { nobody: 'here but us chickens' }; - localforage.getItem = jest.fn(() => Promise.resolve(o)); + localforage.getItem.mockResolvedValue(o); - const s = await getTokenExpiry(); - expect(s).toBeFalsy(); - }); + const s = await getOkapiSession(); + expect(s).toMatchObject(o); }); + }); - describe('setTokenExpiry', () => { - describe('rejects invalid input', () => { - it('missing values', async () => { - const te = { - trinity: 'cowboy junkies', - sweet: 'james', - }; - - await expect(setTokenExpiry(te)).rejects.toThrow(TypeError); - }); - - describe('non-integer values', () => { - it('atExpires', async () => { - const te = { - atExpires: 3.1415926, - }; - await expect(setTokenExpiry(te)).rejects.toThrow(TypeError); - }); - - it('rtExpires', async () => { - const te = { - atExpires: 9_007_199_254_740_991, - rtExpires: 2.71828, - }; - await expect(setTokenExpiry(te)).rejects.toThrow(TypeError); - }); - }); - }); - - it('returns updated session data', async () => { - const storage = { - margo: 'timmins', - margot: 'margot with a t looks better', - also: 'i thought we were talking about margot robbie?', - tokenExpiration: 'time out of mind', - }; + describe('getTokenExpiry', () => { + it('finds tokenExpiration', async () => { + const o = { tokenExpiration: { trinity: 'cowboy junkies' } }; + localforage.getItem = jest.fn(() => Promise.resolve(o)); - localforage.getItem = jest.fn(() => Promise.resolve(storage)); - localforage.setItem = jest.fn((k, v) => Promise.resolve(v)); + const s = await getTokenExpiry(); + expect(s).toMatchObject(o.tokenExpiration); + }); + it('handles missing tokenExpiration', async () => { + const o = { nobody: 'here but us chickens' }; + localforage.getItem = jest.fn(() => Promise.resolve(o)); - const te = { - atExpires: 1, - rtExpires: 2, - }; - const res = await setTokenExpiry(te); - expect(res).toMatchObject({ ...storage, tokenExpiration: te }); - }); + const s = await getTokenExpiry(); + expect(s).toBeFalsy(); }); }); - describe('logout', () => { - describe('when logout has started in this window', () => { - it('returns immediately', async () => { - const store = { - dispatch: jest.fn(), - }; - window.sessionStorage.clear(); - window.sessionStorage.setItem(IS_LOGGING_OUT, 'true'); - - let res; - await logout('', store) - .then(() => { - res = true; - }); - expect(res).toBe(true); - expect(store.dispatch).not.toHaveBeenCalled(); - }); - }); - - describe('when logout has not started in this window', () => { - afterEach(() => { - mockFetchCleanUp(); - }); - - it('clears the redux store', async () => { - global.fetch = jest.fn().mockImplementation(() => Promise.resolve()); - const store = { - dispatch: jest.fn(), - getState: jest.fn(), - }; - window.sessionStorage.clear(); - - let res; - await logout('', store) - .then(() => { - res = true; - }); - expect(res).toBe(true); - - // expect(setItemSpy).toHaveBeenCalled(); - expect(store.dispatch).toHaveBeenCalledWith(setIsAuthenticated(false)); - expect(store.dispatch).toHaveBeenCalledWith(clearCurrentUser()); - expect(store.dispatch).toHaveBeenCalledWith(clearOkapiToken()); - }); - - it('calls fetch() when other window is not logging out', async () => { - global.fetch = jest.fn().mockImplementation(() => Promise.resolve()); - localStorage.setItem(SESSION_NAME, 'true'); - const store = { - dispatch: jest.fn(), - getState: jest.fn(), + describe('setTokenExpiry', () => { + describe('rejects invalid input', () => { + it('missing values', async () => { + const te = { + trinity: 'cowboy junkies', + sweet: 'james', }; - window.sessionStorage.clear(); - let res; - await logout('', store) - .then(() => { - res = true; - }); - - expect(res).toBe(true); - expect(global.fetch).toHaveBeenCalled(); + await expect(setTokenExpiry(te)).rejects.toThrow(TypeError); }); - it('does not call fetch() when other window is logging out', async () => { - global.fetch = jest.fn().mockImplementation(() => Promise.resolve()); - localStorage.clear(); - const store = { - dispatch: jest.fn(), - }; - window.sessionStorage.clear(); - - let res; - await logout('', store) - .then(() => { - res = true; - }); + describe('non-integer values', () => { + it('atExpires', async () => { + const te = { + atExpires: 3.1415926, + }; + await expect(setTokenExpiry(te)).rejects.toThrow(TypeError); + }); - expect(res).toBe(true); - expect(global.fetch).not.toHaveBeenCalled(); + it('rtExpires', async () => { + const te = { + atExpires: 9_007_199_254_740_991, + rtExpires: 2.71828, + }; + await expect(setTokenExpiry(te)).rejects.toThrow(TypeError); + }); }); }); - describe('react-query client', () => { - afterEach(() => { - mockFetchCleanUp(); - }); + it('returns updated session data', async () => { + const storage = { + margo: 'timmins', + margot: 'margot with a t looks better', + also: 'i thought we were talking about margot robbie?', + tokenExpiration: 'time out of mind', + }; - it('calls removeQueries given valid client', async () => { - global.fetch = jest.fn().mockImplementation(() => Promise.resolve()); - const store = { - dispatch: jest.fn(), - }; - const rqc = { - removeQueries: jest.fn(), - }; + localforage.getItem = jest.fn(() => Promise.resolve(storage)); + localforage.setItem = jest.fn((k, v) => Promise.resolve(v)); - let res; - await logout('', store, rqc) - .then(() => { - res = true; - }); - expect(res).toBe(true); - expect(rqc.removeQueries).toHaveBeenCalled(); - }); + const te = { + atExpires: 1, + rtExpires: 2, + }; + const res = await setTokenExpiry(te); + expect(res).toMatchObject({ ...storage, tokenExpiration: te }); }); }); +}); - describe('getLocale', () => { - it('dispatches setTimezone, setCurrency', async () => { - const value = { timezone: 'America/New_York', currency: 'USD' }; - mockFetchSuccess({ configs: [{ value: JSON.stringify(value) }] }); +describe('logout', () => { + describe('when logout has started in this window', () => { + it('returns immediately', async () => { const store = { dispatch: jest.fn(), - getState: () => ({ okapi: {} }), }; - await getLocale('url', store, 'tenant'); - expect(store.dispatch).toHaveBeenCalledWith(setTimezone(value.timezone)); - expect(store.dispatch).toHaveBeenCalledWith(setCurrency(value.currency)); - mockFetchCleanUp(); + window.sessionStorage.clear(); + window.sessionStorage.setItem(IS_LOGGING_OUT, 'true'); + + let res; + await logout('', store) + .then(() => { + res = true; + }); + expect(res).toBe(true); + expect(store.dispatch).not.toHaveBeenCalled(); }); }); - describe('getUserLocale', () => { - it('dispatches setTimezone, setCurrency', async () => { - const value = { locale: 'en-US', timezone: 'America/New_York', currency: 'USD' }; - mockFetchSuccess({ configs: [{ value: JSON.stringify(value) }] }); - const store = { - dispatch: jest.fn(), - getState: () => ({ okapi: {} }), - }; - await getUserLocale('url', store, 'tenant'); - expect(store.dispatch).toHaveBeenCalledWith(setTimezone(value.timezone)); - expect(store.dispatch).toHaveBeenCalledWith(setCurrency(value.currency)); + describe('when logout has not started in this window', () => { + afterEach(() => { mockFetchCleanUp(); }); - }); - describe('getPlugins', () => { - it('dispatches setPlugins', async () => { - const configs = [ - { configName: 'find-user', value: '@folio/plugin-hello-waldo' }, - { configName: 'find-water', value: '@folio/plugin-dowsing-rod' }, - ]; - mockFetchSuccess({ configs }); + it('clears the redux store', async () => { + global.fetch = jest.fn().mockImplementation(() => Promise.resolve()); const store = { dispatch: jest.fn(), - getState: () => ({ okapi: {} }), + getState: jest.fn(), }; - await getPlugins('url', store, 'tenant'); + window.sessionStorage.clear(); - const mappedConfigs = configs.reduce((acc, val) => ({ - ...acc, - [val.configName]: val.value, - }), {}); - expect(store.dispatch).toHaveBeenCalledWith(setPlugins(mappedConfigs)); - mockFetchCleanUp(); + let res; + await logout('', store) + .then(() => { + res = true; + }); + expect(res).toBe(true); + + // expect(setItemSpy).toHaveBeenCalled(); + expect(store.dispatch).toHaveBeenCalledWith(setIsAuthenticated(false)); + expect(store.dispatch).toHaveBeenCalledWith(clearCurrentUser()); + expect(store.dispatch).toHaveBeenCalledWith(clearOkapiToken()); }); - }); - describe('getBindings', () => { - it('dispatches setBindings', async () => { - const value = { key: 'value' }; - mockFetchSuccess({ configs: [{ value: JSON.stringify(value) }] }); + it('calls fetch() when other window is not logging out', async () => { + global.fetch = jest.fn().mockImplementation(() => Promise.resolve()); + localStorage.setItem(SESSION_NAME, 'true'); const store = { dispatch: jest.fn(), - getState: () => ({ okapi: {} }), + getState: jest.fn(), }; - await getBindings('url', store, 'tenant'); - expect(store.dispatch).toHaveBeenCalledWith(setBindings(value)); - mockFetchCleanUp(); - }); - }); - - describe('unauthorizedPath functions', () => { - beforeEach(() => { window.sessionStorage.clear(); - }); - afterEach(() => { - window.sessionStorage.clear(); - }); + let res; + await logout('', store) + .then(() => { + res = true; + }); - describe('removeUnauthorizedPathFromSession', () => { - it('clears the value', () => { - setUnauthorizedPathToSession('monkey'); - removeUnauthorizedPathFromSession(); - expect(getUnauthorizedPathFromSession()).toBe(null); - }); + expect(res).toBe(true); + expect(global.fetch).toHaveBeenCalled(); }); - describe('setUnauthorizedPathToSession', () => { - it('stores the given value', () => { - const value = 'monkey'; - setUnauthorizedPathToSession(value); - expect(getUnauthorizedPathFromSession()).toBe(value); - }); - - it('stores the current location given no value', () => { - window.location.pathname = '/some-path'; - window.location.search = '?monkey=bagel'; - setUnauthorizedPathToSession(); - expect(getUnauthorizedPathFromSession()).toBe(`${window.location.pathname}${window.location.search}`); - }); + it('does not call fetch() when other window is logging out', async () => { + global.fetch = jest.fn().mockImplementation(() => Promise.resolve()); + localStorage.clear(); + const store = { + dispatch: jest.fn(), + }; + window.sessionStorage.clear(); - describe('refuses to set locations beginning with "/logout"', () => { - it('with an argument', () => { - const monkey = '/logout-timeout'; - setUnauthorizedPathToSession(monkey); - expect(getUnauthorizedPathFromSession()).toBeFalsy(); + let res; + await logout('', store) + .then(() => { + res = true; }); - it('without an argument', () => { - window.location.pathname = '/logout-timeout'; - setUnauthorizedPathToSession(); - expect(getUnauthorizedPathFromSession()).toBeFalsy(); - }); - }); + expect(res).toBe(true); + expect(global.fetch).not.toHaveBeenCalled(); }); + }); - describe('getUnauthorizedPathFromSession', () => { - it('retrieves the value', () => { - const value = 'monkey'; - setUnauthorizedPathToSession(value); - expect(getUnauthorizedPathFromSession()).toBe(value); - }); + describe('react-query client', () => { + afterEach(() => { + mockFetchCleanUp(); }); - describe('getLogoutTenant', () => { - afterEach(() => { - localStorage.clear(); - }); + it('calls removeQueries given valid client', async () => { + global.fetch = jest.fn().mockImplementation(() => Promise.resolve()); + const store = { + dispatch: jest.fn(), + }; + const rqc = { + removeQueries: jest.fn(), + }; - it('retrieves the value from localstorage', () => { - const value = { tenantId: 'diku' }; - localStorage.setItem('tenant', JSON.stringify(value)); - const parsedTenant = getLogoutTenant(); - expect(parsedTenant).toStrictEqual(value); - }); + let res; + await logout('', store, rqc) + .then(() => { + res = true; + }); + + expect(res).toBe(true); + expect(rqc.removeQueries).toHaveBeenCalled(); }); + }); +}); - describe('getOIDCRedirectUri', () => { - it('should return encoded return_uri', () => { - window.location.protocol = 'http'; - window.location.host = 'localhost'; +describe('getLocale', () => { + it('dispatches setTimezone, setCurrency', async () => { + const value = { timezone: 'America/New_York', currency: 'USD' }; + mockFetchSuccess({ configs: [{ value: JSON.stringify(value) }] }); + const store = { + dispatch: jest.fn(), + getState: () => ({ okapi: {} }), + }; + await getLocale('url', store, 'tenant'); + expect(store.dispatch).toHaveBeenCalledWith(setTimezone(value.timezone)); + expect(store.dispatch).toHaveBeenCalledWith(setCurrency(value.currency)); + mockFetchCleanUp(); + }); +}); - const tenant = 'tenant'; - const clientId = 'client_id'; +describe('getUserLocale', () => { + it('dispatches setTimezone, setCurrency', async () => { + const value = { locale: 'en-US', timezone: 'America/New_York', currency: 'USD' }; + mockFetchSuccess({ configs: [{ value: JSON.stringify(value) }] }); + const store = { + dispatch: jest.fn(), + getState: () => ({ okapi: {} }), + }; + await getUserLocale('url', store, 'tenant'); + expect(store.dispatch).toHaveBeenCalledWith(setTimezone(value.timezone)); + expect(store.dispatch).toHaveBeenCalledWith(setCurrency(value.currency)); + mockFetchCleanUp(); + }); +}); - expect(getOIDCRedirectUri(tenant, clientId)).toEqual('http%3A%2F%2Flocalhost%2Foidc-landing%3Ftenant%3Dtenant%26client_id%3Dclient_id'); - }); - }); +describe('getPlugins', () => { + it('dispatches setPlugins', async () => { + const configs = [ + { configName: 'find-user', value: '@folio/plugin-hello-waldo' }, + { configName: 'find-water', value: '@folio/plugin-dowsing-rod' }, + ]; + mockFetchSuccess({ configs }); + const store = { + dispatch: jest.fn(), + getState: () => ({ okapi: {} }), + }; + await getPlugins('url', store, 'tenant'); + + const mappedConfigs = configs.reduce((acc, val) => ({ + ...acc, + [val.configName]: val.value, + }), {}); + expect(store.dispatch).toHaveBeenCalledWith(setPlugins(mappedConfigs)); + mockFetchCleanUp(); + }); +}); - describe('requestLogin', () => { - afterEach(() => { - mockFetchCleanUp(); - }); +describe('getBindings', () => { + it('dispatches setBindings', async () => { + const value = { key: 'value' }; + mockFetchSuccess({ configs: [{ value: JSON.stringify(value) }] }); + const store = { + dispatch: jest.fn(), + getState: () => ({ okapi: {} }), + }; + await getBindings('url', store, 'tenant'); + expect(store.dispatch).toHaveBeenCalledWith(setBindings(value)); + mockFetchCleanUp(); + }); +}); - it('should authenticate and create session when valid credentials provided', async () => { - const mockStore = { - getState: () => ({ - okapi: {}, - }), - dispatch: jest.fn() - }; - mockFetchSuccess({}); +describe('unauthorizedPath functions', () => { + beforeEach(() => { + window.sessionStorage.clear(); + }); - await requestLogin( - 'http://okapi-url', - mockStore, - 'test-tenant', - { username: 'testuser', password: 'testpass' } - ); + afterEach(() => { + window.sessionStorage.clear(); + }); - expect(global.fetch).toHaveBeenCalledWith( - 'http://okapi-url/bl-users/login-with-expiry?expandPermissions=true&fullPermissions=true', - expect.objectContaining({ - method: 'POST', - headers: expect.objectContaining({ - 'X-Okapi-Tenant': 'test-tenant', - 'Content-Type': 'application/json' - }) - }) - ); - }); + describe('removeUnauthorizedPathFromSession', () => { + it('clears the value', () => { + setUnauthorizedPathToSession('monkey'); + removeUnauthorizedPathFromSession(); + expect(getUnauthorizedPathFromSession()).toBe(null); }); + }); - describe('requestUserWithPerms', () => { - afterEach(() => { - mockFetchCleanUp(); - jest.clearAllMocks(); - }); - it('should authenticate and create session when valid credentials provided', async () => { - mockFetchSuccess({ tenant: 'tenant', originalTenantId: 'originalTenantId', ok: true }); - const mockStore = { - getState: () => ({ - okapi: {}, - }), - dispatch: jest.fn() - }; + describe('setUnauthorizedPathToSession', () => { + it('stores the given value', () => { + const value = 'monkey'; + setUnauthorizedPathToSession(value); + expect(getUnauthorizedPathFromSession()).toBe(value); + }); - await requestUserWithPerms( - 'http://okapi-url', - mockStore, - 'test-tenant', - 'token' - ); + it('stores the current location given no value', () => { + window.location.pathname = '/some-path'; + window.location.search = '?monkey=bagel'; + setUnauthorizedPathToSession(); + expect(getUnauthorizedPathFromSession()).toBe(`${window.location.pathname}${window.location.search}`); + }); - expect(global.fetch).toHaveBeenCalledWith('http://okapi-url/users-keycloak/_self?expandPermissions=true&fullPermissions=true&overrideUser=true', - { - headers: expect.objectContaining({ - 'X-Okapi-Tenant': 'test-tenant', - 'X-Okapi-Token': 'token', - 'Content-Type': 'application/json', - }), - 'rtrIgnore': false - }); + describe('refuses to set locations beginning with "/logout"', () => { + it('with an argument', () => { + const monkey = '/logout-timeout'; + setUnauthorizedPathToSession(monkey); + expect(getUnauthorizedPathFromSession()).toBeFalsy(); }); - it('should reject with an error object when response is not ok', async () => { - const mockError = { message: 'Permission denied' }; - const mockStore = { - getState: () => ({ - okapi: {}, - }), - dispatch: jest.fn() - }; - const mockResponse = { - ok: false, - json: jest.fn().mockResolvedValue(mockError), // Ensure `json()` is async - }; - global.fetch = jest.fn().mockImplementation(() => ( - Promise.resolve({ - ok: false, - status: 404, - json: () => Promise.resolve('Reject message'), - headers: new Map(), - }))); - fetchOverriddenUserWithPerms.mockResolvedValue(mockResponse); - - await expect(requestUserWithPerms('okapiUrl', mockStore, 'tenant', true)).rejects.toEqual('Reject message'); - mockFetchCleanUp(); + it('without an argument', () => { + window.location.pathname = '/logout-timeout'; + setUnauthorizedPathToSession(); + expect(getUnauthorizedPathFromSession()).toBeFalsy(); }); }); }); - describe('loadResources', () => { - let store; - let loadResourcesResult; + describe('getUnauthorizedPathFromSession', () => { + it('retrieves the value', () => { + const value = 'monkey'; + setUnauthorizedPathToSession(value); + expect(getUnauthorizedPathFromSession()).toBe(value); + }); + }); - const tenantLocaleData = { - items: [{ - id: 'tenantDataId', - value: { - locale: 'en-US', - numberingSystem: 'latn', - timezone: 'America/New_York', - currency: 'USD', - }, - }], - }; + describe('getLogoutTenant', () => { + afterEach(() => { + localStorage.clear(); + }); - const userLocaleData = { - items: [ - { - id: 'userDataId', - value: { - locale: 'en-GB', - timezone: 'Europe/London', - currency: 'GBP', - }, - }, - ], - }; + it('retrieves the value from localstorage', () => { + const value = { tenantId: 'diku' }; + localStorage.setItem('tenant', JSON.stringify(value)); + const parsedTenant = getLogoutTenant(); + expect(parsedTenant).toStrictEqual(value); + }); + }); - const getResponseData = (url) => { - if (url?.includes('key=="tenantLocaleSettings"')) return tenantLocaleData; - if (url?.includes('key=="localeSettings"')) return userLocaleData; + describe('getOIDCRedirectUri', () => { + it('should return encoded return_uri', () => { + window.location.protocol = 'http'; + window.location.host = 'localhost'; - return { url }; - }; + const tenant = 'tenant'; + const clientId = 'client_id'; - beforeEach(() => { - store = { - dispatch: jest.fn(), - getState: jest.fn().mockReturnValue({ - okapi: { - url: 'http://okapi-url', - currentPerms: {} - } + expect(getOIDCRedirectUri(tenant, clientId)).toEqual('http%3A%2F%2Flocalhost%2Foidc-landing%3Ftenant%3Dtenant%26client_id%3Dclient_id'); + }); + }); + + describe('requestLogin', () => { + afterEach(() => { + mockFetchCleanUp(); + }); + + it('should authenticate and create session when valid credentials provided', async () => { + const mockStore = { + getState: () => ({ + okapi: {}, }), + dispatch: jest.fn() }; + mockFetchSuccess({}); - discoverServices.mockResolvedValue({ url: 'discoverServices' }); + await requestLogin( + 'http://okapi-url', + mockStore, + 'test-tenant', + { username: 'testuser', password: 'testpass' } + ); + + expect(global.fetch).toHaveBeenCalledWith( + 'http://okapi-url/bl-users/login-with-expiry?expandPermissions=true&fullPermissions=true', + expect.objectContaining({ + method: 'POST', + headers: expect.objectContaining({ + 'X-Okapi-Tenant': 'test-tenant', + 'Content-Type': 'application/json' + }) + }) + ); }); + }); + describe('requestUserWithPerms', () => { afterEach(() => { mockFetchCleanUp(); - discoverServices.mockRestore(); jest.clearAllMocks(); }); + it('should authenticate and create session when valid credentials provided', async () => { + mockFetchSuccess({ tenant: 'tenant', originalTenantId: 'originalTenantId', ok: true }); + const mockStore = { + getState: () => ({ + okapi: {}, + }), + dispatch: jest.fn() + }; - describe('when there are permissions to read mod-settings and mod-configuration', () => { - beforeEach(() => { - store.getState.mockReturnValue({ - okapi: { - url: 'http://okapi-url', - currentPerms: { - 'mod-settings.entries.collection.get': true, - 'mod-settings.owner.read.stripes-core.prefs.manage': true, - 'configuration.entries.collection.get': true, - }, - }, - }); - }); - - describe('when the user and tenant locale settings are present in mod-settings', () => { - beforeEach(() => { - global.fetch = jest.fn().mockImplementation((url) => Promise.resolve({ - url, - json: () => Promise.resolve(getResponseData(url)), - })); - }); - - it('should fetch the tenant and user locale settings from mod-settings', async () => { - await loadResources(store, 'tenant', 'userId'); - - expect(global.fetch).toHaveBeenCalledWith( - 'http://okapi-url/settings/entries?query=(scope=="stripes-core.prefs.manage" and key=="tenantLocaleSettings")', - expect.anything(), - ); - expect(global.fetch).toHaveBeenCalledWith( - 'http://okapi-url/settings/entries?query=(userId=="userId" and scope=="stripes-core.prefs.manage" and key=="localeSettings")', - expect.anything(), - ); - }); - - it('should not fetch the tenant and user locale settings from mod-configuration', async () => { - await loadResources(store, 'tenant', 'userId'); - - expect(global.fetch).not.toHaveBeenCalledWith( - 'http://okapi-url/configurations/entries?query=(module==ORG AND configName == localeSettings AND (cql.allRecords=1 NOT userId="" NOT code=""))', - expect.anything(), - ); - expect(global.fetch).not.toHaveBeenCalledWith( - 'http://okapi-url/configurations/entries?query=("configName"=="localeSettings" AND "module"=="@folio/stripes-core" and userId=="userId")', - expect.anything(), - ); - }); - - it('should fetch the plugins and bindings', async () => { - await loadResources(store, 'tenant', 'userId'); - - expect(global.fetch).toHaveBeenCalledWith( - 'http://okapi-url/configurations/entries?query=(module==PLUGINS)', - expect.anything(), - ); - expect(global.fetch).toHaveBeenCalledWith( - 'http://okapi-url/configurations/entries?query=(module==ORG and configName==bindings)', - expect.anything(), - ); - }); - - it('should retrieve tenant-locale and user-locale from settings, plugins and bindings from configurations', async () => { - loadResourcesResult = await loadResources(store, 'tenant', 'userId'); - - expect(loadResourcesResult.map(({ url }) => url)).toEqual([ - 'http://okapi-url/settings/entries?query=(scope=="stripes-core.prefs.manage" and key=="tenantLocaleSettings")', - 'http://okapi-url/settings/entries?query=(userId=="userId" and scope=="stripes-core.prefs.manage" and key=="localeSettings")', - 'http://okapi-url/configurations/entries?query=(module==PLUGINS)', - 'http://okapi-url/configurations/entries?query=(module==ORG and configName==bindings)', - 'discoverServices', - ]); - }); - - describe('when both the tenant and user locale settings are present', () => { - it('should apply user locale settings', async () => { - const timezone = userLocaleData.items[0].value.timezone; - const currency = userLocaleData.items[0].value.currency; - - await loadResources(store, 'tenant', 'userId'); - - expect(store.dispatch).toHaveBeenCalledWith(setTimezone(timezone)); - expect(store.dispatch).toHaveBeenCalledWith(setCurrency(currency)); - }); - }); - - describe('when the user locale settings are missing something other than the locale', () => { - it('should take it from tenant locale settings', async () => { - await loadResources(store, 'tenant', 'userId'); + await requestUserWithPerms( + 'http://okapi-url', + mockStore, + 'test-tenant', + 'token' + ); - expect(document.documentElement.lang).toBe('en-GB-u-nu-latn'); - }); + expect(global.fetch).toHaveBeenCalledWith('http://okapi-url/users-keycloak/_self?expandPermissions=true&fullPermissions=true&overrideUser=true', + { + headers: expect.objectContaining({ + 'X-Okapi-Tenant': 'test-tenant', + 'X-Okapi-Token': 'token', + 'Content-Type': 'application/json', + }), + 'rtrIgnore': false }); - }); - - describe('when the user or tenant locale settings are not present in mod-settings', () => { - const getData = (url) => { - // if mod-settings API - if (url?.includes('key=="tenantLocaleSettings"') || url?.includes('key=="localeSettings"')) { - return { url, items: [] }; - } - - // if mod-configuration API - if (url?.includes('configName == localeSettings') || url?.includes('"configName"=="localeSettings"')) { - return { - url, - configs: [{ - value: JSON.stringify({ - locale: 'en-GB-u-nu-latn', - timezone: 'UTC', - currency: 'USD' - }), - }], - }; - } - - return { url }; - }; + }); - beforeEach(() => { - global.fetch = jest.fn().mockImplementation((url) => Promise.resolve({ - url, - json: () => Promise.resolve(getData(url)), - ok: true, - })); - }); + it('should reject with an error object when response is not ok', async () => { + const mockError = { message: 'Permission denied' }; + const mockStore = { + getState: () => ({ + okapi: {}, + }), + dispatch: jest.fn() + }; + const mockResponse = { + ok: false, + json: jest.fn().mockResolvedValue(mockError), // Ensure `json()` is async + }; + global.fetch = jest.fn().mockImplementation(() => ( + Promise.resolve({ + ok: false, + status: 404, + json: () => Promise.resolve('Reject message'), + headers: new Map(), + }))); + fetchOverriddenUserWithPerms.mockResolvedValue(mockResponse); - it('should fetch the tenant and user locale settings from mod-settings and mod-configuration', async () => { - await loadResources(store, 'tenant', 'userId'); + await expect(requestUserWithPerms('okapiUrl', mockStore, 'tenant', true)).rejects.toEqual('Reject message'); + mockFetchCleanUp(); + }); + }); +}); - expect(global.fetch).toHaveBeenCalledWith( - 'http://okapi-url/settings/entries?query=(scope=="stripes-core.prefs.manage" and key=="tenantLocaleSettings")', - expect.anything(), - ); - expect(global.fetch).toHaveBeenCalledWith( - 'http://okapi-url/settings/entries?query=(userId=="userId" and scope=="stripes-core.prefs.manage" and key=="localeSettings")', - expect.anything(), - ); - expect(global.fetch).toHaveBeenCalledWith( - 'http://okapi-url/configurations/entries?query=(module==ORG AND configName == localeSettings AND (cql.allRecords=1 NOT userId="" NOT code=""))', - expect.anything(), - ); - expect(global.fetch).toHaveBeenCalledWith( - 'http://okapi-url/configurations/entries?query=("configName"=="localeSettings" AND "module"=="@folio/stripes-core" and userId=="userId")', - expect.anything(), - ); - }); +describe('loadResources', () => { + let store; + let loadResourcesResult; + + const tenantLocaleData = { + items: [{ + id: 'tenantDataId', + value: { + locale: 'en-US', + numberingSystem: 'latn', + timezone: 'America/New_York', + currency: 'USD', + }, + }], + }; - it('should apply locale settings from mod-configuration', async () => { - await loadResources(store, 'tenant', 'userId'); + const userLocaleData = { + items: [ + { + id: 'userDataId', + value: { + locale: 'en-GB', + timezone: 'Europe/London', + currency: 'GBP', + }, + }, + ], + }; + + const getResponseData = (url) => { + if (url?.includes('key=="tenantLocaleSettings"')) return tenantLocaleData; + if (url?.includes('key=="localeSettings"')) return userLocaleData; + + return { url }; + }; + + beforeEach(() => { + store = { + dispatch: jest.fn(), + getState: jest.fn().mockReturnValue({ + okapi: { + url: 'http://okapi-url', + currentPerms: {} + } + }), + }; - expect(store.dispatch).toHaveBeenCalledWith(setTimezone('UTC')); - expect(store.dispatch).toHaveBeenCalledWith(setCurrency('USD')); - expect(document.documentElement.lang).toBe('en-GB-u-nu-latn'); - }); + discoverServices.mockResolvedValue({ url: 'discoverServices' }); + }); - it('should retrieve tenant-locale, user-locale, plugins, and bindings from configurations', async () => { - loadResourcesResult = await loadResources(store, 'tenant', 'userId'); + afterEach(() => { + mockFetchCleanUp(); + discoverServices.mockRestore(); + jest.clearAllMocks(); + }); - expect(loadResourcesResult.map(({ url }) => url)).toEqual([ - 'http://okapi-url/configurations/entries?query=(module==ORG AND configName == localeSettings AND (cql.allRecords=1 NOT userId="" NOT code=""))', - 'http://okapi-url/configurations/entries?query=("configName"=="localeSettings" AND "module"=="@folio/stripes-core" and userId=="userId")', - 'http://okapi-url/configurations/entries?query=(module==PLUGINS)', - 'http://okapi-url/configurations/entries?query=(module==ORG and configName==bindings)', - 'discoverServices', - ]); - }); + describe('when there are permissions to read mod-settings and mod-configuration', () => { + beforeEach(() => { + store.getState.mockReturnValue({ + okapi: { + url: 'http://okapi-url', + currentPerms: { + 'mod-settings.entries.collection.get': true, + 'mod-settings.owner.read.stripes-core.prefs.manage': true, + 'configuration.entries.collection.get': true, + }, + }, }); }); - describe('when there is permission to only read tenant settings from mod-settings', () => { + describe('when the user and tenant locale settings are present in mod-settings', () => { beforeEach(() => { - store.getState.mockReturnValue({ - okapi: { - url: 'http://okapi-url', - currentPerms: { - 'mod-settings.entries.collection.get': true, - 'mod-settings.global.read.stripes-core.prefs.manage': true, - }, - }, - }); - - global.fetch = jest.fn().mockImplementation(url => { - if (url?.includes('key=="localeSettings"')) { - return Promise.reject(new Error('Request failed')); - } - - return Promise.resolve({ - url, - json: () => Promise.resolve(getResponseData(url)), - }); - }); + global.fetch = jest.fn().mockImplementation((url) => Promise.resolve({ + url, + json: () => Promise.resolve(getResponseData(url)), + })); }); it('should fetch the tenant and user locale settings from mod-settings', async () => { @@ -1179,70 +1003,90 @@ describe('loginServices', () => { ); }); - it('should not fetch the plugins and bindings', async () => { + it('should not fetch the tenant and user locale settings from mod-configuration', async () => { await loadResources(store, 'tenant', 'userId'); expect(global.fetch).not.toHaveBeenCalledWith( - 'http://okapi-url/configurations/entries?query=(module==PLUGINS)', + 'http://okapi-url/configurations/entries?query=(module==ORG AND configName == localeSettings AND (cql.allRecords=1 NOT userId="" NOT code=""))', expect.anything(), ); expect(global.fetch).not.toHaveBeenCalledWith( - 'http://okapi-url/configurations/entries?query=(module==ORG and configName==bindings)', + 'http://okapi-url/configurations/entries?query=("configName"=="localeSettings" AND "module"=="@folio/stripes-core" and userId=="userId")', expect.anything(), ); }); - it('should apply tenant locale settings', async () => { - const timezone = tenantLocaleData.items[0].value.timezone; - const currency = tenantLocaleData.items[0].value.currency; - + it('should fetch the plugins and bindings', async () => { await loadResources(store, 'tenant', 'userId'); - expect(store.dispatch).toHaveBeenCalledWith(setTimezone(timezone)); - expect(store.dispatch).toHaveBeenCalledWith(setCurrency(currency)); - expect(document.documentElement.lang).toBe('en-US-u-nu-latn'); + expect(global.fetch).toHaveBeenCalledWith( + 'http://okapi-url/configurations/entries?query=(module==PLUGINS)', + expect.anything(), + ); + expect(global.fetch).toHaveBeenCalledWith( + 'http://okapi-url/configurations/entries?query=(module==ORG and configName==bindings)', + expect.anything(), + ); }); - it('should retrieve tenant locale from settings', async () => { + it('should retrieve tenant-locale and user-locale from settings, plugins and bindings from configurations', async () => { loadResourcesResult = await loadResources(store, 'tenant', 'userId'); - expect(loadResourcesResult.map(({ url } = {}) => url)).toEqual([ + expect(loadResourcesResult.map(({ url }) => url)).toEqual([ 'http://okapi-url/settings/entries?query=(scope=="stripes-core.prefs.manage" and key=="tenantLocaleSettings")', - undefined, // rejected request for user locale + 'http://okapi-url/settings/entries?query=(userId=="userId" and scope=="stripes-core.prefs.manage" and key=="localeSettings")', + 'http://okapi-url/configurations/entries?query=(module==PLUGINS)', + 'http://okapi-url/configurations/entries?query=(module==ORG and configName==bindings)', 'discoverServices', ]); }); - }); - describe('when there is permission to only read mod-configuration', () => { - beforeEach(() => { - store.getState = jest.fn().mockReturnValue({ - okapi: { - url: 'http://okapi-url', - currentPerms: { - 'configuration.entries.collection.get': true, - }, - }, + describe('when both the tenant and user locale settings are present', () => { + it('should apply user locale settings', async () => { + const timezone = userLocaleData.items[0].value.timezone; + const currency = userLocaleData.items[0].value.currency; + + await loadResources(store, 'tenant', 'userId'); + + expect(store.dispatch).toHaveBeenCalledWith(setTimezone(timezone)); + expect(store.dispatch).toHaveBeenCalledWith(setCurrency(currency)); }); + }); - const getData = (url) => { - // mod-configuration locales - if (url?.includes('configName == localeSettings') || url?.includes('"configName"=="localeSettings"')) { - return { - url, - configs: [{ - value: JSON.stringify({ - locale: 'en-GB-u-nu-latn', - timezone: 'UTC', - currency: 'USD' - }), - }], - }; - } - - return { url }; - }; + describe('when the user locale settings are missing something other than the locale', () => { + it('should take it from tenant locale settings', async () => { + await loadResources(store, 'tenant', 'userId'); + expect(document.documentElement.lang).toBe('en-GB-u-nu-latn'); + }); + }); + }); + + describe('when the user or tenant locale settings are not present in mod-settings', () => { + const getData = (url) => { + // if mod-settings API + if (url?.includes('key=="tenantLocaleSettings"') || url?.includes('key=="localeSettings"')) { + return { url, items: [] }; + } + + // if mod-configuration API + if (url?.includes('configName == localeSettings') || url?.includes('"configName"=="localeSettings"')) { + return { + url, + configs: [{ + value: JSON.stringify({ + locale: 'en-GB-u-nu-latn', + timezone: 'UTC', + currency: 'USD' + }), + }], + }; + } + + return { url }; + }; + + beforeEach(() => { global.fetch = jest.fn().mockImplementation((url) => Promise.resolve({ url, json: () => Promise.resolve(getData(url)), @@ -1250,22 +1094,17 @@ describe('loginServices', () => { })); }); - it('should not fetch the tenant and user locale settings from mod-settings', async () => { + it('should fetch the tenant and user locale settings from mod-settings and mod-configuration', async () => { await loadResources(store, 'tenant', 'userId'); - expect(global.fetch).not.toHaveBeenCalledWith( + expect(global.fetch).toHaveBeenCalledWith( 'http://okapi-url/settings/entries?query=(scope=="stripes-core.prefs.manage" and key=="tenantLocaleSettings")', expect.anything(), ); - expect(global.fetch).not.toHaveBeenCalledWith( + expect(global.fetch).toHaveBeenCalledWith( 'http://okapi-url/settings/entries?query=(userId=="userId" and scope=="stripes-core.prefs.manage" and key=="localeSettings")', expect.anything(), ); - }); - - it('should fetch the tenant and user locale settings from mod-configuration', async () => { - await loadResources(store, 'tenant', 'userId'); - expect(global.fetch).toHaveBeenCalledWith( 'http://okapi-url/configurations/entries?query=(module==ORG AND configName == localeSettings AND (cql.allRecords=1 NOT userId="" NOT code=""))', expect.anything(), @@ -1276,20 +1115,7 @@ describe('loginServices', () => { ); }); - it('should fetch the plugins and bindings', async () => { - await loadResources(store, 'tenant', 'userId'); - - expect(global.fetch).toHaveBeenCalledWith( - 'http://okapi-url/configurations/entries?query=(module==PLUGINS)', - expect.anything(), - ); - expect(global.fetch).toHaveBeenCalledWith( - 'http://okapi-url/configurations/entries?query=(module==ORG and configName==bindings)', - expect.anything(), - ); - }); - - it('should apply locale settings', async () => { + it('should apply locale settings from mod-configuration', async () => { await loadResources(store, 'tenant', 'userId'); expect(store.dispatch).toHaveBeenCalledWith(setTimezone('UTC')); @@ -1309,74 +1135,242 @@ describe('loginServices', () => { ]); }); }); + }); + + describe('when there is permission to only read tenant settings from mod-settings', () => { + beforeEach(() => { + store.getState.mockReturnValue({ + okapi: { + url: 'http://okapi-url', + currentPerms: { + 'mod-settings.entries.collection.get': true, + 'mod-settings.global.read.stripes-core.prefs.manage': true, + }, + }, + }); + + global.fetch = jest.fn().mockImplementation(url => { + if (url?.includes('key=="localeSettings"')) { + return Promise.reject(new Error('Request failed')); + } + + return Promise.resolve({ + url, + json: () => Promise.resolve(getResponseData(url)), + }); + }); + }); - it('should fetch discoverServices when okapi is available', async () => { + it('should fetch the tenant and user locale settings from mod-settings', async () => { await loadResources(store, 'tenant', 'userId'); - expect(discoverServices).toHaveBeenCalledWith(store); + expect(global.fetch).toHaveBeenCalledWith( + 'http://okapi-url/settings/entries?query=(scope=="stripes-core.prefs.manage" and key=="tenantLocaleSettings")', + expect.anything(), + ); + expect(global.fetch).toHaveBeenCalledWith( + 'http://okapi-url/settings/entries?query=(userId=="userId" and scope=="stripes-core.prefs.manage" and key=="localeSettings")', + expect.anything(), + ); }); - }); - describe('getLoginTenant', () => { - it('uses URL values when present', () => { - const search = { tenant: 't', client_id: 'c' }; - Object.defineProperty(window, 'location', { value: { search } }); + it('should not fetch the plugins and bindings', async () => { + await loadResources(store, 'tenant', 'userId'); - const res = getLoginTenant({}, {}); - expect(res.tenant).toBe(search.tenant); - expect(res.clientId).toBe(search.client_id); + expect(global.fetch).not.toHaveBeenCalledWith( + 'http://okapi-url/configurations/entries?query=(module==PLUGINS)', + expect.anything(), + ); + expect(global.fetch).not.toHaveBeenCalledWith( + 'http://okapi-url/configurations/entries?query=(module==ORG and configName==bindings)', + expect.anything(), + ); }); - describe('single-tenant', () => { - it('uses config.tenantOptions values when URL values are absent', () => { - const config = { - tenantOptions: { - denzel: { name: 'denzel', clientId: 'nolan' } - } - }; + it('should apply tenant locale settings', async () => { + const timezone = tenantLocaleData.items[0].value.timezone; + const currency = tenantLocaleData.items[0].value.currency; - const res = getLoginTenant({}, config); - expect(res.tenant).toBe(config.tenantOptions.denzel.name); - expect(res.clientId).toBe(config.tenantOptions.denzel.clientId); - }); + await loadResources(store, 'tenant', 'userId'); - it('uses okapi.tenant and okapi.clientId when config.tenantOptions is missing', () => { - const okapi = { - tenant: 't', - clientId: 'c', - }; + expect(store.dispatch).toHaveBeenCalledWith(setTimezone(timezone)); + expect(store.dispatch).toHaveBeenCalledWith(setCurrency(currency)); + expect(document.documentElement.lang).toBe('en-US-u-nu-latn'); + }); - const res = getLoginTenant(okapi, {}); - expect(res.tenant).toBe(okapi.tenant); - expect(res.clientId).toBe(okapi.clientId); - }); + it('should retrieve tenant locale from settings', async () => { + loadResourcesResult = await loadResources(store, 'tenant', 'userId'); - it('returns undefined when all options are exhausted', () => { - const res = getLoginTenant(); - expect(res.tenant).toBeUndefined(); - expect(res.clientId).toBeUndefined(); + expect(loadResourcesResult.map(({ url } = {}) => url)).toEqual([ + 'http://okapi-url/settings/entries?query=(scope=="stripes-core.prefs.manage" and key=="tenantLocaleSettings")', + undefined, // rejected request for user locale + 'discoverServices', + ]); + }); + }); + + describe('when there is permission to only read mod-configuration', () => { + beforeEach(() => { + store.getState = jest.fn().mockReturnValue({ + okapi: { + url: 'http://okapi-url', + currentPerms: { + 'configuration.entries.collection.get': true, + }, + }, }); + + const getData = (url) => { + // mod-configuration locales + if (url?.includes('configName == localeSettings') || url?.includes('"configName"=="localeSettings"')) { + return { + url, + configs: [{ + value: JSON.stringify({ + locale: 'en-GB-u-nu-latn', + timezone: 'UTC', + currency: 'USD' + }), + }], + }; + } + + return { url }; + }; + + global.fetch = jest.fn().mockImplementation((url) => Promise.resolve({ + url, + json: () => Promise.resolve(getData(url)), + ok: true, + })); + }); + + it('should not fetch the tenant and user locale settings from mod-settings', async () => { + await loadResources(store, 'tenant', 'userId'); + + expect(global.fetch).not.toHaveBeenCalledWith( + 'http://okapi-url/settings/entries?query=(scope=="stripes-core.prefs.manage" and key=="tenantLocaleSettings")', + expect.anything(), + ); + expect(global.fetch).not.toHaveBeenCalledWith( + 'http://okapi-url/settings/entries?query=(userId=="userId" and scope=="stripes-core.prefs.manage" and key=="localeSettings")', + expect.anything(), + ); }); - describe('multi-tenant', () => { - const stripesConfig = { + it('should fetch the tenant and user locale settings from mod-configuration', async () => { + await loadResources(store, 'tenant', 'userId'); + + expect(global.fetch).toHaveBeenCalledWith( + 'http://okapi-url/configurations/entries?query=(module==ORG AND configName == localeSettings AND (cql.allRecords=1 NOT userId="" NOT code=""))', + expect.anything(), + ); + expect(global.fetch).toHaveBeenCalledWith( + 'http://okapi-url/configurations/entries?query=("configName"=="localeSettings" AND "module"=="@folio/stripes-core" and userId=="userId")', + expect.anything(), + ); + }); + + it('should fetch the plugins and bindings', async () => { + await loadResources(store, 'tenant', 'userId'); + + expect(global.fetch).toHaveBeenCalledWith( + 'http://okapi-url/configurations/entries?query=(module==PLUGINS)', + expect.anything(), + ); + expect(global.fetch).toHaveBeenCalledWith( + 'http://okapi-url/configurations/entries?query=(module==ORG and configName==bindings)', + expect.anything(), + ); + }); + + it('should apply locale settings', async () => { + await loadResources(store, 'tenant', 'userId'); + + expect(store.dispatch).toHaveBeenCalledWith(setTimezone('UTC')); + expect(store.dispatch).toHaveBeenCalledWith(setCurrency('USD')); + expect(document.documentElement.lang).toBe('en-GB-u-nu-latn'); + }); + + it('should retrieve tenant-locale, user-locale, plugins, and bindings from configurations', async () => { + loadResourcesResult = await loadResources(store, 'tenant', 'userId'); + + expect(loadResourcesResult.map(({ url }) => url)).toEqual([ + 'http://okapi-url/configurations/entries?query=(module==ORG AND configName == localeSettings AND (cql.allRecords=1 NOT userId="" NOT code=""))', + 'http://okapi-url/configurations/entries?query=("configName"=="localeSettings" AND "module"=="@folio/stripes-core" and userId=="userId")', + 'http://okapi-url/configurations/entries?query=(module==PLUGINS)', + 'http://okapi-url/configurations/entries?query=(module==ORG and configName==bindings)', + 'discoverServices', + ]); + }); + }); + + it('should fetch discoverServices when okapi is available', async () => { + await loadResources(store, 'tenant', 'userId'); + + expect(discoverServices).toHaveBeenCalledWith(store); + }); +}); + +describe('getLoginTenant', () => { + it('uses URL values when present', () => { + const search = { tenant: 't', client_id: 'c' }; + Object.defineProperty(window, 'location', { value: { search } }); + + const res = getLoginTenant({}, {}); + expect(res.tenant).toBe(search.tenant); + expect(res.clientId).toBe(search.client_id); + }); + + describe('single-tenant', () => { + it('uses config.tenantOptions values when URL values are absent', () => { + const config = { tenantOptions: { - tenant1: { name: 'tenant1', clientId: 'client1' }, - tenant2: { name: 'tenant2', clientId: 'client2' }, + denzel: { name: 'denzel', clientId: 'nolan' } } }; - describe('when URL contains tenant and no client_id', () => { - it('should take tenant from URL', () => { - // URL: /reset-password?resetToken=token1&tenant=tenant1 - const search = { tenant: 'tenant1' }; - Object.defineProperty(window, 'location', { value: { search } }); - - const res = getLoginTenant({}, stripesConfig); - expect(res.tenant).toBe(search.tenant); - }); - }); + + const res = getLoginTenant({}, config); + expect(res.tenant).toBe(config.tenantOptions.denzel.name); + expect(res.clientId).toBe(config.tenantOptions.denzel.clientId); + }); + + it('uses okapi.tenant and okapi.clientId when config.tenantOptions is missing', () => { + const okapi = { + tenant: 't', + clientId: 'c', + }; + + const res = getLoginTenant(okapi, {}); + expect(res.tenant).toBe(okapi.tenant); + expect(res.clientId).toBe(okapi.clientId); }); - describe('ECS', () => { }); + it('returns undefined when all options are exhausted', () => { + const res = getLoginTenant(); + expect(res.tenant).toBeUndefined(); + expect(res.clientId).toBeUndefined(); + }); }); + + describe('multi-tenant', () => { + const stripesConfig = { + tenantOptions: { + tenant1: { name: 'tenant1', clientId: 'client1' }, + tenant2: { name: 'tenant2', clientId: 'client2' }, + } + }; + describe('when URL contains tenant and no client_id', () => { + it('should take tenant from URL', () => { + // URL: /reset-password?resetToken=token1&tenant=tenant1 + const search = { tenant: 'tenant1' }; + Object.defineProperty(window, 'location', { value: { search } }); + + const res = getLoginTenant({}, stripesConfig); + expect(res.tenant).toBe(search.tenant); + }); + }); + }); + + describe('ECS', () => { }); }); From 1afb2c1f7eaea274d55c4c0aa847e9197274f590 Mon Sep 17 00:00:00 2001 From: Zak Burke Date: Tue, 3 Feb 2026 16:18:13 -0500 Subject: [PATCH 58/59] s/entitlementUrl/discoveryUrl/g --- src/App.js | 4 ++-- src/components/EntitlementLoader.js | 11 ++++++----- src/components/EntitlementLoader.test.js | 20 ++++++++++---------- src/components/Root/Root.js | 4 ++-- src/components/loadEntitlement.js | 22 +++++++++++++--------- src/constants/stripesHubAPI.js | 2 +- 6 files changed, 34 insertions(+), 29 deletions(-) diff --git a/src/App.js b/src/App.js index 4147b3849..1def11e63 100644 --- a/src/App.js +++ b/src/App.js @@ -104,7 +104,7 @@ export default class StripesCore extends Component { try { const modules = await getModules(); - const entitlementUrl = await localforage.getItem(stripesHubAPI.ENTITLEMENT_URL_KEY); + const discoveryUrl = await localforage.getItem(stripesHubAPI.DISCOVERY_URL_KEY); const hostLocation = await localforage.getItem(stripesHubAPI.HOST_LOCATION_KEY); const remotesList = await localforage.getItem(stripesHubAPI.REMOTE_LIST_KEY); @@ -114,7 +114,7 @@ export default class StripesCore extends Component { actionNames, modules, stripesHub: { - entitlementUrl, + discoveryUrl, hostLocation, remotesList, } diff --git a/src/components/EntitlementLoader.js b/src/components/EntitlementLoader.js index 9c72ef5f3..23bf73529 100644 --- a/src/components/EntitlementLoader.js +++ b/src/components/EntitlementLoader.js @@ -4,6 +4,7 @@ import { useStripes } from '../StripesContext'; import { ModulesContext, useModules, modulesInitialState } from '../ModulesContext'; import loadRemoteComponent from '../loadRemoteComponent'; import { loadEntitlement } from './loadEntitlement'; + /** * preloadModules * Loads each module code and sets up its getModule function. @@ -11,10 +12,10 @@ import { loadEntitlement } from './loadEntitlement'; * settings, handler) where the value of each is an array of corresponding * applications. * + * @param {object} stripes * @param {array} remotes * @returns {app: [], plugin: [], settings: [], handler: []} */ - export const preloadModules = async (stripes, remotes) => { const modules = { app: [], plugin: [], settings: [], handler: [] }; @@ -71,7 +72,7 @@ const loadTranslations = async (stripes, module) => { stripes.setTranslations(tx); return tx; } else { - throw new Error(`Could not load translations for ${module.name}`); + throw new Error(`Could not load translations for ${module.name}; failed to find ${url} `); } }; @@ -179,16 +180,16 @@ const EntitlementLoader = ({ children }) => { const { okapi } = stripes; const controller = new AbortController(); const signal = controller.signal; - if (okapi?.entitlementUrl) { + if (okapi?.discoveryUrl) { // fetches the list of registered apps/metadata, // loads icons and translations, then module code, // ultimately stores the result in the modules state to pass down into the modules context. const fetchRegistry = async () => { let remotes; try { - remotes = await loadEntitlement(okapi.entitlementUrl, signal); + remotes = await loadEntitlement(okapi.discoveryUrl, signal); } catch (e) { - handleRemoteModuleError(stripes, `Error fetching entitlement registry from ${okapi.entitlementUrl}: ${e}`); + handleRemoteModuleError(stripes, `Error fetching entitlement registry from ${okapi.discoveryUrl}: ${e}`); } let cachedModules = modulesInitialState; diff --git a/src/components/EntitlementLoader.test.js b/src/components/EntitlementLoader.test.js index 95c889154..d68de3dd9 100644 --- a/src/components/EntitlementLoader.test.js +++ b/src/components/EntitlementLoader.test.js @@ -150,7 +150,7 @@ describe('EntitlementLoader', () => { jest.restoreAllMocks(); }); - describe('when entitlementUrl is configured', () => { + describe('when discoveryUrl is configured', () => { let capturedModules = null; const TestContextComponent = () => { capturedModules = useModules(); @@ -159,7 +159,7 @@ describe('EntitlementLoader', () => { beforeEach(() => { capturedModules = null; - okapi.entitlementUrl = 'http://localhost:8000/entitlement'; + okapi.discoveryUrl = 'http://localhost:8000/entitlement'; global.fetch = jest.fn(); // two modules in mock, two calls to fetch translations... global.fetch.mockResolvedValueOnce({ @@ -172,16 +172,16 @@ describe('EntitlementLoader', () => { }); it('fetches the registry and loads modules dynamically', async () => { - const entitlementUrl = 'http://localhost:8000/entitlement'; - render(); + const discoveryUrl = 'http://localhost:8000/entitlement'; + render(); await waitFor(() => { - expect(loadEntitlement).toHaveBeenCalledWith(entitlementUrl, new AbortController().signal); + expect(loadEntitlement).toHaveBeenCalledWith(discoveryUrl, new AbortController().signal); }); }); it('passes dynamic modules to ModulesContext.Provider', async () => { - render(); + render(); await waitFor(() => { // expect(screen.queryByText('No Modules')).not.toBeInTheDocument(); @@ -220,7 +220,7 @@ describe('EntitlementLoader', () => { }); }); - describe('when entitlementUrl is not configured', () => { + describe('when discoveryUrl is not configured', () => { let capturedModules = null; const ContextTestComponent = () => { capturedModules = React.useContext(ModulesContext); @@ -229,7 +229,7 @@ describe('EntitlementLoader', () => { beforeEach(() => { capturedModules = null; - okapi.entitlementUrl = undefined; + okapi.discoveryUrl = undefined; }); it('does not fetch the registry', async () => { @@ -244,7 +244,7 @@ describe('EntitlementLoader', () => { }); }); - it('passes through configModules to ModulesContext when no entitlementUrl', async () => { + it('passes through configModules to ModulesContext when no discoveryUrl', async () => { render( @@ -259,7 +259,7 @@ describe('EntitlementLoader', () => { describe('children rendering', () => { it('renders children when modules are available', async () => { - okapi.entitlementUrl = undefined; + okapi.discoveryUrl = undefined; render( diff --git a/src/components/Root/Root.js b/src/components/Root/Root.js index 81168cf68..f88e884d3 100644 --- a/src/components/Root/Root.js +++ b/src/components/Root/Root.js @@ -181,9 +181,9 @@ class Root extends Component { // time, but still, props are props so technically it's possible. config.rtr = configureRtr(this.props.config.rtr); - // if we have a stripesHub entitlementUrl, pass it to stripes... + // if we have a stripesHub discoveryUrl, pass it to stripes... - const stripesOkapi = stripesHub?.entitlementUrl ? { ...okapi, entitlementUrl: stripesHub.entitlementUrl } : okapi; + const stripesOkapi = stripesHub?.discoveryUrl ? { ...okapi, discoveryUrl: stripesHub.discoveryUrl } : okapi; const stripes = new Stripes({ logger, diff --git a/src/components/loadEntitlement.js b/src/components/loadEntitlement.js index 69d9a8aad..33ef9a139 100644 --- a/src/components/loadEntitlement.js +++ b/src/components/loadEntitlement.js @@ -1,22 +1,27 @@ import localforage from 'localforage'; import { stripesHubAPI } from '../constants'; -export const loadEntitlement = async (entitlementUrl, signal) => { +export const loadEntitlement = async (discoveryUrl, signal) => { let registry = {}; const discovery = await localforage.getItem(stripesHubAPI.REMOTE_LIST_KEY); if (discovery) { registry = { discovery }; - } else if (entitlementUrl) { + } else if (discoveryUrl) { try { - const res = await fetch(entitlementUrl, { signal }); - if (!res.ok) throw new Error('Unable to fetch entitlement Url'); + const res = await fetch(discoveryUrl, { signal }); + if (!res.ok) throw new Error(`Unable to fetch discoveryUrl ${discoveryUrl}`); const registryData = await res.json(); - // strip out the host app if it's present... + // strip out the host app if it's present (we ARE the host app; we don't + // want to load ourselves. that would result in the host app loading the + // host app, then loading entitlement and stripping out the host app if + // it's present...) registry.discovery = registryData?.discovery.filter((entry) => entry.name !== stripesHubAPI.HOST_APP_NAME); + + await localforage.setItem(stripesHubAPI.REMOTE_LIST_KEY, registry.discovery); } catch (e) { if (e.name !== 'AbortError') { - console.error('Entitlement fetch error:', e); + console.error('Discovery fetch error:', e); // eslint-disable-line no-console } } } @@ -24,7 +29,6 @@ export const loadEntitlement = async (entitlementUrl, signal) => { // Take the location information for each remote in the response and split out its origin... // i.e. 'http://localhost:3002/remoteEntry.js -> 'http://localhost:3002' // this origin is where stripes-core will attempt to fetch translations and assets from. - registry?.discovery?.forEach(remote => { if (!remote?.location?.startsWith('http')) { remote.location = `${window.location.protocol}//${remote.location}`; @@ -33,9 +37,9 @@ export const loadEntitlement = async (entitlementUrl, signal) => { remote.host = url.hostname; remote.port = url.port; remote.origin = url.origin; - let segments = url.href.split('/'); + const segments = url.href.split('/'); segments.pop(); - let hrefWithoutFilename = segments.join('/') + const hrefWithoutFilename = segments.join('/') remote.assetPath = hrefWithoutFilename; }); return Promise.resolve(registry?.discovery); diff --git a/src/constants/stripesHubAPI.js b/src/constants/stripesHubAPI.js index 9a98d1c77..b9ea146d4 100644 --- a/src/constants/stripesHubAPI.js +++ b/src/constants/stripesHubAPI.js @@ -4,6 +4,6 @@ export default { HOST_LOCATION_KEY: 'hostLocation', REMOTE_LIST_KEY: 'entitlements', - ENTITLEMENT_URL_KEY: 'entitlementUrl', + DISCOVERY_URL_KEY: 'discoveryUrl', HOST_APP_NAME: 'folio_stripes', }; From ba9179ca23567b5251fd4362836ee2c0af85d5ed Mon Sep 17 00:00:00 2001 From: John Coburn Date: Tue, 3 Feb 2026 15:43:54 -0600 Subject: [PATCH 59/59] logging clean-up, remove dangling space/test bother --- src/components/EntitlementLoader.js | 11 +---------- src/components/EntitlementLoader.test.js | 2 +- 2 files changed, 2 insertions(+), 11 deletions(-) diff --git a/src/components/EntitlementLoader.js b/src/components/EntitlementLoader.js index 23bf73529..9406b4d47 100644 --- a/src/components/EntitlementLoader.js +++ b/src/components/EntitlementLoader.js @@ -63,8 +63,6 @@ const loadTranslations = async (stripes, module) => { // somehow adopted for our files in Lokalise). const locale = stripes.locale.split('-u-nu-')[0].replace('-', '_'); const url = `${module.assetPath}/translations/${locale}.json`; - stripes.logger.log('core', `loading ${locale} translations for ${module.name}`); - const res = await fetch(url); if (res.ok) { const fetchedTranslations = await res.json(); @@ -72,7 +70,7 @@ const loadTranslations = async (stripes, module) => { stripes.setTranslations(tx); return tx; } else { - throw new Error(`Could not load translations for ${module.name}; failed to find ${url} `); + throw new Error(`Could not load translations for ${module.name}; failed to find ${url}`); } }; @@ -88,10 +86,7 @@ const loadTranslations = async (stripes, module) => { */ const loadIcons = (stripes, module) => { if (module.icons?.length) { - stripes.logger.log('core', `loading icons for ${module.module}`); module.icons.forEach(i => { - stripes.logger.log('core', ` > ${i.name}`); - const icon = { [i.name]: { src: `${module.assetPath}/icons/${i.name}.svg`, @@ -114,10 +109,6 @@ export const loadModuleAssets = async (stripes, module) => { // register icons loadIcons(stripes, module); - // register sounds - // TODO loadSounds(stripes, module); - - // register translations try { const tx = await loadTranslations(stripes, module); let newDisplayName; diff --git a/src/components/EntitlementLoader.test.js b/src/components/EntitlementLoader.test.js index d68de3dd9..7e7e2934e 100644 --- a/src/components/EntitlementLoader.test.js +++ b/src/components/EntitlementLoader.test.js @@ -387,7 +387,7 @@ describe('EntitlementLoader', () => { try { await loadModuleAssets(mockStripes, module); } catch (e) { - expect(mockStripes.logger.log).toHaveBeenCalledWith('core', 'Error loading assets for test-module: Could not load translations for test-module'); + expect(mockStripes.logger.log).toHaveBeenCalledWith('core', 'Error loading assets for test-module: Could not load translations for test-module; failed to find localhost:3000/path/translations/en_US.json'); } });