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

Conditionally hide component #54

Draft
wants to merge 5 commits into
base: main
Choose a base branch
from
Draft
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
1 change: 1 addition & 0 deletions .npmrc
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
legacy-peer-deps=true
3 changes: 2 additions & 1 deletion .storybook/main.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@ const config: StorybookConfig = {
'@storybook/addon-links',
'@storybook/addon-essentials',
'@storybook/addon-interactions',
'storybook-react-intl',
// 'storybook-react-intl',
'@storybook/addon-webpack5-compiler-babel',
{
name: '@storybook/addon-styling-webpack',
Expand Down Expand Up @@ -67,6 +67,7 @@ const config: StorybookConfig = {
config.plugins.push(
new CircularDependencyPlugin({
failOnError: true,
exclude: /node_modules/,
})
);
return config;
Expand Down
400 changes: 94 additions & 306 deletions package-lock.json

Large diffs are not rendered by default.

1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -66,6 +66,7 @@
"prettier": "^3.2.5",
"react": "^18.2.0",
"react-dom": "^18.2.0",
"react-formio": "~4.3.0",
"sass": "^1.83.0",
"sass-loader": "^16.0.4",
"storybook": "^8.4.7",
Expand Down
13 changes: 13 additions & 0 deletions src/components/FormioForm.stories.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,19 @@ export default {

type Story = StoryObj<typeof FormioForm>;

export const Example: Story = {
args: {
components: [
{
id: 'component1',
type: 'textfield',
key: 'nested.textfield',
label: 'Text field 1',
} satisfies TextFieldComponentSchema,
],
},
};

export const FlatLayout: Story = {
args: {
components: [
Expand Down
49 changes: 38 additions & 11 deletions src/components/FormioForm.tsx
Original file line number Diff line number Diff line change
@@ -1,7 +1,9 @@
import type {AnyComponentSchema} from '@open-formulieren/types';
import {Form, Formik, setIn} from 'formik';
import {Form, Formik, setIn, useFormikContext} from 'formik';
import {useMemo} from 'react';
import {toFormikValidationSchema} from 'zod-formik-adapter';

import {isHidden} from '@/formio';
import {extractInitialValues} from '@/initialValues';
import {getRegistryEntry} from '@/registry';
import type {JSONObject} from '@/types';
Expand All @@ -24,7 +26,7 @@ export interface FormioFormProps {
components: AnyComponentSchema[];
// enforce it to be async, makes Formik call setSubmitting when it resolves
onSubmit: (values: JSONObject) => Promise<void>;
children: React.ReactNode;
children?: React.ReactNode;
}

const FormioForm: React.FC<FormioFormProps> = ({components, onSubmit, children}) => {
Expand All @@ -37,6 +39,7 @@ const FormioForm: React.FC<FormioFormProps> = ({components, onSubmit, children})
{} satisfies JSONObject
);
// build the validation schema from the component definitions
// TODO: take into account hidden components!
const zodSchema = buildValidationSchema(components, getRegistryEntry);

return (
Expand All @@ -49,17 +52,41 @@ const FormioForm: React.FC<FormioFormProps> = ({components, onSubmit, children})
await onSubmit(values);
}}
>
<Form noValidate>
<FormFieldContainer>
{/* TODO: pre-process components to ensure they have an ID? */}
{components.map((definition, index) => (
<FormioComponent key={`${definition.id || index}`} componentDefinition={definition} />
))}
</FormFieldContainer>
{children}
</Form>
{/* TODO: pre-process components to ensure they have an ID? */}
<InnerFormioForm components={components}>{children}</InnerFormioForm>
</Formik>
);
};

export type InnerFormioFormProps = Pick<FormioFormProps, 'components' | 'children'>;

/**
* The FormioForm component inner children, with access to the Formik state.
*/
const InnerFormioForm: React.FC<InnerFormioFormProps> = ({components, children}) => {
const {values} = useFormikContext<JSONObject>();

const componentsToRender: AnyComponentSchema[] = useMemo(() => {
// TODO: handle layout/nesting components
const visibleComponents = components.filter(component => {
const hidden = isHidden(component, values);
// TODO: ensure that clearOnHide behaviour is invoked here!
if (hidden) console.debug(`Component ${component.key} is not visible`);
return !hidden;
});
return visibleComponents;
}, [components, values]);

return (
<Form noValidate>
<FormFieldContainer>
{componentsToRender.map((definition, index) => (
<FormioComponent key={`${definition.id || index}`} componentDefinition={definition} />
))}
</FormFieldContainer>
{children}
</Form>
);
};

export default FormioForm;
54 changes: 54 additions & 0 deletions src/formio.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,54 @@
/**
* Implements Formio business logic.
*
* These are helpers around Formio.js concepts which we implement ourselves rather than
* depending on formio.js or @formio/js packages.
*/
import {AnyComponentSchema} from '@open-formulieren/types';
import {getIn} from 'formik';
import {ConditionalOptions} from 'formiojs';

import {JSONObject} from './types';

// we don't support the 'json' configuration.
export type Conditional = Required<Pick<ConditionalOptions, 'show' | 'when' | 'eq'>>;

export const getConditional = (component: AnyComponentSchema): Conditional | null => {
// component must support the construct in the first place
if (!('conditional' in component)) return null;
// undefined or null -> nothing to extract
if (component.conditional == undefined) return null;
const {show, when, eq} = component.conditional;
// incomplete configuration -> nothing to extract
if (show === undefined || when === undefined || eq === undefined) return null;
return {show, when, eq};
};

/**
* Determine if a component is visible or hidden, depending on the *current* values.
*/
export const isHidden = (component: AnyComponentSchema, values: JSONObject): boolean => {
// dynamic hidden/visible configuration
const conditional = getConditional(component);

// no conditional defined, so there is no dynamic behaviour. We look at the static
// configuration instead.
if (conditional === null) {
// 'static' hidden/visible configuration
const hidden = 'hidden' in component && component.hidden;
return !!hidden;
}

// now that we know there is dynamic configuration, we must evaluate it and use the
// result.
const {show, when, eq} = conditional;

// TODO: how does the scoping of the 'when' expression work for nesting and repeating
// groups? -> check backend code and formio reference.
const compareValue = getIn(values, when);
const conditionSatisfied = eq === compareValue;

// note that we return whether the component is hidden, not whether it is shown, so
// we must invert in the return value
return conditionSatisfied ? !show : show;
};
87 changes: 87 additions & 0 deletions src/reference/hidden.stories.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,87 @@
import {expect, userEvent, waitFor, within} from '@storybook/test';

import {ReferenceMeta, storyFactory} from './utils';

/**
* Stories to guard the 'hidden' feature behaviour against the Formio.js reference.
*
* These stories exist to ensure that our Renderer behaves the same as the original
* SDK _for the feature set we support_.
*/
export default {
title: 'Internal API / Reference behaviour / Hidden',
} satisfies ReferenceMeta;

const {custom: Hidden, reference: HiddenReference} = storyFactory({
args: {
components: [
{
type: 'textfield',
id: 'textfieldVisible',
key: 'textfieldVisible',
label: 'Textfield visible',
hidden: false,
},
{
type: 'textfield',
id: 'textfieldHidden',
key: 'textfieldHidden',
label: 'Textfield hidden',
hidden: true,
},
],
},
play: async ({canvasElement}) => {
const canvas = within(canvasElement);

const visibleField = await canvas.findByLabelText('Textfield visible');
expect(visibleField).toBeVisible();

const hiddenField = canvas.queryByLabelText('Textfield hidden');
expect(hiddenField).not.toBeInTheDocument();
},
});

export {Hidden, HiddenReference};

const {custom: ConditionallyHidden, reference: ConditionallyHiddenReference} = storyFactory({
args: {
components: [
{
type: 'textfield',
id: 'textfieldTrigger',
key: 'textfieldTrigger',
label: 'Trigger',
},
{
type: 'textfield',
id: 'textfieldHide',
key: 'textfieldHide',
label: 'Dynamically hidden',
conditional: {
when: 'textfieldTrigger',
eq: 'hide other field',
show: false,
},
},
],
},

play: async ({canvasElement}) => {
const canvas = within(canvasElement);

const triggerField = await canvas.findByLabelText('Trigger');
const followerField = await canvas.findByLabelText('Dynamically hidden');
expect(followerField).toBeVisible();

await userEvent.type(triggerField, 'hide other field');
expect(triggerField).toHaveDisplayValue('hide other field');

await waitFor(() => {
expect(canvas.queryByLabelText('Dynamically hidden')).not.toBeInTheDocument();
expect(canvas.queryAllByRole('textbox')).toHaveLength(1);
});
},
});

export {ConditionallyHidden, ConditionallyHiddenReference};
64 changes: 64 additions & 0 deletions src/reference/utils.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,64 @@
import {AnyComponentSchema} from '@open-formulieren/types';
import {Meta, StoryObj} from '@storybook/react';
import {fn} from '@storybook/test';
// @ts-expect-error
import {Form as ReactFormioForm} from 'react-formio';

import FormioForm from '@/components/FormioForm';

export interface ReferenceStoryArgs {
components: AnyComponentSchema[];
}

export type ReferenceMeta = Meta<ReferenceStoryArgs> & {
title: `Internal API / Reference behaviour / ${string}`;
};

export type Story = StoryObj<ReferenceStoryArgs>;

// usage: await sleep(3000);
const sleep = (ms: number) => new Promise(resolve => setTimeout(resolve, ms));

const renderCustom = (args: ReferenceStoryArgs) => <FormioForm onSubmit={fn()} {...args} />;

const renderReference = (args: ReferenceStoryArgs) => (
<>
<ReactFormioForm
form={{display: 'form', components: args.components}}
submission={{data: {}}}
options={{noAlerts: true}}
/>
<div
style={{
fontStyle: 'italic',
fontSize: '0.75em',
textAlign: 'center',
background: '#CDCDCDAA',
padding: '0.5em',
marginBlockStart: '1em',
}}
>
Formio.js SDK reference
</div>
</>
);

export const storyFactory = (story: Story): {custom: Story; reference: Story} => {
const play = story.play;
const ourStory: Story = {...story, render: renderCustom};
const referenceStory: Story = {
...story,
render: renderReference,
play: play
? // wrap play function with a timer because Formio takes time to initialize
async (...args) => {
await sleep(100);
await play(...args);
}
: undefined,
};
return {
custom: ourStory,
reference: referenceStory,
};
};
Loading