Skip to content

Commit

Permalink
Merge pull request #33 from open-formulieren/feature/31-date-component
Browse files Browse the repository at this point in the history
Implement form builder for date component type
  • Loading branch information
sergei-maertens authored Sep 4, 2023
2 parents 963fc20 + fae01ba commit 02e67f1
Show file tree
Hide file tree
Showing 25 changed files with 1,443 additions and 123 deletions.
211 changes: 118 additions & 93 deletions package-lock.json

Large diffs are not rendered by default.

3 changes: 2 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -61,7 +61,7 @@
"@formatjs/cli": "^6.1.1",
"@formatjs/ts-transformer": "^3.12.0",
"@fortawesome/fontawesome-free": "^6.4.0",
"@open-formulieren/types": "^0.8.0",
"@open-formulieren/types": "^0.9.0",
"@storybook/addon-actions": "^7.3.2",
"@storybook/addon-essentials": "^7.3.2",
"@storybook/addon-interactions": "^7.3.2",
Expand Down Expand Up @@ -100,6 +100,7 @@
"typescript": "^4.9.5"
},
"dependencies": {
"@emotion/css": "^11.11.2",
"clsx": "^1.2.1",
"formik": "^2.2.9",
"lodash.camelcase": "^4.3.0",
Expand Down
84 changes: 81 additions & 3 deletions src/components/ComponentConfiguration.stories.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -252,7 +252,7 @@ export const Email: Story = {
// 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');
await fireEvent.change(keyInput, {target: {value: 'customKey'}});
fireEvent.change(keyInput, {target: {value: 'customKey'}});
await userEvent.click(canvas.getByRole('tab', {name: 'Advanced'}));
await userEvent.click(canvas.getByRole('tab', {name: 'Basic'}));
await userEvent.clear(canvas.getByLabelText('Label'));
Expand Down Expand Up @@ -286,7 +286,7 @@ export const Email: Story = {

export const NumberField: Story = {
render: Template,
name: 'type: numberfield',
name: 'type: number',

args: {
component: {
Expand Down Expand Up @@ -335,11 +335,89 @@ export const NumberField: Story = {
// 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');
await fireEvent.change(keyInput, {target: {value: 'customKey'}});
fireEvent.change(keyInput, {target: {value: 'customKey'}});
await userEvent.click(canvas.getByRole('tab', {name: 'Advanced'}));
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');
},
};

export const DateField: Story = {
render: Template,
name: 'type: date',

args: {
component: {
id: 'wekruya',
type: 'date',
key: 'date',
label: 'A date field',
validate: {
required: false,
},
},

builderInfo: {
title: 'Date Field',
icon: 'calendar',
group: 'basic',
weight: 10,
schema: {},
},
},

play: async ({canvasElement}) => {
const canvas = within(canvasElement);

await expect(canvas.getByLabelText('Label')).toHaveValue('A date field');
await waitFor(async () => {
await expect(canvas.getByLabelText('Property Name')).toHaveValue('aDateField');
});
await expect(canvas.getByLabelText('Description')).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();
await expect(canvas.queryByLabelText('Placeholder')).not.toBeInTheDocument();

// ensure that changing fields in the edit form properly update the preview
const preview = within(canvas.getByTestId('componentPreview'));

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

const previewInput = preview.getByLabelText<HTMLInputElement>('Updated preview label');
await expect(previewInput).toHaveDisplayValue('');
await expect(previewInput.type).toEqual('date');

// 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.change(keyInput, {target: {value: 'customKey'}});
await userEvent.click(canvas.getByRole('tab', {name: 'Advanced'}));
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');

// check that toggling the 'multiple' checkbox properly updates the preview and default
// value field
await userEvent.click(canvas.getByLabelText('Multiple values'));
await userEvent.click(preview.getByRole('button', {name: 'Add another'}));
// await expect(preview.getByTestId('input-customKey[0]')).toHaveDisplayValue('');
// test for the default value inputs -> these don't have accessible labels/names :(
const addButtons = canvas.getAllByRole('button', {name: 'Add another'});
await userEvent.click(addButtons[0]);
await waitFor(async () => {
await expect(await canvas.findByTestId('input-defaultValue[0]')).toBeVisible();
});

// check that default value is e-mail validated
const defaultInput0 = canvas.getByTestId<HTMLInputElement>('input-defaultValue[0]');
await expect(defaultInput0.type).toEqual('date');
// userEvent.type does not reliably work with date input, and the native browser
// datepicker helps in enforcing only valid dates.
},
};
3 changes: 3 additions & 0 deletions src/components/builder/i18n.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,9 @@ function getEmptyTranslationsObject<T extends SupportedLocales[]>(
*
* useManageTranslations<TextFieldSchema>(['label', 'description', 'defaultValue']);
*
* @todo: only consider field names where the value is a string -> translating defaultValue
* number makes no sense
*
* @param forProperties Array of form field names to monitor for translation.
*/
export function useManageTranslations<S extends AnyComponentSchema = AnyComponentSchema>(
Expand Down
16 changes: 14 additions & 2 deletions src/components/formio/component.tsx
Original file line number Diff line number Diff line change
@@ -1,13 +1,16 @@
import {css} from '@emotion/css';
import clsx from 'clsx';
import React from 'react';

import {AnyComponentSchema} from '@/types';
import {useValidationErrors} from '@/utils/errors';
import {ErrorList} from '@/utils/errors';

import ComponentLabel from './component-label';

export interface ComponentProps {
type: 'textfield' | 'select' | 'checkbox' | 'number' | 'datagrid' | 'datamap'; // TODO: can this be inferred from somewhere?
// XXX: eventually (most) of these literals will be included in AnyComponentType
type: AnyComponentSchema['type'] | 'checkbox' | 'datagrid' | 'datamap' | 'select';
field?: string;
required?: boolean;
label?: React.ReactNode;
Expand All @@ -16,6 +19,15 @@ export interface ComponentProps {
children: React.ReactNode;
}

// Fix the overlapping icons/text when the error icon is shown.
// XXX: once we've moved away from bootstrap/formio 'component library', this fix and
// @emotion/css can be removed again.
const PAD_ERROR_ICON = css`
.form-control.is-invalid {
padding-inline-end: calc(1.5em + 0.75rem);
}
`;

const Component: React.FC<ComponentProps> = ({
type,
field = '',
Expand All @@ -26,7 +38,7 @@ const Component: React.FC<ComponentProps> = ({
...props
}) => {
const {errors} = useValidationErrors(field);
const className = clsx('form-group', 'has-feedback', 'formio-component', {
const className = clsx('form-group', 'has-feedback', 'formio-component', PAD_ERROR_ICON, {
[`formio-component-${type}`]: type,
'has-error': field && errors.length > 0,
required: required,
Expand Down
101 changes: 101 additions & 0 deletions src/components/formio/datefield.stories.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,101 @@
import {expect} from '@storybook/jest';
import {Meta, StoryObj} from '@storybook/react';
import {userEvent, within} from '@storybook/testing-library';

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

import DateField from './datefield';

export default {
title: 'Formio/Components/DateField',
component: DateField,
decorators: [withFormik],
parameters: {
modal: {noModal: true},
formik: {initialValues: {'my-datefield': '1980-01-01'}},
},
args: {
name: 'my-datefield',
},
} as Meta<typeof DateField>;

type Story = StoryObj<typeof DateField>;

export const Required: Story = {
args: {
required: true,
label: 'A required datefield',
},
};

export const WithoutLabel: Story = {
args: {
label: '',
},
};

export const WithToolTip: Story = {
args: {
label: 'With tooltip',
tooltip: 'Hiya!',
required: false,
},
};

export const Multiple: Story = {
args: {
label: 'Multiple inputs',
description: 'Array of dates instead of a single date value',
multiple: true,
},

parameters: {
formik: {
initialValues: {'my-datefield': ['1980-01-01']},
},
},

play: async ({canvasElement}) => {
const canvas = within(canvasElement);

// check that new items can be added
await userEvent.click(canvas.getByRole('button', {name: 'Add another'}));
const input1 = canvas.getByTestId('input-my-datefield[0]');
await expect(input1).toHaveDisplayValue('1980-01-01');

await userEvent.clear(input1);
await expect(input1).toHaveDisplayValue('');

// the label & description should be rendered only once, even with > 1 inputs
await expect(canvas.queryAllByText('Multiple inputs')).toHaveLength(1);
await expect(
canvas.queryAllByText('Array of dates instead of a single date value')
).toHaveLength(1);

// finally, it should be possible delete rows again
const removeButtons = await canvas.findAllByRole('button', {name: 'Remove item'});
await expect(removeButtons).toHaveLength(2);
await userEvent.click(removeButtons[0]);
await expect(canvas.getByTestId('input-my-datefield[0]')).toHaveDisplayValue('');
await expect(canvas.queryByTestId('input-my-datefield[1]')).not.toBeInTheDocument();
},
};

export const WithErrors: Story = {
args: {
label: 'With errors',
},

parameters: {
formik: {
initialValues: {'my-datefield': ''},
initialErrors: {'my-datefield': 'Example error', 'other-field': 'Other error'},
},
},

play: async ({canvasElement}) => {
const canvas = within(canvasElement);
await expect(canvas.queryByText('Other error')).not.toBeInTheDocument();
await expect(canvas.queryByText('Example error')).toBeInTheDocument();
},
};
80 changes: 80 additions & 0 deletions src/components/formio/datefield.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,80 @@
import clsx from 'clsx';
import {Field, useFormikContext} from 'formik';
import {useContext} from 'react';

import {RenderContext} from '@/context';
import {ErrorList, useValidationErrors} from '@/utils/errors';

import Component from './component';
import Description from './description';
import {withMultiple} from './multiple';

export interface DateFieldProps {
name: string;
label?: React.ReactNode;
required?: boolean;
tooltip?: string;
description?: string;
}

export const DateField: React.FC<JSX.IntrinsicElements['input'] & DateFieldProps> = ({
name,
label,
required = false,
tooltip = '',
description = '',
...props
}) => {
const {getFieldProps} = useFormikContext();
const {bareInput} = useContext(RenderContext);
const {errors, hasErrors} = useValidationErrors(name);

const htmlId = `editform-${name}`;

const {value} = getFieldProps<string | undefined | null>(name);

// let's not bother with date pickers - use the native browser date input instead.
const inputField = (
<Field
name={name}
id={htmlId}
as="input"
type="date"
className={clsx('form-control', {'is-invalid': hasErrors})}
data-testid={`input-${name}`}
// text fallback - use ISO-8601
pattern="\d{4}-\d{2}-\d{2}"
value={value ?? ''}
{...props}
/>
);

// 'bare input' is actually a little bit more than just the input, looking at the
// vanillay formio implementation.
if (bareInput) {
return (
<>
{inputField}
<ErrorList errors={errors} />
</>
);
}

// default-mode, wrapping the field with label, description etc.
return (
<Component
type="date"
field={name}
required={required}
htmlId={htmlId}
label={label}
tooltip={tooltip}
>
<div>{inputField}</div>
{description && <Description text={description} />}
</Component>
);
};

export const DateFieldMultiple = withMultiple(DateField, '');
export default DateFieldMultiple;
1 change: 1 addition & 0 deletions src/components/formio/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ export {default as Description} from './description';
export {default as Tooltip} from './tooltip';
export {default as TextField} from './textfield';
export {default as Checkbox} from './checkbox';
export {default as DateField} from './datefield';
export {default as Panel} from './panel';
export {default as Select} from './select';
export {default as NumberField} from './number';
Expand Down
3 changes: 3 additions & 0 deletions src/components/formio/select.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ export interface SelectProps<
tooltip?: string;
isClearable?: boolean;
valueProperty?: string;
onChange?: (event: {target: {name: string; value: any}}) => void;
}

function isOption<Option, Group extends GroupBase<Option> = GroupBase<Option>>(
Expand All @@ -38,6 +39,7 @@ function Select<
tooltip = '',
isClearable = false,
valueProperty = 'value',
onChange,
...props
}: SelectProps<Option, IsMulti, Group>) {
const [field, , {setValue}] = useField(name);
Expand Down Expand Up @@ -83,6 +85,7 @@ function Select<
const rawValues = normalized.map(val => val?.[valueProperty] ?? null);
const rawValue = isSingle ? rawValues[0] : rawValues;
setValue(rawValue);
onChange?.({target: {name, value: rawValue}});
}}
value={value}
/>
Expand Down
Loading

0 comments on commit 02e67f1

Please sign in to comment.