From 6c2160ccf4bc3b3544c73a2afe4dc77ee9de9f9e Mon Sep 17 00:00:00 2001 From: Chris Wesseling Date: Tue, 5 Dec 2023 14:06:47 +0100 Subject: [PATCH] =?UTF-8?q?=E2=9C=A8[open-formulieren/open-forms#3597]=20A?= =?UTF-8?q?dd=20type=20checking?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ... to item expression Closes open-formulieren/open-forms#3596 Closes open-formulieren/open-forms#3597 --- .storybook/decorators.tsx | 8 +- .../ComponentConfiguration.stories.tsx | 17 +- src/components/ComponentConfiguration.tsx | 3 + .../builder/values/items-expression.tsx | 16 +- src/components/static_variables.json | 152 +++++++++++++++++ src/context.ts | 4 + src/index.ts | 1 + src/tests/test-utils.tsx | 6 + src/utils/jsonlogic.ts | 161 ++++++++++++++++++ tsconfig.json | 1 + 10 files changed, 363 insertions(+), 6 deletions(-) create mode 100644 src/components/static_variables.json create mode 100644 src/utils/jsonlogic.ts diff --git a/.storybook/decorators.tsx b/.storybook/decorators.tsx index f94b4ea1..107675da 100644 --- a/.storybook/decorators.tsx +++ b/.storybook/decorators.tsx @@ -14,6 +14,9 @@ import { DEFAULT_VALIDATOR_PLUGINS, sleep, } from '@/tests/sharedUtils'; +import {VariableDefinition, createTypeCheck} from '@/utils/jsonlogic'; + +import static_variables from '../src/components/static_variables.json'; export const ModalDecorator: Decorator = (Story, {parameters}) => { if (parameters?.modal?.noModal) return ; @@ -54,6 +57,8 @@ export const BuilderContextDecorator: Decorator = (Story, context) => { const defaultFileTypes = context.parameters.builder?.defaultFileTypes || DEFAULT_FILE_TYPES; const defaultdocumentTypes = context.parameters.builder?.defaultdocumentTypes || DEFAULT_DOCUMENT_TYPES; + const components = context?.args?.componentTree || defaultComponentTree; + const staticVariables = static_variables as VariableDefinition[]; // source is inferred as string not as "" return ( { uniquifyKey: key => key, supportedLanguageCodes: supportedLanguageCodes, componentTranslationsRef: {current: translationsStore}, - getFormComponents: () => context?.args?.componentTree || defaultComponentTree, + getFormComponents: () => components, getValidatorPlugins: async () => { await sleep(context.parameters?.builder?.validatorPluginsDelay || 0); return context?.args?.validatorPlugins || defaultValidatorPlugins; @@ -86,6 +91,7 @@ export const BuilderContextDecorator: Decorator = (Story, context) => { getDocumentTypes: async () => context?.args?.documentTypes || defaultdocumentTypes, getConfidentialityLevels: async () => CONFIDENTIALITY_LEVELS, getAuthPlugins: async () => DEFAULT_AUTH_PLUGINS, + validateLogic: createTypeCheck({components, staticVariables}), }} > diff --git a/src/components/ComponentConfiguration.stories.tsx b/src/components/ComponentConfiguration.stories.tsx index 2fcfb60d..f04069c8 100644 --- a/src/components/ComponentConfiguration.stories.tsx +++ b/src/components/ComponentConfiguration.stories.tsx @@ -11,12 +11,14 @@ import { DEFAULT_FILE_TYPES, } from '@/tests/sharedUtils'; import {AnyComponentSchema} from '@/types'; +import {VariableDefinition, createTypeCheck} from '@/utils/jsonlogic'; import ComponentConfiguration from './ComponentConfiguration'; import {BuilderInfo} from './ComponentEditForm'; import {PrefillAttributeOption, PrefillPluginOption} from './builder/prefill'; import {RegistrationAttributeOption} from './builder/registration/registration-attribute'; import {ValidatorOption} from './builder/validate/validator-select'; +import static_variables from './static_variables.json'; export default { title: 'Public API/ComponentConfiguration', @@ -31,6 +33,7 @@ export default { args: { isNew: true, otherComponents: [{type: 'select', label: 'A select', key: 'aSelect'}], + variableDefinitions: static_variables, validatorPlugins: [ {id: 'phone-intl', label: 'Phone (international)'}, {id: 'phone-nl', label: 'Phone (Dutch)'}, @@ -82,6 +85,7 @@ interface TemplateArgs { }; }; otherComponents: AnyComponentSchema[]; + variableDefinitions: VariableDefinition[]; validatorPlugins: ValidatorOption[]; registrationAttributes: RegistrationAttributeOption[]; prefillPlugins: PrefillPluginOption[]; @@ -97,6 +101,7 @@ interface TemplateArgs { const Template: StoryFn = ({ component, otherComponents, + variableDefinitions, validatorPlugins, registrationAttributes, prefillPlugins, @@ -115,6 +120,10 @@ const Template: StoryFn = ({ supportedLanguageCodes={supportedLanguageCodes} componentTranslationsRef={{current: translationsStore}} getFormComponents={() => otherComponents} + validateLogic={createTypeCheck({ + formVariables: variableDefinitions, + components: otherComponents, + })} getValidatorPlugins={async () => validatorPlugins} getRegistrationAttributes={async () => registrationAttributes} getPrefillPlugins={async () => prefillPlugins} @@ -1140,13 +1149,17 @@ export const SelectBoxes: Story = { const itemsExpressionInput = canvas.getByLabelText('Items expression'); await userEvent.clear(itemsExpressionInput); // { needs to be escaped: https://github.com/testing-library/user-event/issues/584 - const expression = '{"var": "someVar"}'.replace(/[{[]/g, '$&$&'); + const expression = '{"var": "current_year"}'.replace(/[{[]/g, '$&$&'); await userEvent.type(itemsExpressionInput, expression); await expect(editForm.queryByLabelText('Default value')).toBeNull(); await expect(preview.getByRole('checkbox', {name: /Options from expression:/})).toBeVisible(); await userEvent.click(canvas.getByRole('button', {name: 'Save'})); + expect(itemsExpressionInput).toHaveAttribute('aria-invalid', 'true'); + expect(itemsExpressionInput).toHaveAttribute('aria-errormessage'); + const errorMessageId = itemsExpressionInput.getAttribute('aria-errormessage') ?? ''; + expect(document.getElementById(errorMessageId)).toBeVisible(); expect(args.onSubmit).toHaveBeenCalledWith({ id: 'wqimsadk', type: 'selectboxes', @@ -1163,7 +1176,7 @@ export const SelectBoxes: Story = { isSensitiveData: false, openForms: { dataSrc: 'variable', - itemsExpression: {var: 'someVar'}, + itemsExpression: {var: 'current_year'}, // valid JSON, invalid expression translations: {}, }, defaultValue: {}, diff --git a/src/components/ComponentConfiguration.tsx b/src/components/ComponentConfiguration.tsx index 4cf74eb1..c5735264 100644 --- a/src/components/ComponentConfiguration.tsx +++ b/src/components/ComponentConfiguration.tsx @@ -13,6 +13,7 @@ export interface ComponentConfigurationProps extends BuilderContextType, Compone * * @param options.uniquifyKey Function to make component key unique in the context of all existing components. * @param options.getFormComponents Function returning all other Formio components in the builder context. + * @param options.validateLogic Function to validate JsonLogic expressions in the context of the form. * @param options.componentTranslationsRef Object containing the existing translations from other components, keyed by language code. Each entry is a map of literal => translation. * @param options.isNew Whether the Formio component is a new component being added or an existing being edited. * @param options.component The (starter) schema of the Formio component being edited. @@ -27,6 +28,7 @@ const ComponentConfiguration: React.FC = ({ supportedLanguageCodes = ['nl', 'en'], componentTranslationsRef, getFormComponents, + validateLogic, getValidatorPlugins, getRegistrationAttributes, getPrefillPlugins, @@ -49,6 +51,7 @@ const ComponentConfiguration: React.FC = ({ supportedLanguageCodes, componentTranslationsRef, getFormComponents, + validateLogic, getValidatorPlugins, getRegistrationAttributes, getPrefillPlugins, diff --git a/src/components/builder/values/items-expression.tsx b/src/components/builder/values/items-expression.tsx index bfeb4e4c..4677be36 100644 --- a/src/components/builder/values/items-expression.tsx +++ b/src/components/builder/values/items-expression.tsx @@ -1,9 +1,11 @@ -import {JSONObject} from '@open-formulieren/types/lib/types'; +import {type JSONObject} from '@open-formulieren/types/lib/types'; import {useFormikContext} from 'formik'; +import {useContext} from 'react'; import {FormattedMessage} from 'react-intl'; import JSONEdit from '@/components/JSONEdit'; import {Component, Description} from '@/components/formio'; +import {BuilderContext} from '@/context'; const NAME = 'openForms.itemsExpression'; @@ -11,12 +13,14 @@ const NAME = 'openForms.itemsExpression'; * The `ItemsExpression` component is used to specify the JsonLogic expression to * calculate the values/options for a component. * - * @todo: this would really benefit from a nice, context-aware JsonLogic editor. + * @todo: this would really benefit from a nice JsonLogic editor. */ export const ItemsExpression: React.FC = () => { const {getFieldProps} = useFormikContext(); const {value = ''} = getFieldProps(NAME); + const {validateLogic} = useContext(BuilderContext); + const htmlId = `editform-${NAME}`; return ( { } >
- + validateLogic(logic, [['', '']])} + />
AnyComponentSchema[]; + validateLogic: JsonLogicTypeChecker; getValidatorPlugins: (componentType: string) => Promise; getRegistrationAttributes: (componentType: string) => Promise; getPrefillPlugins: (componentType: string) => Promise; @@ -73,6 +76,7 @@ const BuilderContext = React.createContext({ supportedLanguageCodes: ['nl', 'en'], componentTranslationsRef: {current: null}, getFormComponents: () => [], + validateLogic: createTypeCheck(), getValidatorPlugins: async () => [], getRegistrationAttributes: async () => [], getPrefillPlugins: async () => [], diff --git a/src/index.ts b/src/index.ts index f2e5aed9..21106468 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,2 +1,3 @@ export {default as ComponentEditForm} from '@/components/ComponentEditForm'; export {default as ComponentConfiguration} from '@/components/ComponentConfiguration'; +export * from '@/utils/jsonlogic'; diff --git a/src/tests/test-utils.tsx b/src/tests/test-utils.tsx index f3dc31a3..7aa409a2 100644 --- a/src/tests/test-utils.tsx +++ b/src/tests/test-utils.tsx @@ -21,6 +21,7 @@ import { sleep, } from '@/tests/sharedUtils'; import {AnyComponentSchema} from '@/types'; +import {VariableDefinition, createTypeCheck} from '@/utils/jsonlogic'; import {PrefillAttributeOption, PrefillPluginOption} from '../components/builder/prefill'; import {RegistrationAttributeOption} from '../components/builder/registration/registration-attribute'; @@ -38,6 +39,7 @@ interface BuilderOptions { documentTypes: DocumentTypeOption[]; confidentialityLevels: SelectOption[]; registrationAttributesDelay: number; + staticVariables: VariableDefinition[]; } interface contextRenderOptions { @@ -91,6 +93,10 @@ const contextRender = ( getConfidentialityLevels: async () => builderOptions.confidentialityLevels || CONFIDENTIALITY_LEVELS, getAuthPlugins: async () => DEFAULT_AUTH_PLUGINS, + validateLogic: createTypeCheck({ + components: builderOptions.componentTree || DEFAULT_COMPONENT_TREE, + staticVariables: builderOptions.staticVariables, + }), }} > {children} diff --git a/src/utils/jsonlogic.ts b/src/utils/jsonlogic.ts new file mode 100644 index 00000000..6522e4e3 --- /dev/null +++ b/src/utils/jsonlogic.ts @@ -0,0 +1,161 @@ +/** + * JsonLogic type checking utility functions + */ +import {infer} from '@open-formulieren/infernologic'; +import {type JSONObject, type JSONValue} from '@open-formulieren/types/lib/types'; + +import {type AnyComponentSchema} from '@/types'; + +/** + * @param {object | array | number | string} logic - JsonLogic expression + * @param {object | array | number | string} expected - example value from expected result type e.g. [["label", "value"]] + * @returns {string} - error message or '' if type checks + */ +export type JsonLogicTypeChecker = (logic: JSONValue, expected?: JSONValue) => string; + +type DataType = + | 'string' + | 'int' + | 'float' + | 'array' + | 'object' + | 'date' + | 'datetime' + | 'time' + | 'boolean'; + +interface ServiceFetchConfiguration { + id: number; + name: string; + service: string; + path: string; + method: 'GET' | 'POST'; + headers: Record; + query_params: Record; + body: string | null; + data_mapping_type: 'JsonLogic' | 'jq'; + mapping_expression: unknown; + cache_timeout: number | null; +} + +/* + Description of static, user defined and component variables + */ +export interface VariableDefinition { + form: string | null; + formDefinition: string | null; + name: string; + key: string; + source: + | '' // static + | 'component' + | 'user_defined'; + serviceFetchConfiguration: ServiceFetchConfiguration | null; + prefillPlugin: string; + prefillAttribute: string; + prefillIdentifierRole: 'authorised_person' | 'main'; + dataType: DataType; // (incomplete) type annotation + dataFormat: string; + isSensitiveData: boolean; + initialValue: JSONValue; +} + +interface FormContext { + formVariables?: VariableDefinition[]; + staticVariables?: VariableDefinition[]; + components?: AnyComponentSchema[]; +} + +/** + * Return a function that can type check whether + */ +export const createTypeCheck = ({ + formVariables = [], + staticVariables = [], + components = [], +}: FormContext = {}): JsonLogicTypeChecker => { + const variableContext = staticVariables.concat(formVariables); + let data: JSONObject = Object.fromEntries( + variableContext.map(variable => [variable.key, dataTypeForVariableDefinition(variable)]) + ); + // formVariables contain user defined and component variables + // but dataType `array` and `object` are incomplete + // overwrite the component variables + Object.entries(components).map( + ([key, component]) => (data[key] = dataTypeForComponent(component)) + ); + + return (logic, expected = undefined) => { + // We don't evaluate logic, we just look at types. + // "===" forces the logic expression align with the expectancy if defined + const result = infer(expected !== undefined ? {'===': [logic, expected]} : logic, data); + return result.startsWith('result type:') ? '' : result; + }; +}; + +// TODO use infer if serviceFetchConfiguration.data_mapping_type == 'JsonLogic' +const dataTypeForVariableDefinition = ({initialValue, dataType}: VariableDefinition): JSONValue => + initialValue !== null + ? initialValue // may be something more complete than [] or {} ... TODO prefillAttribute too? + : { + string: '', + int: 1, + float: 1.1, + array: [], + object: {}, + date: '', + datetime: '', + time: '', + boolean: true, + }[dataType]; + +const dataTypeForComponent = ( + component: + | AnyComponentSchema + | { + type: 'map' | 'editgrid' | 'password' | 'signature'; // Not yet implemented in this builder + multiple?: boolean; + defaultValue: JSONValue; + } +): JSONValue => { + // For now return example values as accepted by InferNoLogic + // But example values cannot distinguish arrays from tuples! + const value = { + currency: 1, + number: 1, + checkbox: true, + //@ts-ignore selectboxes always have component.defaultValue (this does work when rewritten as a lengthy switch/case) + selectboxes: component.defaultValue, + npFamilyMembers: {}, // TODO record type + map: [1, 1], // TODO tuple type + bsn: 'string', + content: null, + date: 'date', // TODO string for now + datetime: 'datetime', // TODO string for now + editgrid: [{}], // TODO inspect the component + email: 'string', + file: [ + { + data: {baseUrl: '', form: '', name: '', project: '', size: 0, url: ''}, + name: '', + originalName: '', + size: 0, + storage: '', + type: '', // mime + url: '', + }, + ], + iban: 'string', + licenseplate: 'string', + password: 'string', + phoneNumber: 'string', + postcode: 'string', + radio: 'string', + select: 'string', + signature: 'string', // data-url + textarea: 'string', + textfield: 'string', + time: 'time', // TODO string for now + }[component.type]; + return 'multiple' in component && component.multiple ? [value] : value; +}; diff --git a/tsconfig.json b/tsconfig.json index d67340f2..e1e0d800 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -8,6 +8,7 @@ "jsx": "react-jsx", "declaration": true, "moduleResolution": "node", + "resolveJsonModule": true, "noUnusedLocals": true, "noUnusedParameters": true, "esModuleInterop": true,