From e2d87050ef0ce4fd092b5156f29867bba51f11d6 Mon Sep 17 00:00:00 2001 From: Dmytro-Melnyshyn <77053927+Dmytro-Melnyshyn@users.noreply.github.com> Date: Tue, 17 Dec 2024 13:15:13 +0200 Subject: [PATCH] UIIN-3116: Add call number browse settings. (#2694) * UIIN-3116: Add call number browse settings. * add proptypes for components * UIIN-3116 added a call bumber browse settings permission to translations --------- Co-authored-by: Denys Bohdan --- CHANGELOG.md | 1 + package.json | 12 ++ src/hooks/index.js | 1 + src/hooks/useCallNumberTypesQuery/index.js | 1 + .../useCallNumberTypesQuery.js | 22 +++ .../useCallNumberTypesQuery.test.js | 44 +++++ .../CallNumberBrowseSettings.js | 142 ++++++++++++++ .../CallNumberBrowseSettings.test.js | 179 ++++++++++++++++++ .../CallNumberTypeField.js | 40 ++++ .../CallNumberTypeList.js | 20 ++ .../CallNumberBrowseSettings/constants.js | 6 + .../CallNumberBrowseSettings/index.js | 1 + .../InventorySettings/InventorySettings.js | 18 +- translations/ui-inventory/en.json | 11 ++ 14 files changed, 493 insertions(+), 5 deletions(-) create mode 100644 src/hooks/useCallNumberTypesQuery/index.js create mode 100644 src/hooks/useCallNumberTypesQuery/useCallNumberTypesQuery.js create mode 100644 src/hooks/useCallNumberTypesQuery/useCallNumberTypesQuery.test.js create mode 100644 src/settings/CallNumberBrowseSettings/CallNumberBrowseSettings.js create mode 100644 src/settings/CallNumberBrowseSettings/CallNumberBrowseSettings.test.js create mode 100644 src/settings/CallNumberBrowseSettings/CallNumberTypeField.js create mode 100644 src/settings/CallNumberBrowseSettings/CallNumberTypeList.js create mode 100644 src/settings/CallNumberBrowseSettings/constants.js create mode 100644 src/settings/CallNumberBrowseSettings/index.js diff --git a/CHANGELOG.md b/CHANGELOG.md index a51575939..e93c35fba 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,7 @@ * React v19: refactor away from default props for functional components. Refs UIIN-2890. * User can edit Source consortium "Holdings sources" in member tenant but not in Consortia manager. Refs UIIN-3147. * React 19: refactor away from react-dom/test-utils. Refs UIIN-2888. +* Add call number browse settings. Refs UIIN-3116. ## [12.0.7] (IN PROGRESS) diff --git a/package.json b/package.json index b8894c4d7..d6c8f2008 100644 --- a/package.json +++ b/package.json @@ -265,6 +265,18 @@ ], "visible": true }, + { + "permissionName": "ui-inventory.settings.call-number-browse", + "displayName": "Settings (Inventory): Configure call number browse", + "subPermissions": [ + "settings.inventory.enabled", + "inventory-storage.call-number-types.collection.get", + "browse.config.collection.get", + "browse.config.item.put", + "perms.users.get" + ], + "visible": true + }, { "permissionName": "ui-inventory.settings.classification-browse", "displayName": "Settings (Inventory): Configure classification browse", diff --git a/src/hooks/index.js b/src/hooks/index.js index 694eb96fe..42b3de607 100644 --- a/src/hooks/index.js +++ b/src/hooks/index.js @@ -16,3 +16,4 @@ export { default as useUpdateOwnership } from './useUpdateOwnership'; export { default as useLocalStorageItems } from './useLocalStorageItems'; export * from './useQuickExport'; export * from '@folio/stripes-inventory-components/lib/queries/useInstanceDateTypes'; +export * from './useCallNumberTypesQuery'; diff --git a/src/hooks/useCallNumberTypesQuery/index.js b/src/hooks/useCallNumberTypesQuery/index.js new file mode 100644 index 000000000..8d1fa5c39 --- /dev/null +++ b/src/hooks/useCallNumberTypesQuery/index.js @@ -0,0 +1 @@ +export * from './useCallNumberTypesQuery'; diff --git a/src/hooks/useCallNumberTypesQuery/useCallNumberTypesQuery.js b/src/hooks/useCallNumberTypesQuery/useCallNumberTypesQuery.js new file mode 100644 index 000000000..7d5546213 --- /dev/null +++ b/src/hooks/useCallNumberTypesQuery/useCallNumberTypesQuery.js @@ -0,0 +1,22 @@ +import { useQuery } from 'react-query'; + +import { + useNamespace, + useOkapiKy, +} from '@folio/stripes/core'; +import { CQL_FIND_ALL } from '@folio/stripes-inventory-components'; + +export const useCallNumberTypesQuery = ({ tenantId } = {}) => { + const ky = useOkapiKy({ tenant: tenantId }); + const [namespace] = useNamespace({ key: 'call-number-types' }); + + const { data, isFetching } = useQuery( + [namespace, tenantId], + () => ky.get(`call-number-types?limit=2000&query=${CQL_FIND_ALL} sortby name`).json(), + ); + + return { + callNumberTypes: data?.callNumberTypes || [], + isCallNumberTypesLoading: isFetching, + }; +}; diff --git a/src/hooks/useCallNumberTypesQuery/useCallNumberTypesQuery.test.js b/src/hooks/useCallNumberTypesQuery/useCallNumberTypesQuery.test.js new file mode 100644 index 000000000..f25ec8bad --- /dev/null +++ b/src/hooks/useCallNumberTypesQuery/useCallNumberTypesQuery.test.js @@ -0,0 +1,44 @@ +import { + QueryClient, + QueryClientProvider, +} from 'react-query'; + +import { + renderHook, + act, +} from '@folio/jest-config-stripes/testing-library/react'; +import { useOkapiKy } from '@folio/stripes/core'; + +import { useCallNumberTypesQuery } from './useCallNumberTypesQuery'; + +const queryClient = new QueryClient(); + +const wrapper = ({ children }) => ( + + {children} + +); + +describe('useCallNumberTypesQuery', () => { + beforeEach(() => { + useOkapiKy.mockClear().mockReturnValue({ + get: () => ({ + json: () => Promise.resolve({ + callNumberTypes: [{ id: 'call-number-type-id' }], + }), + }), + }); + }); + + afterEach(() => { + jest.clearAllMocks(); + }); + + it('should fetch call number types', async () => { + const { result } = renderHook(() => useCallNumberTypesQuery(), { wrapper }); + + await act(() => !result.current.isLoading); + + expect(result.current.callNumberTypes).toEqual([{ id: 'call-number-type-id' }]); + }); +}); diff --git a/src/settings/CallNumberBrowseSettings/CallNumberBrowseSettings.js b/src/settings/CallNumberBrowseSettings/CallNumberBrowseSettings.js new file mode 100644 index 000000000..6d212da37 --- /dev/null +++ b/src/settings/CallNumberBrowseSettings/CallNumberBrowseSettings.js @@ -0,0 +1,142 @@ +import { + useCallback, + useMemo, +} from 'react'; +import { + FormattedMessage, + useIntl, +} from 'react-intl'; + +import { ControlledVocab } from '@folio/stripes/smart-components'; +import { + InfoPopover, + LoadingPane, +} from '@folio/stripes/components'; +import { + TitleManager, + useStripes, + useUserTenantPermissions, +} from '@folio/stripes/core'; + +import { CallNumberTypeList } from './CallNumberTypeList'; +import { CallNumberTypeField } from './CallNumberTypeField'; +import { useCallNumberTypesQuery } from '../../hooks'; +import { CALL_NUMBER_BROWSE_COLUMNS } from './constants'; + +const CallNumberBrowseSettings = () => { + const stripes = useStripes(); + const intl = useIntl(); + const centralTenantId = stripes.user.user?.consortium?.centralTenantId; + const { callNumberTypes, isCallNumberTypesLoading } = useCallNumberTypesQuery({ tenantId: centralTenantId }); + const ConnectedControlledVocab = useMemo(() => stripes.connect(ControlledVocab), [stripes]); + + const { + userPermissions: centralTenantPermissions, + isFetching: isCentralTenantPermissionsLoading, + } = useUserTenantPermissions({ + tenantId: centralTenantId, + }); + + const permission = 'ui-inventory.settings.call-number-browse'; + const hasCentralTenantPerm = centralTenantPermissions.some(({ permissionName }) => permissionName === permission); + const hasRequiredPermissions = stripes.hasInterface('consortia') + ? (hasCentralTenantPerm && stripes.hasPerm(permission)) + : stripes.hasPerm(permission); + + const fieldLabels = { + [CALL_NUMBER_BROWSE_COLUMNS.ID]: intl.formatMessage({ id: 'ui-inventory.name' }), + [CALL_NUMBER_BROWSE_COLUMNS.TYPE_IDS]: intl.formatMessage({ id: 'ui-inventory.callNumberTypes' }), + }; + + const columnMapping = useMemo(() => ({ + [CALL_NUMBER_BROWSE_COLUMNS.ID]: fieldLabels[CALL_NUMBER_BROWSE_COLUMNS.ID], + [CALL_NUMBER_BROWSE_COLUMNS.TYPE_IDS]: ( + <> + {fieldLabels[CALL_NUMBER_BROWSE_COLUMNS.TYPE_IDS]} + + + ), + })); + + const callNumberTypeOptions = useMemo(() => callNumberTypes.map(type => ({ + id: type.id, + label: type.name, + })), [callNumberTypes]); + + const formatRowData = useCallback(rowData => { + const callNumberType = rowData[CALL_NUMBER_BROWSE_COLUMNS.TYPE_IDS] + ?.map(id => callNumberTypeOptions.find(type => type.id === id)); + + return { + ...rowData, + name: intl.formatMessage({ id: `ui-inventory.settings.instanceCallNumber.${rowData.id}` }), + [CALL_NUMBER_BROWSE_COLUMNS.TYPE_IDS]: callNumberType, + }; + }, [callNumberTypeOptions]); + + const formatItemForSaving = useCallback((item) => ({ + [CALL_NUMBER_BROWSE_COLUMNS.ID]: item[CALL_NUMBER_BROWSE_COLUMNS.ID], + [CALL_NUMBER_BROWSE_COLUMNS.SHELVING_ALGORITHM]: item[CALL_NUMBER_BROWSE_COLUMNS.SHELVING_ALGORITHM], + [CALL_NUMBER_BROWSE_COLUMNS.TYPE_IDS]: item[CALL_NUMBER_BROWSE_COLUMNS.TYPE_IDS].map(type => type.id), + }), []); + + if (!hasRequiredPermissions) { + return null; + } + + if (isCentralTenantPermissionsLoading || isCallNumberTypesLoading) { + return ; + } + + return ( + + } + labelSingular={intl.formatMessage({ id: 'ui-inventory.name' })} + visibleFields={[CALL_NUMBER_BROWSE_COLUMNS.NAME, CALL_NUMBER_BROWSE_COLUMNS.TYPE_IDS]} + columnMapping={columnMapping} + hiddenFields={[CALL_NUMBER_BROWSE_COLUMNS.SHELVING_ALGORITHM, 'lastUpdated', 'numberOfObjects']} + formatter={{ + [CALL_NUMBER_BROWSE_COLUMNS.TYPE_IDS]: CallNumberTypeList, + }} + readOnlyFields={[CALL_NUMBER_BROWSE_COLUMNS.NAME]} + nameKey="name" + formType="final-form" + id="call-number-browse" + preUpdateHook={formatItemForSaving} + editable + fieldComponents={{ + [CALL_NUMBER_BROWSE_COLUMNS.TYPE_IDS]: (callNumberTypeProps) => ( + + ), + }} + canCreate={false} + parseRow={formatRowData} + actionSuppressor={{ + delete: () => true, + edit: () => false, + }} + translations={{ + termUpdated: 'ui-inventory.settings.instanceCallNumber.termUpdated' + }} + hideCreateButton + tenant={centralTenantId} + /> + + ); +}; + +export default CallNumberBrowseSettings; diff --git a/src/settings/CallNumberBrowseSettings/CallNumberBrowseSettings.test.js b/src/settings/CallNumberBrowseSettings/CallNumberBrowseSettings.test.js new file mode 100644 index 000000000..276628282 --- /dev/null +++ b/src/settings/CallNumberBrowseSettings/CallNumberBrowseSettings.test.js @@ -0,0 +1,179 @@ +import { MemoryRouter } from 'react-router-dom'; + +import { + useStripes, + useUserTenantPermissions, +} from '@folio/stripes/core'; +import { runAxeTest } from '@folio/stripes-testing'; + +import { + renderWithIntl, + translationsProperties, +} from '../../../test/jest/helpers'; + +import buildStripes from '../../../test/jest/__mock__/stripesCore.mock'; + +import CallNumberBrowseSettings from './CallNumberBrowseSettings'; + +import { useCallNumberTypesQuery } from '../../hooks'; + +jest.unmock('@folio/stripes/components'); +jest.unmock('@folio/stripes/smart-components'); +jest.mock('../../hooks', () => ({ + ...jest.requireActual('../../hooks'), + useCallNumberTypesQuery: jest.fn(), +})); +jest.mock('@folio/stripes/core', () => ({ + ...jest.requireActual('@folio/stripes/core'), + useStripes: jest.fn().mockReturnValue({ + hasInterface: () => true, + hasPerm: () => true, + connect: component => component, + user: {}, + okapi: {}, + }), + useOkapiKy: jest.fn().mockReturnValue({ + get: jest.fn(), + extend: jest.fn(), + }), + useUserTenantPermissions: jest.fn().mockReturnValue({ + userPermissions: [], + isFetching: false, + }), +})); + +const defaultProps = { + stripes: buildStripes(), +}; + +const renderCallNumberBrowseSettings = (props = {}) => renderWithIntl( + + + , + translationsProperties +); + +describe('CallNumberBrowseSettings', () => { + beforeEach(() => { + useCallNumberTypesQuery.mockClear().mockReturnValue({ + callNumberTypes: [], + isLoading: false, + }); + }); + + describe('when data is still loading', () => { + beforeEach(() => { + useCallNumberTypesQuery.mockClear().mockReturnValue({ + callNumberTypes: [], + isLoading: true, + }); + }); + + it('should not render the settings page', () => { + const { queryByText } = renderCallNumberBrowseSettings(); + + expect(queryByText('Call number browse')).not.toBeInTheDocument(); + }); + }); + + describe('when user does not have central tenant permissions', () => { + beforeEach(() => { + useCallNumberTypesQuery.mockClear().mockReturnValue({ + callNumberTypes: [], + isLoading: false, + }); + + useStripes.mockClear().mockReturnValue(buildStripes({ + hasPerm: () => false, + })); + }); + + it('should not render the settings page', () => { + const { queryByText } = renderCallNumberBrowseSettings(); + + expect(queryByText('Call number browse')).not.toBeInTheDocument(); + }); + }); + + describe('when data is loaded', () => { + const mutator = { + values: { + GET: jest.fn(), + }, + }; + + const resources = { + values: { + records: [{ + id: 'all', + typeIds: ['type-1', 'type-2'], + }], + }, + }; + + beforeEach(() => { + useCallNumberTypesQuery.mockClear().mockReturnValue({ + callNumberTypes: [{ + id: 'type-1', + name: 'Type 1', + }, { + id: 'type-2', + name: 'Type 2', + }], + isLoading: false, + }); + useUserTenantPermissions.mockClear().mockReturnValue({ + userPermissions: [], + isFetching: false, + }); + + useStripes.mockClear().mockReturnValue(buildStripes({ + hasInterface: () => false, + hasPerm: () => true, + connect: Component => (props) => ( + + ), + })); + }); + + it('should render with no axe errors', async () => { + const { container } = renderCallNumberBrowseSettings(); + + await runAxeTest({ + rootNode: container, + }); + }); + + it('should render the settings page', () => { + const { getAllByText } = renderCallNumberBrowseSettings(); + + expect(getAllByText('Call number browse')).toBeDefined(); + }); + + it('should render Call number browse config name', () => { + const { getByText } = renderCallNumberBrowseSettings(); + + expect(getByText('Call numbers (all)')).toBeInTheDocument(); + }); + + it('should render Call number type names', () => { + const { getByText } = renderCallNumberBrowseSettings(); + + expect(getByText('Type 1')).toBeInTheDocument(); + expect(getByText('Type 2')).toBeInTheDocument(); + }); + + it('should not render "+ New" button', () => { + const { queryByText } = renderCallNumberBrowseSettings(); + + expect(queryByText('+ New')).not.toBeInTheDocument(); + }); + }); +}); diff --git a/src/settings/CallNumberBrowseSettings/CallNumberTypeField.js b/src/settings/CallNumberBrowseSettings/CallNumberTypeField.js new file mode 100644 index 000000000..b07902659 --- /dev/null +++ b/src/settings/CallNumberBrowseSettings/CallNumberTypeField.js @@ -0,0 +1,40 @@ +import PropTypes from 'prop-types'; + +import { Field } from 'react-final-form'; + +import { MultiSelection } from '@folio/stripes/components'; + +export const CallNumberTypeField = ({ + fieldProps, + name, + rowIndex, + fieldIndex, + fieldLabels, + callNumberTypeOptions, +}) => { + return ( + option.label} + dataOptions={callNumberTypeOptions} + autoFocus={fieldIndex === 0} + /> + ); +}; + +CallNumberTypeField.propTypes = { + fieldProps: PropTypes.object.isRequired, + name: PropTypes.string.isRequired, + rowIndex: PropTypes.number.isRequired, + fieldIndex: PropTypes.number.isRequired, + fieldLabels: PropTypes.object.isRequired, + callNumberTypeOptions: PropTypes.arrayOf(PropTypes.shape({ + id: PropTypes.string, + label: PropTypes.string.isRequired, + })), +}; diff --git a/src/settings/CallNumberBrowseSettings/CallNumberTypeList.js b/src/settings/CallNumberBrowseSettings/CallNumberTypeList.js new file mode 100644 index 000000000..1ef8e4a5f --- /dev/null +++ b/src/settings/CallNumberBrowseSettings/CallNumberTypeList.js @@ -0,0 +1,20 @@ +import PropTypes from 'prop-types'; + +import { List } from '@folio/stripes/components'; + +export const CallNumberTypeList = ({ typeIds = [] }) => { + return ( +
  • {type?.label}
  • } + listStyle="bullets" + marginBottom0 + /> + ); +}; + +CallNumberTypeList.propTypes = { + typeIds: PropTypes.arrayOf(PropTypes.shape({ + label: PropTypes.string.isRequired, + })), +}; diff --git a/src/settings/CallNumberBrowseSettings/constants.js b/src/settings/CallNumberBrowseSettings/constants.js new file mode 100644 index 000000000..ff4805b06 --- /dev/null +++ b/src/settings/CallNumberBrowseSettings/constants.js @@ -0,0 +1,6 @@ +export const CALL_NUMBER_BROWSE_COLUMNS = { + ID: 'id', + NAME: 'name', + TYPE_IDS: 'typeIds', + SHELVING_ALGORITHM: 'shelvingAlgorithm', +}; diff --git a/src/settings/CallNumberBrowseSettings/index.js b/src/settings/CallNumberBrowseSettings/index.js new file mode 100644 index 000000000..c083d589d --- /dev/null +++ b/src/settings/CallNumberBrowseSettings/index.js @@ -0,0 +1 @@ +export { default } from './CallNumberBrowseSettings'; diff --git a/src/settings/InventorySettings/InventorySettings.js b/src/settings/InventorySettings/InventorySettings.js index 089e69556..62db20c8f 100644 --- a/src/settings/InventorySettings/InventorySettings.js +++ b/src/settings/InventorySettings/InventorySettings.js @@ -45,6 +45,7 @@ import ClassificationBrowseSettings from '../ClassificationBrowseSettings'; import SubjectSourcesSettings from '../SubjectSourcesSettings'; import SubjectTypesSettings from '../SubjectTypesSettings'; import DisplaySettings from '../DisplaySettings'; +import CallNumberBrowseSettings from '../CallNumberBrowseSettings'; import { flattenCentralTenantPermissions, isUserInConsortiumMode, @@ -78,10 +79,11 @@ const InventorySettings = (props) => { }; const getSections = (_centralTenantPermissions) => { - const canUserViewClassificationBrowse = isUserInConsortiumMode(stripes) - ? checkIfUserInCentralTenant(stripes) - || flattenCentralTenantPermissions(_centralTenantPermissions).has('ui-inventory.settings.classification-browse') - : true; + const hasPermission = (perm) => { + return isUserInConsortiumMode(stripes) + ? checkIfUserInCentralTenant(stripes) || flattenCentralTenantPermissions(_centralTenantPermissions).has(perm) + : true; + }; const _sections = [ { @@ -104,7 +106,7 @@ const InventorySettings = (props) => { component: AlternativeTitleTypesSettings, perm: addPerm('ui-inventory.settings.alternative-title-types'), }, - ...(canUserViewClassificationBrowse ? [{ + ...(hasPermission('ui-inventory.settings.classification-browse') ? [{ route: 'classificationBrowse', label: , component: ClassificationBrowseSettings, @@ -268,6 +270,12 @@ const InventorySettings = (props) => { { label: , pages: [ + ...(hasPermission('ui-inventory.settings.call-number-browse') ? [{ + route: 'callNumberBrowse', + label: , + component: CallNumberBrowseSettings, + perm: 'ui-inventory.settings.call-number-browse', + }] : []), { route: 'callNumberTypes', label: , diff --git a/translations/ui-inventory/en.json b/translations/ui-inventory/en.json index c45e0a711..e5f26d70d 100644 --- a/translations/ui-inventory/en.json +++ b/translations/ui-inventory/en.json @@ -352,6 +352,7 @@ "addUrl": "Add URL", "items": "Items", "instanceHoldingsItem": "Instances, Holdings, Items", + "callNumberBrowse": "Call number browse", "callNumberTypes": "Call number types", "selectCallNumberType": "Select type", "holdingsItems": "Holdings, Items", @@ -695,6 +696,15 @@ "settings.instanceClassification.termUpdated": "The Classification browse type {term} was successfully updated", "settings.instanceClassification.identifierTypesPopover": "Please note that if no classification identifier types are selected for a browse option, this option will display all classification identifier types.", + "settings.instanceCallNumber.all": "Call numbers (all)", + "settings.instanceCallNumber.dewey": "Dewey Decimal classification", + "settings.instanceCallNumber.lc": "Library of Congress classification", + "settings.instanceCallNumber.nlm": "National Library of Medicine classification", + "settings.instanceCallNumber.other": "Other scheme", + "settings.instanceCallNumber.sudoc": "Superintendent of Documents classification", + "settings.instanceCallNumber.termUpdated": "The call number browse type {term} was successfully updated", + "settings.instanceCallNumber.identifierTypesPopover": "Please note that if no call number types are selected for a browse option, this option will display all call number types.", + "permission.all-permissions.TEMPORARY": "Inventory: All permissions", "permission.settings.material-types": "Settings (Inventory): Create, edit, delete material types", "permission.settings.loan-types": "Settings (Inventory): Create, edit, delete loan types", @@ -702,6 +712,7 @@ "permission.settings.instance-formats": "Settings (Inventory): Create, edit, delete formats", "permission.settings.electronic-access-relationships": "Settings (Inventory): Create, edit, delete URL relationships", "permission.settings.holdings-types": "Settings (Inventory): Create, edit, delete holdings types", + "permission.settings.call-number-browse": "Settings (Inventory): Configure call number browse", "permission.settings.classification-browse": "Settings (Inventory): Configure classification browse", "permission.settings.classification-types": "Settings (Inventory): Create, edit, delete classification identifier types", "permission.settings.identifier-types": "Settings (Inventory): Create, edit, delete resource identifier types",