Skip to content

Commit

Permalink
Merge pull request #56 from open-formulieren/feature/zod-validation
Browse files Browse the repository at this point in the history
Implement foundation for validation
  • Loading branch information
sergei-maertens authored Jan 11, 2025
2 parents b3143da + f4eed48 commit 32a6063
Show file tree
Hide file tree
Showing 24 changed files with 770 additions and 221 deletions.
File renamed without changes.
21 changes: 20 additions & 1 deletion package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

18 changes: 17 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -78,6 +78,22 @@
"dependencies": {
"@utrecht/component-library-react": "1.0.0-alpha.353",
"clsx": "^2.1.0",
"formik": "^2.4.5"
"formik": "^2.4.5",
"zod": "^3.24.1",
"zod-formik-adapter": "^1.3.0"
},
"jest": {
"roots": [
"<rootDir>/src"
],
"modulePaths": [
"<rootDir>/src"
],
"moduleNameMapper": {
"@/(.*)$": "<rootDir>/src/$1"
},
"transformIgnorePatterns": [
"/node_modules/(?!uuid)/"
]
}
}
26 changes: 16 additions & 10 deletions src/components/FormioForm.tsx
Original file line number Diff line number Diff line change
@@ -1,9 +1,11 @@
import type {AnyComponentSchema} from '@open-formulieren/types';
import {Form, Formik, setIn} from 'formik';
import {toFormikValidationSchema} from 'zod-formik-adapter';

import {extractInitialValues} from '@/initialValues';
import {getRegistryEntry} from '@/registry';
import type {JSONObject} from '@/types';
import {extractInitialValues} from '@/utils';
import {buildValidationSchema} from '@/validationSchema';

import FormFieldContainer from './FormFieldContainer';
import FormioComponent from './FormioComponent';
Expand All @@ -26,24 +28,28 @@ export interface FormioFormProps {
}

