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
+
+ {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(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}`);
+ }
+ });
+});