Skip to content

Commit

Permalink
(enhc) add improvement on loading sub-forms (openmrs#34)
Browse files Browse the repository at this point in the history
* (enhc) add improvement on loading sub-forms

* Fix broken tests

---------

Co-authored-by: Samuel Male <samuelsmalek@gmail.com>
  • Loading branch information
2 people authored and eudson committed Apr 21, 2023
1 parent 69303d4 commit 52aff38
Show file tree
Hide file tree
Showing 7 changed files with 118 additions and 57 deletions.
2 changes: 1 addition & 1 deletion __mocks__/forms/omrs-forms/mini-form.json
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@
"display": "Mini Form",
"name": "Mini Form",
"description": null,
"encounterType": null,
"encounterType": "503a5764-feaa-43d5-ad7e-f523091fbd8f",
"version": "1.0",
"build": null,
"published": false,
Expand Down
2 changes: 1 addition & 1 deletion __mocks__/forms/omrs-forms/nested-form1.json
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@
"display": "Nested Form One",
"name": "Nested Form One",
"description": null,
"encounterType": null,
"encounterType": "79c1f50f-f77d-42e2-ad2a-d29304dde2fe",
"version": "1.0",
"build": null,
"published": false,
Expand Down
33 changes: 26 additions & 7 deletions src/api/api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -54,10 +54,16 @@ export function getLatestObs(patientUuid: string, conceptUuid: string, encounter
});
}

