Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

(feat) O3-3367 Add support for person attributes #423

Open
wants to merge 2 commits into
base: main
Choose a base branch
from

Conversation

CynthiaKamau
Copy link
Contributor

@CynthiaKamau CynthiaKamau commented Nov 19, 2024

Requirements

  • This PR has a title that briefly describes the work done including the ticket number. If there is a ticket, make sure your PR title includes a conventional commit label. See existing PR titles for inspiration.
  • My work conforms to the OpenMRS 3.0 Styleguide and design documentation.
  • My work includes tests or is validated by existing tests.

Summary

Add support for person attributes

Schema :

{
  "name": "New person attributes  form ",
  "pages": [
    {
      "label": "newPage ",
      "sections": [
        {
          "label": "New section",
          "isExpanded": "true",
          "questions": [
            {
              "label": "Person attribute (String)",
              "type": "personAttribute",
              "required": false,
              "id": "bloodTyping",
              "questionOptions": {
                "rendering": "text",
                "attributeType": "8d871d18-c2cc-11de-8d13-0010c6dffd0f"
              }
            },
            {
              "label": "Person attribute (location drop down)",
              "type": "personAttribute",
              "required": false,
              "id": "bloodTyping1",
              "questionOptions": {
                "rendering": "ui-select-extended",
                "attributeType": "8d87236c-c2cc-11de-8d13-0010c6dffd0f"
              },
              "validators": []
            },
            {
              "label": "Person attribute (concept select drop down)",
              "type": "personAttribute",
              "required": false,
              "id": "bloodTyping2",
              "questionOptions": {
                "rendering": "ui-select-extended",
                "concept": "5272AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA",
                "attributeType": "8d871f2a-c2cc-11de-8d13-0010c6dffd0f"
              },
              "validators": []
            }
          ]
        }
      ]
    }
  ],
  "processor": "EncounterFormProcessor",
  "encounterType": "dd528487-82a5-4082-9c72-ed246bd49591",
  "referencedForms": [],
  "description": "Form that keeps HIV patient data",
  "version": "2.0",
  "uuid": "9478f8b6-0875-4127-b557-f8d844d1bd69"
}

Screenshots

Screen.Recording.2024-11-21.at.13.54.06.mov

Related Issue

Other

@CynthiaKamau CynthiaKamau marked this pull request as draft November 19, 2024 10:41
Copy link

github-actions bot commented Nov 19, 2024

Size Change: -264 kB (-17.31%) 👏

Total Size: 1.26 MB

Filename Size Change
dist/219.js 0 B -264 kB (removed) 🏆
ℹ️ View Unchanged
Filename Size Change
dist/151.js 382 kB 0 B
dist/225.js 2.57 kB 0 B
dist/277.js 1.85 kB 0 B
dist/289.js 88.9 kB 0 B
dist/300.js 645 B 0 B
dist/317.js 266 kB 0 B
dist/335.js 968 B 0 B
dist/353.js 3.02 kB 0 B
dist/41.js 3.37 kB 0 B
dist/540.js 2.63 kB 0 B
dist/55.js 758 B 0 B
dist/585.js 112 kB 0 B
dist/635.js 14.4 kB 0 B
dist/690.js 11.5 kB 0 B
dist/70.js 483 B 0 B
dist/979.js 6.87 kB 0 B
dist/99.js 691 B 0 B
dist/993.js 3.09 kB 0 B
dist/main.js 355 kB +174 B (+0.05%)
dist/openmrs-esm-form-engine-lib.js 3.8 kB +1 B (+0.03%)

compressed-size-action

@CynthiaKamau CynthiaKamau force-pushed the O3-3367 branch 2 times, most recently from be9d967 to 48be05c Compare November 21, 2024 11:22
@CynthiaKamau CynthiaKamau marked this pull request as ready for review November 21, 2024 11:25
@CynthiaKamau CynthiaKamau force-pushed the O3-3367 branch 2 times, most recently from 7b25040 to ebf119f Compare November 21, 2024 11:42
@CynthiaKamau CynthiaKamau marked this pull request as draft November 21, 2024 11:48
@CynthiaKamau CynthiaKamau marked this pull request as ready for review November 22, 2024 07:43
@ibacher
Copy link
Member

ibacher commented Nov 22, 2024

So the immediate thing I see here is that we're embedding "format" in the form. I don't really love when we duplicate metadata like this in forms. Just load the format from the backend definition of the attribute type, so we don't face weird issues where changing an attribute type causes forms to break.

