Skip to content

Commit

Permalink
Merge branch 'STCOR-936' of https://github.com/folio-org/stripes-core
Browse files Browse the repository at this point in the history
…into STCOR-936
  • Loading branch information
JohnC-80 committed Jan 28, 2025
2 parents c7bf306 + c36e6cf commit 2ad7943
Show file tree
Hide file tree
Showing 15 changed files with 529 additions and 34 deletions.
2 changes: 2 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,8 @@
* Don't override initial discovery and okapi data in test mocks. Refs STCOR-913.
* `<Logout>` must consume `QueryClient` in order to supply it to `loginServices::logout()`. Refs STCOR-907.
* On resuming session, spread session and `_self` together to preserve session values. Refs STCOR-912.
* Fetch `/saml/check` when starting a new session, i.e. before discovery. Refs STCOR-933, STCOR-816.
* Provide `useModuleInfo(path)` hook that maps paths to their implementing modules. Refs STCOR-932.

## [10.2.0](https://github.com/folio-org/stripes-core/tree/v10.2.0) (2024-10-11)
[Full Changelog](https://github.com/folio-org/stripes-core/compare/v10.1.1...v10.2.0)
Expand Down
1 change: 1 addition & 0 deletions index.js
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,7 @@ export { getUserTenantsPermissions } from './src/queries';

/* Hooks */
export { useUserTenantPermissions } from './src/hooks';
export { useModuleInfo } from './src/hooks';

/* misc */
export { supportedLocales } from './src/loginServices';
Expand Down
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -99,7 +99,7 @@
"ms": "^2.1.3",
"prop-types": "^15.5.10",
"query-string": "^7.1.2",
"react-cookie": "^4.0.3",
"react-cookie": "^7.2.2",
"react-final-form": "^6.3.0",
"react-query": "^3.6.0",
"react-titled": "^2.0.0",
Expand Down
63 changes: 55 additions & 8 deletions src/App.js
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import PropTypes from 'prop-types';
import { okapi as okapiConfig, config } from 'stripes-config';
import merge from 'lodash/merge';

import AppConfigError from './components/AppConfigError';
import connectErrorEpic from './connectErrorEpic';
import configureEpics from './configureEpics';
import configureLogger from './configureLogger';
Expand All @@ -23,6 +24,39 @@ const StrictWrapper = ({ children }) => {
return <StrictMode>{children}</StrictMode>;
};

/**
* isStorageEnabled
* Return true if local-storage, session-storage, and cookies are all enabled.
* Return false otherwise.
* @returns boolean true if storages are enabled; false otherwise.
*/
export const isStorageEnabled = () => {
let isEnabled = true;
// local storage
try {
localStorage.getItem('test-key');
} catch (e) {
console.warn('local storage is disabled'); // eslint-disable-line no-console
isEnabled = false;
}

// session storage
try {
sessionStorage.getItem('test-key');
} catch (e) {
console.warn('session storage is disabled'); // eslint-disable-line no-console
isEnabled = false;
}

// cookies
if (!navigator.cookieEnabled) {
console.warn('cookies are disabled'); // eslint-disable-line no-console
isEnabled = false;
}

return isEnabled;
};

StrictWrapper.propTypes = {
children: PropTypes.node.isRequired,
};
Expand All @@ -36,24 +70,37 @@ export default class StripesCore extends Component {
constructor(props) {
super(props);

const parsedTenant = getStoredTenant();
if (isStorageEnabled()) {
const parsedTenant = getStoredTenant();

const okapi = (typeof okapiConfig === 'object' && Object.keys(okapiConfig).length > 0)
? { ...okapiConfig, tenant: parsedTenant?.tenantName || okapiConfig.tenant, clientId: parsedTenant?.clientId || okapiConfig.clientId } : { withoutOkapi: true };
const okapi = (typeof okapiConfig === 'object' && Object.keys(okapiConfig).length > 0)
? { ...okapiConfig, tenant: parsedTenant?.tenantName || okapiConfig.tenant, clientId: parsedTenant?.clientId || okapiConfig.clientId } : { withoutOkapi: true };

const initialState = merge({}, { okapi }, props.initialState);
const initialState = merge({}, { okapi }, props.initialState);

this.logger = configureLogger(config);
this.epics = configureEpics(connectErrorEpic);
this.store = configureStore(initialState, this.logger, this.epics);
this.actionNames = gatherActions();
this.logger = configureLogger(config);
this.epics = configureEpics(connectErrorEpic);
this.store = configureStore(initialState, this.logger, this.epics);
this.actionNames = gatherActions();

this.state = { isStorageEnabled: true };
} else {
this.state = { isStorageEnabled: false };
}
}

componentWillUnmount() {
this.store.dispatch(destroyStore());
}

render() {
// Stripes requires cookies (for login) and session and local storage
// (for session state and all manner of things). If these are not enabled,
// stop and show an error message.
if (!this.state.isStorageEnabled) {
return <AppConfigError />;
}

// no need to pass along `initialState`
// eslint-disable-next-line no-unused-vars
const { initialState, ...props } = this.props;
Expand Down
36 changes: 36 additions & 0 deletions src/App.test.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
import { isStorageEnabled } from './App';

const storageMock = () => ({
getItem: () => {
throw new Error();
},
});

describe('isStorageEnabled', () => {
afterEach(() => {
jest.restoreAllMocks();
});

it('returns true when all storage options are enabled', () => {
expect(isStorageEnabled()).toBeTrue;
});

describe('returns false when any storage option is disabled', () => {
it('handles local storage', () => {
Object.defineProperty(window, 'localStorage', { value: storageMock });
const isEnabled = isStorageEnabled();
expect(isEnabled).toBeFalse;
});
it('handles session storage', () => {
Object.defineProperty(window, 'sessionStorage', { value: storageMock });
const isEnabled = isStorageEnabled();
expect(isEnabled).toBeFalse;
});

it('handles cookies', () => {
jest.spyOn(navigator, 'cookieEnabled', 'get').mockReturnValue(false);
const isEnabled = isStorageEnabled();
expect(isEnabled).toBeFalse;
});
});
});
32 changes: 32 additions & 0 deletions src/components/AppConfigError/AppConfigError.css
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
@import "@folio/stripes-components/lib/variables.css";

.wrapper {
display: flex;
justify-content: center;
min-height: 100vh;
}

.container {
width: 100%;
max-width: 940px;
min-height: 330px;
margin: 12vh 2rem 0;
}

@media (--medium-up) {
.container {
min-height: initial;
}
}

@media (--large-up) {
.header {
font-size: var(--font-size-xx-large);
}
}

@media (height <= 440px) {
.container {
min-height: 330px;
}
}
47 changes: 47 additions & 0 deletions src/components/AppConfigError/AppConfigError.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
import { branding } from 'stripes-config';

import {
Row,
Col,
Headline,
} from '@folio/stripes-components';

import OrganizationLogo from '../OrganizationLogo';
import styles from './AppConfigError.css';

/**
* AppConfigError
* Show an error message. This component is rendered by App, before anything
* else, when it detects that local storage, session storage, or cookies are
* unavailable. This happens _before_ Root has been initialized, i.e. before
* an IntlProvider is available, hence the hard-coded, English-only message.
*
* @returns English-only error message
*/
const AppConfigError = () => {
return (
<main>
<div className={styles.wrapper} style={branding?.style?.login ?? {}}>
<div className={styles.container}>
<Row center="xs">
<Col xs={6}>
<OrganizationLogo />
</Col>
</Row>
<Row center="xs">
<Col xs={6}>
<Headline
size="xx-large"
tag="h1"
>
FOLIO requires cookies, sessionStorage, and localStorage. Please enable these features and try again.
</Headline>
</Col>
</Row>
</div>
</div>
</main>
);
};

export default AppConfigError;
12 changes: 12 additions & 0 deletions src/components/AppConfigError/AppConfigError.test.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
import { render, screen } from '@folio/jest-config-stripes/testing-library/react';
import AppConfigError from './AppConfigError';

jest.mock('../OrganizationLogo', () => () => 'OrganizationLogo');
describe('AppConfigError', () => {
it('displays a warning message', async () => {
render(<AppConfigError />);

expect(screen.getByText(/cookies/i)).toBeInTheDocument();
expect(screen.getByText(/storage/i)).toBeInTheDocument();
});
});
1 change: 1 addition & 0 deletions src/components/AppConfigError/index.js
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
export { default } from './AppConfigError';
10 changes: 9 additions & 1 deletion src/components/OIDCRedirect.js
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,15 @@ import { getUnauthorizedPathFromSession, removeUnauthorizedPathFromSession } fro

// Setting at top of component since value should be retained during re-renders
// but will be correctly re-fetched when redirected from Keycloak login page.
const unauthorizedPath = getUnauthorizedPathFromSession();
// The empty try/catch is necessary because, by setting this at the top of
// the component, it is automatically executed even before <App /> renders.
// IOW, even though we check for session-storage in App, we still have to
// protect the call here.
let unauthorizedPath = null;
try {
unauthorizedPath = getUnauthorizedPathFromSession();
} catch (e) { // eslint-disable-line no-empty
}

/**
* OIDCRedirect authenticated route handler for /oidc-landing.
Expand Down
3 changes: 2 additions & 1 deletion src/hooks/index.js
Original file line number Diff line number Diff line change
@@ -1,2 +1,3 @@
export { default as useUserTenantPermissions } from './useUserTenantPermissions'; // eslint-disable-line import/prefer-default-export
export { default as useUserTenantPermissions } from './useUserTenantPermissions';
export { default as useModuleInfo } from './useModuleInfo';
export { default as usePreferences } from './usePreferences';
110 changes: 110 additions & 0 deletions src/hooks/useModuleInfo.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,110 @@
import { useQuery } from 'react-query';

import { useStripes } from '../StripesContext';
import { useNamespace } from '../components';
import useOkapiKy from '../useOkapiKy';

/**
* map a module implementation string to a module-name, hopefully.
* given a string like "mod-users-16.2.0-SNAPSHOT.127" return "mod-users"
*/
const implToModule = (impl) => {
const moduleName = impl.match(/^(.*)-[0-9]+\.[0-9]+\.[0-9]+.*/);
return moduleName[1] ? moduleName[1] : '';
};

/**
* mapPathToImpl
* Remap the input datastructure from an array of modules (containing
* details about the interfaces they implement, and the paths handled by
* each interface) to a map from path to module.
*
* i.e. map from sth like
* [{
* provides: {
* handlers: [
* { pathPattern: "/foo", ... }
* { pathPattern: "/bar", ... }
* ]
* }
* }, ... ]
* to
* {
* foo: { ...impl },
* bar: { ...impl },
* }
* @param {object} impl
* @returns object
*/
const mapPathToImpl = (impl) => {
const moduleName = implToModule(impl.id);
const paths = {};
if (impl.provides) {
// not all interfaces actually implement routes, e.g. edge-connexion
// so those must be filtered out
impl.provides.filter(i => i.handlers).forEach(i => {
i.handlers.forEach(handler => {
if (!paths[handler.pathPattern]) {
paths[handler.pathPattern] = { name: moduleName, impl };
}
});
});
}
return paths;
};

/**
* canonicalPath
* Prepend a leading / if none is present.
* Strip everything after ?
* @param {string} str a string that represents a portion of a URL
* @returns {string}
*/
const canonicalPath = (str) => {
return `${str.startsWith('/') ? '' : '/'}${str.split('?')[0]}`;
};

/**
* useModuleInfo
* Given a path, retrieve information about the module that implements it
* by querying the discovery endpoint /_/proxy/tenants/${tenant}/modules.
*
* @param {string} path
* @returns object shaped like { isFetching, isFetched, isLoading, module }
*/
const useModuleInfo = (path) => {
const stripes = useStripes();
const ky = useOkapiKy();
const [namespace] = useNamespace({ key: `/_/proxy/tenants/${stripes.okapi.tenant}/modules` });
let paths = {};

const {
isFetching,
isFetched,
isLoading,
data,
} = useQuery(
[namespace],
({ signal }) => {
return ky.get(
`/_/proxy/tenants/${stripes.okapi.tenant}/modules?full=true`,
{ signal },
).json();
}
);

if (data) {
data.forEach(impl => {
paths = { ...paths, ...mapPathToImpl(impl) };
});
}

return ({
isFetching,
isFetched,
isLoading,
module: paths?.[canonicalPath(path)],
});
};

export default useModuleInfo;
Loading

0 comments on commit 2ad7943

Please sign in to comment.