Skip to content

Commit

Permalink
fix(useformstate): properly assert that field types inferred from yup…
Browse files Browse the repository at this point in the history
… schema are defined (#60)

Also adds a "Complex field types and validations" example to the docs
  • Loading branch information
mturley authored Apr 28, 2021
1 parent a1e3601 commit 26a34c0
Show file tree
Hide file tree
Showing 4 changed files with 120 additions and 7 deletions.
22 changes: 20 additions & 2 deletions src/hooks/useFormState/useFormState.stories.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import {
PatternFlyTextFields,
PatternFlyTextFieldsWithHelpers,
AsyncPrefilling,
ComplexFieldTypes,
} from './useFormState.stories.tsx';
import GithubLink from '../../../.storybook/helpers/GithubLink';

Expand All @@ -19,8 +20,7 @@ parameters returned by `useFormField` and `useFormState` can be inferred from th
passed as individual type parameters to each `useFormField<T>()`, or passed as a unified interface to the type parameter of `useFormState<T>()`.

This hook uses [yup](https://github.com/jquense/yup) for field validation, and its TypeScript signature enforces that each of your schema types is
compatible with its field type. `yup` is bundled as a dependency of `@konveyor/lib-ui`, but it is recommended that you also install `@types/yup` as a
devDependency in your app if you are using TypeScript.
compatible with its field type. `yup` is bundled as a dependency of `@konveyor/lib-ui`.

We built this solution because we find that most forms do not have complex requirements, and libraries like Formik and co. tend to add unnecessary complexity.
We were burned by lots of confusing debugging sessions trying to figure out the reinitialization behavior of Formik, among other things,
Expand Down Expand Up @@ -115,6 +115,24 @@ Most custom non-text fields can use `getFormGroupProps` even if the other helper
<Story story={PatternFlyTextFieldsWithHelpers} />
</Canvas>

### Complex field types and validations

You can store any type of value in a form field, but you need to find the right `yup` schema type to use. `yup.string()`, `yup.number()` and `yup.boolean()` are straightforward,
but you may also need to use things like `yup.array()`, `yup.date()`, `yup.object<ObjectSchema>()`. (Note: only use `yup.object` when you need specific validation on each property in the object, which may be better designed as individual useFormField hooks).
If nothing else fits, you can always use `yup.mixed<T>()` which allows you to use any type but does not validate the value for you. If you use `mixed`, be sure to add your own validations with schema methods like `required`, `oneOf`, and `test`.
See the [yup API documentation](https://github.com/jquense/yup#api) for more information.

This example includes a mixed field and an array field (leveraging the `useSelectionState` hook also provided by lib-ui). It has custom validations to make sure the store you select is open and that you don't select more than 2 items (for some reason).

Note that the type parameters passed here to `useFormField` are not required, because the field type can usually be inferred from the yup schema.
However, since the schema are non-trivial we're specifying types here explicitly just to be sure the schema match up with what we're expecting.
If you're having trouble with yup schema types resolving to `T | undefined` instead of `T`, try chaining `.default(someDefault)` to tell yup what to validate against if there is an undefined value.
[Learn more about yup's TypeScript support here](https://github.com/jquense/yup/blob/master/docs/typescript.md).

<Canvas>
<Story story={ComplexFieldTypes} />
</Canvas>

### Pre-filling a form asynchronously

If the form's initial values are known when it is first rendered, they can simply be passed as the `initialValue` arguments of each `useFormField` call.
Expand Down
99 changes: 97 additions & 2 deletions src/hooks/useFormState/useFormState.stories.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import {
getFormGroupProps,
getTextInputProps,
getTextAreaProps,
useSelectionState,
} from '../..';

export const BasicTextFields: React.FunctionComponent = () => {
Expand All @@ -29,7 +30,7 @@ export const BasicTextFields: React.FunctionComponent = () => {
onBlur={() => form.fields.name.setIsTouched(true)}
/>
{!form.fields.name.isValid ? (
<p style={{ color: 'red' }}>{form.fields.name.error.message}</p>
<p style={{ color: 'red' }}>{form.fields.name.error?.message}</p>
) : null}
</div>
<div>
Expand All @@ -42,7 +43,7 @@ export const BasicTextFields: React.FunctionComponent = () => {
onBlur={() => form.fields.description.setIsTouched(true)}
/>
{!form.fields.description.isValid ? (
<p style={{ color: 'red' }}>{form.fields.description.error.message}</p>
<p style={{ color: 'red' }}>{form.fields.description.error?.message}</p>
) : null}
</div>
<button
Expand Down Expand Up @@ -204,3 +205,97 @@ export const AsyncPrefilling: React.FunctionComponent = () => {
</Form>
);
};

export const ComplexFieldTypes: React.FunctionComponent = () => {
// GROCERY_STORES and GROCERY_ITEMS represent some data you might load from an API.
interface IGroceryStore {
name: string;
isOpen: boolean;
}
const GROCERY_STORES: IGroceryStore[] = [
{ name: 'Market Basket', isOpen: true },
{ name: 'Wegmans', isOpen: false },
{ name: 'Aldi', isOpen: true },
];
const GROCERY_ITEMS: string[] = ['Milk', 'Eggs', 'Flour', 'Butter'];

const form = useFormState({
store: useFormField<IGroceryStore | null>(
null,
yup
.mixed<IGroceryStore | null>()
.required()
.test('is-open', 'The selected store is closed', (value) => !!value?.isOpen)
),
items: useFormField<string[]>(
[],
yup.array(yup.string().default('')).required().max(2, 'Select no more than 2 items')
),
});

const { isItemSelected, toggleItemSelected } = useSelectionState<string>({
items: GROCERY_ITEMS,
externalState: [form.fields.items.value, form.fields.items.setValue],
});

return (
<>
<div>
<label htmlFor="example-4-store">Store:</label>&nbsp;
<select
id="example-4-store"
onChange={(event) => {
form.fields.store.setValue(
GROCERY_STORES.find((store) => store.name === event.target.value) || null
);
form.fields.store.setIsTouched(true);
}}
value={form.values.store?.name || ''}
>
<option value="" disabled>
Select a store...
</option>
{GROCERY_STORES.map((store) => (
<option key={store.name} value={store.name}>
{store.name} - {store.isOpen ? 'Open' : 'Closed'}
</option>
))}
</select>
{!form.fields.store.isValid ? (
<p style={{ color: 'red' }}>{form.fields.store.error?.message}</p>
) : null}
</div>
<div>
<label htmlFor="example-4-items">Items:</label>
<br />
{GROCERY_ITEMS.map((item) => (
<div key={item}>
<input
type="checkbox"
id={`${item}-checkbox`}
checked={isItemSelected(item)}
onChange={() => {
toggleItemSelected(item);
form.fields.items.setIsTouched(true);
}}
/>
&nbsp;
<label htmlFor={`${item}-checkbox`}>{item}</label>
</div>
))}
{!form.fields.items.isValid ? (
<p style={{ color: 'red' }}>{form.fields.items.error?.message}</p>
) : null}
</div>
<button
disabled={!form.isDirty || !form.isValid}
onClick={() => alert(`Submit form! ${JSON.stringify(form.values)}`)}
>
Submit
</button>
<button disabled={!form.isDirty} onClick={form.reset} style={{ marginLeft: 5 }}>
Reset
</button>
</>
);
};
4 changes: 2 additions & 2 deletions src/hooks/useFormState/useFormState.ts
Original file line number Diff line number Diff line change
Expand Up @@ -52,7 +52,7 @@ export interface IFormState<TFieldValues> {

export const useFormField = <T>(
initialValue: T,
schema: yup.AnySchema<T>,
schema: yup.AnySchema<T | undefined>,
options: { initialTouched?: boolean } = {}
): IFormField<T> => {
const [initializedValue, setInitializedValue] = React.useState<T>(initialValue);
Expand All @@ -72,7 +72,7 @@ export const useFormField = <T>(
setValue(initializedValue);
setIsTouched(options.initialTouched || false);
},
schema,
schema: schema.defined(),
};
};

Expand Down
2 changes: 1 addition & 1 deletion tsconfig.json
Original file line number Diff line number Diff line change
Expand Up @@ -20,5 +20,5 @@
"skipLibCheck": true
},
"include": ["src/**/*"],
"exclude": ["node_modules", "dist", "src/**/*.stories.tsx", "src/**/*.test.tsx"]
"exclude": ["node_modules", "dist"]
}

0 comments on commit 26a34c0

Please sign in to comment.