@@ -1086,6 +1090,7 @@ ViewHoldingsRecord.propTypes = {
connect: PropTypes.func.isRequired,
hasPerm: PropTypes.func.isRequired,
hasInterface: PropTypes.func.isRequired,
+ okapi: PropTypes.object.isRequired,
}).isRequired,
resources: PropTypes.shape({
instances1: PropTypes.object,
diff --git a/src/ViewHoldingsRecord.test.js b/src/ViewHoldingsRecord.test.js
index 1f5f4459c..5287c07bb 100644
--- a/src/ViewHoldingsRecord.test.js
+++ b/src/ViewHoldingsRecord.test.js
@@ -19,9 +19,13 @@ import {
import ViewHoldingsRecord from './ViewHoldingsRecord';
-
jest.mock('./withLocation', () => jest.fn(c => c));
+jest.mock('./common', () => ({
+ ...jest.requireActual('./common'),
+ useTenantKy: jest.fn(),
+}));
+
const spyOncollapseAllSections = jest.spyOn(require('@folio/stripes/components'), 'collapseAllSections');
const spyOnexpandAllSections = jest.spyOn(require('@folio/stripes/components'), 'expandAllSections');
@@ -77,7 +81,11 @@ const defaultProps = {
},
location: {
search: '/',
- pathname: 'pathname'
+ pathname: 'pathname',
+ state: {
+ tenantTo: 'testTenantToId',
+ tenantFrom: 'testTenantFromId',
+ }
},
};
@@ -108,7 +116,7 @@ describe('ViewHoldingsRecord actions', () => {
it('should close view holding page', async () => {
renderViewHoldingsRecord();
fireEvent.click(await screen.findByRole('button', { name: 'confirm' }));
- expect(defaultProps.history.push).toBeCalled();
+ expect(defaultProps.history.push).toHaveBeenCalled();
});
it('should translate to edit holding form page', async () => {
diff --git a/src/ViewInstance.js b/src/ViewInstance.js
index f67977944..3aa636308 100644
--- a/src/ViewInstance.js
+++ b/src/ViewInstance.js
@@ -33,6 +33,8 @@ import makeConnectedInstance from './ConnectedInstance';
import withLocation from './withLocation';
import InstancePlugin from './components/InstancePlugin';
import {
+ isUserInConsortiumMode,
+ getUserTenantsPermissions,
handleKeyCommand,
isInstanceShadowCopy,
isMARCSource,
@@ -186,6 +188,7 @@ class ViewInstance extends React.Component {
isNewOrderModalOpen: false,
afterCreate: false,
instancesQuickExportInProgress: false,
+ userTenantPermissions: [],
};
this.instanceId = null;
this.intervalId = null;
@@ -196,7 +199,10 @@ class ViewInstance extends React.Component {
}
componentDidMount() {
- const { selectedInstance } = this.props;
+ const {
+ selectedInstance,
+ stripes,
+ } = this.props;
const isMARCSourceRecord = isMARCSource(selectedInstance?.source);
if (isMARCSourceRecord) {
@@ -204,6 +210,10 @@ class ViewInstance extends React.Component {
}
this.setTlrSettings();
+
+ if (isUserInConsortiumMode(stripes)) {
+ this.getCurrentTenantPermissions();
+ }
}
componentDidUpdate(prevProps) {
@@ -249,6 +259,15 @@ class ViewInstance extends React.Component {
clearInterval(this.intervalId);
}
+ getCurrentTenantPermissions = () => {
+ const {
+ stripes,
+ stripes: { user: { user: { tenants } } },
+ } = this.props;
+
+ getUserTenantsPermissions(stripes, tenants).then(userTenantPermissions => this.setState({ userTenantPermissions }));
+ }
+
getMARCRecord = () => {
const { mutator } = this.props;
mutator.marcRecord.GET()
@@ -917,6 +936,7 @@ class ViewInstance extends React.Component {
instance={instance}
tagsEnabled={tagsEnabled}
ref={this.accordionStatusRef}
+ userTenantPermissions={this.state.userTenantPermissions}
isLoading={isInstanceLoading}
isShared={isShared}
>
@@ -1061,8 +1081,16 @@ ViewInstance.propTypes = {
hasPerm: PropTypes.func.isRequired,
locale: PropTypes.string.isRequired,
logger: PropTypes.object.isRequired,
+ user: PropTypes.shape({
+ user: PropTypes.shape({
+ tenants: PropTypes.arrayOf(PropTypes.object),
+ }).isRequired
+ }).isRequired,
+ }).isRequired,
+ okapi: PropTypes.shape({
+ tenant: PropTypes.string.isRequired,
+ token: PropTypes.string.isRequired
}).isRequired,
- okapi: PropTypes.object.isRequired,
tagsEnabled: PropTypes.bool,
updateLocation: PropTypes.func.isRequired,
};
diff --git a/src/ViewInstance.test.js b/src/ViewInstance.test.js
index 558b74a27..188eb8000 100644
--- a/src/ViewInstance.test.js
+++ b/src/ViewInstance.test.js
@@ -32,6 +32,8 @@ import {
translationsProperties,
} from '../test/jest/helpers';
+import * as utils from './utils';
+
jest.mock('./components/ImportRecordModal/ImportRecordModal', () => (props) => {
const { isOpen, handleSubmit, handleCancel } = props;
if (isOpen) {
@@ -68,10 +70,16 @@ jest.mock('./common/hooks', () => ({
...jest.requireActual('./common/hooks'),
useTenantKy: jest.fn(),
}));
+jest.mock('react-beautiful-dnd', () => ({
+ ...jest.requireActual('react-beautiful-dnd'),
+ Draggable: jest.fn(() => Press space bar to start a drag
),
+}));
const spyOncollapseAllSections = jest.spyOn(require('@folio/stripes/components'), 'collapseAllSections');
const spyOnexpandAllSections = jest.spyOn(require('@folio/stripes/components'), 'expandAllSections');
+const spyOnGetUserTenantsPermissions = jest.spyOn(utils, 'getUserTenantsPermissions');
+
const location = {
pathname: '/testPathName',
search: '?filters=test1&query=test2&sort=test3&qindex=test',
@@ -132,7 +140,10 @@ const mockStripes = {
log: jest.fn()
},
okapi: { tenant: 'diku' },
- user: { user: {} },
+ user: {
+ user: {},
+ tenants: ['testTenantId']
+ },
};
const defaultProp = {
centralTenantPermissions: [],
@@ -257,11 +268,22 @@ const checkActionItemExists = (actionItemName) => {
expect(screen.getByRole('button', { name: actionItemName })).toBeInTheDocument();
};
+global.fetch = jest.fn();
+
describe('ViewInstance', () => {
beforeEach(() => {
jest.clearAllMocks();
StripesConnectedInstance.prototype.instance.mockImplementation(() => instance);
checkIfUserInCentralTenant.mockReturnValue(false);
+ spyOnGetUserTenantsPermissions.mockResolvedValueOnce([{
+ tenantId: 'testTenantId',
+ permissionNames: ['test permission 1'],
+ }]);
+ global.fetch
+ .mockResolvedValueOnce({
+ ok: true,
+ json: async () => ({}),
+ });
useStripes().hasInterface.mockReturnValue(true);
});
it('should display action menu items', () => {
@@ -399,11 +421,14 @@ describe('ViewInstance', () => {
checkActionItemExists('Move items within an instance');
});
- it('"Move items within an instance" button to be clicked', () => {
- renderViewInstance();
- fireEvent.click(screen.getByRole('button', { name: 'Actions' }));
- fireEvent.click(screen.getByRole('button', { name: 'Move items within an instance' }));
- expect(renderViewInstance()).toBeTruthy();
+ describe('when click "Move items within an instance" button', () => {
+ it('should render component for dragging', () => {
+ const { getByText } = renderViewInstance();
+ fireEvent.click(screen.getByRole('button', { name: 'Actions' }));
+ fireEvent.click(screen.getByRole('button', { name: 'Move items within an instance' }));
+
+ expect(getByText(/Press space bar to start a drag/i)).toBeInTheDocument();
+ });
});
describe('when user is in central tenant', () => {
@@ -629,11 +654,14 @@ describe('ViewInstance', () => {
checkActionItemExists('Export instance (MARC)');
});
- it('"Export instance (MARC)" button to be clicked', () => {
- renderViewInstance();
- fireEvent.click(screen.getByRole('button', { name: 'Actions' }));
- fireEvent.click(screen.getByRole('button', { name: 'Export instance (MARC)' }));
- expect(renderViewInstance()).toBeTruthy();
+ describe('when click "Export instance (MARC)" button', () => {
+ it('should call function to export instance', () => {
+ renderViewInstance();
+ fireEvent.click(screen.getByRole('button', { name: 'Actions' }));
+ fireEvent.click(screen.getByRole('button', { name: 'Export instance (MARC)' }));
+
+ expect(defaultProp.mutator.quickExport.POST).toHaveBeenCalled();
+ });
});
});
describe('"New order" action item', () => {
@@ -665,7 +693,12 @@ describe('ViewInstance', () => {
const stripes = {
...defaultProp.stripes,
okapi: { tenant: 'consortium' },
- user: { user: { consortium: { centralTenantId: 'consortium' } } },
+ user: {
+ user: {
+ consortium: { centralTenantId: 'consortium' },
+ tenants: ['testTenantId'],
+ },
+ },
};
checkIfUserInCentralTenant.mockClear().mockReturnValue(true);
@@ -710,7 +743,12 @@ describe('ViewInstance', () => {
const stripes = {
...defaultProp.stripes,
okapi: { tenant: 'consortium' },
- user: { user: { consortium: { centralTenantId: 'consortium' } } },
+ user: {
+ user: {
+ consortium: { centralTenantId: 'consortium' },
+ tenants: ['testTenantId'],
+ },
+ },
};
renderViewInstance({ stripes });
diff --git a/src/common/hooks/useHolding/useHolding.js b/src/common/hooks/useHolding/useHolding.js
index 6c47cee3a..19dba9eb0 100644
--- a/src/common/hooks/useHolding/useHolding.js
+++ b/src/common/hooks/useHolding/useHolding.js
@@ -1,9 +1,11 @@
import { useQuery } from 'react-query';
-import { useOkapiKy, useNamespace } from '@folio/stripes/core';
+import { useNamespace } from '@folio/stripes/core';
-const useHolding = (holdingId) => {
- const ky = useOkapiKy();
+import useTenantKy from '../useTenantKy';
+
+const useHolding = (holdingId, { tenantId = '' } = {}) => {
+ const ky = useTenantKy({ tenantId });
const [namespace] = useNamespace({ key: 'holding' });
const { isLoading, data: holding = {} } = useQuery(
diff --git a/src/constants.js b/src/constants.js
index 118aaf841..534d4d280 100644
--- a/src/constants.js
+++ b/src/constants.js
@@ -631,6 +631,8 @@ export const SOURCE_VALUES = {
export const CONSORTIUM_PREFIX = 'CONSORTIUM-';
export const OKAPI_TENANT_HEADER = 'X-Okapi-Tenant';
+export const OKAPI_TOKEN_HEADER = 'X-Okapi-Token';
+export const CONTENT_TYPE_HEADER = 'Content-Type';
export const DEFAULT_ITEM_TABLE_SORTBY_FIELD = 'barcode';
diff --git a/src/hooks/useChunkedCQLFetch.js b/src/hooks/useChunkedCQLFetch.js
index 34ede67ce..56406f83c 100644
--- a/src/hooks/useChunkedCQLFetch.js
+++ b/src/hooks/useChunkedCQLFetch.js
@@ -3,16 +3,17 @@ import { useQueries } from 'react-query';
import { chunk } from 'lodash';
-import { useOkapiKy } from '@folio/stripes/core';
+import { useTenantKy } from '../common';
// When fetching from a potentially large list of items,
// make sure to chunk the request to avoid hitting limits.
const useChunkedCQLFetch = ({
endpoint, // endpoint to hit to fetch items
ids, // List of ids to fetch
- reduceFunction // Function to reduce fetched objects at the end into single array
+ reduceFunction, // Function to reduce fetched objects at the end into single array
+ tenantId,
}) => {
- const ky = useOkapiKy();
+ const ky = useTenantKy({ tenantId });
const CONCURRENT_REQUESTS = 5; // Number of requests to make concurrently
const STEP_SIZE = 60; // Number of ids to request for per concurrent request
diff --git a/src/routes/ItemRoute.js b/src/routes/ItemRoute.js
index 60b186e86..f3235ab5e 100644
--- a/src/routes/ItemRoute.js
+++ b/src/routes/ItemRoute.js
@@ -13,6 +13,7 @@ import withLocation from '../withLocation';
import { ItemView } from '../views';
import { PaneLoading } from '../components';
import { DataContext } from '../contexts';
+import { switchAffiliation } from '../utils';
const getRequestsPath = `circulation/requests?query=(itemId==:{itemid}) and status==(${requestsStatusString}) sortby requestDate desc&limit=1`;
@@ -24,6 +25,7 @@ class ItemRoute extends React.Component {
path: 'inventory/items/:{itemid}',
POST: { path: 'inventory/items' },
resourceShouldRefresh: true,
+ tenant: '!{location.state.tenantTo}',
},
markItemAsWithdrawn: {
type: 'okapi',
@@ -100,6 +102,7 @@ class ItemRoute extends React.Component {
holdingsRecords: {
type: 'okapi',
path: 'holdings-storage/holdings/:{holdingsrecordid}',
+ tenant: '!{location.state.tenantTo}',
},
instanceRecords: {
type: 'okapi',
@@ -155,19 +158,26 @@ class ItemRoute extends React.Component {
},
});
- onClose = () => {
+ goBack = () => {
const {
- goTo,
- match: {
- params: { id },
- },
- location: { pathname, search },
+ match: { params: { id } },
+ location: { search },
+ history,
} = this.props;
- // extract instance url
- const [path] = pathname.match(new RegExp(`(.*)${id}`));
+ history.push({
+ pathname: `/inventory/view/${id}`,
+ search,
+ });
+ }
+
+ onClose = () => {
+ const {
+ stripes,
+ location: { state: { tenantFrom } },
+ } = this.props;
- goTo(`${path}${search}`);
+ switchAffiliation(stripes, tenantFrom, this.goBack);
}
isLoading = () => {
@@ -212,6 +222,9 @@ ItemRoute.propTypes = {
match: PropTypes.object,
location: PropTypes.object,
resources: PropTypes.object,
+ stripes: PropTypes.object,
+ tenantFrom: PropTypes.string,
+ history: PropTypes.object,
};
export default flowRight(
diff --git a/src/utils.js b/src/utils.js
index 19e11fcc3..0de843be4 100644
--- a/src/utils.js
+++ b/src/utils.js
@@ -21,6 +21,10 @@ import {
} from 'lodash';
import moment from 'moment';
+import {
+ updateTenant,
+ validateUser,
+} from '@folio/stripes/core';
import { FormattedUTCDate } from '@folio/stripes/components';
import {
@@ -34,6 +38,9 @@ import {
ERROR_TYPES,
SINGLE_ITEM_QUERY_TEMPLATES,
CONSORTIUM_PREFIX,
+ OKAPI_TENANT_HEADER,
+ CONTENT_TYPE_HEADER,
+ OKAPI_TOKEN_HEADER,
} from './constants';
export const areAllFieldsEmpty = fields => fields.every(item => (isArray(item)
@@ -782,3 +789,64 @@ export const isMARCSource = (source) => {
export const isUserInConsortiumMode = stripes => stripes.hasInterface('consortia');
export const isInstanceShadowCopy = (source) => [`${CONSORTIUM_PREFIX}FOLIO`, `${CONSORTIUM_PREFIX}MARC`].includes(source);
+
+export const getUserTenantsPermissions = (stripes, tenants = []) => {
+ const {
+ user: { user: { id } },
+ okapi: {
+ url,
+ token,
+ }
+ } = stripes;
+ const userTenantIds = tenants.map(tenant => tenant.id || tenant);
+
+ const promises = userTenantIds.map(async (tenantId) => {
+ const result = await fetch(`${url}/perms/users/${id}/permissions?full=true&indexField=userId`, {
+ headers: {
+ [OKAPI_TENANT_HEADER]: tenantId,
+ [CONTENT_TYPE_HEADER]: 'application/json',
+ ...(token && { [OKAPI_TOKEN_HEADER]: token }),
+ },
+ credentials: 'include',
+ });
+
+ const json = await result.json();
+
+ return { tenantId, ...json };
+ });
+
+ return Promise.all(promises);
+};
+
+export const hasMemberTenantPermission = (permissionName, tenantId, permissions = []) => {
+ const tenantPermissions = permissions?.find(permission => permission?.tenantId === tenantId)?.permissionNames || [];
+
+ const hasPermission = tenantPermissions?.some(tenantPermission => tenantPermission?.permissionName === permissionName);
+
+ if (!hasPermission) {
+ return tenantPermissions.some(tenantPermission => tenantPermission.subPermissions.includes(permissionName));
+ }
+
+ return hasPermission;
+};
+
+export const switchAffiliation = async (stripes, tenantId, move) => {
+ if (stripes.okapi.tenant !== tenantId) {
+ await updateTenant(stripes.okapi, tenantId);
+
+ validateUser(
+ stripes.okapi.url,
+ stripes.store,
+ tenantId,
+ {
+ token: stripes.okapi.token,
+ user: stripes.user.user,
+ perms: stripes.user.perms,
+ },
+ );
+
+ move();
+ } else {
+ move();
+ }
+};
diff --git a/src/utils.test.js b/src/utils.test.js
index e225ca34e..1c8fe72d8 100644
--- a/src/utils.test.js
+++ b/src/utils.test.js
@@ -1,12 +1,18 @@
import '../test/jest/__mock__';
import { FormattedMessage } from 'react-intl';
+
+import { updateTenant } from '@folio/stripes/core';
+
+import buildStripes from '../test/jest/__mock__/stripesCore.mock';
+
import {
validateRequiredField,
validateFieldLength,
validateNumericField,
validateAlphaNumericField,
getQueryTemplate,
+ switchAffiliation,
} from './utils';
import { browseModeOptions } from './constants';
@@ -144,3 +150,32 @@ describe('getQueryTemplate', () => {
});
});
+describe('switchAffiliation', () => {
+ beforeEach(() => {
+ jest.clearAllMocks();
+ });
+
+ const moveMock = jest.fn();
+ const stripes = buildStripes({
+ okapi: {
+ tenant: 'college',
+ token: 'testToken',
+ },
+ });
+
+ describe('when current tenant is the same as tenant to switch', () => {
+ it('should only move to the next page', () => {
+ switchAffiliation(stripes, 'college', moveMock);
+
+ expect(moveMock).toHaveBeenCalled();
+ });
+ });
+
+ describe('when current tenant is not the same as tenant to switch', () => {
+ it('should switch affiliation', () => {
+ switchAffiliation(stripes, 'university', moveMock);
+
+ expect(updateTenant).toHaveBeenCalled();
+ });
+ });
+});
diff --git a/src/views/ItemView.js b/src/views/ItemView.js
index eff5f68e3..a613e1c90 100644
--- a/src/views/ItemView.js
+++ b/src/views/ItemView.js
@@ -753,7 +753,7 @@ class ItemView extends React.Component {
}
value={checkIfElementIsEmpty(itemLocation.effectiveLocation.name)}
- subValue={!itemLocation.effectiveLocation.isActive &&
+ subValue={!itemLocation.effectiveLocation?.isActive &&
}
data-testid="item-effective-location"
@@ -1335,7 +1335,7 @@ class ItemView extends React.Component {
}
value={checkIfElementIsEmpty(holdingLocation.permanentLocation.name)}
- subValue={!holdingLocation.permanentLocation.isActive &&
+ subValue={!holdingLocation.permanentLocation?.isActive &&
}
data-testid="holding-permanent-location"
@@ -1345,7 +1345,7 @@ class ItemView extends React.Component {
}
value={checkIfElementIsEmpty(holdingLocation.temporaryLocation.name)}
- subValue={holdingLocation.temporaryLocation.isActive === false &&
+ subValue={holdingLocation.temporaryLocation?.isActive === false &&
}
data-testid="holding-temporary-location"
@@ -1371,7 +1371,7 @@ class ItemView extends React.Component {
}
value={checkIfElementIsEmpty(itemLocation.permanentLocation.name)}
- subValue={itemLocation.permanentLocation.isActive === false &&
+ subValue={itemLocation.permanentLocation?.isActive === false &&
}
data-testid="item-permanent-location"
@@ -1381,7 +1381,7 @@ class ItemView extends React.Component {
}
value={checkIfElementIsEmpty(itemLocation.temporaryLocation.name)}
- subValue={itemLocation.temporaryLocation.isActive === false &&
+ subValue={itemLocation.temporaryLocation?.isActive === false &&
}
data-testid="item-temporary-location"
diff --git a/test/jest/__mock__/stripesCore.mock.js b/test/jest/__mock__/stripesCore.mock.js
index 15b036546..a9ad0a2b4 100644
--- a/test/jest/__mock__/stripesCore.mock.js
+++ b/test/jest/__mock__/stripesCore.mock.js
@@ -101,6 +101,10 @@ const mockStripesCore = {
checkIfUserInMemberTenant: jest.fn(() => true),
checkIfUserInCentralTenant: jest.fn(() => false),
+
+ updateTenant: jest.fn(() => {}),
+
+ validateUser: jest.fn(() => {}),
};
jest.mock('@folio/stripes/core', () => ({