Skip to content

Commit

Permalink
🎨 [#4524] Tweak implementation details
Browse files Browse the repository at this point in the history
Attempted to make the code a bit easier/simpler, and clean up the
test helpers to use accessible queries.
  • Loading branch information
sergei-maertens committed Oct 11, 2024
1 parent 104487e commit 72859d8
Show file tree
Hide file tree
Showing 5 changed files with 139 additions and 91 deletions.
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import {expect, fireEvent, fn, userEvent, waitFor, within} from '@storybook/test';
import {expect, findByRole, fireEvent, fn, userEvent, waitFor, within} from '@storybook/test';
import selectEvent from 'react-select-event';

import {
Expand All @@ -8,7 +8,7 @@ import {
} from 'components/admin/form_design/mocks';
import {FormDecorator} from 'components/admin/form_design/story-decorators';
import {serializeValue} from 'components/admin/forms/VariableMapping';
import {getReactSelectInput} from 'utils/storybookTestHelpers';
import {getReactSelectContainer} from 'utils/storybookTestHelpers';

import DMNActionConfig from './DMNActionConfig';

Expand Down Expand Up @@ -187,7 +187,7 @@ export const Empty = {
outputMapping: [],
},
},
play: async ({canvasElement, step}) => {
play: async ({canvasElement, step, args}) => {
const canvas = within(canvasElement);
const originalConfirm = window.confirm;
window.confirm = () => true;
Expand Down Expand Up @@ -230,16 +230,25 @@ export const Empty = {
const dropdowns = within(document.querySelector('.logic-dmn__mapping-config')).getAllByRole(
'combobox'
);

await expect(dropdowns.length).toBe(2);
expect(dropdowns.length).toBe(2);

const [formVarsDropdowns, dmnVarsDropdown] = dropdowns;

await selectEvent.select(formVarsDropdowns, 'Name');
await userEvent.selectOptions(dmnVarsDropdown, 'Camunda variable');
// this is super flaky for some reason on both Chromium and Firefox :/
await waitFor(async () => {
await userEvent.selectOptions(dmnVarsDropdown, 'Camunda variable');
expect(dmnVarsDropdown).toHaveValue(serializeValue('camundaVar'));
});

await expect(getReactSelectInput(formVarsDropdowns)).toHaveValue('name');
await expect(dmnVarsDropdown).toHaveValue(serializeValue('camundaVar'));
await userEvent.click(canvas.getByRole('button', {name: 'Save'}));
expect(args.onSave).toHaveBeenCalledWith({
pluginId: 'camunda7',
decisionDefinitionId: 'approve-payment',
decisionDefinitionVersion: '2',
inputMapping: [{formVariable: 'name', dmnVariable: 'camundaVar'}],
outputMapping: [],
});
});

await step('Changing plugin clears decision definition, version and DMN vars', async () => {
Expand Down Expand Up @@ -320,9 +329,15 @@ export const withInitialValues = {
const formVariableDropdowns = await canvas.findAllByLabelText('Formuliervariabele');

await waitFor(async () => {
await expect(getReactSelectInput(formVariableDropdowns[0])).toHaveValue('name');
await expect(getReactSelectInput(formVariableDropdowns[1])).toHaveValue('surname');
await expect(getReactSelectInput(formVariableDropdowns[2])).toHaveValue('canApply');
expect(
await within(getReactSelectContainer(formVariableDropdowns[0])).findByText('Name')
).toBeVisible();
expect(
await within(getReactSelectContainer(formVariableDropdowns[1])).findByText('Surname')
).toBeVisible();
expect(
await within(getReactSelectContainer(formVariableDropdowns[2])).findByText('Can apply?')
).toBeVisible();
});
});

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,6 @@ import {
} from 'components/admin/form_design/mocks';
import {BACKEND_OPTIONS_FORMS} from 'components/admin/form_design/registrations';
import {mockTargetPathsPost} from 'components/admin/form_design/registrations/objectsapi/mocks';
import {getReactSelectInput} from 'utils/storybookTestHelpers';

import {serializeValue} from '../../forms/VariableMapping';
import {mockObjecttypeVersionsGet, mockObjecttypesGet} from '../registrations/objectsapi/mocks';
Expand Down Expand Up @@ -590,32 +589,39 @@ export const ConfigurePrefill = {
};

export const ConfigurePrefillObjectsAPI = {
play: async ({canvasElement}) => {
play: async ({canvasElement, step}) => {
const canvas = within(canvasElement);

const userDefinedVarsTab = await canvas.findByRole('tab', {name: 'Gebruikersvariabelen'});
expect(userDefinedVarsTab).toBeVisible();
await userEvent.click(userDefinedVarsTab);
await step('Open configuration modal', async () => {
const userDefinedVarsTab = await canvas.findByRole('tab', {name: 'Gebruikersvariabelen'});
expect(userDefinedVarsTab).toBeVisible();
await userEvent.click(userDefinedVarsTab);

// open modal for configuration
const editIcon = canvas.getByTitle('Prefill instellen');
await userEvent.click(editIcon);

const pluginDropdown = await screen.findByLabelText('Plugin');
expect(pluginDropdown).toBeVisible();

await userEvent.selectOptions(pluginDropdown, 'Objects API');

const variableSelect = await screen.findByLabelText('Formuliervariabele');
await expect(getReactSelectInput(variableSelect)).toHaveValue('formioComponent');
// open modal for configuration
const editIcon = canvas.getByTitle('Prefill instellen');
await userEvent.click(editIcon);
expect(await screen.findByRole('dialog')).toBeVisible();
});

// Wait until the API call to retrieve the prefillAttributes is done
await waitFor(async () => {
const prefillPropertySelect = await screen.findByLabelText(
'Select a property from the object type'
);
expect(prefillPropertySelect).toBeVisible();
expect(prefillPropertySelect).toHaveValue(serializeValue(['firstName']));
await step('Configure Objects API prefill', async () => {
const modal = within(await screen.findByRole('dialog'));
const pluginDropdown = await screen.findByLabelText('Plugin');
expect(pluginDropdown).toBeVisible();
await userEvent.selectOptions(pluginDropdown, 'Objects API');

// check mappings
const variableSelect = await screen.findByLabelText('Formuliervariabele');
expect(variableSelect).toBeVisible();
expect(modal.getByText('Form.io component')).toBeVisible();

// Wait until the API call to retrieve the prefillAttributes is done
await waitFor(async () => {
const prefillPropertySelect = await screen.findByLabelText(
'Select a property from the object type'
);
expect(prefillPropertySelect).toBeVisible();
expect(prefillPropertySelect).toHaveValue(serializeValue(['firstName']));
});
});
},
};
Expand Down
79 changes: 43 additions & 36 deletions src/openforms/js/components/admin/forms/ReactSelect.js
Original file line number Diff line number Diff line change
Expand Up @@ -32,23 +32,38 @@ const styles = {
}),
};

/**
* Find the option by `value` in the list of possible options.
*
* `options` are the options passed to ReactSelect, which may be a list of options or
* a list of option groups: `(Option | Group)[]`. If an item is a group, it has a nested
* property `options` of type `Option[]`.
*
* See https://github.com/JedWatson/react-select/blob/a8b8f4342cc113e921bb238de2dd69a2d345b5f8/packages/react-select/src/Select.tsx#L406
* for a reference.
*/
const getValue = (options, value) => {
if (!value) {
// 0 could be an actual selected value (!)
if (value == null || value === '') {
return null;
}

// We support grouped ReactSelect options.
// So we need to look in the first, and possible second level of options
for (let index = 0; index < options.length; index++) {
const option = options[index];
if ('value' in option && option.value === value) {
return option;
}
const foundOption = (option?.options || []).find(opt => opt.value === value);
if (foundOption) {
return foundOption;
// check all the options until we find a match on the `value` property for the option.
// ReactSelect allows using different properties for the value, but let's handle that
// when we actually need it.
for (const groupOrOption of options) {
if ('options' in groupOrOption) {
const hit = getValue(groupOrOption.options, value);
if (hit) return hit; // otherwise continue with the rest
} else {
// we're dealing with a plain option, compare the value property
if (groupOrOption.value === value) {
return groupOrOption;
}
}
}

return null;
};

/**
Expand All @@ -59,27 +74,22 @@ const getValue = (options, value) => {
* @deprecated - if possible, refactor the form to use Formik and use the Formik-enabled
* variant.
*/
const SelectWithoutFormik = ({name, options, value, className, onChange, ...props}) => {
const classes = classNames('admin-react-select', {
[`${className}`]: className,
});
return (
<ReactSelect
inputId={`id_${name}`}
name={name}
className={classes}
classNamePrefix="admin-react-select"
styles={styles}
menuPlacement="auto"
options={options}
value={getValue(options, value)}
onChange={selectedOption => {
onChange(selectedOption === null ? undefined : selectedOption.value);
}}
{...props}
/>
);
};
const SelectWithoutFormik = ({name, options, value, className, onChange, ...props}) => (
<ReactSelect
inputId={`id_${name}`}
name={name}
className={classNames('admin-react-select', className)}
classNamePrefix="admin-react-select"
styles={styles}
menuPlacement="auto"
options={options}
value={getValue(options, value)}
onChange={selectedOption => {
onChange(selectedOption === null ? undefined : selectedOption.value);
}}
{...props}
/>
);

/**
* A select dropdown backed by react-select for Formik forms.
Expand All @@ -90,13 +100,10 @@ const SelectWithFormik = ({name, options, className, ...props}) => {
const [fieldProps, , fieldHelpers] = useField(name);
const {value} = fieldProps;
const {setValue} = fieldHelpers;
const classes = classNames('admin-react-select', {
[`${className}`]: className,
});
return (
<ReactSelect
inputId={`id_${name}`}
className={classes}
className={classNames('admin-react-select', className)}
classNamePrefix="admin-react-select"
styles={styles}
menuPlacement="auto"
Expand Down
36 changes: 24 additions & 12 deletions src/openforms/js/components/admin/forms/VariableMapping.stories.js
Original file line number Diff line number Diff line change
@@ -1,8 +1,9 @@
import {expect, fn, userEvent, within} from '@storybook/test';
import {expect, fn, userEvent, waitFor, within} from '@storybook/test';
import selectEvent from 'react-select-event';

import {FormDecorator, FormikDecorator} from 'components/admin/form_design/story-decorators';
import {getReactSelectOptions} from 'utils/storybookTestHelpers';
import {VARIABLE_SOURCES} from 'components/admin/form_design/variables/constants';
import {getReactSelectContainer} from 'utils/storybookTestHelpers';

import VariableMapping, {serializeValue} from './VariableMapping';

Expand Down Expand Up @@ -35,17 +36,19 @@ export default {
{
form: 'foo',
formDefinition: 'foo',
name: 'name1',
name: 'Name 1',
key: 'key1',
source: '',
},
],

availableFormVariables: [
{
form: 'bar',
formDefinition: 'bar',
name: 'name2',
name: 'Name 2',
key: 'key2',
source: VARIABLE_SOURCES.component,
},
],
},
Expand Down Expand Up @@ -124,11 +127,15 @@ export const NonStringValues = {
export const SelectOptions = {
render: args => (
<>
<VariableMapping {...args} includeStaticVariables />
<VariableMapping {...args} />
<button type="submit">Submit</button>
</>
),

args: {
includeStaticVariables: true,
},

parameters: {
formik: {
onSubmit: fn(),
Expand All @@ -140,18 +147,23 @@ export const SelectOptions = {

await step('Check rendered values', async () => {
const formVariableDropdown = canvas.getByLabelText('Formuliervariabele');
await selectEvent.openMenu(formVariableDropdown);
const variableOptions = getReactSelectOptions(formVariableDropdown);
selectEvent.openMenu(formVariableDropdown);

const formVarOptions = await within(
getReactSelectContainer(formVariableDropdown)
).findAllByRole('option');

await expect(variableOptions).toHaveLength(2);
await expect(variableOptions[1]).toHaveTextContent('(key2)');
expect(formVarOptions).toHaveLength(2);
expect(formVarOptions[0]).toHaveTextContent('key2');
expect(formVarOptions[0]).toHaveTextContent('Name 2');
await userEvent.click(formVariableDropdown); // close the menu again

const propertyDropdown = canvas.getByLabelText('Pick a property');
const propertyOptions = within(propertyDropdown).getAllByRole('option');

await expect(propertyOptions).toHaveLength(3);
await expect(propertyOptions[1]).toHaveValue(serializeValue('option_1'));
await expect(propertyOptions[2]).toHaveValue(serializeValue('option_2'));
expect(propertyOptions).toHaveLength(3);
expect(propertyOptions[1]).toHaveValue(serializeValue('option_1'));
expect(propertyOptions[2]).toHaveValue(serializeValue('option_2'));
});

await step('Select different option and submit', async () => {
Expand Down
26 changes: 17 additions & 9 deletions src/openforms/js/utils/storybookTestHelpers.js
Original file line number Diff line number Diff line change
@@ -1,11 +1,19 @@
import {within} from '@storybook/test';

const getReactSelectInput = SelectElement => {
return SelectElement.closest('.admin-react-select').querySelector('input[type="hidden"]');
};

const getReactSelectOptions = SelectElement => {
return within(SelectElement.closest('.admin-react-select')).getAllByRole('option');
/**
* From the input field (retrieved by accessible queries), find the react-select container.
*
* Equivalent of https://github.com/romgain/react-select-event/blob/8619e8b3da349eadfa7321ea4aa2b7eee7209f9f/src/index.ts#L14,
* however instead of relying on the DOM structure we can leverage class names that are
* guaranteed to be set by us.
*
* Usage:
*
* const dropdown = canvas.getByLabelText('My dropdown');
* const container = getReactSelectContainer(dropdown);
* const options = within(container).queryByRole('option');
*/
const getReactSelectContainer = comboboxInput => {
const container = comboboxInput.closest('.admin-react-select');
return container;
};

export {getReactSelectInput, getReactSelectOptions};
export {getReactSelectContainer};

0 comments on commit 72859d8

Please sign in to comment.