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 all commits
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
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[];
Comment on lines +39 to +41
Copy link
Member

Choose a reason for hiding this comment

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

technically more correct would be (typescript code untested, so probably has some mistakes):

interface Columns {
  type: 'columns',
  columns: IRenderable[];  // or even a specific Column type...
}

type ComponentContainer = ComponentSchema & {
  type: 'fieldset' | 'editgrid'; // I think that's all we support at the moment
  components: IRenderable[];
}

type IRenderable = ComponentSchema | ComponentContainer  | Columns;

The union here is especially powerful for implementation/decision branches that you can isolate.

Copy link
Collaborator Author

@svenvandescheur svenvandescheur May 11, 2023

Choose a reason for hiding this comment

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

I'm already planning on refactoring this further in #43 due to needing to implement stuff which is renderable but not explcitly derived from the schema as a component, this is still a moving thing.

conditional?: ConditionalOptions;
hidden?: boolean;
id?: string;

[index: string]: any;
sergei-maertens marked this conversation as resolved.
Show resolved Hide resolved
}

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;
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. */
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);
}
svenvandescheur marked this conversation as resolved.
Show resolved Hide resolved

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