From ea9b415c431a4c0dbf187d1b284c633df7773d2a Mon Sep 17 00:00:00 2001 From: Chris Wesseling Date: Tue, 28 Nov 2023 10:00:41 +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 --- .../ComponentConfiguration.stories.tsx | 14 +- src/components/ComponentConfiguration.tsx | 2 + src/components/JSONEdit.tsx | 51 ++++-- .../values/items-expression.stories.ts | 1 + .../builder/values/items-expression.tsx | 100 +++++++++++- src/components/static_variables.json | 152 ++++++++++++++++++ src/context.ts | 40 +++++ tsconfig.json | 1 + 8 files changed, 348 insertions(+), 13 deletions(-) create mode 100644 src/components/static_variables.json diff --git a/src/components/ComponentConfiguration.stories.tsx b/src/components/ComponentConfiguration.stories.tsx index dc69c45e..271bcefc 100644 --- a/src/components/ComponentConfiguration.stories.tsx +++ b/src/components/ComponentConfiguration.stories.tsx @@ -9,6 +9,7 @@ import { DEFAULT_DOCUMENT_TYPES, DEFAULT_FILE_TYPES, } from '@/../.storybook/decorators'; +import {VariableDefinition} from '@/context'; import {AnyComponentSchema} from '@/types'; import ComponentConfiguration from './ComponentConfiguration'; @@ -16,6 +17,7 @@ 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', @@ -30,6 +32,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)'}, @@ -81,6 +84,7 @@ interface TemplateArgs { }; }; otherComponents: AnyComponentSchema[]; + variableDefinitions: VariableDefinition[]; validatorPlugins: ValidatorOption[]; registrationAttributes: RegistrationAttributeOption[]; prefillPlugins: PrefillPluginOption[]; @@ -96,6 +100,7 @@ interface TemplateArgs { const Template: StoryFn = ({ component, otherComponents, + variableDefinitions, validatorPlugins, registrationAttributes, prefillPlugins, @@ -114,6 +119,7 @@ const Template: StoryFn = ({ supportedLanguageCodes={supportedLanguageCodes} componentTranslationsRef={{current: translationsStore}} getFormComponents={() => otherComponents} + getVariableDefinitions={() => variableDefinitions} getValidatorPlugins={async () => validatorPlugins} getRegistrationAttributes={async () => registrationAttributes} getPrefillPlugins={async () => prefillPlugins} @@ -1058,13 +1064,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', @@ -1081,7 +1091,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 127a48e6..198190a8 100644 --- a/src/components/ComponentConfiguration.tsx +++ b/src/components/ComponentConfiguration.tsx @@ -27,6 +27,7 @@ const ComponentConfiguration: React.FC = ({ supportedLanguageCodes = ['nl', 'en'], componentTranslationsRef, getFormComponents, + getVariableDefinitions, getValidatorPlugins, getRegistrationAttributes, getPrefillPlugins, @@ -48,6 +49,7 @@ const ComponentConfiguration: React.FC = ({ supportedLanguageCodes, componentTranslationsRef, getFormComponents, + getVariableDefinitions, getValidatorPlugins, getRegistrationAttributes, getPrefillPlugins, diff --git a/src/components/JSONEdit.tsx b/src/components/JSONEdit.tsx index 633b6fdf..bbdfa3af 100644 --- a/src/components/JSONEdit.tsx +++ b/src/components/JSONEdit.tsx @@ -1,25 +1,34 @@ -import {JSONObject} from '@open-formulieren/types/lib/types'; +import {infer} from '@open-formulieren/infernologic'; +import {type JSONObject, type JSONValue} from '@open-formulieren/types/lib/types'; import clsx from 'clsx'; import {useFormikContext} from 'formik'; +import uniqueId from 'lodash.uniqueid'; import {TextareaHTMLAttributes, useRef, useState} from 'react'; +type LogicAnnotation = { + resultShape: JSONValue; + dataContext: JSONObject; +}; + interface JSONEditProps { data: unknown; // JSON.stringify first argument has the 'any' type in TS itself... className?: string; name?: string; + logic?: LogicAnnotation; } const JSONEdit: React.FC> = ({ data, className = 'form-control', name = '', + logic, ...props }) => { const dataAsJSON = JSON.stringify(data, null, 2); const inputRef = useRef(null); const [value, setValue] = useState(dataAsJSON); - const [JSONValid, setJSONValid] = useState(true); + const [JsonLogicError, setJsonLogicError] = useState(''); const {setValues, setFieldValue} = useFormikContext(); // if no name is provided, replace the entire form state, otherwise only set a @@ -32,6 +41,21 @@ const JSONEdit: React.FC { + // Infer doesn't evaluate logic, it just looks at types. + // "===" type checks when logic and expected are of the same type + // when the return type of infer is not stringly, we can do something neater + const result = infer({'===': [logic, expected]}, dataContext); + const ok = result.startsWith('result type:'); + if (!ok) { + setJsonLogicError(result); + } + return ok; + }; + const onChange = (event: React.ChangeEvent) => { const rawValue = event.target.value; setValue(rawValue); @@ -39,26 +63,35 @@ const JSONEdit: React.FC