diff --git a/CHANGELOG.md b/CHANGELOG.md index c2c2e7cf4..b015184a6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -16,6 +16,7 @@ * Create new permission 'Users: Can view profile pictures'. Refs UIU-3018. * Format currency values as currencies, not numbers. Refs UIU-2026. * Show country name in user address instead of country id. Refs UIU-2976. +* UserInformation in UserDetails to display profile picture. Refs UIU-3011. ## [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/icons/ProfilePicThumbnail.png b/icons/ProfilePicThumbnail.png new file mode 100644 index 000000000..82cc3094b Binary files /dev/null and b/icons/ProfilePicThumbnail.png differ diff --git a/src/components/UserDetailSections/UserInfo/UserInfo.css b/src/components/UserDetailSections/UserInfo/UserInfo.css index 569a1e98c..f87a4c591 100644 --- a/src/components/UserDetailSections/UserInfo/UserInfo.css +++ b/src/components/UserDetailSections/UserInfo/UserInfo.css @@ -27,4 +27,5 @@ .profilePlaceholder { width: 100px; height: 100px; + object-fit: scale-down; } diff --git a/src/components/UserDetailSections/UserInfo/UserInfo.js b/src/components/UserDetailSections/UserInfo/UserInfo.js index ff64e8174..5216d7a8d 100644 --- a/src/components/UserDetailSections/UserInfo/UserInfo.js +++ b/src/components/UserDetailSections/UserInfo/UserInfo.js @@ -1,7 +1,7 @@ import { get } from 'lodash'; import React from 'react'; import PropTypes from 'prop-types'; -import { FormattedMessage } from 'react-intl'; +import { FormattedMessage, useIntl } from 'react-intl'; import { Row, Col, @@ -13,137 +13,152 @@ import { } from '@folio/stripes/components'; import { ViewMetaData } from '@folio/stripes/smart-components'; +import { useStripes } from '@folio/stripes/core'; import css from './UserInfo.css'; -import appIcon from '../../../../icons/app.png'; import { USER_TYPE_FIELD } from '../../../constants'; +import ProfilePicThumbnail from '../../../../icons/ProfilePicThumbnail.png'; -class UserInfo extends React.Component { - static propTypes = { - expanded: PropTypes.bool, - stripes: PropTypes.object.isRequired, - onToggle: PropTypes.func, - accordionId: PropTypes.string.isRequired, - user: PropTypes.object.isRequired, - patronGroup: PropTypes.object.isRequired, - settings: PropTypes.arrayOf(PropTypes.object).isRequired, - }; - - constructor(props) { - super(props); +import { useProfilePicture } from './hooks'; - this.cViewMetaData = props.stripes.connect(ViewMetaData); - } - - render() { - const { - user, - patronGroup, - settings, - expanded, - accordionId, - onToggle, - } = this.props; - const userStatus = (user?.active ? - : - ); - const hasProfilePicture = (settings.length && settings[0].value === 'true'); +const UserInfo = (props) => { + const { + user, + patronGroup, + settings, + expanded, + accordionId, + onToggle + } = props; + const stripes = useStripes(); + const intl = useIntl(); + const userStatus = (user?.active ? + : + ); + const hasProfilePicture = Boolean(user?.personal?.profilePictureLink); + const profilePicturesEnabled = Boolean(settings.length) && settings[0].enabled; + const hasViewProfilePicPerm = stripes.hasPerm('ui-users.profilepictures.view'); + const { isFetching, isLoading, profilePictureData } = useProfilePicture({ profilePictureId: user?.personal?.profilePictureLink }); + const renderProfilePic = () => { + const imgSrc = isLoading || isFetching || !hasProfilePicture ? ProfilePicThumbnail : 'data:;base64,' + profilePictureData; return ( - - - )} - > - - - - - - - - - - } - value={get(user, ['personal', 'lastName'], '')} - /> - - - } - value={get(user, ['personal', 'firstName'], '')} - /> - - - } - value={get(user, ['personal', 'middleName'], '')} - /> - - - } - value={get(user, ['personal', 'preferredFirstName']) || } - /> - - + {intl.formatMessage({ + ); + }; + + return ( + + + )} + > + + + + + + + + + + } + value={get(user, ['personal', 'lastName'], '')} + /> + + + } + value={get(user, ['personal', 'firstName'], '')} + /> + + + } + value={get(user, ['personal', 'middleName'], '')} + /> + + + } + value={get(user, ['personal', 'preferredFirstName']) || } + /> + + - - - } - value={patronGroup.group} - /> - - - } - value={userStatus} - /> - - - } - value={user.expirationDate ? : '-'} - /> - - - } - value={get(user, ['barcode'], '')} - /> - - - - - } - value={get(user, [USER_TYPE_FIELD], '')} - /> - - - + + + } + value={patronGroup.group} + /> + + + } + value={userStatus} + /> + + + } + value={user.expirationDate ? : '-'} + /> + + + } + value={get(user, ['barcode'], '')} + /> + + + + + } + value={get(user, [USER_TYPE_FIELD], '')} + /> + + + - {hasProfilePicture === true && + { + profilePicturesEnabled && + hasViewProfilePicPerm && - presentation + } + value={renderProfilePic()} + /> - } - - - ); - } -} + } + + + ); +}; + +UserInfo.propTypes = { + expanded: PropTypes.bool, + onToggle: PropTypes.func, + accordionId: PropTypes.string.isRequired, + user: PropTypes.object.isRequired, + patronGroup: PropTypes.object.isRequired, + settings: PropTypes.arrayOf(PropTypes.object).isRequired, +}; export default UserInfo; diff --git a/src/components/UserDetailSections/UserInfo/UserInfo.test.js b/src/components/UserDetailSections/UserInfo/UserInfo.test.js index 13a40430f..712a63fcc 100644 --- a/src/components/UserDetailSections/UserInfo/UserInfo.test.js +++ b/src/components/UserDetailSections/UserInfo/UserInfo.test.js @@ -3,9 +3,16 @@ import '__mock__/stripesComponents.mock'; import renderWithRouter from 'helpers/renderWithRouter'; import UserInfo from './UserInfo'; +import { useProfilePicture } from './hooks'; + +import profilePicData from '../../../../test/jest/fixtures/profilePicture'; const toggleMock = jest.fn(); +jest.mock('./hooks', () => ({ + useProfilePicture: jest.fn(), +})); + const renderUserInfo = (props) => renderWithRouter(); const props = { @@ -38,6 +45,9 @@ const props = { }; describe('Render userInfo component', () => { + beforeEach(() => { + useProfilePicture.mockClear().mockReturnValue(profilePicData.profile_picture_blob); + }); describe('Check if user data are shown', () => { it('Active Users', () => { renderUserInfo(props); diff --git a/src/components/UserDetailSections/UserInfo/hooks/index.js b/src/components/UserDetailSections/UserInfo/hooks/index.js new file mode 100644 index 000000000..e2cdaa9b8 --- /dev/null +++ b/src/components/UserDetailSections/UserInfo/hooks/index.js @@ -0,0 +1 @@ +export { default as useProfilePicture } from './useProfilePicture'; diff --git a/src/components/UserDetailSections/UserInfo/hooks/useProfilePicture/index.js b/src/components/UserDetailSections/UserInfo/hooks/useProfilePicture/index.js new file mode 100644 index 000000000..9e023fdae --- /dev/null +++ b/src/components/UserDetailSections/UserInfo/hooks/useProfilePicture/index.js @@ -0,0 +1 @@ +export { default } from './useProfilePicture'; diff --git a/src/components/UserDetailSections/UserInfo/hooks/useProfilePicture/useProfilePicture.js b/src/components/UserDetailSections/UserInfo/hooks/useProfilePicture/useProfilePicture.js new file mode 100644 index 000000000..26085782f --- /dev/null +++ b/src/components/UserDetailSections/UserInfo/hooks/useProfilePicture/useProfilePicture.js @@ -0,0 +1,35 @@ +import { useQuery } from 'react-query'; + +import { + useNamespace, + useOkapiKy, +} from '@folio/stripes/core'; + +import { PROFILE_PIC_API } from '../../../../../constants'; + +const useProfilePicture = ({ profilePictureId }, options = {}) => { + const ky = useOkapiKy(); + const [namespace] = useNamespace({ key: 'get-profile-picture-of-a-user' }); + const DEFAULT_DATA = {}; + const { + isFetching, + isLoading, + data = DEFAULT_DATA, + } = useQuery( + [namespace, profilePictureId], + () => { + return ky.get(`${PROFILE_PIC_API}/${profilePictureId}`).json(); + }, + { + enabled: Boolean(profilePictureId), + ...options, + } + ); + + return ({ + isLoading, + isFetching, + profilePictureData: data?.profile_picture_blob, + }); +}; +export default useProfilePicture; diff --git a/src/components/UserDetailSections/UserInfo/hooks/useProfilePicture/useProfilePicture.test.js b/src/components/UserDetailSections/UserInfo/hooks/useProfilePicture/useProfilePicture.test.js new file mode 100644 index 000000000..5c2c2945e --- /dev/null +++ b/src/components/UserDetailSections/UserInfo/hooks/useProfilePicture/useProfilePicture.test.js @@ -0,0 +1,61 @@ +import profilePicture from 'fixtures/profilePicture'; +import { renderHook, waitFor } from '@folio/jest-config-stripes/testing-library/react'; +import { + QueryClient, + QueryClientProvider, +} from 'react-query'; + +import { useOkapiKy } from '@folio/stripes/core'; + +import useProfilePicture from './useProfilePicture'; + +jest.mock('@folio/stripes/core', () => ({ + ...jest.requireActual('@folio/stripes/core'), + useNamespace: jest.fn(() => ['test']), + useOkapiKy: jest.fn(), +})); + +const queryClient = new QueryClient(); + +// eslint-disable-next-line react/prop-types +const wrapper = ({ children }) => ( + + {children} + +); + +describe('useProfilePicture', () => { + beforeEach(() => { + useOkapiKy.mockClear().mockReturnValue({ + get: () => ({ json: () => Promise.resolve(profilePicture) }), + }); + }); + + it('should return null', async () => { + const { result } = renderHook(() => useProfilePicture({ + profilePictureId: undefined, + }), { wrapper }); + + await waitFor(() => !result.current.isLoading); + + expect(result.current).toEqual(expect.objectContaining({ + 'isFetching': false, + 'isLoading': false, + 'profilePictureData': undefined, + })); + }); + + it('should return profile picture data', async () => { + const { result } = renderHook(() => useProfilePicture({ + profilePictureId: profilePicture.id, + }, {}), { wrapper }); + + await waitFor(() => expect(result.current.isFetching).toBeFalsy()); + + expect(result.current).toEqual(expect.objectContaining({ + 'isFetching': false, + 'isLoading': false, + 'profilePictureData': profilePicture.profile_picture_blob + })); + }); +}); diff --git a/src/constants.js b/src/constants.js index 314df3230..cd11a0b5a 100644 --- a/src/constants.js +++ b/src/constants.js @@ -335,6 +335,7 @@ export const CONFIGURATIONS_ENTRIES_API = `${CONFIGURATIONS_API}/entries`; export const CONSORTIA_API = 'consortia'; export const CONSORTIA_TENANTS_API = 'tenants'; export const CONSORTIA_USER_TENANTS_API = 'user-tenants'; +export const PROFILE_PIC_API = 'users/profile-picture'; export const RECORD_SOURCE = { CONSORTIUM: 'consortium', diff --git a/src/routes/UserRecordContainer.js b/src/routes/UserRecordContainer.js index bda59bec3..aaee34aef 100644 --- a/src/routes/UserRecordContainer.js +++ b/src/routes/UserRecordContainer.js @@ -158,8 +158,7 @@ class UserRecordContainer extends React.Component { }, settings: { type: 'okapi', - records: 'configs', - path: 'configurations/entries?query=(module==USERS and configName==profile_pictures)', + path: 'users/configurations/entry', }, requestPreferences: { type: 'okapi', diff --git a/test/jest/fixtures/profilePicture.json b/test/jest/fixtures/profilePicture.json new file mode 100644 index 000000000..d961130ed --- /dev/null +++ b/test/jest/fixtures/profilePicture.json @@ -0,0 +1,4 @@ +{ + "id": "1fe3a5f8-64d9-4913-a24a-ed169db923dd", + "profile_picture_blob": "base64-profile-pic-data" +} \ No newline at end of file diff --git a/translations/ui-users/en.json b/translations/ui-users/en.json index 303194116..80cc88b26 100644 --- a/translations/ui-users/en.json +++ b/translations/ui-users/en.json @@ -308,6 +308,7 @@ "information.lastName": "Last name", "information.firstName": "First name", "information.middleName": "Middle name", + "information.profilePicture": "Profile picture", "information.barcode": "Barcode", "information.selectUserType": "Select user type", "information.userType": "User type",