From 22d85288342283f3ea33bf3edf57f2d98e8290f5 Mon Sep 17 00:00:00 2001 From: Daniel da Silva Date: Tue, 17 Dec 2024 10:53:23 +0000 Subject: [PATCH] Add form validation --- .../src/pages/CollectionForm/EditForm.tsx | 6 +- .../client/src/pages/CollectionForm/index.tsx | 9 +- packages/data-core/lib/index.ts | 1 + .../lib/plugin-utils/validate.test.ts | 263 ++++++++++++++++++ .../data-core/lib/plugin-utils/validate.ts | 179 ++++++++++++ packages/data-core/package.json | 6 +- .../lib/components/object-property.tsx | 11 +- 7 files changed, 469 insertions(+), 6 deletions(-) create mode 100644 packages/data-core/lib/plugin-utils/validate.test.ts create mode 100644 packages/data-core/lib/plugin-utils/validate.ts diff --git a/packages/client/src/pages/CollectionForm/EditForm.tsx b/packages/client/src/pages/CollectionForm/EditForm.tsx index f0c3ba5..69d521c 100644 --- a/packages/client/src/pages/CollectionForm/EditForm.tsx +++ b/packages/client/src/pages/CollectionForm/EditForm.tsx @@ -2,6 +2,7 @@ import React, { useMemo, useState } from 'react'; import { PluginBox, useCollectionPlugins, + validatePluginsFieldsData, WidgetRenderer } from '@stac-manager/data-core'; import { Box, Button, ButtonGroup, Flex, Heading } from '@chakra-ui/react'; @@ -42,7 +43,6 @@ export function EditForm(props: { { @@ -50,6 +50,10 @@ export function EditForm(props: { view === 'json' ? values.jsonData : toOutData(values); return onSubmit(exitData, actions); }} + validate={(values) => { + const [, error] = validatePluginsFieldsData(plugins, values); + if (error) return error; + }} > {({ handleSubmit, values, isSubmitting }) => ( ) => { try { @@ -42,6 +43,8 @@ export function CollectionFormNew() { duration: 5000, isClosable: true }); + + navigate(`/collections/${data.id}`); } catch (error: any) { toast.update('collection-submit', { title: 'Collection creation failed', @@ -64,6 +67,8 @@ export function CollectionFormEdit(props: { id: string }) { usePageTitle(collection ? `Edit collection ${id}` : 'Edit collection'); + const navigate = useNavigate(); + const toast = useToast(); useEffect(() => { @@ -98,6 +103,8 @@ export function CollectionFormEdit(props: { id: string }) { duration: 5000, isClosable: true }); + + navigate(`/collections/${data.id}`); } catch (error: any) { toast.update('collection-submit', { title: 'Collection update failed', diff --git a/packages/data-core/lib/index.ts b/packages/data-core/lib/index.ts index d950b9a..02ee26c 100644 --- a/packages/data-core/lib/index.ts +++ b/packages/data-core/lib/index.ts @@ -1,4 +1,5 @@ export * from './plugin-utils/plugin'; +export * from './plugin-utils/validate'; export * from './plugin-utils/use-plugins-hook'; export * from './context/plugin-config'; export * from './config'; diff --git a/packages/data-core/lib/plugin-utils/validate.test.ts b/packages/data-core/lib/plugin-utils/validate.test.ts new file mode 100644 index 0000000..77b831e --- /dev/null +++ b/packages/data-core/lib/plugin-utils/validate.test.ts @@ -0,0 +1,263 @@ +import { Plugin, PluginEditSchema } from './plugin'; +import { validatePluginsFieldsData } from './validate'; + +describe('validatePluginsFieldsData', () => { + test('No data', () => { + const plugins = [ + { + name: 'Render Extension', + editSchema(): PluginEditSchema { + return { + type: 'root', + required: ['description', 'license'], + properties: { + description: { + label: 'Description', + type: 'string' + }, + license: { + label: 'License', + type: 'string', + enum: [ + ['one', 'License One'], + ['two', 'License Two'], + ['three', 'License Three'] + ] + } + } + }; + } + } + ] as Plugin[]; + const [, errors] = validatePluginsFieldsData(plugins, {}); + expect(errors).toEqual({ + description: 'Description is a required field', + license: 'License is a required field' + }); + }); + + test('Invalid enum', () => { + const plugins = [ + { + editSchema(): PluginEditSchema { + return { + type: 'root', + required: ['license'], + properties: { + license: { + label: 'License', + type: 'string', + enum: [ + ['one', 'License One'], + ['two', 'License Two'], + ['three', 'License Three'] + ] + } + } + }; + } + } + ] as Plugin[]; + const data = { + license: 'invalid' + }; + const [, errors] = validatePluginsFieldsData(plugins, data); + expect(errors).toEqual({ + license: 'License value is invalid' + }); + }); + + test('Invalid enum empty', () => { + const plugins = [ + { + name: 'Render Extension', + editSchema(): PluginEditSchema { + return { + type: 'root', + required: ['license'], + properties: { + license: { + label: 'License', + type: 'string', + enum: [ + ['one', 'License One'], + ['two', 'License Two'], + ['three', 'License Three'] + ] + } + } + }; + } + } + ] as Plugin[]; + const data = { + license: '' + }; + const [, errors] = validatePluginsFieldsData(plugins, data); + expect(errors).toEqual({ + license: 'License value is invalid' + }); + }); + + test('Allowed empty enum', () => { + const plugins = [ + { + name: 'Render Extension', + editSchema(): PluginEditSchema { + return { + type: 'root', + properties: { + type: { + label: 'Type', + type: 'string', + enum: [ + ['one', 'Type One'], + ['two', 'Type Two'], + ['three', 'Type Three'] + ] + } + } + }; + } + } + ] as Plugin[]; + const data = { + type: '' + }; + const [, errors] = validatePluginsFieldsData(plugins, data); + expect(errors).toEqual(null); + }); + + test('Allowed other enum', () => { + const plugins = [ + { + name: 'Render Extension', + editSchema(): PluginEditSchema { + return { + type: 'root', + properties: { + type: { + label: 'Type', + type: 'string', + allowOther: { + type: 'string' + }, + enum: [ + ['one', 'Type One'], + ['two', 'Type Two'], + ['three', 'Type Three'] + ] + } + } + }; + } + } + ] as Plugin[]; + const data = { + type: 'special' + }; + const [, errors] = validatePluginsFieldsData(plugins, data); + expect(errors).toEqual(null); + }); + + test('Missing nested', () => { + const plugins = [ + { + name: 'Render Extension', + editSchema(): PluginEditSchema { + return { + type: 'root', + properties: { + providers: { + type: 'array', + label: 'Providers', + items: { + type: 'object', + required: ['name'], + properties: { + name: { + label: 'Name', + type: 'string' + } + } + } + } + } + }; + } + } + ] as Plugin[]; + const data = { + providers: [ + { + url: 'http://example.com' + } + ] + }; + const [, errors] = validatePluginsFieldsData(plugins, data); + expect(errors).toEqual({ + providers: [{ name: 'Name is a required field' }] + }); + }); + + test('Wrong array count', () => { + const plugins = [ + { + name: 'Render Extension', + editSchema(): PluginEditSchema { + return { + type: 'root', + properties: { + spatial: { + label: 'Spatial Extent', + type: 'array', + minItems: 2, + items: { + type: 'number' + } + } + } + }; + } + } + ] as Plugin[]; + const data = { + spatial: [1] + }; + const [, errors] = validatePluginsFieldsData(plugins, data); + expect(errors).toEqual({ + spatial: 'Spatial Extent field must have at least 2 items' + }); + }); + + test('Wrong field type', () => { + const plugins = [ + { + name: 'Render Extension', + editSchema(): PluginEditSchema { + return { + type: 'root', + required: ['description', 'license'], + properties: { + spatial: { + label: 'Spatial Extent', + type: 'array', + minItems: 2, + items: { + type: 'number' + } + } + } + }; + } + } + ] as Plugin[]; + const data = { + spatial: ['one', 'two'] + }; + const [, errors] = validatePluginsFieldsData(plugins, data); + expect(errors).toEqual({ + spatial: ['Value must be a number', 'Value must be a number'] + }); + }); +}); diff --git a/packages/data-core/lib/plugin-utils/validate.ts b/packages/data-core/lib/plugin-utils/validate.ts new file mode 100644 index 0000000..aed25d4 --- /dev/null +++ b/packages/data-core/lib/plugin-utils/validate.ts @@ -0,0 +1,179 @@ +import * as Yup from 'yup'; +import { yupToFormErrors } from 'formik'; + +import { + SchemaFieldArray, + SchemaFieldJson, + SchemaFieldNumber, + SchemaFieldObject, + SchemaFieldString +} from '../schema/types'; +import { Plugin } from './plugin'; + +/** + * Validates the data against the edit schemas defined by the plugins. + * @param {Plugin[]} plugins - The list of plugins. + * @param {any} data - The data to validate. + * @returns {[any, any]} - Returns a tuple with the validated data or errors. + */ +export function validatePluginsFieldsData(plugins: Plugin[], data: any) { + const validationSchema = plugins.reduce((acc, plugin) => { + const editSchema = plugin.editSchema(data); + if (!editSchema || editSchema === Plugin.HIDDEN) return acc; + + const schema = createObjectSchema({ + ...(editSchema as SchemaFieldObject), + type: 'object' + }); + + return acc.concat(schema); + }, Yup.object()); + + try { + const result = validationSchema.validateSync(data, { abortEarly: false }); + return [result, null]; + } catch (error: any) { + const e = yupToFormErrors(error); + return [null, e]; + } +} + +/** + * Creates a Yup object schema based on the provided data. + * @param {SchemaFieldObject} data - The schema field object. + * @param {boolean} [isRequired] - Whether the schema is required. + * @returns {Yup.ObjectSchema} - The Yup object schema. + */ +function createObjectSchema( + data: SchemaFieldObject, + isRequired?: boolean +): Yup.ObjectSchema { + const required = data.required || []; + const properties = data.properties || {}; + + const mappedProperties = Object.keys(properties).reduce((acc, key) => { + const field = properties[key]; + const type = field.type; + const isFieldRequired = required.includes(key); + + let schema; + if (type === 'object' || type === 'root') { + schema = createObjectSchema(field, isFieldRequired); + } else if (type === 'string') { + schema = createStringSchema(field, isFieldRequired); + } else if (type === 'number') { + schema = createNumberSchema(field, isFieldRequired); + } else if (type === 'json') { + schema = createJsonSchema(field, isFieldRequired); + } else if (type === 'array') { + schema = createArraySchema(field, isFieldRequired); + } else { + throw new Error('Invalid item type'); + } + + return { + ...acc, + [key]: schema + }; + }, {}); + + if (isRequired) { + return Yup.object(mappedProperties).required(); + } else { + return Yup.object(mappedProperties); + } +} + +/** + * Creates a Yup string schema based on the provided data. + * @param {SchemaFieldString} data - The schema field string. + * @param {boolean} [isRequired] - Whether the schema is required. + * @returns {Yup.StringSchema} - The Yup string schema. + */ +function createStringSchema(data: SchemaFieldString, isRequired?: boolean) { + let schema = Yup.string().label( + typeof data.label === 'string' ? data.label : 'Value' + ); + + if (data.enum) { + schema = schema.test('enum', '${path} value is invalid', (value) => { + if (value === '' && !isRequired) return true; + + const inOption = data.enum.some(([v]) => v === value); + if (inOption) { + return true; + } + + if (data.allowOther) { + return typeof value === data.allowOther.type; + } + return false; + }); + } + return isRequired ? schema.required() : schema; +} + +/** + * Creates a Yup number schema based on the provided data. + * @param {SchemaFieldNumber} data - The schema field number. + * @param {boolean} [isRequired] - Whether the schema is required. + * @returns {Yup.NumberSchema} - The Yup number schema. + */ +function createNumberSchema(data: SchemaFieldNumber, isRequired?: boolean) { + const schema = Yup.number() + .label(typeof data.label === 'string' ? data.label : 'Value') + .typeError('Value must be a number'); + + return isRequired ? schema.required() : schema; +} + +/** + * Creates a Yup JSON schema based on the provided data. + * @param {SchemaFieldJson} data - The schema field JSON. + * @param {boolean} [isRequired] - Whether the schema is required. + * @returns {Yup.ObjectSchema} - The Yup JSON schema. + */ +function createJsonSchema(data: SchemaFieldJson, isRequired?: boolean) { + const schema = Yup.object().label( + typeof data.label === 'string' ? data.label : 'Value' + ); + + return isRequired ? schema.required() : schema; +} + +/** + * Creates a Yup array schema based on the provided data. + * @param {SchemaFieldArray} data - The schema field array. + * @param {boolean} [isRequired] - Whether the schema is required. + * @returns {Yup.ArraySchema} - The Yup array schema. + */ +function createArraySchema( + data: SchemaFieldArray, + isRequired?: boolean +): Yup.ArraySchema { + const field = data.items || {}; + const type = field.type; + + let itemSchema; + if (type === 'object') { + itemSchema = createObjectSchema(field); + } else if (type === 'string') { + itemSchema = createStringSchema(field); + } else if (type === 'number') { + itemSchema = createNumberSchema(field); + } else if (type === 'json') { + itemSchema = createJsonSchema(field); + } else if (type === 'array') { + itemSchema = createArraySchema(field); + } else { + throw new Error('Invalid item type'); + } + + const schema = Yup.array() + .label(typeof data.label === 'string' ? data.label : 'Value') + .of(itemSchema) + .min(data.minItems || 0) + .max(data.maxItems || Infinity); + + return isRequired ? schema.required() : schema; +} diff --git a/packages/data-core/package.json b/packages/data-core/package.json index be81631..873a47a 100644 --- a/packages/data-core/package.json +++ b/packages/data-core/package.json @@ -31,10 +31,12 @@ "framer-motion": "^10.16.5", "lodash-es": "^4.17.21", "react": "^18.3.1", - "react-dom": "^18.3.1" + "react-dom": "^18.3.1", + "yup": "^1.5.0" }, "devDependencies": { "@types/lodash-es": "^4.17.12", + "@types/node": "^22.10.2", "@types/react": "^18.3.12", "@types/react-dom": "^18.3.1" }, @@ -42,8 +44,8 @@ "@chakra-ui/react": "^2.8.2", "@emotion/react": "^11.13.3", "@emotion/styled": "^11.13.0", - "framer-motion": "^10.16.5", "formik": "^2.4.6", + "framer-motion": "^10.16.5", "react": "^18.3.1", "react-dom": "^18.3.1" } diff --git a/packages/data-widgets/lib/components/object-property.tsx b/packages/data-widgets/lib/components/object-property.tsx index b19c2bf..b2a10be 100644 --- a/packages/data-widgets/lib/components/object-property.tsx +++ b/packages/data-widgets/lib/components/object-property.tsx @@ -89,9 +89,16 @@ const inferFieldType = (value: any): FieldTypes => { * @returns A SchemaField object if the type is recognized, otherwise null. */ const getFieldSchema = (type: FieldTypes): SchemaField | null => { - if (type === 'string' || type === 'number') { + if (type === 'string') { return { - type: type, + type: 'string', + label: 'Value' + }; + } + + if (type === 'number') { + return { + type: 'number', label: 'Value' }; }