Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

✨ #19 - Basic Form.io conditionals. #28

Merged
merged 5 commits into from
May 11, 2023
Merged
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 2 additions & 4 deletions src/components/columns/columns.component.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -36,11 +36,9 @@ export interface IFormioColumn {
sizeMobile?: ColumnSize;
}

export interface IColumnComponent extends IFormioColumn {
defaultValue: undefined;

key: undefined;
type IFormioColumnComponentSchema = IFormioColumn & ComponentSchema;
svenvandescheur marked this conversation as resolved.
Show resolved Hide resolved

export interface IColumnComponent extends IFormioColumnComponentSchema {
type: 'column';
}

Expand Down
86 changes: 86 additions & 0 deletions src/fixtures/formio/formio-conditional.ts
svenvandescheur marked this conversation as resolved.
Show resolved Hide resolved
Original file line number Diff line number Diff line change
@@ -0,0 +1,86 @@
import {IContentComponent} from '@components';
import {IRendererComponent} from '@lib/renderer';

export const FORMIO_CONDITIONAL: Array<IRendererComponent | IContentComponent> = [
// 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"?',
},
];
1 change: 1 addition & 0 deletions src/fixtures/formio/index.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
export * from './formio-columns';
export * from './formio-conditional';
export * from './formio-example';
export * from './formio-length';
export * from './formio-pattern';
Expand Down
93 changes: 90 additions & 3 deletions src/lib/renderer/renderer.stories.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,16 @@
import {FORMIO_EXAMPLE, FORMIO_LENGTH, FORMIO_PATTERN, FORMIO_REQUIRED} from '@fixtures';
import {DEFAULT_RENDER_CONFIGURATION, RenderComponent, RenderForm} from '@lib/renderer/renderer';
import {
FORMIO_CONDITIONAL,
FORMIO_EXAMPLE,
FORMIO_LENGTH,
FORMIO_PATTERN,
FORMIO_REQUIRED,
} from '@fixtures';
import {
DEFAULT_RENDER_CONFIGURATION,
IRendererComponent,
RenderComponent,
RenderForm,
} from '@lib/renderer/renderer';
import {expect} from '@storybook/jest';
import type {ComponentStory, Meta} from '@storybook/react';
import {userEvent, within} from '@storybook/testing-library';
Expand Down Expand Up @@ -151,6 +162,78 @@ renderFormWithNestedKeyValidation.play = async ({canvasElement}) => {
await canvas.findByText('Er zijn te weinig karakters opgegeven.');
};

