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

(BREAKING) Migrate from Formik to RHF #349

Merged
merged 16 commits into from
Aug 7, 2024
Merged

(BREAKING) Migrate from Formik to RHF #349

merged 16 commits into from
Aug 7, 2024

Conversation

samuelmale
Copy link
Member

@samuelmale samuelmale commented Jul 11, 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

This PR introduces a major migration from Formik to React Hook Form, along with the implementation of a new form processing architecture. The changes focus on enhancing the form handling capabilities, improving performance, and providing a more flexible and maintainable codebase. The introduction of form processors abstracts the form engine from the underlying domain objects, allowing for more modular and reusable code. Additionally, the field submission handlers have been refactored to form-field adapters for better clarity and naming consistency. Form validation has also been updated to address specific challenges.

Major Changes Made

  1. Migration from Formik to React Hook Form:

    • Replaced Formik with React Hook Form across the project to leverage its performance benefits and simpler API.
    • Updated all form components to use React Hook Form's hooks and methods for managing form state and validation.
  2. Introduction of Form Processors:

    • Implemented a new form processing architecture to abstract the form engine from the underlying domain objects (e.g., Encounter, Patient).
    • Form processors provide an abstraction layer where the form engine is agnostic about the underlying domain object. This allows for the implementation details of the domain to be defined in specific processors, such as the EncounterFormProcessor. These processors handle tasks such as resolving dependencies, initializing form values, and processing submissions, ensuring a clean separation of concerns and enhancing code modularity.
  3. EncounterFormProcessor Implementation:

    • Defined the EncounterFormProcessor to manage the encounter-specific logic, including preparing the form schema, processing submissions, and resolving dependencies.
    • Implemented methods to handle the initialization of form values, submission of encounter data, and saving of related entities such as patient identifiers, patient programs, and attachments.
    • Utilized custom hooks (useCustomHooks) to manage the loading state and context updates for encounters.
  4. Refactoring of Field Submission Handlers to Form-Field Adapters:

    • Renamed field submission handlers to form-field adapters to better reflect their purpose and functionality.
    • Added a tearDown function to the underlying interface of form-field adapters to handle any necessary cleanup.
  5. Form Validation Management:

    • Initially planned to use Zod's integration with React Hook Form for validation but encountered challenges in managing errors vs. warnings with different severity levels and handling expression-based validators.
    • Decided to manage form validation manually to better control the validation logic and handle complex validation scenarios for the time being.

Screenshots

TBD

Related Issue

https://openmrs.atlassian.net/browse/O3-2906

Other

Form Engine Migration Checklist

Pending tasks

Unmasked issues/bugs in the Migration course

  • Previous values aren’t correctly initialised (tested with date comp, enc-datetime)
  • Errors and warnings aren't rendered correctly for content-switcher rendering
  • File picker doesn’t support multiple files selection (https://openmrs.atlassian.net/browse/O3-3682)
  • Encounter date time isn’t editable, and it also doesn’t seem to update the encounter’s date (https://openmrs.atlassian.net/browse/O3-3646)
  • The Obs comment feature wasn’t functional
  • Number input doesn’t render error messages when validation fails

package.json Outdated
"react-markdown": "^7.1.2",
"react-waypoint": "^10.3.0",
"react-webcam": "^7.2.0",
"yup": "^1.4.0"
"yup": "^1.4.0",
Copy link
Member Author

@samuelmale samuelmale Jul 11, 2024

Choose a reason for hiding this comment

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

Remove yup and formik plus other unused libraries

@samuelmale samuelmale closed this Jul 11, 2024
@samuelmale samuelmale reopened this Jul 11, 2024
@samuelmale samuelmale marked this pull request as draft July 11, 2024 14:52
@gracepotma
Copy link
Contributor

@ibacher @brandones and @denniskigen - Samuel would love your early impressions on this work to migrate the engine to RHF :)

@brandones
Copy link
Contributor

Wow, this is a big change, and deals with a lot of stuff I haven't really worked on. Sorry I'm not really in a good position to review this.

@brandones brandones removed their request for review July 23, 2024 03:08
@slubwama
Copy link

@samuelmale resolve conflicts

Copy link
Member

@ibacher ibacher left a comment

Choose a reason for hiding this comment

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

Throwing up a few comments for you.

Comment on lines +64 to +74
const rootForm = useRef<FormContextProps>();
const subForms = useRef<Record<string, FormContextProps>>({});
Copy link
Member

Choose a reason for hiding this comment

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

Why are we using Refs here?

Copy link
Member Author

Choose a reason for hiding this comment

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

The reason I used refs is that they won’t trigger a re-render on subsequent state updates. Do you want me to use the standard useState?

Copy link
Member

Choose a reason for hiding this comment

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

No, I guess since we're not sending them anywhere it's probably ok.

src/provider/form-factory-provider.tsx Outdated Show resolved Hide resolved
setCurrentPage,
handleConfirmQuestionDeletion,
}}>
{formProcessors.current && children}
Copy link
Member

