Skip to content

Commit

Permalink
#43 - Add support for multiple.
Browse files Browse the repository at this point in the history
  • Loading branch information
Sven van de Scheur committed Jun 1, 2023
1 parent bd04ec0 commit 23d7ea7
Show file tree
Hide file tree
Showing 8 changed files with 246 additions and 20 deletions.
1 change: 1 addition & 0 deletions src/containers/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
export * from './multiple';
1 change: 1 addition & 0 deletions src/containers/multiple/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
export * from './multiple.container';
93 changes: 93 additions & 0 deletions src/containers/multiple/multiple.container.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,93 @@
import {Component, Description, Label} from '@components';
import {IRenderable, RenderComponent} from '@lib/renderer';
import {IComponentProps, Values} from '@types';
import {ComponentSchema} from 'formiojs';
import React, {useState} from 'react';

export interface IMultipleComponent extends ComponentSchema {
type: string;
}

export interface IMultipleProps extends IComponentProps {
component: IMultipleComponent;
value: Values;
}

/**
* Implements `multiple: true` behaviour.
*
* Provide a thin wrapper around a component with controls for adding multiple instances. Utilizes
* <RenderComponent/> to render individual instances.
*/
export const Multiple: React.FC<IMultipleProps> = props => {
const {component, form, path, value = [], setValue} = props; // FIXME: Awaits future pr.
const [keys, setKeys] = useState<number[]>(Array.from({length: 1}, (_, i) => i));

/** Finds next key by increasing the max key with 1. */
const getKey = (): number => {
if (!keys.length) {
return 0;
}
const max = Math.max(...keys);
return max + 1;
};

/** Add item. */
const add = () => {
setKeys([...keys, getKey()]);
};

/** Remove item at index. */
const remove = (index: number) => {
const _keys = keys.filter((_, i) => i !== index);
const val = value.filter((_, i) => i !== index);
setKeys(_keys);
setValue(path, val);
};

/** Renders individual components utilizing <RenderComponent/>. */
const renderComponents = () =>
Array.from({length: keys.length}, (_, index) => {
// Clone and adjust component to fit nested needs.
const renderable: IRenderable = {
...structuredClone(component),
key: `${path}.${index}`, // Trigger Formik array values.
multiple: false, // Handled by <Multiple/>
description: '', // One description rendered for all components.
label: '', // One label rendered for all components.
};

return (
<tr key={keys[index]}>
<td>
<RenderComponent component={renderable} form={form} path={path} value={value[index]} />
</td>
<td>
<button onClick={() => remove(index)} aria-controls={renderable.key}>
Remove item
</button>
</td>
</tr>
);
});

return (
<Component {...props}>
<Label {...props} />
<table>
<tbody>{renderComponents()}</tbody>
<tfoot>
<tr>
<td>
<button type="button" onClick={() => add()}>
Add another
</button>
</td>
</tr>
</tfoot>
</table>
<table />
<Description {...props} />
</Component>
);
};
64 changes: 64 additions & 0 deletions src/containers/multiple/multiple.stories.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,64 @@
import {DEFAULT_RENDER_CONFIGURATION, RenderForm} from '@lib/renderer';
import {expect} from '@storybook/jest';
import type {ComponentStory, Meta} from '@storybook/react';
import {userEvent, within} from '@storybook/testing-library';

import {Multiple} from './multiple.container';

const meta: Meta<typeof Multiple> = {
title: 'Containers / Multiple',
component: Multiple,
decorators: [],
parameters: {},
};
export default meta;

export const multipleTextfields: ComponentStory<typeof RenderForm> = args => (
<RenderForm {...args} />
);
multipleTextfields.args = {
configuration: DEFAULT_RENDER_CONFIGURATION,
form: {
display: 'form',
components: [
{
type: 'textfield',
key: 'multiple-inputs',
description: 'Array of strings instead of a single string value',
label: 'Multiple inputs',
multiple: true,
},
],
},
initialValues: {
'multiple-inputs': ['first value'],
},
onSubmit: () => {},
};
multipleTextfields.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 = await canvas.getAllByRole('textbox')[0];
expect(input1).toHaveDisplayValue('first value');
await userEvent.clear(input1);
await userEvent.type(input1, 'Foo');
expect(input1).toHaveDisplayValue('Foo');

