Skip to content

Commit

Permalink
🦺 [#52] Implement and test input validation
Browse files Browse the repository at this point in the history
When specifying values manually, the form builder enforces
accessible practices in the value/label of each option.

There are some limitations in the flexibility with zod
validation unions and deep discriminators, but the UI
currently prevents ending up in those edge case.
  • Loading branch information
sergei-maertens committed Nov 17, 2023
1 parent 9ff5333 commit 8233fd2
Show file tree
Hide file tree
Showing 3 changed files with 123 additions and 0 deletions.
23 changes: 23 additions & 0 deletions src/registry/selectboxes/edit-validation.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
import {IntlShape} from 'react-intl';
import {z} from 'zod';

import {buildCommonSchema, jsonSchema, optionSchema} from '@/registry/validation';

// z.object(...).or(z.object(...)) based on openForms.dataSrc doesn't seem to work,
// looks like the union validation only works if the discriminator is in the top level
// object :(
// so we mark each aspect as optional so that *when* it is provided, we can run the
// validation
const buildValuesSchema = (intl: IntlShape) =>
z.object({
values: optionSchema(intl).array().min(1).optional(),
openForms: z.object({
dataSrc: z.union([z.literal('manual'), z.literal('variable')]),
// TODO: wire up infernologic type checking
itemsExpression: jsonSchema.optional(),
}),
});

const schema = (intl: IntlShape) => buildCommonSchema(intl).and(buildValuesSchema(intl));

export default schema;
60 changes: 60 additions & 0 deletions src/registry/selectboxes/selectboxes-validation.stories.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,60 @@
import {expect} from '@storybook/jest';
import {Meta, StoryObj} from '@storybook/react';
import {userEvent, within} from '@storybook/testing-library';

import ComponentEditForm from '@/components/ComponentEditForm';
import {BuilderContextDecorator} from '@/sb-decorators';

export default {
title: 'Builder components/SelectBoxes/Validations',
component: ComponentEditForm,
decorators: [BuilderContextDecorator],
parameters: {
builder: {enableContext: true},
},
args: {
isNew: true,
component: {
id: 'wqimsadk',
type: 'selectboxes',
key: 'selectboxes',
label: 'A selectboxes field',
openForms: {
dataSrc: 'manual',
translations: {},
},
values: [{value: '', label: ''}],
defaultValue: {},
},

builderInfo: {
title: 'Select Boxes',
icon: 'plus-square',
group: 'basic',
weight: 60,
schema: {},
},
},
} as Meta<typeof ComponentEditForm>;

type Story = StoryObj<typeof ComponentEditForm>;

export const ManualMinimumOneValue: Story = {
name: 'Manual values: must have at least one non-empty value',
play: async ({canvasElement, step}) => {
const canvas = within(canvasElement);

await step('Option values and labels are required fields', async () => {
// a value must be set, otherwise there's nothing to check and a label must be
// set, otherwise there is no clickable/accessible label for an option.

// we trigger input validation by touching the field and clearing it again
const labelInput = canvas.getByLabelText('Option label');
await userEvent.type(labelInput, 'Foo');
await userEvent.clear(labelInput);
await userEvent.keyboard('[Tab]');
await expect(await canvas.findByText('The option label is a required field.')).toBeVisible();
await expect(await canvas.findByText('The option value is a required field.')).toBeVisible();
});
},
};
40 changes: 40 additions & 0 deletions src/registry/validation.ts
Original file line number Diff line number Diff line change
Expand Up @@ -74,6 +74,46 @@ export const buildCommonSchema = (intl: IntlShape) =>
key: buildKeySchema(intl),
});

// From https://zod.dev/?id=json-type
const literalSchema = z.union([z.string(), z.number(), z.boolean(), z.null()]);
type Literal = z.infer<typeof literalSchema>;
type Json = Literal | {[key: string]: Json} | Json[];
export const jsonSchema: z.ZodType<Json> = z.lazy(() =>
z.union([literalSchema, z.array(jsonSchema), z.record(jsonSchema)])
);

// Maps to @open-formulieren/types common.ts Option type.
const optionTranslationSchema = z.object({
label: z.string(),
});

export const optionSchema = (intl: IntlShape) =>
z.object({
value: z
.string({
required_error: intl.formatMessage({
description: 'Form builder option value required error',
defaultMessage: 'The option value is a required field.',
}),
})
.min(1),
label: z
.string({
required_error: intl.formatMessage({
description: 'Form builder option label required error',
defaultMessage: 'The option label is a required field.',
}),
})
.min(1),
openForms: z
.object({
// zod doesn't seem to be able to use our supportedLanguageCodes for z.object keys,
// they need to be defined statically. So, 'record' it is.
translations: z.record(optionTranslationSchema.optional()),
})
.optional(),
});

/*
Helpers
*/
Expand Down

0 comments on commit 8233fd2

Please sign in to comment.