@CynthiaKamau CynthiaKamau force-pushed the O3-3367 branch 4 times, most recently from 8ac443b to e0f7bcc Compare November 27, 2024 09:15
@@ -102,7 +102,7 @@ export interface FormSchemaTransformer {
/**
* Transforms the raw schema to be compatible with the React Form Engine.
*/
transform: (form: FormSchema) => FormSchema;
transform: (form: FormSchema) => Promise<FormSchema> | FormSchema;
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Do we need the Promise here? i believe the form is resolved before we get here

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We need it

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Comment on lines 187 to 189
attribute?: {
type?: string;
};
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Looking at the ampath schema docs for personAtttribute, we could eliminate the need for this being an object for backwards compatibility as well have a single format unless you have reasons for going this route

Screenshot 2024-11-29 at 07 10 58

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Let me work on this

}
field.meta.submission.newValue = {
value: value,
attributeType: field.questionOptions?.attribute?.type,
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The suggested changes in the schema will trickle down

formJson: any,
schemaTransformers: FormSchemaTransformer[] = [],
formSessionIntent?: string,
): FormSchema {
): Promise<FormSchema> {
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I have reservations to the Promise additions to this file because this affects the way the forms load. Maybe you can share why we need this

Copy link
Contributor Author

@CynthiaKamau CynthiaKamau Nov 29, 2024

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This adjustment is needed because the format of a person attribute uuid can be a text, location or concept and those details are fetched from the getPersonAttributeTypeFormat endpoint. Based on the response, we transform the question to the right datasource. @ibacher recommended that we get the backend definitions incase they change, we will still always get the right one. You can add suggestions on how to better handle it incase there is a better way to do it.

Copy link
Member

@samuelmale samuelmale left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nice work @CynthiaKamau! Any chance we can have some integrational test cases covering all supported rendering types?

export const PersonAttributesAdapter: FormFieldValueAdapter = {
transformFieldValue: function (field: FormField, value: any, context: FormContextProps) {
clearSubmission(field);
if (field.meta?.previousValue?.value === value || isEmpty(value)) {
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

What happens if the user tries to delete an attribute in edit mode?

 if (field.meta?.previousValue && isEmpty(value)) {
   // should we void the attribute?
}

import { clearSubmission } from '../utils/common-utils';
import { isEmpty } from '../validators/form-validator';

export const PersonAttributesAdapter: FormFieldValueAdapter = {
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Are you planning on adding some test coverage for this adapter?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

yes i will

Comment on lines 118 to 121
for (let transformer of schemaTransformers) {
const draftForm = await transformer.transform(formJson);
formJson = draftForm;
}
Copy link
Member

@samuelmale samuelmale Dec 2, 2024

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@ibacher Do you see any benefits from executing these sequentially?

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Well, merging things would be hard, but this code actually has very different semantics from the previous version. Might be better to preserve the same semantics:

Suggested change
for (let transformer of schemaTransformers) {
const draftForm = await transformer.transform(formJson);
formJson = draftForm;
}
schemaTransformers.reduce((form, transformer) => Promise.resolve(transformer.transform(await form)), Promise.resolve(formJson));

const [error, setError] = useState(null);

useEffect(() => {
if (patientUuid) {
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Can you conditionally load attributes only for forms that support them? (Here is an example https://github.com/openmrs/openmrs-esm-form-engine-lib/blob/main/src/hooks/usePatientPrograms.ts#L12)

@@ -162,6 +168,27 @@ export class EncounterFormProcessor extends FormProcessor {
});
}

// save person attributes
try {
const personattributes = preparePersonAttributes(context.formFields, context.location?.uuid);
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
const personattributes = preparePersonAttributes(context.formFields, context.location?.uuid);
const personAttributes = preparePersonAttributes(context.formFields, context.location?.uuid);

// save person attributes
try {
const personattributes = preparePersonAttributes(context.formFields, context.location?.uuid);
const savedPrograms = await savePersonAttributes(context.patient, personattributes);
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
const savedPrograms = await savePersonAttributes(context.patient, personattributes);
const savedAttributes = await savePersonAttributes(context.patient, personAttributes);

@@ -102,7 +102,7 @@ export interface FormSchemaTransformer {
/**
* Transforms the raw schema to be compatible with the React Form Engine.
*/
transform: (form: FormSchema) => FormSchema;
transform: (form: FormSchema) => Promise<FormSchema> | FormSchema;
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

import { openmrsFetch, restBaseUrl } from '@openmrs/esm-framework';
import { BaseOpenMRSDataSource } from './data-source';

export class PersonAttributeLocationDataSource extends BaseOpenMRSDataSource {
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
export class PersonAttributeLocationDataSource extends BaseOpenMRSDataSource {
export class LocationAttributeDataSource extends BaseOpenMRSDataSource {

Comment on lines +9 to +15
async fetchData(searchTerm: string, config?: Record<string, any>, uuid?: string): Promise<any[]> {
const rep = 'v=custom:(uuid,display)';
const url = `${restBaseUrl}/location?${rep}`;
const { data } = await openmrsFetch(searchTerm ? `${url}&q=${searchTerm}` : url);

return data?.results;
}
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This data source seems to just load locations (not tied down to the "attribute" model). Anything stopping you from reusing the existing location DS`?

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

^^^^ This

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Done

@@ -267,3 +283,25 @@ function handleQuestionsWithObsComments(sectionQuestions: Array<FormField>): Arr

return augmentedQuestions;
}

async function handlePersonAttributeType(question: FormField) {
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Are you planning on adding some test coverage?

@@ -149,7 +149,7 @@ const UiSelectExtended: React.FC<FormFieldInputProps> = ({ field, errors, warnin
selectedItem={selectedItem}
placeholder={isSearchable ? t('search', 'Search') + '...' : null}
shouldFilterItem={({ item, inputValue }) => {
if (!inputValue) {
if (!inputValue || items.find((item) => item.uuid == field.value)) {
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Is this required for this PR?

Comment on lines +9 to +15
async fetchData(searchTerm: string, config?: Record<string, any>, uuid?: string): Promise<any[]> {
const rep = 'v=custom:(uuid,display)';
const url = `${restBaseUrl}/location?${rep}`;
const { data } = await openmrsFetch(searchTerm ? `${url}&q=${searchTerm}` : url);

return data?.results;
}
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

^^^^ This

@@ -7,7 +7,7 @@ export class SelectConceptAnswersDatasource extends BaseOpenMRSDataSource {
}

fetchData(searchTerm: string, config?: Record<string, any>): Promise<any[]> {
const apiUrl = this.url.replace('conceptUuid', config.referencedValue || config.concept);
const apiUrl = this.url.replace('conceptUuid', config.concept || config.referencedValue);
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Why the inversion here?

Comment on lines 118 to 121
for (let transformer of schemaTransformers) {
const draftForm = await transformer.transform(formJson);
formJson = draftForm;
}
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Well, merging things would be hard, but this code actually has very different semantics from the previous version. Might be better to preserve the same semantics:

Suggested change
for (let transformer of schemaTransformers) {
const draftForm = await transformer.transform(formJson);
formJson = draftForm;
}
schemaTransformers.reduce((form, transformer) => Promise.resolve(transformer.transform(await form)), Promise.resolve(formJson));

if (patientUuid) {
openmrsFetch(`${restBaseUrl}/patient/${patientUuid}?v=custom:(attributes)`)
.then((response) => {
setPersonAttributes(response?.data?.attributes);
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
setPersonAttributes(response?.data?.attributes);
setPersonAttributes(response.data?.attributes);

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Is there some reason we're not using SWR here?

@@ -94,6 +94,6 @@ export const inbuiltControls: Array<RegistryItem<React.ComponentType<FormFieldIn
},
...controlTemplates.map((template) => ({
name: template.name,
component: templateToComponentMap.find((component) => component.name === template.name).baseControlComponent,
component: templateToComponentMap.find((component) => component.name === template.name)?.baseControlComponent,
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Don't really understand why we need the ? here?

@@ -171,7 +171,7 @@ export async function getRegisteredFieldValueAdapter(type: string): Promise<Form
}

export async function getRegisteredFormSchemaTransformers(): Promise<FormSchemaTransformer[]> {
const transformers = [];
const transformers: FormSchemaTransformer[] = [];
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
const transformers: FormSchemaTransformer[] = [];
const transformers: Array<FormSchemaTransformer> = [];

Comment on lines -189 to +203

transformers.push(...inbuiltFormTransformers.map((inbuiltTransformer) => inbuiltTransformer.component));

const inbuiltTransformersPromises = inbuiltFormTransformers.map(async (inbuiltTransformer) => {
const transformer = inbuiltTransformer.component;
if (transformer instanceof Promise) {
return await transformer;
}
return transformer;
});
const resolvedInbuiltTransformers = await Promise.all(inbuiltTransformersPromises);
transformers.push(...resolvedInbuiltTransformers);
transformers.forEach((transformer) => {
const inbuiltTransformer = inbuiltFormTransformers.find((t) => t.component === transformer);
registryCache.formSchemaTransformers[inbuiltTransformer.name] = transformer;
if (inbuiltTransformer) {
registryCache.formSchemaTransformers[inbuiltTransformer.name] = transformer;
}
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Could we refactor this? The code is way more complicated than it needs to be and has way more iterations than it needs to. For example:

transformers = transformers.concat(inbuildFormTransformers.map((transformer) => transformer.component ? ({...transformer, component: Promise.resolve(transformer.component)}) : null).filter(Boolean).map(async (transformer) => {
  const theTransformer = await transformer.component;
  registryCache.formSchemaTransformers[transformer.name] = theTransformer;
  return theTransformer;
}));

@@ -125,7 +138,7 @@ function setFieldValidators(question: FormField) {
}
}

function transformByType(question: FormField) {
async function transformByType(question: FormField) {
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I actually think there's a good argument to be made to separate this out from the DefaultFormSchemaTransformer. This is inherently async whereas nothing in the default transformer otherwise is. Also, for handling attributes, it would be substantially better to batch them up into a single request... or at least to avoid re-requesting an attribute type already loaded, but that logic requires a different structure than the default class provides.

Comment on lines 288 to 290
if (question.questionOptions.rendering !== 'text') {
question.questionOptions.rendering === 'ui-select-extended';
}
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I don't think this does what you wanted it to do.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
None yet
Projects
None yet
Development

Successfully merging this pull request may close these issues.

4 participants