export const renderFormWithConditionalLogic: ComponentStory<typeof RenderForm> = args => (
<RenderForm {...args}>
<button type="submit">Submit</button>
</RenderForm>
);
renderFormWithConditionalLogic.args = {
configuration: DEFAULT_RENDER_CONFIGURATION,
form: {
display: 'form',
components: FORMIO_CONDITIONAL,
},
initialValues: {
favoriteAnimal: '',
motivationCat: '',
motivationDog: '',
content: '',
},
};
renderFormWithConditionalLogic.play = async ({canvasElement}) => {
const canvas = within(canvasElement);
const input = canvas.getByLabelText('Favorite animal', {
selector: 'input',
svenvandescheur marked this conversation as resolved.
Show resolved Hide resolved
});
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<typeof RenderForm> = args => (
<RenderForm {...args}>
<button type="submit">Submit</button>
Expand Down Expand Up @@ -199,7 +282,11 @@ export const renderComponent: ComponentStory<typeof RenderComponent> = args => (
</RenderComponent>
);
renderComponent.args = {
component: FORMIO_EXAMPLE[0],
component: FORMIO_EXAMPLE[0] as IRendererComponent,
form: {
display: 'form',
components: FORMIO_EXAMPLE,
},
};
renderComponent.play = async ({canvasElement}) => {
const canvas = within(canvasElement);
Expand Down
82 changes: 69 additions & 13 deletions src/lib/renderer/renderer.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -9,11 +9,13 @@ import {
} 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 {ComponentSchema, Utils} from 'formiojs';
import React, {FormHTMLAttributes, useContext} from 'react';

import getComponent = Utils.getComponent;

export const DEFAULT_RENDER_CONFIGURATION: IRenderConfiguration = {
components: {
columns: Columns,
Expand All @@ -29,6 +31,14 @@ export const RenderContext = React.createContext<IRenderConfiguration>(
DEFAULT_RENDER_CONFIGURATION
);

export interface IRendererComponent extends ComponentSchema {
columns?: IFormioColumn[];
components?: IRendererComponent[];
id: string;
key: string;
type: string;
}

export interface IRenderFormProps {
children: React.ReactNode;
configuration: IRenderConfiguration;
Expand Down Expand Up @@ -72,7 +82,7 @@ export const RenderForm = ({
}: IRenderFormProps): React.ReactElement => {
const childComponents =
form.components?.map((component: IRendererComponent, i: number) => (
<RenderComponent key={component.id || i} component={component} />
<RenderComponent key={component.id || i} component={component} form={form} />
)) || null;

return (
Expand All @@ -95,15 +105,9 @@ export const RenderForm = ({
);
};

export interface IRendererComponent extends ComponentSchema {
columns?: IFormioColumn[];
components?: IRendererComponent[];
id?: string;
type: string;
}

export interface IRenderComponentProps {
component: IColumnComponent | IRendererComponent;
form: IFormioForm;
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'm wondering if it wouldn't make more sense to provide the entire form via RenderContext = React.createContext({form: {}})

It's something that will affect other aspects (I think) so it has more of a "global" and implicit nature. It would also avoid prop-drilling through utility components and have to be provided only once in the RenderForm, while we could add a storybook decorator to wrap atomic components with 🤔

Mostly thinking out loud here!

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yeah I really don't like context for that global nature. Pretty much only for application state and only if props passing and composition seem to be unpractical IMO.

Especially in the domain where we don't want to rely on internals due to the use case it's also an issue when stuff is available only in context and not through the proper interfaces.

}

/** @const Form.io does not guarantee a key for a form component, we use this as a fallback. */
Expand Down Expand Up @@ -135,9 +139,25 @@ 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 {setFieldValue} = useFormikContext();
const Component = useComponentType(component);
const field = useField(component.key || OF_MISSING_KEY);

// Basic Form.io conditional.
const show = useConditional(component, form);

if (!show && component.clearOnHide) {
setFieldValue(component.key || OF_MISSING_KEY, null);
}

if (!show) {
return null;
}

const [{value, onBlur, onChange}, {error}] = field;
const callbacks = {onBlur, onChange};
const errors = error?.split('\n') || []; // Reconstruct array.
Expand All @@ -154,14 +174,23 @@ export const RenderComponent = ({component}: IRenderComponentProps): React.React

// Regular children, either from component or column.
const childComponents = cComponents?.map((c: IRendererComponent, i: number) => (
<RenderComponent key={c.id || i} component={c} />
<RenderComponent key={c.id || i} component={c} form={form} />
));

// Columns from component.
const childColumns = cColumns?.map((c: IFormioColumn, i) => (
<RenderComponent
key={i}
component={{...c, defaultValue: undefined, key: undefined, type: 'column'}}
component={{
...c,
clearOnHide: undefined,
conditional: {eq: undefined, show: undefined, when: undefined},
defaultValue: undefined,
hidden: false,
key: undefined,
type: 'column',
}}
form={form}
/>
));

Expand All @@ -172,6 +201,7 @@ export const RenderComponent = ({component}: IRenderComponentProps): React.React
</Component>
);
};

/**
* 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
Expand All @@ -190,3 +220,29 @@ export const useComponentType = (
const ComponentType = renderConfiguration.components[component.type];
return ComponentType || Fallback;
};

/**
* Evaluates the `component.conditional` (if set) and returns whether the component should be shown.
* This does not evaluate complex form logic but merely the basic Form.io conditional logic (found
* in the "Advanced" tab).
*
* @external {FormikContext} Expects `Formik`/`FormikContext` to be available.
* @return {boolean} If a conditional passes, the show argument is returned to respect it's
* configuration. If a conditional does not pass, `!component.hidden` is used as return value.
*/
export const useConditional = (
component: IColumnComponent | IRendererComponent,
form: IFormioForm
) => {
const {eq, show, when} = component?.conditional || {};
const isConditionalSet = typeof show == 'boolean' && when;
svenvandescheur marked this conversation as resolved.
Show resolved Hide resolved
const otherComponent = getComponent(form.components, when as string, false);
const [{value}] = useField(otherComponent.key || OF_MISSING_KEY);
const isEqual = eq === value;
svenvandescheur marked this conversation as resolved.
Show resolved Hide resolved

if (!isConditionalSet || !otherComponent || !isEqual) {
svenvandescheur marked this conversation as resolved.
Show resolved Hide resolved
return !component.hidden;
}

return show;
};