diff --git a/src/App.js b/src/App.js index db89ff325..1def11e63 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 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); + const actionNames = gatherActions(modules); this.setState({ actionNames, modules, + stripesHub: { + discoveryUrl, + 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/RootWithIntl.js b/src/RootWithIntl.js index 4c0339b2b..3c7317550 100644 --- a/src/RootWithIntl.js +++ b/src/RootWithIntl.js @@ -43,6 +43,7 @@ import StaleBundleWarning from './components/StaleBundleWarning'; import { StripesContext } from './StripesContext'; import { CalloutContext } from './CalloutContext'; import AuthnLogin from './components/AuthnLogin'; +import EntitlementLoader from './components/EntitlementLoader'; const RootWithIntl = ({ stripes, token = '', isAuthenticated = false, disableAuth, history = {}, queryClient }) => { const connect = connectFor('@folio/core', stripes.epics, stripes.logger); @@ -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/components/EntitlementLoader.js b/src/components/EntitlementLoader.js new file mode 100644 index 000000000..9406b4d47 --- /dev/null +++ b/src/components/EntitlementLoader.js @@ -0,0 +1,238 @@ +import { useEffect, useState, useMemo } from 'react'; +import PropTypes from 'prop-types'; +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. + * 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 {object} stripes + * @param {array} remotes + * @returns {app: [], plugin: [], settings: [], handler: []} + */ +export const preloadModules = async (stripes, remotes) => { + const modules = { app: [], plugin: [], settings: [], handler: [] }; + + try { + const loaderArray = []; + remotes.forEach(remote => { + const { name, location } = remote; + loaderArray.push(loadRemoteComponent(location, 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) { + stripes.logger.log('core', `Error preloading modules from entitlement response: ${e}`); + } + + return modules; +}; + +/** + * loadTranslations + * 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 + * + * @returns {Promise} + */ +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 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.assetPath}/translations/${locale}.json`; + 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}; failed to find ${url}`); + } +}; + +/** + * 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?.length) { + module.icons.forEach(i => { + const icon = { + [i.name]: { + src: `${module.assetPath}/icons/${i.name}.svg`, + alt: i.title, + } + }; + 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 + */ +export const loadModuleAssets = async (stripes, module) => { + // register icons + loadIcons(stripes, module); + + 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) { + 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}`); + } +}; + +/** + * loadAllModuleAssets + * Loads icons, translations, and sounds for all modules. Inserts the correct 'displayName' for each module. + * @param {props} + * @returns Promise + */ +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); +}; + + +/** + * Entitlement Loader + * fetches/preloads all remote modules on mount. + * Passes the dynamically loaded modules into the modules context. + * @param {*} children + */ +const EntitlementLoader = ({ children }) => { + const stripes = useStripes(); + const configModules = useModules(); + const [remoteModules, setRemoteModules] = useState(modulesInitialState); + + // 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?.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.discoveryUrl, signal); + } catch (e) { + handleRemoteModuleError(stripes, `Error fetching entitlement registry from ${okapi.discoveryUrl}: ${e}`); + } + + 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)... + 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}`); + } + + setRemoteModules(cachedModules); + } + }; + fetchRegistry(); + } + return () => { + controller.abort(); + }; + // no, we don't want to refetch the registry if stripes changes + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []); + + const combinedModules = useMemo(() => { + const baseModules = {}; + Object.keys(modulesInitialState).forEach(key => { baseModules[key] = [...configModules[key], ...remoteModules[key]]; }); + return baseModules; + }, [configModules, remoteModules]); + + return ( + + {children} + + ); +}; + +EntitlementLoader.propTypes = { + children: PropTypes.oneOfType([ + PropTypes.arrayOf(PropTypes.node), + PropTypes.node, + PropTypes.func, + ]) +}; + +export default EntitlementLoader; diff --git a/src/components/EntitlementLoader.test.js b/src/components/EntitlementLoader.test.js new file mode 100644 index 000000000..7e7e2934e --- /dev/null +++ b/src/components/EntitlementLoader.test.js @@ -0,0 +1,423 @@ +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; + } +} + +const mockConfigModules = { + app: [ + { + name: 'config-app', + module: 'config-app', + displayName: 'Config App', + }, + ], + plugin: [], + settings: [], + handler: [], +}; + +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 = { + discovery: [ + { + name: 'app_module', + id: 'app_module-1.0.0', + location: 'http://localhost:3000/remoteEntry.js', + host: 'localhost', + port: 3000, + module: 'app-module', + displayName: 'appModule.label', + actsAs: ['app'], + icons: [{ name: 'icon', title: 'icon title' }], + }, + { + 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 = mockRegistry.discovery; + + const translations = { + 'testModule.label': 'Test Module Display', + 'appModule.label': 'App Module Display', + 'pluginModule.label': 'Plugin Module Display', + }; + + 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
+ + {children} + ); + }; + + const TestHarness = ({ children, testStripes = mockStripes, testModulesContext = mockModuleInitialState }) => ( + + + + + + {children} + + + + + + ); + + beforeEach(() => { + global.fetch = jest.fn(); + loadEntitlement.mockResolvedValueOnce(mockRemotes); + loadRemoteComponent.mockResolvedValue({ default: {} }); + }); + + afterEach(() => { + jest.clearAllMocks(); + }); + + afterAll(() => { + jest.restoreAllMocks(); + }); + + describe('when discoveryUrl is configured', () => { + let capturedModules = null; + const TestContextComponent = () => { + capturedModules = useModules(); + return null; + }; + + beforeEach(() => { + capturedModules = null; + okapi.discoveryUrl = '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 () => { + const discoveryUrl = 'http://localhost:8000/entitlement'; + render(); + + await waitFor(() => { + expect(loadEntitlement).toHaveBeenCalledWith(discoveryUrl, new AbortController().signal); + }); + }); + + 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(); + }, { timeout: 1000 }); + }); + + 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 discoveryUrl is not configured', () => { + let capturedModules = null; + const ContextTestComponent = () => { + capturedModules = React.useContext(ModulesContext); + return null; + }; + + beforeEach(() => { + capturedModules = null; + okapi.discoveryUrl = undefined; + }); + + it('does not fetch the registry', async () => { + render( + +
Content
+
+ ); + + await waitFor(() => { + expect(loadEntitlement).not.toHaveBeenCalled(); + }); + }); + + it('passes through configModules to ModulesContext when no discoveryUrl', async () => { + render( + + + + ); + + await waitFor(() => { + expect(capturedModules).toEqual(mockConfigModules); + }); + }); + }); + + describe('children rendering', () => { + it('renders children when modules are available', async () => { + okapi.discoveryUrl = 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', + assetPath: 'localhost:3000/path', + location: 'localhost:3000' + }; + + 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/path/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, + }); + + 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; failed to find localhost:3000/path/translations/en_US.json'); + } + }); + + 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') + ); + }); + + it('loadEntitlement calls fetch', async () => { + const actualLoadEntitlement = jest.requireActual('./loadEntitlement').loadEntitlement; + global.fetch.mockResolvedValueOnce({ + ok: true, + json: jest.fn().mockResolvedValueOnce(mockRegistry), + }); + const remotes = await actualLoadEntitlement('okapi:3000'); + expect(fetch).toHaveBeenCalledWith('okapi:3000', { signal: undefined }); + expect(remotes).toEqual(mockRemotes); + }); + }); +}); diff --git a/src/components/Root/Root.js b/src/components/Root/Root.js index 243430dd3..f88e884d3 100644 --- a/src/components/Root/Root.js +++ b/src/components/Root/Root.js @@ -9,14 +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 { metadata, icons as configIcons } 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 { 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'; @@ -133,7 +133,32 @@ 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, + 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 discoveryUrl, pass it to stripes... + + const stripesOkapi = stripesHub?.discoveryUrl ? { ...okapi, discoveryUrl: stripesHub.discoveryUrl } : 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)); }, @@ -170,9 +199,11 @@ 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)); }, + setTranslations: (nextTranslations) => { store.dispatch(setTranslations(nextTranslations)); }, setCurrency: (currencyValue) => { store.dispatch(setCurrency(currencyValue)); }, updateUser: (userValue) => { store.dispatch(updateCurrentUser(userValue)); }, plugins: plugins || {}, @@ -185,6 +216,7 @@ class Root extends Component { perms: currentPerms, }, connect(X) { return X; }, + stripesHub, }); return ( @@ -265,6 +297,7 @@ Root.propTypes = { push: PropTypes.func.isRequired, replace: PropTypes.func.isRequired, }), + icons: PropTypes.object, okapiReady: PropTypes.bool, serverDown: PropTypes.bool, }; @@ -275,6 +308,7 @@ Root.defaultProps = { currency: 'USD', okapiReady: false, serverDown: false, + icons: {}, }; function mapStateToProps(state) { @@ -284,6 +318,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, diff --git a/src/components/Settings/Settings.js b/src/components/Settings/Settings.js index fb3e0fd74..db42bce3a 100644 --- a/src/components/Settings/Settings.js +++ b/src/components/Settings/Settings.js @@ -61,11 +61,9 @@ const Settings = ({ stripes }) => { .map((m) => { try { const connect = connectFor(m.module, stripes.epics, stripes.logger); - const module = m.getModule(); - return { module: m, - Component: connect(module), + Component: connect(m.getModule()), moduleStripes: stripes.clone({ connect }), }; } catch (error) { diff --git a/src/components/loadEntitlement.js b/src/components/loadEntitlement.js new file mode 100644 index 000000000..33ef9a139 --- /dev/null +++ b/src/components/loadEntitlement.js @@ -0,0 +1,46 @@ +import localforage from 'localforage'; +import { stripesHubAPI } from '../constants'; + +export const loadEntitlement = async (discoveryUrl, signal) => { + let registry = {}; + const discovery = await localforage.getItem(stripesHubAPI.REMOTE_LIST_KEY); + if (discovery) { + registry = { discovery }; + } else if (discoveryUrl) { + try { + 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 (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('Discovery fetch error:', e); // eslint-disable-line no-console + } + } + } + + // 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}`; + } + const url = new URL(remote.location); + remote.host = url.hostname; + remote.port = url.port; + remote.origin = url.origin; + const segments = url.href.split('/'); + segments.pop(); + const hrefWithoutFilename = segments.join('/') + remote.assetPath = hrefWithoutFilename; + }); + return Promise.resolve(registry?.discovery); +}; 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..b9ea146d4 --- /dev/null +++ b/src/constants/stripesHubAPI.js @@ -0,0 +1,9 @@ +// 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', + DISCOVERY_URL_KEY: 'discoveryUrl', + HOST_APP_NAME: 'folio_stripes', +}; diff --git a/src/loadRemoteComponent.js b/src/loadRemoteComponent.js new file mode 100644 index 000000000..6c9c7a053 --- /dev/null +++ b/src/loadRemoteComponent.js @@ -0,0 +1,39 @@ +// 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. + +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 container.init(__webpack_share_scopes__.default); + + 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(error); + throw error; + } +} 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/loginServices.js b/src/loginServices.js index 72ce4f463..120166589 100644 --- a/src/loginServices.js +++ b/src/loginServices.js @@ -303,8 +303,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 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 the current location. + let translationOrigin = await localforage.getItem('hostLocation'); + if (!translationOrigin) { + translationOrigin = window.location.origin; + } + + const translationUrl = new URL(translationName, translationOrigin); + const res = await fetch(translationUrl.href) .then((response) => { if (response.ok) { response.json().then((stripesTranslations) => { diff --git a/src/loginServices.test.js b/src/loginServices.test.js index 8853aeee3..b3cf30207 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,14 @@ 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 +90,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() @@ -159,65 +165,54 @@ describe('handleLoginError', () => { }); describe('loadTranslations', () => { - it('dispatches setLocale', async () => { - const store = { + let store; + beforeEach(() => { + store = { dispatch: jest.fn(), }; + mockFetchSuccess({}); + }); + + it('dispatches setLocale', async () => { const locale = 'cs-CZ'; - mockFetchSuccess({}); await loadTranslations(store, locale, {}); expect(store.dispatch).toHaveBeenCalledWith(setLocale(locale)); - mockFetchCleanUp(); }); describe('sets document attributes correctly', () => { it('sets lang given region', async () => { - const store = { - dispatch: jest.fn(), - }; const locale = 'cs-CZ'; - - mockFetchSuccess({}); await loadTranslations(store, locale, {}); expect(document.documentElement.lang).toMatch('cs'); - mockFetchCleanUp(); }); it('sets lang without region', async () => { - const store = { - dispatch: jest.fn(), - }; const locale = 'cs'; - - mockFetchSuccess({}); await loadTranslations(store, locale, {}); expect(document.documentElement.lang).toMatch('cs'); - mockFetchCleanUp(); }); it('sets dir (LTR)', async () => { - const store = { - dispatch: jest.fn(), - }; const locale = 'fr'; - - mockFetchSuccess({}); await loadTranslations(store, locale, {}); expect(document.dir).toMatch('ltr'); - mockFetchCleanUp(); }); it('sets dir (RTL)', async () => { - const store = { - dispatch: jest.fn(), - }; const locale = 'ar'; - - mockFetchSuccess({}); await loadTranslations(store, locale, {}); expect(document.dir).toMatch('rtl'); - mockFetchCleanUp(); + }); + }); + + 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`); }); }); }); @@ -430,7 +425,19 @@ describe('updateUser', () => { const store = { dispatch: jest.fn(), }; + + const session = { + user: { + id: 'id', + username: 'username', + storageOnlyValue: 'is still persisted', + }, + perms: { foo: true }, + tenant: 'testTenant', + token: 'token', + }; const data = { thunder: 'chicken' }; + localforage.getItem.mockResolvedValueOnce(session); await updateUser(store, data); expect(store.dispatch).toHaveBeenCalledWith(updateCurrentUser(data)); }); @@ -468,6 +475,10 @@ describe('updateTenant', () => { }); describe('localforage wrappers', () => { + afterEach(() => { + jest.resetAllMocks(); + }); + describe('getOkapiSession', () => { it('retrieves a session object', async () => { const o = { @@ -475,9 +486,10 @@ describe('localforage wrappers', () => { margot: 'margot with a t looks better', also: 'i thought we were talking about margot robbie?', tokenExpiration: 'time out of mind', + test: 'okapiSess', }; - localforage.getItem = jest.fn(() => Promise.resolve(o)); + localforage.getItem.mockResolvedValue(o); const s = await getOkapiSession(); expect(s).toMatchObject(o); @@ -1362,4 +1374,3 @@ describe('getLoginTenant', () => { describe('ECS', () => { }); }); - diff --git a/src/okapiActions.js b/src/okapiActions.js index 6debc86ca..a479cb4bd 100644 --- a/src/okapiActions.js +++ b/src/okapiActions.js @@ -197,7 +197,16 @@ function toggleRtrModal(isVisible) { }; } +function addIcon(key, icon) { + return { + type: OKAPI_REDUCER_ACTIONS.ADD_ICON, + key, + icon + }; +} + export { + addIcon, checkSSO, clearCurrentUser, clearOkapiToken, diff --git a/src/okapiReducer.js b/src/okapiReducer.js index 023e215d0..ecf6a30fb 100644 --- a/src/okapiReducer.js +++ b/src/okapiReducer.js @@ -1,4 +1,5 @@ export const OKAPI_REDUCER_ACTIONS = { + ADD_ICON: 'ADD_ICON', CHECK_SSO: 'CHECK_SSO', CLEAR_CURRENT_USER: 'CLEAR_CURRENT_USER', CLEAR_OKAPI_TOKEN: 'CLEAR_OKAPI_TOKEN', @@ -88,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 { ...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: @@ -134,6 +135,34 @@ export default function okapiReducer(state = {}, action) { return { ...state, rtrModalIsVisible: action.isVisible }; } + /** + * 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; } diff --git a/src/okapiReducer.test.js b/src/okapiReducer.test.js index 424597c48..324d392ac 100644 --- a/src/okapiReducer.test.js +++ b/src/okapiReducer.test.js @@ -1,4 +1,5 @@ import { + addIcon, checkSSO, clearCurrentUser, clearOkapiToken, @@ -305,10 +306,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.translations).toMatchObject({ ...state.translations, ...translations }); }); it('CHECK_SSO', () => { @@ -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; diff --git a/test/bigtest/tests/LoadRemoteComponent-test.js b/test/bigtest/tests/LoadRemoteComponent-test.js new file mode 100644 index 000000000..396190418 --- /dev/null +++ b/test/bigtest/tests/LoadRemoteComponent-test.js @@ -0,0 +1,51 @@ +import { beforeEach, it, afterEach, describe } from 'mocha'; +import { expect } from 'chai'; +import { createServer, Response } from 'miragejs'; + +import loadRemoteComponent from '../../../src/loadRemoteComponent'; + +describe.only('loadRemoteComponent', () => { + let server; + const mockRemoteUrl = '/example/testRemote/remoteEntry.js'; + const mockErrorUrl = 'https://example.com/nonexistent/remoteEntry.js'; + + const mockRemoteName = 'testComponent'; + + beforeEach(async function () { + server = createServer({ environment: 'test' }); + 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; + return mockScriptContent; + }); + + server.get(mockErrorUrl, () => (server.serialize({ ok: false }))); + }); + + afterEach(function () { + server?.shutdown(); + server = null; + delete window[mockRemoteName]; + }); + + 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 load remote script from ${mockErrorUrl}`); + } + }); +});