Skip to content

Commit

Permalink
CareUI: Adds a higher-order Form component (not used anywhere) (#4633)
Browse files Browse the repository at this point in the history
* remove `reducerProps` to make things simpler

* Implements `Form` HOC and `FormContext`

* removes unused reducer props from search input

* RequiredFieldValidator to support custom message

* consume form context and resolve props internally

* rename 'props' to 'field' to strictly break if incorrect migration

* migrate existing fields

* fix month form field
  • Loading branch information
rithviknishad authored and rabilrbl committed Feb 6, 2023
1 parent 030d6ec commit 819614b
Show file tree
Hide file tree
Showing 18 changed files with 346 additions and 229 deletions.
16 changes: 7 additions & 9 deletions src/Components/Common/DiagnosisSelectFormField.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -5,17 +5,15 @@ import { AutocompleteMutliSelect } from "../Form/FormFields/AutocompleteMultisel
import FormField from "../Form/FormFields/FormField";
import {
FormFieldBaseProps,
resolveFormFieldChangeEventHandler,
useFormFieldPropsResolver,
} from "../Form/FormFields/Utils";

type Props =
// | ({ multiple?: false | undefined } & FormFieldBaseProps<ICD11DiagnosisModel>) // uncomment when single select form field is required and implemented.
{ multiple: true } & FormFieldBaseProps<ICD11DiagnosisModel[]>;

export function DiagnosisSelectFormField(props: Props) {
const { name } = props;
const handleChange = resolveFormFieldChangeEventHandler(props);

const field = useFormFieldPropsResolver(props);
const { fetchOptions, isLoading, options } =
useAsyncOptions<ICD11DiagnosisModel>("id");

Expand All @@ -28,17 +26,17 @@ export function DiagnosisSelectFormField(props: Props) {
}

return (
<FormField props={props}>
<FormField field={field}>
<AutocompleteMutliSelect
id={props.id}
disabled={props.disabled}
value={props.value || []}
id={field.id}
disabled={field.disabled}
value={field.value || []}
onChange={field.handleChange}
options={options(props.value)}
optionLabel={(option) => option.label}
optionValue={(option) => option}
onQuery={(query) => fetchOptions(listICD11Diagnosis({ query }, ""))}
isLoading={isLoading}
onChange={(value) => handleChange({ name, value })}
/>
</FormField>
);
Expand Down
17 changes: 8 additions & 9 deletions src/Components/Common/SymptomsSelect.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ import { SYMPTOM_CHOICES } from "../../Common/constants";
import FormField from "../Form/FormFields/FormField";
import {
FormFieldBaseProps,
resolveFormFieldChangeEventHandler,
useFormFieldPropsResolver,
} from "../Form/FormFields/Utils";
import MultiSelectMenuV2 from "../Form/MultiSelectMenuV2";

Expand All @@ -18,12 +18,11 @@ const ASYMPTOMATIC_ID = 1;
* - For other scenarios, this simply works like a `MultiSelect`.
*/
export const SymptomsSelect = (props: FormFieldBaseProps<number[]>) => {
const { name } = props;
const handleChange = resolveFormFieldChangeEventHandler(props);
const field = useFormFieldPropsResolver(props);

const updateSelection = (value: number[]) => {
// Skip the complexities if no initial value was present
if (!props.value?.length) return handleChange({ name, value });
if (!props.value?.length) return field.handleChange(value);

const initialValue = props.value || [];

Expand All @@ -33,16 +32,16 @@ export const SymptomsSelect = (props: FormFieldBaseProps<number[]>) => {
if (asymptomaticIndex > -1) {
// unselect asym.
value.splice(asymptomaticIndex, 1);
return handleChange({ name, value });
return field.handleChange(value);
}
}

if (!initialValue.includes(ASYMPTOMATIC_ID) && value.includes(1)) {
// If new selections have asym., unselect everything else
return handleChange({ name, value: [ASYMPTOMATIC_ID] });
return field.handleChange([ASYMPTOMATIC_ID]);
}

handleChange({ name, value });
field.handleChange(value);
};

const getDescription = ({ id }: { id: number }) => {
Expand All @@ -69,9 +68,9 @@ export const SymptomsSelect = (props: FormFieldBaseProps<number[]>) => {
};

return (
<FormField props={props}>
<FormField field={field}>
<MultiSelectMenuV2
id={props.id}
id={field.id}
options={SYMPTOM_CHOICES}
disabled={props.disabled}
placeholder="Select symptoms"
Expand Down
12 changes: 7 additions & 5 deletions src/Components/Form/FieldValidators.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,8 +7,8 @@ export type FieldValidator<T> = (value: T) => FieldError;
* <EmailField
* ...
* validate={MultiValidator([
* RequiredFieldValidator,
* EmailValidator,
* RequiredFieldValidator(),
* EmailValidator(),
* ])}
* ...
* />
Expand All @@ -29,7 +29,9 @@ export const MultiValidator = <T>(
return validator;
};

export const RequiredFieldValidator = <T>(value: T): FieldError => {
if (value === undefined || (typeof value === "string" && value === ""))
return "Field is required";
export const RequiredFieldValidator = (message = "Field is required") => {
return <T>(value: T): FieldError => {
if (!value) return message;
if (Array.isArray(value) && value.length === 0) return message;
};
};
123 changes: 123 additions & 0 deletions src/Components/Form/Form.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,123 @@
import { isEmpty, omitBy } from "lodash";
import { useEffect, useReducer, useState } from "react";
import { classNames } from "../../Utils/utils";
import { Cancel, Submit } from "../Common/components/ButtonV2";
import { FieldValidator } from "./FieldValidators";
import { FormContext, FormContextValue } from "./FormContext";
import { FieldChangeEvent } from "./FormFields/Utils";
import { FormDetails, FormErrors, formReducer, FormReducer } from "./Utils";

type Props<T extends FormDetails> = {
context: FormContext<T>;
className?: string;
defaults: T;
asyncGetDefaults?: (() => Promise<T>) | false;
onlyChild?: boolean;
validate?: (form: T) => FormErrors<T>;
onSubmit: (form: T) => Promise<FormErrors<T> | void>;
onCancel?: () => void;
noPadding?: true;
disabled?: boolean;
submitLabel?: string;
cancelLabel?: string;
children: (props: FormContextValue<T>) => React.ReactNode;
};

const Form = <T extends FormDetails>({
asyncGetDefaults,
validate,
...props
}: Props<T>) => {
const initial = { form: props.defaults, errors: {} };
const [isLoading, setIsLoading] = useState(!!asyncGetDefaults);
const [state, dispatch] = useReducer<FormReducer<T>>(formReducer, initial);

useEffect(() => {
if (!asyncGetDefaults) return;

asyncGetDefaults().then((form) => {
dispatch({ type: "set_form", form });
setIsLoading(false);
});
}, [asyncGetDefaults]);

const handleSubmit = async (event: React.FormEvent<HTMLFormElement>) => {
event.preventDefault();

if (validate) {
const errors = omitBy(validate(state.form), isEmpty) as FormErrors<T>;

if (Object.keys(errors).length) {
dispatch({ type: "set_errors", errors });
return;
}
}

const errors = await props.onSubmit(state.form);
if (errors) {
dispatch({
type: "set_errors",
errors: { ...state.errors, ...errors },
});
}
};

const { Provider, Consumer } = props.context;
const disabled = isLoading || props.disabled;

return (
<form
onSubmit={handleSubmit}
className={classNames(
"bg-white rounded w-full max-w-3xl mx-auto",
!props.noPadding && "px-8 md:px-16 py-5 md:py-11",
props.className
)}
noValidate
>
<Provider
value={(name: keyof T, validate?: FieldValidator<T[keyof T]>) => {
return {
name,
id: name,
onChange: ({ name, value }: FieldChangeEvent<T[keyof T]>) =>
dispatch({
type: "set_field",
name,
value,
error: validate && validate(value),
}),
value: state.form[name],
error: state.errors[name],
disabled,
};
}}
>
{props.onlyChild ? (
<Consumer>{props.children}</Consumer>
) : (
<>
<div className="my-6 flex flex-col gap-4">
<Consumer>{props.children}</Consumer>
</div>
<div className="flex justify-end gap-2">
<Cancel
className="w-full md:w-auto"
onClick={props.onCancel}
label={props.cancelLabel ?? "Cancel"}
/>
<Submit
type="submit"
className="w-full md:w-auto"
disabled={disabled}
label={props.submitLabel ?? "Submit"}
/>
</div>
</>
)}
</Provider>
</form>
);
};

export default Form;
19 changes: 19 additions & 0 deletions src/Components/Form/FormContext.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
import { Context, createContext } from "react";
import { FieldError, FieldValidator } from "./FieldValidators";
import { FormDetails } from "./Utils";

export type FormContextValue<T extends FormDetails> = (
name: keyof T,
validate?: FieldValidator<T[keyof T]>
) => {
id: keyof T;
name: keyof T;
onChange: any;
value: any;
error: FieldError | undefined;
};

export type FormContext<T extends FormDetails> = Context<FormContextValue<T>>;

export const createFormContext = <T extends FormDetails>() =>
createContext<FormContextValue<T>>(undefined as any);
23 changes: 9 additions & 14 deletions src/Components/Form/FormFields/Autocomplete.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,10 +3,7 @@ import { Combobox } from "@headlessui/react";
import { DropdownTransition } from "../../Common/components/HelperComponents";
import CareIcon from "../../../CAREUI/icons/CareIcon";
import { dropdownOptionClassNames } from "../MultiSelectMenuV2";
import {
FormFieldBaseProps,
resolveFormFieldChangeEventHandler,
} from "./Utils";
import { FormFieldBaseProps, useFormFieldPropsResolver } from "./Utils";
import FormField from "./FormField";

type OptionCallback<T, R> = (option: T) => R;
Expand All @@ -24,24 +21,22 @@ type AutocompleteFormFieldProps<T, V> = FormFieldBaseProps<V> & {
const AutocompleteFormField = <T, V>(
props: AutocompleteFormFieldProps<T, V>
) => {
const { name } = props;
const handleChange = resolveFormFieldChangeEventHandler(props);

const field = useFormFieldPropsResolver(props);
return (
<FormField props={props}>
<FormField field={field}>
<Autocomplete
id={props.id}
id={field.id}
disabled={field.disabled}
required={field.required}
className={field.className}
value={field.value}
onChange={(value: any) => field.handleChange(value)}
options={props.options}
disabled={props.disabled}
value={props.value}
placeholder={props.placeholder}
optionLabel={props.optionLabel}
optionIcon={props.optionIcon}
optionValue={props.optionValue}
className={props.className}
required={props.required}
onQuery={props.onQuery}
onChange={(value: any) => handleChange({ name, value })}
/>
</FormField>
);
Expand Down
16 changes: 6 additions & 10 deletions src/Components/Form/FormFields/AutocompleteMultiselect.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,10 +2,7 @@ import React, { useEffect, useState } from "react";
import { Combobox } from "@headlessui/react";
import { DropdownTransition } from "../../Common/components/HelperComponents";
import CareIcon from "../../../CAREUI/icons/CareIcon";
import {
FormFieldBaseProps,
resolveFormFieldChangeEventHandler,
} from "./Utils";
import { FormFieldBaseProps, useFormFieldPropsResolver } from "./Utils";
import FormField from "./FormField";
import {
dropdownOptionClassNames,
Expand All @@ -26,15 +23,14 @@ type AutocompleteMultiSelectFormFieldProps<T, V> = FormFieldBaseProps<V[]> & {
const AutocompleteMultiSelectFormField = <T, V>(
props: AutocompleteMultiSelectFormFieldProps<T, V>
) => {
const { name } = props;
const handleChange = resolveFormFieldChangeEventHandler(props);

const field = useFormFieldPropsResolver(props);
return (
<FormField props={props}>
<FormField field={field}>
<AutocompleteMutliSelect
{...props}
value={props.value || []}
onChange={(value) => handleChange({ name, value })}
{...field}
value={field.value || []}
onChange={field.handleChange}
/>
</FormField>
);
Expand Down
27 changes: 10 additions & 17 deletions src/Components/Form/FormFields/DateFormField.tsx
Original file line number Diff line number Diff line change
@@ -1,11 +1,7 @@
import { classNames } from "../../../Utils/utils";
import DateInputV2, { DatePickerPosition } from "../../Common/DateInputV2";
import FormField from "./FormField";
import {
FormFieldBaseProps,
resolveFormFieldChangeEventHandler,
resolveFormFieldError,
} from "./Utils";
import { FormFieldBaseProps, useFormFieldPropsResolver } from "./Utils";

type Props = FormFieldBaseProps<Date> & {
placeholder?: string;
Expand All @@ -30,22 +26,19 @@ type Props = FormFieldBaseProps<Date> & {
* />
* ```
*/
const DateFormField = ({ position = "RIGHT", ...props }: Props) => {
const handleChange = resolveFormFieldChangeEventHandler(props);
const error = resolveFormFieldError(props);
const name = props.name;

const DateFormField = (props: Props) => {
const field = useFormFieldPropsResolver(props as any);
return (
<FormField props={props}>
<FormField field={field}>
<DateInputV2
className={classNames(error && "border-red-500")}
id={props.id}
value={props.value}
onChange={(value) => handleChange({ name, value })}
className={classNames(field.error && "border-red-500")}
id={field.id}
value={field.value}
onChange={field.handleChange}
disabled={field.disabled}
max={props.max || (props.disableFuture ? new Date() : undefined)}
min={props.min || (props.disablePast ? yesterday() : undefined)}
position={position}
disabled={props.disabled}
position={props.position || "RIGHT"}
placeholder={props.placeholder}
/>
</FormField>
Expand Down
Loading

0 comments on commit 819614b

Please sign in to comment.