const input2 = await canvas.getAllByRole('textbox')[1];
expect(input2).toHaveDisplayValue('');

// the label & description should be rendered only once, even with > 1 inputs
expect(canvas.queryAllByText('Multiple inputs')).toHaveLength(1);
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'});
expect(removeButtons).toHaveLength(2);
await userEvent.click(removeButtons[0]);
expect(await canvas.getAllByRole('textbox')[0]).toHaveDisplayValue('');
expect(await canvas.getAllByRole('textbox')).toHaveLength(1);
};
77 changes: 63 additions & 14 deletions src/lib/renderer/renderer.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import {Column, Columns, Content, IColumnProps, TextField} from '@components';
import {Multiple} from '@containers';
import {DEFAULT_VALIDATORS, getFormErrors} from '@lib/validation';
import {IComponentProps, IFormioForm, IRenderConfiguration, IValues} from '@types';
import {IComponentProps, IFormioForm, IRenderConfiguration, IValues, Value, Values} from '@types';
import {Formik, useField, useFormikContext} from 'formik';
import {FormikHelpers} from 'formik/dist/types';
import {Utils} from 'formiojs';
Expand All @@ -14,6 +15,9 @@ export const DEFAULT_RENDER_CONFIGURATION: IRenderConfiguration = {
content: Content,
textfield: TextField,
},
containers: {
multiple: Multiple,
},
validators: DEFAULT_VALIDATORS,
};

