Skip to content

Commit

Permalink
Update PractitionerAssigment Behavior to include new strategy (#1217)
Browse files Browse the repository at this point in the history
* Add a pkg-config env on practiitoner assignment strategy

* Update team assignment to support new practitioner assignment

* Update envs in app and docs

* Update documentation

* Update strategy value in evs

* Change practitioner assignment strategy enum value

* Fix typo
  • Loading branch information
peterMuriuki authored Jun 30, 2023
1 parent ab58a5a commit fbeb24b
Show file tree
Hide file tree
Showing 12 changed files with 132 additions and 16 deletions.
1 change: 1 addition & 0 deletions app/.env.sample
Original file line number Diff line number Diff line change
Expand Up @@ -66,3 +66,4 @@ REACT_APP_ENABLE_REPORTS=false
REACT_APP_ENABLE_FHIR_GROUP=true
REACT_APP_ENABLE_FHIR_COMMODITY=true
REACT_APP_COMMODITIES_LIST_RESOURCE_ID="uuid"
REACT_APP_PRACTITIONER_TO_ORG_ASSIGNMENT_STRATEGY=ONE_TO_MANY
2 changes: 2 additions & 0 deletions app/src/configs/dispatchConfig.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ import {
PROJECT_CODE,
FHIR_API_BASE_URL,
DEFAULTS_TABLE_PAGE_SIZE,
PRACTITIONER_TO_ORG_ASSIGNMENT_STRATEGY,
} from './env';
import { URL_BACKEND_LOGIN, URL_REACT_LOGIN } from '../constants';

Expand All @@ -29,6 +30,7 @@ const configObject: ConfigState = {
opensrpBaseURL: OPENSRP_API_BASE_URL,
fhirBaseURL: FHIR_API_BASE_URL,
defaultTablesPageSize: DEFAULTS_TABLE_PAGE_SIZE,
practToOrgAssignmentStrategy: PRACTITIONER_TO_ORG_ASSIGNMENT_STRATEGY,
};

setAllConfigs(configObject);
5 changes: 5 additions & 0 deletions app/src/configs/env.ts
Original file line number Diff line number Diff line change
Expand Up @@ -232,3 +232,8 @@ export const ENABLE_REPORTS = setEnv('REACT_APP_ENABLE_REPORTS', 'false') === 't
export const ENABLE_FHIR_COMMODITY = setEnv('REACT_APP_ENABLE_FHIR_COMMODITY', 'false') === 'true';

export const COMMODITIES_LIST_RESOURCE_ID = setEnv('REACT_APP_COMMODITIES_LIST_RESOURCE_ID', '');

export const PRACTITIONER_TO_ORG_ASSIGNMENT_STRATEGY = setEnv(
'REACT_APP_PRACTITIONER_TO_ORG_ASSIGNMENT_STRATEGY',
undefined
);
6 changes: 6 additions & 0 deletions docs/env.md
Original file line number Diff line number Diff line change
Expand Up @@ -343,6 +343,12 @@ Below is a list of currently supported environment variables:
- default: 'false'

- **REACT_APP_COMMODITIES_LIST_RESOURCE_ID**

- scopes down what commodities are shown on the fhir commidities module.
- **Conditionally Optional**(`string`) - required when `REACT_APP_ENABLE_FHIR_COMMODITY` is set to `"true"`
- default: ''

- **REACT_APP_PRACTITIONER_TO_ORG_ASSIGNMENT_STRATEGY=ONE_TO_ONE**
- define the assignment relationship between practitioners to organizations for fhir deployments. This strategy only applies unilaterally, i.e. form practitioner to organization. It does not imply any relations in the opposite direction i.e. from organization to practitioner.
- **Optional**(`ONE_TO_ONE, ONE_TO_MANY`)
- default: `ONE_TO_MANY`
1 change: 1 addition & 0 deletions docs/fhir-web-docker-deployment.md
Original file line number Diff line number Diff line change
Expand Up @@ -176,6 +176,7 @@ We use different technologies to deploy OpenSRP FHIR Web. This documentation wil
REACT_APP_OPENSRP_LOGOUT_URL: 'null',
REACT_APP_OPENSRP_ROLES:
'{"USERS":"ROLE_EDIT_KEYCLOAK_USERS","LOCATIONS":"ROLE_VIEW_KEYCLOAK_USERS","TEAMS":"ROLE_VIEW_KEYCLOAK_USERS","CARE_TEAM":"ROLE_VIEW_KEYCLOAK_USERS","QUEST":"ROLE_VIEW_KEYCLOAK_USERS","HEALTHCARE_SERVICE":"ROLE_VIEW_KEYCLOAK_USERS","GROUP":"ROLE_VIEW_KEYCLOAK_USERS","COMMODITY":"ROLE_VIEW_KEYCLOAK_USERS",}',
REACT_APP_PRACTITIONER_TO_ORG_ASSIGNMENT_STRATEGY: 'ONE_TO_MANY',
// optional sentry config
// REACT_APP_SENTRY_CONFIG_JSON: "{\"dsn\":\"<sentry-dsn>\",\"environment\":\"<sentry-environment>\",\"release\":\"<app-release-version>\",\"release-name\":\"<app-release-name>\",\"release-namespace\":\"<app-release-namespace>\",\"tags\":{}}",
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,7 @@ import {
validationRulesFactory,
} from './utils';
import { formItemLayout, tailLayout } from '@opensrp/react-utils';
import { PractToOrgAssignmentStrategy } from '@opensrp/pkg-config';

const { Item: FormItem } = Form;
interface OrganizationFormProps {
Expand All @@ -45,6 +46,8 @@ interface OrganizationFormProps {
successUrl?: string;
practitioners: IPractitioner[];
existingPractitionerRoles: IPractitionerRole[];
allPractitionerRoles: IPractitionerRole[];
configuredPractAssignmentStrategy?: PractToOrgAssignmentStrategy;
}

const defaultProps = {
Expand All @@ -61,6 +64,8 @@ const OrganizationForm = (props: OrganizationFormProps) => {
successUrl,
practitioners,
existingPractitionerRoles,
allPractitionerRoles,
configuredPractAssignmentStrategy,
} = props;

const queryClient = useQueryClient();
Expand Down Expand Up @@ -117,7 +122,12 @@ const OrganizationForm = (props: OrganizationFormProps) => {
{ label: t('Inactive'), value: false },
];

const practitionersSelectOptions = getPractitionerOptions(practitioners);
const practitionersSelectOptions = getPractitionerOptions(
practitioners,
existingPractitionerRoles,
allPractitionerRoles,
configuredPractAssignmentStrategy
);
const validationRules = validationRulesFactory(t);

return (
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ import type { IPractitionerRole } from '@smile-cdr/fhirts/dist/FHIR-R4/interface
import { IOrganization } from '@smile-cdr/fhirts/dist/FHIR-R4/interfaces/IOrganization';
import { getOrgFormFields } from './utils';
import { useTranslation } from '../../mls';
import { getConfig } from '@opensrp/pkg-config';

export interface AddEditOrganizationProps {
fhirBaseURL: string;
Expand All @@ -35,6 +36,7 @@ export const AddEditOrganization = (props: AddEditOrganizationProps) => {

const { id: orgId } = useParams<RouteParams>();
const { t } = useTranslation();
const configuredPractAssignmentStrategy = getConfig('practToOrgAssignmentStrategy');

const organization = useQuery(
[organizationResourceType, orgId],
Expand All @@ -57,12 +59,9 @@ export const AddEditOrganization = (props: AddEditOrganizationProps) => {
);

// practitioners already assigned to this organization
const assignedPractitioners = useQuery(
const allPractitionerRoles = useQuery(
[practitionerResourceType, organizationResourceType, orgId],
() =>
loadAllResources(fhirBaseUrl, practitionerRoleResourceType, {
organization: orgId as string,
}),
() => loadAllResources(fhirBaseUrl, practitionerRoleResourceType),
{
onError: () => sendErrorNotification(t('An Error occurred')),
select: (res) => {
Expand All @@ -75,7 +74,7 @@ export const AddEditOrganization = (props: AddEditOrganizationProps) => {
if (
(!organization.isIdle && organization.isLoading) ||
(!practitioners.isIdle && practitioners.isLoading) ||
(!assignedPractitioners.isIdle && assignedPractitioners.isLoading)
(!allPractitionerRoles.isIdle && allPractitionerRoles.isLoading)
) {
return <Spin size="large" className="custom-spinner"></Spin>;
}
Expand All @@ -84,7 +83,13 @@ export const AddEditOrganization = (props: AddEditOrganizationProps) => {
return <BrokenPage errorMessage={(organization.error as Error).message} />;
}

const initialValues = getOrgFormFields(organization.data, assignedPractitioners.data);
const assignedPractitionerRoles = (allPractitionerRoles.data ?? []).filter(
(practitionerRole) =>
practitionerRole.organization?.reference ===
`${organizationResourceType}/${(organization.data as IOrganization).id}`
);

const initialValues = getOrgFormFields(organization.data, assignedPractitionerRoles);

const pageTitle = organization.data
? t('Edit team | {{teamName}}', { teamName: organization.data.name ?? '' })
Expand All @@ -101,9 +106,11 @@ export const AddEditOrganization = (props: AddEditOrganizationProps) => {
fhirBaseUrl={fhirBaseUrl}
initialValues={initialValues}
practitioners={practitioners.data ?? []}
existingPractitionerRoles={assignedPractitioners.data ?? []}
existingPractitionerRoles={assignedPractitionerRoles}
allPractitionerRoles={allPractitionerRoles.data ?? []}
cancelUrl={ORGANIZATION_LIST_URL}
successUrl={ORGANIZATION_LIST_URL}
configuredPractAssignmentStrategy={configuredPractAssignmentStrategy}
/>
</div>
</section>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,7 @@ import { IPractitionerRole } from '@smile-cdr/fhirts/dist/FHIR-R4/interfaces/IPr
import { getOrgFormFields } from '../utils';
import * as notifications from '@opensrp/notifications';
import userEvents from '@testing-library/user-event';
import { PractToOrgAssignmentStrategy } from '@opensrp/pkg-config';

jest.mock('@opensrp/notifications', () => ({
__esModule: true,
Expand Down Expand Up @@ -72,6 +73,7 @@ describe('OrganizationForm', () => {
practitioners: getResourcesFromBundle<IPractitioner>(allPractitioners),
existingPractitionerRoles: [],
initialValues: getOrgFormFields(),
allPractitionerRoles: [],
};

beforeAll(() => {
Expand Down Expand Up @@ -407,4 +409,42 @@ describe('OrganizationForm', () => {

wrapper.unmount();
});

it('#1210 - assigns practitioners to organizations using a 1-1 assignment strategy', () => {
const container = document.createElement('div');
document.body.appendChild(container);

const someMockURL = '/someURL';

const props = {
...formProps,
configuredPractAssignmentStrategy: PractToOrgAssignmentStrategy.ONE_TO_ONE,
allPractitionerRoles: getResourcesFromBundle(org105Practitioners),
existingPractitionerRoles: [],
};

const wrapper = mount(
<AppWrapper>
<OrganizationForm successUrl={someMockURL} {...props} />
</AppWrapper>,
{ attachTo: container }
);

// simulate value selection for members
wrapper.find('input#members').simulate('mousedown');

const optionTexts = [
...document.querySelectorAll(
'#members_list+div.rc-virtual-list .ant-select-item-option-content'
),
].map((option) => {
return option.textContent;
});

// three options instead of 5
expect(optionTexts).toHaveLength(3);
expect(optionTexts).toEqual(['Ward N Williams MD', 'Allay Allan', 'test fhir']);

wrapper.unmount();
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@ import {
practitionerResourceType,
practitionerRoleResourceType,
} from '../../../constants';
import { allPractitioners, org105, org105Practitioners } from '../tests/fixtures';
import { allPractitioners, org105 } from '../tests/fixtures';

jest.mock('fhirclient', () => {
return jest.requireActual('fhirclient/lib/entry/browser');
Expand Down Expand Up @@ -111,11 +111,11 @@ test('renders correctly for edit locations', async () => {

nock(props.fhirBaseURL)
.get(`/${practitionerRoleResourceType}/_search`)
.query({ _summary: 'count', organization: '105' })
.query({ _summary: 'count' })
.reply(200, { total: 1000 })
.get(`/${practitionerRoleResourceType}/_search`)
.query({ _count: 1000, organization: '105' })
.reply(200, org105Practitioners);
.query({ _count: 1000 })
.reply(200, allPractitioners);

render(
<Router history={history}>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -10,11 +10,12 @@ import {
practitionerResourceType,
} from '../../constants';
import { getObjLike, parseFhirHumanName, IdentifierUseCodes } from '@opensrp/react-utils';
import { flatten } from 'lodash';
import { flatten, groupBy } from 'lodash';
import { Rule } from 'rc-field-form/lib/interface';
import { v4 } from 'uuid';
import { IPractitioner } from '@smile-cdr/fhirts/dist/FHIR-R4/interfaces/IPractitioner';
import type { TFunction } from '@opensrp/i18n';
import { PractToOrgAssignmentStrategy } from '@opensrp/pkg-config';

export interface OrganizationFormFields {
id?: string;
Expand Down Expand Up @@ -172,9 +173,30 @@ export const getAssignedPractsOptions = (roles: IPractitionerRole[]) => {
* map practitioner to select options
*
* @param practitioners - map of practitioners
* @param existingPractitionerRoles - practitioner Roles that reference organizatio, [] when creating an organization
* @param allPractitionerRoles - all practitioner roles resources
* @param assignmentStrategy - strategy to use when generating options to assign
*/
export const getPractitionerOptions = (practitioners: IPractitioner[]) => {
return practitioners.map((pract) => {
export const getPractitionerOptions = (
practitioners: IPractitioner[],
existingPractitionerRoles: IPractitionerRole[],
allPractitionerRoles: IPractitionerRole[],
assignmentStrategy?: PractToOrgAssignmentStrategy
) => {
let allowedPractitioners = practitioners;
const rolesWithOrganizations = allPractitionerRoles.filter(
(practRole) => practRole.organization?.reference
);
// group allPractitionerRoles by practitioner references
const rolesByPractReference = groupBy(rolesWithOrganizations, 'practitioner.reference');

if (assignmentStrategy && assignmentStrategy === PractToOrgAssignmentStrategy.ONE_TO_ONE) {
allowedPractitioners = allowedPractitioners.filter((pract) => {
const practReference = `${pract.resourceType}/${pract.id}`;
return !rolesByPractReference[practReference] as boolean;
});
}
const newPractitionerOptions = allowedPractitioners.map((pract) => {
const nameObj = getObjLike(pract.name, 'use', HumanNameUseCodes.OFFICIAL)[0];
const value = `${practitionerResourceType}/${pract.id}`;
const label = parseFhirHumanName(nameObj);
Expand All @@ -183,4 +205,13 @@ export const getPractitionerOptions = (practitioners: IPractitioner[]) => {
label: label ?? value,
};
});
const existingPractitionerOptions = existingPractitionerRoles.map((role) => {
const value = role.practitioner?.reference as string;
const label = role.practitioner?.display;
return {
value,
label: label ?? value,
};
});
return [...newPractitionerOptions, ...existingPractitionerOptions];
};
12 changes: 12 additions & 0 deletions packages/pkg-config/src/configStore/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,12 +25,23 @@ export interface ConfigState {
opensrpBaseURL?: string;
fhirBaseURL?: string;
defaultTablesPageSize?: number; // static value of the default number of rows per page
practToOrgAssignmentStrategy?: PractToOrgAssignmentStrategy;
}

export interface UserPreference {
tablespref?: Record<string, TableState>;
}

/**
* This strategy only applies unilaterally, i.e. from practitioner to organization.
* It does not imply any relations in the opposite direction i.e. from organization
* to practitioner.
*/
export enum PractToOrgAssignmentStrategy {
ONE_TO_ONE = 'ONE_TO_ONE', // one practitioner assignable to one organization
ONE_TO_MANY = 'ONE_TO_MANY', // one practitioner assignable to multiple organizations
}

const defaultConfigs: GlobalState = {
languageCode: 'en',
appLoginURL: undefined,
Expand All @@ -40,6 +51,7 @@ const defaultConfigs: GlobalState = {
tablespref: undefined,
defaultTablesPageSize: 5,
projectCode: 'core',
practToOrgAssignmentStrategy: PractToOrgAssignmentStrategy.ONE_TO_MANY,
};

let localstorage: UserPreference = localStorage.getItem(USER_PREFERENCE_KEY)
Expand Down
1 change: 1 addition & 0 deletions packages/pkg-config/src/configStore/tests/index.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ describe('pkg-configs/configStore', () => {
keycloakBaseURL: undefined,
languageCode: 'en',
opensrpBaseURL: undefined,
practToOrgAssignmentStrategy: 'ONE_TO_MANY',
fhirBaseURL: undefined,
projectCode: 'core',
defaultTablesPageSize: 5,
Expand Down

0 comments on commit fbeb24b

Please sign in to comment.