{incompatibleModuleMsg}
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..665b05d6e
--- /dev/null
+++ b/src/components/RegistryLoader.js
@@ -0,0 +1,208 @@
+import React, { useEffect, useState } from 'react';
+import PropTypes from 'prop-types';
+import { okapi } from 'stripes-config';
+
+import { ModulesContext } from '../ModulesContext';
+import loadRemoteComponent from '../loadRemoteComponent';
+
+/**
+ * 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: [] };
+
+ remotes.forEach(remote => {
+ const { actsAs, ...rest } = remote;
+ actsAs.forEach(type => modules[type].push(rest));
+ });
+
+ 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 = (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
+ // 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];
+ });
+
+ 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']}`)
+ stripes.setLocale(stripes.locale, tx);
+ return 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 => {
+ stripes.logger.log('core', ` > ${i.name}`);
+
+ const icon = {
+ [i.name]: {
+ src: `${module.host}:${module.port}/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
+ */
+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 [modules, setModules] = useState();
+
+ // read the list of registered apps from the registry,
+ useEffect(() => {
+ const fetchRegistry = async () => {
+ // 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({ 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);
+ module.getModule = () => component?.default;
+ }));
+ }
+
+ 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,
+ ]),
+ stripes: PropTypes.object.isRequired,
+};
+
+
+export default RegistryLoader;
diff --git a/src/components/Root/Root.js b/src/components/Root/Root.js
index caf9680dd..2d032d9fc 100644
--- a/src/components/Root/Root.js
+++ b/src/components/Root/Root.js
@@ -9,14 +9,13 @@ 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 { 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';
@@ -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);
@@ -117,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?
;
@@ -150,9 +144,9 @@ class Root extends Component {
locale,
timezone,
currency,
- metadata,
icons,
- setLocale: (localeValue) => { loadTranslations(store, localeValue, defaultTranslations); },
+ 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)); },
@@ -172,7 +166,7 @@ class Root extends Component {
-
+
{
.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/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/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);
diff --git a/src/init.js b/src/init.js
deleted file mode 100644
index ebf891cce..000000000
--- a/src/init.js
+++ /dev/null
@@ -1,12 +0,0 @@
-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();
-}
diff --git a/src/loadRemoteComponent.js b/src/loadRemoteComponent.js
new file mode 100644
index 000000000..3d13a064f
--- /dev/null
+++ b/src/loadRemoteComponent.js
@@ -0,0 +1,23 @@
+// 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 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();
+
+ 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/loginServices.js b/src/loginServices.js
index 5a38dc465..17023704a 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}`));
}
});
}
@@ -250,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;
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/translateModules.js b/src/translateModules.js
deleted file mode 100644
index e69de29bb..000000000