diff --git a/apps/demo/src/app/config.ts b/apps/demo/src/app/config.ts index 7dcbdcc..18fad73 100644 --- a/apps/demo/src/app/config.ts +++ b/apps/demo/src/app/config.ts @@ -14,13 +14,33 @@ export const config = { choices: ['male', 'female'] }, type: 'select', - defaultValue: 'male' + dependsOn: [ + 'email', + { fieldId: 'discloseGender', key: 'isTrue', validate: true } + ], + validation: { + required: { + key: 'required', + message: 'Required field', + value: true + } + } + }, + discloseGender: { + id: 'discloseGender', + title: 'discloseGender checkbox', + meta: { + label: 'I agree to disclose my gender', + name: 'discloseGender' + }, + type: 'checkbox', + dependsOn: ['email'] }, birthdate: { id: 'birthdate', meta: { errorMessage: 'Birth date invalid', - label: 'Birth date', + label: 'Birthdate', name: 'birthdate' }, title: 'birthdate', @@ -73,6 +93,7 @@ export const config = { }, title: 'firstName', type: 'text', + dependsOn: ['email'], validation: { checkPattern: { key: 'checkPattern', @@ -95,12 +116,18 @@ export const config = { }, title: 'lastName', type: 'text', + dependsOn: ['firstName', 'gender', 'email'], validation: { maxLength: { key: 'checkMaxLength', message: 'Maximum input length', value: 20 }, + minLength: { + key: 'checkMinLength', + message: 'Minimum input length', + value: 2 + }, required: { key: 'required', message: 'Required field', @@ -145,7 +172,13 @@ export const config = { }, steps: { 'register-step-0': { - fieldsById: ['email', 'gender'], + fieldsById: [ + 'email', + 'discloseGender', + 'gender', + 'firstName', + 'lastName' + ], id: 'register-step-0', meta: { subtitle: 'Email', @@ -167,19 +200,8 @@ export const config = { } }, 'register-step-2': { - fieldsById: ['firstName', 'lastName'], - id: 'register-step-2', - meta: { - subtitle: 'First name and Last name', - title: 'First name and Last name' - }, - submit: { - label: 'Next' - } - }, - 'register-step-3': { fieldsById: ['birthdate'], - id: 'register-step-3', + id: 'register-step-2', meta: { subtitle: 'Birthdate', title: 'Birthdate' @@ -189,12 +211,7 @@ export const config = { } } }, - stepsById: [ - 'register-step-0', - 'register-step-1', - 'register-step-2', - 'register-step-3' - ] + stepsById: ['register-step-0', 'register-step-1', 'register-step-2'] }, single_step_register: { fields: { diff --git a/apps/demo/src/app/examples/with-material-ui/dictionary.ts b/apps/demo/src/app/examples/with-material-ui/dictionary.ts index c97a7aa..d535830 100644 --- a/apps/demo/src/app/examples/with-material-ui/dictionary.ts +++ b/apps/demo/src/app/examples/with-material-ui/dictionary.ts @@ -4,12 +4,14 @@ import { DateInput } from './dictionary/date.component'; import { Submit } from './dictionary/submit.component'; import { Select } from './dictionary/select.component'; import { Previous } from './dictionary/previous.component'; +import { Checkbox } from './dictionary/checkBox.component'; export const dictionary = { text: Text, password: Password, date: DateInput, select: Select, + checkbox: Checkbox, submit: Submit, previous: Previous }; diff --git a/apps/demo/src/app/examples/with-material-ui/dictionary/checkBox.component.tsx b/apps/demo/src/app/examples/with-material-ui/dictionary/checkBox.component.tsx new file mode 100644 index 0000000..bb78d50 --- /dev/null +++ b/apps/demo/src/app/examples/with-material-ui/dictionary/checkBox.component.tsx @@ -0,0 +1,62 @@ +import * as React from 'react'; +import { Ref, useMemo } from 'react'; +import { FieldErrors } from 'react-hook-form'; + +import FormGroup from '@mui/material/FormGroup'; +import FormControlLabel from '@mui/material/FormControlLabel'; +import MUICheckbox from '@mui/material/Checkbox'; +import Box from '@mui/material/Box'; + +export const Checkbox = ({ + 'data-testid': dataTestId, + errorMessage, + errors, + id, + label, + name, + onBlur, + onChange, + optionalText, + propRef, + value +}: { + 'data-testid': string; + errorMessage: string; + errors: FieldErrors; + id: string; + label: string; + name: string; + onBlur: (event: any) => void; + onChange: (event: React.ChangeEvent) => void; + optionalText?: string; + propRef: Ref; + type?: string; + value?: boolean; +}) => { + const inputProps = useMemo( + () => ({ ref: propRef, 'aria-label': 'controlled' }), + [propRef] + ); + const error = errors && errors.type && errorMessage; + + return ( + + + + } + /> + + + ); +}; diff --git a/apps/demo/src/app/examples/with-material-ui/form.component.tsx b/apps/demo/src/app/examples/with-material-ui/form.component.tsx index f2fa4f1..2f401e2 100644 --- a/apps/demo/src/app/examples/with-material-ui/form.component.tsx +++ b/apps/demo/src/app/examples/with-material-ui/form.component.tsx @@ -36,7 +36,9 @@ const defaultValues = { firstName: '', lastName: '', birthdate: '', - password: '' + password: '', + gender: '', + newsletter: true }; const { diff --git a/apps/demo/src/app/examples/with-styled-components/dictionary.ts b/apps/demo/src/app/examples/with-styled-components/dictionary.ts index fa42fc1..59be1a4 100644 --- a/apps/demo/src/app/examples/with-styled-components/dictionary.ts +++ b/apps/demo/src/app/examples/with-styled-components/dictionary.ts @@ -3,11 +3,15 @@ import { Password } from './dictionary/password.component'; import { DateInput } from './dictionary/date.component'; import { Submit } from './dictionary/submit.component'; import { Previous } from './dictionary/previous.component'; +import { Checkbox } from './dictionary/checkBox.component'; +import { Select } from './dictionary/select.component'; export const dictionary = { text: Text, password: Password, date: DateInput, submit: Submit, - previous: Previous + previous: Previous, + checkbox: Checkbox, + select: Select }; diff --git a/apps/demo/src/app/examples/with-styled-components/dictionary/checkBox.component.tsx b/apps/demo/src/app/examples/with-styled-components/dictionary/checkBox.component.tsx new file mode 100644 index 0000000..9062cab --- /dev/null +++ b/apps/demo/src/app/examples/with-styled-components/dictionary/checkBox.component.tsx @@ -0,0 +1,17 @@ +export const Checkbox = ({ + onChange, + value, + label, + ...props +}: { + onChange: (event: React.ChangeEvent) => void; + value: boolean; + label: string; +}) => { + return ( +
+ + +
+ ); +}; diff --git a/apps/demo/src/app/examples/with-styled-components/dictionary/gender.component.tsx b/apps/demo/src/app/examples/with-styled-components/dictionary/gender.component.tsx new file mode 100644 index 0000000..e69de29 diff --git a/apps/demo/src/app/examples/with-styled-components/dictionary/select.component.tsx b/apps/demo/src/app/examples/with-styled-components/dictionary/select.component.tsx new file mode 100644 index 0000000..2d92160 --- /dev/null +++ b/apps/demo/src/app/examples/with-styled-components/dictionary/select.component.tsx @@ -0,0 +1,28 @@ +export const Select = ({ + onChange, + value, + label, + choices, + multiple +}: { + onChange: (event: React.ChangeEvent) => void; + value: string | number; + label: string; + choices: string[] | number[]; + multiple?: boolean; +}) => { + return ( +
+ +
+ ); +}; diff --git a/apps/demo/src/app/examples/with-styled-components/dictionary/styled.js b/apps/demo/src/app/examples/with-styled-components/dictionary/styled.js index 7b12c00..44ccf90 100644 --- a/apps/demo/src/app/examples/with-styled-components/dictionary/styled.js +++ b/apps/demo/src/app/examples/with-styled-components/dictionary/styled.js @@ -2,7 +2,7 @@ import styled from 'styled-components'; export const TextFieldMarginWrapper = styled.div` margin-top: 20px; - margin-bottom: 50px; + margin-bottom: 20px; `; export const TextFieldTopMarginWrapper = styled.div` diff --git a/apps/demo/src/app/extraValidation.ts b/apps/demo/src/app/extraValidation.ts index 4327f00..96f7c1b 100644 --- a/apps/demo/src/app/extraValidation.ts +++ b/apps/demo/src/app/extraValidation.ts @@ -1,6 +1,7 @@ import _ from 'lodash'; import { getUserAge, isDateValid } from '@forms/examples/birthdate'; +import { ExtraValidation } from '@bedrockstreaming/form-builder'; const config = { onboarding: { @@ -33,7 +34,8 @@ export const extraValidation = { checkForUpper: () => (input: string) => /[A-Z]+/g.test(input), checkForLower: () => (input: string) => /[a-z]+/g.test(input), checkForNumber: () => (input: string) => /\d+/g.test(input), - isChecked: () => (value?: string | number) => !!value, + isChecked: () => (value?: boolean) => !!value, checkPattern: (value: string) => (input: string) => - new RegExp(value).test(input) -}; + new RegExp(value).test(input), + isTrue: () => (input: boolean) => input === true +} as ExtraValidation; diff --git a/apps/docsite/docs/form-builder.md b/apps/docsite/docs/form-builder.md index a96649d..c7c7a6d 100644 --- a/apps/docsite/docs/form-builder.md +++ b/apps/docsite/docs/form-builder.md @@ -23,15 +23,77 @@ In order to create a form using this library, you simply need to import the `For You should provide a `schema` with the following structure: +```ts +import { + DeepMap, + DeepPartial, + Path, + PathValue, + UnionLike, + UnpackNestedValue, +} from 'react-hook-form'; + +export interface FormSchema { + fields: { + [FieldId: string]: { + id: string; + title: string; + type: string; + meta?: { + [key: string]: unknown; + }; + dependsOn?: Array< + | string + | { + key: string; + value?: string | number | null | string[] | number[]; + callback: string; + } + >; + validation?: { + [key: string]: { + key: string; + type?: string; + message: string; + value?: unknown; + }; + }; + defaultValue?: + | UnpackNestedValue> + | string + | number + | string[] + | number[] + | Path; + }; + }; + steps: { + [StepId: string]: { + id: string; + fieldsById: string[]; + submit: { + label: string; + }; + meta?: { + [key: string]: unknown; + }; + }; + }; + stepsById: string[]; +} +``` + +See this stripped down example below of a single input form + ```jsx const schema = { fields: { 'some-unique-identifier': { id: 'some-unique-identifier', title: 'First name', - type: 'text' + type: 'text', }, - ... + // ... }, steps: { 'step-foo': { @@ -42,7 +104,7 @@ const schema = { }, }, }, - stepsById: ['step-foo'] + stepsById: ['step-foo'], }; ``` @@ -60,6 +122,33 @@ const dictionary = { Make sure the `dictionary` keys corresponds to your fields types. +--- + +Dictionary components (field components) can use some defined props, + +Here are all the base props that will be passed to every FormField. + +```ts +export interface FormFieldProps { + id: string; + validation?: Validations; + errors?: ErrorOption; + setFieldValue?: SetFieldValue; + triggerValidationField?: (value: Path) => void; + propRef?: Ref; + disabled?: boolean; + label?: string; + onClick?: (event: any) => void; + isValidating?: boolean; +} +``` + +:::tip + +You can leverage the `meta` field property to pass more values to your field ! + +::: + ### onSubmit The `onSubmit` callback is called when submitting the form, it follows `react-hook-form` API. @@ -203,71 +292,145 @@ This library doesn't provide steps state management by default. You can implemen :bulb: If you are using redux, we have a slice ready for you :point_right: [@bedrockstreaming/form-redux](./form-redux.md) -## FormField - -The FormField specifics should be handled in the `dictionary` components -The FormField has props: - -- id: the unique identifier of the field -- fieldType: the dictionary type of the field -- dictionary: the list of implemented fields -- setFieldValue: a wrapper to the native react-hooks-form function `setValue`, allow to change the field value without making a controlled component -- errors: return from react-hook-form with errors type and message -- validation: get the validation rules from the form config - ## Dictionary -Dictionary components (field components) must accept three props: - -- propRef: the field registered ref -- name: the id of the field element -- onChange: the onChange callback - ## Validation -To do fields validation, we use the native implementation of react-hooks-form. We get access to a `rule` prop that is passed to our (currently controlled) components, which takes an object that can have several rules. +To do fields validation, we use the native implementation of `react-hooks-form`. We leverage it through the validation field property. -When we need more personalization in our validation for a special type of field for example, we need to do 2 things : +When we want to perform a complex or very specific validation, even async, we need to: - Create an object containing the custom validation functions and pass it to the `extraValidation` prop of the form-Builder - Reference those `extraValidation` functions in the schema config ```jsx - const extraValidation = { - 'customValidationFunction1': (valueFromSchema) => fieldValue => doCustomValidationHere(valueFromSchema, fieldValue), - }; +const extraValidation = { + customValidationFunction1: (valueFromSchema) => (fieldValue) => + doCustomValidationHere(valueFromSchema, fieldValue), +}; - const schema = { - fields: { - BIRTHDATE: { - ... - meta: { - ... +const schema = { + fields: { + birthdate: { + // [...] + validation: { + customValidationFunction1: { + // <-- this is a custom validation + key: 'customValidationFunction1', + message: 'forms.register.birthdate.minAgeError', + value: 13, }, - validation: { - customValidationFunction1: { // <-- this is a custom validation - key: 'customValidationFunction1', - message: 'forms.register.birthdate.minAgeError', - value: 13, - }, - required: { // <-- this is a default validation (native to react-hook-form) - key: 'required', - message: 'forms.required.error', - value: true, - }, + required: { + // <-- this is a default validation (native to react-hook-form) + key: 'required', + message: 'forms.required.error', + value: true, }, }, - } - }; + }, + }, +}; - const MyForm = () => ( - - ); +const MyForm = () => ( + +); - // More info on the official react-hooks-form doc : https://react-hook-form.com/get-started#Applyvalidation +// More info on the official react-hooks-form doc : https://react-hook-form.com/get-started#Applyvalidation ``` + +## Conditional Fields + +You can add a `dependsOn` entry in any of your field schema. + +```ts +export interface FormField { + // [...] + dependsOn?: Array< + | string // an other field id + | { + fieldId: string; // an other field id + key: string; // validation key + value?: string | number | null | string[] | number[]; // any serializable value, works the same way as validation + validate?: boolean; // perform an extra validation "manually" + } + >; +} +``` + +### Using strings + +When using a string, corresponding to a field id, the form builder will hide the field until those target field ids have been touched and validated. + +```jsx +const schema = { + fields: { + someField: { + id: 'someField', + // ... + }, + myConditionalField: { + id: 'myConditionalField', + dependsOn: [ + { + key: 'someField', + callback: 'customValidationFunction1', + value: 13, + }, + ], + }, + }, +}; +``` + +### Using objects + +Otherwise, when using objects, you can either check for a specific validation error or leverage the `extraValidation` functions to execute a + +```jsx +const extraValidation = { + customValidationFunction1: (valueFromSchema) => (fieldValue) => + doCustomValidationHere(valueFromSchema, fieldValue), + customValidationFunction2: (valueFromSchema) => (fieldValue) => + valueFromSchema === fieldValue, +}; + +const schema = { + fields: { + someField: { + id: 'someField', + validation: { + key: 'customValidationFunction2' + value: 'foo', + message: 'Some error message' + } + // ... + }, + myConditionalField: { + id: 'myConditionalField', + dependsOn: [ + { + fieldId: 'someField', + key: 'customValidationFunction1', + value: 13, + validate: true, + }, + { + fieldId: 'someField', + key: 'customValidationFunction2', + }, + ], + }, + }, +}; +``` + +:::tip + +When using boolean values (e.g. for checkbox), there is no other way than asserting the opposite of the default value to display a conditional field + +::: diff --git a/apps/docsite/docs/overview.md b/apps/docsite/docs/overview.md index effee1e..cf4f732 100644 --- a/apps/docsite/docs/overview.md +++ b/apps/docsite/docs/overview.md @@ -22,17 +22,22 @@ As we were eager to keep some control over the process, we went with [react-hook Here is a list of features we are supporting :white_check_mark: -:white_check_mark: Form Generation -:white_check_mark: Asynchronous Custom Validation -:white_check_mark: Multi Steps Forms +- :white_check_mark: Form Generation +- :white_check_mark: Asynchronous Custom Validation +- :white_check_mark: Multi Steps Forms +- :white_check_mark: Conditional Fields --- -Here is a list of features we are **not** supporting :x: +Here is a list of features we will **not** support :x: -:x: Form UI Components -:construction: Conditional Steps -:construction: Conditional Fields +- :x: Form UI Components + +--- + +Here is a list of features we will **probably** support :thinking: + +- :construction: Conditional Steps ## Why did we have to make it ? diff --git a/documentation/release.md b/documentation/release.md index 1298ce8..03c3c48 100644 --- a/documentation/release.md +++ b/documentation/release.md @@ -71,7 +71,3 @@ Then in the `workspace.json` file, add the publish target to your workspace vers } } ``` - -## Caveats - -:warning: Changelog are not properly pushed on Github, bug is being investigated in `@jscutlery/semver` diff --git a/libs/form-builder/README.md b/libs/form-builder/README.md index cbdd79e..0ba1087 100644 --- a/libs/form-builder/README.md +++ b/libs/form-builder/README.md @@ -20,15 +20,77 @@ In order to create a form using this library, you simply need to import the `For You should provide a `schema` with the following structure: +```ts +import { + DeepMap, + DeepPartial, + Path, + PathValue, + UnionLike, + UnpackNestedValue, +} from 'react-hook-form'; + +export interface FormSchema { + fields: { + [FieldId: string]: { + id: string; + title: string; + type: string; + meta?: { + [key: string]: unknown; + }; + dependsOn?: Array< + | string + | { + key: string; + value?: string | number | null | string[] | number[]; + callback: string; + } + >; + validation?: { + [key: string]: { + key: string; + type?: string; + message: string; + value?: unknown; + }; + }; + defaultValue?: + | UnpackNestedValue> + | string + | number + | string[] + | number[] + | Path; + }; + }; + steps: { + [StepId: string]: { + id: string; + fieldsById: string[]; + submit: { + label: string; + }; + meta?: { + [key: string]: unknown; + }; + }; + }; + stepsById: string[]; +} +``` + +See this stripped down example below of a single input form + ```jsx const schema = { fields: { 'some-unique-identifier': { id: 'some-unique-identifier', title: 'First name', - type: 'text' + type: 'text', }, - ... + // ... }, steps: { 'step-foo': { @@ -39,7 +101,7 @@ const schema = { }, }, }, - stepsById: ['step-foo'] + stepsById: ['step-foo'], }; ``` @@ -57,6 +119,37 @@ const dictionary = { Make sure the `dictionary` keys corresponds to your fields types. +--- + +Dictionary components (field components) must accept three props: + +- propRef: the field registered ref +- name: the id of the field element +- onChange: the onChange callback + +Here are all the base props that will be passed to every FormField. + +```ts +export interface FormFieldProps { + id: string; + validation?: Validations; + errors?: ErrorOption; + setFieldValue?: SetFieldValue; + triggerValidationField?: (value: Path) => void; + propRef?: Ref; + disabled?: boolean; + label?: string; + onClick?: (event: any) => void; + isValidating?: boolean; +} +``` + +:::tip + +You can leverage the `meta` field property to pass more values to your field ! + +::: + ### onSubmit The `onSubmit` callback is called when submitting the form, it follows `react-hook-form` API. @@ -198,75 +291,143 @@ const FormWrapper = () => { This library doesn't provide steps state management by default. You can implement your own step management logic through the `onNextStep` and `onPreviousStep` callbacks, there you can change the `currentStepIndex` prop passed to the `FormBuilder` as it is done in the previous example. -:bulb: If you are using redux, we have a slice ready for you :point_right: [@bedrockstreaming/form-redux](../form-redux/README.md) - -## FormField - -The FormField specifics should be handled in the `dictionary` components -The FormField has props: - -- id: the unique identifier of the field -- fieldType: the dictionary type of the field -- dictionary: the list of implemented fields -- setFieldValue: a wrapper to the native react-hooks-form function `setValue`, allow to change the field value without making a controlled component -- errors: return from react-hook-form with errors type and message -- validation: get the validation rules from the form config +:bulb: If you are using redux, we have a slice ready for you :point_right: [@bedrockstreaming/form-redux](./form-redux.md) ## Dictionary -Dictionary components (field components) must accept three props: - -- propRef: the field registered ref -- name: the id of the field element -- onChange: the onChange callback - ## Validation -To do fields validation, we use the native implementation of react-hooks-form. We get access to a `rule` prop that is passed to our (currently controlled) components, which takes an object that can have several rules. +To do fields validation, we use the native implementation of `react-hooks-form`. We leverage it through the validation field property. -When we need more personalization in our validation for a special type of field for example, we need to do 2 things : +When we want to perform a complex or very specific validation, even async, we need to: - Create an object containing the custom validation functions and pass it to the `extraValidation` prop of the form-Builder - Reference those `extraValidation` functions in the schema config ```jsx - const extraValidation = { - 'customValidationFunction1': (valueFromSchema) => fieldValue => doCustomValidationHere(valueFromSchema, fieldValue), - }; +const extraValidation = { + customValidationFunction1: (valueFromSchema) => (fieldValue) => + doCustomValidationHere(valueFromSchema, fieldValue), +}; - const schema = { - fields: { - birthDate: { - ... - meta: { - ... +const schema = { + fields: { + birthdate: { + // [...] + validation: { + customValidationFunction1: { + // <-- this is a custom validation + key: 'customValidationFunction1', + message: 'forms.register.birthdate.minAgeError', + value: 13, }, - validation: { - customValidationFunction1: { // <-- this is a custom validation - key: 'customValidationFunction1', - message: 'some.translated.message.minAgeError', - value: 13, - }, - required: { // <-- this is a default validation (native to react-hook-form) - key: 'required', - message: 'some.translated.message.requiredError', - value: true, - }, + required: { + // <-- this is a default validation (native to react-hook-form) + key: 'required', + message: 'forms.required.error', + value: true, }, }, - } - }; + }, + }, +}; - const MyForm = () => ( - - ); +const MyForm = () => ( + +); + +// More info on the official react-hooks-form doc : https://react-hook-form.com/get-started#Applyvalidation +``` + +## ConditionalFields + +You can add a `dependsOn` entry in any of your field schema. + +```ts +export interface FormField { + // [...] + dependsOn?: Array< + | string // an other field id + | { + fieldId: string; // an other field id + key: string; // validation key + value?: string | number | null | string[] | number[]; // any serializable value, works the same way as validation + validate?: boolean; // perform an extra validation "manually" + } + >; +} +``` + +### Using strings + +When using a string, corresponding to a field id, the form builder will hide the field until those target field ids have been touched and validated. + +```jsx +const schema = { + fields: { + someField: { + id: 'someField', + // ... + }, + myConditionalField: { + id: 'myConditionalField', + dependsOn: [ + { + key: 'someField', + callback: 'customValidationFunction1', + value: 13, + }, + ], + }, + }, +}; +``` + +### Using objects + +Otherwise, when using objects, you can either check for a specific validation error or leverage the `extraValidation` functions to execute a + +```jsx +const extraValidation = { + customValidationFunction1: (valueFromSchema) => (fieldValue) => + doCustomValidationHere(valueFromSchema, fieldValue), + customValidationFunction2: (valueFromSchema) => (fieldValue) => + valueFromSchema === fieldValue, +}; - // More info on the official react-hooks-form doc : https://react-hook-form.com/get-started#Applyvalidation +const schema = { + fields: { + someField: { + id: 'someField', + validation: { + key: 'customValidationFunction2' + value: 'foo', + message: 'Some error message' + } + // ... + }, + myConditionalField: { + id: 'myConditionalField', + dependsOn: [ + { + fieldId: 'someField', + key: 'customValidationFunction1', + value: 13, + validate: true, + }, + { + fieldId: 'someField', + key: 'customValidationFunction2', + }, + ], + }, + }, +}; ``` ## Examples diff --git a/libs/form-builder/src/lib/formBuilder.tsx b/libs/form-builder/src/lib/formBuilder.tsx index a6cebf9..ef8a7c6 100644 --- a/libs/form-builder/src/lib/formBuilder.tsx +++ b/libs/form-builder/src/lib/formBuilder.tsx @@ -26,8 +26,10 @@ import { SubmitField } from './components/submitField.component'; import { getFieldRules, FieldRules } from './utils/validation.utils'; import { PreviousStepField } from './components/previousStepField.component'; import { FORM_CLASSNAMES } from './constants'; +import { filterDependentsFieldsById } from './utils/conditionalFields.utils'; const EMPTY_OBJECT = {} as const; + export interface FormBuilderProps { defaultValues?: DefaultValues; behavior?: keyof ValidationMode; @@ -78,6 +80,14 @@ export function FormBuilder({ [currentStepIndex, schema, typesAllowed] ); + const filteredFields = filterDependentsFieldsById({ + fieldsById, + fields, + getValues, + errors, + extraValidation + }); + const validationRulesById = React.useMemo( () => _.reduce( @@ -139,7 +149,7 @@ export function FormBuilder({ {_.map(stepsById, (stepId) => ( - {_.map(fieldsById, (fieldId) => { + {_.map(filteredFields, (fieldId) => { const { type, id, defaultValue, meta, validation } = fields[fieldId]; diff --git a/libs/form-builder/src/lib/types.ts b/libs/form-builder/src/lib/types.ts index b0b9f2d..dd3d7fa 100644 --- a/libs/form-builder/src/lib/types.ts +++ b/libs/form-builder/src/lib/types.ts @@ -1,14 +1,13 @@ import * as React from 'react'; import { - DeepMap, - DeepPartial, Path, PathValue, - UnionLike, + FieldValues, + FieldNamesMarkedBoolean, UnpackNestedValue } from 'react-hook-form'; -export type DirtyFields = DeepMap>, true>; +export type DirtyFields = FieldNamesMarkedBoolean; export interface FormMeta { [key: string]: unknown; @@ -25,11 +24,19 @@ export interface Validations { [key: string]: Validation; } +export interface DependsOnObject { + fieldId: string; + key: string; + value?: string | number | null | string[] | number[]; + validate?: boolean; +} + export interface FormField { - id: Path; + id: string; title: string; type: string; meta?: FormMeta | undefined; + dependsOn?: Array; validation?: Validations | undefined; defaultValue?: | UnpackNestedValue> @@ -37,8 +44,7 @@ export interface FormField { | number | string[] | number[] - | Path - | undefined; + | Path; } export interface FormFields { diff --git a/libs/form-builder/src/lib/utils/conditionalFields.utils.ts b/libs/form-builder/src/lib/utils/conditionalFields.utils.ts new file mode 100644 index 0000000..d9f780b --- /dev/null +++ b/libs/form-builder/src/lib/utils/conditionalFields.utils.ts @@ -0,0 +1,83 @@ +import { UseFormGetValues, FieldValues, FieldErrors } from 'react-hook-form'; +import { ExtraValidation, FormField, FormFields } from '../types'; + +export interface ShouldDisplayField { + getValues: UseFormGetValues; + extraValidation?: ExtraValidation; + errors: FieldErrors; + dependsOn: FormField['dependsOn']; +} + +export const shouldDisplayField = ({ + dependsOn, + getValues, + extraValidation, + errors +}: ShouldDisplayField) => { + if (!dependsOn) return true; + + const dependsOnConditions = [] as boolean[]; + + dependsOn.forEach((dependRule) => { + // Validate at field level on string + if (typeof dependRule === 'string') { + return dependsOnConditions.push( + !!getValues(dependRule) && !errors[dependRule] + ); + } + const fieldValue = getValues(dependRule.fieldId); + const fieldError = errors[dependRule.fieldId]; + + // When the validate option is disabled + // Check for specific validation error + if (!dependRule.validate) { + const validationError = fieldError && fieldError[dependRule.key]; + return dependsOnConditions.push(!!fieldValue && !validationError); + } + + const validateMethod = extraValidation && extraValidation[dependRule.key]; + + // When validation method is missing from extraValidation, only assert on fieldError + if (!validateMethod) { + return dependsOnConditions.push(!!fieldValue && !fieldError); + } + + return dependsOnConditions.push( + !!validateMethod(dependRule.value)(fieldValue) && !fieldError + ); + }); + + return dependsOnConditions.filter((value) => !value).length === 0; +}; + +export interface FilterDependentsFieldsById { + fieldsById: string[]; + fields: FormFields; + getValues: UseFormGetValues; + extraValidation?: ExtraValidation; + errors: FieldErrors; +} + +export const filterDependentsFieldsById = ({ + fieldsById, + fields, + getValues, + extraValidation, + errors +}: FilterDependentsFieldsById) => { + return fieldsById.reduce((acc, fieldId) => { + const { dependsOn } = fields[fieldId]; + if (!dependsOn) { + return [...acc, fieldId]; + } + + return shouldDisplayField({ + dependsOn, + getValues, + extraValidation, + errors + }) + ? [...acc, fieldId] + : acc; + }, [] as string[]); +}; diff --git a/libs/form-builder/src/lib/utils/getSchemaInfo.util.ts b/libs/form-builder/src/lib/utils/getSchemaInfo.util.ts index f4afe8d..5a00fa3 100644 --- a/libs/form-builder/src/lib/utils/getSchemaInfo.util.ts +++ b/libs/form-builder/src/lib/utils/getSchemaInfo.util.ts @@ -16,11 +16,18 @@ export const sanitizeFieldsById = ( return typesAllowed.includes(type) && type !== SUBMIT_FIELD_TYPE; }); +export interface SchemaInfo { + fields: FormFields; + fieldsById: string[]; + submitLabel: string; + stepsById: string[]; +} + export const getSchemaInfo = ( schema: FormSchema, typesAllowed: string[], currentStepIndex: number -) => { +): SchemaInfo => { const steps = _.get(schema, 'steps'); const stepsById = _.get(schema, 'stepsById', EMPTY_ARRAY); const stepId = _.get(stepsById, currentStepIndex); diff --git a/package.json b/package.json index d921d0d..105d426 100644 --- a/package.json +++ b/package.json @@ -63,7 +63,7 @@ "prism-react-renderer": "1.2.1", "react": "17.0.2", "react-dom": "17.0.2", - "react-hook-form": "7.19.1", + "react-hook-form": "7.25.0", "react-redux": "7.2.6", "react-router-dom": "5.2.0", "redux": "4.1.2", diff --git a/yarn.lock b/yarn.lock index e02f470..626126b 100644 --- a/yarn.lock +++ b/yarn.lock @@ -17027,10 +17027,10 @@ react-helmet@^6.1.0: react-fast-compare "^3.1.1" react-side-effect "^2.1.0" -react-hook-form@7.19.1: - version "7.19.1" - resolved "https://registry.yarnpkg.com/react-hook-form/-/react-hook-form-7.19.1.tgz#df19c065a96cbd4275c4ca8992a9f5908b114a4f" - integrity sha512-e0Oii07qNAa72JeGUT5czVCMwdAFPxmxYvd1Y9oPy2KVD6ZGblN6DG1G7AwL9Bz2lOPFZu15SRNnn0Vpx/eGdg== +react-hook-form@7.25.0: + version "7.25.0" + resolved "https://registry.yarnpkg.com/react-hook-form/-/react-hook-form-7.25.0.tgz#ffba58e5e14c8eeb81e2c43b741667552f11ae29" + integrity sha512-MyF4YXegIT/vfyZloTm98mpJwLUPfULdX37yPzXeijT1hePCkV8DN1IAnEufxgtqCpc7aFGRinegQwisUGZCnA== react-is@^16.13.1, react-is@^16.6.0, react-is@^16.7.0, react-is@^16.8.1: version "16.13.1"