diff --git a/CHANGELOG.md b/CHANGELOG.md index f2298f44e..535a9ca62 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,7 +2,6 @@ ## [10.2.0] IN PROGRESS - ## [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) @@ -49,6 +48,7 @@ * Add `users-keycloak` permissions. Refs UIU-3068. * Omit permissions accordions and queries when `roles` interface is present. Refs UIU-3061, UIU-3062. * Retrieve user's central-tenant permission from `users-keycloak` endpoints when available. Refs UIU-3054. +* Show Roles assigned to users. Refs UIU-3110. ## [10.0.4](https://github.com/folio-org/ui-users/tree/v10.0.4) (2023-11-10) [Full Changelog](https://github.com/folio-org/ui-users/compare/v10.0.3...v10.0.4) 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/hooks/useUserTenantRoles/useUserTenantRoles.test.js b/src/hooks/useUserTenantRoles/useUserTenantRoles.test.js new file mode 100644 index 000000000..ac8ffe0b8 --- /dev/null +++ b/src/hooks/useUserTenantRoles/useUserTenantRoles.test.js @@ -0,0 +1,162 @@ +import { QueryClient, QueryClientProvider } from 'react-query'; +import { act, renderHook } from '@folio/jest-config-stripes/testing-library/react'; + +import { + useChunkedCQLFetch, + useOkapiKy, +} from '@folio/stripes/core'; + +import useUserTenantRoles, { chunkedRolesReducer } from './useUserTenantRoles'; + +const queryClient = new QueryClient(); +const wrapper = ({ children }) => ( + + {children} + +); + +const userRoleData = { + 'userRoles': [ + { + 'userId': 'cchase', + 'roleId': 'amigo', + }, + { + 'userId': 'cchase', + 'roleId': 'clark', + }, + ], + 'totalRecords': 2 +}; + +const roleData = { + 'roles': [ + { + 'id': 'clark', + 'name': 'Clark Griswold', + 'description': 'Father of the year', + }, + { + 'id': 'amigo', + 'name': 'Bandit', + 'description': 'A very thirsty man', + } + ], + 'totalRecords': 2, + 'resultInfo': { + 'totalRecords': 2 + } +}; + +describe('useUserTenantRoles', () => { + const mockUsersTenantRolesGet = jest.fn(() => ({ + json: () => Promise.resolve(userRoleData), + })); + const mockRolesGet = jest.fn(() => ({ + items: roleData, + isLoading: false, + })); + + beforeEach(() => { + queryClient.clear(); + mockUsersTenantRolesGet.mockClear(); + mockRolesGet.mockClear(); + useOkapiKy.mockClear().mockReturnValue({ + extend: () => ({ + get: mockUsersTenantRolesGet, + }) + }); + useChunkedCQLFetch.mockClear().mockReturnValue({ + items: roleData, + isLoading: false, + }); + }); + + it('fetches roles assigned to a user', async () => { + const { result } = renderHook(() => useUserTenantRoles({ userId: 'u', tenantId: 't' }), { wrapper }); + await act(() => !result.current.isFetching); + + expect(result.current.isLoading).toBe(false); + expect(result.current.userRoles).toEqual(roleData); + }); +}); + +describe('chunkedRolesReducer', () => { + it('assembles chunks', () => { + const list = [ + { data: { roles: [1, 2, 3] } }, + { data: { roles: [4, 5, 6] } }, + ]; + + const result = chunkedRolesReducer(list); + expect(result.length).toBe(6); + }); +}); + + + +// import { renderHook, waitFor } from '@folio/jest-config-stripes/testing-library/react'; +// import { +// QueryClient, +// QueryClientProvider, +// } from 'react-query'; + +// import { useOkapiKy, useStripes } from '@folio/stripes/core'; + +// import roles from 'fixtures/roles'; +// import useUserTenantRoles from './useUserTenantRoles'; + +// const queryClient = new QueryClient(); + +// // eslint-disable-next-line react/prop-types +// const wrapper = ({ children }) => ( +// +// {children} +// +// ); + +// const response = { +// roles, +// totalRecords: roles.length, +// }; + +// describe('useUserTenantRoles', () => { +// const getMock = jest.fn(() => ({ +// json: () => Promise.resolve(response), +// })); +// const setHeaderMock = jest.fn(); +// const kyMock = { +// extend: jest.fn(({ hooks: { beforeRequest } }) => { +// beforeRequest.forEach(handler => handler({ headers: { set: setHeaderMock } })); + +// return { +// get: getMock, +// }; +// }), +// }; + +// beforeEach(() => { +// getMock.mockClear(); + +// useStripes.mockClear().mockReturnValue({ +// okapi: {}, +// config: { +// maxUnpagedResourceCount: 1000, +// } +// }); +// useOkapiKy.mockClear().mockReturnValue(kyMock); +// }); + +// it('should fetch user roles for specified tenant', async () => { +// const options = { +// userId: 'userId', +// tenantId: 'tenantId', +// }; +// const { result } = renderHook(() => useUserTenantRoles(options), { wrapper }); + +// await waitFor(() => !result.current.isLoading); + +// expect(setHeaderMock).toHaveBeenCalledWith('X-Okapi-Tenant', options.tenantId); +// expect(getMock).toHaveBeenCalledWith('roles/users', expect.objectContaining({})); +// }); +// }); 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() && + + } + null), PatronBlock: jest.fn(() => null), UserPermissions: jest.fn(() =>
    Permissions accordion
    ), + UserRoles: jest.fn(() =>
    Roles accordion
    ), UserLoans: jest.fn(() => null), UserRequests: jest.fn(() => null), UserAccounts: jest.fn(() => null), @@ -284,14 +285,23 @@ describe('UserDetail', () => { expect(screen.getByRole('button', { name: 'ui-users.details.checkDelete' })).toBeVisible(); }); - describe('permissions', () => { - it('shows permissions accordion in legacy mode', async () => { + describe('when roles interface is absent', () => { + it('shows permissions accordion', async () => { stripes.hasInterface = () => false; renderUserDetail(stripes); expect(screen.getByText('Permissions accordion')).toBeTruthy(); }); + }); + + describe('when roles interface is present', () => { + it('shows roles accordion', async () => { + stripes.hasInterface = () => true; + renderUserDetail(stripes); + expect(screen.queryByText('Permissions accordion')).not.toBeTruthy(); + expect(screen.getByText('Roles accordion')).toBeTruthy(); + }); - it('omits permissions pane when "roles" interface is present', async () => { + it('omits permissions accordion', async () => { stripes.hasInterface = (i) => { return (i === 'roles'); }; diff --git a/test/jest/__mock__/stripesCore.mock.js b/test/jest/__mock__/stripesCore.mock.js index c4c3d4210..303efae07 100644 --- a/test/jest/__mock__/stripesCore.mock.js +++ b/test/jest/__mock__/stripesCore.mock.js @@ -147,7 +147,8 @@ jest.mock('@folio/stripes/core', () => { stripesConnect, useCallout: jest.fn(() => ({ sendCallout: jest.fn() })), useStripes: jest.fn(() => STRIPES), - useOkapiKy: jest.fn(() => {}), + useOkapiKy: jest.fn(() => ({ extend: jest.fn() })), + useChunkedCQLFetch: jest.fn(), useNamespace: jest.fn(() => ['@folio/users']), withOkapiKy: jest.fn((Component) => (props) => ), withStripes: diff --git a/test/jest/fixtures/roles.json b/test/jest/fixtures/roles.json new file mode 100644 index 000000000..4426ffc69 --- /dev/null +++ b/test/jest/fixtures/roles.json @@ -0,0 +1,12 @@ +[ + { + "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!" + } +] diff --git a/translations/ui-users/en.json b/translations/ui-users/en.json index f719545bf..38af2bba3 100644 --- a/translations/ui-users/en.json +++ b/translations/ui-users/en.json @@ -1169,5 +1169,8 @@ "patronNoticePrintJobs.email": "Email", "patronNoticePrintJobs.updated": "Updated", "patronNoticePrintJobs.created": "Created", - "patronNoticePrintJobs.errors.pdf": "'PDF generation failed" + "patronNoticePrintJobs.errors.pdf": "'PDF generation failed", + + "roles.userRoles": "Roles", + "roles.empty": "No roles found" }