export async function getOpenMRSForm(nameOrUUID: string): Promise<OpenmrsForm> {
/**
* Fetches an OpenMRS form using either its name or UUID.
* @param {string} nameOrUUID - The form's name or UUID.
* @returns {Promise<OpenmrsForm | null>} - A Promise that resolves to the fetched OpenMRS form or null if not found.
*/
export async function fetchOpenMRSForm(nameOrUUID: string): Promise<OpenmrsForm | null> {
if (!nameOrUUID) {
return null;
}

const { url, isUUID } = isUuid(nameOrUUID)
? { url: `/ws/rest/v1/form/${nameOrUUID}?v=full`, isUUID: true }
: { url: `/ws/rest/v1/form?q=${nameOrUUID}&v=full`, isUUID: false };
Expand All @@ -66,15 +72,28 @@ export async function getOpenMRSForm(nameOrUUID: string): Promise<OpenmrsForm> {
if (isUUID) {
return openmrsFormResponse;
}
return openmrsFormResponse.results?.length ? openmrsFormResponse.results[0] : null;
return openmrsFormResponse.results?.length
? openmrsFormResponse.results[0]
: new Error(`Form with ${nameOrUUID} was not found`);
}

export async function getOpenmrsFormBody(formSkeleton: OpenmrsForm) {
if (!formSkeleton) {
/**
* Fetches ClobData for a given OpenMRS form.
* @param {OpenmrsForm} form - The OpenMRS form object.
* @returns {Promise<any | null>} - A Promise that resolves to the fetched ClobData or null if not found.
*/
export async function fetchClobData(form: OpenmrsForm): Promise<any | null> {
if (!form) {
return null;
}
const { data: clobDataResponse } = await openmrsFetch(
`/ws/rest/v1/clobdata/${formSkeleton.resources.find(({ name }) => name === 'JSON schema').valueReference}`,
);

const jsonSchemaResource = form.resources.find(({ name }) => name === 'JSON schema');
if (!jsonSchemaResource) {
return null;
}

const clobDataUrl = `/ws/rest/v1/clobdata/${jsonSchemaResource.valueReference}`;
const { data: clobDataResponse } = await openmrsFetch(clobDataUrl);

return clobDataResponse;
}
12 changes: 5 additions & 7 deletions src/components/encounter/ohri-encounter-form.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -99,9 +99,9 @@ export const OHRIEncounterForm: React.FC<OHRIEncounterFormProps> = ({

const flattenedFields = useMemo(() => {
const flattenedFieldsTemp = [];
form.pages.forEach(page =>
page.sections.forEach(section => {
section.questions.forEach(question => {
form.pages?.forEach(page =>
page.sections?.forEach(section => {
section.questions?.forEach(question => {
// explicitly set blank values to null
// TODO: shouldn't we be setting to the default behaviour?
section.inlineRendering = isEmpty(section.inlineRendering) ? null : section.inlineRendering;
Expand Down Expand Up @@ -183,13 +183,13 @@ export const OHRIEncounterForm: React.FC<OHRIEncounterFormProps> = ({
}),
);

form.pages.forEach(page => {
form?.pages?.forEach(page => {
if (page.hide) {
evalHide({ value: page, type: 'page' }, flattenedFields, tempInitialValues);
} else {
page.isHidden = false;
}
page.sections.forEach(section => {
page?.sections?.forEach(section => {
if (section.hide) {
evalHide({ value: section, type: 'section' }, flattenedFields, tempInitialValues);
} else {
Expand Down Expand Up @@ -554,8 +554,6 @@ export const OHRIEncounterForm: React.FC<OHRIEncounterFormProps> = ({
if (sessionMode != 'enter' && !page.subform?.form.encounter) {
return null;
}
// filter out all nested subforms
page.subform.form.pages = page.subform.form.pages.filter(page => !isTrue(page.isSubform));
return (
<OHRIEncounterForm
key={index}
Expand Down
22 changes: 12 additions & 10 deletions src/hooks/useFormJson.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,8 @@ when(mockOpenmrsFetch).calledWith(buildPath(MINI_FORM_UUID)).mockResolvedValue({
when(mockOpenmrsFetch).calledWith(buildPath(MINI_FORM_SCHEMA_VALUE_REF)).mockResolvedValue({ data: miniFormBody });

describe('useFormJson', () => {


it('should fetch basic form by name', async () => {
let hook = null;
await act(async () => {
Expand All @@ -63,7 +65,7 @@ describe('useFormJson', () => {
expect(hook.result.current.formJson.name).toBe(MINI_FORM_NAME);
});

it('should load form with nested subforms', async () => {
fit('should load form with nested subforms', async () => {
let hook = null;
await act(async () => {
hook = renderHook(() => useFormJson(PARENT_FORM_NAME, null, null, null));
Expand All @@ -74,7 +76,7 @@ describe('useFormJson', () => {
expect(hook.result.current.formJson.name).toBe(PARENT_FORM_NAME);

// verify subforms
verifyNestedForms(hook.result.current.formJson);
verifyEmbeddedForms(hook.result.current.formJson);
});

it('should load subforms for raw form json', async () => {
Expand All @@ -88,19 +90,19 @@ describe('useFormJson', () => {
expect(hook.result.current.formJson.name).toBe(PARENT_FORM_NAME);

// verify subforms
verifyNestedForms(hook.result.current.formJson);
verifyEmbeddedForms(hook.result.current.formJson);
});
});

function buildPath(path: string) {
return when((url: string) => url.includes(path));
}

function verifyNestedForms(formJson) {
const subform = formJson.pages[1].subform.form;
const nestedSubform = subform.pages[1].subform.form;
expect(subform.name).toBe(SUB_FORM_NAME);
expect(nestedSubform.name).toBe(MINI_FORM_NAME);
expect(subform.pages.length).toBe(2);
expect(nestedSubform.pages.length).toBe(1);
function verifyEmbeddedForms(formJson) {
// assert that the nestedForm2's (level one subform) pages have been aligned with the parent because they share the same encounterType
expect(formJson.pages.length).toBe(3);
// the mini form (it's not flattened into the parent form because it has a different encounterType)
const nestedSubform = formJson.pages[2].subform.form;
expect(nestedSubform.name).toBe(MINI_FORM_NAME);
expect(nestedSubform.pages.length).toBe(1);
}
98 changes: 70 additions & 28 deletions src/hooks/useFormJson.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ import { useEffect, useState } from 'react';
import { OHRIFormSchema } from '../api/types';
import { isTrue } from '../utils/boolean-utils';
import { applyFormIntent } from '../utils/forms-loader';
import { getOpenMRSForm, getOpenmrsFormBody } from '../api/api';
import { fetchOpenMRSForm, fetchClobData } from '../api/api';

export function useFormJson(formUuid: string, rawFormJson: any, encounterUuid: string, formSessionIntent: string) {
const [formJson, setFormJson] = useState<OHRIFormSchema>(null);
Expand Down Expand Up @@ -37,23 +37,36 @@ export function useFormJson(formUuid: string, rawFormJson: any, encounterUuid: s
*/
export async function loadFormJson(
formIdentifier: string,
rawFormJson?: any,
rawFormJson?: OHRIFormSchema,
formSessionIntent?: string,
): Promise<OHRIFormSchema> {
const openmrsFormResponse = await getOpenMRSForm(formIdentifier);
const clobDataResponse = await getOpenmrsFormBody(openmrsFormResponse);
const openmrsFormResponse = await fetchOpenMRSForm(formIdentifier);
const clobDataResponse = await fetchClobData(openmrsFormResponse);
const formJson: OHRIFormSchema = clobDataResponse ?? rawFormJson;
const subformRefs = extractSubformRefs(formJson);
const subforms = await loadSubforms(subformRefs, formSessionIntent);
updateFormJsonWithSubforms(formJson, subforms);

const formJson: OHRIFormSchema = rawFormJson
? refineFormJson(rawFormJson, formSessionIntent)
: refineFormJson(clobDataResponse, formSessionIntent);
const subformRefs = formJson.pages
return refineFormJson(formJson, formSessionIntent);
}

function extractSubformRefs(formJson: OHRIFormSchema): string[] {
return formJson.pages
.filter(page => page.isSubform && !page.subform.form && page.subform?.name)
.map(page => page.subform?.name);
const subforms = await Promise.all(subformRefs.map(subform => loadFormJson(subform, null, formSessionIntent)));
}

async function loadSubforms(subformRefs: string[], formSessionIntent?: string): Promise<OHRIFormSchema[]> {
return Promise.all(subformRefs.map(subform => loadFormJson(subform, null, formSessionIntent)));
}

function updateFormJsonWithSubforms(formJson: OHRIFormSchema, subforms: OHRIFormSchema[]): void {
subforms.forEach(subform => {
formJson.pages.find(page => page.subform?.name === subform.name).subform.form = subform;
const matchingPage = formJson.pages.find(page => page.subform?.name === subform.name);
if (matchingPage) {
matchingPage.subform.form = subform;
}
});
return formJson;
}

function validateFormsArgs(formUuid: string, rawFormJson: any): Error {
Expand All @@ -64,25 +77,54 @@ function validateFormsArgs(formUuid: string, rawFormJson: any): Error {
return new Error('InvalidArgumentsErr: Both formUuid and formJson cannot be provided at the same time.');
}
}
/**
* Refines the input form JSON object by parsing it, removing inline subforms, setting the encounter type, and applying form intents if provided.
* @param {any} formJson - The input form JSON object or string.
* @param {string} [formSessionIntent] - The optional form session intent.
* @returns {OHRIFormSchema} - The refined form JSON object of type OHRIFormSchema.
*/
function refineFormJson(formJson: any, formSessionIntent?: string): OHRIFormSchema {
const parsedFormJson: OHRIFormSchema = parseFormJson(formJson);
removeInlineSubforms(parsedFormJson, formSessionIntent);
setEncounterType(parsedFormJson);
return formSessionIntent ? applyFormIntent(formSessionIntent, parsedFormJson) : parsedFormJson;
}

function refineFormJson(formJson: any, formSessionIntent: string): OHRIFormSchema {
const copy: OHRIFormSchema =
typeof formJson == 'string' ? JSON.parse(formJson) : JSON.parse(JSON.stringify(formJson));
let i = copy.pages.length;
// let's loop backwards so that we splice in the opposite direction
while (i--) {
const page = copy.pages[i];
if (isTrue(page.isSubform) && !isTrue(page.isHidden) && page.subform?.form?.encounterType == copy.encounterType) {
copy.pages.splice(i, 1, ...page.subform.form.pages.filter(page => !isTrue(page.isSubform)));
/**
* Parses the input form JSON and returns a deep copy of the object.
* @param {any} formJson - The input form JSON object or string.
* @returns {OHRIFormSchema} - The parsed form JSON object of type OHRIFormSchema.
*/
function parseFormJson(formJson: any): OHRIFormSchema {
return typeof formJson === 'string' ? JSON.parse(formJson) : JSON.parse(JSON.stringify(formJson));
}

/**
* Removes inline subforms from the form JSON and replaces them with their pages if the encounter type matches.
* @param {OHRIFormSchema} formJson - The input form JSON object of type OHRIFormSchema.
* @param {string} formSessionIntent - The form session intent.
*/
function removeInlineSubforms(formJson: OHRIFormSchema, formSessionIntent: string): void {
for (let i = formJson.pages.length - 1; i >= 0; i--) {
const page = formJson.pages[i];
if (
isTrue(page.isSubform) &&
!isTrue(page.isHidden) &&
page.subform?.form?.encounterType === formJson.encounterType
) {
const nonSubformPages = page.subform.form.pages.filter(page => !isTrue(page.isSubform));
formJson.pages.splice(i, 1, ...refineFormJson(page.subform.form, formSessionIntent).pages);
}
}
// Ampath forms configure the `encounterType` property through the `encounter` attribute
if (copy.encounter && typeof copy.encounter == 'string' && !copy.encounterType) {
copy.encounterType = copy.encounter;
delete copy.encounter;
}
if (formSessionIntent) {
return applyFormIntent(formSessionIntent, copy);
}

/**
* Sets the encounter type for the form JSON if it's provided through the `encounter` attribute.
* @param {OHRIFormSchema} formJson - The input form JSON object of type OHRIFormSchema.
*/
function setEncounterType(formJson: OHRIFormSchema): void {
if (formJson.encounter && typeof formJson.encounter === 'string' && !formJson.encounterType) {
formJson.encounterType = formJson.encounter;
delete formJson.encounter;
}
return copy;
}
6 changes: 3 additions & 3 deletions src/utils/common-expression-helpers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -335,7 +335,7 @@ export class CommonExpressionHelpers {

calcGravida(parityTerm: number, parityAbortion: number) {
let gravida = 0;
if(parityTerm && parityAbortion) {
if (parityTerm && parityAbortion) {
gravida = parityTerm + parityAbortion + 1;
}

Expand All @@ -344,8 +344,8 @@ export class CommonExpressionHelpers {

calcDaysSinceCircumcisionProcedure(val) {
let daySinceLastCircumcision = 0;
if(val) {
var timeDiff = Math.abs((new Date).getTime() - Date.parse(val));
if (val) {
var timeDiff = Math.abs(new Date().getTime() - Date.parse(val));
daySinceLastCircumcision = Math.floor(timeDiff / (1000 * 60 * 60 * 24));
}
return daySinceLastCircumcision;
Expand Down

0 comments on commit 52aff38

Please sign in to comment.