const FormioForm: React.FC<FormioFormProps> = ({components, onSubmit, children}) => {
// build the initial values from the component definitions
const initialValuePairs = extractInitialValues(components, getRegistryEntry);

// assign all the default values from the component definitions
let initialValues: JSONObject = {};
initialValuePairs.forEach(pair => {
const [key, value] = pair;
initialValues = setIn(initialValues, key, value);
});
const initialValues = Object.entries(extractInitialValues(components, getRegistryEntry)).reduce(
(acc: JSONObject, [key, value]) => {
acc = setIn(acc, key, value);
return acc;
},
{} satisfies JSONObject
);
// build the validation schema from the component definitions
const zodSchema = buildValidationSchema(components, getRegistryEntry);

return (
<Formik
initialValues={initialValues}
validateOnChange={false}
validateOnBlur
validationSchema={toFormikValidationSchema(zodSchema)}
onSubmit={async values => {
await onSubmit(values);
}}
>
<Form>
<Form noValidate>
<FormFieldContainer>
{/* TODO: pre-process components to ensure they have an ID? */}
{components.map((definition, index) => (
Expand Down
23 changes: 12 additions & 11 deletions src/utils.ts → src/initialValues.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,16 +18,17 @@ import type {JSONValue} from '@/types';
export const extractInitialValues = (
components: AnyComponentSchema[],
getRegistryEntry: GetRegistryEntry
): [string, JSONValue][] => {
const initialValuePairs: [string, JSONValue][] = components
// map over all the components and process them one by one
.map(componentDefinition => {
): Record<string, JSONValue> => {
const initialValues = components.reduce(
(acc: Record<string, JSONValue>, componentDefinition) => {
const getInitialValues = getRegistryEntry(componentDefinition)?.getInitialValues;
if (getInitialValues === undefined) return [];
return getInitialValues(componentDefinition, getRegistryEntry);
})
// since each component returns an *array* of pairs, we flatten this again into a
// single array
.flat(1);
return initialValuePairs;
if (getInitialValues !== undefined) {
const extraInitialValues = getInitialValues(componentDefinition, getRegistryEntry);
acc = {...acc, ...extraInitialValues};
}
return acc;
},
{} satisfies Record<string, JSONValue>
);
return initialValues;
};
77 changes: 0 additions & 77 deletions src/registry/email/index.stories.ts

This file was deleted.

180 changes: 180 additions & 0 deletions src/registry/email/index.stories.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,180 @@
import {EmailComponentSchema} from '@open-formulieren/types';
import type {Meta, StoryObj} from '@storybook/react';
import {expect, fn, userEvent, within} from '@storybook/test';

import FormioForm, {FormioFormProps} from '@/components/FormioForm';
import {withFormik} from '@/sb-decorators';

import FormioEmail from './';

export default {
title: 'Component registry / basic / email',
component: FormioEmail,
decorators: [withFormik],
} satisfies Meta<typeof FormioEmail>;

type Story = StoryObj<typeof FormioEmail>;

export const MinimalConfiguration: Story = {
args: {
componentDefinition: {
id: 'component1',
type: 'email',
key: 'my.email',
label: 'Your email',
// TODO: implement!
validateOn: 'blur',
},
},
parameters: {
formik: {
initialValues: {
my: {
email: '',
},
},
},
},
};

export const WithPlaceholder: Story = {
args: {
componentDefinition: {
id: 'component1',
type: 'email',
key: 'email',
label: 'Your email',
// TODO: implement!
validateOn: 'blur',
placeholder: 'geralt@kaer.moh.en',
},
},
parameters: {
formik: {
initialValues: {
email: '',
},
},
},
};

export const WithAutoComplete: Story = {
args: {
componentDefinition: {
id: 'component1',
type: 'email',
key: 'email',
label: 'Your email',
// TODO: implement!
validateOn: 'blur',
autocomplete: 'email',
},
},
parameters: {
formik: {
initialValues: {
email: '',
},
},
},
};

interface ValidationStoryArgs {
componentDefinition: EmailComponentSchema;
onSubmit: FormioFormProps['onSubmit'];
}

type ValidationStory = StoryObj<ValidationStoryArgs>;

const BaseValidationStory: ValidationStory = {
render: args => (
<FormioForm onSubmit={args.onSubmit} components={[args.componentDefinition]}>
<div style={{marginBlockStart: '20px'}}>
<button type="submit">Submit</button>
</div>
</FormioForm>
),
parameters: {
formik: {
disable: true,
},
},
};

export const ValidateEmailFormat: ValidationStory = {
...BaseValidationStory,
args: {
onSubmit: fn(),
componentDefinition: {
id: 'component1',
type: 'email',
key: 'my.email',
label: 'Your email',
// TODO: implement or just ignore it?
validateOn: 'blur',
},
},
play: async ({canvasElement}) => {
const canvas = within(canvasElement);

const emailField = canvas.getByLabelText('Your email');
await userEvent.type(emailField, 'invalid');

await userEvent.click(canvas.getByRole('button', {name: 'Submit'}));
expect(await canvas.findByText('Invalid email')).toBeVisible();
},
};

export const ValidateEmailRequired: ValidationStory = {
...BaseValidationStory,
args: {
onSubmit: fn(),
componentDefinition: {
id: 'component1',
type: 'email',
key: 'my.email',
label: 'Your email',
// TODO: implement or just ignore it?
validateOn: 'blur',
validate: {
required: true,
},
},
},
play: async ({canvasElement}) => {
const canvas = within(canvasElement);

const emailField = canvas.getByLabelText('Your email');
expect(emailField).toBeVisible();

await userEvent.click(canvas.getByRole('button', {name: 'Submit'}));
expect(await canvas.findByText('Required')).toBeVisible();
},
};

export const PassesAllValidations: ValidationStory = {
...BaseValidationStory,
args: {
onSubmit: fn(),
componentDefinition: {
id: 'component1',
type: 'email',
key: 'my.email',
label: 'Your email',
// TODO: implement or just ignore it?
validateOn: 'blur',
validate: {
required: true,
},
},
},
play: async ({canvasElement, args}) => {
const canvas = within(canvasElement);

const emailField = canvas.getByLabelText('Your email');
await userEvent.type(emailField, 'info@example.com');

await userEvent.click(canvas.getByRole('button', {name: 'Submit'}));
expect(args.onSubmit).toHaveBeenCalled();
},
};
Loading

0 comments on commit 32a6063

Please sign in to comment.