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 Dec 5, 2023
1 parent 4f8e8f7 commit 06817a1
Show file tree
Hide file tree
Showing 10 changed files with 381 additions and 15 deletions.
7 changes: 6 additions & 1 deletion .storybook/decorators.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,10 +2,12 @@ import type {StoryContext, StoryFn} from '@storybook/react';
import {Formik} from 'formik';

import {BuilderContext, DocumentTypeOption} from '@/context';
import {VariableDefinition, createTypeCheck} from '@/utils/jsonlogic';

import {PrefillAttributeOption, PrefillPluginOption} from '../src/components/builder/prefill';
import {RegistrationAttributeOption} from '../src/components/builder/registration/registration-attribute';
import {ValidatorOption} from '../src/components/builder/validate/validator-select';
import static_variables from '../src/components/static_variables.json';

export const ModalDecorator = (Story, {parameters}) => {
if (parameters?.modal?.noModal) return <Story />;
Expand Down Expand Up @@ -216,13 +218,16 @@ export const BuilderContextDecorator = (Story: StoryFn, context: StoryContext) =
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 (
<BuilderContext.Provider
value={{
uniquifyKey: key => key,
supportedLanguageCodes: supportedLanguageCodes,
componentTranslationsRef: {current: translationsStore},
getFormComponents: () => context?.args?.componentTree || defaultComponentTree,
getFormComponents: () => components,
validateLogic: createTypeCheck({components, staticVariables}),
getValidatorPlugins: async () => {
await sleep(context.parameters?.builder?.validatorPluginsDelay || 0);
return context?.args?.validatorPlugins || defaultValidatorPlugins;
Expand Down
17 changes: 15 additions & 2 deletions src/components/ComponentConfiguration.stories.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -10,12 +10,14 @@ import {
DEFAULT_FILE_TYPES,
} from '@/../.storybook/decorators';
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',
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,10 @@ const Template: StoryFn<TemplateArgs> = ({
supportedLanguageCodes={supportedLanguageCodes}
componentTranslationsRef={{current: translationsStore}}
getFormComponents={() => otherComponents}
validateLogic={createTypeCheck({
formVariables: variableDefinitions,
components: otherComponents,
})}
getValidatorPlugins={async () => validatorPlugins}
getRegistrationAttributes={async () => registrationAttributes}
getPrefillPlugins={async () => prefillPlugins}
Expand Down Expand Up @@ -1058,13 +1067,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 +1094,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
3 changes: 3 additions & 0 deletions src/components/ComponentConfiguration.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand All @@ -27,6 +28,7 @@ const ComponentConfiguration: React.FC<ComponentConfigurationProps> = ({
supportedLanguageCodes = ['nl', 'en'],
componentTranslationsRef,
getFormComponents,
validateLogic,
getValidatorPlugins,
getRegistrationAttributes,
getPrefillPlugins,
Expand All @@ -48,6 +50,7 @@ const ComponentConfiguration: React.FC<ComponentConfigurationProps> = ({
supportedLanguageCodes,
componentTranslationsRef,
getFormComponents,
validateLogic,
getValidatorPlugins,
getRegistrationAttributes,
getPrefillPlugins,
Expand Down
34 changes: 25 additions & 9 deletions src/components/JSONEdit.tsx
Original file line number Diff line number Diff line change
@@ -1,25 +1,28 @@
import {JSONObject} from '@open-formulieren/types/lib/types';
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';

interface JSONEditProps {
data: unknown; // JSON.stringify first argument has the 'any' type in TS itself...
className?: string;
name?: string;
validateLogic?: (logic: JSONValue) => string;
}

const JSONEdit: React.FC<JSONEditProps & TextareaHTMLAttributes<HTMLTextAreaElement>> = ({
data,
className = 'form-control',
name = '',
validateLogic = _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 @@ -39,26 +42,39 @@ const JSONEdit: React.FC<JSONEditProps & TextareaHTMLAttributes<HTMLTextAreaElem
let updatedData: any;
try {
updatedData = JSON.parse(rawValue);
setJSONValid(true);
} catch {
setJSONValid(false);
setJsonLogicError('');
} catch (error) {
setJsonLogicError(error.toString());
return;
}

updateValue(updatedData);
const error = validateLogic(updatedData);
if (error) {
setJsonLogicError(error);
return;
}
updateValue(updatedData); // valid JSON
};

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
16 changes: 13 additions & 3 deletions src/components/builder/values/items-expression.tsx
Original file line number Diff line number Diff line change
@@ -1,22 +1,26 @@
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';

/**
* 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<JSONObject | string | undefined>(NAME);

const {validateLogic} = useContext(BuilderContext);

const htmlId = `editform-${NAME}`;
return (
<Component
Expand All @@ -32,7 +36,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}
validateLogic={logic => validateLogic(logic, [['', '']])}
/>
</div>

<Description
Expand Down
Loading

0 comments on commit 06817a1

Please sign in to comment.