Skip to content

Commit

Permalink
✨ [#52] Manage the appropriate form field values depending on openFor…
Browse files Browse the repository at this point in the history
…ms.dataSrc
  • Loading branch information
sergei-maertens committed Nov 16, 2023
1 parent fee85ae commit 6184b95
Show file tree
Hide file tree
Showing 6 changed files with 227 additions and 87 deletions.
12 changes: 10 additions & 2 deletions src/components/JSONEdit.tsx
Original file line number Diff line number Diff line change
@@ -1,23 +1,30 @@
import {JSONObject} from '@open-formulieren/types/lib/types';
import clsx from 'clsx';
import {useFormikContext} from 'formik';
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;
}

const JSONEdit: React.FC<JSONEditProps & TextareaHTMLAttributes<HTMLTextAreaElement>> = ({
data,
className = 'form-control',
name = '',
...props
}) => {
const dataAsJSON = JSON.stringify(data, null, 2);
const inputRef = useRef<HTMLTextAreaElement>(null);

const [value, setValue] = useState(dataAsJSON);
const [JSONValid, setJSONValid] = useState(true);
const {setValues} = useFormikContext();
const {setValues, setFieldValue} = useFormikContext();

// if no name is provided, replace the entire form state, otherwise only set a
// specific value
const updateValue = name ? (v: JSONObject) => setFieldValue(name, v) : setValues;

// synchronize external state changes
const isFocused = inputRef.current == document.activeElement;
Expand All @@ -37,7 +44,8 @@ const JSONEdit: React.FC<JSONEditProps & TextareaHTMLAttributes<HTMLTextAreaElem
setJSONValid(false);
return;
}
setValues(updatedData);

