diff --git a/packages/fhir-keycloak-user-management/src/components/CreateEditUser/index.tsx b/packages/fhir-keycloak-user-management/src/components/CreateEditUser/index.tsx index 6c74c5cc1..cb547bbe8 100644 --- a/packages/fhir-keycloak-user-management/src/components/CreateEditUser/index.tsx +++ b/packages/fhir-keycloak-user-management/src/components/CreateEditUser/index.tsx @@ -10,6 +10,7 @@ import { practitionerResourceType, groupResourceType, practitionerRoleResourceType, + renderExtraFields, } from '../../constants'; import { FHIRServiceClass, @@ -31,6 +32,7 @@ import { HumanName } from '@smile-cdr/fhirts/dist/FHIR-R4/classes/humanName'; import { HumanNameUseCodes } from '@opensrp/fhir-team-management'; import { Identifier } from '@smile-cdr/fhirts/dist/FHIR-R4/classes/identifier'; import { keycloakIdentifierCoding } from '@opensrp/fhir-helpers'; +import { getConfig } from '@opensrp/pkg-config'; export const getPractitioner = (baseUrl: string, userId: string) => { const serve = new FHIRServiceClass(baseUrl, practitionerResourceType); @@ -64,6 +66,23 @@ export const getPractitionerSecondaryIdentifier = (keycloakID: string): Identifi }; }; +export const nationalIdIdentifierBuilder = (nationalId: string) => { + return { + use: IdentifierUseCodes.OFFICIAL, + type: { + coding: [ + { + system: 'http://smartregister.org/codes/naitonal_id', + code: 'NationalID', + display: 'Naitonal ID', + }, + ], + text: 'National ID', + }, + value: nationalId, + }; +}; + export const createEditGroupResource = ( keycloakUserEnabled: boolean, keycloakID: string, @@ -176,6 +195,7 @@ export const createEditPractitionerRoleResource = ( }; const serve = new FHIRServiceClass(baseUrl, practitionerRoleResourceType); + return ( serve // use update (PUT) for both creating and updating practitioner resource @@ -221,12 +241,18 @@ export const practitionerUpdater = let officialIdentifier; let secondaryIdentifier; + let nationalIdIdentifier; + if (values.practitioner) { const currentIdentifiers = (values.practitioner as IPractitioner).identifier; officialIdentifier = getObjLike(currentIdentifiers, 'use', IdentifierUseCodes.OFFICIAL)[0]; secondaryIdentifier = getObjLike(currentIdentifiers, 'use', IdentifierUseCodes.SECONDARY)[0]; } + if (values.nationalId) { + nationalIdIdentifier = nationalIdIdentifierBuilder(values.nationalId); + } + if (!officialIdentifier) { officialIdentifier = { use: IdentifierUseCodes.OFFICIAL, @@ -241,7 +267,9 @@ export const practitionerUpdater = const payload: IPractitioner = { resourceType: practitionerResourceType, id: officialIdentifier.value, - identifier: [officialIdentifier, secondaryIdentifier], + identifier: nationalIdIdentifier + ? [officialIdentifier, secondaryIdentifier, nationalIdIdentifier] + : [officialIdentifier, secondaryIdentifier], active: values.enabled ?? false, name: [ { @@ -250,12 +278,24 @@ export const practitionerUpdater = given: [values.firstName, ''], }, ], - telecom: [ - { - system: 'email', - value: values.email, - }, - ], + telecom: values.phoneNumber + ? [ + { + system: 'email', + value: values.email, + }, + { + system: 'phone', + value: values.phoneNumber, + use: 'mobile', + }, + ] + : [ + { + system: 'email', + value: values.email, + }, + ], }; const serve = new FHIRServiceClass(baseUrl, practitionerResourceType); @@ -295,9 +335,13 @@ export const practitionerUpdater = values.practitionerRole?.id ) .then(() => sendSuccessNotification(practitionerRoleSuccessMessage)) - .catch(() => sendErrorNotification(practitionerRoleErrorMessage)); + .catch(() => { + return sendErrorNotification(practitionerRoleErrorMessage); + }); + }) + .catch(() => { + return sendErrorNotification(practitionerErrorMessage); }) - .catch(() => sendErrorNotification(practitionerErrorMessage)) .finally(() => { if (!isEditMode) { history.push(`${URL_USER_CREDENTIALS}/${userId}/${values.username}`); @@ -312,11 +356,13 @@ export const practitionerUpdater = * @param props - component props */ export function CreateEditUser(props: CreateEditPropTypes) { + const extraFormFields = getConfig('projectCode') === 'giz' ? renderExtraFields : []; const baseCompProps = { ...props, getPractitionerFun: getPractitioner, getPractitionerRoleFun: getPractitionerRole, postPutPractitionerFactory: practitionerUpdater, + extraFormFields: extraFormFields, }; return ; diff --git a/packages/fhir-keycloak-user-management/src/components/CreateEditUser/tests/extra-fields-user.test.tsx b/packages/fhir-keycloak-user-management/src/components/CreateEditUser/tests/extra-fields-user.test.tsx new file mode 100644 index 000000000..dbf7f6c4a --- /dev/null +++ b/packages/fhir-keycloak-user-management/src/components/CreateEditUser/tests/extra-fields-user.test.tsx @@ -0,0 +1,346 @@ +/* eslint-disable @typescript-eslint/naming-convention */ +import React from 'react'; +import { Route, Router, Switch } from 'react-router'; +import { QueryClient, QueryClientProvider } from 'react-query'; +import { + CreateEditUser, + getGroup, + createEditGroupResource, + practitionerUpdater, + getPractitioner, + getPractitionerRole, +} from '..'; +import { Provider } from 'react-redux'; +import { store } from '@opensrp/store'; +import nock from 'nock'; +import { cleanup, fireEvent, render } from '@testing-library/react'; +import { waitFor } from '@testing-library/dom'; +import { createMemoryHistory } from 'history'; +import { authenticateUser } from '@onaio/session-reducer'; +import fetch from 'jest-fetch-mock'; +import { + keycloakUser, + practitioner, + userGroup, + group, + updatedGroup, + practitionerRoleBundle, + updatedPractitionerRole, + compositionResource, + extraFieldsPractitioner, +} from './fixtures'; +import userEvent from '@testing-library/user-event'; +import * as notifications from '@opensrp/notifications'; +import { practitionerResourceType, practitionerRoleResourceType } from '../../../constants'; +import { fetchKeycloakUsers } from '@opensrp/user-management'; +import { history } from '@onaio/connected-reducer-registry'; +import { opensrpI18nInstance } from '@opensrp/i18n'; + +jest.mock('fhirclient', () => { + return jest.requireActual('fhirclient/lib/entry/browser'); +}); + +jest.mock('@opensrp/pkg-config', () => { + const actual = jest.requireActual('@opensrp/pkg-config'); + return { + ...actual, + + getConfig: () => { + return 'giz'; + }, + }; +}); + +jest.mock('@opensrp/notifications', () => ({ + __esModule: true, + ...Object.assign({}, jest.requireActual('@opensrp/notifications')), +})); + +jest.mock('uuid', () => { + const actual = jest.requireActual('uuid'); + return { + ...actual, + v4: () => 'acb9d47e-7247-448f-be93-7a193a5312da', + }; +}); + +const queryClient = new QueryClient({ + defaultOptions: { + queries: { + retry: false, + cacheTime: 0, + }, + }, +}); + +const props = { + baseUrl: 'http://test.server.org', + keycloakBaseURL: 'http://test-keycloak.server.org', + history, + location: { + hash: '', + pathname: '/users/edit', + search: '', + state: '', + }, + match: { + isExact: true, + params: { userId: keycloakUser.id }, + path: `/add/:id`, + url: `/add/${keycloakUser.id}`, + }, + keycloakUser, + extraData: {}, + fetchKeycloakUsersCreator: fetchKeycloakUsers, + getPractitionerFun: getPractitioner, + getPractitionerRoleFun: getPractitionerRole, + postPutPractitionerFactory: practitionerUpdater, +}; + +// eslint-disable-next-line @typescript-eslint/no-explicit-any +const AppWrapper = (props: any) => { + return ( + + + + + + + + + + + + + ); +}; + +afterEach(() => { + cleanup(); + nock.cleanAll(); + fetch.resetMocks(); + jest.resetAllMocks(); +}); + +beforeAll(async () => { + await opensrpI18nInstance.init(); + nock.disableNetConnect(); + store.dispatch( + authenticateUser( + true, + { + email: 'bob@example.com', + name: 'Bobbie', + username: 'RobertBaratheon', + }, + { api_token: 'hunter2', oAuth2Data: { access_token: 'sometoken', state: 'abcde' } } + ) + ); +}); + +afterAll(() => { + nock.enableNetConnect(); +}); + +test('renders correctly for edit user', async () => { + const history = createMemoryHistory(); + history.push('/add/id'); + + fetch.once(JSON.stringify(userGroup)).once(JSON.stringify(keycloakUser)).once(JSON.stringify([])); + fetch.mockResponses(JSON.stringify([])); + + nock(props.baseUrl) + .get(`/${practitionerResourceType}/_search`) + .query({ + identifier: keycloakUser.id, + }) + .reply(200, practitioner); + + nock(props.baseUrl) + .get('/PractitionerRole/_search') + .query({ + identifier: keycloakUser.id, + }) + .reply(200, practitionerRoleBundle); + + nock(props.baseUrl) + .put(`/${practitionerResourceType}/${extraFieldsPractitioner.id}`, extraFieldsPractitioner) + .reply(200, extraFieldsPractitioner); + + nock(props.baseUrl) + .put( + `/${practitionerRoleResourceType}/${practitionerRoleBundle.entry[0].resource.id}`, + updatedPractitionerRole + ) + .reply(200, {}); + + nock(props.baseUrl) + .get(`/Group/_search`) + .query({ + identifier: keycloakUser.id, + }) + .reply(200, group); + + nock(props.baseUrl) + .put('/Group/acb9d47e-7247-448f-be93-7a193a5312da', updatedGroup) + .reply(200, {}); + + nock(props.baseUrl) + .get(`/Composition/_search`) + .query({ + _getpagesoffset: '0', + _count: '20', + type: `http://snomed.info/sct|1156600005`, + _elements: 'identifier,title', + }) + .reply(200, compositionResource) + .persist(); + + const successStub = jest + .spyOn(notifications, 'sendSuccessNotification') + .mockImplementation(jest.fn); + + const errorStub = jest.spyOn(notifications, 'sendErrorNotification').mockImplementation(jest.fn); + + const { getByTestId, getByText, queryByTitle } = render( + + + + ); + + expect(getByTestId('custom-create-user-spinner')).toBeInTheDocument(); + + await waitFor(() => { + expect(getByText(/User Type/)).toBeInTheDocument(); + }); + + expect(fetch.mock.calls.map((req) => req[0])).toEqual([ + 'http://test-keycloak.server.org/groups', + 'http://test-keycloak.server.org/users/cab07278-c77b-4bc7-b154-bcbf01b7d35b', + 'http://test-keycloak.server.org/users/cab07278-c77b-4bc7-b154-bcbf01b7d35b/groups', + ]); + + // simulate first Name change + const firstNameInput = document.querySelector('input#firstName'); + // eslint-disable-next-line @typescript-eslint/no-non-null-assertion + userEvent.type(firstNameInput!, 'flotus'); + + const lastNameInput = document.querySelector('input#lastName'); + // eslint-disable-next-line @typescript-eslint/no-non-null-assertion + userEvent.type(lastNameInput!, 'plotus'); + + const nationalIdInput = document.querySelector('input#nationalId'); + // eslint-disable-next-line @typescript-eslint/no-non-null-assertion + userEvent.type(nationalIdInput!, '1234567891011121'); + + const phoneNumberInput = document.querySelector('input#phoneNumber'); + // eslint-disable-next-line @typescript-eslint/no-non-null-assertion + userEvent.type(phoneNumberInput!, '0700123456'); + + const emailInput = document.querySelector('input#email'); + // eslint-disable-next-line @typescript-eslint/no-non-null-assertion + userEvent.type(emailInput!, 'flotus@plotus.duck'); + + const usernameInput = document.querySelector('input#username'); + // eslint-disable-next-line @typescript-eslint/no-non-null-assertion + userEvent.type(usernameInput!, 'flopo'); + + // change mark as practitioner to tue + const yesMarkPractitioner = document.querySelectorAll('input[name="active"]')[0]; + userEvent.click(yesMarkPractitioner); + + const markSupervisor = document.querySelectorAll('input[name="userType"]')[1]; + userEvent.click(markSupervisor); + + const submitButton = document.querySelector('button[type="submit"]'); + + // find antd Select with id 'fhirCoreAppId' in the 'Form' component + const appIdSection = document.querySelector('[data-testid="fhirCoreAppId"]') as Element; + + // click on input. - should see the first 5 records by default + const appIdInput = appIdSection.querySelector('.ant-select-selector') as Element; + + // simulate click on select - to show dropdown items + fireEvent.mouseDown(appIdInput); + + // await waitForElementToBeRemoved(appIdSection.querySelector('.anticon-spin')); + await waitFor(() => { + const spin = appIdSection.querySelector('.anticon-spin'); + expect(spin).toBeNull(); + }); + + fireEvent.click(queryByTitle('Device configurations(cha)') as Element); + + // eslint-disable-next-line @typescript-eslint/no-non-null-assertion + fireEvent.click(submitButton!); + + // need not concern ourselves with groups, should be tested in user-management package + + await waitFor(() => { + expect(errorStub.mock.calls).toEqual([]); + expect(successStub.mock.calls).toEqual([ + ['User edited successfully'], + ['Practitioner updated successfully'], + ['User Group edited successfully'], + ['Group resource updated successfully'], + ['PractitionerRole updated successfully'], + ]); + }); + + expect( + fetch.mock.calls.map((call) => ({ + endpoint: call[0], + method: call[1]?.method, + })) + ).toEqual([ + { endpoint: 'http://test-keycloak.server.org/groups', method: 'GET' }, + { + endpoint: 'http://test-keycloak.server.org/users/cab07278-c77b-4bc7-b154-bcbf01b7d35b', + method: 'GET', + }, + { + endpoint: 'http://test-keycloak.server.org/users/cab07278-c77b-4bc7-b154-bcbf01b7d35b/groups', + method: 'GET', + }, + { + endpoint: 'http://test-keycloak.server.org/users/cab07278-c77b-4bc7-b154-bcbf01b7d35b', + method: 'PUT', + }, + ]); +}); + +test('it fetches groups', async () => { + nock(props.baseUrl) + .get(`/Group/_search`) + .query({ + identifier: keycloakUser.id, + }) + .reply(200, group); + + const fetchGroup = await getGroup(props.baseUrl, keycloakUser.id); + expect(fetchGroup).toEqual(group.entry[0].resource); +}); + +test('it creates a group resource', async () => { + const successMessage = { message: 'Successfully created' }; + + nock(props.baseUrl).put(`/Group/${updatedGroup.id}`, updatedGroup).reply(200, successMessage); + + const successStub = jest.fn(); + const errorStub = jest.fn(); + + await createEditGroupResource( + updatedGroup.active, + updatedGroup.identifier[1].value, + updatedGroup.name, + updatedGroup.member[0].entity.reference.split('/')[1], + props.baseUrl + ) + .then((resp) => successStub(resp)) + .catch(() => errorStub()); + + await waitFor(() => { + expect(errorStub).not.toHaveBeenCalled(); + expect(successStub).toHaveBeenCalledWith(successMessage); + }); +}); diff --git a/packages/fhir-keycloak-user-management/src/components/CreateEditUser/tests/fixtures.ts b/packages/fhir-keycloak-user-management/src/components/CreateEditUser/tests/fixtures.ts index f615784ce..1fa379374 100644 --- a/packages/fhir-keycloak-user-management/src/components/CreateEditUser/tests/fixtures.ts +++ b/packages/fhir-keycloak-user-management/src/components/CreateEditUser/tests/fixtures.ts @@ -188,6 +188,90 @@ export const updatedPractitioner = { telecom: [{ system: 'email', value: 'test@onatest.comflotus@plotus.duck' }], }; +export const extraFieldsPractitioner = { + resourceType: 'Practitioner', + id: 'c1d36d9a-b771-410b-959e-af2c04d132a2', + identifier: [ + { use: 'official', value: 'c1d36d9a-b771-410b-959e-af2c04d132a2' }, + { + use: 'secondary', + type: { + coding: [ + { + system: 'http://hl7.org/fhir/identifier-type', + code: 'KUID', + display: 'Keycloak user ID', + }, + ], + text: 'Keycloak user ID', + }, + value: 'cab07278-c77b-4bc7-b154-bcbf01b7d35b', + }, + { + use: 'official', + type: { + coding: [ + { + system: 'http://smartregister.org/codes/naitonal_id', + code: 'NationalID', + display: 'Naitonal ID', + }, + ], + text: 'National ID', + }, + value: '1234567891011121', + }, + ], + active: true, + name: [{ use: 'official', family: 'kenyaplotus', given: ['Demoflotus', ''] }], + telecom: [ + { system: 'email', value: 'test@onatest.comflotus@plotus.duck' }, + { system: 'phone', value: '0700123456', use: 'mobile' }, + ], +}; + +export const updatedExtraFieldsPractitioner = { + resourceType: 'Practitioner', + id: 'acb9d47e-7247-448f-be93-7a193a5312da', + identifier: [ + { use: 'official', value: 'acb9d47e-7247-448f-be93-7a193a5312da' }, + { + use: 'secondary', + type: { + coding: [ + { + system: 'http://hl7.org/fhir/identifier-type', + code: 'KUID', + display: 'Keycloak user ID', + }, + ], + text: 'Keycloak user ID', + }, + value: 'cab07278-c77b-4bc7-b154-bcbf01b7d35b', + }, + { + use: 'official', + type: { + coding: [ + { + system: 'http://smartregister.org/codes/naitonal_id', + code: 'NationalID', + display: 'Naitonal ID', + }, + ], + text: 'National ID', + }, + value: '1234567891011121', + }, + ], + active: true, + name: [{ use: 'official', family: 'plotus', given: ['flotus', ''] }], + telecom: [ + { system: 'email', value: 'flotus@plotus.duck' }, + { system: 'phone', value: '0700123456', use: 'mobile' }, + ], +}; + export const group = { resourceType: 'Bundle', id: 'f20c0276-8364-4e31-ae99-e4bcdb3813ce', diff --git a/packages/fhir-keycloak-user-management/src/components/UserList/Viewdetails/ViewDetailResources/tests/fixtures.ts b/packages/fhir-keycloak-user-management/src/components/UserList/Viewdetails/ViewDetailResources/tests/fixtures.ts index 1d543f159..d4b5bb55e 100644 --- a/packages/fhir-keycloak-user-management/src/components/UserList/Viewdetails/ViewDetailResources/tests/fixtures.ts +++ b/packages/fhir-keycloak-user-management/src/components/UserList/Viewdetails/ViewDetailResources/tests/fixtures.ts @@ -206,6 +206,33 @@ export const user1147 = { }, }; +export const user1147ExtraFields = { + id: '9f72c646-dc1e-4f24-98df-6f04373b9ec6', + createdTimestamp: 1675179889477, + username: '1147', + enabled: true, + totp: false, + emailVerified: false, + firstName: 'test1147', + lastName: '1147', + email: 'mejay2303@gmail.com', + attributes: { + fhir_core_app_id: ['giz'], + nationalId: ['1234567891011121'], + phoneNumber: ['0101345678'], + }, + disableableCredentialTypes: [], + requiredActions: [], + notBefore: 1681810919, + access: { + manageGroupMembership: true, + view: true, + mapRoles: true, + impersonate: true, + manage: true, + }, +}; + export const user1147Groups = [ { id: 'b68e2590-c2ee-4b3c-9184-c4b35a69a271', diff --git a/packages/fhir-keycloak-user-management/src/components/UserList/Viewdetails/index.tsx b/packages/fhir-keycloak-user-management/src/components/UserList/Viewdetails/index.tsx index 4ad0d0fe9..fca27ad89 100644 --- a/packages/fhir-keycloak-user-management/src/components/UserList/Viewdetails/index.tsx +++ b/packages/fhir-keycloak-user-management/src/components/UserList/Viewdetails/index.tsx @@ -26,7 +26,7 @@ import { PractitionerDetailsView } from './ViewDetailResources/PractitionerDetai import { CareTeamDetailsView } from './ViewDetailResources/CareTeamDetails'; import { OrganizationDetailsView } from './ViewDetailResources/OrganizationDetailsView'; import { PractitionerDetail } from './types'; -import { practitionerDetailsResourceType } from '../../../constants'; +import { practitionerDetailsResourceType, renderExtraFields } from '../../../constants'; import './index.css'; import { UserDeleteBtn } from '../../UserDeleteBtn'; import { KeycloakRoleDetails } from './ViewDetailResources/RoleDetailView'; @@ -81,6 +81,7 @@ export const UserDetails = (props: UserDetailProps) => { }, } ); + const practDetailsByResName: PractitionerDetail['fhir'] = practitionerDetails?.fhir ?? {}; if (userIsLoading) { @@ -100,10 +101,13 @@ export const UserDetails = (props: UserDetailProps) => { } const { id, firstName, lastName, username, email, emailVerified, enabled, attributes } = user; + const userDetails = { [t('Id')]: id, [t('First Name')]: firstName, [t('Last Name')]: lastName, + ...(attributes?.nationalId ? { [t('National ID')]: attributes.nationalId } : {}), + ...(attributes?.phoneNumber ? { [t('Phone Number')]: attributes.phoneNumber } : {}), [t('Username')]: username, [t('Email')]: email, [t('Verified')]: emailVerified ? t('True') : t('False'), @@ -174,13 +178,13 @@ export const UserDetails = (props: UserDetailProps) => { ) : ( - {attributesArray.map(([key, value]) => { - return ( + {attributesArray + .filter(([key]) => !renderExtraFields.includes(key)) + .map(([key, value]) => ( {JSON.stringify(value)} - ); - })} + ))} )} diff --git a/packages/fhir-keycloak-user-management/src/components/UserList/Viewdetails/tests/index.test.tsx b/packages/fhir-keycloak-user-management/src/components/UserList/Viewdetails/tests/index.test.tsx index 8d5f7469d..271a854a2 100644 --- a/packages/fhir-keycloak-user-management/src/components/UserList/Viewdetails/tests/index.test.tsx +++ b/packages/fhir-keycloak-user-management/src/components/UserList/Viewdetails/tests/index.test.tsx @@ -25,6 +25,7 @@ import { import { practitionerDetailsBundle, user1147, + user1147ExtraFields, user1147Groups, user1147Roles, } from '../ViewDetailResources/tests/fixtures'; @@ -308,3 +309,166 @@ test('Edit button works correctly', async () => { expect(history.location.pathname).toEqual('/admin/users/edit/userId'); }); + +test('Renders extra user fields correctly', async () => { + const history = createMemoryHistory(); + history.push(`${URL_USER}/${userId}`); + + const successMock = jest + .spyOn(notifications, 'sendSuccessNotification') + .mockImplementation(() => { + return; + }); + + nock(props.fhirBaseURL) + .get(`/${practitionerDetailsResourceType}/_search`) + .query({ 'keycloak-uuid': userId }) + .reply(200, practitionerDetailsBundle); + + nock(props.keycloakBaseURL) + .get(`${KEYCLOAK_URL_USERS}/${userId}`) + .reply(200, user1147ExtraFields); + + nock(props.keycloakBaseURL) + .get(`${KEYCLOAK_URL_USERS}/${userId}${KEYCLOAK_URL_USER_GROUPS}`) + .reply(200, user1147Groups); + + render( + + + + ); + + // this only await the first call to get the users. + await waitForElementToBeRemoved(document.querySelector('.ant-spin')); + + expect(screen.queryByTitle(/View details/i)).toBeInTheDocument(); + + // second page header details. + const userProfile = screen.getByTestId('user-profile'); + const textContent = userProfile.textContent; + expect(textContent).toEqual( + '1147EnabledDeleteEditId9f72c646-dc1e-4f24-98df-6f04373b9ec6First Nametest1147Last Name1147National ID1234567891011121Phone Number0101345678Username1147Emailmejay2303@gmail.comVerifiedFalseAttributesfhir_core_app_id["giz"]' + ); + + // have a look at the tabs + + // start with group + const groupTab = screen.getByText('User groups'); + fireEvent.click(groupTab); + + await waitForElementToBeRemoved(document.querySelector('.ant-spin')); + + // check table has correct number of rows. and try removing user from one group + let detailsTabSection = document.querySelector('.details-tab'); + const groupsTable = detailsTabSection?.querySelector('table'); + + const tableData = [...(groupsTable?.querySelectorAll('tr') ?? [])].map((tr) => tr.textContent); + expect(tableData).toEqual(['NamePathActions', 'SuperUser/SuperUserLeave']); + + const leaveBtn = screen.getByText('Leave'); + expect(leaveBtn).toMatchInlineSnapshot(` + + Leave + + `); + + nock(props.keycloakBaseURL) + .delete(`${KEYCLOAK_URL_USERS}/${userId}${KEYCLOAK_URL_USER_GROUPS}/${user1147Groups[0].id}`) + .reply(200, user1147Groups); + + fireEvent.click(leaveBtn); + + await waitFor(() => { + expect(successMock).toHaveBeenCalledWith( + 'User has been successfully removed from the keycloak group' + ); + }); + + // go to practitioners + const practTab = screen.getByText('Practitioners'); + fireEvent.click(practTab); + + // Check that practitioner-details has finished loading. + await waitFor(() => { + expect(screen.getByText('3a801d6e-7bd3-4a5f-bc9c-64758fbb3dad')).toBeInTheDocument(); + }); + + // practitioner records + detailsTabSection = document.querySelector('div.ant-tabs-tabpane-active'); + const practitionerTable = detailsTabSection?.querySelector('table'); + + const practitionerData = [...(practitionerTable?.querySelectorAll('tr') ?? [])].map( + (tr) => tr.textContent + ); + expect(practitionerData).toEqual([ + 'IdNameActiveUser TypePractitioner Role Coding', + '3a801d6e-7bd3-4a5f-bc9c-64758fbb3dadtest1147 1147ActivepractitionerAssigned practitioner(http://snomed.info/sct|405623001), ', + ]); + + // go to roles + nock(props.keycloakBaseURL) + .get(`${KEYCLOAK_URL_USERS}/${userId}/${keycloakRoleMappingsEndpoint}`) + .reply(200, user1147Roles); + + const rolesTab = screen.getByText('User roles'); + fireEvent.click(rolesTab); + + // Check that practitioner-details has finished loading. + await waitFor(() => { + expect(screen.getByText('GET_LOCATION')).toBeInTheDocument(); + }); + + // practitioner records + detailsTabSection = document.querySelector('div.ant-tabs-tabpane-active'); + const realmRolesTable = detailsTabSection?.querySelectorAll('table')[0]; + const clientRolesTable = detailsTabSection?.querySelectorAll('table')[1]; + const realmRolesData = [...(realmRolesTable?.querySelectorAll('tr') ?? [])].map( + (tr) => tr.textContent + ); + const clientRolesData = [...(clientRolesTable?.querySelectorAll('tr') ?? [])].map( + (tr) => tr.textContent + ); + expect(realmRolesData).toEqual([ + 'NameDescription', + 'POST_LOCATION', + 'GET_LOCATION', + 'offline_access${role_offline-access}', + ]); + + expect(clientRolesData).toEqual([ + 'ClientNameDescription', + 'realm-managementmanage-realm${role_manage-realm}', + 'realm-managementmanage-users${role_manage-users}', + 'accountmanage-account${role_manage-account}', + ]); + + // go to careTeams + const careTeamsTab = screen.getByText('CareTeams'); + fireEvent.click(careTeamsTab); + + // practitioner records + detailsTabSection = document.querySelector('div.ant-tabs-tabpane-active'); + const careTeamsTable = detailsTabSection?.querySelector('table'); + + const careTeamsData = [...(careTeamsTable?.querySelectorAll('tr') ?? [])].map( + (tr) => tr.textContent + ); + expect(careTeamsData).toEqual(['IdNameStatusCategory', 'No data']); + + // go to organization + const organizationsTab = screen.getByText('Organizations'); + fireEvent.click(organizationsTab); + + // practitioner records + detailsTabSection = document.querySelector('div.ant-tabs-tabpane-active'); + const organizationsTable = detailsTabSection?.querySelector('table'); + + const organizationsData = [...(organizationsTable?.querySelectorAll('tr') ?? [])].map( + (tr) => tr.textContent + ); + expect(organizationsData).toEqual([ + 'IdNameActiveType', + '0d7ae048-9b84-4f0c-ba37-8d6c0b97dc84e2e-corporationActive(http://terminology.hl7.org/CodeSystem/organization-type|team), ', + ]); +}); diff --git a/packages/fhir-keycloak-user-management/src/constants.ts b/packages/fhir-keycloak-user-management/src/constants.ts index f9c1abfc0..a7cdd3594 100644 --- a/packages/fhir-keycloak-user-management/src/constants.ts +++ b/packages/fhir-keycloak-user-management/src/constants.ts @@ -15,3 +15,9 @@ export const keycloakMembersEndpoint = 'members'; // router urls export const USER_DETAILS_URL = `${URL_USER}/details`; + +// form field names +export const NATIONAL_ID_FORM_FIELD = 'nationalId'; +export const PHONE_NUMBER_FORM_FIELD = 'phoneNumber'; + +export const renderExtraFields = [NATIONAL_ID_FORM_FIELD, PHONE_NUMBER_FORM_FIELD]; diff --git a/packages/i18n/src/init.tsx b/packages/i18n/src/init.tsx index 6a09ceebc..3b86180df 100644 --- a/packages/i18n/src/init.tsx +++ b/packages/i18n/src/init.tsx @@ -25,7 +25,7 @@ const customLanguageDetectorName = 'customLanguageDetector'; const newInstance = i18next.createInstance(); const languageCode = getConfig('languageCode'); -const projectCode = getConfig('projectCode'); +const projectCode = getConfig('projectCode') === 'giz' ? 'core' : getConfig('projectCode'); const fallbackLng = `en-core`; const configuredLng = `${languageCode}-${projectCode}`; diff --git a/packages/keycloak-user-management/src/components/CreateEditUser/index.tsx b/packages/keycloak-user-management/src/components/CreateEditUser/index.tsx index 304172298..0becc1939 100644 --- a/packages/keycloak-user-management/src/components/CreateEditUser/index.tsx +++ b/packages/keycloak-user-management/src/components/CreateEditUser/index.tsx @@ -51,6 +51,7 @@ export interface CreateEditUserProps { getPractitionerFun: (baseUrl: string, userId: string) => Promise; getPractitionerRoleFun?: (baseUrl: string, userId: string) => Promise; postPutPractitionerFactory: UserFormProps['practitionerUpdaterFactory']; + extraFormFields: string[]; } const getOpenSrpPractitioner = (baseUrl: string, userId: string) => { @@ -94,6 +95,7 @@ const CreateEditUser: React.FC = (props: CreateEditPropType getPractitionerFun, postPutPractitionerFactory, getPractitionerRoleFun, + extraFormFields, } = props; const userId = props.match.params[ROUTE_PARAM_USER_ID]; @@ -214,6 +216,7 @@ const CreateEditUser: React.FC = (props: CreateEditPropType renderFields={userFormRenderFields} practitionerUpdaterFactory={postPutPractitionerFactory} isFHIRInstance={!!getPractitionerRoleFun} + extraFormFields={extraFormFields} /> diff --git a/packages/keycloak-user-management/src/components/forms/UserForm/index.tsx b/packages/keycloak-user-management/src/components/forms/UserForm/index.tsx index b38c3b2bc..93c3b1d58 100644 --- a/packages/keycloak-user-management/src/components/forms/UserForm/index.tsx +++ b/packages/keycloak-user-management/src/components/forms/UserForm/index.tsx @@ -21,7 +21,13 @@ import { } from './types'; import { SelectProps } from 'antd/lib/select'; import { useTranslation } from '../../../mls'; -import { compositionResourceType, PRACTITIONER, SUPERVISOR } from '../../../constants'; +import { + compositionResourceType, + NATIONAL_ID_FORM_FIELD, + PHONE_NUMBER_FORM_FIELD, + PRACTITIONER, + SUPERVISOR, +} from '../../../constants'; import { PaginatedAsyncSelect } from '@opensrp/react-utils'; import { IComposition } from '@smile-cdr/fhirts/dist/FHIR-R4/interfaces/IComposition'; @@ -36,8 +42,10 @@ const UserForm: FC = (props: UserFormProps) => { renderFields, hiddenFields, isFHIRInstance, + extraFormFields, } = props; const shouldRender = (fieldName: FormFieldsKey) => !!renderFields?.includes(fieldName); + const isHidden = (fieldName: FormFieldsKey) => !!hiddenFields?.includes(fieldName); const { t } = useTranslation(); @@ -155,6 +163,38 @@ const UserForm: FC = (props: UserFormProps) => { > + {extraFormFields.includes(NATIONAL_ID_FORM_FIELD) && ( + + + + )} + {extraFormFields.includes(PHONE_NUMBER_FORM_FIELD) && ( + + + + )} @@ -276,6 +316,8 @@ export const defaultUserFormInitialValues: FormFields = { firstName: '', id: '', lastName: '', + nationalId: '', + phoneNumber: '', username: '', active: true, userType: 'practitioner', diff --git a/packages/keycloak-user-management/src/components/forms/UserForm/types.ts b/packages/keycloak-user-management/src/components/forms/UserForm/types.ts index be0e0d370..1d35e460f 100644 --- a/packages/keycloak-user-management/src/components/forms/UserForm/types.ts +++ b/packages/keycloak-user-management/src/components/forms/UserForm/types.ts @@ -6,7 +6,17 @@ import { IPractitionerRole } from '@smile-cdr/fhirts/dist/FHIR-R4/interfaces/IPr import { PRACTITIONER, SUPERVISOR } from '../../../constants'; export interface FormFields - extends Pick { + extends Pick< + KeycloakUser, + | 'id' + | 'username' + | 'firstName' + | 'lastName' + | 'nationalId' + | 'phoneNumber' + | 'email' + | 'enabled' + > { active?: boolean; userType?: typeof PRACTITIONER | typeof SUPERVISOR; userGroups?: string[]; @@ -36,6 +46,7 @@ export interface UserFormProps { renderFields?: FormFieldsKey[]; practitionerUpdaterFactory: PractitionerUpdaterFactory; isFHIRInstance: boolean; + extraFormFields: string[]; } /** descibes antd select component options */ diff --git a/packages/keycloak-user-management/src/components/forms/UserForm/utils.tsx b/packages/keycloak-user-management/src/components/forms/UserForm/utils.tsx index 2d5f9fb78..de2811be1 100644 --- a/packages/keycloak-user-management/src/components/forms/UserForm/utils.tsx +++ b/packages/keycloak-user-management/src/components/forms/UserForm/utils.tsx @@ -99,6 +99,7 @@ const createEditKeycloakUser = async ( `${KEYCLOAK_URL_USERS}/${keycloakUserPayload.id}`, keycloakBaseURL ); + return serve .update(keycloakUserPayload) .then(() => { @@ -252,7 +253,12 @@ export const getFormValues = ( return defaultUserFormInitialValues; } const { id, username, firstName, lastName, email, enabled } = keycloakUser; - const { contact: contacts, fhir_core_app_id: fhirCoreAppId } = keycloakUser.attributes ?? {}; + const { + contact: contacts, + fhir_core_app_id: fhirCoreAppId, + nationalId = '', + phoneNumber = '', + } = keycloakUser.attributes ?? {}; const { active } = practitioner ?? {}; let userType: FormFields['userType'] = 'practitioner'; @@ -274,6 +280,8 @@ export const getFormValues = ( id, firstName, lastName, + nationalId, + phoneNumber, email, username, enabled, @@ -298,10 +306,23 @@ export const getUserAndGroupsPayload = (values: FormFields) => { const isEditMode = !!values.id; // possibility of creating a practitioner for an existing user if one was not created before - const { id, username, firstName, lastName, email, enabled, contact, fhirCoreAppId } = values; + const { + id, + username, + firstName, + lastName, + nationalId, + phoneNumber, + email, + enabled, + contact, + fhirCoreAppId, + } = values; const preUserAttributes = { ...(contact ? { contact: [contact] } : {}), ...(fhirCoreAppId ? { fhir_core_app_id: [fhirCoreAppId] } : {}), + ...(nationalId ? { nationalId: [nationalId] } : {}), + ...(phoneNumber ? { phoneNumber: [phoneNumber] } : {}), }; const cleanedAttributes = pickBy( @@ -365,6 +386,7 @@ export const postPutPractitioner = // otherwise follow the practitioner's activation field const practitionerActive = enabled === false ? false : active === undefined ? false : active; const practObj = values.practitioner as Practitioner | undefined; + if (practObj?.identifier) { practitioner = { ...practObj, @@ -375,7 +397,7 @@ export const postPutPractitioner = }; } - const practitionerIsEditMode = !!values.practitioner?.identifier; + const practitionerIsEditMode = !!practObj?.identifier; return createOrEditPractitioners(baseUrl, practitioner, practitionerIsEditMode, t); }; diff --git a/packages/keycloak-user-management/src/constants.ts b/packages/keycloak-user-management/src/constants.ts index 655cb741b..f2f517279 100644 --- a/packages/keycloak-user-management/src/constants.ts +++ b/packages/keycloak-user-management/src/constants.ts @@ -44,3 +44,7 @@ export const SUPERVISOR_USER_TYPE_CODE = '236321002'; export const PRACTITIONER_USER_TYPE_CODE = '405623001'; export const SNOMED_CODEABLE_SYSTEM = 'http://snomed.info/sct'; export const DEVICE_SETTING_CODEABLE_CODE = '1156600005'; + +// Form field name +export const NATIONAL_ID_FORM_FIELD = 'nationalId'; +export const PHONE_NUMBER_FORM_FIELD = 'phoneNumber'; diff --git a/packages/keycloak-user-management/src/ducks/user.ts b/packages/keycloak-user-management/src/ducks/user.ts index c0d5b2d86..4c0bf4cd4 100644 --- a/packages/keycloak-user-management/src/ducks/user.ts +++ b/packages/keycloak-user-management/src/ducks/user.ts @@ -41,6 +41,8 @@ export interface UserAttributes { // while these may be adhoc and arbitrary it carries with it serious back and cross compatibility issues, these should not be modified lightly contact?: string[]; fhir_core_app_id?: string[]; + nationalId?: string; + phoneNumber?: string; } /** Interface for user json object */ @@ -60,6 +62,8 @@ export interface KeycloakUser { firstName: string; id: string; lastName: string; + nationalId?: string; + phoneNumber?: string; notBefore?: number; requiredActions?: string[]; totp?: boolean; diff --git a/packages/pkg-config/src/configStore/index.ts b/packages/pkg-config/src/configStore/index.ts index 4b3470e88..44f909fb9 100644 --- a/packages/pkg-config/src/configStore/index.ts +++ b/packages/pkg-config/src/configStore/index.ts @@ -8,8 +8,14 @@ export const supportedLanguageCodes = ['en', 'sw', 'fr', 'ar', 'th', 'vi'] as co export const eusmProjectCode = 'eusm' as const; export const coreProjectCode = 'core' as const; export const echisProjectCode = 'echis' as const; - -export const supportedProjectCode = [eusmProjectCode, coreProjectCode, echisProjectCode] as const; +export const gizProjectCode = 'giz' as const; + +export const supportedProjectCode = [ + eusmProjectCode, + coreProjectCode, + echisProjectCode, + gizProjectCode, +] as const; export const supportedRbacStrategies = ['keycloak'] as const; export type LanguageCode = typeof supportedLanguageCodes[number];