From 1854687145f30eb2f6f9b9a6eb906499d03d5b02 Mon Sep 17 00:00:00 2001 From: Jeff Puzzo Date: Mon, 27 Mar 2023 10:00:54 -0400 Subject: [PATCH 1/2] feat(FormContext): Add context for Form to manage input state --- packages/react-core/package.json | 2 +- .../src/components/Form/FormContext.tsx | 129 ++++++++++ .../Form/__tests__/FormContext.test.tsx | 238 ++++++++++++++++++ .../src/components/Form/examples/Form.md | 8 + .../components/Form/examples/FormState.tsx | 96 +++++++ .../react-core/src/components/Form/index.ts | 1 + packages/react-docs/package.json | 2 +- 7 files changed, 474 insertions(+), 2 deletions(-) create mode 100644 packages/react-core/src/components/Form/FormContext.tsx create mode 100644 packages/react-core/src/components/Form/__tests__/FormContext.test.tsx create mode 100644 packages/react-core/src/components/Form/examples/FormState.tsx diff --git a/packages/react-core/package.json b/packages/react-core/package.json index 05f6ce15cd9..f60c052202a 100644 --- a/packages/react-core/package.json +++ b/packages/react-core/package.json @@ -41,7 +41,7 @@ "scripts": { "build:umd": "rollup -c --environment IS_PRODUCTION", "build:single:packages": "node ../../scripts/build-single-packages.js --config single-packages.config.json", - "clean": "rimraf dist", + "clean": "rimraf dist components layouts helpers next deprecated node_modules", "generate": "node scripts/copyStyles.js", "subpaths": "node scripts/createSubpaths.js" }, diff --git a/packages/react-core/src/components/Form/FormContext.tsx b/packages/react-core/src/components/Form/FormContext.tsx new file mode 100644 index 00000000000..c9df359e827 --- /dev/null +++ b/packages/react-core/src/components/Form/FormContext.tsx @@ -0,0 +1,129 @@ +import React, { Dispatch, SetStateAction } from 'react'; + +export interface FormContextProps { + /** Record of values for all fieldIds */ + values: Record; + /** Record of errors for all fieldIds */ + errors: Record; + /** Record of touched state for all fieldIds */ + touched: Record; + /** Flag to determine the overall validity. True if the record of errors is empty. */ + isValid: boolean; + + /** Get the value for a given fieldId */ + getValue(fieldId: string): string; + /** Set the value for a given fieldId */ + setValue(fieldId: string, value: string): void; + /** Set multiple values within the managed record of values */ + setValues: Dispatch>>; + + /** Get the error message for a given fieldId */ + getError(fieldId: string): string; + /** Set the error message for a given fieldId */ + setError(fieldId: string, error: string | undefined): void; + /** Set multiple errors within the managed record of errors */ + setErrors: Dispatch>>; + + /** Used to determine touched state for a given fieldId */ + isTouched(fieldId: string): boolean; + /** Used to update the touched state for a given fieldId */ + setTouched(fieldId: string, isTouched: boolean): void; + + /** Triggers all fieldId-specific validators */ + validate(): Record; + /** Set a validator for a specific fieldId */ + setValidator(fieldId: string, validate: (value: string) => string | null): void; +} + +const FormContext = React.createContext({} as FormContextProps); + +export const FormContextConsumer = FormContext.Consumer; + +export interface FormContextProviderProps { + /** Record of initial values */ + initialValues?: Record; + /** Any react node. Can optionally use render function to return context props. */ + children?: React.ReactNode | ((props: FormContextProps) => React.ReactNode); +} + +export const FormContextProvider: React.FC = ({ initialValues, children }) => { + const [values, setValues] = React.useState>(initialValues || {}); + const [errors, setErrors] = React.useState>({}); + const [validators, setValidators] = React.useState>({}); + const [touched, setTouched] = React.useState>({}); + const isValid = Object.keys(errors)?.length === 0; + + const getValue = (fieldId: string) => + Object.entries(values).reduce((acc, [id, value]) => (id === fieldId ? value : acc), ''); + + const setValue = (fieldId: string, value: string, triggerValidation: boolean = true) => { + if (values[fieldId] !== value) { + setValues((prevValues) => ({ ...prevValues, [fieldId]: value })); + triggerValidation && validators[fieldId]?.(value); + } + }; + + const getError = (fieldId: string) => + Object.entries(errors).reduce((acc, [id, error]) => (id === fieldId ? error : acc), ''); + + const setError = (fieldId: string, error: string) => + errors[fieldId] !== error && + setErrors(({ [fieldId]: _, ...prevErrors }) => ({ + ...prevErrors, + ...(!!error && { [fieldId]: error }) + })); + + const isTouched = (fieldId: string) => + Object.entries(touched).reduce((acc, [id, isTouched]) => (id === fieldId ? isTouched : acc), false); + + const setFieldTouched = (fieldId: string, isTouched: boolean) => + touched[fieldId] !== isTouched && + setTouched(({ [fieldId]: _, ...prevTouched }) => ({ + ...prevTouched, + ...(isTouched && { [fieldId]: isTouched }) + })); + + const setValidator = (fieldId: string, validate: (value: string) => string | null) => + validators[fieldId] !== validate && setValidators((prevValidators) => ({ ...prevValidators, [fieldId]: validate })); + + // Accumulate and return errors from all fields with validators. + const validate = () => + Object.entries(validators)?.reduce((acc: Record, [id, validateField]) => { + const fieldError = validateField(values[id]); + + if (fieldError) { + acc[id] = fieldError; + } + + return acc; + }, {}); + + return ( + + {typeof children === 'function' ? ( + {(formContext) => children(formContext)} + ) : ( + children + )} + + ); +}; + +export const useFormContext = () => React.useContext(FormContext); diff --git a/packages/react-core/src/components/Form/__tests__/FormContext.test.tsx b/packages/react-core/src/components/Form/__tests__/FormContext.test.tsx new file mode 100644 index 00000000000..61f59fdeb37 --- /dev/null +++ b/packages/react-core/src/components/Form/__tests__/FormContext.test.tsx @@ -0,0 +1,238 @@ +import React from 'react'; +import { render, screen } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import { FormContextConsumer, FormContextProvider } from '../FormContext'; + +const customRender = (ui, initialValues?: Record) => { + return render({ui}); +}; + +test('consumers can show all values from "values" provided by "initialValues"', () => { + const initialValues = { fieldOneId: 'some value', fieldTwoId: 'some other value' }; + + customRender( + + {({ values }) => Object.values(values).map((value) => {value})} + , + initialValues + ); + + Object.values(initialValues).forEach((initialValue) => expect(screen.getByText(initialValue)).toBeVisible()); +}); + +test('consumer results in "isValid" as "true" without errors and "false" with errors', async () => { + const user = userEvent.setup(); + const initialValues = { someId: 'some value' }; + const onChange = (setValue, setError) => (event) => { + const { value } = event.target; + setValue('someId', value); + + if (!value) { + setError('someId', 'Some error!'); + } + }; + + customRender( + + {({ values, setValue, setError, isValid }) => ( + <> + + {`isValid: ${isValid}`} + + )} + , + initialValues + ); + + expect(screen.getByText(/^isValid:/)).toHaveTextContent('isValid: true'); + + await user.clear(screen.getByRole('textbox')); + expect(screen.getByText(/^isValid:/)).toHaveTextContent('isValid: false'); +}); + +test('using getValue, the consumer can show a value passed from initialValues', () => { + customRender({({ getValue }) => <>{getValue('someId')}}, { + someId: 'some value' + }); + + expect(screen.getByText('some value')).toBeVisible(); +}); + +test('sets a new value for a single field using setValue', async () => { + const user = userEvent.setup(); + + customRender( + + {({ getValue, setValue }) => { + return setValue('someId', event.target.value)} value={getValue('someId')} />; + }} + , + { + someId: 'some value' + } + ); + + const consumerInput = screen.getByRole('textbox'); + + await user.clear(consumerInput); + await user.type(consumerInput, 'some updated value'); + + expect(consumerInput).toHaveValue('some updated value'); +}); + +test('using setValues, consumers can set multiple values at once', async () => { + const user = userEvent.setup(); + const newValues = { someId: 'updated value', someNewId: 'new value' }; + + customRender( + + {({ values, setValues }) => { + return ( + <> + setValues(newValues)} /> + {Object.entries(values).map(([fieldId, value]) => ( + {`${fieldId}: ${value}`} + ))} + + ); + }} + , + { + someId: 'some value' + } + ); + + await user.click(screen.getByRole('checkbox')); + Object.entries(newValues).map(([fieldId, value]) => expect(screen.getByText(`${fieldId}: ${value}`)).toBeVisible()); +}); + +test('using getError, the consumer returns an error', async () => { + const user = userEvent.setup(); + + customRender( + + {({ getError, setError }) => ( + <> + setError('someId', 'some error!')} /> + {getError('someId')} + + )} + + ); + + await user.click(screen.getByRole('checkbox')); + expect(screen.getByText('some error!')).toBeVisible(); +}); + +test('sets a new error for a single field using setError', async () => { + const user = userEvent.setup(); + + customRender( + + {({ errors, setError }) => { + return ( + <> + setError('someId', 'some error!')} /> + {`Error: ${errors.someId}`} + + ); + }} + + ); + + await user.click(screen.getByRole('checkbox')); + expect(screen.getByText(/^Error:/)).toHaveTextContent('Error: some error!'); +}); + +test('using setErrors, consumers can set multiple errors at once', async () => { + const user = userEvent.setup(); + const newErrors = { someId: 'some error!', someNewId: 'new field error!' }; + + customRender( + + {({ errors, setErrors }) => { + return ( + <> + setErrors(newErrors)} /> + {Object.entries(errors).map(([fieldId, error]) => ( + {`${fieldId}: ${error}`} + ))} + + ); + }} + , + { + someId: 'some value' + } + ); + + await user.click(screen.getByRole('checkbox')); + Object.entries(newErrors).map(([fieldId, error]) => expect(screen.getByText(`${fieldId}: ${error}`)).toBeVisible()); +}); + +test('sets a new touched state for a single field using setTouched', async () => { + const user = userEvent.setup(); + + customRender( + + {({ touched, setTouched }) => { + return ( + <> + setTouched('someId', true)} /> + {`isTouched: ${touched.someId}`} + + ); + }} + + ); + + await user.click(screen.getByRole('checkbox')); + expect(screen.getByText(/^isTouched:/)).toHaveTextContent('isTouched: true'); +}); + +test("can get a single field's touched state by using isTouched", async () => { + const user = userEvent.setup(); + + customRender( + + {({ touched, setTouched, isTouched }) => { + return ( + <> + setTouched('someId', !touched.someId)} /> + {`isTouched: ${isTouched('someId')}`} + + ); + }} + + ); + + const consumerCheckbox = screen.getByRole('checkbox'); + + await user.click(consumerCheckbox); + expect(screen.getByText(/^isTouched:/)).toHaveTextContent('isTouched: true'); + + await user.click(consumerCheckbox); + expect(screen.getByText(/^isTouched:/)).toHaveTextContent('isTouched: false'); +}); + +test('validate returns errors set by the "setValidator" function', async () => { + const user = userEvent.setup(); + + customRender( + + {({ validate, setValidator }) => { + const errors = validate(); + + return ( + <> + setValidator('someId', (value) => (!value ? 'some error!' : null))} /> + {`Error: ${errors.someId}`} + + ); + }} + + ); + + await user.click(screen.getByRole('checkbox')); + expect(screen.getByText(/^Error:/)).toHaveTextContent('Error: some error!'); +}); diff --git a/packages/react-core/src/components/Form/examples/Form.md b/packages/react-core/src/components/Form/examples/Form.md index cd6bc010cf1..c699df86295 100644 --- a/packages/react-core/src/components/Form/examples/Form.md +++ b/packages/react-core/src/components/Form/examples/Form.md @@ -14,6 +14,8 @@ propComponents: 'FormFieldGroupExpandable', 'FormFieldGroupHeader', 'FormFieldGroupHeaderTitleTextObject', + 'FormContextProps', + 'FormContextProviderProps', 'Button', 'Popover' ] @@ -114,3 +116,9 @@ When using helper text inside a `FormGroup`, the `HelperText` component should b ```ts file="./FormFieldGroups.tsx" ``` + +### Form state + +```ts file="./FormState.tsx" isBeta + +``` diff --git a/packages/react-core/src/components/Form/examples/FormState.tsx b/packages/react-core/src/components/Form/examples/FormState.tsx new file mode 100644 index 00000000000..b6b6976d57a --- /dev/null +++ b/packages/react-core/src/components/Form/examples/FormState.tsx @@ -0,0 +1,96 @@ +import React from 'react'; +import { + ActionGroup, + Button, + ButtonType, + ButtonVariant, + Divider, + Form, + FormContextProvider, + FormGroup, + Select, + SelectOption, + TextArea, + TextInput +} from '@patternfly/react-core'; +import ExclamationCircleIcon from '@patternfly/react-icons/dist/esm/icons/exclamation-circle-icon'; + +export const FormState = () => { + const [isSelectOpen, setIsSelectOpen] = React.useState(false); + const [formStateExpanded, setFormStateExpanded] = React.useState(false); + + return ( + + {({ setValue, getValue, setError, values, errors }) => ( +
+ } + fieldId="input-id" + validated={errors['input-id'] ? 'error' : 'default'} + isRequired + > + { + setValue('input-id', value); + setError('input-id', undefined); + }} + value={getValue('input-id')} + validated={errors['input-id'] ? 'error' : 'default'} + /> + + +