Skip to content

Commit

Permalink
✨[open-formulieren/open-forms#3597] Add type checking
Browse files Browse the repository at this point in the history
  • Loading branch information
CharString committed Nov 28, 2023
1 parent 4f8e8f7 commit ea9b415
Show file tree
Hide file tree
Showing 8 changed files with 348 additions and 13 deletions.
14 changes: 12 additions & 2 deletions src/components/ComponentConfiguration.stories.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -9,13 +9,15 @@ import {
DEFAULT_DOCUMENT_TYPES,
DEFAULT_FILE_TYPES,
} from '@/../.storybook/decorators';
import {VariableDefinition} from '@/context';
import {AnyComponentSchema} from '@/types';

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',
Expand All @@ -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)'},
Expand Down Expand Up @@ -81,6 +84,7 @@ interface TemplateArgs {
};
};
otherComponents: AnyComponentSchema[];
variableDefinitions: VariableDefinition[];
validatorPlugins: ValidatorOption[];
registrationAttributes: RegistrationAttributeOption[];
prefillPlugins: PrefillPluginOption[];
Expand All @@ -96,6 +100,7 @@ interface TemplateArgs {
const Template: StoryFn<TemplateArgs> = ({
component,
otherComponents,
variableDefinitions,
validatorPlugins,
registrationAttributes,
prefillPlugins,
Expand All @@ -114,6 +119,7 @@ const Template: StoryFn<TemplateArgs> = ({
supportedLanguageCodes={supportedLanguageCodes}
componentTranslationsRef={{current: translationsStore}}
getFormComponents={() => otherComponents}
getVariableDefinitions={() => variableDefinitions}
getValidatorPlugins={async () => validatorPlugins}
getRegistrationAttributes={async () => registrationAttributes}
getPrefillPlugins={async () => prefillPlugins}
Expand Down Expand Up @@ -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',
Expand All @@ -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: {},
Expand Down
2 changes: 2 additions & 0 deletions src/components/ComponentConfiguration.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@ const ComponentConfiguration: React.FC<ComponentConfigurationProps> = ({
supportedLanguageCodes = ['nl', 'en'],
componentTranslationsRef,
getFormComponents,
getVariableDefinitions,
getValidatorPlugins,
getRegistrationAttributes,
getPrefillPlugins,
Expand All @@ -48,6 +49,7 @@ const ComponentConfiguration: React.FC<ComponentConfigurationProps> = ({
supportedLanguageCodes,
componentTranslationsRef,
getFormComponents,
getVariableDefinitions,
getValidatorPlugins,
getRegistrationAttributes,
getPrefillPlugins,
Expand Down
51 changes: 42 additions & 9 deletions src/components/JSONEdit.tsx
Original file line number Diff line number Diff line change
@@ -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<JSONEditProps & TextareaHTMLAttributes<HTMLTextAreaElement>> = ({
data,
className = 'form-control',
name = '',
logic,
...props
}) => {
const dataAsJSON = JSON.stringify(data, null, 2);
const inputRef = useRef<HTMLTextAreaElement>(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
Expand All @@ -32,33 +41,57 @@ const JSONEdit: React.FC<JSONEditProps & TextareaHTMLAttributes<HTMLTextAreaElem
setValue(dataAsJSON);
}

const validateLogic = (
logic: JSONValue,
{resultShape: expected, dataContext}: LogicAnnotation
) => {
// 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<HTMLTextAreaElement>) => {
const rawValue = event.target.value;
setValue(rawValue);

let updatedData: any;
try {
updatedData = JSON.parse(rawValue);
setJSONValid(true);
} catch {
setJSONValid(false);
setJsonLogicError('');
} catch (error) {
setJsonLogicError(error.toString());
return;
}

updateValue(updatedData);
updateValue(updatedData); // valid JSON
if (logic && !validateLogic(updatedData, logic)) return;
};

const errorMessageId = JsonLogicError ? uniqueId() : '';

return (
<>
<textarea
ref={inputRef}
value={value}
className={clsx(className, {'is-invalid': !JSONValid})}
className={clsx(className, {'is-invalid': !!errorMessageId})}
aria-invalid={errorMessageId ? 'true' : 'false'}
{...(errorMessageId ? {'aria-errormessage': errorMessageId} : {})}
data-testid="jsonEdit"
onChange={onChange}
spellCheck={false}
{...props}
/>
{!JSONValid && <div className="invalid-feedback">Could not parse the JSON.</div>}
{errorMessageId && (
<div id={errorMessageId} className="invalid-feedback">
{JsonLogicError}
</div>
)}
</>
);
};
Expand Down
1 change: 1 addition & 0 deletions src/components/builder/values/items-expression.stories.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ export default {
},
args: {
name: 'values',
dataContext: {someVar: [['value', 'label']]},
},
argTypes: {
name: {control: {disable: true}},
Expand Down
100 changes: 98 additions & 2 deletions src/components/builder/values/items-expression.tsx
Original file line number Diff line number Diff line change
@@ -1,12 +1,96 @@
import {JSONObject} from '@open-formulieren/types/lib/types';
import {AnyComponentSchema} from '@open-formulieren/types';
import {JSONObject, JSONValue} 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, VariableDefinition} from '@/context';

const NAME = 'openForms.itemsExpression';

const dataTypeForComponent = (component: AnyComponentSchema): JSONValue => {
// For now return example values as accepted by InferNoLogic
// But example values cannot distinguish arrays from tuples!
let example_value;
switch (component.type) {
case 'file':
example_value = [
{
data: {
baseUrl: 'string',
form: 'string',
name: 'string',
project: 'string',
size: 1,
url: 'string',
},
name: 'string',
originalName: 'string',
size: 1,
storage: 'string',
type: 'string',
url: 'string',
},
];
break;
case 'currency':
example_value = 1;
break;
case 'number':
example_value = 1;
break;
case 'checkbox':
example_value = true;
break;
case 'selectboxes':
example_value = component.defaultValue;
break;
case 'npFamilyMembers':
example_value = {}; // TODO record type
break;
// case 'map':
// example_value = [1, 1]; // TODO tuple type
// break;
// case 'editgrid':
// example_value = [{}]; // TODO inspect the component
// break;
case 'datetime':
example_value = 'string'; // TODO
case 'date':
example_value = 'string'; // TODO
case 'time':
example_value = 'string'; // TODO
default:
example_value = 'string';
}
return 'multiple' in component && component.multiple ? [example_value] : example_value;
};

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 contextFromComponents = (components: AnyComponentSchema[]): JSONObject =>
Object.fromEntries(components.map(component => [component.key, dataTypeForComponent(component)]));

const contextFromVariableDefinitions = (definitions: VariableDefinition[]): JSONObject =>
Object.fromEntries(
definitions.map(definition => [definition.key, dataTypeForVariableDefinition(definition)])
);

/**
* The `ItemsExpression` component is used to specify the JsonLogic expression to
* calculate the values/options for a component.
Expand All @@ -17,6 +101,12 @@ export const ItemsExpression: React.FC = () => {
const {getFieldProps} = useFormikContext();
const {value = ''} = getFieldProps<JSONObject | string | undefined>(NAME);

const {getFormComponents, getVariableDefinitions} = useContext(BuilderContext);
const dataContext: Record<string, JSONValue> = {
...contextFromComponents(getFormComponents()),
...contextFromVariableDefinitions(getVariableDefinitions()),
};

const htmlId = `editform-${NAME}`;
return (
<Component
Expand All @@ -32,7 +122,13 @@ export const ItemsExpression: React.FC = () => {
}
>
<div>
<JSONEdit name={NAME} data={value} rows={3} id={htmlId} />
<JSONEdit
name={NAME}
data={value}
rows={3}
id={htmlId}
logic={{resultShape: [['', '']], dataContext}}
/>
</div>

<Description
Expand Down
Loading

0 comments on commit ea9b415

Please sign in to comment.