diff --git a/src/containers/index.ts b/src/containers/index.ts new file mode 100644 index 0000000..e6a6634 --- /dev/null +++ b/src/containers/index.ts @@ -0,0 +1 @@ +export * from './multiple'; diff --git a/src/containers/multiple/index.ts b/src/containers/multiple/index.ts new file mode 100644 index 0000000..16bc0aa --- /dev/null +++ b/src/containers/multiple/index.ts @@ -0,0 +1 @@ +export * from './multiple.container'; diff --git a/src/containers/multiple/multiple.container.tsx b/src/containers/multiple/multiple.container.tsx new file mode 100644 index 0000000..0cc96b6 --- /dev/null +++ b/src/containers/multiple/multiple.container.tsx @@ -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 + * to render individual instances. + */ +export const Multiple: React.FC = props => { + const {component, form, path, value = [], setValue} = props; // FIXME: Awaits future pr. + const [keys, setKeys] = useState(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 . */ + 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 + description: '', // One description rendered for all components. + label: '', // One label rendered for all components. + }; + + return ( + + + + + + remove(index)} aria-controls={renderable.key}> + Remove item + + + + ); + }); + + return ( + + + + {renderComponents()} + + + + add()}> + Add another + + + + + + + + + ); +}; diff --git a/src/containers/multiple/multiple.stories.tsx b/src/containers/multiple/multiple.stories.tsx new file mode 100644 index 0000000..1c77a52 --- /dev/null +++ b/src/containers/multiple/multiple.stories.tsx @@ -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 = { + title: 'Containers / Multiple', + component: Multiple, + decorators: [], + parameters: {}, +}; +export default meta; + +export const multipleTextfields: ComponentStory = 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); +}; diff --git a/src/lib/renderer/renderer.tsx b/src/lib/renderer/renderer.tsx index 74bc908..06a5701 100644 --- a/src/lib/renderer/renderer.tsx +++ b/src/lib/renderer/renderer.tsx @@ -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'; @@ -14,6 +15,9 @@ export const DEFAULT_RENDER_CONFIGURATION: IRenderConfiguration = { content: Content, textfield: TextField, }, + containers: { + multiple: Multiple, + }, validators: DEFAULT_VALIDATORS, }; @@ -79,18 +83,26 @@ export interface IRenderFormProps { * * @external {RenderContext} Provides `RenderContext`. */ -export const RenderForm = ({ +export const RenderForm: React.FC = ({ children, configuration, form, formAttrs, initialValues = {}, onSubmit, -}: IRenderFormProps): React.ReactElement => { - const childComponents = - form.components?.map((c: IRenderable) => ( - - )) || null; +}) => { + const childComponents: React.ReactElement[] = []; + // TODO: Refactor types + Utils.eachComponent( + form.components, + (c: IRenderable, path: string) => { + const component = ( + + ); + childComponents.push(component); + }, + true + ); return ( @@ -115,6 +127,8 @@ export const RenderForm = ({ export interface IRenderComponentProps { component: IRenderable; form: IFormioForm; + path: string; + value?: Value | Values; } /** @@ -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 = ({ component, form, -}: IRenderComponentProps): React.ReactElement | null => { + path, + value = undefined, +}) => { const key = component.key || OF_MISSING_KEY; const {setFieldValue, values} = useFormikContext(); const Component = useComponentType(component); @@ -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. @@ -180,7 +200,7 @@ export const RenderComponent = ({ // Regular children, either from component or column. const childComponents = cComponents?.map(c => ( - + )); // Columns from component. @@ -193,12 +213,21 @@ export const RenderComponent = ({ type: 'column', }} form={form} + path={path} /> )); // Return the component, pass children. return ( - + {childComponents || childColumns} ); @@ -212,13 +241,33 @@ export const RenderComponent = ({ const Fallback = (props: IComponentProps) => {props.children}; /** - * 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 => { 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; }; diff --git a/src/types/componentprops.d.ts b/src/types/componentprops.d.ts index cd42247..4930b5a 100644 --- a/src/types/componentprops.d.ts +++ b/src/types/componentprops.d.ts @@ -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; } diff --git a/src/types/config.d.ts b/src/types/config.d.ts index 0718d3b..f17730f 100644 --- a/src/types/config.d.ts +++ b/src/types/config.d.ts @@ -1,4 +1,5 @@ import {IColumnProps} from '@components'; +import {IRenderable} from '@lib/renderer'; import {validator} from '@lib/validation'; import React from 'react'; @@ -6,6 +7,7 @@ import {IComponentProps} from './componentprops'; export interface IRenderConfiguration { components: IComponentConfiguration; + containers: IContainerConfiguration; validators: validator[]; } @@ -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"}` -> + */ export interface IComponentConfiguration { [index: string]: React.ComponentType; } + +/** + * 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"}` -> + */ +export interface IContainerConfiguration { + [index: string]: React.ComponentType; +} diff --git a/tsconfig.json b/tsconfig.json index 471b5b9..94686ad 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -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"]