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

Also adds a "Complex field types and validations" example to the docs
  • Loading branch information
mturley committed Apr 27, 2021
1 parent a1e3601 commit 82d882b
Show file tree
Hide file tree
Showing 4 changed files with 119 additions and 5 deletions.
19 changes: 19 additions & 0 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 Down Expand Up @@ -115,6 +116,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> | 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 82d882b

Please sign in to comment.