Skip to content

Commit

Permalink
✨ [#52] Add formio textarea component
Browse files Browse the repository at this point in the history
This was originally planned to use for the itemsExpression
in the selectboxes component, but turned out to not be easy
to manage to validate for valid JSON and eventually
replaced with the already existing (courtesy of Laurens)
JSONEdit component.

Since we need to eventually implement the TextArea builder
component anyway, it will be of good use later and was
decided to keep it.
  • Loading branch information
sergei-maertens committed Nov 17, 2023
1 parent e0ec215 commit 44cd6fe
Show file tree
Hide file tree
Showing 4 changed files with 203 additions and 1 deletion.
2 changes: 1 addition & 1 deletion src/components/formio/component.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@ import ComponentLabel from './component-label';

export interface ComponentProps {
// XXX: eventually (most) of these literals will be included in AnyComponentType
type: AnyComponentSchema['type'] | 'checkbox' | 'datagrid' | 'datamap' | 'select' | 'columns';
type: AnyComponentSchema['type'] | 'datagrid' | 'datamap' | 'select' | 'columns' | 'textarea';
field?: string;
required?: boolean;
label?: React.ReactNode;
Expand Down
1 change: 1 addition & 0 deletions src/components/formio/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ export {default as Panel} from './panel';
export {default as Select} from './select';
export {default as SelectBoxes} from './selectboxes';
export {default as NumberField} from './number';
export {default as TextArea} from './textarea';
export * from './datagrid';
export {default as DataGrid} from './datagrid';
export {default as DataMap} from './datamap';
104 changes: 104 additions & 0 deletions src/components/formio/textarea.stories.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,104 @@
import {expect} from '@storybook/jest';
import {Meta, StoryObj} from '@storybook/react';
import {userEvent, within} from '@storybook/testing-library';

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

import TextArea from './textarea';

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

type Story = StoryObj<typeof TextArea>;

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

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 strings instead of a single string value',
multiple: true,
},

parameters: {
formik: {
initialValues: {'my-textarea': ['first value']},
},
},

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-textarea[0]');
await expect(input1).toHaveDisplayValue('first value');
await userEvent.clear(input1);
await userEvent.type(input1, 'Foo');
await expect(input1).toHaveDisplayValue('Foo');

const input2 = canvas.getByTestId('input-my-textarea[1]');
await expect(input2).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 strings instead of a single string 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-textarea[0]')).toHaveDisplayValue('');
await expect(canvas.queryByTestId('input-my-textarea[1]')).not.toBeInTheDocument();
},
};

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

parameters: {
formik: {
initialValues: {'my-textarea': ''},
initialErrors: {'my-textarea': '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();
},
};
97 changes: 97 additions & 0 deletions src/components/formio/textarea.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,97 @@
import clsx from 'clsx';
import {Field, useFormikContext} from 'formik';
import {useContext, useRef} from 'react';

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

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

export interface TextAreaProps {
name: string;
label?: React.ReactNode;
required?: boolean;
tooltip?: string;
description?: React.ReactNode;
showCharCount?: boolean;
onChange?: (event: React.ChangeEvent<HTMLInputElement>) => void;
}

export const TextArea: React.FC<JSX.IntrinsicElements['textarea'] & TextAreaProps> = ({
name,
label,
required = false,
tooltip = '',
description = '',
showCharCount = false,
onChange,
...props
}) => {
const {getFieldProps, getFieldMeta} = useFormikContext();
const {value, onChange: formikOnChange} = getFieldProps<string | undefined>(name);
const {touched} = getFieldMeta<string | undefined>(name);
const {errors, hasErrors} = useValidationErrors(name);
// const [{value}, {touched}] = useField<string | undefined>(name);
const inputRef = useRef<HTMLInputElement>(null);
const {bareInput} = useContext(RenderContext);

const htmlId = `editform-${name}`;
if (value === undefined && props.value === undefined) {
props = {...props, value: ''};
}

const inputField = (
<Field
innerRef={inputRef}
name={name}
id={htmlId}
as="textarea"
type="textarea"
className={clsx('form-control', {'is-invalid': hasErrors})}
data-testid={`input-${name}`}
onChange={(event: React.ChangeEvent<HTMLInputElement>) => {
formikOnChange(event);
onChange?.(event);
}}
{...props}
/>
);

const hasFocus = inputRef.current === document.activeElement;
const charCount = showCharCount && (touched || hasFocus) && value && <CharCount value={value} />;

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

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

// make the TextArea component 'multiple' capable
export const TextAreaMultiple = withMultiple(TextArea, '');
export default TextAreaMultiple;

0 comments on commit 44cd6fe

Please sign in to comment.