Skip to content

Commit

Permalink
💚 [#59] Fix and test form state reset when switching between dataSrc
Browse files Browse the repository at this point in the history
The hasOwnProperty could not deal with dotted paths, which caused
stale data to be present. This in turn triggered validation errors,
preventing the edit model from being submittable.
  • Loading branch information
sergei-maertens committed Nov 22, 2023
1 parent 7d16743 commit 3d4e5cb
Show file tree
Hide file tree
Showing 3 changed files with 294 additions and 4 deletions.
218 changes: 218 additions & 0 deletions src/components/ComponentConfiguration.stories.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -1311,3 +1311,221 @@ export const Radio: Story = {
});
},
};

export const Select: Story = {
render: Template,
name: 'type: select',

args: {
component: {
id: 'wqimsadk',
type: 'select',
key: 'select',
label: 'A select field',
openForms: {
dataSrc: 'manual',
translations: {},
},
dataSrc: 'values',
data: {values: []},
defaultValue: '',
},

builderInfo: {
title: 'Select',
icon: 'th-list',
group: 'basic',
weight: 70,
schema: {},
},
},

play: async ({canvasElement, step, args}) => {
const canvas = within(canvasElement);
const editForm = within(canvas.getByTestId('componentEditForm'));
const preview = within(canvas.getByTestId('componentPreview'));

await expect(canvas.getByLabelText('Label')).toHaveValue('A select field');
await waitFor(async () => {
await expect(canvas.getByLabelText('Property Name')).toHaveValue('aSelectField');
});
await expect(canvas.getByLabelText('Description')).toHaveValue('');
await expect(canvas.getByLabelText('Tooltip')).toHaveValue('');
await expect(canvas.getByLabelText('Show in summary')).toBeChecked();
await expect(canvas.getByLabelText('Show in email')).not.toBeChecked();
await expect(canvas.getByLabelText('Show in PDF')).toBeChecked();

// ensure that changing fields in the edit form properly update the preview

await userEvent.clear(canvas.getByLabelText('Label'));
await userEvent.type(canvas.getByLabelText('Label'), 'Updated preview label');
expect(await preview.findByText('Updated preview label'));

// Ensure that the manually entered key is kept instead of derived from the label,
// even when key/label components are not mounted.
const keyInput = canvas.getByLabelText('Property Name');
// fireEvent is deliberate, as userEvent.clear + userEvent.type briefly makes the field
// not have any value, which triggers the generate-key-from-label behaviour.
fireEvent.change(keyInput, {target: {value: 'customKey'}});
await userEvent.click(canvas.getByRole('tab', {name: 'Basic'}));
await userEvent.clear(canvas.getByLabelText('Label'));
await userEvent.type(canvas.getByLabelText('Label'), 'Other label', {delay: 50});
await expect(canvas.getByLabelText('Property Name')).toHaveDisplayValue('customKey');

await step('Set up manual options', async () => {
// enter some possible options
const firstOptionLabelInput = canvas.getByLabelText('Option label');
expect(firstOptionLabelInput).toHaveDisplayValue('');
await userEvent.type(firstOptionLabelInput, 'Option label 1');
const firstOptionValue = canvas.getByLabelText('Option value');
await waitFor(() => expect(firstOptionValue).toHaveDisplayValue('optionLabel1'));

// add a second option
await userEvent.click(canvas.getByRole('button', {name: 'Add another'}));
const optionLabels = canvas.queryAllByLabelText('Option label');
const optionValues = canvas.queryAllByLabelText('Option value');
expect(optionLabels).toHaveLength(2);
expect(optionValues).toHaveLength(2);
await userEvent.type(optionValues[1], 'manualValue');
await userEvent.type(optionLabels[1], 'Second option');

const previewSearchInput = preview.getByLabelText('Other label');
previewSearchInput.focus();
await userEvent.keyboard('[ArrowDown]');
await expect(await preview.findByText('Second option')).toBeVisible();
await waitFor(() => {
expect(preview.queryByRole('listbox')).toBeNull();
});

await userEvent.click(canvas.getByRole('button', {name: 'Save'}));
expect(args.onSubmit).toHaveBeenCalledWith({
id: 'wqimsadk',
type: 'select',
// basic tab
label: 'Other label',
key: 'customKey',
description: '',
tooltip: '',
showInSummary: true,
showInEmail: false,
showInPDF: true,
hidden: false,
clearOnHide: true,
isSensitiveData: false,
dataSrc: 'values',
data: {
values: [
{
value: 'optionLabel1',
label: 'Option label 1',
},
{
value: 'manualValue',
label: 'Second option',
openForms: {translations: {}},
},
],
},
openForms: {
dataSrc: 'manual',
translations: {},
},
defaultValue: '',
// Advanced tab
conditional: {
show: undefined,
when: '',
eq: '',
},
// Validation tab
validate: {
required: false,
plugins: [],
},
translatedErrors: {
nl: {required: ''},
},
// registration tab
registration: {
attribute: '',
},
});
// @ts-expect-error
args.onSubmit.mockClear();
});

await step('Option labels are translatable', async () => {
await userEvent.click(canvas.getByRole('tab', {name: 'Translations'}));

// check that the option labels are in the translations table
expect(await editForm.findByText('Option label 1')).toBeVisible();
expect(await editForm.findByText('Second option')).toBeVisible();
});

await step('Set up itemsExpression for options', async () => {
await userEvent.click(canvas.getByRole('tab', {name: 'Basic'}));

canvas.getByLabelText('Data source').focus();
await userEvent.keyboard('[ArrowDown]');
await userEvent.click(await canvas.findByText('From variable'));
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, '$&$&');
await userEvent.type(itemsExpressionInput, expression);

await expect(editForm.queryByLabelText('Default value')).toBeNull();

const previewSearchInput = preview.getByLabelText('Other label');
previewSearchInput.focus();
await userEvent.keyboard('[ArrowDown]');
await expect(await preview.findByText(/"someVar"/)).toBeVisible();
await userEvent.keyboard('[Esc]');

await userEvent.click(canvas.getByRole('button', {name: 'Save'}));
expect(args.onSubmit).toHaveBeenCalledWith({
id: 'wqimsadk',
type: 'select',
// basic tab
label: 'Other label',
key: 'customKey',
description: '',
tooltip: '',
showInSummary: true,
showInEmail: false,
showInPDF: true,
hidden: false,
clearOnHide: true,
isSensitiveData: false,
dataSrc: 'values',
data: {},
openForms: {
dataSrc: 'variable',
itemsExpression: {var: 'someVar'},
translations: {},
},
defaultValue: '',
// Advanced tab
conditional: {
show: undefined,
when: '',
eq: '',
},
// Validation tab
validate: {
required: false,
plugins: [],
},
translatedErrors: {
nl: {required: ''},
},
// registration tab
registration: {
attribute: '',
},
});
// @ts-expect-error
args.onSubmit.mockClear();
});
},
};
61 changes: 59 additions & 2 deletions src/components/builder/values/values-config.stories.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,8 @@
import {RadioComponentSchema, SelectboxesComponentSchema} from '@open-formulieren/types';
import {
RadioComponentSchema,
SelectComponentSchema,
SelectboxesComponentSchema,
} from '@open-formulieren/types';
import {expect, jest} from '@storybook/jest';
import {Meta, StoryObj} from '@storybook/react';
import {fireEvent, userEvent, waitFor, within} from '@storybook/testing-library';
Expand Down Expand Up @@ -33,6 +37,7 @@ export default {

type SelectboxesStory = StoryObj<typeof ValuesConfig<SelectboxesComponentSchema>>;
type RadioStory = StoryObj<typeof ValuesConfig<RadioComponentSchema>>;
type SelectStory = StoryObj<typeof ValuesConfig<SelectComponentSchema>>;

/**
* Variant pinned to the `SelectboxesComponentSchema` component type.
Expand Down Expand Up @@ -221,7 +226,7 @@ export const RadioManual: RadioStory = {
},
};

export const Radioiable: RadioStory = {
export const RadioVariable: RadioStory = {
decorators: [withFormik],
parameters: {
formik: {
Expand All @@ -234,3 +239,55 @@ export const Radioiable: RadioStory = {
},
},
};

/**
* Variant pinned to the `SelectComponentSchema` component type.
*/
export const Select: SelectStory = {
decorators: [withFormik],
args: {
name: 'data.values',
},
};

export const SelectManual: SelectStory = {
...Select,

parameters: {
formik: {
initialValues: {
openForms: {
dataSrc: 'manual',
},
data: {
values: [
{
value: 'a',
label: 'A',
},
{
value: 'b',
label: 'B',
},
],
},
},
},
},
};

export const SelectVariable: SelectStory = {
...Select,

parameters: {
formik: {
initialValues: {
openForms: {
dataSrc: 'variable',
itemsExpression: {var: 'someVariable'},
},
data: {},
},
},
},
};
19 changes: 17 additions & 2 deletions src/components/builder/values/values-config.tsx
Original file line number Diff line number Diff line change
@@ -1,11 +1,26 @@
import {useFormikContext} from 'formik';
import {useLayoutEffect} from 'react';

import {hasOwnProperty} from '@/types';

import ItemsExpression from './items-expression';
import {SchemaWithDataSrc} from './types';
import ValuesSrc from './values-src';
import ValuesTable, {ValuesTableProps} from './values-table';

function isNestedKeySet(obj: {}, path: string): boolean {
const bits = path.split('.');
for (const bit of bits) {
// as soon as any node does not have the respective path set, exit, the full deep
// path will then also not be set.
if (!hasOwnProperty(obj, bit)) {
return false;
}
obj = obj[bit] as {};
}
return true;
}

export interface ValuesConfigProps<T> {
name: ValuesTableProps<T>['name'];
}
Expand All @@ -32,13 +47,13 @@ export function ValuesConfig<T extends SchemaWithDataSrc>({name}: ValuesConfigPr
if (values.openForms.hasOwnProperty('itemsExpression')) {
setFieldValue('openForms.itemsExpression', undefined);
}
if (!values.hasOwnProperty(name)) {
if (!isNestedKeySet(values, name)) {
setFieldValue(name, [{value: '', label: '', openForms: {translations: {}}}]);
}
break;
}
case 'variable': {
if (values.hasOwnProperty(name)) {
if (isNestedKeySet(values, name)) {
setFieldValue(name, undefined);
}
break;
Expand Down

0 comments on commit 3d4e5cb

Please sign in to comment.