From e7b776439789e62960932cdd43c4d0e13fad742c Mon Sep 17 00:00:00 2001 From: Zak Burke Date: Thu, 23 Jan 2025 07:59:36 -0500 Subject: [PATCH] STSMACOM-886 provide LinkedUser component (#1555) Link to a user-details record if permissions allow, or return a plaintext name. Mocks shamelessly copied from ui-users. Refs STSMACOM-886 --- .eslintrc | 3 + .github/workflows/ui.yml | 3 +- index.js | 2 + jest.config.js | 10 + lib/LinkedUser/LinkedUser.js | 32 + lib/LinkedUser/LinkedUser.test.js | 64 + lib/LinkedUser/index.js | 1 + package.json | 7 +- tests/index.js | 2 +- tests/jest/__mock__/__resizeObserver.mock.js | 6 + tests/jest/__mock__/currencyData.mock.js | 1 + tests/jest/__mock__/index.js | 8 + tests/jest/__mock__/intl.mock.js | 61 + tests/jest/__mock__/matchMedia.mock.js | 13 + .../__mock__/reactFinalFormArrays.mock.js | 9 + .../__mock__/reactFinalFormListeners.mock.js | 9 + tests/jest/__mock__/resizeObserver.mock.js | 5 + tests/jest/__mock__/stripes.mock.js | 26 + tests/jest/__mock__/stripesComponents.mock.js | 286 +++ tests/jest/__mock__/stripesConfig.mock.js | 7 + tests/jest/__mock__/stripesCore.mock.js | 167 ++ tests/jest/__mock__/stripesIcon.mock.js | 5 + tests/jest/helpers/buildResources.js | 32 + tests/jest/helpers/renderWithRouter.js | 29 + tests/jest/helpers/translationProperties.js | 10 + tests/jest/setupFiles.js | 7 + yarn.lock | 1781 ++++++++++++++++- 27 files changed, 2558 insertions(+), 28 deletions(-) create mode 100644 jest.config.js create mode 100644 lib/LinkedUser/LinkedUser.js create mode 100644 lib/LinkedUser/LinkedUser.test.js create mode 100644 lib/LinkedUser/index.js create mode 100644 tests/jest/__mock__/__resizeObserver.mock.js create mode 100644 tests/jest/__mock__/currencyData.mock.js create mode 100644 tests/jest/__mock__/index.js create mode 100644 tests/jest/__mock__/intl.mock.js create mode 100644 tests/jest/__mock__/matchMedia.mock.js create mode 100644 tests/jest/__mock__/reactFinalFormArrays.mock.js create mode 100644 tests/jest/__mock__/reactFinalFormListeners.mock.js create mode 100644 tests/jest/__mock__/resizeObserver.mock.js create mode 100644 tests/jest/__mock__/stripes.mock.js create mode 100644 tests/jest/__mock__/stripesComponents.mock.js create mode 100644 tests/jest/__mock__/stripesConfig.mock.js create mode 100644 tests/jest/__mock__/stripesCore.mock.js create mode 100644 tests/jest/__mock__/stripesIcon.mock.js create mode 100644 tests/jest/helpers/buildResources.js create mode 100644 tests/jest/helpers/renderWithRouter.js create mode 100644 tests/jest/helpers/translationProperties.js create mode 100644 tests/jest/setupFiles.js diff --git a/.eslintrc b/.eslintrc index 195869013..3b7a427c0 100644 --- a/.eslintrc +++ b/.eslintrc @@ -1,4 +1,7 @@ { + "env": { + "jest": true + }, "extends": "@folio/eslint-config-stripes", "parser": "@babel/eslint-parser", "rules": { diff --git a/.github/workflows/ui.yml b/.github/workflows/ui.yml index e096bb4e4..efc31379f 100644 --- a/.github/workflows/ui.yml +++ b/.github/workflows/ui.yml @@ -10,7 +10,8 @@ jobs: if: github.ref_name == github.event.repository.default_branch || github.event_name != 'push' || github.ref_type == 'tag' secrets: inherit with: - jest-enabled: false + jest-enabled: true + jest-test-command: yarn run test:jest bigtest-enabled: true bigtest-test-command: xvfb-run --server-args="-screen 0 1024x768x24" yarn test $YARN_TEST_OPTIONS --karma.singleRun --karma.browsers ChromeDocker --karma.reporters mocha junit --coverage sonar-sources: ./lib diff --git a/index.js b/index.js index 9a7bcef90..c5e1f546c 100644 --- a/index.js +++ b/index.js @@ -82,5 +82,7 @@ export { default as useCustomFields } from './lib/CustomFields/utils/useCustomFi export { default as ProfilePicture } from './lib/ProfilePicture'; export { default as useProfilePicture } from './lib/ProfilePicture/utils'; +export { default as LinkedUser } from './lib/LinkedUser'; + export * from './lib/ColumnManager'; export * from './lib/utils'; diff --git a/jest.config.js b/jest.config.js new file mode 100644 index 000000000..d1d3e0057 --- /dev/null +++ b/jest.config.js @@ -0,0 +1,10 @@ +const path = require('path'); +const config = require('@folio/jest-config-stripes'); + +module.exports = { + ...config, + setupFiles: [ + ...config.setupFiles, + path.join(__dirname, './tests/jest/setupFiles.js'), + ], +}; diff --git a/lib/LinkedUser/LinkedUser.js b/lib/LinkedUser/LinkedUser.js new file mode 100644 index 000000000..bf2954646 --- /dev/null +++ b/lib/LinkedUser/LinkedUser.js @@ -0,0 +1,32 @@ +import PropTypes from 'prop-types'; + +import { useStripes } from '@folio/stripes-core'; +import { getFullName } from '@folio/stripes-util'; +import { TextLink } from '@folio/stripes-components'; + +/** + * LinkedUser + * Link to a user-details record if permissions allow, + * or return a plaintext name. + */ +const LinkedUser = ({ user, formatter = getFullName }) => { + const stripes = useStripes(); + + return stripes.hasPerm('ui-users.view') ? ( + + {formatter(user)} + + ) : ( + <>{formatter(user)} + ); +}; + +LinkedUser.propTypes = { + user: PropTypes.shape({ + id: PropTypes.string, + username: PropTypes.string, + }), + formatter: PropTypes.func, +}; + +export default LinkedUser; diff --git a/lib/LinkedUser/LinkedUser.test.js b/lib/LinkedUser/LinkedUser.test.js new file mode 100644 index 000000000..94df2a2fe --- /dev/null +++ b/lib/LinkedUser/LinkedUser.test.js @@ -0,0 +1,64 @@ +import { render, screen } from '@folio/jest-config-stripes/testing-library/react'; +import { useStripes } from '@folio/stripes-core'; + +import LinkedUser from './LinkedUser'; + +const mockHasPerm = jest.fn(); + +jest.mock('stripes-config', () => ( + { + modules: [], + metadata: {}, + } +), +{ virtual: true }); + +jest.mock('@folio/stripes-core', () => ({ + ...jest.requireActual('@folio/stripes-core'), + useStripes: () => ({ hasPerm: mockHasPerm }), +})); + +const userRecord = { + id: 'id', + username: 'username', + personal: { + firstName: 'first', + lastName: 'last', + middleName: 'middle', + } +}; + +describe('useLinkedUser', () => { + beforeEach(() => { + mockHasPerm.mockReturnValue(true); + }); + + afterAll(() => { + jest.clearAllMocks(); + }); + + it('returns a link when permission is present', () => { + mockHasPerm.mockReturnValue(true); + + const { container } = render(); + expect(container.querySelector('a')).not.toBeNull(); + screen.getByText(userRecord.personal.firstName, { exact: false }); + }); + + it('returns plain text when permission is absent', () => { + mockHasPerm.mockReturnValue(false); + + const { container } = render(); + expect(container.querySelector('a')).toBeNull(); + screen.getByText(userRecord.personal.firstName, { exact: false }); + }); + + it('uses provided formatter', () => { + mockHasPerm.mockReturnValue(true); + const formatter = (u) => u.username; + + const { container } = render(); + expect(container.querySelector('a')).not.toBeNull(); + screen.getByText(userRecord.username); + }); +}); diff --git a/lib/LinkedUser/index.js b/lib/LinkedUser/index.js new file mode 100644 index 000000000..69f7bd0b3 --- /dev/null +++ b/lib/LinkedUser/index.js @@ -0,0 +1 @@ +export { default } from './LinkedUser'; diff --git a/package.json b/package.json index 760ccfdbf..9de802273 100644 --- a/package.json +++ b/package.json @@ -31,7 +31,9 @@ "lint": "eslint . && stylelint \"lib/**/*.css\"", "eslint": "eslint .", "stylelint": "stylelint \"lib/**/*.css\"", - "test": "stripes test karma", + "test:bigtest": "stripes test karma", + "test:jest": "jest --ci --coverage --colors", + "test": "yarn test:jest && yarn test:bigtest", "test-dev": "stripes test karma --watch", "build-mod-descriptor": "stripes mod descriptor --full --strict | jq '.[]' > module-descriptor.json ", "formatjs-compile": "formatjs compile-folder --ast --format simple ./translations/stripes-smart-components ./translations/stripes-smart-components/compiled" @@ -44,6 +46,7 @@ "@bigtest/mirage": "^0.0.1", "@bigtest/mocha": "^0.5.1", "@folio/eslint-config-stripes": "^7.0.0", + "@folio/jest-config-stripes": "^2.1.0", "@folio/stripes-cli": "^3.2.1", "@folio/stripes-components": "^12.0.0", "@folio/stripes-connect": "^9.0.0", @@ -95,10 +98,10 @@ "react-final-form": "^6.3.0", "react-final-form-arrays": "^3.1.1", "react-final-form-listeners": "^1.0.3", + "react-image": "^4.1.0", "react-query": "^3.9.8", "react-quill": "^2.0.0", "react-router-prop-types": "^1.0.4", - "react-image": "^4.1.0", "redux-form": "^8.3.0", "uuid": "^9.0.0" }, diff --git a/tests/index.js b/tests/index.js index 0aa157313..7cddf8ecc 100644 --- a/tests/index.js +++ b/tests/index.js @@ -10,5 +10,5 @@ requireTest.keys().forEach(requireTest); turnOffWarnings(); // require all source files in lib for code coverage -const componentsContext = require.context('../lib/', true, /^(?!.*(stories|examples)).*\.js$/); +const componentsContext = require.context('../lib/', true, /^(?!.*(stories|examples|\.test\.)).*\.js$/); componentsContext.keys().forEach(componentsContext); diff --git a/tests/jest/__mock__/__resizeObserver.mock.js b/tests/jest/__mock__/__resizeObserver.mock.js new file mode 100644 index 000000000..6ccb28cf6 --- /dev/null +++ b/tests/jest/__mock__/__resizeObserver.mock.js @@ -0,0 +1,6 @@ +window.ResizeObserver = jest.fn().mockImplementation(() => ({ + disconnect: jest.fn(), + observe: jest.fn(), + unobserve: jest.fn(), +})); + diff --git a/tests/jest/__mock__/currencyData.mock.js b/tests/jest/__mock__/currencyData.mock.js new file mode 100644 index 000000000..f791ffbfc --- /dev/null +++ b/tests/jest/__mock__/currencyData.mock.js @@ -0,0 +1 @@ +jest.mock('currency-codes/data', () => ({ filter: () => [] })); diff --git a/tests/jest/__mock__/index.js b/tests/jest/__mock__/index.js new file mode 100644 index 000000000..40ed8897d --- /dev/null +++ b/tests/jest/__mock__/index.js @@ -0,0 +1,8 @@ +import './stripesConfig.mock'; +import './stripesCore.mock'; +import './stripes.mock'; +import './intl.mock'; +import './stripesIcon.mock'; +import './stripesComponents.mock'; +import './currencyData.mock'; +import './resizeObserver.mock'; diff --git a/tests/jest/__mock__/intl.mock.js b/tests/jest/__mock__/intl.mock.js new file mode 100644 index 000000000..12f3febf1 --- /dev/null +++ b/tests/jest/__mock__/intl.mock.js @@ -0,0 +1,61 @@ +import React from 'react'; + +jest.mock('react-intl', () => { + const intl = { + formatMessage: ({ id }) => id, + formatNumber: (value) => value, + formatTime: (value) => value, + formatDisplayName: (value) => value, + formatDate: (value) => value, + formatDateToParts: jest.fn(() => ([ + { + 'type': 'month', + 'value': '7' + }, + { + 'type': 'literal', + 'value': '/' + }, + { + 'type': 'day', + 'value': '31' + }, + { + 'type': 'literal', + 'value': '/' + }, + { + 'type': 'year', + 'value': '2024' + } + ] + )), + }; + + return { + ...jest.requireActual('react-intl'), + FormattedMessage: jest.fn(({ id, children }) => { + if (children) { + return children([id]); + } + + return id; + }), + FormattedTime: jest.fn(({ value, children }) => { + if (children) { + return children([value]); + } + + return value; + }), + FormattedDate: jest.fn(({ value, children }) => { + if (children) { + return children([value]); + } + + return value; + }), + useIntl: () => intl, + injectIntl: (Component) => (props) => , + }; +}); diff --git a/tests/jest/__mock__/matchMedia.mock.js b/tests/jest/__mock__/matchMedia.mock.js new file mode 100644 index 000000000..adce8e8bc --- /dev/null +++ b/tests/jest/__mock__/matchMedia.mock.js @@ -0,0 +1,13 @@ +Object.defineProperty(window, 'matchMedia', { + writable: true, + value: jest.fn().mockImplementation(query => ({ + matches: false, + media: query, + onchange: null, + addListener: jest.fn(), // Deprecated + removeListener: jest.fn(), // Deprecated + addEventListener: jest.fn(), + removeEventListener: jest.fn(), + dispatchEvent: jest.fn(), + })) +}); diff --git a/tests/jest/__mock__/reactFinalFormArrays.mock.js b/tests/jest/__mock__/reactFinalFormArrays.mock.js new file mode 100644 index 000000000..3df493e3a --- /dev/null +++ b/tests/jest/__mock__/reactFinalFormArrays.mock.js @@ -0,0 +1,9 @@ +import React from 'react'; + +jest.mock('react-final-form-arrays', () => { + return { + ...jest.requireActual('react-final-form-arrays'), + + FieldArray: () =>
FieldArray
, + }; +}); diff --git a/tests/jest/__mock__/reactFinalFormListeners.mock.js b/tests/jest/__mock__/reactFinalFormListeners.mock.js new file mode 100644 index 000000000..e54bf1695 --- /dev/null +++ b/tests/jest/__mock__/reactFinalFormListeners.mock.js @@ -0,0 +1,9 @@ +import React from 'react'; + +jest.mock('react-final-form-listeners', () => { + return { + ...jest.requireActual('react-final-form-listeners'), + + OnChange: jest.fn(({ children }) => <>{children()}), + }; +}); diff --git a/tests/jest/__mock__/resizeObserver.mock.js b/tests/jest/__mock__/resizeObserver.mock.js new file mode 100644 index 000000000..da66fa4f9 --- /dev/null +++ b/tests/jest/__mock__/resizeObserver.mock.js @@ -0,0 +1,5 @@ +global.ResizeObserver = jest.fn().mockImplementation(() => ({ + observe: jest.fn(), + unobserve: jest.fn(), + disconnect: jest.fn(), +})); diff --git a/tests/jest/__mock__/stripes.mock.js b/tests/jest/__mock__/stripes.mock.js new file mode 100644 index 000000000..5163003cb --- /dev/null +++ b/tests/jest/__mock__/stripes.mock.js @@ -0,0 +1,26 @@ +import { noop } from 'lodash'; + +const buildStripes = (otherProperties = {}) => ({ + hasPerm: noop, + hasInterface: noop, + clone: noop, + logger: { log: noop }, + config: {}, + okapi: { + url: '', + tenant: '', + }, + locale: 'en-US', + withOkapi: true, + setToken: noop, + actionNames: [], + setLocale: noop, + setTimezone: noop, + setCurrency: noop, + setSinglePlugin: noop, + setBindings: noop, + connect: noop, + ...otherProperties, +}); + +export default buildStripes; diff --git a/tests/jest/__mock__/stripesComponents.mock.js b/tests/jest/__mock__/stripesComponents.mock.js new file mode 100644 index 000000000..bd93e760a --- /dev/null +++ b/tests/jest/__mock__/stripesComponents.mock.js @@ -0,0 +1,286 @@ +import React from 'react'; + +jest.mock('@folio/stripes-components', () => ({ + Accordion: jest.fn(({ children, ...rest }) => ( + {children} + )), + AccordionSet: jest.fn(({ children, ...rest }) => ( + {children} + )), + AccordionStatus: jest.fn(({ children, ...rest }) => ( + {children} + )), + Badge: jest.fn((props) => ( + + {props.children} + + )), + Button: jest.fn(({ + children, + onClick = jest.fn(), + // eslint-disable-next-line no-unused-vars + buttonStyle, buttonRef, + ...rest + }) => ( + + )), + Callout: jest.fn(({ children, ...rest }) => ( + {children} + )), + Col: jest.fn(({ children }) =>
{ children }
), + ConfirmationModal: jest.fn(({ heading, message, onConfirm, onCancel }) => ( +
+ ConfirmationModal + {heading} +
{message}
+
+ + +
+
+ )), + Dropdown: jest.fn(({ children, ...rest }) =>
{ children }
), + DropdownMenu: jest.fn(({ children, ...rest }) =>
{ children }
), + Datepicker: jest.fn(({ ref, children, ...rest }) => ( +
+ {children} + +
+ )), + ExpandAllButton: jest.fn(({ children }) => ( + {children} + )), + FormattedTime: jest.fn(({ value, children }) => { + if (children) { + return children([value]); + } + + return value; + }), + FormattedDate: jest.fn(({ value, children }) => { + if (children) { + return children([value]); + } + + return value; + }), + HasCommand: jest.fn(({ children }) => ( + {children} + )), + Headline: jest.fn(({ children }) =>
{ children }
), + Icon: jest.fn((props) => (props && props.children ? props.children : )), + IconButton: jest.fn(({ + buttonProps, + // eslint-disable-next-line no-unused-vars + iconClassName, + ...rest + }) => ( + + )), + InfoPopover: jest.fn(({ children, ...rest }) => ( + {children} + )), + KeyValue: jest.fn(({ label, children, value }) => ( + <> + {label} + {value || children} + + )), + Label: jest.fn(({ children, ...rest }) => ( + {children} + )), + List: jest.fn(({ children, items, itemFormatter, ...rest }) => ( + <> +
List Component
+ + {children} + { items.length > 0 ? items.map((item, index) =>
{item.formattedMessageId || item.id}
) : ''} + + )), + Loading: () =>
Loading
, + LoadingPane: () =>
LoadingPane
, + // oy, dismissible. we need to pull it out of props so it doesn't + // get applied to the div as an attribute, which must have a string-value, + // which will shame you in the console: + // + // Warning: Received `true` for a non-boolean attribute `dismissible`. + // If you want to write it to the DOM, pass a string instead: dismissible="true" or dismissible={value.toString()}. + // in div (created by mockConstructor) + // + // is there a better way to throw it away? If we don't destructure and + // instead access props.label and props.children, then we get a test + // failure that the modal isn't visible. oy, dismissible. + Modal: jest.fn(({ children, label, dismissible, footer, ...rest }) => { + return ( +
+

{label}

+ {children} + {footer} +
+ ); + }), + ModalFooter: jest.fn((props) => ( +
{props.children}
+ )), + MultiColumnList: jest.fn((props) => ( +
+ )), + MultiSelection: jest.fn(({ children, dataOptions }) => ( +
+ + {children} +
+ )), + NavList: jest.fn(({ children, className, ...rest }) => ( +
{children}
+ )), + NavListItem: jest.fn(({ children, className, ...rest }) => ( +
{children}
+ )), + NavListSection: jest.fn(({ children, className, ...rest }) => ( +
{children}
+ )), + NoValue: jest.fn(({ ariaLabel }) => ({ariaLabel})), + // destructure appIcon and dismissible so they aren't incorrectly + // applied as DOM attributes via ...rest. + // eslint-disable-next-line no-unused-vars + Pane: jest.fn(({ children, className, defaultWidth, paneTitle, firstMenu, lastMenu, actionMenu, appIcon, dismissible, onClose, ...rest }) => { + return ( +
+
+ {dismissible && ( +
+ {children} +
+ ); + }), + PaneBackLink: jest.fn(() => ), + PaneFooter: jest.fn(({ ref, children, ...rest }) => ( +
{children}
+ )), + PaneHeader: jest.fn(({ paneTitle, firstMenu, lastMenu, actionMenu }) => ( +
+ {firstMenu ?? null} + {paneTitle} + {actionMenu ? actionMenu({ onToggle: jest.fn() }) : null} + {lastMenu ?? null} +
+ )), + PaneHeaderIconButton: jest.fn(({ children }) =>
{ children }
), + PaneMenu: jest.fn((props) =>
{props.children}
), + Paneset: jest.fn((props) =>
{props.children}
), + RadioButton: jest.fn(({ label, name, ...rest }) => ( +
+ + +
+ )), + RadioButtonGroup: jest.fn(({ label, children, ...rest }) => ( +
+ {label} + {children} +
+ )), + Row: jest.fn(({ children }) =>
{ children }
), + SearchField: jest.fn((props) => ( + + )), + Select: jest.fn(({ children, dataOptions }) => { + const dummyData = [{ + value: 'testValue', + id: 'testId', + label: 'TestLabel' + }]; + const options = dataOptions && dataOptions.length > 0 ? dataOptions : dummyData; + return ( +
+ + {children} +
+ ); + }), + TextArea: jest.fn((props) => ( +
+ +