Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion packages/react-core/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Copy link
Contributor Author

Choose a reason for hiding this comment

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

This does not pertain to this exact change, but something noticed when running clean, I believe these built sub-directories should be included.

"generate": "node scripts/copyStyles.js",
"subpaths": "node scripts/createSubpaths.js"
},
Expand Down
129 changes: 129 additions & 0 deletions packages/react-core/src/components/Form/FormContext.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,129 @@
import React, { Dispatch, SetStateAction } from 'react';

export interface FormContextProps {
/** Record of values for all fieldIds */
values: Record<string, string>;
/** Record of errors for all fieldIds */
errors: Record<string, string>;
/** Record of touched state for all fieldIds */
touched: Record<string, boolean>;
/** 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<SetStateAction<Record<string, string>>>;

/** 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<SetStateAction<Record<string, string>>>;

/** 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<string, string | null>;
/** 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<string, string>;
/** Any react node. Can optionally use render function to return context props. */
children?: React.ReactNode | ((props: FormContextProps) => React.ReactNode);
}

export const FormContextProvider: React.FC<FormContextProviderProps> = ({ initialValues, children }) => {
const [values, setValues] = React.useState<Record<string, string>>(initialValues || {});
const [errors, setErrors] = React.useState<Record<string, string>>({});
const [validators, setValidators] = React.useState<Record<string, Function>>({});
const [touched, setTouched] = React.useState<Record<string, boolean>>({});
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<string, string>, [id, validateField]) => {
const fieldError = validateField(values[id]);

if (fieldError) {
acc[id] = fieldError;
}

return acc;
}, {});

return (
<FormContext.Provider
value={{
values,
errors,
touched,
isValid,
setValues,
setErrors,
getValue,
setValue,
getError,
setError,
validate,
setValidator,
isTouched,
setTouched: setFieldTouched
}}
>
{typeof children === 'function' ? (
<FormContext.Consumer>{(formContext) => children(formContext)}</FormContext.Consumer>
) : (
children
)}
</FormContext.Provider>
);
};

export const useFormContext = () => React.useContext(FormContext);
238 changes: 238 additions & 0 deletions packages/react-core/src/components/Form/__tests__/FormContext.test.tsx
Original file line number Diff line number Diff line change
@@ -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<string, any>) => {
return render(<FormContextProvider initialValues={initialValues}>{ui}</FormContextProvider>);
};

test('consumers can show all values from "values" provided by "initialValues"', () => {
const initialValues = { fieldOneId: 'some value', fieldTwoId: 'some other value' };

customRender(
<FormContextConsumer>
{({ values }) => Object.values(values).map((value) => <span key={value}>{value}</span>)}
</FormContextConsumer>,
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(
<FormContextConsumer>
{({ values, setValue, setError, isValid }) => (
<>
<input onChange={onChange(setValue, setError)} value={values.someId} />
{`isValid: ${isValid}`}
</>
)}
</FormContextConsumer>,
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(<FormContextConsumer>{({ getValue }) => <>{getValue('someId')}</>}</FormContextConsumer>, {
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(
<FormContextConsumer>
{({ getValue, setValue }) => {
return <input onChange={(event) => setValue('someId', event.target.value)} value={getValue('someId')} />;
}}
</FormContextConsumer>,
{
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(
<FormContextConsumer>
{({ values, setValues }) => {
return (
<>
<input type="checkbox" onClick={() => setValues(newValues)} />
{Object.entries(values).map(([fieldId, value]) => (
<span key={fieldId}>{`${fieldId}: ${value}`}</span>
))}
</>
);
}}
</FormContextConsumer>,
{
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(
<FormContextConsumer>
{({ getError, setError }) => (
<>
<input type="checkbox" onClick={() => setError('someId', 'some error!')} />
{getError('someId')}
</>
)}
</FormContextConsumer>
);

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(
<FormContextConsumer>
{({ errors, setError }) => {
return (
<>
<input type="checkbox" onClick={() => setError('someId', 'some error!')} />
{`Error: ${errors.someId}`}
</>
);
}}
</FormContextConsumer>
);

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(
<FormContextConsumer>
{({ errors, setErrors }) => {
return (
<>
<input type="checkbox" onClick={() => setErrors(newErrors)} />
{Object.entries(errors).map(([fieldId, error]) => (
<span key={fieldId}>{`${fieldId}: ${error}`}</span>
))}
</>
);
}}
</FormContextConsumer>,
{
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(
<FormContextConsumer>
{({ touched, setTouched }) => {
return (
<>
<input type="checkbox" onClick={() => setTouched('someId', true)} />
{`isTouched: ${touched.someId}`}
</>
);
}}
</FormContextConsumer>
);

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(
<FormContextConsumer>
{({ touched, setTouched, isTouched }) => {
return (
<>
<input type="checkbox" onClick={() => setTouched('someId', !touched.someId)} />
{`isTouched: ${isTouched('someId')}`}
</>
);
}}
</FormContextConsumer>
);

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(
<FormContextConsumer>
{({ validate, setValidator }) => {
const errors = validate();

return (
<>
<input type="checkbox" onClick={() => setValidator('someId', (value) => (!value ? 'some error!' : null))} />
{`Error: ${errors.someId}`}
</>
);
}}
</FormContextConsumer>
);

await user.click(screen.getByRole('checkbox'));
expect(screen.getByText(/^Error:/)).toHaveTextContent('Error: some error!');
});
8 changes: 8 additions & 0 deletions packages/react-core/src/components/Form/examples/Form.md
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,8 @@ propComponents:
'FormFieldGroupExpandable',
'FormFieldGroupHeader',
'FormFieldGroupHeaderTitleTextObject',
'FormContextProps',
'FormContextProviderProps',
Copy link
Contributor Author

Choose a reason for hiding this comment

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

Both of these types should probably be marked as beta, but there is no way to do so yet with the PF documentation-framework. The only use-case is an example called FormState, which is marked as beta, so hopefully that will suffice for now.

'Button',
'Popover'
]
Expand Down Expand Up @@ -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

```
Loading