Skip to content

Commit

Permalink
Merge pull request #28 from open-formulieren/feature/#19-conditionals
Browse files Browse the repository at this point in the history
#19 - Basic Form.io conditionals.
  • Loading branch information
svenvandescheur authored May 11, 2023
2 parents 0a39682 + 210bae7 commit 86e9584
Show file tree
Hide file tree
Showing 4 changed files with 225 additions and 45 deletions.
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;

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

Expand Down
6 changes: 0 additions & 6 deletions src/fixtures/formio/formio-example.ts
Original file line number Diff line number Diff line change
Expand Up @@ -47,10 +47,4 @@ export const FORMIO_EXAMPLE = [
multiple: true,
input: true,
},
{
type: 'button',
action: 'submit',
label: 'Submit',
theme: 'primary',
},
];
156 changes: 156 additions & 0 deletions src/lib/renderer/renderer.stories.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -151,6 +151,158 @@ 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: [
// 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<typeof RenderForm> = args => (
<RenderForm {...args}>
<button type="submit">Submit</button>
Expand Down Expand Up @@ -200,6 +352,10 @@ export const renderComponent: ComponentStory<typeof RenderComponent> = args => (
);
renderComponent.args = {
component: FORMIO_EXAMPLE[0],
form: {
display: 'form',
components: FORMIO_EXAMPLE,
},
};
renderComponent.play = async ({canvasElement}) => {
const canvas = within(canvasElement);
Expand Down
102 changes: 67 additions & 35 deletions src/lib/renderer/renderer.tsx
Original file line number Diff line number Diff line change
@@ -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 = {
Expand All @@ -29,6 +22,30 @@ export const RenderContext = React.createContext<IRenderConfiguration>(
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;
Expand Down Expand Up @@ -71,8 +88,8 @@ export const RenderForm = ({
onSubmit,
}: IRenderFormProps): React.ReactElement => {
const childComponents =
form.components?.map((component: IRendererComponent, i: number) => (
<RenderComponent key={component.id || i} component={component} />
form.components?.map((c: IRenderable) => (
<RenderComponent key={`${c.id}-${c.key}`} component={c} form={form} />
)) || null;

return (
Expand All @@ -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.
Expand All @@ -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.
Expand All @@ -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) => (
<RenderComponent key={c.id || i} component={c} />
const childComponents = cComponents?.map(c => (
<RenderComponent key={`${c.id}-${c.key}`} component={c} form={form} />
));

// Columns from component.
const childColumns = cColumns?.map((c: IFormioColumn, i) => (
const childColumns = cColumns?.map(c => (
<RenderComponent
key={i}
component={{...c, defaultValue: undefined, key: undefined, type: 'column'}}
key={`${c.id}-${c.key}`}
component={{
...c,
key: OF_MISSING_KEY,
type: 'column',
}}
form={form}
/>
));

Expand All @@ -172,6 +203,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 @@ -184,7 +216,7 @@ const Fallback = (props: IComponentProps) => <React.Fragment>{props.children}</R
* @external {RenderContext} Expects `RenderContext` to be available.
*/
export const useComponentType = (
component: IColumnComponent | IRendererComponent
component: IRenderable
): React.ComponentType<IColumnProps | IComponentProps> => {
const renderConfiguration = useContext(RenderContext);
const ComponentType = renderConfiguration.components[component.type];
Expand Down

0 comments on commit 86e9584

Please sign in to comment.