From 616fce6b0e94126531afec59ce60374f7a277e70 Mon Sep 17 00:00:00 2001 From: Mariia Aloshyna <55138456+mariia-aloshyna@users.noreply.github.com> Date: Thu, 16 Jan 2025 16:32:49 +0200 Subject: [PATCH 1/5] Release v12.0.9 (#2714) --- CHANGELOG.md | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index b35adebbc..353e20cb0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,13 +10,17 @@ * React 19: refactor away from react-dom/test-utils. Refs UIIN-2888. * Add call number browse settings. Refs UIIN-3116. * Add "linked-data 1.0" interface to "optionalOkapiInterfaces". Refs UIIN-3166. -* Fix infinite loading animation after cancel edit/duplicate or 'Save & Close' consortial holdings/items. Fixes UIIN-3167. * Remove hover-over text next to "Effective call number" on the Item record detail view. Refs UIIN-3131. * Change import of `exportToCsv` from `stripes-util` to `stripes-components`. Refs UIIN-3025. * Display `Shared` facet when user opens "Move holdings/items to another instance" modal. Refs UIIN-3198. * ECS - Allow 'Move holdings/items to another instance' if instance is shared. Refs UIIN-3188. * Fix '"location name" is undefined' error when trying to open instance details on ECS. Fixes UIIN-3196. +## [12.0.9](https://github.com/folio-org/ui-inventory/tree/v12.0.9) (2025-01-13) +[Full Changelog](https://github.com/folio-org/ui-inventory/compare/v12.0.8...v12.0.9) + +* Fix infinite loading animation after cancel edit/duplicate or 'Save & Close' consortial holdings/items. Fixes UIIN-3167. + ## [12.0.8](https://github.com/folio-org/ui-inventory/tree/v12.0.8) (2024-12-24) [Full Changelog](https://github.com/folio-org/ui-inventory/compare/v12.0.7...v12.0.8) From cfec02aceb1efbbf0fcf598e87ce36bb9cff7805 Mon Sep 17 00:00:00 2001 From: Mariia Aloshyna <55138456+mariia-aloshyna@users.noreply.github.com> Date: Tue, 21 Jan 2025 18:40:55 +0200 Subject: [PATCH 2/5] Release v12.0.10 (#2718) --- CHANGELOG.md | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 353e20cb0..f2e13db2b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -12,9 +12,13 @@ * Add "linked-data 1.0" interface to "optionalOkapiInterfaces". Refs UIIN-3166. * Remove hover-over text next to "Effective call number" on the Item record detail view. Refs UIIN-3131. * Change import of `exportToCsv` from `stripes-util` to `stripes-components`. Refs UIIN-3025. + +## [12.0.10](https://github.com/folio-org/ui-inventory/tree/v12.0.10) (2025-01-20) +[Full Changelog](https://github.com/folio-org/ui-inventory/compare/v12.0.9...v12.0.10) + +* Fix '"location name" is undefined' error when trying to open instance details on ECS. Fixes UIIN-3196. * Display `Shared` facet when user opens "Move holdings/items to another instance" modal. Refs UIIN-3198. * ECS - Allow 'Move holdings/items to another instance' if instance is shared. Refs UIIN-3188. -* Fix '"location name" is undefined' error when trying to open instance details on ECS. Fixes UIIN-3196. ## [12.0.9](https://github.com/folio-org/ui-inventory/tree/v12.0.9) (2025-01-13) [Full Changelog](https://github.com/folio-org/ui-inventory/compare/v12.0.8...v12.0.9) From 9ae6bd9e5f7fe6e5afb853d394940d6edcf79060 Mon Sep 17 00:00:00 2001 From: Mariia Aloshyna <55138456+mariia-aloshyna@users.noreply.github.com> Date: Wed, 22 Jan 2025 16:29:55 +0200 Subject: [PATCH 3/5] UIIN-3187: ECS: Disable opening item details if a user is not affiliated with item's member tenant (#2707) --- CHANGELOG.md | 1 + src/components/InstancesList/InstancesList.js | 28 ++ .../InstancesList/InstancesList.test.js | 289 +++++++++--------- 3 files changed, 182 insertions(+), 136 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index f2e13db2b..7d22f7106 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -12,6 +12,7 @@ * Add "linked-data 1.0" interface to "optionalOkapiInterfaces". Refs UIIN-3166. * Remove hover-over text next to "Effective call number" on the Item record detail view. Refs UIIN-3131. * Change import of `exportToCsv` from `stripes-util` to `stripes-components`. Refs UIIN-3025. +* ECS: Disable opening item details if a user is not affiliated with item's member tenant. Fixes UIIN-3187. ## [12.0.10](https://github.com/folio-org/ui-inventory/tree/v12.0.10) (2025-01-20) [Full Changelog](https://github.com/folio-org/ui-inventory/compare/v12.0.9...v12.0.10) diff --git a/src/components/InstancesList/InstancesList.js b/src/components/InstancesList/InstancesList.js index 7b6768ebc..62f04568d 100644 --- a/src/components/InstancesList/InstancesList.js +++ b/src/components/InstancesList/InstancesList.js @@ -28,6 +28,7 @@ import { withNamespace, checkIfUserInCentralTenant, TitleManager, + getUserTenantsPermissions, } from '@folio/stripes/core'; import { SearchAndSort, @@ -83,6 +84,7 @@ import { replaceFilter, batchQueryIntoSmaller, getSortOptions, + hasMemberTenantPermission, } from '../../utils'; import { INSTANCES_ID_REPORT_TIMEOUT, @@ -213,6 +215,7 @@ class InstancesList extends React.Component { searchAndSortKey: 0, segmentsSortBy: this.getInitialSegmentsSortBy(), searchInProgress: false, + userTenantPermissions: [], }; } @@ -222,6 +225,7 @@ class InstancesList extends React.Component { location: _location, getParams, data, + stripes, } = this.props; const params = getParams(); @@ -260,6 +264,10 @@ class InstancesList extends React.Component { if (isSortingUpdated || isStaffSuppressFilterChanged) { this.redirectToSearchParams(searchParams); } + + if (isUserInConsortiumMode(stripes)) { + this.getCurrentTenantPermissions(); + } } componentDidUpdate(prevProps) { @@ -1190,6 +1198,15 @@ class InstancesList extends React.Component { }; } + getCurrentTenantPermissions = () => { + const { + stripes, + stripes: { user: { user: { tenants } } }, + } = this.props; + + getUserTenantsPermissions(stripes, tenants).then(userTenantPermissions => this.setState({ userTenantPermissions })); + } + findAndOpenItem = async (instance) => { const { parentResources, @@ -1213,6 +1230,17 @@ class InstancesList extends React.Component { const tenantItemBelongsTo = instance?.items?.[0]?.tenantId || stripes.okapi.tenant; + // if a user is not affiliated with the item's member tenant then item details cannot be open + if (isUserInConsortiumMode(stripes)) { + const tenants = stripes.user.user.tenants || []; + const isUserAffiliatedWithMemberTenant = tenants.find(tenant => tenant?.id === tenantItemBelongsTo); + const canMemberTenantViewItems = hasMemberTenantPermission('ui-inventory.instance.view', tenantItemBelongsTo, this.state.userTenantPermissions); + + if (isEmpty(isUserAffiliatedWithMemberTenant) || !canMemberTenantViewItems) { + return instance; + } + } + itemsByQuery.reset(); const items = await itemsByQuery.GET({ params: { query: itemQuery }, diff --git a/src/components/InstancesList/InstancesList.test.js b/src/components/InstancesList/InstancesList.test.js index 0ad43ffe3..866473c38 100644 --- a/src/components/InstancesList/InstancesList.test.js +++ b/src/components/InstancesList/InstancesList.test.js @@ -50,6 +50,7 @@ const mockUnsubscribeFromReset = jest.fn(); const mockPublishOnReset = jest.fn(); const spyOnIsUserInConsortiumMode = jest.spyOn(utils, 'isUserInConsortiumMode'); +const spyOnHasMemberTenantPermission = jest.spyOn(utils, 'hasMemberTenantPermission'); const spyOnCheckIfUserInCentralTenant = jest.spyOn(require('@folio/stripes/core'), 'checkIfUserInCentralTenant'); jest.mock('../../storage', () => ({ @@ -90,6 +91,7 @@ jest.mock('@folio/stripes/core', () => ({ TitleManager: ({ page }) => (
{page}
), + getUserTenantsPermissions: jest.fn(() => Promise.resolve([])), })); jest.mock('@folio/stripes-inventory-components', () => ({ @@ -202,7 +204,7 @@ describe('InstancesList', () => { it('should replace history with selected facet value', async () => { jest.spyOn(history, 'replace'); - renderInstancesList({ segment: 'instances' }); + await act(async () => renderInstancesList({ segment: 'instances' })); expect(history.replace).toHaveBeenLastCalledWith({ pathname: '/', @@ -217,17 +219,18 @@ describe('InstancesList', () => { it('should replace history with selected facet value', async () => { jest.spyOn(history, 'replace'); - history.push({ + act(() => history.push({ pathname: '/', search: 'filters=staffSuppress.true', - }); - renderInstancesList({ + })); + await act(async () => renderInstancesList({ segment: 'instances', stripes: { hasPerm: () => false, hasInterface: () => true, + user: { user: { tenants: [] } }, }, - }); + })); expect(history.replace).toHaveBeenLastCalledWith({ pathname: '/', @@ -239,8 +242,8 @@ describe('InstancesList', () => { }); describe('when switching segment (Instance/Holdings/Item)', () => { - it('should unsubscribe from reset event', () => { - renderInstancesList(); + it('should unsubscribe from reset event', async () => { + await act(async () => renderInstancesList()); fireEvent.click(screen.getByRole('button', { name: 'Holdings' })); @@ -260,7 +263,7 @@ describe('InstancesList', () => { }); it('should clear USER_TOUCHED_STAFF_SUPPRESS_STORAGE_KEY', async () => { - renderInstancesList({ segment: 'instances' }); + await act(async () => renderInstancesList({ segment: 'instances' })); const search = '?segment=instances&sort=title'; act(() => { history.push({ search }); }); @@ -273,17 +276,17 @@ describe('InstancesList', () => { it('should replace history with selected facet value', async () => { jest.spyOn(history, 'replace'); - const { rerender } = renderInstancesList({ segment: 'instances' }); + const { rerender } = await act(async () => renderInstancesList({ segment: 'instances' })); - history.push({ + act(() => history.push({ pathname: '/', search: 'segment=holdings', - }); + })); rerender(getInstancesListTree({ segment: 'holdings' })); await waitFor(() => expect(history.replace).toHaveBeenCalledWith({ pathname: '/', - search: 'segment=holdings&filters=staffSuppress.false&sort=contributors', + search: 'segment=holdings&sort=contributors&filters=staffSuppress.false', state: undefined, })); }); @@ -292,12 +295,12 @@ describe('InstancesList', () => { describe('when the component is mounted', () => { describe('and sort parameter does not match the one selected in Settings', () => { - it('should not be replaced', () => { + it('should not be replaced', async () => { jest.spyOn(history, 'replace'); - history.push('/inventory?filters=staffSuppress.false&sort=title'); + act(() => history.push('/inventory?filters=staffSuppress.false&sort=title')); - renderInstancesList({ + await act(async () => renderInstancesList({ segment: 'instances', data: { ...data, @@ -308,7 +311,7 @@ describe('InstancesList', () => { defaultSort: SORT_OPTIONS.RELEVANCE, }, }, - }); + })); expect(history.replace).not.toHaveBeenLastCalledWith(expect.objectContaining({ search: expect.stringContaining('sort=relevance'), @@ -317,12 +320,12 @@ describe('InstancesList', () => { }); describe('and sort parameter is missing', () => { - it('should call history.replace with the default sort parameter', () => { + it('should call history.replace with the default sort parameter', async () => { jest.spyOn(history, 'replace'); - history.push('/inventory?filters=staffSuppress.false'); + act(() => history.push('/inventory?filters=staffSuppress.false')); - renderInstancesList({ + await act(async () => renderInstancesList({ data: { ...data, query: { @@ -332,7 +335,7 @@ describe('InstancesList', () => { defaultSort: SORT_OPTIONS.RELEVANCE, }, }, - }); + })); expect(history.replace).toHaveBeenLastCalledWith(expect.objectContaining({ search: expect.stringContaining('sort=relevance'), @@ -341,8 +344,8 @@ describe('InstancesList', () => { }); describe('when query is present', () => { - it('should display correct document title', () => { - renderInstancesList({ + it('should display correct document title', async () => { + await act(async () => renderInstancesList({ segment: 'instances', data: { ...data, @@ -350,14 +353,14 @@ describe('InstancesList', () => { query: 'test', }, }, - }); + })); expect(screen.getByText('Inventory - test - Search')).toBeInTheDocument(); }); }); describe('and browse result was selected', () => { - it('should reset offset', () => { + it('should reset offset', async () => { mockResultOffsetReplace.mockClear(); const params = { selectedBrowseResult: 'true', @@ -366,23 +369,23 @@ describe('InstancesList', () => { query: 'Abdill, Aasha M.,', }; - renderInstancesList({ + await act(async () => renderInstancesList({ segment: 'instances', getParams: () => params, - }); + })); expect(mockResultOffsetReplace).toHaveBeenCalledWith(0); }); }); describe('and browse result was not selected', () => { - it('should replace resultOffset', () => { + it('should replace resultOffset', async () => { mockResultOffsetReplace.mockClear(); - renderInstancesList({ + await act(async () => renderInstancesList({ segment: 'instances', getLastSearchOffset: () => 100, - }); + })); expect(mockResultOffsetReplace).toHaveBeenCalledWith(100); }); @@ -391,8 +394,8 @@ describe('InstancesList', () => { describe('when the component is updated', () => { describe('and location.search has been changed', () => { - it('should write location.search to the session storage', () => { - renderInstancesList({ segment: 'instances' }); + it('should write location.search to the session storage', async () => { + await act(async () => renderInstancesList({ segment: 'instances' })); const search = '?qindex=title&query=book&sort=title'; act(() => { history.push({ search }); }); @@ -402,11 +405,11 @@ describe('InstancesList', () => { }); describe('and offset has been changed', () => { - it('should write offset to storage', () => { + it('should write offset to storage', async () => { const offset = 100; mockStoreLastSearchOffset.mockClear(); - const { rerender } = renderInstancesList({ segment: 'instances' }); + const { rerender } = await act(async () => renderInstancesList({ segment: 'instances' })); rerender(getInstancesListTree({ segment: 'instances', @@ -421,12 +424,12 @@ describe('InstancesList', () => { }); describe('and segment has been changed', () => { - it('should apply offset from storage', () => { + it('should apply offset from storage', async () => { const lastSearchOffset = 200; - const { rerender } = renderInstancesList({ + const { rerender } = await act(async () => renderInstancesList({ segment: segments.instances, - }); + })); mockStoreLastSearchOffset.mockClear(); mockResultOffsetReplace.mockClear(); @@ -452,10 +455,10 @@ describe('InstancesList', () => { global.Storage.prototype.setItem.mockReset(); }); - it('should move focus to query input', () => { - renderInstancesList({ + it('should move focus to query input', async () => { + await act(async () => renderInstancesList({ segment: 'instances', - }); + })); fireEvent.change(screen.getByRole('textbox', { name: 'Search' }), { target: { value: 'test' } }); fireEvent.click(screen.getAllByRole('button', { name: 'Search' })[1]); @@ -464,10 +467,10 @@ describe('InstancesList', () => { expect(screen.getByRole('textbox', { name: 'Search' })).toHaveFocus(); }); - it('should clear "user touched staff suppress" session storage flag', () => { - renderInstancesList({ + it('should clear "user touched staff suppress" session storage flag', async () => { + await act(async () => renderInstancesList({ segment: 'instances', - }); + })); fireEvent.change(screen.getByRole('textbox', { name: 'Search' }), { target: { value: 'test' } }); fireEvent.click(screen.getAllByRole('button', { name: 'Search' })[1]); @@ -476,20 +479,20 @@ describe('InstancesList', () => { expect(mockSetItem).toHaveBeenCalledWith(USER_TOUCHED_STAFF_SUPPRESS_STORAGE_KEY, false); }); - it('should publish the reset event', () => { - renderInstancesList(); + it('should publish the reset event', async () => { + await act(async () => renderInstancesList()); fireEvent.click(screen.getByRole('button', { name: 'Reset all' })); expect(mockPublishOnReset).toHaveBeenCalled(); }); - it('should call history.replace to add the default sort query parameter from inventory settings', () => { + it('should call history.replace to add the default sort query parameter from inventory settings', async () => { jest.spyOn(history, 'replace'); - history.push('/inventory?filters=staffSuppress.false&sort=contributors'); + act(() => history.push('/inventory?filters=staffSuppress.false&sort=contributors')); - renderInstancesList({ + await act(async () => renderInstancesList({ segment: 'instances', data: { ...data, @@ -501,7 +504,7 @@ describe('InstancesList', () => { defaultSort: SORT_OPTIONS.RELEVANCE, }, } - }); + })); fireEvent.change(screen.getByRole('textbox', { name: 'Search' }), { target: { value: 'test' } }); fireEvent.click(screen.getByRole('button', { name: 'Reset all' })); @@ -515,13 +518,13 @@ describe('InstancesList', () => { }); describe('when search segment is changed', () => { - it('should clear selected rows', () => { + it('should clear selected rows', async () => { const { getAllByLabelText, getByText, - } = renderInstancesList({ + } = await act(async () => renderInstancesList({ segment: 'instances', - }); + })); fireEvent.click(getAllByLabelText('Select instance')[0]); fireEvent.click(getByText('Holdings')); @@ -532,10 +535,10 @@ describe('InstancesList', () => { describe('when a user performs a search and clicks the `Next` button in the list of records', () => { describe('then clicks on the `Browse` lookup tab and then clicks `Search` lookup tab', () => { - it('should avoid infinity loading by resetting the records on unmounting', () => { + it('should avoid infinity loading by resetting the records on unmounting', async () => { mockRecordsReset.mockClear(); - const { unmount } = renderInstancesList({ segment: 'instances' }); + const { unmount } = await act(async () => renderInstancesList({ segment: 'instances' })); unmount(); expect(mockRecordsReset).toHaveBeenCalled(); }); @@ -543,29 +546,29 @@ describe('InstancesList', () => { }); describe('when clicking on the `Browse` tab', () => { - it('should pass the correct search by clicking on the `Browse` tab', () => { + it('should pass the correct search by clicking on the `Browse` tab', async () => { const search = '?qindex=subject&query=book'; jest.spyOn(history, 'push'); - renderInstancesList({ + await act(async () => renderInstancesList({ segment: 'instances', getLastBrowse: () => search, - }); + })); fireEvent.click(screen.getByRole('button', { name: 'Browse' })); expect(history.push).toHaveBeenCalledWith(expect.objectContaining({ search })); }); - it('should store last opened record id', () => { + it('should store last opened record id', async () => { history = createMemoryHistory({ initialEntries: [{ pathname: '/inventory/view/test-id', }] }); - renderInstancesList({ + await act(async () => renderInstancesList({ segment: 'instances', - }); + })); fireEvent.click(screen.getByRole('button', { name: 'Browse' })); @@ -573,15 +576,15 @@ describe('InstancesList', () => { }); }); - it('should have proper list results size', () => { - renderInstancesList({ segment: 'instances' }); + it('should have proper list results size', async () => { + await act(async () => renderInstancesList({ segment: 'instances' })); expect(document.querySelectorAll('#pane-results-content .mclRowContainer > [role=row]').length).toEqual(4); }); describe('opening action menu', () => { - it('should disable toggleable columns', () => { - renderInstancesList({ segment: 'instances' }); + it('should disable toggleable columns', async () => { + await act(async () => renderInstancesList({ segment: 'instances' })); openActionMenu(); expect(screen.getByText(/show columns/i)).toBeInTheDocument(); @@ -589,10 +592,10 @@ describe('InstancesList', () => { describe('"New record" button', () => { describe('for non-consortial tenant', () => { - it('should display the default "New" menu option', () => { + it('should display the default "New" menu option', async () => { spyOnIsUserInConsortiumMode.mockReturnValue(false); - renderInstancesList({ segment: 'instances' }); + await act(async () => renderInstancesList({ segment: 'instances' })); openActionMenu(); expect(screen.getByRole('button', { name: 'New' })).toBeInTheDocument(); @@ -600,11 +603,11 @@ describe('InstancesList', () => { }); describe('for a Consortial central tenant', () => { - it('should display "New shared record" menu option', () => { + it('should display "New shared record" menu option', async () => { spyOnIsUserInConsortiumMode.mockReturnValue(true); spyOnCheckIfUserInCentralTenant.mockReturnValue(true); - renderInstancesList({ segment: 'instances' }); + await act(async () => renderInstancesList({ segment: 'instances' })); openActionMenu(); expect(screen.getByRole('button', { name: 'New shared record' })).toBeInTheDocument(); @@ -612,11 +615,11 @@ describe('InstancesList', () => { }); describe('for a Member library tenant', () => { - it('should display "New local record" menu option', () => { + it('should display "New local record" menu option', async () => { spyOnIsUserInConsortiumMode.mockReturnValue(true); spyOnCheckIfUserInCentralTenant.mockReturnValue(false); - renderInstancesList({ segment: 'instances' }); + await act(async () => renderInstancesList({ segment: 'instances' })); openActionMenu(); expect(screen.getByRole('button', { name: 'New local record' })).toBeInTheDocument(); @@ -625,12 +628,12 @@ describe('InstancesList', () => { describe('when canceling a record', () => { it('should remove the "layer" parameter and focus on the search field', async () => { - history.push('/inventory?filters=staffSuppress.false&layer=foo'); + act(() => history.push('/inventory?filters=staffSuppress.false&layer=foo')); jest.spyOn(history, 'push'); - const { getByRole } = renderInstancesList({ segment: 'instances' }); - SearchAndSort.mock.calls[0][0].onCloseNewRecord(); + const { getByRole } = await act(async () => renderInstancesList({ segment: 'instances' })); + act(() => SearchAndSort.mock.calls[0][0].onCloseNewRecord()); expect(history.push).toHaveBeenCalledWith('/?filters=staffSuppress.false&sort=contributors'); await waitFor(() => expect(getByRole('textbox', { name: /search/i })).toHaveFocus()); @@ -639,8 +642,8 @@ describe('InstancesList', () => { }); describe('"New fast add record" button', () => { - it('should render', () => { - renderInstancesList({ segment: 'instances' }); + it('should render', async () => { + await act(async () => renderInstancesList({ segment: 'instances' })); openActionMenu(); expect(screen.getByRole('button', { name: 'New fast add record' })).toBeInTheDocument(); @@ -649,7 +652,7 @@ describe('InstancesList', () => { describe('when saving the record', () => { it('should redirect to new Instance record', async () => { jest.spyOn(history, 'push'); - renderInstancesList({ segment: 'instances' }); + await act(async () => renderInstancesList({ segment: 'instances' })); openActionMenu(); const button = screen.getByRole('button', { name: 'New fast add record' }); @@ -668,14 +671,14 @@ describe('InstancesList', () => { it('should focus on search field', async () => { jest.useFakeTimers(); - const { getByRole } = renderInstancesList({ segment: 'instances' }); + const { getByRole } = await act(async () => renderInstancesList({ segment: 'instances' })); openActionMenu(); fireEvent.click(screen.getByRole('button', { name: 'New fast add record' })); fireEvent.click(screen.getByTestId('plugin-cancel')); - jest.runAllTimers(); + act(() => jest.runAllTimers()); expect(getByRole('textbox', { name: /search/i })).toHaveFocus(); }); @@ -683,8 +686,8 @@ describe('InstancesList', () => { }); describe('"New MARC bibliographic record" button', () => { - it('should render', () => { - renderInstancesList({ segment: 'instances' }); + it('should render', async () => { + await act(async () => renderInstancesList({ segment: 'instances' })); openActionMenu(); expect(screen.getByRole('button', { name: 'New MARC bibliographic record' })).toBeInTheDocument(); @@ -692,7 +695,7 @@ describe('InstancesList', () => { it('should redirect to the correct layer', async () => { jest.spyOn(history, 'push'); - renderInstancesList({ segment: 'instances' }); + await act(async () => renderInstancesList({ segment: 'instances' })); openActionMenu(); const button = screen.getByRole('button', { name: 'New MARC bibliographic record' }); @@ -704,8 +707,8 @@ describe('InstancesList', () => { }); describe('hiding contributors column', () => { - it('should hide contributors column', () => { - renderInstancesList({ segment: 'instances' }); + it('should hide contributors column', async () => { + await act(async () => renderInstancesList({ segment: 'instances' })); fireEvent.click(screen.getByTestId('contributors')); expect(document.querySelector('#clickable-list-column-contributors')).not.toBeInTheDocument(); @@ -713,20 +716,20 @@ describe('InstancesList', () => { }); describe('select sort by', () => { - it('should render menu option', () => { - renderInstancesList({ segment: 'instances' }); + it('should render menu option', async () => { + await act(async () => renderInstancesList({ segment: 'instances' })); expect(screen.getByTestId('menu-section-sort-by')).toBeInTheDocument(); }); - it('should render select', () => { - renderInstancesList({ segment: 'instances' }); + it('should render select', async () => { + await act(async () => renderInstancesList({ segment: 'instances' })); expect(screen.getByTestId('sort-by-selection')).toBeInTheDocument(); }); - it('should render correct order of options', () => { - renderInstancesList({ segment: 'instances' }); + it('should render correct order of options', async () => { + await act(async () => renderInstancesList({ segment: 'instances' })); openActionMenu(); const options = within(screen.getByTestId('sort-by-selection')).getAllByRole('option'); @@ -740,8 +743,8 @@ describe('InstancesList', () => { }); describe('select proper sort options', () => { - it('should select Title as default selected sort option', () => { - renderInstancesList({ segment: 'instances' }); + it('should select Title as default selected sort option', async () => { + await act(async () => renderInstancesList({ segment: 'instances' })); const search = '?segment=instances&sort=title'; act(() => { history.push({ search }); }); @@ -751,8 +754,8 @@ describe('InstancesList', () => { expect(option.selected).toBeTruthy(); }); - it('should select Contributors option', () => { - renderInstancesList({ segment: 'instances' }); + it('should select Contributors option', async () => { + await act(async () => renderInstancesList({ segment: 'instances' })); openActionMenu(); fireEvent.change(screen.getByTestId('sort-by-selection'), { target: { value: 'contributors' } }); @@ -763,7 +766,7 @@ describe('InstancesList', () => { }); it('should select option value "Contributors" after column "Contributors" click', async () => { - renderInstancesList({ segment: 'instances' }); + await act(async () => renderInstancesList({ segment: 'instances' })); await act(async () => fireEvent.click(document.querySelector('#clickable-list-column-contributors'))); openActionMenu(); @@ -771,8 +774,8 @@ describe('InstancesList', () => { expect((screen.getByRole('option', { name: 'Contributors' })).selected).toBeTruthy(); }); - it('should select Relevance selected sort option when in search query', () => { - renderInstancesList({ segment: 'instances' }); + it('should select Relevance selected sort option when in search query', async () => { + await act(async () => renderInstancesList({ segment: 'instances' })); const search = '?segment=instances&sort=relevance'; act(() => { history.push({ search }); }); @@ -782,8 +785,8 @@ describe('InstancesList', () => { expect(option.selected).toBeTruthy(); }); - it('should set aria-sort to none on sorted columns after query sort by Relevance', () => { - renderInstancesList({ + it('should set aria-sort to none on sorted columns after query sort by Relevance', async () => { + await act(async () => renderInstancesList({ segment: 'instances', parentResources: { ...resources, @@ -792,14 +795,14 @@ describe('InstancesList', () => { sort: 'relevance', } }, - }); + })); const sortCols = document.querySelectorAll('[aria-sort="ascending"], [aria-sort="descending"]'); expect(sortCols).toHaveLength(0); }); - it('should select Date option', () => { - renderInstancesList(); + it('should select Date option', async () => { + await act(async () => renderInstancesList()); openActionMenu(); fireEvent.change(screen.getByTestId('sort-by-selection'), { target: { value: SORT_OPTIONS.DATE } }); @@ -813,7 +816,7 @@ describe('InstancesList', () => { describe('when clicking on the `Holdings` or `Items` segments', () => { it('should take default sort option from data for Holdings or Item segments', async () => { - renderInstancesList({ segment: 'instances' }); + await act(async () => renderInstancesList({ segment: 'instances' })); const search = '?segment=instances&sort=title'; act(() => { history.push({ search }); }); @@ -830,15 +833,15 @@ describe('InstancesList', () => { describe('filters pane', () => { it('should have selected effective call number option', async () => { - renderInstancesList({ segment: 'instances' }); + await act(async () => renderInstancesList({ segment: 'instances' })); await act(async () => fireEvent.change(screen.getByLabelText('Search field index'), { target: { value: 'callNumber' } })); expect((screen.getByRole('option', { name: 'Effective call number (item), shelving order' })).selected).toBeTruthy(); }); - it('should have query in search input', () => { - renderInstancesList({ segment: 'instances' }); + it('should have query in search input', async () => { + await act(async () => renderInstancesList({ segment: 'instances' })); fireEvent.change(screen.getByRole('textbox', { name: 'Search' }), { target: { value: 'search query' } }); fireEvent.click(screen.getAllByRole('button', { name: 'Search' })[1]); @@ -850,7 +853,7 @@ describe('InstancesList', () => { it('should not change the URL in the onChangeIndex function', async () => { history.push = jest.fn(); - renderInstancesList({ segment: 'instances' }); + await act(async () => renderInstancesList({ segment: 'instances' })); fireEvent.change(screen.getByLabelText('Search field index'), { target: { value: 'Title (all)' } }); @@ -862,8 +865,8 @@ describe('InstancesList', () => { }); describe('when using advanced search', () => { - it('should set advanced search query in search input', () => { - renderInstancesList({ segment: 'instances' }); + it('should set advanced search query in search input', async () => { + await act(async () => renderInstancesList({ segment: 'instances' })); fireEvent.click(screen.getByRole('button', { name: 'Advanced search' })); fireEvent.change(screen.getAllByTestId('advanced-search-query')[0], { @@ -879,13 +882,13 @@ describe('InstancesList', () => { describe('when too many filters had been selected and user saves Instance UUIDs', () => { const generateUUID = () => new Array(36).fill('a').join(''); - it('should send multiple requests for IDs', () => { + it('should send multiple requests for IDs', async () => { const qindex = queryIndexes.QUERY_SEARCH; const _query = `(item.effectiveLocaionId==(${new Array(100).fill(`"${generateUUID()}"`).join(' or ')})`; buildSearchQuery.mockReturnValue(() => _query); - renderInstancesList({ + await act(async () => renderInstancesList({ segment: 'items', parentResources: { ...resources, @@ -895,11 +898,11 @@ describe('InstancesList', () => { query: _query, }, }, - }); + })); openActionMenu(); - fireEvent.click(screen.getByRole('button', { name: 'Save instances UUIDs' })); + await act(async () => fireEvent.click(screen.getByRole('button', { name: 'Save instances UUIDs' }))); expect(mockRecordsToExportIDs).toHaveBeenCalledTimes(2); }); @@ -907,8 +910,8 @@ describe('InstancesList', () => { }); describe('rendering InstancesList with holdings segment', () => { - it('should show Save Holdings UUIDs button', () => { - renderInstancesList({ segment: 'holdings' }); + it('should show Save Holdings UUIDs button', async () => { + await act(async () => renderInstancesList({ segment: 'holdings' })); fireEvent.change(screen.getByRole('combobox'), { target: { value: queryIndexes.INSTANCE_KEYWORD } @@ -930,7 +933,9 @@ describe('InstancesList', () => { ].forEach(({ qindex, query: _query, option }) => { describe('when open item view', () => { it(`should enclose the ${option} query in quotes`, async () => { - renderInstancesList({ + spyOnHasMemberTenantPermission.mockReturnValue(true); + + await act(async () => renderInstancesList({ segment: 'items', parentResources: { ...resources, @@ -940,7 +945,13 @@ describe('InstancesList', () => { query: _query, }, }, - }); + stripes: { + hasPerm: () => false, + hasInterface: () => true, + user: { user: { tenants: [{ id: 'college' }] } }, + okapi: { tenant: 'diku', token: '' }, + }, + })); await act(() => fireEvent.change(screen.getByLabelText('Search field index'), { target: { value: qindex } })); await act(() => fireEvent.change(screen.getByRole('textbox', { name: 'Search' }), { target: { value: _query } })); @@ -949,7 +960,7 @@ describe('InstancesList', () => { const row = screen.getAllByText('ABA Journal')[0]; - await act(() => fireEvent.click(row)); + await act(async () => fireEvent.click(row)); expect(mockItemsByQuery).toHaveBeenCalledWith({ headers: { @@ -966,7 +977,7 @@ describe('InstancesList', () => { describe('when there is one item found', () => { it('should navigate to item details page', async () => { - renderInstancesList({ + await act(async () => renderInstancesList({ segment: 'items', parentResources: { ...resources, @@ -976,7 +987,13 @@ describe('InstancesList', () => { query: '1234567(89)', }, }, - }); + stripes: { + hasPerm: () => true, + hasInterface: () => true, + user: { user: { tenants: [{ id: 'college' }] } }, + okapi: { tenant: 'diku' }, + }, + })); jest.spyOn(history, 'push'); @@ -996,14 +1013,14 @@ describe('InstancesList', () => { }); describe('when dismissing a record detail view', () => { - it('should reset selected row and focus on the search field', () => { + it('should reset selected row and focus on the search field', async () => { SearchAndSort.mockClear(); history = createMemoryHistory(); - history.push('inventory/view/5bf370e0-8cca-4d9c-82e4-5170ab2a0a39'); + act(() => history.push('inventory/view/5bf370e0-8cca-4d9c-82e4-5170ab2a0a39')); const mockResetSelectedItem = jest.fn(); - const { getByText } = renderInstancesList({ segment: 'instances' }); + const { getByText } = await act(async () => renderInstancesList({ segment: 'instances' })); const clickedListItem = getByText('A semantic web primer'); SearchAndSort.mock.calls[0][0].onDismissDetail(mockResetSelectedItem); @@ -1016,8 +1033,8 @@ describe('InstancesList', () => { describe('Date column', () => { describe('when there is no delimiter', () => { - it('should use a comma', () => { - const { getByText } = renderInstancesList({ + it('should use a comma', async () => { + const { getByText } = await act(async () => renderInstancesList({ parentResources: { ...resources, records: { @@ -1032,15 +1049,15 @@ describe('InstancesList', () => { }], }, }, - }); + })); expect(getByText('2022, 2024')).toBeVisible(); }); }); describe('when delimiter is a comma', () => { - it('should be displayed with a space after comma', () => { - const { getByText } = renderInstancesList({ + it('should be displayed with a space after comma', async () => { + const { getByText } = await act(async () => renderInstancesList({ data: { ...data, ...query, @@ -1067,15 +1084,15 @@ describe('InstancesList', () => { }], }, }, - }); + })); expect(getByText('2023, 2024')).toBeVisible(); }); }); describe('when keepDelimiter is true', () => { - it('should display the delimiter', () => { - const { getByText } = renderInstancesList({ + it('should display the delimiter', async () => { + const { getByText } = await act(async () => renderInstancesList({ data: { ...data, ...query, @@ -1101,15 +1118,15 @@ describe('InstancesList', () => { }], }, }, - }); + })); expect(getByText('-2024')).toBeVisible(); }); }); describe('when keepDelimiter is false', () => { - it('should not display a delimiter', () => { - const { getByText } = renderInstancesList({ + it('should not display a delimiter', async () => { + const { getByText } = await act(async () => renderInstancesList({ data: { ...data, ...query, @@ -1135,15 +1152,15 @@ describe('InstancesList', () => { }], }, }, - }); + })); expect(getByText('2024')).toBeVisible(); }); }); }); - it('should have correct order of search columns', () => { - const { getAllByRole } = renderInstancesList(); + it('should have correct order of search columns', async () => { + const { getAllByRole } = await act(async () => renderInstancesList()); const searchColumns = getAllByRole('columnheader'); @@ -1156,7 +1173,7 @@ describe('InstancesList', () => { }); it('should render correct order of options in "Show columns" section of actions', async () => { - renderInstancesList(); + await act(async () => renderInstancesList()); openActionMenu(); const checkboxes = within(document.getElementById('columns-menu-section')).getAllByText( From 55d300104062f82c60d851f03b72a668a548d656 Mon Sep 17 00:00:00 2001 From: Oleksandr Hladchenko <85172747+OleksandrHladchenko1@users.noreply.github.com> Date: Wed, 22 Jan 2025 17:00:46 +0100 Subject: [PATCH 4/5] UIIN-3195: Display failure message during Update Ownership action when Item contains Local reference data (#2719) --- CHANGELOG.md | 1 + src/views/ItemView.js | 27 ++++++++++++++++++----- src/views/ItemView.test.js | 36 +++++++++++++++++++++++++++++-- translations/ui-inventory/en.json | 1 + 4 files changed, 58 insertions(+), 7 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 7d22f7106..94d99be43 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -13,6 +13,7 @@ * Remove hover-over text next to "Effective call number" on the Item record detail view. Refs UIIN-3131. * Change import of `exportToCsv` from `stripes-util` to `stripes-components`. Refs UIIN-3025. * ECS: Disable opening item details if a user is not affiliated with item's member tenant. Fixes UIIN-3187. +* Display failure message during `Update Ownership` action when Item contains Local reference data. Fixes UIIN-3195. ## [12.0.10](https://github.com/folio-org/ui-inventory/tree/v12.0.10) (2025-01-20) [Full Changelog](https://github.com/folio-org/ui-inventory/compare/v12.0.9...v12.0.10) diff --git a/src/views/ItemView.js b/src/views/ItemView.js index 4e4e0bfa6..7c4b2a1e1 100644 --- a/src/views/ItemView.js +++ b/src/views/ItemView.js @@ -540,15 +540,28 @@ const ItemView = props => { goBack(); }; + const resetFormAndCloseModal = () => { + setTargetTenant({}); + setUpdateOwnershipData({}); + setIsConfirmUpdateOwnershipModalOpen(false); + }; + const showErrorMessage = () => { calloutContext.sendCallout({ type: 'error', message: , }); - setTargetTenant({}); - setUpdateOwnershipData({}); - setIsConfirmUpdateOwnershipModalOpen(false); + resetFormAndCloseModal(); + }; + + const showReferenceDataError = () => { + calloutContext.sendCallout({ + type: 'error', + message: , + }); + + resetFormAndCloseModal(); }; const createNewHoldingForlocation = async (itemId, targetLocation, targetTenantId) => { @@ -590,8 +603,12 @@ const ItemView = props => { targetTenantId: tenantId, }); showSuccessMessageAndGoBack(item.hrid); - } catch (e) { - showErrorMessage(); + } catch (error) { + if (error.response.status === 400) { + showReferenceDataError(); + } else { + showErrorMessage(); + } } } }; diff --git a/src/views/ItemView.test.js b/src/views/ItemView.test.js index 96f092ecc..a36586ed8 100644 --- a/src/views/ItemView.test.js +++ b/src/views/ItemView.test.js @@ -376,10 +376,42 @@ describe('ItemView', () => { expect(screen.queryByText('Linked order line')).not.toBeInTheDocument(); }); - describe('when an error was occured', () => { + describe('when error was occured due to local-specific reference data', () => { it('should show an error message', async () => { useHoldingMutation.mockClear().mockReturnValue({ mutateHolding: mockMutate }); - useUpdateOwnership.mockClear().mockReturnValue({ updateOwnership: jest.fn().mockRejectedValue() }); + useUpdateOwnership.mockClear().mockReturnValue({ + updateOwnership: jest.fn().mockRejectedValue({ + response: { + status: 400, + } + }) + }); + checkIfUserInCentralTenant.mockClear().mockReturnValue(false); + + renderWithIntl(, translationsProperties); + + const updateOwnershipBtn = screen.getByText('Update ownership'); + fireEvent.click(updateOwnershipBtn); + + act(() => UpdateItemOwnershipModal.mock.calls[0][0].handleSubmit('university', { id: 'locationId' }, 'holdingId')); + + const confirmationModal = screen.getByText('Update ownership of items'); + fireEvent.click(within(confirmationModal).getByText('confirm')); + + await waitFor(() => expect(screen.queryByText('Item ownership could not be updated because it contains local-specific reference data.')).toBeDefined()); + }); + }); + + describe('when error was occured', () => { + it('should show an error message', async () => { + useHoldingMutation.mockClear().mockReturnValue({ mutateHolding: mockMutate }); + useUpdateOwnership.mockClear().mockReturnValue({ + updateOwnership: jest.fn().mockRejectedValue({ + response: { + status: 500, + }, + }) + }); checkIfUserInCentralTenant.mockClear().mockReturnValue(false); renderWithIntl(, translationsProperties); diff --git a/translations/ui-inventory/en.json b/translations/ui-inventory/en.json index fffbf33f1..204c2d47f 100644 --- a/translations/ui-inventory/en.json +++ b/translations/ui-inventory/en.json @@ -446,6 +446,7 @@ "updateOwnership.items.modal.heading": "Update ownership of items", "updateOwnership.items.modal.message": "Would you like to update ownership of Item {itemHrid} from {currentTenant} to {targetTenant}?", "updateOwnership.item.message.success": "Ownership of item {itemHrid} has been successfully updated to {targetTenantName}", + "updateOwnership.items.message.error": "Item ownership could not be updated because it contains local-specific reference data.", "consortialHoldings": "Consortial holdings", "instanceData": "Administrative data", "instanceHrid": "Instance HRID", From ebc566d8bc68b9818b42a43dcb5e1532130a2299 Mon Sep 17 00:00:00 2001 From: Zak Burke Date: Wed, 22 Jan 2025 13:14:07 -0500 Subject: [PATCH 5/5] UIIN-3203 correctly depend on inflected (#2715) `inflected` is used in `src/views/ItemView.js` and so must be a direct dep, not a dev-dep. Refs UIIN-3203 --- CHANGELOG.md | 1 + package.json | 2 +- 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 94d99be43..63248f9c9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -14,6 +14,7 @@ * Change import of `exportToCsv` from `stripes-util` to `stripes-components`. Refs UIIN-3025. * ECS: Disable opening item details if a user is not affiliated with item's member tenant. Fixes UIIN-3187. * Display failure message during `Update Ownership` action when Item contains Local reference data. Fixes UIIN-3195. +* Correctly depend on `inflected`. Refs UIIN-3203. ## [12.0.10](https://github.com/folio-org/ui-inventory/tree/v12.0.10) (2025-01-20) [Full Changelog](https://github.com/folio-org/ui-inventory/compare/v12.0.9...v12.0.10) diff --git a/package.json b/package.json index fc873ec94..0f7e8990b 100644 --- a/package.json +++ b/package.json @@ -1031,7 +1031,6 @@ "eslint-plugin-filenames": "^1.3.2", "eslint-plugin-import": "^2.22.1", "eslint-plugin-jest": "^24.0.0", - "inflected": "^2.1.0", "react": "^18.2.0", "react-dom": "^18.2.0", "react-intl": "^6.4.4", @@ -1048,6 +1047,7 @@ "final-form": "^4.18.2", "final-form-arrays": "^3.0.1", "history": "^4.10.0", + "inflected": "^2.1.0", "ky": "^0.23.0", "lodash": "^4.17.4", "moment": "~2.29.4",