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",