Choose a reason for hiding this comment

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

Since we set formProcessors() above, won't this condition always be true?

Copy link
Member Author

Choose a reason for hiding this comment

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

Well it appears redundant for now because I'm currently hardcoding the form processors. I left myself a TODO which suggests that the processors will be promised.

  // TODO: Manage and load processors from the registry
  const formProcessors = useRef<Record<string, FormProcessorConstructor>>({
    EncounterFormProcessor: EncounterFormProcessor,
  });

Copy link
Member

Choose a reason for hiding this comment

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

A slightly different way to handle it would be to put the form processors in a separate component with a separate context and just wrap the component in Suspense until they're loaded, e.g.,

<Suspense fallback={null}>
  <FormProcessorProvider>
    {children}
  </FormProcessorProvider>
</Suspense>

visit,
});
const { formFields: rawFormFields, conceptReferences } = useFormFields(formJson);
const { concepts: formFieldsConcepts, isLoading: isLoadingConcepts } = useConcepts(conceptReferences);
Copy link
Member

Choose a reason for hiding this comment

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

isLoading here seems to be unused?

Copy link
Member

Choose a reason for hiding this comment

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

I'd also add that this was one of the sort of requests I'd hoped we could avoid with the o3forms endpoint, but if that doesn't work for some reason, let me know.

Copy link
Member Author

Choose a reason for hiding this comment

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

I agree that loading all the concepts is an expensive operation, and we should rely on backend optimization. I've tried loading a form using the O3 forms endpoint, but in my experience, it doesn't seem to resolve missing labels (by inferring them from the concept).

For us to fully depend on the O3 forms endpoint, we need to add support for resolving subforms.

Copy link
Member

Choose a reason for hiding this comment

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

it doesn't seem to resolve missing labels

Ah, yes, it doesn't magically create label elements. Basically, this was so, in the NGX engine, we could override the label with translations or, absent those, the concept name, since the concept name isn't always the appropriate string. That said, the endpoint should give you an element called "conceptReferences" which has, for every concept in the form, the concept's UUID and display name.

I guess that's a good point about subforms, but you could always just request them as separate forms for now.

Copy link
Member Author

Choose a reason for hiding this comment

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

Since the o3forms endpoints now supports subforms and correctly queries forms by name, I'm gonna create a ticket to track the work of fully leveraging this backend optimisation.

