diff --git a/src/components/columns/columns.component.tsx b/src/components/columns/columns.component.tsx index 99b3be8..3fa3d1a 100644 --- a/src/components/columns/columns.component.tsx +++ b/src/components/columns/columns.component.tsx @@ -36,11 +36,9 @@ export interface IFormioColumn { sizeMobile?: ColumnSize; } -export interface IColumnComponent extends IFormioColumn { - defaultValue: undefined; - - key: undefined; +type IFormioColumnComponentSchema = IFormioColumn & ComponentSchema; +export interface IColumnComponent extends IFormioColumnComponentSchema { type: 'column'; } diff --git a/src/fixtures/formio/formio-example.ts b/src/fixtures/formio/formio-example.ts index 067fa15..85907bd 100644 --- a/src/fixtures/formio/formio-example.ts +++ b/src/fixtures/formio/formio-example.ts @@ -47,10 +47,4 @@ export const FORMIO_EXAMPLE = [ multiple: true, input: true, }, - { - type: 'button', - action: 'submit', - label: 'Submit', - theme: 'primary', - }, ]; diff --git a/src/lib/renderer/renderer.stories.tsx b/src/lib/renderer/renderer.stories.tsx index ba59767..57a1f80 100644 --- a/src/lib/renderer/renderer.stories.tsx +++ b/src/lib/renderer/renderer.stories.tsx @@ -151,6 +151,158 @@ renderFormWithNestedKeyValidation.play = async ({canvasElement}) => { await canvas.findByText('Er zijn te weinig karakters opgegeven.'); }; +export const renderFormWithConditionalLogic: ComponentStory = args => ( + + + +); +renderFormWithConditionalLogic.args = { + configuration: DEFAULT_RENDER_CONFIGURATION, + form: { + display: 'form', + components: [ + // Reference field. + { + id: 'favoriteAnimal', + type: 'textfield', + label: 'Favorite animal', + key: 'favoriteAnimal', + }, + + // Case: hide unless "cat" + { + conditional: { + eq: 'cat', + show: true, + when: 'favoriteAnimal', + }, + id: 'motivationCat', + hidden: true, + type: 'textfield', + key: 'motivation', + label: 'Motivation', + placeholder: 'I like cats because...', + description: 'Please motivate why "cat" is your favorite animal...', + }, + + // Case hide unless "dog" + { + conditional: { + eq: 'dog', + show: true, + when: 'favoriteAnimal', + }, + id: 'motivationDog', + hidden: true, + type: 'textfield', + key: 'motivation', + label: 'Motivation', + placeholder: 'I like dogs because...', + description: 'Please motivate why "dog" is your favorite animal...', + }, + + // Case hide unless "" (empty string) + { + conditional: { + eq: '', + show: true, + when: 'favoriteAnimal', + }, + id: 'content1', + hidden: true, + type: 'content', + key: 'content', + html: 'Please enter you favorite animal.', + }, + + // Case show unless "cat" + { + conditional: { + eq: 'cat', + show: false, + when: 'favoriteAnimal', + }, + id: 'content2', + hidden: false, + type: 'content', + key: 'content', + html: 'Have you tried "cat"?', + }, + + // Case show unless "dog" + { + conditional: { + eq: 'dog', + show: false, + when: 'favoriteAnimal', + }, + id: 'content3', + hidden: false, + type: 'content', + key: 'content', + html: 'Have you tried "dog"?', + }, + ], + }, + initialValues: { + favoriteAnimal: '', + motivationCat: '', + motivationDog: '', + content: '', + }, +}; +renderFormWithConditionalLogic.play = async ({canvasElement}) => { + const canvas = within(canvasElement); + const input = canvas.getByLabelText('Favorite animal'); + expect( + await canvas.queryByText('Please motivate why "cat" is your favorite animal...') + ).toBeNull(); + expect( + await canvas.queryByText('Please motivate why "dog" is your favorite animal...') + ).toBeNull(); + await canvas.findByText('Please enter you favorite animal.'); + await canvas.findByText('Have you tried "cat"?'); + await canvas.findByText('Have you tried "dog"?'); + await userEvent.type(input, 'horse', {delay: 30}); + expect( + await canvas.queryByText('Please motivate why "cat" is your favorite animal...') + ).toBeNull(); + expect( + await canvas.queryByText('Please motivate why "dog" is your favorite animal...') + ).toBeNull(); + expect(await canvas.queryByText('Please enter you favorite animal.')).toBeNull(); + await canvas.findByText('Have you tried "cat"?'); + await canvas.findByText('Have you tried "dog"?'); + await userEvent.clear(input); + await userEvent.type(input, 'cat', {delay: 30}); + await canvas.findByText('Please motivate why "cat" is your favorite animal...'); + expect( + await canvas.queryByText('Please motivate why "dog" is your favorite animal...') + ).toBeNull(); + expect(await canvas.queryByText('Please enter you favorite animal.')).toBeNull(); + expect(await canvas.queryByText('Have you tried "cat"?')).toBeNull(); + await canvas.findByText('Have you tried "dog"?'); + await userEvent.clear(input); + await userEvent.type(input, 'dog', {delay: 30}); + expect( + await canvas.queryByText('Please motivate why "cat" is your favorite animal...') + ).toBeNull(); + await canvas.findByText('Please motivate why "dog" is your favorite animal...'); + expect(await canvas.queryByText('Please enter you favorite animal.')).toBeNull(); + await canvas.findByText('Have you tried "cat"?'); + expect(await canvas.queryByText('Have you tried "dog"?')).toBeNull(); + await userEvent.clear(input); + expect( + await canvas.queryByText('Please motivate why "cat" is your favorite animal...') + ).toBeNull(); + expect( + await canvas.queryByText('Please motivate why "dog" is your favorite animal...') + ).toBeNull(); + await canvas.findByText('Please enter you favorite animal.'); + await canvas.findByText('Have you tried "cat"?'); + await canvas.findByText('Have you tried "dog"?'); +}; + export const formValidationWithLayoutComponent: ComponentStory = args => ( @@ -200,6 +352,10 @@ export const renderComponent: ComponentStory = args => ( ); renderComponent.args = { component: FORMIO_EXAMPLE[0], + form: { + display: 'form', + components: FORMIO_EXAMPLE, + }, }; renderComponent.play = async ({canvasElement}) => { const canvas = within(canvasElement); diff --git a/src/lib/renderer/renderer.tsx b/src/lib/renderer/renderer.tsx index eab5236..74bc908 100644 --- a/src/lib/renderer/renderer.tsx +++ b/src/lib/renderer/renderer.tsx @@ -1,17 +1,10 @@ -import { - Column, - Columns, - Content, - IColumnComponent, - IColumnProps, - IFormioColumn, - TextField, -} from '@components'; +import {Column, Columns, Content, IColumnProps, TextField} from '@components'; import {DEFAULT_VALIDATORS, getFormErrors} from '@lib/validation'; import {IComponentProps, IFormioForm, IRenderConfiguration, IValues} from '@types'; -import {Formik, useField} from 'formik'; +import {Formik, useField, useFormikContext} from 'formik'; import {FormikHelpers} from 'formik/dist/types'; -import {ComponentSchema} from 'formiojs'; +import {Utils} from 'formiojs'; +import {ConditionalOptions} from 'formiojs/types/components/schema'; import React, {FormHTMLAttributes, useContext} from 'react'; export const DEFAULT_RENDER_CONFIGURATION: IRenderConfiguration = { @@ -29,6 +22,30 @@ export const RenderContext = React.createContext( DEFAULT_RENDER_CONFIGURATION ); +/** Form.io does not guarantee a key for a form component, we use this as a fallback. */ +export const OF_MISSING_KEY = 'OF_MISSING_KEY'; + +/** + * Specifies the required and optional properties for a schema which can be rendered by the + * renderer. + * + * A schema implementing `IRenderable` is not limited to `ComponentSchema` (as columns can be + * rendered) and components will be rendered with the full (Component)Schema. + */ +export interface IRenderable { + key: string; + type: string; + + components?: IRenderable[]; + clearOnHide?: boolean; + columns?: IRenderable[]; + conditional?: ConditionalOptions; + hidden?: boolean; + id?: string; + + [index: string]: any; +} + export interface IRenderFormProps { children: React.ReactNode; configuration: IRenderConfiguration; @@ -71,8 +88,8 @@ export const RenderForm = ({ onSubmit, }: IRenderFormProps): React.ReactElement => { const childComponents = - form.components?.map((component: IRendererComponent, i: number) => ( - + form.components?.map((c: IRenderable) => ( + )) || null; return ( @@ -95,20 +112,11 @@ export const RenderForm = ({ ); }; -export interface IRendererComponent extends ComponentSchema { - columns?: IFormioColumn[]; - components?: IRendererComponent[]; - id?: string; - type: string; -} - export interface IRenderComponentProps { - component: IColumnComponent | IRendererComponent; + component: IRenderable; + form: IFormioForm; } -/** @const Form.io does not guarantee a key for a form component, we use this as a fallback. */ -export const OF_MISSING_KEY = 'OF_MISSING_KEY'; - /** * Renderer for rendering a Form.io component passed as component. Iterates over children (and * columns) and returns a `React.ReactElement` containing the rendered component. @@ -135,9 +143,28 @@ export const OF_MISSING_KEY = 'OF_MISSING_KEY'; * @external {FormikContext} Expects `Formik`/`FormikContext` to be available. * @external {RenderContext} Expects `RenderContext` to be available. */ -export const RenderComponent = ({component}: IRenderComponentProps): React.ReactElement => { +export const RenderComponent = ({ + component, + form, +}: IRenderComponentProps): React.ReactElement | null => { + const key = component.key || OF_MISSING_KEY; + const {setFieldValue, values} = useFormikContext(); const Component = useComponentType(component); - const field = useField(component.key || OF_MISSING_KEY); + const field = useField(key); + + // Basic Form.io conditional. + const show = Utils.hasCondition(component) + ? Utils.checkCondition(component, null, values, form, null) + : !component.hidden; + + if (!show && component.clearOnHide) { + setFieldValue(key, null); + } + + if (!show) { + return null; + } + const [{value, onBlur, onChange}, {error}] = field; const callbacks = {onBlur, onChange}; const errors = error?.split('\n') || []; // Reconstruct array. @@ -148,20 +175,24 @@ export const RenderComponent = ({component}: IRenderComponentProps): React.React // // This allows for components to remain simple and increases compatibility with existing design // systems. - const _component = component as IRendererComponent; - const cComponents = component.components ? component.components : null; - const cColumns = _component.columns ? _component.columns : null; + const cComponents = component.components || null; + const cColumns = component.columns || null; // Regular children, either from component or column. - const childComponents = cComponents?.map((c: IRendererComponent, i: number) => ( - + const childComponents = cComponents?.map(c => ( + )); // Columns from component. - const childColumns = cColumns?.map((c: IFormioColumn, i) => ( + const childColumns = cColumns?.map(c => ( )); @@ -172,6 +203,7 @@ export const RenderComponent = ({component}: IRenderComponentProps): React.React ); }; + /** * Fallback component, gets used when no other component is found within the `RenderContext` * The Fallback component makes sure (child) components keep being rendered with as little side @@ -184,7 +216,7 @@ const Fallback = (props: IComponentProps) => {props.children} => { const renderConfiguration = useContext(RenderContext); const ComponentType = renderConfiguration.components[component.type];