Expand Down Expand Up @@ -79,18 +83,26 @@ export interface IRenderFormProps {
*
* @external {RenderContext} Provides `RenderContext`.
*/
export const RenderForm = ({
export const RenderForm: React.FC<IRenderFormProps> = ({
children,
configuration,
form,
formAttrs,
initialValues = {},
onSubmit,
}: IRenderFormProps): React.ReactElement => {
const childComponents =
form.components?.map((c: IRenderable) => (
<RenderComponent key={`${c.id}-${c.key}`} component={c} form={form} />
)) || null;
}) => {
const childComponents: React.ReactElement[] = [];
// TODO: Refactor types
Utils.eachComponent(
form.components,
(c: IRenderable, path: string) => {
const component = (
<RenderComponent key={`${c.id}-${c.key}`} component={c} form={form} path={path} />
);
childComponents.push(component);
},
true
);

return (
<RenderContext.Provider value={configuration}>
Expand All @@ -115,6 +127,8 @@ export const RenderForm = ({
export interface IRenderComponentProps {
component: IRenderable;
form: IFormioForm;
path: string;
value?: Value | Values;
}

/**
Expand Down Expand Up @@ -143,10 +157,12 @@ export interface IRenderComponentProps {
* @external {FormikContext} Expects `Formik`/`FormikContext` to be available.
* @external {RenderContext} Expects `RenderContext` to be available.
*/
export const RenderComponent = ({
export const RenderComponent: React.FC<IRenderComponentProps> = ({
component,
form,
}: IRenderComponentProps): React.ReactElement | null => {
path,
value = undefined,
}) => {
const key = component.key || OF_MISSING_KEY;
const {setFieldValue, values} = useFormikContext();
const Component = useComponentType(component);
Expand All @@ -165,7 +181,11 @@ export const RenderComponent = ({
return null;
}

const [{value, onBlur, onChange}, {error}] = field;
const [{onBlur, onChange}, {error}] = field;

// Allow the value to be overriden.
const _value = value !== undefined ? value : field[0].value;

const callbacks = {onBlur, onChange};
const errors = error?.split('\n') || []; // Reconstruct array.

Expand All @@ -180,7 +200,7 @@ export const RenderComponent = ({

// Regular children, either from component or column.
const childComponents = cComponents?.map(c => (
<RenderComponent key={`${c.id}-${c.key}`} component={c} form={form} />
<RenderComponent key={`${c.id}-${c.key}`} component={c} form={form} path={path} />
));

// Columns from component.
Expand All @@ -193,12 +213,21 @@ export const RenderComponent = ({
type: 'column',
}}
form={form}
path={path}
/>
));

// Return the component, pass children.
return (
<Component callbacks={callbacks} component={component} errors={errors} value={value}>
<Component
callbacks={callbacks}
component={component}
form={form}
errors={errors}
path={path}
value={_value}
setValue={setFieldValue}
>
{childComponents || childColumns}
</Component>
);
Expand All @@ -212,13 +241,33 @@ export const RenderComponent = ({
const Fallback = (props: IComponentProps) => <React.Fragment>{props.children}</React.Fragment>;

/**
* Custom hook resolving the `React.ComponentType` from `RenderContext`.
* Custom hook resolving the `React.ComponentType` based on the configuration in `RenderContext`.
* Resolving is performed in the following order:
*
* 1. - A `React.ComponentType` configured for a certain "containers" entry indicating an internal
* edge case.
* 2. - A `React.ComponentType` configured for a certain "components" entry indicating a regular
* component.
* 3. - A fallback component solely rendering `props.children`.
* @external {RenderContext} Expects `RenderContext` to be available.
*/
export const useComponentType = (
component: IRenderable
): React.ComponentType<IColumnProps | IComponentProps> => {
const renderConfiguration = useContext(RenderContext);
const ContainerType = useContainerConfiguration(component);
const ComponentType = renderConfiguration.components[component.type];
return ComponentType || Fallback;
return ContainerType || ComponentType || Fallback;
};

/**
* Returns the applicable `containerConfiguration` (if any) for component.
* @see {IContainerConfiguration}
*/
export const useContainerConfiguration = (component: IRenderable) => {
const renderConfiguration = useContext(RenderContext);
if (component.multiple) {
return renderConfiguration.containers.multiple || null;
}
return null;
};
12 changes: 6 additions & 6 deletions src/types/componentprops.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,16 +2,16 @@ import {ComponentSchema} from 'formiojs';
import React from 'react';

import {ICallbackConfiguration} from './config';
import {IFormioForm} from './form';
import {Value, Values} from './value';

interface IComponentProps {
export interface IComponentProps {
callbacks: ICallbackConfiguration;

children?: React.ReactNode;

component: ComponentSchema;

errors: string[];

form: IFormioForm;
value: Value | Values | undefined;
path: string;
setValue: (field: string, value: any, shouldValidate?: boolean) => void;
children?: React.ReactNode;
}
17 changes: 17 additions & 0 deletions src/types/config.d.ts
Original file line number Diff line number Diff line change
@@ -1,11 +1,13 @@
import {IColumnProps} from '@components';
import {IRenderable} from '@lib/renderer';
import {validator} from '@lib/validation';
import React from 'react';

import {IComponentProps} from './componentprops';

export interface IRenderConfiguration {
components: IComponentConfiguration;
containers: IContainerConfiguration;
validators: validator[];
}

Expand All @@ -22,6 +24,21 @@ export interface ICallbackConfiguration {

export type callback = (e: Event | React.BaseSyntheticEvent) => void;

/**
* Describes a mapping between a component type (`ComponentSchema.type`) and a (React) component to
* render.
* @example `{type: "textfield"}` -> <TextField/>
*/
export interface IComponentConfiguration {
[index: string]: React.ComponentType<IColumnProps | IComponentProps>;
}

/**
* Describes a mapping between an (internal) container type and a (React) component to render. The
* internal container type is not directly linked to a specific key in `ComponentSchema` but may be
* referenced directly by the renderer based specific conditions.
* @example `{multiple: "true"}` -> <Multiple/>
*/
export interface IContainerConfiguration {
[index: string]: React.ComponentType<IColumnProps | IComponentProps>;
}
1 change: 1 addition & 0 deletions tsconfig.json
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@
"sourceMap": true,
"paths": {
"@components": ["src/components/index"],
"@containers": ["src/containers/index"],
"@fixtures": ["src/fixtures/index"],
"@lib/*": ["src/lib/*"],
"@types": ["src/types/index"]
Expand Down

0 comments on commit 23d7ea7

Please sign in to comment.