diff --git a/i18n/messages/en.json b/i18n/messages/en.json index 74fa11de..7a644ba8 100644 --- a/i18n/messages/en.json +++ b/i18n/messages/en.json @@ -464,6 +464,11 @@ "description": "Values table: accessible label to remove an option", "originalDefault": "Remove" }, + "ISVTEk": { + "defaultMessage": "The component type {type} is unknown. We can only display the JSON definition.", + "description": "Informational message about unsupported component", + "originalDefault": "The component type {type} is unknown. We can only display the JSON definition." + }, "JDYF2q": { "defaultMessage": "The data entered in this component will be removed in accordance with the privacy settings.", "description": "Tooltip for 'IsSensitiveData' builder field", diff --git a/i18n/messages/nl.json b/i18n/messages/nl.json index 941de639..8367cd31 100644 --- a/i18n/messages/nl.json +++ b/i18n/messages/nl.json @@ -470,6 +470,11 @@ "description": "Values table: accessible label to remove an option", "originalDefault": "Remove" }, + "ISVTEk": { + "defaultMessage": "The component type {type} is unknown. We can only display the JSON definition.", + "description": "Informational message about unsupported component", + "originalDefault": "The component type {type} is unknown. We can only display the JSON definition." + }, "JDYF2q": { "defaultMessage": "Gegevens opgevoerd in dit component worden geschoond volgens de privacy-instellingen.", "description": "Tooltip for 'IsSensitiveData' builder field", diff --git a/src/components/ComponentConfiguration.stories.tsx b/src/components/ComponentConfiguration.stories.tsx index c30a33cc..8b4e5c8e 100644 --- a/src/components/ComponentConfiguration.stories.tsx +++ b/src/components/ComponentConfiguration.stories.tsx @@ -137,6 +137,25 @@ export const Default: Story = { }, }; +export const UnsupportedComponent: Story = { + render: Template, + name: 'invalid/unsupported component type', + + args: { + component: { + // @ts-expect-error + type: 'an-invalid-type', + }, + builderInfo: { + title: 'invalid', + group: 'basic', + icon: 'terminal', + schema: {placeholder: ''}, + weight: 0, + }, + }, +}; + export const TextField: Story = { render: Template, name: 'type: textfield', diff --git a/src/components/ComponentConfiguration.tsx b/src/components/ComponentConfiguration.tsx index 20780ef5..eca05a25 100644 --- a/src/components/ComponentConfiguration.tsx +++ b/src/components/ComponentConfiguration.tsx @@ -1,8 +1,16 @@ +import {JSONEditor} from '@open-formulieren/monaco-json-editor'; +import {FallbackSchema} from '@open-formulieren/types'; +import {FormattedMessage} from 'react-intl'; + import {BuilderContext, BuilderContextType} from '@/context'; +import {isKnownComponentType} from '@/registry'; import ComponentEditForm, {ComponentEditFormProps} from './ComponentEditForm'; -export interface ComponentConfigurationProps extends BuilderContextType, ComponentEditFormProps {} +type MergedProps = BuilderContextType & ComponentEditFormProps; +export type ComponentConfigurationProps = Omit & { + component: MergedProps['component'] | FallbackSchema; +}; /** * The main entrypoint to edit a component in the builder modal. @@ -42,34 +50,65 @@ const ComponentConfiguration: React.FC = ({ onCancel, onRemove, onSubmit, -}) => ( - - { + if (!isKnownComponentType(component)) { + return ; + } + return ( + + + + ); +}; + +interface FallbackProps { + component: FallbackSchema; + theme: BuilderContextType['theme']; +} + +const Fallback: React.FC = ({component, theme}) => ( + <> + {chunks}, + }} + /> + alert('Editing is not possible in unknown components.')} + theme={theme} + readOnly /> - + ); export default ComponentConfiguration; diff --git a/src/components/ComponentEditForm.tsx b/src/components/ComponentEditForm.tsx index f3a57b4d..633e1131 100644 --- a/src/components/ComponentEditForm.tsx +++ b/src/components/ComponentEditForm.tsx @@ -1,3 +1,4 @@ +import {AnyComponentSchema} from '@open-formulieren/types'; import {Form, Formik} from 'formik'; import {ExtendedComponentSchema} from 'formiojs/types/components/schema'; import {cloneDeep, merge} from 'lodash'; @@ -6,8 +7,7 @@ import {FormattedMessage, useIntl} from 'react-intl'; import {toFormikValidationSchema} from 'zod-formik-adapter'; import {BuilderContext} from '@/context'; -import {Fallback, getRegistryEntry, isKnownComponentType} from '@/registry'; -import {AnyComponentSchema, FallbackSchema} from '@/types'; +import {getRegistryEntry} from '@/registry'; import GenericComponentPreview from './ComponentPreview'; @@ -22,13 +22,11 @@ export interface BuilderInfo { export interface ComponentEditFormProps { isNew: boolean; - // it is (currently) possible someone drags a component type into the canvas that we - // don't know (yet), so we need to handle FallbackSchema. - component: AnyComponentSchema | FallbackSchema; + component: AnyComponentSchema; builderInfo: BuilderInfo; onCancel: (e: React.MouseEvent) => void; onRemove: (e: React.MouseEvent) => void; - onSubmit: (component: AnyComponentSchema | FallbackSchema) => void; + onSubmit: (component: AnyComponentSchema) => void; } type ButtonRowProps = Pick & { @@ -76,7 +74,7 @@ const ComponentEditForm: React.FC = ({ const hasPreview = registryEntry.preview !== null; let initialValues = cloneDeep(component); - if (isNew && isKnownComponentType(component)) { + if (isNew) { // Formio.js mutates components when adding children (like fieldset layout component), // which apparently goes all the way to our default value definition, which is // supposed to be static. @@ -86,17 +84,12 @@ const ComponentEditForm: React.FC = ({ initialValues = merge(defaultValues, initialValues); } - // we infer the specific schema from the EditForm component obtained from the registry. - // This gives a specific schema rather than AnyComponentSchema and allows us to type - // check the values accordingly. - type ComponentSchema = React.ComponentProps['component']; - const Wrapper = hasPreview ? LayoutWithPreview : LayoutWithoutPreview; // Markup (mostly) taken from formio's default templates - there's room for improvement here // to de-bootstrapify it. return ( - + validateOnChange={false} validateOnBlur initialValues={initialValues} @@ -148,11 +141,7 @@ const ComponentEditForm: React.FC = ({
- {isKnownComponentType(component) ? ( - - ) : ( - - )} +
@@ -175,8 +164,8 @@ const ComponentEditForm: React.FC = ({ type EditFormLayoutProps = PropsWithChildren< Pick & { onSubmit: () => void; - onComponentChange: (value: AnyComponentSchema | FallbackSchema) => void; - component: AnyComponentSchema | FallbackSchema; + onComponentChange: (value: AnyComponentSchema) => void; + component: AnyComponentSchema; } >; diff --git a/src/components/ComponentPreview.stories.tsx b/src/components/ComponentPreview.stories.tsx index 417010a9..3663e9dc 100644 --- a/src/components/ComponentPreview.stories.tsx +++ b/src/components/ComponentPreview.stories.tsx @@ -14,38 +14,6 @@ const Template: StoryFn = ({component}) => ( ); -export const Default: Story = { - render: Template, - - args: { - component: { - id: 'foo', - type: 'textfield', - label: 'A text field', - validate: { - required: false, - }, - }, - }, -}; - -export const Fallback: Story = { - render: Template, - - args: { - component: { - id: 'fallback', - // should never accidentally be an actual type - type: '85230383-896e-40ce-a1a9-35a090b73f17', - } as any, - }, - - play: async ({canvasElement}) => { - const canvas = within(canvasElement); - await canvas.findByTestId('jsonPreview'); - }, -}; - export const TextField: Story = { render: Template, @@ -141,6 +109,7 @@ export const Email: Story = { label: 'Email preview', description: 'A preview of the email Formio component', hidden: true, // must be ignored + validateOn: 'blur', }, }, @@ -174,6 +143,7 @@ export const EmailMultiple: Story = { description: 'Description only once', hidden: true, // must be ignored multiple: true, + validateOn: 'blur', }, }, diff --git a/src/components/ComponentPreview.tsx b/src/components/ComponentPreview.tsx index 72b71c86..75941988 100644 --- a/src/components/ComponentPreview.tsx +++ b/src/components/ComponentPreview.tsx @@ -5,19 +5,19 @@ import React, {useContext, useState} from 'react'; import {FormattedMessage} from 'react-intl'; import {BuilderContext} from '@/context'; -import {Fallback, getRegistryEntry, isKnownComponentType} from '@/registry'; -import {AnyComponentSchema, FallbackSchema, hasOwnProperty} from '@/types'; +import {getRegistryEntry} from '@/registry'; +import {AnyComponentSchema, hasOwnProperty} from '@/types'; /* Generic preview (preview + wrapper with view mode) */ export interface ComponentPreviewWrapperProps { - component: AnyComponentSchema | FallbackSchema; + component: AnyComponentSchema; /** Initial values for the preview component, e.g. `{"componentKey": "some_value"}` */ initialValues: Record; /** Handler to be called when the component JSON definition changes */ - onComponentChange: (value: AnyComponentSchema | FallbackSchema) => void; + onComponentChange: (value: AnyComponentSchema) => void; children: React.ReactNode; } @@ -69,8 +69,8 @@ const ComponentPreviewWrapper: React.FC = ({ }; export interface GenericComponentPreviewProps { - component: AnyComponentSchema | FallbackSchema; - onComponentChange: (value: AnyComponentSchema | FallbackSchema) => void; + component: AnyComponentSchema; + onComponentChange: (value: AnyComponentSchema) => void; } /** @@ -86,7 +86,7 @@ const GenericComponentPreview: React.FC = ({ component, onComponentChange, }) => { - const key = isKnownComponentType(component) ? component.key : ''; + const {key} = component; const entry = getRegistryEntry(component); const {preview: PreviewComponent, defaultValue = ''} = entry; if (PreviewComponent === null) { @@ -109,11 +109,7 @@ const GenericComponentPreview: React.FC = ({ component={component} initialValues={initialValues} > - {isKnownComponentType(component) ? ( - - ) : ( - - )} + ); }; diff --git a/src/components/builder/simple-conditional.tsx b/src/components/builder/simple-conditional.tsx index 997d7581..db6188fa 100644 --- a/src/components/builder/simple-conditional.tsx +++ b/src/components/builder/simple-conditional.tsx @@ -1,14 +1,16 @@ +import {AnyComponentSchema} from '@open-formulieren/types'; import {useFormikContext} from 'formik'; -import {ExtendedComponentSchema, Utils as FormioUtils} from 'formiojs'; +import {Utils as FormioUtils} from 'formiojs'; import type {ConditionalOptions} from 'formiojs'; import {useContext, useEffect} from 'react'; import {FormattedMessage} from 'react-intl'; import usePrevious from 'react-use/esm/usePrevious'; +import {TextField} from '@/components/formio/textfield'; import {BuilderContext} from '@/context'; import {getRegistryEntry} from '@/registry'; -import Fallback from '@/registry/fallback'; import {ComparisonValueProps} from '@/registry/types'; +import {hasOwnProperty} from '@/types'; import {Panel, Select} from '../formio'; import ComponentSelect from './component-select'; @@ -22,7 +24,7 @@ export const ComparisonValueInput: React.FC = () => { const {values, setFieldValue} = useFormikContext(); const componentKey = values?.conditional?.when; - const chosenComponent: ExtendedComponentSchema = componentKey + const chosenComponent: AnyComponentSchema = componentKey ? FormioUtils.getComponent(getFormComponents(), componentKey, false) : null; const previousWhen = usePrevious(componentKey); @@ -43,7 +45,7 @@ export const ComparisonValueInput: React.FC = () => { const registryEntry = getRegistryEntry(chosenComponent); const {comparisonValue} = registryEntry; - const InputComponent = comparisonValue || Fallback.comparisonValue; + const InputComponent = comparisonValue || TextField; const props: ComparisonValueProps = { name: 'conditional.eq', @@ -54,7 +56,10 @@ export const ComparisonValueInput: React.FC = () => { /> ), }; - if (chosenComponent.hasOwnProperty('multiple')) props.multiple = chosenComponent.multiple; + + if (hasOwnProperty(chosenComponent, 'multiple')) { + props.multiple = chosenComponent.multiple as boolean; + } return ; }; diff --git a/src/registry/fallback/index.tsx b/src/registry/fallback/index.tsx deleted file mode 100644 index 6cca14d6..00000000 --- a/src/registry/fallback/index.tsx +++ /dev/null @@ -1,29 +0,0 @@ -import {ComponentSchema} from 'formiojs'; -import {z} from 'zod'; - -import JSONPreview from '@/components/JSONPreview'; -import {TextField} from '@/components/formio/textfield'; -import {FallbackSchema} from '@/types'; - -import {EditFormDefinition, RegistryEntry} from '../types'; - -const EditForm: EditFormDefinition = ({component: {type = 'unknown'}}) => ( -
Unknown component type: {type}
-); -EditForm.defaultValues = {}; - -export interface ComponentPreviewProps { - component: ComponentSchema; -} - -const Preview: React.FC = ({component}) => ; - -const Fallback = { - edit: EditForm, - editSchema: () => z.object({}), - preview: Preview, - defaultValue: undefined, - comparisonValue: TextField, -} satisfies RegistryEntry; - -export default Fallback; diff --git a/src/registry/index.tsx b/src/registry/index.tsx index 422a7c29..f8916c6a 100644 --- a/src/registry/index.tsx +++ b/src/registry/index.tsx @@ -1,4 +1,6 @@ -import {AnyComponentSchema, FallbackSchema, hasOwnProperty} from '@/types'; +import type {AnyComponentSchema, FallbackSchema} from '@open-formulieren/types'; + +import {hasOwnProperty} from '@/types'; import AddressNL from './addressNL'; import BSN from './bsn'; @@ -12,7 +14,6 @@ import DateField from './date'; import DateTimeField from './datetime'; import EditGrid from './editgrid'; import Email from './email'; -import Fallback from './fallback'; import FieldSet from './fieldset'; import FileUpload from './file'; import Iban from './iban'; @@ -32,18 +33,24 @@ import TextField from './textfield'; import TimeField from './time'; import {Registry, RegistryEntry} from './types'; +/** + * Type guard to determine if the passed in 'component' is something we have type + * definitions for. + * + * Use this check as high as possible, so that all other child components and + * functionality do not need to worry about `FallbackSchema`. + */ export const isKnownComponentType = ( component: AnyComponentSchema | FallbackSchema ): component is AnyComponentSchema => { return Boolean(component.type && hasOwnProperty(REGISTRY, component.type)); }; -export const getRegistryEntry = (component: S) => { - if (isKnownComponentType(component)) { - const entry = REGISTRY[component.type]; - return entry as RegistryEntry; - } - return Fallback; +export const getRegistryEntry = ( + component: AnyComponentSchema +): RegistryEntry => { + const entry = REGISTRY[component.type]; + return entry; }; const REGISTRY: Registry = { @@ -81,5 +88,4 @@ const REGISTRY: Registry = { password: Password, }; -export {Fallback}; export default REGISTRY; diff --git a/src/registry/types.ts b/src/registry/types.ts index 381aa5fe..36caf657 100644 --- a/src/registry/types.ts +++ b/src/registry/types.ts @@ -1,8 +1,8 @@ +import {AnyComponentSchema} from '@open-formulieren/types'; import {IntlShape} from 'react-intl'; import {z} from 'zod'; import {BuilderContextType} from '@/context'; -import {AnyComponentSchema, FallbackSchema} from '@/types'; // Edit form @@ -11,17 +11,18 @@ export interface EditFormProps { } export interface EditFormDefinition extends React.FC> { - defaultValues: S extends AnyComponentSchema ? Omit : FallbackSchema; + // the ternary is a trick to force typescript to distribute unions inside S - otherwise + // it will operate on the intersection of the union and it loses type information + // for specific schemas + defaultValues: S extends AnyComponentSchema ? Omit : never; } // Component preview -export interface ComponentPreviewProps { +export interface ComponentPreviewProps { component: S; } -export type Preview = React.FC< - ComponentPreviewProps ->; +export type Preview = React.FC>; export interface EditSchemaArgs { intl: IntlShape; @@ -32,13 +33,13 @@ export type EditSchema = (args: EditSchemaArgs) => z.ZodFirstPartySchemaTypes; export interface ComparisonValueProps { name: string; - label: string | React.ReactNode; + label: React.ReactNode; multiple?: boolean; } // Registry entry -export interface RegistryEntry { +export interface RegistryEntry { edit: EditFormDefinition; editSchema: EditSchema; preview: Preview | null; diff --git a/src/types/schemas.ts b/src/types/schemas.ts index 4f4e377e..8503de89 100644 --- a/src/types/schemas.ts +++ b/src/types/schemas.ts @@ -1,4 +1,4 @@ /** * Open Forms specific Formio component schema extensions. */ -export type {AnyComponentSchema, FallbackSchema} from '@open-formulieren/types'; +export type {AnyComponentSchema} from '@open-formulieren/types';