diff --git a/CHANGELOG.md b/CHANGELOG.md
index 84f9e556b..89c975a9e 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -4,6 +4,7 @@
* UX consistency: Use Save & close button label stripes-component translation key. Refs UIU-3078.
* Fix two "triangle down" icons in select element of "Copy existing fee/fine owner table entries" modal. Refs UIU-2929.
+* Show Roles assigned to users. Refs UIU-3110.
## [10.1.0](https://github.com/folio-org/ui-users/tree/v10.1.0) (2024-03-20)
[Full Changelog](https://github.com/folio-org/ui-users/compare/v10.0.4...v10.1.0)
diff --git a/src/components/RenderRoles/RenderRoles.js b/src/components/RenderRoles/RenderRoles.js
new file mode 100644
index 000000000..3883c60f8
--- /dev/null
+++ b/src/components/RenderRoles/RenderRoles.js
@@ -0,0 +1,109 @@
+import _ from 'lodash';
+import React from 'react';
+import { FormattedMessage, injectIntl } from 'react-intl';
+import PropTypes from 'prop-types';
+import {
+ List,
+ Accordion,
+ Badge,
+ Headline,
+ Loading
+} from '@folio/stripes/components';
+
+import AffiliationsSelect from '../AffiliationsSelect/AffiliationsSelect';
+import IfConsortium from '../IfConsortium';
+import IfConsortiumPermission from '../IfConsortiumPermission';
+import { affiliationsShape } from '../../shapes';
+
+class RenderRoles extends React.Component {
+ static propTypes = {
+ accordionId: PropTypes.string,
+ affiliations: affiliationsShape,
+ expanded: PropTypes.bool,
+ heading: PropTypes.node.isRequired,
+ intl: PropTypes.shape({
+ formatMessage: PropTypes.func.isRequired,
+ }),
+ isLoading: PropTypes.bool,
+ listedRoles: PropTypes.arrayOf(PropTypes.object),
+ onChangeAffiliation: PropTypes.func,
+ onToggle: PropTypes.func,
+ permToRead: PropTypes.string.isRequired,
+ selectedAffiliation: PropTypes.string,
+ stripes: PropTypes.shape({
+ hasPerm: PropTypes.func.isRequired,
+ config: PropTypes.shape({
+ showPerms: PropTypes.bool,
+ listInvisiblePerms: PropTypes.bool,
+ }).isRequired,
+ }).isRequired,
+ };
+
+ static defaultProps = {
+ onChangeAffiliation: _.noop,
+ isLoading: false,
+ }
+
+ renderList() {
+ const {
+ listedRoles,
+ } = this.props;
+ const listFormatter = item =>
{item.name};
+ const noPermissionsFound = ;
+
+ return (
+ a.name.localeCompare(b.name))}
+ itemFormatter={listFormatter}
+ isEmptyMessage={noPermissionsFound}
+ />
+ );
+ }
+
+ render() {
+ const {
+ affiliations,
+ accordionId,
+ expanded,
+ isLoading,
+ onChangeAffiliation,
+ onToggle,
+ listedRoles,
+ stripes,
+ permToRead,
+ selectedAffiliation,
+ heading,
+ } = this.props;
+
+ if (!stripes.hasPerm(permToRead)) { return null; }
+
+ return (
+ {heading}}
+ displayWhenClosed={
+ isLoading ? : {listedRoles.length}
+ }
+ >
+
+
+ {Boolean(affiliations?.length) && (
+
+ )}
+
+
+
+ {this.renderList()}
+
+ );
+ }
+}
+
+export default injectIntl(RenderRoles);
diff --git a/src/components/RenderRoles/RenderRoles.test.js b/src/components/RenderRoles/RenderRoles.test.js
new file mode 100644
index 000000000..f38e3dcca
--- /dev/null
+++ b/src/components/RenderRoles/RenderRoles.test.js
@@ -0,0 +1,74 @@
+import renderWithRouter from 'helpers/renderWithRouter';
+import RenderRoles from './RenderRoles';
+
+
+jest.unmock('@folio/stripes/components');
+
+const renderRenderRoles = (props) => renderWithRouter();
+const STRIPES = {
+ config: {},
+ hasPerm: jest.fn().mockReturnValue(true),
+};
+
+const STRIPESWITHOUTPERMISSION = {
+ config: {},
+ hasPerm: jest.fn().mockReturnValue(false),
+};
+
+describe('render RenderRoles component', () => {
+ it('Component must be rendered', () => {
+ const props = {
+ accordionId: 'assignedRoles',
+ expanded: true,
+ onToggle: jest.fn(),
+ heading: Assigned roles
,
+ permToRead: 'perms.permissions.get',
+ listedRoles: [
+ {
+ 'id': '024f7895-45fa-4ea7-ba06-a6a51758559f',
+ 'name': 'funky',
+ 'description': 'get down get down'
+ },
+ {
+ 'id': '27b6cf82-303a-4737-b2d8-c5bc807f077f',
+ 'name': 'chicken',
+ 'description': 'look up look up, the sky is falling!'
+ }
+ ],
+ intl: {},
+ stripes: STRIPES,
+ };
+ renderRenderRoles(props);
+ expect(renderRenderRoles(props)).toBeTruthy();
+ });
+
+ it('Checking for roles', () => {
+ const props = {
+ accordionId: 'assignedRoles',
+ expanded: true,
+ onToggle: jest.fn(),
+ heading: Assigned Permissions
,
+ permToRead: 'perms.permissions.get',
+ listedPermissions: [],
+ intl: {},
+ stripes: STRIPESWITHOUTPERMISSION,
+ };
+ renderRenderRoles(props);
+ expect(renderRenderRoles(props)).toBeTruthy();
+ });
+
+ it('Passing empty props', () => {
+ const props = {
+ accordionId: 'assignedRoles',
+ expanded: true,
+ onToggle: jest.fn(),
+ heading: Assigned Permissions
,
+ permToRead: 'perms.permissions.get',
+ listedRoles: [],
+ intl: {},
+ stripes: STRIPES,
+ };
+ renderRenderRoles(props);
+ expect(renderRenderRoles(props)).toBeTruthy();
+ });
+});
diff --git a/src/components/RenderRoles/index.js b/src/components/RenderRoles/index.js
new file mode 100644
index 000000000..5bbd736ca
--- /dev/null
+++ b/src/components/RenderRoles/index.js
@@ -0,0 +1 @@
+export { default } from './RenderRoles';
diff --git a/src/components/UserDetailSections/UserRoles/UserRoles.js b/src/components/UserDetailSections/UserRoles/UserRoles.js
new file mode 100644
index 000000000..cedcf2002
--- /dev/null
+++ b/src/components/UserDetailSections/UserRoles/UserRoles.js
@@ -0,0 +1,55 @@
+import React, { useEffect, useState } from 'react';
+import { useParams } from 'react-router-dom';
+import PropTypes from 'prop-types';
+import { FormattedMessage } from 'react-intl';
+
+import {
+ useUserAffiliations,
+ useUserTenantRoles,
+} from '../../../hooks';
+import RenderRoles from '../../RenderRoles';
+import { isAffiliationsEnabled } from '../../util';
+
+const propTypes = {
+ stripes: PropTypes.object.isRequired,
+ user: PropTypes.object.isRequired,
+};
+
+const UserRoles = (props) => {
+ const { stripes, user } = props;
+ const { id: userId } = useParams();
+ const [tenantId, setTenantId] = useState(stripes.okapi.tenant);
+
+ const {
+ affiliations,
+ isFetching: isAffiliationsFetching,
+ } = useUserAffiliations({ userId }, { enabled: isAffiliationsEnabled(user) });
+
+ const {
+ userRoles,
+ isFetching: isPermissionsFetching,
+ } = useUserTenantRoles({ userId, tenantId });
+
+ const isLoading = isAffiliationsFetching || isPermissionsFetching;
+
+ useEffect(() => {
+ if (!affiliations.some(({ tenantId: assigned }) => tenantId === assigned)) {
+ setTenantId(stripes.okapi.tenant);
+ }
+ }, [affiliations, stripes.okapi.tenant, tenantId]);
+
+ return (}
+ permToRead="roles.users.collection.get"
+ affiliations={affiliations}
+ selectedAffiliation={tenantId}
+ isLoading={isLoading}
+ onChangeAffiliation={setTenantId}
+ listedRoles={userRoles || []}
+ />);
+};
+
+UserRoles.propTypes = propTypes;
+
+export default UserRoles;
diff --git a/src/components/UserDetailSections/UserRoles/UserRoles.test.js b/src/components/UserDetailSections/UserRoles/UserRoles.test.js
new file mode 100644
index 000000000..f57cf7e6a
--- /dev/null
+++ b/src/components/UserDetailSections/UserRoles/UserRoles.test.js
@@ -0,0 +1,87 @@
+import userEvent from '@folio/jest-config-stripes/testing-library/user-event';
+import { screen } from '@folio/jest-config-stripes/testing-library/react';
+
+import '__mock__/matchMedia.mock';
+import renderWithRouter from 'helpers/renderWithRouter';
+import affiliations from 'fixtures/affiliations';
+import roles from 'fixtures/roles';
+import {
+ useUserAffiliations,
+ useUserTenantRoles,
+} from '../../../hooks';
+import IfConsortiumPermission from '../../IfConsortiumPermission';
+import UserRoles from './UserRoles';
+
+jest.unmock('@folio/stripes/components');
+jest.mock('../../../hooks', () => ({
+ useUserAffiliations: jest.fn(),
+ useUserTenantRoles: jest.fn(),
+}));
+jest.mock('../../IfConsortium', () => jest.fn(({ children }) => <>{children}>));
+jest.mock('../../IfConsortiumPermission', () => jest.fn());
+
+const STRIPES = {
+ config: {},
+ hasPerm: jest.fn().mockReturnValue(true),
+ okapi: {
+ tenant: 'diku',
+ },
+ user: {
+ user: {
+ consortium: {},
+ },
+ },
+};
+
+const defaultProps = {
+ accordionId: 'assignedRoles',
+ expanded: true,
+ onToggle: jest.fn(),
+ heading: User roles
,
+ permToRead: 'perms.permissions.get',
+ intl: {},
+ stripes: STRIPES,
+ user: {},
+};
+
+const renderUserRoles = (props = {}) => renderWithRouter(
+
+);
+
+describe('UserRoles component', () => {
+ beforeEach(() => {
+ useUserAffiliations.mockClear().mockReturnValue({ isFetching: false, affiliations });
+ useUserTenantRoles.mockClear().mockImplementation(({ tenantId }) => ({
+ isFetching: false,
+ userRoles: tenantId === 'diku' ? roles : [],
+ }));
+ });
+
+ it('should render user roles accordion', () => {
+ IfConsortiumPermission.mockReturnValue(null);
+ renderUserRoles();
+
+ expect(screen.getByText('ui-users.roles.userRoles')).toBeInTheDocument();
+ expect(screen.getByText('funky')).toBeInTheDocument();
+ expect(screen.getByText('chicken')).toBeInTheDocument();
+ });
+
+ // describe('Consortia', () => {
+ // it('should update roles list after selecting another affiliation', async () => {
+ // IfConsortiumPermission.mockImplementation(({ children }) => children);
+ // renderUserRoles();
+
+ // expect(screen.getByText('funky')).toBeInTheDocument();
+ // expect(screen.getByText('chicken')).toBeInTheDocument();
+
+ // await userEvent.click(screen.getByText(affiliations[1].tenantName));
+
+ // expect(screen.getByText('funky')).not.toBeInTheDocument();
+ // expect(screen.getByText('chicken')).not.toBeInTheDocument();
+ // expect(screen.getByText('ui-users.roles.empty')).toBeInTheDocument();
+ // });
+ // });
+});
diff --git a/src/components/UserDetailSections/UserRoles/index.js b/src/components/UserDetailSections/UserRoles/index.js
new file mode 100644
index 000000000..0311fe588
--- /dev/null
+++ b/src/components/UserDetailSections/UserRoles/index.js
@@ -0,0 +1 @@
+export { default } from './UserRoles';
diff --git a/src/components/UserDetailSections/index.js b/src/components/UserDetailSections/index.js
index 8daff5148..6d8bbbcec 100644
--- a/src/components/UserDetailSections/index.js
+++ b/src/components/UserDetailSections/index.js
@@ -8,5 +8,6 @@ export { default as UserRequests } from './UserRequests';
export { default as UserAccounts } from './UserAccounts';
export { default as UserAffiliations } from './UserAffiliations';
export { default as UserPermissions } from './UserPermissions';
+export { default as UserRoles } from './UserRoles';
export { default as UserServicePoints } from './UserServicePoints';
export { default as RequestPreferencesView } from './ExtendedInfo/components/RequestPreferencesView';
diff --git a/src/hooks/index.js b/src/hooks/index.js
index 8053b27d3..e14862bec 100644
--- a/src/hooks/index.js
+++ b/src/hooks/index.js
@@ -5,6 +5,7 @@ export { default as useToggle } from './useToggle';
export { default as useUserAffiliations } from './useUserAffiliations';
export { default as useUserAffiliationsMutation } from './useUserAffiliationsMutation';
export { default as useUserTenantPermissions } from './useUserTenantPermissions';
+export { default as useUserTenantRoles } from './useUserTenantRoles';
export { default as useProfilePicture } from './useProfilePicture';
export { default as useLocalizedCurrency } from './useLocalizedCurrency';
diff --git a/src/hooks/useUserTenantRoles/index.js b/src/hooks/useUserTenantRoles/index.js
new file mode 100644
index 000000000..1f6a8f059
--- /dev/null
+++ b/src/hooks/useUserTenantRoles/index.js
@@ -0,0 +1 @@
+export { default } from './useUserTenantRoles';
diff --git a/src/hooks/useUserTenantRoles/useUserTenantRoles.js b/src/hooks/useUserTenantRoles/useUserTenantRoles.js
new file mode 100644
index 000000000..ca2460ec2
--- /dev/null
+++ b/src/hooks/useUserTenantRoles/useUserTenantRoles.js
@@ -0,0 +1,84 @@
+import { useQuery } from 'react-query';
+
+import {
+ useChunkedCQLFetch,
+ useNamespace,
+ useStripes,
+ useOkapiKy,
+} from '@folio/stripes/core';
+
+/**
+ * chunkedRolesReducer
+ * reducer for useChunkedCQLFetch. Given input
+ * [
+ * { data: { roles: [1, 2, 3] } },
+ * { data: { roles: [4, 5, 6] } },
+ * ]
+ * return
+ * [1, 2, 3, 4, 5, 6]
+ *
+ * @param {Array} list of chunks, each item shaped like { data: { roles: [] }}
+ * @returns Array flattened array of role data
+ */
+export const chunkedRolesReducer = (list) => (
+ list.reduce((acc, cur) => {
+ return [...acc, ...(cur?.data?.roles ?? [])];
+ }, []));
+
+const useUserTenantRoles = (
+ { userId, tenantId },
+) => {
+ const stripes = useStripes();
+ const ky = useOkapiKy();
+ const api = ky.extend({
+ hooks: {
+ beforeRequest: [(req) => req.headers.set('X-Okapi-Tenant', tenantId)]
+ }
+ });
+ const [namespace] = useNamespace({ key: 'user-affiliation-roles' });
+
+ const searchParams = {
+ limit: stripes.config.maxUnpagedResourceCount,
+ query: `userId==${userId}`,
+ };
+
+ // retrieve roles assigned to the user to get their IDs...
+ const { data, isSuccess, refetch } = useQuery(
+ [namespace, userId, tenantId],
+ ({ signal }) => {
+ return api.get(
+ 'roles/users',
+ {
+ searchParams,
+ signal,
+ },
+ ).json();
+ },
+ {
+ enabled: Boolean(userId && tenantId),
+ }
+ );
+
+ // ... then retrieve corresponding role objects via chunked fetch
+ // since the list may be long.
+ const ids = isSuccess ? data.userRoles.map(i => i.roleId) : [];
+ const {
+ isFetching,
+ isLoading,
+ items: roles
+ } = useChunkedCQLFetch({
+ endpoint: 'roles',
+ ids,
+ queryEnabled: isSuccess,
+ reduceFunction: chunkedRolesReducer,
+ });
+
+ return {
+ userRoles: roles || [],
+ isFetching,
+ isLoading,
+ refetch
+ };
+};
+
+export default useUserTenantRoles;
diff --git a/src/routes/UserRecordContainer.js b/src/routes/UserRecordContainer.js
index 1aeb55468..163b673e5 100644
--- a/src/routes/UserRecordContainer.js
+++ b/src/routes/UserRecordContainer.js
@@ -203,7 +203,12 @@ class UserRecordContainer extends React.Component {
},
settings: {
type: 'okapi',
- path: 'users/configurations/entry',
+ path: (queryParams, pathComponents, resourceData, config, props) => {
+ if (props.stripes.hasInterface('users', '16.1')) {
+ return 'users/configurations/entry';
+ }
+ return null;
+ },
},
requestPreferences: {
type: 'okapi',
diff --git a/src/views/UserDetail/UserDetail.js b/src/views/UserDetail/UserDetail.js
index 75552266c..28bd07d33 100644
--- a/src/views/UserDetail/UserDetail.js
+++ b/src/views/UserDetail/UserDetail.js
@@ -44,6 +44,7 @@ import {
ProxyPermissions,
PatronBlock,
UserPermissions,
+ UserRoles,
UserLoans,
UserRequests,
UserAccounts,
@@ -193,6 +194,7 @@ class UserDetail extends React.Component {
requestsSection: false,
accountsSection: false,
permissionsSection: false,
+ rolesSection: false,
servicePointsSection: false,
notesAccordion: false,
customFields: false,
@@ -857,6 +859,16 @@ class UserDetail extends React.Component {
}
+ { !this.showPermissionsAccordion() &&
+
+ }
+