const {
isLoadingInitialValues,
initialValues,
error: initialValuesError,
Copy link
Member

Choose a reason for hiding this comment

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

Should this value be used for something?

Comment on lines 66 to 101
if (formFieldAdapters) {
setProcessorContext((prev) => ({
...prev,
...{ formFieldAdapters },
}));
}
if (formFieldValidators) {
setProcessorContext((prev) => ({
...prev,
...{ formFieldValidators },
}));
}
if (formFieldsWithMeta?.length) {
setProcessorContext((prev) => ({
...prev,
...{ formFields: formFieldsWithMeta },
}));
} else if (rawFormFields?.length) {
setProcessorContext((prev) => ({
...prev,
...{ formFields: rawFormFields },
}));
}
Copy link
Member

Choose a reason for hiding this comment

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

Maybe something like this:

Suggested change
if (formFieldAdapters) {
setProcessorContext((prev) => ({
...prev,
...{ formFieldAdapters },
}));
}
if (formFieldValidators) {
setProcessorContext((prev) => ({
...prev,
...{ formFieldValidators },
}));
}
if (formFieldsWithMeta?.length) {
setProcessorContext((prev) => ({
...prev,
...{ formFields: formFieldsWithMeta },
}));
} else if (rawFormFields?.length) {
setProcessorContext((prev) => ({
...prev,
...{ formFields: rawFormFields },
}));
}
setProcessorContext((prev) => merge({}, prev, formFieldAdapters ?? {}, formFieldValidators ? {}, formFieldsWithMeta?.length ? {formFields: formFieldsWithMeta} : {}, rawFormFields?.length ? { formFields: rawFormFields } : {}));

Copy link
Member

Choose a reason for hiding this comment

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

merge from lodash to support deep merging, but otherwise Object.assign() would be the same as this with many fewer setState() calls.

Copy link
Member Author

Choose a reason for hiding this comment

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

This is a good solution, but it ends up losing my object references in memory due to the deep clone. This is problematic because I would have to manually reconcile the fields in the formJson tree versus the flattened fields. This can lead to increased memory usage and potential inconsistencies, as I will end up with two separate instances for each form field.

I've ended doing something like:

setProcessorContext((prev) => ({
      ...prev,
      ...(formFieldAdapters && { formFieldAdapters }),
      ...(formFieldValidators && { formFieldValidators }),
      ...(formFieldsWithMeta?.length
        ? { formFields: formFieldsWithMeta }
        : rawFormFields?.length
        ? { formFields: rawFormFields }
        : {}),
    }));

Copy link
Member

Choose a reason for hiding this comment

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

Ah! Fair enough. Still... involves fewer calls to the setProcessContext(), which is basically the whole point.

const subForms = useRef<Record<string, FormContextProps>>({});
const layoutType = useLayoutType();
const { isSubmitting, setIsSubmitting, onSubmit, onError, handleClose } = formSubmissionProps;
const registerForm = (formId: string, context: FormContextProps, isSubForm: boolean) => {
Copy link
Member

Choose a reason for hiding this comment

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

This should probably be wrapped in useCallback()

Copy link
Member

Choose a reason for hiding this comment

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

I also wonder about the isSubForm parameter. Shouldn't the assumption be something like the first form registered is the root form and every other form is a subform, so something like:

if (!rootForm.current) {
  rootForm.current = context;
} else {
  subForm.current[formId] = context;
}

Copy link
Member Author

Choose a reason for hiding this comment

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

Nice one!

Comment on lines 122 to 123
getSubForms,
getRootForm,
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 a use case for providing these methods to children? The only use I see of them is in the "submitting" hook and it's better if we expose as minimal an API as possible to each child.

Copy link
Member Author

Choose a reason for hiding this comment

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

Nice argument! Updating 🧑‍🍳

@samuelmale samuelmale marked this pull request as ready for review August 6, 2024 18:40
Copy link

github-actions bot commented Aug 7, 2024

Size Change: -417 kB (-26.75%) 🎉

Total Size: 1.14 MB

Filename Size Change
dist/184.js 0 B -11.2 kB (removed) 🏆
dist/474.js 0 B -116 kB (removed) 🏆
dist/888.js 0 B -259 kB (removed) 🏆
dist/main.js 340 kB -17.5 kB (-4.89%)
ℹ️ View Unchanged
Filename Size Change
dist/151.js 300 kB 0 B
dist/194.js 0 B -9.51 kB (removed) 🏆
dist/225.js 2.57 kB 0 B
dist/277.js 1.84 kB 0 B
dist/3.js 480 B 0 B
dist/300.js 709 B 0 B
dist/335.js 967 B 0 B
dist/353.js 3.02 kB 0 B
dist/41.js 3.36 kB 0 B
dist/422.js 6.79 kB 0 B
dist/540.js 2.63 kB 0 B
dist/55.js 756 B 0 B
dist/572.js 251 kB 0 B
dist/635.js 14.3 kB 0 B
dist/674.js 86.4 kB +1 B (0%)
dist/70.js 0 B -482 B (removed) 🏆
dist/733.js 107 kB 0 B
dist/776.js 0 B -3.2 kB (removed) 🏆
dist/901.js 11.8 kB 0 B
dist/99.js 690 B 0 B
dist/993.js 3.09 kB 0 B
dist/openmrs-form-engine-lib.js 3.67 kB -51 B (-1.37%)

compressed-size-action

@ibacher ibacher merged commit 1e744c8 into main Aug 7, 2024
4 checks passed
@ibacher ibacher deleted the feat/migrateToRHF branch August 7, 2024 11:57
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.

5 participants