diff --git a/bootstrap.js b/bootstrap.js new file mode 100644 index 000000000..bdb96b828 --- /dev/null +++ b/bootstrap.js @@ -0,0 +1,10 @@ +import 'core-js/stable'; +import 'regenerator-runtime/runtime'; +import React from 'react'; +import { createRoot } from 'react-dom/client'; + +import App from './src/App'; + +const container = document.getElementById('root'); +const root = createRoot(container); +root.render(); diff --git a/index.js b/index.js index 3a9510d40..eb5c7e073 100644 --- a/index.js +++ b/index.js @@ -49,7 +49,6 @@ export { supportedNumberingSystems } from './src/loginServices'; export { userLocaleConfig } from './src/loginServices'; export * from './src/consortiaServices'; export { default as queryLimit } from './src/queryLimit'; -export { default as init } from './src/init'; /* localforage wrappers hide the session key */ export { getOkapiSession, getTokenExpiry, setTokenExpiry } from './src/loginServices'; diff --git a/package.json b/package.json index 05dc18b3a..479e15f84 100644 --- a/package.json +++ b/package.json @@ -73,6 +73,7 @@ }, "dependencies": { "@apollo/client": "^3.2.1", + "@folio/stripes-shared-context": "^1.0.0", "classnames": "^2.2.5", "core-js": "^3.26.1", "final-form": "^4.18.2", diff --git a/src/AppRoutes.js b/src/AppRoutes.js index d2cf919b4..f01cc26a4 100644 --- a/src/AppRoutes.js +++ b/src/AppRoutes.js @@ -1,8 +1,9 @@ -import React, { useMemo } from 'react'; +import React, { useMemo, Suspense } from 'react'; import { Route } from 'react-router-dom'; import PropTypes from 'prop-types'; import { connectFor } from '@folio/stripes-connect'; +import { LoadingView } from '@folio/stripes-components'; import { StripesContext } from './StripesContext'; import TitleManager from './components/TitleManager'; @@ -11,6 +12,7 @@ import { getEventHandlers } from './handlerService'; import { packageName } from './constants'; import { ModuleHierarchyProvider } from './components'; import events from './events'; +import loadRemoteComponent from './loadRemoteComponent'; // Process and cache "app" type modules and render the routes const AppRoutes = ({ modules, stripes }) => { @@ -22,11 +24,13 @@ const AppRoutes = ({ modules, stripes }) => { const perm = `module.${name}.enabled`; if (!stripes.hasPerm(perm)) return null; + const RemoteComponent = React.lazy(() => loadRemoteComponent(module.url, module.name)); const connect = connectFor(module.module, stripes.epics, stripes.logger); let ModuleComponent; + try { - ModuleComponent = connect(module.getModule()); + ModuleComponent = connect(RemoteComponent); } catch (error) { console.error(error); // eslint-disable-line throw Error(error); @@ -47,37 +51,39 @@ const AppRoutes = ({ modules, stripes }) => { }, [modules.app, stripes]); return cachedModules.map(({ ModuleComponent, connect, module, name, moduleStripes, stripes: propsStripes, displayName }) => ( - { - const data = { displayName, name }; + }> + { + const data = { displayName, name }; - // allow SELECT_MODULE handlers to intervene - const handlerComponents = getEventHandlers(events.SELECT_MODULE, moduleStripes, modules.handler, data); - if (handlerComponents.length) { - return handlerComponents.map(Handler => ()); - } + // allow SELECT_MODULE handlers to intervene + const handlerComponents = getEventHandlers(events.SELECT_MODULE, moduleStripes, modules.handler, data); + if (handlerComponents.length) { + return handlerComponents.map(Handler => ()); + } - return ( - - -
- - - - - -
-
-
- ); - }} - /> + return ( + + +
+ + + + + +
+
+
+ ); + }} + /> +
)); }; diff --git a/src/CalloutContext.js b/src/CalloutContext.js index b2b0f180c..66665e70f 100644 --- a/src/CalloutContext.js +++ b/src/CalloutContext.js @@ -1,6 +1,8 @@ import React, { useContext } from 'react'; -export const CalloutContext = React.createContext(); +import { CalloutContext } from '@folio/stripes-shared-context'; + +export { CalloutContext }; export const useCallout = () => { return useContext(CalloutContext); diff --git a/src/ModuleRoutes.js b/src/ModuleRoutes.js index d1dc65ce0..08046067f 100644 --- a/src/ModuleRoutes.js +++ b/src/ModuleRoutes.js @@ -71,11 +71,7 @@ function ModuleRoutes({ stripes }) { ); } - return ( - }> - - - ); + return ; }} ); diff --git a/src/ModulesContext.js b/src/ModulesContext.js index 2ffed1213..863526877 100644 --- a/src/ModulesContext.js +++ b/src/ModulesContext.js @@ -1,7 +1,6 @@ -import React, { useContext } from 'react'; -import { modules } from 'stripes-config'; +import { useContext } from 'react'; +import { ModulesContext } from '@folio/stripes-shared-context'; -export const ModulesContext = React.createContext(modules); +export { ModulesContext }; export default ModulesContext; export const useModules = () => useContext(ModulesContext); -export { modules as originalModules }; diff --git a/src/Pluggable.js b/src/Pluggable.js index 6d1a27baa..6685366e1 100644 --- a/src/Pluggable.js +++ b/src/Pluggable.js @@ -1,10 +1,15 @@ -import React, { useMemo } from 'react'; +import React, { useMemo, Suspense } from 'react'; import PropTypes from 'prop-types'; -import { modules } from 'stripes-config'; + +import { Icon } from '@folio/stripes-components'; + +import { useModules } from './ModulesContext'; import { withStripes } from './StripesContext'; import { ModuleHierarchyProvider } from './components'; +import loadRemoteComponent from './loadRemoteComponent'; const Pluggable = (props) => { + const modules = useModules(); const plugins = modules.plugin || []; const cachedPlugins = useMemo(() => { const cached = []; @@ -22,7 +27,8 @@ const Pluggable = (props) => { } if (best) { - const Child = props.stripes.connect(best.getModule()); + const RemoteComponent = React.lazy(() => loadRemoteComponent(best.url, best.name)); + const Child = props.stripes.connect(RemoteComponent); cached.push({ Child, @@ -32,12 +38,14 @@ const Pluggable = (props) => { } return cached; - }, [plugins]); + }, [plugins, props.type]); if (cachedPlugins.length) { return cachedPlugins.map(({ plugin, Child }) => ( - + }> + + )); } diff --git a/src/RootWithIntl.js b/src/RootWithIntl.js index 1dcf29c63..118da5bd4 100644 --- a/src/RootWithIntl.js +++ b/src/RootWithIntl.js @@ -17,7 +17,6 @@ import { MainContainer, MainNav, ModuleContainer, - ModuleTranslator, TitledRoute, Front, OIDCRedirect, @@ -40,6 +39,7 @@ import StaleBundleWarning from './components/StaleBundleWarning'; import { StripesContext } from './StripesContext'; import { CalloutContext } from './CalloutContext'; import AuthnLogin from './components/AuthnLogin'; +import RegistryLoader from './components/RegistryLoader'; const RootWithIntl = ({ stripes, token = '', isAuthenticated = false, disableAuth, history = {}, queryClient }) => { const connect = connectFor('@folio/core', stripes.epics, stripes.logger); @@ -53,7 +53,7 @@ const RootWithIntl = ({ stripes, token = '', isAuthenticated = false, disableAut return ( - + - + ); diff --git a/src/StripesContext.js b/src/StripesContext.js index 835262268..16c062389 100644 --- a/src/StripesContext.js +++ b/src/StripesContext.js @@ -2,7 +2,9 @@ import React, { useContext } from 'react'; import PropTypes from 'prop-types'; import hoistNonReactStatics from 'hoist-non-react-statics'; -export const StripesContext = React.createContext(); +import { StripesContext } from '@folio/stripes-shared-context'; + +export { StripesContext }; function getDisplayName(WrappedComponent) { return WrappedComponent.displayName || WrappedComponent.name || 'Component'; diff --git a/src/components/About/WarningBanner.js b/src/components/About/WarningBanner.js index 1ff3867b2..528f806cc 100644 --- a/src/components/About/WarningBanner.js +++ b/src/components/About/WarningBanner.js @@ -48,7 +48,7 @@ const WarningBanner = ({ {missingModulesMsg} @@ -61,7 +61,7 @@ const WarningBanner = ({ {incompatibleModuleMsg} diff --git a/src/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