updateValue(updatedData);
};
return (
<>
Expand Down
2 changes: 1 addition & 1 deletion src/components/builder/values/items-expression.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -32,7 +32,7 @@ export const ItemsExpression: React.FC = () => {
}
>
<div>
<JSONEdit data={value} rows={3} />
<JSONEdit name={NAME} data={value} rows={3} id={htmlId} />
</div>

<Description
Expand Down
4 changes: 3 additions & 1 deletion src/components/builder/values/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,4 +5,6 @@ type ExtractDataSrcValues<T> = T extends {openForms: {dataSrc: infer U}} ? U : n

export type OptionValue = ExtractDataSrcValues<AnyComponentSchema>;

export type SchemaWithDataSrc = AnyComponentSchema & {openForms: {dataSrc: OptionValue}};
type FilterSchemasWithDataSrc<T> = T extends {openForms: {dataSrc: OptionValue}} ? T : never;

export type SchemaWithDataSrc = FilterSchemasWithDataSrc<AnyComponentSchema>;
78 changes: 0 additions & 78 deletions src/components/builder/values/values-config.stories.ts

This file was deleted.

185 changes: 185 additions & 0 deletions src/components/builder/values/values-config.stories.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,185 @@
import {RadioComponentSchema, SelectboxesComponentSchema} from '@open-formulieren/types';
import {expect, jest} from '@storybook/jest';
import {Meta, StoryObj} from '@storybook/react';
import {userEvent, within} from '@storybook/testing-library';
import {Form, Formik} from 'formik';

import {withFormik} from '@/sb-decorators';

import ValuesConfig from './values-config';

export default {
title: 'Formio/Builder/Values/ValuesConfig',
component: ValuesConfig,
parameters: {
controls: {hideNoControlsWarning: true},
modal: {noModal: true},
formik: {
initialValues: {
openForms: {
dataSrc: '',
},
},
},
},
args: {
name: 'values',
},
argTypes: {
name: {control: {disable: true}},
},
tags: ['autodocs'],
} as Meta<typeof ValuesConfig>;

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

/**
* Variant pinned to the `SelectboxesComponentSchema` component type.
*/
export const SelectBoxes: SelectboxesStory = {
decorators: [withFormik],
};

export const SelectBoxesManual: SelectboxesStory = {
decorators: [withFormik],
parameters: {
formik: {
initialValues: {
openForms: {
dataSrc: 'manual',
},
values: [
{
value: 'a',
label: 'A',
},
{
value: 'b',
label: 'B',
},
],
},
},
},
};

export const SelectBoxesVariable: SelectboxesStory = {
decorators: [withFormik],
parameters: {
formik: {
initialValues: {
openForms: {
dataSrc: 'variable',
itemsExpression: {var: 'someVariable'},
},
},
},
},
};

export const SelectBoxesResetState: StoryObj<{
onSubmit: (values: any) => void;
}> = {
render: ({onSubmit}) => {
return (
<Formik
initialValues={{openForms: {dataSrc: ''}}}
onSubmit={values => {
onSubmit(values);
}}
>
<Form>
<ValuesConfig<SelectboxesComponentSchema> name="values" />
<button type="submit" className="btn btn-primary">
Submit
</button>
</Form>
</Formik>
);
},
args: {
onSubmit: jest.fn(),
},
play: async ({canvasElement, step, args}) => {
const canvas = within(canvasElement);

await step('Nothing selected', async () => {
await userEvent.click(canvas.getByRole('button', {name: 'Submit'}));

expect(args.onSubmit).toHaveBeenCalledWith({openForms: {dataSrc: ''}});
// @ts-expect-error jest mocks + TS doesn't play nice together
args.onSubmit.mockClear();
});

await step('Manual values', async () => {
// Open the dropdown
const dataSrcSelect = canvas.getByLabelText('Data source');
await userEvent.click(dataSrcSelect);
await userEvent.keyboard('[ArrowDown]');

await userEvent.click(await canvas.findByText('Manually fill in'));
const addBtn = await canvas.findByRole('button', {name: 'Add another'});
await expect(addBtn).toBeVisible();
await userEvent.click(addBtn);

await userEvent.click(canvas.getByRole('button', {name: 'Submit'}));
expect(args.onSubmit).toHaveBeenCalledWith({
openForms: {dataSrc: 'manual'},
values: [{value: '', label: '', openForms: {translations: {}}}],
});
// @ts-expect-error jest mocks + TS doesn't play nice together
args.onSubmit.mockClear();
});

await step('Set variable source', async () => {
// Open the dropdown
const dataSrcSelect = canvas.getByLabelText('Data source');
await userEvent.click(dataSrcSelect);
await userEvent.keyboard('[ArrowDown]');

await userEvent.click(await canvas.findByText('From variable'));

const expressionInput = await canvas.findByLabelText('Items expression');
await userEvent.clear(expressionInput);
// { needs to be escaped: https://github.com/testing-library/user-event/issues/584
const expression = '{"var": "someVar"}'.replace(/[{[]/g, '$&$&');
await userEvent.type(expressionInput, expression);

await userEvent.click(canvas.getByRole('button', {name: 'Submit'}));
await expect(args.onSubmit).toHaveBeenCalledWith({
openForms: {
dataSrc: 'variable',
itemsExpression: {var: 'someVar'},
},
});
// @ts-expect-error jest mocks + TS doesn't play nice together
args.onSubmit.mockClear();
});

await step('Reset back to manual values', async () => {
// Open the dropdown
const dataSrcSelect = canvas.getByLabelText('Data source');
await userEvent.click(dataSrcSelect);
await userEvent.keyboard('[ArrowDown]');

await userEvent.click(await canvas.findByText('Manually fill in'));
await expect(await canvas.findByText('Manually fill in')).toBeVisible();

await userEvent.click(canvas.getByRole('button', {name: 'Submit'}));
expect(args.onSubmit).toHaveBeenCalledWith({
openForms: {dataSrc: 'manual'},
values: [], // all pre-existing items have been cleared
});
// @ts-expect-error jest mocks + TS doesn't play nice together
args.onSubmit.mockClear();
});
},
};

/**
* Variant pinned to the `RadioComponentSchema` component type.
*/
export const Radio: RadioStory = {
decorators: [withFormik],
};
33 changes: 28 additions & 5 deletions src/components/builder/values/values-config.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import {useFormikContext} from 'formik';
import {useLayoutEffect} from 'react';

import ItemsExpression from './items-expression';
import {SchemaWithDataSrc} from './types';
Expand All @@ -20,11 +21,33 @@ export interface ValuesConfigProps<T> {
* referencing other variables in the form evaluation context.
*/
export function ValuesConfig<T extends SchemaWithDataSrc>({name}: ValuesConfigProps<T>) {
const {
values: {
openForms: {dataSrc},
},
} = useFormikContext<T>();
const {values, setFieldValue} = useFormikContext<T>();
const {dataSrc} = values.openForms;

// synchronize form state with the dataSrc value, and ensure this is done *before* the
// browser repaints to prevent race conditions
useLayoutEffect(() => {
switch (dataSrc) {
case 'manual': {
if (values.openForms.hasOwnProperty('itemsExpression')) {
setFieldValue('openForms.itemsExpression', undefined);
}
if (!values.hasOwnProperty(name)) {
setFieldValue(name, []);
}
break;
}
case 'variable': {
if (values.hasOwnProperty(name)) {
setFieldValue(name, undefined);
}
break;
}
}
// deliberate that we only provide dataSrc as dependency, the hook should only run
// when that dropdown changes value.
}, [dataSrc]);

return (
<>
<ValuesSrc />
Expand Down

0 comments on commit 6184b95

Please sign in to comment.