From 7e35d39b2bc6251421d27264d6f3f4314d1a9f6e Mon Sep 17 00:00:00 2001 From: Sergei Maertens Date: Wed, 25 Oct 2023 15:25:38 +0200 Subject: [PATCH 01/15] :arrow_up: Bump to @open-formulieren/types 0.12.0+ --- package-lock.json | 14 +++++++------- package.json | 2 +- 2 files changed, 8 insertions(+), 8 deletions(-) diff --git a/package-lock.json b/package-lock.json index f6b24807..3ca6986e 100644 --- a/package-lock.json +++ b/package-lock.json @@ -35,7 +35,7 @@ "@formatjs/cli": "^6.1.1", "@formatjs/ts-transformer": "^3.12.0", "@fortawesome/fontawesome-free": "^6.4.0", - "@open-formulieren/types": "^0.11.0", + "@open-formulieren/types": "^0.12.0", "@storybook/addon-actions": "^7.3.2", "@storybook/addon-essentials": "^7.3.2", "@storybook/addon-interactions": "^7.3.2", @@ -5840,9 +5840,9 @@ } }, "node_modules/@open-formulieren/types": { - "version": "0.11.0", - "resolved": "https://registry.npmjs.org/@open-formulieren/types/-/types-0.11.0.tgz", - "integrity": "sha512-yQhxIfLTCTlb9+nIc/jzoa8tVpX2auT1TiU1QDc62A/tWQSqt4V9rBvgTQGhFedROYr3TggL4bGVV0kimv4tVg==", + "version": "0.12.0", + "resolved": "https://registry.npmjs.org/@open-formulieren/types/-/types-0.12.0.tgz", + "integrity": "sha512-JfS1XM9SI3mfM7RuO8Pdo5QaagnerIyRfsTfBX8N6zy6ujUoc0aMap8baJGH5/fMW8DwVo1xB1I45qPCj6LyCg==", "dev": true }, "node_modules/@pkgjs/parseargs": { @@ -35200,9 +35200,9 @@ } }, "@open-formulieren/types": { - "version": "0.11.0", - "resolved": "https://registry.npmjs.org/@open-formulieren/types/-/types-0.11.0.tgz", - "integrity": "sha512-yQhxIfLTCTlb9+nIc/jzoa8tVpX2auT1TiU1QDc62A/tWQSqt4V9rBvgTQGhFedROYr3TggL4bGVV0kimv4tVg==", + "version": "0.12.0", + "resolved": "https://registry.npmjs.org/@open-formulieren/types/-/types-0.12.0.tgz", + "integrity": "sha512-JfS1XM9SI3mfM7RuO8Pdo5QaagnerIyRfsTfBX8N6zy6ujUoc0aMap8baJGH5/fMW8DwVo1xB1I45qPCj6LyCg==", "dev": true }, "@pkgjs/parseargs": { diff --git a/package.json b/package.json index 09ca2364..fa787bbc 100644 --- a/package.json +++ b/package.json @@ -61,7 +61,7 @@ "@formatjs/cli": "^6.1.1", "@formatjs/ts-transformer": "^3.12.0", "@fortawesome/fontawesome-free": "^6.4.0", - "@open-formulieren/types": "^0.11.0", + "@open-formulieren/types": "^0.12.0", "@storybook/addon-actions": "^7.3.2", "@storybook/addon-essentials": "^7.3.2", "@storybook/addon-interactions": "^7.3.2", From 9ab3668bb80978df7624fabe37870059f7daf525 Mon Sep 17 00:00:00 2001 From: Sergei Maertens Date: Wed, 25 Oct 2023 15:44:44 +0200 Subject: [PATCH 02/15] :sparkles: Implement file upload preview --- src/components/ComponentPreview.stories.tsx | 23 ++++++ src/registry/file/index.ts | 10 +++ src/registry/file/preview.tsx | 82 +++++++++++++++++++++ src/registry/index.tsx | 2 + 4 files changed, 117 insertions(+) create mode 100644 src/registry/file/index.ts create mode 100644 src/registry/file/preview.tsx diff --git a/src/components/ComponentPreview.stories.tsx b/src/components/ComponentPreview.stories.tsx index a657dcfa..c197afc9 100644 --- a/src/components/ComponentPreview.stories.tsx +++ b/src/components/ComponentPreview.stories.tsx @@ -592,3 +592,26 @@ export const PhoneNumberMultiple: Story = { await expect(canvas.queryByTestId('input-phoneNumberPreview[1]')).not.toBeInTheDocument(); }, }; + +export const File: Story = { + name: 'File upload', + render: Template, + + args: { + component: { + type: 'file', + id: 'file', + key: 'filePreview', + label: 'File upload preview', + description: 'A preview of the file Formio component', + }, + }, + + play: async ({canvasElement}) => { + const canvas = within(canvasElement); + + // check that the user-controlled content is visible + await canvas.findByText('File upload preview'); + await canvas.findByText('A preview of the file Formio component'); + }, +}; diff --git a/src/registry/file/index.ts b/src/registry/file/index.ts new file mode 100644 index 00000000..2741dadc --- /dev/null +++ b/src/registry/file/index.ts @@ -0,0 +1,10 @@ +// import EditForm from './edit'; +// import validationSchema from './edit-validation'; +import Preview from './preview'; + +export default { + // edit: EditForm, + // editSchema: validationSchema, + preview: Preview, + defaultValue: [], +}; diff --git a/src/registry/file/preview.tsx b/src/registry/file/preview.tsx new file mode 100644 index 00000000..6005647e --- /dev/null +++ b/src/registry/file/preview.tsx @@ -0,0 +1,82 @@ +import {FileComponentSchema} from '@open-formulieren/types'; +import {FormattedMessage} from 'react-intl'; + +import {Component, Description} from '@/components/formio'; + +import {ComponentPreviewProps} from '../types'; + +/** + * Show a formio file component preview. + * + * NOTE: for the time being, this is rendered in the default Formio bootstrap style, + * however at some point this should use the components of + * @open-formulieren/formio-renderer instead for a more accurate preview. + */ +const Preview: React.FC> = ({component}) => { + const {key, label, description, tooltip, validate = {}} = component; + const {required = false} = validate; + + return ( + +
    +
  • +
    +
    + +
    + + + +
    +
    + + + +
    +
    +
  • +
+ +
+ + ( + { + e.preventDefault(); + alert('Uploading is disabled in preview mode.'); + }} + > + {nodes} + + ), + }} + /> +
+
+
+
+ {description && } + + ); +}; + +export default Preview; diff --git a/src/registry/index.tsx b/src/registry/index.tsx index 6f9f9456..3b7a5885 100644 --- a/src/registry/index.tsx +++ b/src/registry/index.tsx @@ -4,6 +4,7 @@ import DateField from './date'; import DateTimeField from './datetime'; import Email from './email'; import Fallback from './fallback'; +import FileUpload from './file'; import NumberField from './number'; import PhoneNumber from './phonenumber'; import Postcode from './postcode'; @@ -34,6 +35,7 @@ const REGISTRY: Registry = { time: TimeField, phoneNumber: PhoneNumber, postcode: Postcode, + file: FileUpload, }; export {Fallback}; From 0ae5b35f391466b181af2a56abdf4df6c2d22704 Mon Sep 17 00:00:00 2001 From: Sergei Maertens Date: Wed, 25 Oct 2023 16:05:28 +0200 Subject: [PATCH 03/15] :sparkles: Implement edit form form file component --- .../ComponentConfiguration.stories.tsx | 30 ++++ src/registry/file/edit-validation.ts | 7 + src/registry/file/edit.tsx | 164 ++++++++++++++++++ src/registry/file/index.ts | 8 +- 4 files changed, 205 insertions(+), 4 deletions(-) create mode 100644 src/registry/file/edit-validation.ts create mode 100644 src/registry/file/edit.tsx diff --git a/src/components/ComponentConfiguration.stories.tsx b/src/components/ComponentConfiguration.stories.tsx index 080793ad..34e82a6f 100644 --- a/src/components/ComponentConfiguration.stories.tsx +++ b/src/components/ComponentConfiguration.stories.tsx @@ -756,3 +756,33 @@ export const PhoneNumber: Story = { expect(args.onSubmit).toHaveBeenCalled(); }, }; + +export const FileUpload: Story = { + render: Template, + name: 'type: file', + + args: { + component: { + id: 'kiweljhr', + storage: 'url', + url: '', + type: 'file', + key: 'file', + label: 'A file upload', + file: { + name: '', + type: [], + allowedTypesLabels: [], + }, + filePattern: '', + }, + + builderInfo: { + title: 'File upload', + icon: '', + group: 'file', + weight: 10, + schema: {}, + }, + }, +}; diff --git a/src/registry/file/edit-validation.ts b/src/registry/file/edit-validation.ts new file mode 100644 index 00000000..1cb4c6eb --- /dev/null +++ b/src/registry/file/edit-validation.ts @@ -0,0 +1,7 @@ +import {IntlShape} from 'react-intl'; + +import {buildCommonSchema} from '@/registry/validation'; + +const schema = (intl: IntlShape) => buildCommonSchema(intl); + +export default schema; diff --git a/src/registry/file/edit.tsx b/src/registry/file/edit.tsx new file mode 100644 index 00000000..879584d3 --- /dev/null +++ b/src/registry/file/edit.tsx @@ -0,0 +1,164 @@ +import {FileComponentSchema} from '@open-formulieren/types'; +import {useFormikContext} from 'formik'; +import {FormattedMessage, useIntl} from 'react-intl'; + +import { + BuilderTabs, + ClearOnHide, + Description, + Hidden, + IsSensitiveData, + Key, + Label, + Multiple, + PresentationConfig, + SimpleConditional, + Tooltip, + Translations, + Validate, + useDeriveComponentKey, +} from '@/components/builder'; +import {LABELS} from '@/components/builder/messages'; +import {Checkbox, Tab, TabList, TabPanel, Tabs, TextField} from '@/components/formio'; +import {getErrorNames} from '@/utils/errors'; + +import {EditFormDefinition} from '../types'; + +/** + * Form to configure a Formio 'file' type component. + */ +const EditForm: EditFormDefinition = () => { + const intl = useIntl(); + const [isKeyManuallySetRef, generatedKey] = useDeriveComponentKey(); + const {errors} = useFormikContext(); + + const erroredFields = Object.keys(errors).length + ? getErrorNames(errors) + : []; + // TODO: pattern match instead of just string inclusion? + // TODO: move into more generically usuable utility when we implement other component + // types + const hasAnyError = (...fieldNames: string[]): boolean => { + if (!erroredFields.length) return false; + return fieldNames.some(name => erroredFields.includes(name)); + }; + + Validate.useManageValidatorsTranslations(['required']); + + return ( + + + + + + + + + + + + + {/* Basic tab */} + + + + {/* Advanced tab */} + + + + + {/* Validation tab */} + + + + + + {/* File tab */} + + + {/* Registration tab */} + TODO + + {/* Translations */} + + + propertyLabels={{ + label: intl.formatMessage(LABELS.label), + description: intl.formatMessage(LABELS.description), + tooltip: intl.formatMessage(LABELS.tooltip), + }} + /> + + + ); +}; + +EditForm.defaultValues = { + storage: 'url', + url: '', + // basic tab + label: '', + key: '', + description: '', + tooltip: '', + showInSummary: true, + showInEmail: false, + showInPDF: true, + multiple: false, + hidden: false, + clearOnHide: true, + isSensitiveData: true, + // Advanced tab + conditional: { + show: undefined, + when: '', + eq: '', + }, + // Validation tab + validate: { + required: false, + }, + translatedErrors: {}, + // file tab + file: { + name: '', + type: [], + allowedTypesLabels: [], + }, + filePattern: '', + // registration tab + registration: { + informatieobjecttype: '', + bronorganisatie: '', + docVertrouwelijkheidaanduiding: '', + titel: '', + }, +}; + +export default EditForm; diff --git a/src/registry/file/index.ts b/src/registry/file/index.ts index 2741dadc..2c39637f 100644 --- a/src/registry/file/index.ts +++ b/src/registry/file/index.ts @@ -1,10 +1,10 @@ -// import EditForm from './edit'; -// import validationSchema from './edit-validation'; +import EditForm from './edit'; +import validationSchema from './edit-validation'; import Preview from './preview'; export default { - // edit: EditForm, - // editSchema: validationSchema, + edit: EditForm, + editSchema: validationSchema, preview: Preview, defaultValue: [], }; From bf609f583fab9cb309986331bfc193116acb17e9 Mon Sep 17 00:00:00 2001 From: Sergei Maertens Date: Wed, 25 Oct 2023 17:15:53 +0200 Subject: [PATCH 04/15] :sparkles: Implement file tab of file component edit form * Expose file types from context - the backend needs to feed the list of possible options to the builder. * Extend decorators and stories to handle (default) file types * Add file name template field * Add file type (multi) select * Add 'use global config file types' checkbox * Add 'maxFileSize' textbox * Add 'maxNumberOfFiles' number field The input validation is added as todo for a separate commit. --- .storybook/decorators.tsx | 79 ++++++++++ .../ComponentConfiguration.stories.tsx | 12 ++ src/components/ComponentConfiguration.tsx | 4 + src/components/formio/description.tsx | 2 +- src/components/formio/multiple.tsx | 2 +- src/components/formio/textfield.tsx | 2 +- src/context.ts | 9 ++ src/registry/file/edit-validation.ts | 9 ++ src/registry/file/edit.tsx | 20 ++- src/registry/file/file-tab.tsx | 149 ++++++++++++++++++ 10 files changed, 281 insertions(+), 7 deletions(-) create mode 100644 src/registry/file/file-tab.tsx diff --git a/.storybook/decorators.tsx b/.storybook/decorators.tsx index 1197dc2e..1047b06b 100644 --- a/.storybook/decorators.tsx +++ b/.storybook/decorators.tsx @@ -68,6 +68,79 @@ const DEFAULT_PREFILL_ATTRIBUTES: {[key: string]: PrefillAttributeOption[]} = { ], }; +export const DEFAULT_FILE_TYPES = [ + { + label: 'any filetype', + value: '*', + }, + { + label: '.heic', + value: 'image/heic', + }, + { + label: '.png', + value: 'image/png', + }, + { + label: '.jpg', + value: 'image/jpeg', + }, + { + label: '.pdf', + value: 'application/pdf', + }, + { + label: '.xls', + value: 'application/vnd.ms-excel', + }, + { + label: '.xlsx', + value: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet', + }, + { + label: '.csv', + value: 'text/csv', + }, + { + label: '.txt', + value: 'text/plain', + }, + { + label: '.doc', + value: 'application/msword', + }, + { + label: '.docx', + value: 'application/vnd.openxmlformats-officedocument.wordprocessingml.document', + }, + { + label: 'Open Office', + value: + 'application/vnd.oasis.opendocument.*,application/vnd.stardivision.*,application/vnd.sun.xml.*', + }, + { + label: '.zip', + value: 'application/zip', + }, + { + label: '.rar', + value: 'application/vnd.rar', + }, + { + label: '.tar', + value: 'application/x-tar', + }, + { + label: '.msg', + value: 'application/vnd.ms-outlook', + }, + { + label: '.dwg', + value: + 'application/acad.dwg,application/autocad_dwg.dwg,application/dwg.dwg,application/x-acad.dwg,application/x-autocad.dwg,application/x-dwg.dwg,drawing/dwg.dwg,image/vnd.dwg,image/x-dwg.dwg', + }, +]; + function sleep(ms: number) { return new Promise(resolve => setTimeout(resolve, ms)); } @@ -86,6 +159,8 @@ export const BuilderContextDecorator = (Story: StoryFn, context: StoryContext) = context.parameters.builder?.defaultPrefillPlugins || DEFAULT_PREFILL_PLUGINS; const defaultPrefillAttributes = context.parameters.builder?.defaultPrefillAttributes || DEFAULT_PREFILL_ATTRIBUTES; + const defaultFileTypes = context.parameters.builder?.defaultFileTypes || DEFAULT_FILE_TYPES; + return ( { + return context?.args?.fileTypes || defaultFileTypes; + }, + serverUploadLimit: '50MB', }} > diff --git a/src/components/ComponentConfiguration.stories.tsx b/src/components/ComponentConfiguration.stories.tsx index 34e82a6f..8c86c4b9 100644 --- a/src/components/ComponentConfiguration.stories.tsx +++ b/src/components/ComponentConfiguration.stories.tsx @@ -4,6 +4,7 @@ import {Meta, StoryFn, StoryObj} from '@storybook/react'; import {fireEvent, userEvent, waitFor, within} from '@storybook/testing-library'; import React from 'react'; +import {DEFAULT_FILE_TYPES} from '@/../.storybook/decorators'; import {AnyComponentSchema} from '@/types'; import ComponentConfiguration from './ComponentConfiguration'; @@ -50,6 +51,7 @@ export default { ], }, supportedLanguageCodes: ['nl'], + fileTypes: DEFAULT_FILE_TYPES, translationsStore: { nl: { 'A select': 'Een dropdown', @@ -79,6 +81,7 @@ interface TemplateArgs { registrationAttributes: RegistrationAttributeOption[]; prefillPlugins: PrefillPluginOption[]; prefillAttributes: Record; + fileTypes: Array<{value: string; label: string}>; isNew: boolean; builderInfo: BuilderInfo; onCancel: (e: React.MouseEvent) => void; @@ -95,6 +98,7 @@ const Template: StoryFn = ({ prefillAttributes, supportedLanguageCodes, translationsStore, + fileTypes, isNew, builderInfo, onCancel, @@ -110,6 +114,8 @@ const Template: StoryFn = ({ getRegistrationAttributes={async () => registrationAttributes} getPrefillPlugins={async () => prefillPlugins} getPrefillAttributes={async (plugin: string) => prefillAttributes[plugin]} + getFileTypes={async () => fileTypes} + serverUploadLimit="50MB" component={component} isNew={isNew} builderInfo={builderInfo} @@ -785,4 +791,10 @@ export const FileUpload: Story = { schema: {}, }, }, + + play: async ({canvasElement}) => { + const canvas = within(canvasElement); + + await userEvent.click(canvas.getByRole('link', {name: 'File'})); + }, }; diff --git a/src/components/ComponentConfiguration.tsx b/src/components/ComponentConfiguration.tsx index 1da36bf0..c6dd73cd 100644 --- a/src/components/ComponentConfiguration.tsx +++ b/src/components/ComponentConfiguration.tsx @@ -31,6 +31,8 @@ const ComponentConfiguration: React.FC = ({ getRegistrationAttributes, getPrefillPlugins, getPrefillAttributes, + getFileTypes, + serverUploadLimit, isNew, component, builderInfo, @@ -48,6 +50,8 @@ const ComponentConfiguration: React.FC = ({ getRegistrationAttributes, getPrefillPlugins, getPrefillAttributes, + getFileTypes, + serverUploadLimit, }} > = ({text}) => { diff --git a/src/components/formio/multiple.tsx b/src/components/formio/multiple.tsx index 58a3d6b2..9e4893b2 100644 --- a/src/components/formio/multiple.tsx +++ b/src/components/formio/multiple.tsx @@ -30,7 +30,7 @@ import Description from './description'; */ export interface CommonInputProps extends ComponentLabelProps { name: string; - description?: string; + description?: React.ReactNode; } export interface MultipleProps

{ diff --git a/src/components/formio/textfield.tsx b/src/components/formio/textfield.tsx index da3c2058..ea9cc0d5 100644 --- a/src/components/formio/textfield.tsx +++ b/src/components/formio/textfield.tsx @@ -16,7 +16,7 @@ export interface TextFieldProps { label?: React.ReactNode; required?: boolean; tooltip?: string; - description?: string; + description?: React.ReactNode; showCharCount?: boolean; inputMask?: string; } diff --git a/src/context.ts b/src/context.ts index 55b458e4..a304510c 100644 --- a/src/context.ts +++ b/src/context.ts @@ -35,6 +35,13 @@ export interface BuilderContextType { getRegistrationAttributes: (componentType: string) => Promise; getPrefillPlugins: (componentType: string) => Promise; getPrefillAttributes: (plugin: string) => Promise; + getFileTypes: () => Promise< + Array<{ + value: string; + label: string; + }> + >; + serverUploadLimit: string; } const BuilderContext = React.createContext({ @@ -46,6 +53,8 @@ const BuilderContext = React.createContext({ getRegistrationAttributes: async () => [], getPrefillPlugins: async () => [], getPrefillAttributes: async () => [], + getFileTypes: async () => [], + serverUploadLimit: '(unknown)', }); BuilderContext.displayName = 'BuilderContext'; diff --git a/src/registry/file/edit-validation.ts b/src/registry/file/edit-validation.ts index 1cb4c6eb..95ec5ecd 100644 --- a/src/registry/file/edit-validation.ts +++ b/src/registry/file/edit-validation.ts @@ -2,6 +2,15 @@ import {IntlShape} from 'react-intl'; import {buildCommonSchema} from '@/registry/validation'; +/** + * @todo implement validations: + * + * - fileMaxSize must be int (bytes) or int(K|M)B (?) -> check how fileMaxSize is + * handled in the backend, if at all. + * - validate fileMaxSize <= serverUploadLimit from context, if set. + * - maxNumberOfFiles must be positive integer + */ + const schema = (intl: IntlShape) => buildCommonSchema(intl); export default schema; diff --git a/src/registry/file/edit.tsx b/src/registry/file/edit.tsx index 879584d3..9ec5c637 100644 --- a/src/registry/file/edit.tsx +++ b/src/registry/file/edit.tsx @@ -19,10 +19,11 @@ import { useDeriveComponentKey, } from '@/components/builder'; import {LABELS} from '@/components/builder/messages'; -import {Checkbox, Tab, TabList, TabPanel, Tabs, TextField} from '@/components/formio'; +import {Tab, TabList, TabPanel, Tabs} from '@/components/formio'; import {getErrorNames} from '@/utils/errors'; import {EditFormDefinition} from '../types'; +import FileTabFields from './file-tab'; /** * Form to configure a Formio 'file' type component. @@ -65,7 +66,9 @@ const EditForm: EditFormDefinition = () => { /> - + = () => { {/* File tab */} - + + + {/* Registration tab */} TODO @@ -119,9 +124,15 @@ const EditForm: EditFormDefinition = () => { ); }; +/** + * + * @todo options.withCredentials: true seems to be set somewhere -> session cookie + * needs to be sent by the SDK/client! + */ EditForm.defaultValues = { storage: 'url', url: '', + // basic tab label: '', key: '', @@ -151,7 +162,8 @@ EditForm.defaultValues = { type: [], allowedTypesLabels: [], }, - filePattern: '', + filePattern: '*', + useConfigFiletypes: false, // registration tab registration: { informatieobjecttype: '', diff --git a/src/registry/file/file-tab.tsx b/src/registry/file/file-tab.tsx new file mode 100644 index 00000000..aa383f3c --- /dev/null +++ b/src/registry/file/file-tab.tsx @@ -0,0 +1,149 @@ +import {useContext} from 'react'; +import {FormattedMessage, useIntl} from 'react-intl'; +import useAsync from 'react-use/esm/useAsync'; + +import {Checkbox, NumberField, Select, TextField} from '@/components/formio'; +import {BuilderContext} from '@/context'; + +const FileName: React.FC<{}> = () => { + const intl = useIntl(); + + const tooltip = intl.formatMessage( + { + description: "Tooltip for 'file.name' builder field", + defaultMessage: + "Specify template for name of uploaded file(s). '{{ fileName }}' contains the original filename.", + }, + { + code: chunks => {chunks}, + } + ) as string; // library doesn't narrow the type when using template fns :( + + return ( + + } + tooltip={tooltip} + placeholder={intl.formatMessage({ + description: "placeholder for 'file.name' builder field", + defaultMessage: '(optional)', + })} + /> + ); +}; + +const FileTypesSelect: React.FC<{}> = () => { + const {getFileTypes} = useContext(BuilderContext); + + const {value: options, loading, error} = useAsync(async () => await getFileTypes(), []); + + if (error) { + throw error; + } + + return ( + = () => { } isLoading={loading} isClearable + isMulti options={options} valueProperty="value" + onChange={onChange} /> ); }; @@ -130,20 +177,88 @@ const MaxNumberOfFiles = () => { /> } tooltip={tooltip} - min={0} + min={1} step={1} /> ); }; -const FileTabFields = () => ( - <> - - - - - - -); +const ImageResizeApply = () => { + const intl = useIntl(); + const tooltip = intl.formatMessage({ + description: "Tooltip for 'of.image.resize.apply' builder field", + defaultMessage: 'When this is checked, any uploaded image(s) will be resized.', + }); + return ( + + } + tooltip={tooltip} + /> + ); +}; + +const ImageResizingOptions = () => { + const {values} = useFormikContext(); + const applyResize = values?.of?.image?.resize?.apply ?? false; + + return ( + <> + + {applyResize && ( + +

+
+ + } + min={0} + step={100} + /> +
+
+ + } + min={0} + step={100} + /> +
+
+ + )} + + ); +}; + +const FileTabFields = () => { + const {values} = useFormikContext(); + const hasImages = hasImageMimeType(values.file.type); + return ( + <> + + + + {hasImages && } + + + + ); +}; export default FileTabFields; From 3179e42a29303bd09864a1424b091b5947761818 Mon Sep 17 00:00:00 2001 From: Sergei Maertens Date: Thu, 26 Oct 2023 15:16:14 +0200 Subject: [PATCH 06/15] :sparkles: Implement file tab validations --- .../ComponentConfiguration.stories.tsx | 66 +++++++- src/registry/file/edit-validation.ts | 41 ++++- src/registry/file/file-tab.stories.ts | 87 ++++++++++ src/registry/file/file-tab.tsx | 3 +- src/registry/file/file-validation.stories.ts | 160 ++++++++++++++++++ 5 files changed, 348 insertions(+), 9 deletions(-) create mode 100644 src/registry/file/file-tab.stories.ts create mode 100644 src/registry/file/file-validation.stories.ts diff --git a/src/components/ComponentConfiguration.stories.tsx b/src/components/ComponentConfiguration.stories.tsx index b5415f3b..695c4d1c 100644 --- a/src/components/ComponentConfiguration.stories.tsx +++ b/src/components/ComponentConfiguration.stories.tsx @@ -792,7 +792,7 @@ export const FileUpload: Story = { }, }, - play: async ({canvasElement, step}) => { + play: async ({canvasElement, step, args}) => { const canvas = within(canvasElement); await step('Basic tab', async () => { @@ -826,7 +826,69 @@ export const FileUpload: Story = { await expect(canvas.queryByText('any filetype')).toBeVisible(); }); await expect(canvas.queryByText('.pdf')).toBeVisible(); - await userEvent.keyboard('[Escape]'); + await userEvent.click(canvas.getByText('.jpg')); + }); + + await step('Submit configuration', async () => { + await userEvent.click(canvas.getByRole('button', {name: 'Save'})); + // See EditForm.defaultValues for the defaults + expect(args.onSubmit).toHaveBeenCalledWith({ + type: 'file', + id: 'kiweljhr', + storage: 'url', + url: '', + // basic tab + label: 'A file upload', + key: 'aFileUpload', + description: '', + tooltip: '', + showInSummary: true, + showInEmail: false, + showInPDF: true, + multiple: false, + hidden: false, + clearOnHide: true, + isSensitiveData: true, + // Advanced tab + conditional: { + show: undefined, + when: '', + eq: '', + }, + // Validation tab + validate: { + required: false, + }, + translatedErrors: { + nl: {required: ''}, + }, + // file tab + file: { + name: '', + type: ['image/jpeg'], + allowedTypesLabels: ['.jpg'], // derived from file.type + }, + filePattern: 'image/jpeg', // derived from file.type + useConfigFiletypes: false, + of: { + image: { + resize: { + apply: false, + width: 2000, + height: 2000, + }, + }, + }, + fileMaxSize: '10MB', + maxNumberOfFiles: null, + // registration tab + registration: { + informatieobjecttype: '', + bronorganisatie: '', + docVertrouwelijkheidaanduiding: '', + titel: '', + }, + }); }); }, }; diff --git a/src/registry/file/edit-validation.ts b/src/registry/file/edit-validation.ts index 95ec5ecd..c107415d 100644 --- a/src/registry/file/edit-validation.ts +++ b/src/registry/file/edit-validation.ts @@ -1,16 +1,47 @@ import {IntlShape} from 'react-intl'; +import {z} from 'zod'; import {buildCommonSchema} from '@/registry/validation'; +const imgSize = z.number().int().positive(); + +const ofSchema = z.object({ + image: z + .object({ + resize: z + .object({ + apply: z.boolean().optional(), + width: imgSize.optional(), + height: imgSize.optional(), + }) + .optional(), + }) + .optional(), +}); + +const buildFileMaxSizeSchema = (intl: IntlShape) => + z.string().refine(value => /^\d+\s*(B|KB|MB|GB)/i.test(value), { + message: intl.formatMessage({ + description: "File component 'fileMaxSize' validation error", + defaultMessage: 'Specify a positive, non-zero file size without decimals, e.g. 10MB.', + }), + }); + +const buildFileSchema = (intl: IntlShape) => + z.object({ + of: ofSchema.optional(), + maxNumberOfFiles: z.union([z.null(), z.number().int().positive().optional()]), + fileMaxSize: buildFileMaxSizeSchema(intl).optional(), + }); + /** * @todo implement validations: * - * - fileMaxSize must be int (bytes) or int(K|M)B (?) -> check how fileMaxSize is - * handled in the backend, if at all. - * - validate fileMaxSize <= serverUploadLimit from context, if set. - * - maxNumberOfFiles must be positive integer + * - validate fileMaxSize <= serverUploadLimit from context, if set. -> pass builder + * context by default to validator factory? we already pass the intl object so there's + * precedent... */ -const schema = (intl: IntlShape) => buildCommonSchema(intl); +const schema = (intl: IntlShape) => buildCommonSchema(intl).and(buildFileSchema(intl)); export default schema; diff --git a/src/registry/file/file-tab.stories.ts b/src/registry/file/file-tab.stories.ts new file mode 100644 index 00000000..d7d8f88f --- /dev/null +++ b/src/registry/file/file-tab.stories.ts @@ -0,0 +1,87 @@ +import {expect} from '@storybook/jest'; +import {Meta, StoryObj} from '@storybook/react'; +import {fireEvent, userEvent, waitFor, within} from '@storybook/testing-library'; + +import {BuilderContextDecorator, withFormik} from '@/sb-decorators'; + +import FileTabFields from './file-tab'; + +export default { + title: 'Builder components/File upload', + component: FileTabFields, + decorators: [withFormik, BuilderContextDecorator], + parameters: { + formik: { + initialValues: { + id: 'wekruya', + type: 'file', + key: 'file', + label: 'A file field', + storage: 'url', + url: '', + file: { + name: '', + type: [], + allowedTypesLabels: [], + }, + filePattern: '*', + useConfigFiletypes: false, + of: { + image: { + resize: { + apply: false, + width: 2000, + height: 2000, + }, + }, + }, + fileMaxSize: '10MB', + maxNumberOfFiles: null, + validate: { + required: false, + }, + }, + }, + modal: {noModal: true}, + builder: {enableContext: true}, + }, + args: {}, +} as Meta; + +type Story = StoryObj; + +export const ImageResizeOptionsShown: Story = { + name: 'Files tab - show image resize options', + + play: async ({canvasElement, step}) => { + const canvas = within(canvasElement); + + await step('No resize controls initially visible', () => { + expect(canvas.queryByLabelText('Resize image')).not.toBeInTheDocument(); + expect(canvas.queryByLabelText('Maximum width')).not.toBeInTheDocument(); + expect(canvas.queryByLabelText('Maximum height')).not.toBeInTheDocument(); + }); + + await step('Select image file types displays controls', async () => { + canvas.getByLabelText('File types').focus(); + await userEvent.keyboard('[ArrowDown]'); + await waitFor(async () => { + await expect(canvas.queryByText('.png')).toBeVisible(); + }); + await userEvent.click(canvas.getByText('.png')); + expect(canvas.queryByLabelText('Resize image')).toBeVisible(); + expect(canvas.queryByLabelText('Resize image')).not.toBeChecked(); + expect(canvas.queryByLabelText('Maximum width')).not.toBeInTheDocument(); + expect(canvas.queryByLabelText('Maximum height')).not.toBeInTheDocument(); + }); + + await step('Enabling image resizing displays dimension controls', async () => { + fireEvent.click(canvas.getByLabelText('Resize image')); + + expect(canvas.queryByLabelText('Maximum width')).toBeVisible(); + expect(canvas.queryByLabelText('Maximum width')).toHaveDisplayValue('2000'); + expect(canvas.queryByLabelText('Maximum height')).toBeVisible(); + expect(canvas.queryByLabelText('Maximum height')).toHaveDisplayValue('2000'); + }); + }, +}; diff --git a/src/registry/file/file-tab.tsx b/src/registry/file/file-tab.tsx index f2498142..48dfce9b 100644 --- a/src/registry/file/file-tab.tsx +++ b/src/registry/file/file-tab.tsx @@ -136,8 +136,7 @@ const FileMaxSize = () => { const {serverUploadLimit} = useContext(BuilderContext); const tooltip = intl.formatMessage({ description: "Tooltip for 'fileMaxSize' builder field", - defaultMessage: - 'When this is checked, the filetypes configured in the global settings will be used.', + defaultMessage: 'The maximum size of a single file to upload.', }); return ( diff --git a/src/registry/file/file-validation.stories.ts b/src/registry/file/file-validation.stories.ts new file mode 100644 index 00000000..4eade1e0 --- /dev/null +++ b/src/registry/file/file-validation.stories.ts @@ -0,0 +1,160 @@ +import {expect} from '@storybook/jest'; +import {Meta, StoryObj} from '@storybook/react'; +import {fireEvent, userEvent, within} from '@storybook/testing-library'; + +import ComponentEditForm from '@/components/ComponentEditForm'; +import {BuilderContextDecorator} from '@/sb-decorators'; + +export default { + title: 'Builder components/File upload/Validations', + component: ComponentEditForm, + decorators: [BuilderContextDecorator], + parameters: { + builder: {enableContext: true}, + }, + args: { + isNew: true, + component: { + id: 'kiweljhr', + storage: 'url', + url: '', + type: 'file', + key: 'file', + label: 'A file upload', + file: { + name: '', + type: [], + allowedTypesLabels: [], + }, + filePattern: '', + }, + builderInfo: { + title: 'File upload', + icon: '', + group: 'file', + weight: 10, + schema: {}, + }, + }, +} as Meta; + +type Story = StoryObj; + +export const ResizeOptions: Story = { + name: 'resize options', + + play: async ({canvasElement, step}) => { + const canvas = within(canvasElement); + + await step('Configure image file types', async () => { + await userEvent.click(canvas.getByRole('link', {name: 'File'})); + canvas.getByLabelText('File types').focus(); + await userEvent.keyboard('[ArrowDown]'); + await userEvent.click(await canvas.findByText('.jpg')); + const enableResize = await canvas.findByLabelText('Resize image'); + fireEvent.click(enableResize); + }); + + await step('Enter invalid dimension values', async () => { + const maxWidth = await canvas.findByLabelText('Maximum width'); + await userEvent.clear(maxWidth); + await userEvent.type(maxWidth, '-100'); + + const maxHeight = await canvas.findByLabelText('Maximum height'); + await userEvent.clear(maxHeight); + await userEvent.type(maxHeight, '3.14'); + + await userEvent.keyboard('[Tab]'); + + expect(await canvas.findByText('Number must be greater than 0')).toBeVisible(); + expect(await canvas.findByText('Expected integer, received float')).toBeVisible(); + }); + }, +}; + +export const MaxNumberOfFiles: Story = { + name: 'validate maxNumberOfFiles', + + play: async ({canvasElement}) => { + const canvas = within(canvasElement); + + await userEvent.click(canvas.getByRole('link', {name: 'File'})); + await userEvent.type(canvas.getByLabelText('Maximum number of files'), '0'); + await userEvent.keyboard('[Tab]'); + + expect(await canvas.findByText('Number must be greater than 0')).toBeVisible(); + }, +}; + +export const MaxFileSize: Story = { + name: 'validate fileMaxSize', + + play: async ({canvasElement, step}) => { + const canvas = within(canvasElement); + + await userEvent.click(canvas.getByRole('link', {name: 'File'})); + const fileSize = canvas.getByLabelText('Maximum file size'); + + await step('Negative file size', async () => { + await userEvent.clear(fileSize); + await userEvent.type(fileSize, '-10MB'); + await userEvent.keyboard('[Tab]'); + expect( + await canvas.findByText( + 'Specify a positive, non-zero file size without decimals, e.g. 10MB.' + ) + ).toBeVisible(); + }); + + await step('Decimal file size (period)', async () => { + await userEvent.clear(fileSize); + await userEvent.type(fileSize, '10.5MB'); + await userEvent.keyboard('[Tab]'); + expect( + await canvas.findByText( + 'Specify a positive, non-zero file size without decimals, e.g. 10MB.' + ) + ).toBeVisible(); + }); + + await step('Decimal file size (comma)', async () => { + await userEvent.clear(fileSize); + await userEvent.type(fileSize, '10,5 MB'); + await userEvent.keyboard('[Tab]'); + expect( + await canvas.findByText( + 'Specify a positive, non-zero file size without decimals, e.g. 10MB.' + ) + ).toBeVisible(); + }); + }, +}; + +export const ValidMaxFileSize: Story = { + name: 'valid fileMaxSize variants', + + play: async ({canvasElement, step}) => { + const canvas = within(canvasElement); + + await userEvent.click(canvas.getByRole('link', {name: 'File'})); + const fileSize = canvas.getByLabelText('Maximum file size'); + + await step('Whitespace between value and unit', async () => { + await userEvent.clear(fileSize); + await userEvent.type(fileSize, '15 mb'); + await userEvent.keyboard('[Tab]'); + expect( + canvas.queryByText('Specify a positive, non-zero file size without decimals, e.g. 10MB.') + ).not.toBeInTheDocument(); + }); + + await step('Accept non-MB values', async () => { + await userEvent.clear(fileSize); + await userEvent.type(fileSize, '200 KB'); + await userEvent.keyboard('[Tab]'); + expect( + canvas.queryByText('Specify a positive, non-zero file size without decimals, e.g. 10MB.') + ).not.toBeInTheDocument(); + }); + }, +}; From de2db7db54764ce7b17b75c212e65e88deb6a441 Mon Sep 17 00:00:00 2001 From: Sergei Maertens Date: Thu, 26 Oct 2023 17:17:46 +0200 Subject: [PATCH 07/15] :sparkles: Implement file component registrationt ab * Process the possible document types per backend/domain into optgroups for react-select to properly display. * Fetch confidentiality levels from backend/context * Add bronorganisatie and title fields --- .storybook/decorators.tsx | 63 ++++++- src/components/ComponentConfiguration.tsx | 4 + src/context.ts | 36 +++- src/registry/file/registration-tab.stories.ts | 44 +++++ src/registry/file/registration-tab.tsx | 169 ++++++++++++++++++ 5 files changed, 307 insertions(+), 9 deletions(-) create mode 100644 src/registry/file/registration-tab.stories.ts create mode 100644 src/registry/file/registration-tab.tsx diff --git a/.storybook/decorators.tsx b/.storybook/decorators.tsx index 1047b06b..482fb45e 100644 --- a/.storybook/decorators.tsx +++ b/.storybook/decorators.tsx @@ -1,11 +1,11 @@ import type {StoryContext, StoryFn} from '@storybook/react'; import {Formik} from 'formik'; -import React from 'react'; + +import {BuilderContext, DocumentTypeOption} from '@/context'; import {PrefillAttributeOption, PrefillPluginOption} from '../src/components/builder/prefill'; import {RegistrationAttributeOption} from '../src/components/builder/registration/registration-attribute'; import {ValidatorOption} from '../src/components/builder/validate/validator-select'; -import {BuilderContext} from '../src/context'; export const ModalDecorator = (Story, {parameters}) => { if (parameters?.modal?.noModal) return ; @@ -141,6 +141,60 @@ export const DEFAULT_FILE_TYPES = [ }, ]; +const DEFAULT_DOCUMENT_TYPES: DocumentTypeOption[] = [ + { + backendLabel: '', + catalogus: { + domein: 'VTH', + }, + informatieobjecttype: { + url: 'https://example.com/iotype/123', + omschrijving: 'Vergunning', + }, + }, + { + backendLabel: 'Open Zaak', + catalogus: { + domein: 'VTH', + }, + informatieobjecttype: { + url: 'https://example.com/iotype/456', + omschrijving: 'Vergunning', + }, + }, + { + backendLabel: 'Open Zaak', + catalogus: { + domein: 'VTH', + }, + informatieobjecttype: { + url: 'https://example.com/iotype/789', + omschrijving: 'Ontheffing', + }, + }, + { + backendLabel: 'Open Zaak', + catalogus: { + domein: 'SOC', + }, + informatieobjecttype: { + url: 'https://example.com/iotype/abc', + omschrijving: 'Aanvraag', + }, + }, +]; + +const CONFIDENTIALITY_LEVELS = [ + {label: 'Openbaar', value: 'openbaar'}, + {label: 'Beperkt openbaar', value: 'beperkt_openbaar'}, + {label: 'Intern', value: 'intern'}, + {label: 'Zaakvertrouwelijk', value: 'zaakvertrouwelijk'}, + {label: 'Vertrouwelijk', value: 'vertrouwelijk'}, + {label: 'Confidentieel', value: 'confidentieel'}, + {label: 'Geheim', value: 'geheim'}, + {label: 'Zeer geheim', value: 'zeer_geheim'}, +]; + function sleep(ms: number) { return new Promise(resolve => setTimeout(resolve, ms)); } @@ -160,7 +214,8 @@ export const BuilderContextDecorator = (Story: StoryFn, context: StoryContext) = const defaultPrefillAttributes = context.parameters.builder?.defaultPrefillAttributes || DEFAULT_PREFILL_ATTRIBUTES; const defaultFileTypes = context.parameters.builder?.defaultFileTypes || DEFAULT_FILE_TYPES; - + const defaultdocumentTypes = + context.parameters.builder?.defaultdocumentTypes || DEFAULT_DOCUMENT_TYPES; return ( context?.args?.documentTypes || defaultdocumentTypes, + getConfidentialityLevels: async () => CONFIDENTIALITY_LEVELS, }} > diff --git a/src/components/ComponentConfiguration.tsx b/src/components/ComponentConfiguration.tsx index c6dd73cd..127a48e6 100644 --- a/src/components/ComponentConfiguration.tsx +++ b/src/components/ComponentConfiguration.tsx @@ -33,6 +33,8 @@ const ComponentConfiguration: React.FC = ({ getPrefillAttributes, getFileTypes, serverUploadLimit, + getDocumentTypes, + getConfidentialityLevels, isNew, component, builderInfo, @@ -52,6 +54,8 @@ const ComponentConfiguration: React.FC = ({ getPrefillAttributes, getFileTypes, serverUploadLimit, + getDocumentTypes, + getConfidentialityLevels, }} > Promise; getPrefillPlugins: (componentType: string) => Promise; getPrefillAttributes: (plugin: string) => Promise; - getFileTypes: () => Promise< - Array<{ - value: string; - label: string; - }> - >; + getFileTypes: () => Promise; serverUploadLimit: string; + getDocumentTypes: () => Promise>; + getConfidentialityLevels: () => Promise; } const BuilderContext = React.createContext({ @@ -55,6 +77,8 @@ const BuilderContext = React.createContext({ getPrefillAttributes: async () => [], getFileTypes: async () => [], serverUploadLimit: '(unknown)', + getDocumentTypes: async () => [], + getConfidentialityLevels: async () => [], }); BuilderContext.displayName = 'BuilderContext'; diff --git a/src/registry/file/registration-tab.stories.ts b/src/registry/file/registration-tab.stories.ts new file mode 100644 index 00000000..68614c3d --- /dev/null +++ b/src/registry/file/registration-tab.stories.ts @@ -0,0 +1,44 @@ +import {expect} from '@storybook/jest'; +import {Meta, StoryObj} from '@storybook/react'; +import {fireEvent, userEvent, waitFor, within} from '@storybook/testing-library'; + +import {BuilderContextDecorator, withFormik} from '@/sb-decorators'; + +import RegistrationTabFields from './registration-tab'; + +export default { + title: 'Builder components/File upload', + component: RegistrationTabFields, + decorators: [withFormik, BuilderContextDecorator], + parameters: { + formik: { + initialValues: { + id: 'wekruya', + type: 'file', + key: 'file', + label: 'A file field', + storage: 'url', + url: '', + registration: { + informatieobjecttype: '', + bronorganisatie: '', + docVertrouwelijkheidaanduiding: '', + titel: '', + }, + }, + }, + modal: {noModal: true}, + builder: {enableContext: true}, + }, + args: {}, +} as Meta; + +type Story = StoryObj; + +export const RegistrationTab: Story = { + name: 'Registration tab', + + play: async ({canvasElement, step}) => { + const canvas = within(canvasElement); + }, +}; diff --git a/src/registry/file/registration-tab.tsx b/src/registry/file/registration-tab.tsx new file mode 100644 index 00000000..4489e9a6 --- /dev/null +++ b/src/registry/file/registration-tab.tsx @@ -0,0 +1,169 @@ +import {useContext} from 'react'; +import {FormattedMessage, useIntl} from 'react-intl'; +import useAsync from 'react-use/esm/useAsync'; + +import {Select, TextField} from '@/components/formio'; +import {BuilderContext, DocumentTypeOption, SelectOption} from '@/context'; + +interface DocumentTypeOptionsGroup { + label: string; + options: Array<{ + value: string; + label: string; + }>; +} + +const groupDocumentTypeOptions = (options: DocumentTypeOption[]): DocumentTypeOptionsGroup[] => { + const optionsWithGroupLabel = options.map(item => { + const groupLabel = [item.backendLabel, item.catalogus.domein].filter(Boolean).join(' > '); + return { + groupLabel, + value: item.informatieobjecttype.url, + label: item.informatieobjecttype.omschrijving, + }; + }); + + type OptGroupsMapping = Record; + const groups: OptGroupsMapping = optionsWithGroupLabel.reduce( + (accumulator: OptGroupsMapping, optionWithGroupLabel) => { + const {groupLabel, value, label} = optionWithGroupLabel; + if (!accumulator[groupLabel]) { + accumulator[groupLabel] = []; + } + accumulator[groupLabel].push({value, label}); + return accumulator; + }, + {} + ); + + // now convert this mapping back to a list of opt groups + const optGroups = Object.entries(groups).map(([groupLabel, options]) => ({ + label: groupLabel, + options, + })); + return optGroups; +}; + +const DocumentTypeSelect: React.FC<{}> = () => { + const intl = useIntl(); + const {getDocumentTypes} = useContext(BuilderContext); + const {value: options, loading, error} = useAsync(async () => await getDocumentTypes(), []); + if (error) { + throw error; + } + + const tooltip = intl.formatMessage({ + description: "Tooltip for 'registration.informatieobjecttype' builder field", + defaultMessage: `Save the attachment in the Documents API with this InformatieObjectType. If + unspecified, the registration plugin defaults are used.`, + }); + + return ( + + } + tooltip={tooltip} + isLoading={loading} + isClearable + isMulti + options={options} + valueProperty="url" + /> + ); +}; + +const Title = () => { + const intl = useIntl(); + const tooltip = intl.formatMessage({ + description: "Tooltip for 'registration.titel' builder field", + defaultMessage: 'The name under which the INFORMATIEOBJECT is formally known.', + }); + + return ( + + } + tooltip={tooltip} + /> + ); +}; + +const RegistrationTabFields: React.FC<{}> = () => { + return ( + <> + + + + + </> + ); +}; + +export default RegistrationTabFields; From e9a3900082947323b6d85bc13ecd2c0aef88eb87 Mon Sep 17 00:00:00 2001 From: Sergei Maertens <sergei@maykinmedia.nl> Date: Thu, 26 Oct 2023 18:01:49 +0200 Subject: [PATCH 08/15] :recycle: Refactor select component to handle opt groups too --- src/components/formio/select.tsx | 56 +++++++++++++++---- src/registry/file/registration-tab.stories.ts | 41 +++++++++++++- src/registry/file/registration-tab.tsx | 4 +- 3 files changed, 84 insertions(+), 17 deletions(-) diff --git a/src/components/formio/select.tsx b/src/components/formio/select.tsx index f3454ff4..6c9e4dbe 100644 --- a/src/components/formio/select.tsx +++ b/src/components/formio/select.tsx @@ -1,7 +1,11 @@ import {useField} from 'formik'; import React from 'react'; import ReactSelect from 'react-select'; -import type {GroupBase, Props as RSProps} from 'react-select/dist/declarations/src'; +import type { + GroupBase, + OptionsOrGroups, + Props as RSProps, +} from 'react-select/dist/declarations/src'; import Component from './component'; @@ -27,6 +31,40 @@ function isOption<Option, Group extends GroupBase<Option> = GroupBase<Option>>( return (opt as Group).options === undefined; } +function isOptionGroup<Option, Group extends GroupBase<Option> = GroupBase<Option>>( + opt: Option | Group +): opt is Group { + return (opt as Group).options !== undefined; +} + +function extractSelectedValue<Option extends {[key: string]: any}, Group extends GroupBase<Option>>( + options: OptionsOrGroups<Option, Group>, + currentValue: any, + isSingle: boolean, + valueProperty: string = 'value' +): any { + // normalize everything to arrays, for isSingle -> return the first (and only) element. + const normalizedCurrentValue: any[] = isSingle ? [currentValue] : currentValue; + const value: any[] = []; + + const valueTest = (opt: Option) => normalizedCurrentValue.includes(opt[valueProperty]); + + for (const optionOrGroup of options) { + if (isOption<Option, Group>(optionOrGroup) && valueTest(optionOrGroup)) { + value.push(optionOrGroup); + } + if (isOptionGroup<Option, Group>(optionOrGroup)) { + for (const option of optionOrGroup.options) { + if (valueTest(option)) { + value.push(option); + } + } + } + } + // if no value is set an isSingle is true -> returns undefined, as intended + return isSingle ? value[0] : value; +} + // can't use React.FC with generics function Select< Option extends {[key: string]: any}, @@ -49,16 +87,12 @@ function Select< if (props.options) { const currentValue = field.value; const isSingle = !Array.isArray(currentValue); - if (isSingle) { - value = - props.options.find( - opt => isOption<Option, Group>(opt) && opt[valueProperty] === currentValue - ) || null; - } else { - value = props.options.filter( - opt => isOption<Option, Group>(opt) && currentValue.includes(opt[valueProperty]) - ); - } + value = extractSelectedValue<Option, Group>( + props.options, + currentValue, + isSingle, + valueProperty + ); } return ( diff --git a/src/registry/file/registration-tab.stories.ts b/src/registry/file/registration-tab.stories.ts index 68614c3d..891136b2 100644 --- a/src/registry/file/registration-tab.stories.ts +++ b/src/registry/file/registration-tab.stories.ts @@ -1,6 +1,6 @@ import {expect} from '@storybook/jest'; import {Meta, StoryObj} from '@storybook/react'; -import {fireEvent, userEvent, waitFor, within} from '@storybook/testing-library'; +import {userEvent, within} from '@storybook/testing-library'; import {BuilderContextDecorator, withFormik} from '@/sb-decorators'; @@ -35,10 +35,45 @@ export default { type Story = StoryObj<typeof RegistrationTabFields>; -export const RegistrationTab: Story = { - name: 'Registration tab', +export const DocumentTypes: Story = { + name: 'Registration tab - document types', play: async ({canvasElement, step}) => { const canvas = within(canvasElement); + + const documentTypeSelect = canvas.getByLabelText('Information object type'); + documentTypeSelect.focus(); + await userEvent.keyboard('[ArrowDown]'); + + await step('Option group labels', async () => { + expect(await canvas.findByText('VTH', {exact: true})).toBeVisible(); + expect(canvas.getByText('Open Zaak > VTH', {exact: true})).toBeVisible(); + expect(canvas.getByText('Open Zaak > SOC', {exact: true})).toBeVisible(); + }); + + await step('Option labels', async () => { + expect(canvas.queryAllByText('Vergunning')).toHaveLength(2); + expect(canvas.queryAllByText('Ontheffing')).toHaveLength(1); + expect(canvas.queryAllByText('Aanvraag')).toHaveLength(1); + }); + }, +}; + +export const ConfidentialityLevels: Story = { + name: 'Registration tab - confidentiality levels', + + play: async ({canvasElement, step}) => { + const canvas = within(canvasElement); + + const documentTypeSelect = canvas.getByLabelText('Confidentiality level'); + documentTypeSelect.focus(); + await userEvent.keyboard('[ArrowDown]'); + + await step('Option labels', async () => { + expect(canvas.queryByText('Openbaar')).toBeVisible(); + expect(canvas.queryByText('Beperkt openbaar')).toBeVisible(); + expect(canvas.queryByText('Zaakvertrouwelijk')).toBeVisible(); + // there are more, but the backend provides this list via the context mechanism. + }); }, }; diff --git a/src/registry/file/registration-tab.tsx b/src/registry/file/registration-tab.tsx index 4489e9a6..c3b44106 100644 --- a/src/registry/file/registration-tab.tsx +++ b/src/registry/file/registration-tab.tsx @@ -70,7 +70,6 @@ const DocumentTypeSelect: React.FC<{}> = () => { tooltip={tooltip} isLoading={loading} isClearable - isMulti options={groupDocumentTypeOptions(options || [])} /> ); @@ -127,9 +126,8 @@ const ConfidentialityLevelSelect: React.FC<{}> = () => { tooltip={tooltip} isLoading={loading} isClearable - isMulti options={options} - valueProperty="url" + valueProperty="value" /> ); }; From 9864f0a077993939fbf6bc954681c0c4eff56a72 Mon Sep 17 00:00:00 2001 From: Sergei Maertens <sergei@maykinmedia.nl> Date: Thu, 26 Oct 2023 18:05:19 +0200 Subject: [PATCH 09/15] :sparkles: Add the file component registration fields to the builder form --- .storybook/decorators.tsx | 4 ++-- src/components/ComponentConfiguration.stories.tsx | 8 +++++++- src/registry/file/edit.tsx | 5 ++++- src/registry/file/registration-tab.tsx | 1 + 4 files changed, 14 insertions(+), 4 deletions(-) diff --git a/.storybook/decorators.tsx b/.storybook/decorators.tsx index 482fb45e..d3430b11 100644 --- a/.storybook/decorators.tsx +++ b/.storybook/decorators.tsx @@ -141,7 +141,7 @@ export const DEFAULT_FILE_TYPES = [ }, ]; -const DEFAULT_DOCUMENT_TYPES: DocumentTypeOption[] = [ +export const DEFAULT_DOCUMENT_TYPES: DocumentTypeOption[] = [ { backendLabel: '', catalogus: { @@ -184,7 +184,7 @@ const DEFAULT_DOCUMENT_TYPES: DocumentTypeOption[] = [ }, ]; -const CONFIDENTIALITY_LEVELS = [ +export const CONFIDENTIALITY_LEVELS = [ {label: 'Openbaar', value: 'openbaar'}, {label: 'Beperkt openbaar', value: 'beperkt_openbaar'}, {label: 'Intern', value: 'intern'}, diff --git a/src/components/ComponentConfiguration.stories.tsx b/src/components/ComponentConfiguration.stories.tsx index 695c4d1c..4decd986 100644 --- a/src/components/ComponentConfiguration.stories.tsx +++ b/src/components/ComponentConfiguration.stories.tsx @@ -4,7 +4,11 @@ import {Meta, StoryFn, StoryObj} from '@storybook/react'; import {fireEvent, userEvent, waitFor, within} from '@storybook/testing-library'; import React from 'react'; -import {DEFAULT_FILE_TYPES} from '@/../.storybook/decorators'; +import { + CONFIDENTIALITY_LEVELS, + DEFAULT_DOCUMENT_TYPES, + DEFAULT_FILE_TYPES, +} from '@/../.storybook/decorators'; import {AnyComponentSchema} from '@/types'; import ComponentConfiguration from './ComponentConfiguration'; @@ -116,6 +120,8 @@ const Template: StoryFn<TemplateArgs> = ({ getPrefillAttributes={async (plugin: string) => prefillAttributes[plugin]} getFileTypes={async () => fileTypes} serverUploadLimit="50MB" + getDocumentTypes={async () => DEFAULT_DOCUMENT_TYPES} + getConfidentialityLevels={async () => CONFIDENTIALITY_LEVELS} component={component} isNew={isNew} builderInfo={builderInfo} diff --git a/src/registry/file/edit.tsx b/src/registry/file/edit.tsx index 5ccb7325..dfd4f4fb 100644 --- a/src/registry/file/edit.tsx +++ b/src/registry/file/edit.tsx @@ -24,6 +24,7 @@ import {getErrorNames} from '@/utils/errors'; import {EditFormDefinition} from '../types'; import FileTabFields from './file-tab'; +import RegistrationTabFields from './registration-tab'; /** * Form to configure a Formio 'file' type component. @@ -114,7 +115,9 @@ const EditForm: EditFormDefinition<FileComponentSchema> = () => { </TabPanel> {/* Registration tab */} - <TabPanel>TODO</TabPanel> + <TabPanel> + <RegistrationTabFields /> + </TabPanel> {/* Translations */} <TabPanel> diff --git a/src/registry/file/registration-tab.tsx b/src/registry/file/registration-tab.tsx index c3b44106..933fa97e 100644 --- a/src/registry/file/registration-tab.tsx +++ b/src/registry/file/registration-tab.tsx @@ -92,6 +92,7 @@ const SourceOrganisation = () => { /> } tooltip={tooltip} + inputMode="numeric" /> ); }; From a1378f3703d9245f8daae8ca2191a88b3eb6a090 Mon Sep 17 00:00:00 2001 From: Sergei Maertens <sergei@maykinmedia.nl> Date: Fri, 27 Oct 2023 12:42:59 +0200 Subject: [PATCH 10/15] :green_heart: Increase simulated backend delay to remove test flakiness The useAsync hook runs before the select component even renders, so there is some execution time that may cause the 'loading...' message to never display or too briefly for the testing tools to pick it up. --- .../validate/validator-select.stories.tsx | 23 ++++++++++--------- 1 file changed, 12 insertions(+), 11 deletions(-) diff --git a/src/components/builder/validate/validator-select.stories.tsx b/src/components/builder/validate/validator-select.stories.tsx index 2606856e..0b964936 100644 --- a/src/components/builder/validate/validator-select.stories.tsx +++ b/src/components/builder/validate/validator-select.stories.tsx @@ -1,6 +1,6 @@ import {expect} from '@storybook/jest'; import {Meta, StoryFn, StoryObj} from '@storybook/react'; -import {userEvent, waitFor, within} from '@storybook/testing-library'; +import {userEvent, within} from '@storybook/testing-library'; import {withFormik} from '@/sb-decorators'; @@ -27,7 +27,7 @@ export default { iframeHeight: 200, }, modal: {noModal: true}, - builder: {enableContext: true, validatorPluginsDelay: 100}, + builder: {enableContext: true, validatorPluginsDelay: 500}, formik: {initialValues: {validate: {plugins: []}}}, }, args: { @@ -42,21 +42,22 @@ const Template: StoryFn<typeof ValidatorPluginSelect> = () => <ValidatorPluginSe export const Default: Story = { render: Template, - play: async ({canvasElement}) => { + play: async ({canvasElement, step}) => { const canvas = within(canvasElement); - const input = await canvas.getByLabelText('Plugin(s)'); + const input = canvas.getByLabelText('Plugin(s)'); // open the dropdown - await input.focus(); + input.focus(); await userEvent.keyboard('[ArrowDown]'); - await waitFor(async () => { - await expect(canvas.queryByText('Loading...')).toBeInTheDocument(); + await step('Loading items from backend', async () => { + await expect(await canvas.findByText('Loading...')).toBeInTheDocument(); }); - // assert the options are present - await waitFor(async () => { - await expect(canvas.queryByText('Plugin 1')).toBeInTheDocument(); - await expect(canvas.queryByText('Plugin 2')).toBeInTheDocument(); + + await step('Check available options displayed', async () => { + // assert the options are present + await expect(await canvas.findByText('Plugin 1')).toBeInTheDocument(); + await expect(await canvas.findByText('Plugin 2')).toBeInTheDocument(); }); }, }; From 3e0a7d570054277068bc192667d6869e59825a10 Mon Sep 17 00:00:00 2001 From: Sergei Maertens <sergei@maykinmedia.nl> Date: Fri, 27 Oct 2023 12:50:15 +0200 Subject: [PATCH 11/15] :white_check_mark: Add test for desired validation against server upload limit --- src/registry/file/file-validation.stories.ts | 20 ++++++++++++++++++++ 1 file changed, 20 insertions(+) diff --git a/src/registry/file/file-validation.stories.ts b/src/registry/file/file-validation.stories.ts index 4eade1e0..21a9a6a8 100644 --- a/src/registry/file/file-validation.stories.ts +++ b/src/registry/file/file-validation.stories.ts @@ -158,3 +158,23 @@ export const ValidMaxFileSize: Story = { }); }, }; + +export const ValidateMaxFileSizeAgainstServerValue: Story = { + name: 'validate fileMaxSize against serverUploadLimit', + + play: async ({canvasElement}) => { + const canvas = within(canvasElement); + + await userEvent.click(canvas.getByRole('link', {name: 'File'})); + const fileSize = canvas.getByLabelText('Maximum file size'); + + await userEvent.clear(fileSize); + await userEvent.type(fileSize, '100MB'); + await userEvent.keyboard('[Tab]'); + expect( + await canvas.findByText( + "Specify a file size that's less than or equal to the server upload limit." + ) + ).toBeVisible(); + }, +}; From a5eeb6c0974edd8aae50363b0233cd4faf306171 Mon Sep 17 00:00:00 2001 From: Sergei Maertens <sergei@maykinmedia.nl> Date: Fri, 27 Oct 2023 14:42:28 +0200 Subject: [PATCH 12/15] :sparkles: Implement parsing string filesize as numbers that can be compared. --- src/registry/file/edit-validation.ts | 61 +++++++++++++++----- src/registry/file/file-tab.tsx | 19 ------ src/registry/file/file-validation.stories.ts | 4 +- 3 files changed, 47 insertions(+), 37 deletions(-) diff --git a/src/registry/file/edit-validation.ts b/src/registry/file/edit-validation.ts index c107415d..18c99fab 100644 --- a/src/registry/file/edit-validation.ts +++ b/src/registry/file/edit-validation.ts @@ -3,6 +3,27 @@ import {z} from 'zod'; import {buildCommonSchema} from '@/registry/validation'; +const SERVER_LIMIT = '50MB'; + +// Reference: formio's file component translateScalars method, but without the weird +// units that don't make sense for files... +const TRANSFORMATIONS = { + B: Math.pow(1024, 0), + KB: Math.pow(1024, 1), + MB: Math.pow(1024, 2), + GB: Math.pow(1024, 3), +}; + +const getSizeInBytes = (filesize: string): number | null => { + const match = /^(\d+)\s*(B|KB|MB|GB)?$/i.exec(filesize); + if (match === null) { + return null; + } + const size = parseInt(match[1], 10); + const unit = (match[2] || 'B').toUpperCase() as keyof typeof TRANSFORMATIONS; + return size * TRANSFORMATIONS[unit]; +}; + const imgSize = z.number().int().positive(); const ofSchema = z.object({ @@ -19,13 +40,31 @@ const ofSchema = z.object({ .optional(), }); -const buildFileMaxSizeSchema = (intl: IntlShape) => - z.string().refine(value => /^\d+\s*(B|KB|MB|GB)/i.test(value), { - message: intl.formatMessage({ - description: "File component 'fileMaxSize' validation error", - defaultMessage: 'Specify a positive, non-zero file size without decimals, e.g. 10MB.', - }), - }); +const buildFileMaxSizeSchema = (intl: IntlShape) => { + const serverLimitInBytes = getSizeInBytes(SERVER_LIMIT) ?? Number.MAX_SAFE_INTEGER; + return z + .string() + .transform((val, ctx) => { + const sizeInBytes = getSizeInBytes(val); + if (sizeInBytes === null) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + message: intl.formatMessage({ + description: "File component 'fileMaxSize' validation error", + defaultMessage: 'Specify a positive, non-zero file size without decimals, e.g. 10MB.', + }), + }); + return z.NEVER; + } + return sizeInBytes; + }) + .refine(value => value <= serverLimitInBytes, { + message: intl.formatMessage({ + description: "File component 'fileMaxSize' greater than server limit", + defaultMessage: 'Specify a file size less than or equal to the server upload limit.', + }), + }); +}; const buildFileSchema = (intl: IntlShape) => z.object({ @@ -34,14 +73,6 @@ const buildFileSchema = (intl: IntlShape) => fileMaxSize: buildFileMaxSizeSchema(intl).optional(), }); -/** - * @todo implement validations: - * - * - validate fileMaxSize <= serverUploadLimit from context, if set. -> pass builder - * context by default to validator factory? we already pass the intl object so there's - * precedent... - */ - const schema = (intl: IntlShape) => buildCommonSchema(intl).and(buildFileSchema(intl)); export default schema; diff --git a/src/registry/file/file-tab.tsx b/src/registry/file/file-tab.tsx index 48dfce9b..11ed5239 100644 --- a/src/registry/file/file-tab.tsx +++ b/src/registry/file/file-tab.tsx @@ -8,25 +8,6 @@ import useAsync from 'react-use/esm/useAsync'; import {Checkbox, Component, NumberField, Select, TextField} from '@/components/formio'; import {BuilderContext} from '@/context'; -// // lifted from formio's file component in 4.13.x -// const translateScalars(str) { -// if (typeof str === 'string') { -// if (str.search(/kb/i) === str.length - 2) { -// return parseFloat(str.substring(0, str.length - 2) * 1024); -// } -// if (str.search(/mb/i) === str.length - 2) { -// return parseFloat(str.substring(0, str.length - 2) * 1024 * 1024); -// } -// if (str.search(/gb/i) === str.length - 2) { -// return parseFloat(str.substring(0, str.length - 2) * 1024 * 1024 * 1024); -// } -// if (str.search(/b/i) === str.length - 1) { -// return parseFloat(str.substring(0, str.length - 1)); -// } -// } -// return str; -// } - const hasImageMimeType = (mimetypes: string[]): boolean => mimetypes.some(mimeType => mimeType.startsWith('image/') || mimeType === '*'); diff --git a/src/registry/file/file-validation.stories.ts b/src/registry/file/file-validation.stories.ts index 21a9a6a8..4eb0273b 100644 --- a/src/registry/file/file-validation.stories.ts +++ b/src/registry/file/file-validation.stories.ts @@ -172,9 +172,7 @@ export const ValidateMaxFileSizeAgainstServerValue: Story = { await userEvent.type(fileSize, '100MB'); await userEvent.keyboard('[Tab]'); expect( - await canvas.findByText( - "Specify a file size that's less than or equal to the server upload limit." - ) + await canvas.findByText('Specify a file size less than or equal to the server upload limit.') ).toBeVisible(); }, }; From dce9b58120e9238886187a65231e0cdb305bcdf6 Mon Sep 17 00:00:00 2001 From: Sergei Maertens <sergei@maykinmedia.nl> Date: Fri, 27 Oct 2023 14:52:03 +0200 Subject: [PATCH 13/15] :recycle: Pass builder context to validation schema factory --- src/components/ComponentEditForm.tsx | 5 ++++- src/registry/file/edit-validation.ts | 18 +++++++++++------- src/registry/types.ts | 3 ++- 3 files changed, 17 insertions(+), 9 deletions(-) diff --git a/src/components/ComponentEditForm.tsx b/src/components/ComponentEditForm.tsx index 89399c5c..f3848237 100644 --- a/src/components/ComponentEditForm.tsx +++ b/src/components/ComponentEditForm.tsx @@ -2,9 +2,11 @@ import {Form, Formik} from 'formik'; import {ExtendedComponentSchema} from 'formiojs/types/components/schema'; import cloneDeep from 'lodash.clonedeep'; import merge from 'lodash.merge'; +import {useContext} from 'react'; 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'; @@ -39,6 +41,7 @@ const ComponentEditForm: React.FC<ComponentEditFormProps> = ({ onSubmit, }) => { const intl = useIntl(); + const builderContext = useContext(BuilderContext); const registryEntry = getRegistryEntry(component); const {edit: EditForm, editSchema: zodSchema} = registryEntry; @@ -65,7 +68,7 @@ const ComponentEditForm: React.FC<ComponentEditFormProps> = ({ onSubmit(values); setSubmitting(false); }} - validationSchema={toFormikValidationSchema(zodSchema(intl))} + validationSchema={toFormikValidationSchema(zodSchema(intl, builderContext))} > {formik => { const component = formik.values; diff --git a/src/registry/file/edit-validation.ts b/src/registry/file/edit-validation.ts index 18c99fab..9cfa1b1a 100644 --- a/src/registry/file/edit-validation.ts +++ b/src/registry/file/edit-validation.ts @@ -1,10 +1,9 @@ import {IntlShape} from 'react-intl'; import {z} from 'zod'; +import {BuilderContextType} from '@/context'; import {buildCommonSchema} from '@/registry/validation'; -const SERVER_LIMIT = '50MB'; - // Reference: formio's file component translateScalars method, but without the weird // units that don't make sense for files... const TRANSFORMATIONS = { @@ -40,8 +39,9 @@ const ofSchema = z.object({ .optional(), }); -const buildFileMaxSizeSchema = (intl: IntlShape) => { - const serverLimitInBytes = getSizeInBytes(SERVER_LIMIT) ?? Number.MAX_SAFE_INTEGER; +const buildFileMaxSizeSchema = (intl: IntlShape, builderContext: BuilderContextType) => { + const {serverUploadLimit} = builderContext; + const serverLimitInBytes = getSizeInBytes(serverUploadLimit) ?? Number.MAX_SAFE_INTEGER; return z .string() .transform((val, ctx) => { @@ -66,13 +66,17 @@ const buildFileMaxSizeSchema = (intl: IntlShape) => { }); }; -const buildFileSchema = (intl: IntlShape) => +const buildFileSchema = (intl: IntlShape, builderContext: BuilderContextType) => z.object({ of: ofSchema.optional(), maxNumberOfFiles: z.union([z.null(), z.number().int().positive().optional()]), - fileMaxSize: buildFileMaxSizeSchema(intl).optional(), + fileMaxSize: buildFileMaxSizeSchema(intl, builderContext).optional(), }); -const schema = (intl: IntlShape) => buildCommonSchema(intl).and(buildFileSchema(intl)); +const schema = (intl: IntlShape, builderContext: BuilderContextType) => { + const common = buildCommonSchema(intl); + const fileSpecific = buildFileSchema(intl, builderContext); + return common.and(fileSpecific); +}; export default schema; diff --git a/src/registry/types.ts b/src/registry/types.ts index 92c02fce..08289dd7 100644 --- a/src/registry/types.ts +++ b/src/registry/types.ts @@ -1,6 +1,7 @@ import {IntlShape} from 'react-intl'; import {z} from 'zod'; +import {BuilderContextType} from '@/context'; import {AnyComponentSchema, FallbackSchema} from '@/types'; // Edit form @@ -26,7 +27,7 @@ export type Preview<S extends AnyComponentSchema | FallbackSchema> = React.FC< export interface RegistryEntry<S extends AnyComponentSchema | FallbackSchema> { edit: EditFormDefinition<S>; - editSchema: (intl: IntlShape) => z.ZodFirstPartySchemaTypes; + editSchema: (intl: IntlShape, builderContext: BuilderContextType) => z.ZodFirstPartySchemaTypes; preview: Preview<S>; // textfield -> string, numberfield -> number etc. This is used for the formik // initial data From 453a2355e2a83b44b2e8a6702cc5a3aae735f473 Mon Sep 17 00:00:00 2001 From: Sergei Maertens <sergei@maykinmedia.nl> Date: Fri, 27 Oct 2023 15:42:56 +0200 Subject: [PATCH 14/15] :arrow_up: Bump to types v0.13.0 Contains the fixes for the file upload component. --- package-lock.json | 14 +++++++------- package.json | 2 +- src/components/ComponentConfiguration.stories.tsx | 2 ++ src/registry/file/edit.tsx | 5 ++--- 4 files changed, 12 insertions(+), 11 deletions(-) diff --git a/package-lock.json b/package-lock.json index 3ca6986e..4b39bbcc 100644 --- a/package-lock.json +++ b/package-lock.json @@ -35,7 +35,7 @@ "@formatjs/cli": "^6.1.1", "@formatjs/ts-transformer": "^3.12.0", "@fortawesome/fontawesome-free": "^6.4.0", - "@open-formulieren/types": "^0.12.0", + "@open-formulieren/types": "^0.13.0", "@storybook/addon-actions": "^7.3.2", "@storybook/addon-essentials": "^7.3.2", "@storybook/addon-interactions": "^7.3.2", @@ -5840,9 +5840,9 @@ } }, "node_modules/@open-formulieren/types": { - "version": "0.12.0", - "resolved": "https://registry.npmjs.org/@open-formulieren/types/-/types-0.12.0.tgz", - "integrity": "sha512-JfS1XM9SI3mfM7RuO8Pdo5QaagnerIyRfsTfBX8N6zy6ujUoc0aMap8baJGH5/fMW8DwVo1xB1I45qPCj6LyCg==", + "version": "0.13.0", + "resolved": "https://registry.npmjs.org/@open-formulieren/types/-/types-0.13.0.tgz", + "integrity": "sha512-zG5eHaSqont3nSO2wzSO8fJL43E3pePbStenoISZuFR4dDV0sjdyUFwfmujZd0qgj089rJDE+MAZB3SwF4hZyg==", "dev": true }, "node_modules/@pkgjs/parseargs": { @@ -35200,9 +35200,9 @@ } }, "@open-formulieren/types": { - "version": "0.12.0", - "resolved": "https://registry.npmjs.org/@open-formulieren/types/-/types-0.12.0.tgz", - "integrity": "sha512-JfS1XM9SI3mfM7RuO8Pdo5QaagnerIyRfsTfBX8N6zy6ujUoc0aMap8baJGH5/fMW8DwVo1xB1I45qPCj6LyCg==", + "version": "0.13.0", + "resolved": "https://registry.npmjs.org/@open-formulieren/types/-/types-0.13.0.tgz", + "integrity": "sha512-zG5eHaSqont3nSO2wzSO8fJL43E3pePbStenoISZuFR4dDV0sjdyUFwfmujZd0qgj089rJDE+MAZB3SwF4hZyg==", "dev": true }, "@pkgjs/parseargs": { diff --git a/package.json b/package.json index fa787bbc..d6520f0b 100644 --- a/package.json +++ b/package.json @@ -61,7 +61,7 @@ "@formatjs/cli": "^6.1.1", "@formatjs/ts-transformer": "^3.12.0", "@fortawesome/fontawesome-free": "^6.4.0", - "@open-formulieren/types": "^0.12.0", + "@open-formulieren/types": "^0.13.0", "@storybook/addon-actions": "^7.3.2", "@storybook/addon-essentials": "^7.3.2", "@storybook/addon-interactions": "^7.3.2", diff --git a/src/components/ComponentConfiguration.stories.tsx b/src/components/ComponentConfiguration.stories.tsx index 4decd986..62f0531b 100644 --- a/src/components/ComponentConfiguration.stories.tsx +++ b/src/components/ComponentConfiguration.stories.tsx @@ -841,6 +841,8 @@ export const FileUpload: Story = { expect(args.onSubmit).toHaveBeenCalledWith({ type: 'file', id: 'kiweljhr', + webcam: false, + options: {withCredentials: true}, storage: 'url', url: '', // basic tab diff --git a/src/registry/file/edit.tsx b/src/registry/file/edit.tsx index dfd4f4fb..2d3bb730 100644 --- a/src/registry/file/edit.tsx +++ b/src/registry/file/edit.tsx @@ -141,9 +141,8 @@ const EditForm: EditFormDefinition<FileComponentSchema> = () => { EditForm.defaultValues = { storage: 'url', url: '', - // TODO -> add to type definition! - // options: '{"withCredentials": true}', - // webcam: false, + options: {withCredentials: true}, + webcam: false, // basic tab label: '', From 661addc7427f568bf8a86ea1c8e19dda5fc20a4d Mon Sep 17 00:00:00 2001 From: Sergei Maertens <sergei@maykinmedia.nl> Date: Mon, 30 Oct 2023 12:55:23 +0100 Subject: [PATCH 15/15] :globe_with_meridians: Add Dutch translations --- i18n/messages/en.json | 155 ++++++++++++++++++++++++++++++++++ i18n/messages/nl.json | 188 ++++++++++++++++++++++++++++++++++++++---- 2 files changed, 327 insertions(+), 16 deletions(-) diff --git a/i18n/messages/en.json b/i18n/messages/en.json index 73e875dd..6b122b4a 100644 --- a/i18n/messages/en.json +++ b/i18n/messages/en.json @@ -9,6 +9,11 @@ "description": "Remove component button", "originalDefault": "Remove" }, + "+e1pgl": { + "defaultMessage": "Resize image", + "description": "Label for 'of.image.resize.apply' builder field", + "originalDefault": "Resize image" + }, "+q3LGC": { "defaultMessage": "Regular Expression Pattern", "description": "Placeholder for 'validate.pattern' builder field", @@ -44,16 +49,36 @@ "description": "Label for 'delta.months' in relative delta date constraint validation", "originalDefault": "Months" }, + "0gmZ4c": { + "defaultMessage": "Edit component JSON", + "description": "Accessible label for builder preview JSON edit field", + "originalDefault": "Edit component JSON" + }, + "1Vvryq": { + "defaultMessage": "File name template", + "description": "Label for 'file.name' builder field", + "originalDefault": "File name template" + }, "1WxO/D": { "defaultMessage": "Regular Expression Pattern", "description": "Label for 'validate.pattern' builder field", "originalDefault": "Regular Expression Pattern" }, + "1pC7IP": { + "defaultMessage": "Specify template for name of uploaded file(s). <code>'{{ fileName }}'</code> contains the original filename.", + "description": "Tooltip for 'file.name' builder field", + "originalDefault": "Specify template for name of uploaded file(s). <code>'{{ fileName }}'</code> contains the original filename." + }, "2/2h2C": { "defaultMessage": "The minimum value this field can have before the form can be submitted.", "description": "Tooltip for 'validate.min' builder field", "originalDefault": "The minimum value this field can have before the form can be submitted." }, + "2ajYIp": { + "defaultMessage": "Note that the server upload limit is {serverUploadLimit}.", + "description": "Description for 'fileMaxSize' builder field", + "originalDefault": "Note that the server upload limit is {serverUploadLimit}." + }, "2k+Mca": { "defaultMessage": "Validation", "description": "Component edit form tab title for 'Validation' tab", @@ -99,6 +124,16 @@ "description": "Label for 'delta.days' in relative delta date constraint validation", "originalDefault": "Days" }, + "4HBnrF": { + "defaultMessage": "Drag or <browse>select</browse> files to upload.", + "description": "file component: drag/select files to upload text", + "originalDefault": "Drag or <browse>select</browse> files to upload." + }, + "5/jkH9": { + "defaultMessage": "Specify a file size less than or equal to the server upload limit.", + "description": "File component 'fileMaxSize' greater than server limit", + "originalDefault": "Specify a file size less than or equal to the server upload limit." + }, "5DyU8e": { "defaultMessage": "The maximum length requirement this field must meet.", "description": "Tooltip for 'validate.maxLength' builder field", @@ -119,6 +154,11 @@ "description": "Character count", "originalDefault": "{length} {length, plural, one {character} other {characters}}" }, + "5y8Yt4": { + "defaultMessage": "Save the attachment in the Documents API with this InformatieObjectType. If unspecified, the registration plugin defaults are used.", + "description": "Tooltip for 'registration.informatieobjecttype' builder field", + "originalDefault": "Save the attachment in the Documents API with this InformatieObjectType. If unspecified, the registration plugin defaults are used." + }, "5yP7G/": { "defaultMessage": "Maximum time", "description": "Label for 'validate.maxTime' builder field", @@ -134,6 +174,11 @@ "description": "Tooltip for 'validate.required' builder field", "originalDefault": "A required field must be filled in before the form can be submitted." }, + "6XG2pL": { + "defaultMessage": "Confidentiality level", + "description": "Label for 'registration.docVertrouwelijkheidaanduiding' builder field", + "originalDefault": "Confidentiality level" + }, "7Ldfn+": { "defaultMessage": "Relative to variable", "description": "Date constraint mode 'relativeToVariable' label", @@ -174,6 +219,11 @@ "description": "Simple conditional panel title", "originalDefault": "Simple conditional" }, + "Ax7r1y": { + "defaultMessage": "File types", + "description": "Label for 'file.type' builder field", + "originalDefault": "File types" + }, "B1ONuu": { "defaultMessage": "Location", "description": "Translations: location column header", @@ -194,6 +244,11 @@ "description": "Placeholder for 'validate.maxLength' builder field", "originalDefault": "Maximum length" }, + "CG/va1": { + "defaultMessage": "Maximum file size", + "description": "Label for 'fileMaxSize' builder field", + "originalDefault": "Maximum file size" + }, "CRN74c": { "defaultMessage": "Placeholder", "description": "Placeholder for 'placeholder' builder field", @@ -284,11 +339,21 @@ "description": "Component 'JSON' preview mode", "originalDefault": "JSON" }, + "JnAJoU": { + "defaultMessage": "Use globally configured filetypes", + "description": "Label for 'useConfigFiletypes' builder field", + "originalDefault": "Use globally configured filetypes" + }, "JneaQM": { "defaultMessage": "Hidden", "description": "Label for 'Hidden' builder field", "originalDefault": "Hidden" }, + "KrJ+rN": { + "defaultMessage": "The maximum number of files that can be uploaded.", + "description": "Tooltip for 'maxNumberOfFiles' builder field", + "originalDefault": "The maximum number of files that can be uploaded." + }, "Nsifca": { "defaultMessage": "The maximum date that can be picked.", "description": "Date field 'maxDate' fixed value tooltip", @@ -314,6 +379,11 @@ "description": "Invalid email address format validation error", "originalDefault": "{field} must be a valid email." }, + "QW32Dd": { + "defaultMessage": "Specify a positive, non-zero file size without decimals, e.g. 10MB.", + "description": "File component 'fileMaxSize' validation error", + "originalDefault": "Specify a positive, non-zero file size without decimals, e.g. 10MB." + }, "Qjl92W": { "defaultMessage": "Preview", "description": "Component preview card title", @@ -329,11 +399,21 @@ "description": "Label identifier role main", "originalDefault": "Main" }, + "RxmRzd": { + "defaultMessage": "When this is checked, the filetypes configured in the global settings will be used.", + "description": "Tooltip for 'useConfigFiletypes' builder field", + "originalDefault": "When this is checked, the filetypes configured in the global settings will be used." + }, "SEXqhC": { "defaultMessage": "Mode preset", "description": "Label for 'date constraint mode' builder field", "originalDefault": "Mode preset" }, + "SEu4I2": { + "defaultMessage": "Title", + "description": "Label for 'registration.titel' builder field", + "originalDefault": "Title" + }, "SH67gH": { "defaultMessage": "In the future", "description": "Date constraint mode 'future' label", @@ -369,6 +449,11 @@ "description": "Label for 'derivePostcode' builder field", "originalDefault": "Postcode component" }, + "UxTJsK": { + "defaultMessage": "File", + "description": "Component edit form tab title for 'File' tab", + "originalDefault": "File" + }, "V6ClU9": { "defaultMessage": "If the postcode and house number are entered this field will autofill with the city", "description": "Tooltip for 'deriveCity' builder field", @@ -414,6 +499,21 @@ "description": "Tooltip for 'prefill.identifierRole' builder field", "originalDefault": "In case that multiple identifiers are returned (in the case of eHerkenning bewindvoering and DigiD Machtigen), should the prefill data related to the main identifier be used, or that related to the authorised person?" }, + "XXRiTx": { + "defaultMessage": "File name", + "description": "file component preview: file name column header", + "originalDefault": "File name" + }, + "Y4oNhH": { + "defaultMessage": "Maximum width", + "description": "Label for 'of.image.resize.width' builder field", + "originalDefault": "Maximum width" + }, + "Y8cmP1": { + "defaultMessage": "Size", + "description": "file component preview: file size column header", + "originalDefault": "Size" + }, "YBY95E": { "defaultMessage": "Manual", "description": "Link to manual title", @@ -429,6 +529,11 @@ "description": "Tooltip for 'prefill.plugin' builder field", "originalDefault": "Select the plugin to use for the prefill functionality." }, + "aBADYT": { + "defaultMessage": "Maximum number of files", + "description": "Label for 'maxNumberOfFiles' builder field", + "originalDefault": "Maximum number of files" + }, "asZV7t": { "defaultMessage": "Derive street name", "description": "Label for 'deriveStreetName' builder field", @@ -474,6 +579,11 @@ "description": "Label for 'prefill.plugin' builder field", "originalDefault": "Plugin" }, + "ctoEdl": { + "defaultMessage": "The name under which the INFORMATIEOBJECT is formally known.", + "description": "Tooltip for 'registration.titel' builder field", + "originalDefault": "The name under which the INFORMATIEOBJECT is formally known." + }, "czUDSQ": { "defaultMessage": "A short indicator that can provide more context for the expected field value. The '<sup> and <sub>' HTML tags are supported.", "description": "Tooltip for 'suffix' builder field", @@ -529,6 +639,16 @@ "description": "Tooltip for 'registration.attribute' builder field", "originalDefault": "Save the value as this attribute in the registration backend system." }, + "fX5VwA": { + "defaultMessage": "The maximum size of a single file to upload.", + "description": "Tooltip for 'fileMaxSize' builder field", + "originalDefault": "The maximum size of a single file to upload." + }, + "fY/8xt": { + "defaultMessage": "Information object type", + "description": "Label for 'registration.informatieobjecttype' builder field", + "originalDefault": "Information object type" + }, "h0B9Fr": { "defaultMessage": "The property name must only contain alphanumeric characters, underscores, dots and dashes and should not be ended by dash or dot.", "description": "Form builder 'key' pattern validation error", @@ -539,6 +659,11 @@ "description": "Component 'Rich' preview mode", "originalDefault": "Form" }, + "hduHvs": { + "defaultMessage": "(optional)", + "description": "placeholder for 'file.name' builder field", + "originalDefault": "(optional)" + }, "iOADkJ": { "defaultMessage": "Show in summary", "description": "Label for 'showInSummary' builder field", @@ -604,6 +729,16 @@ "description": "Tooltip for 'operator' in relative delta date constraint validation", "originalDefault": "Specify whether to add or subtract a time delta to/from the variable." }, + "n9T2Oy": { + "defaultMessage": "When this is checked, any uploaded image(s) will be resized.", + "description": "Tooltip for 'of.image.resize.apply' builder field", + "originalDefault": "When this is checked, any uploaded image(s) will be resized." + }, + "nW5g1S": { + "defaultMessage": "Bronorganisatie", + "description": "Label for 'registration.bronorganisatie' builder field", + "originalDefault": "Bronorganisatie" + }, "nqJbi9": { "defaultMessage": "Tooltip", "description": "Component property 'Tooltip' label", @@ -659,6 +794,11 @@ "description": "Title of validation error translations panel", "originalDefault": "Custom error messages" }, + "vlY36U": { + "defaultMessage": "Indication of the level to which extent the INFORMATIEOBJECT is meant to be public.", + "description": "Tooltip for 'registration.docVertrouwelijkheidaanduiding' builder field", + "originalDefault": "Indication of the level to which extent the INFORMATIEOBJECT is meant to be public." + }, "vzhWgR": { "defaultMessage": "Specify the attribute holding the pre-fill data.", "description": "Tooltip for 'prefill.attribute' builder field", @@ -674,6 +814,11 @@ "description": "Label for 'operator' in relative delta date constraint validation", "originalDefault": "Add/subtract duration" }, + "xnRkUj": { + "defaultMessage": "Edit JSON", + "description": "Component 'editJSON' preview mode", + "originalDefault": "Edit JSON" + }, "yD4X2y": { "defaultMessage": "Maximum length", "description": "Label for 'validate.maxLength' builder field", @@ -684,9 +829,19 @@ "description": "Translations: translation column header", "originalDefault": "Translations" }, + "yQUV3M": { + "defaultMessage": "RSIN of the organization which creates the ENKELVOUDIGINFORMATIEOBJECT.", + "description": "Tooltip for 'registration.bronorganisatie' builder field", + "originalDefault": "RSIN of the organization which creates the ENKELVOUDIGINFORMATIEOBJECT." + }, "yQtFln": { "defaultMessage": "Hide a field from the form.", "description": "Tooltip for 'Hidden' builder field", "originalDefault": "Hide a field from the form." + }, + "yujuQr": { + "defaultMessage": "Maximum height", + "description": "Label for 'of.image.resize.height' builder field", + "originalDefault": "Maximum height" } } diff --git a/i18n/messages/nl.json b/i18n/messages/nl.json index 911a6962..a374af94 100644 --- a/i18n/messages/nl.json +++ b/i18n/messages/nl.json @@ -9,6 +9,11 @@ "description": "Remove component button", "originalDefault": "Remove" }, + "+e1pgl": { + "defaultMessage": "Afbeeldingsformaat wijzigen", + "description": "Label for 'of.image.resize.apply' builder field", + "originalDefault": "Resize image" + }, "+q3LGC": { "defaultMessage": "Reguliere expressie", "description": "Placeholder for 'validate.pattern' builder field", @@ -44,16 +49,36 @@ "description": "Label for 'delta.months' in relative delta date constraint validation", "originalDefault": "Months" }, + "0gmZ4c": { + "defaultMessage": "Component-JSON aanpassen", + "description": "Accessible label for builder preview JSON edit field", + "originalDefault": "Edit component JSON" + }, + "1Vvryq": { + "defaultMessage": "Bestandsnaam", + "description": "Label for 'file.name' builder field", + "originalDefault": "File name template" + }, "1WxO/D": { "defaultMessage": "Reguliere expressie", "description": "Label for 'validate.pattern' builder field", "originalDefault": "Regular Expression Pattern" }, + "1pC7IP": { + "defaultMessage": "Specifieer een template voor de naam van het/de ge\u00fcploade bestand(en). <code>'{{ fileName }}'</code> bevat de oorspronkelijke bestandsnaam.", + "description": "Tooltip for 'file.name' builder field", + "originalDefault": "Specify template for name of uploaded file(s). <code>'{{ fileName }}'</code> contains the original filename." + }, "2/2h2C": { "defaultMessage": "De minimale waarde die dit veld kan hebben voordat het formulier kan worden verzonden.", "description": "Tooltip for 'validate.min' builder field", "originalDefault": "The minimum value this field can have before the form can be submitted." }, + "2ajYIp": { + "defaultMessage": "Merk op dat de maximale uploadgrootte op de server {serverUploadLimit} is.", + "description": "Description for 'fileMaxSize' builder field", + "originalDefault": "Note that the server upload limit is {serverUploadLimit}." + }, "2k+Mca": { "defaultMessage": "Validatie", "description": "Component edit form tab title for 'Validation' tab", @@ -99,6 +124,16 @@ "description": "Label for 'delta.days' in relative delta date constraint validation", "originalDefault": "Days" }, + "4HBnrF": { + "defaultMessage": "Sleep of <browse>selecteer</browse> bestanden om te uploaden.", + "description": "file component: drag/select files to upload text", + "originalDefault": "Drag or <browse>select</browse> files to upload." + }, + "5/jkH9": { + "defaultMessage": "De maximale bestandsgrootte moet gelijk aan of kleiner dan de serverlimiet zijn.", + "description": "File component 'fileMaxSize' greater than server limit", + "originalDefault": "Specify a file size less than or equal to the server upload limit." + }, "5DyU8e": { "defaultMessage": "De maximale toegestane lengte voor dit veld.", "description": "Tooltip for 'validate.maxLength' builder field", @@ -119,6 +154,11 @@ "description": "Character count", "originalDefault": "{length} {length, plural, one {character} other {characters}}" }, + "5y8Yt4": { + "defaultMessage": "Sla het bestand op in de Documenten API met dit documenttype. Indien leeg, dan worden algemene instellingen gebruikt.", + "description": "Tooltip for 'registration.informatieobjecttype' builder field", + "originalDefault": "Save the attachment in the Documents API with this InformatieObjectType. If unspecified, the registration plugin defaults are used." + }, "5yP7G/": { "defaultMessage": "Maximale tijd", "description": "Label for 'validate.maxTime' builder field", @@ -134,6 +174,11 @@ "description": "Tooltip for 'validate.required' builder field", "originalDefault": "A required field must be filled in before the form can be submitted." }, + "6XG2pL": { + "defaultMessage": "Vertrouwelijkheidaanduiding", + "description": "Label for 'registration.docVertrouwelijkheidaanduiding' builder field", + "originalDefault": "Confidentiality level" + }, "7Ldfn+": { "defaultMessage": "Ten opzichte van een variabele", "description": "Date constraint mode 'relativeToVariable' label", @@ -174,6 +219,11 @@ "description": "Simple conditional panel title", "originalDefault": "Simple conditional" }, + "Ax7r1y": { + "defaultMessage": "Bestandstypen", + "description": "Label for 'file.type' builder field", + "originalDefault": "File types" + }, "B1ONuu": { "defaultMessage": "Locatie", "description": "Translations: location column header", @@ -194,11 +244,16 @@ "description": "Placeholder for 'validate.maxLength' builder field", "originalDefault": "Maximum length" }, + "CG/va1": { + "defaultMessage": "Maximale bestandsgrootte", + "description": "Label for 'fileMaxSize' builder field", + "originalDefault": "Maximum file size" + }, "CRN74c": { "defaultMessage": "Placeholder", "description": "Placeholder for 'placeholder' builder field", - "originalDefault": "Placeholder", - "isTranslated": true + "isTranslated": true, + "originalDefault": "Placeholder" }, "D0hDzV": { "defaultMessage": "{componentType}-component", @@ -213,8 +268,8 @@ "Dm3S1P": { "defaultMessage": "Placeholder", "description": "Component property 'Placeholder' label", - "originalDefault": "Placeholder", - "isTranslated": true + "isTranslated": true, + "originalDefault": "Placeholder" }, "Do20MZ": { "defaultMessage": "Basis", @@ -284,14 +339,24 @@ "JdwEl7": { "defaultMessage": "JSON", "description": "Component 'JSON' preview mode", - "originalDefault": "JSON", - "isTranslated": true + "isTranslated": true, + "originalDefault": "JSON" + }, + "JnAJoU": { + "defaultMessage": "Gebruik algemene bestandstypeninstellingen", + "description": "Label for 'useConfigFiletypes' builder field", + "originalDefault": "Use globally configured filetypes" }, "JneaQM": { "defaultMessage": "Verborgen", "description": "Label for 'Hidden' builder field", "originalDefault": "Hidden" }, + "KrJ+rN": { + "defaultMessage": "Het maximaal aantal bestanden die mogen geüpload worden.", + "description": "Tooltip for 'maxNumberOfFiles' builder field", + "originalDefault": "The maximum number of files that can be uploaded." + }, "Nsifca": { "defaultMessage": "De maximale datum die gekozen kan worden.", "description": "Date field 'maxDate' fixed value tooltip", @@ -305,8 +370,8 @@ "OtGsP8": { "defaultMessage": "Label", "description": "Component property 'Label' label", - "originalDefault": "Label", - "isTranslated": true + "isTranslated": true, + "originalDefault": "Label" }, "PhXIai": { "defaultMessage": "Aantal jaren. Lege waarden worden genegeerd.", @@ -318,6 +383,11 @@ "description": "Invalid email address format validation error", "originalDefault": "{field} must be a valid email." }, + "QW32Dd": { + "defaultMessage": "Geef een bestandsgrootte groter dan nul zonder decimalen op, bijvoorbeeld '10MB'.", + "description": "File component 'fileMaxSize' validation error", + "originalDefault": "Specify a positive, non-zero file size without decimals, e.g. 10MB." + }, "Qjl92W": { "defaultMessage": "Voorbeeld", "description": "Component preview card title", @@ -333,11 +403,21 @@ "description": "Label identifier role main", "originalDefault": "Main" }, + "RxmRzd": { + "defaultMessage": "Indien ingeschakeld, gebruik dan de algemene instellingen voor toegestane bestandstypen.", + "description": "Tooltip for 'useConfigFiletypes' builder field", + "originalDefault": "When this is checked, the filetypes configured in the global settings will be used." + }, "SEXqhC": { "defaultMessage": "Validatiemethode", "description": "Label for 'date constraint mode' builder field", "originalDefault": "Mode preset" }, + "SEu4I2": { + "defaultMessage": "Titel", + "description": "Label for 'registration.titel' builder field", + "originalDefault": "Title" + }, "SH67gH": { "defaultMessage": "In de toekomst", "description": "Date constraint mode 'future' label", @@ -356,8 +436,8 @@ "UCGSib": { "defaultMessage": "Plugin(s)", "description": "Label for 'validate.plugins' builder field", - "originalDefault": "Plugin(s)", - "isTranslated": true + "isTranslated": true, + "originalDefault": "Plugin(s)" }, "UGLnLd": { "defaultMessage": "Selecteer de plugin(s) om te gebruiken voor validatie.", @@ -374,6 +454,11 @@ "description": "Label for 'derivePostcode' builder field", "originalDefault": "Postcode component" }, + "UxTJsK": { + "defaultMessage": "Bestand", + "description": "Component edit form tab title for 'File' tab", + "originalDefault": "File" + }, "V6ClU9": { "defaultMessage": "Indien de postcode en huisnummer ingevuld zijn, dan wordt automatisch de stad ingevuld.", "description": "Tooltip for 'deriveCity' builder field", @@ -419,6 +504,21 @@ "description": "Tooltip for 'prefill.identifierRole' builder field", "originalDefault": "In case that multiple identifiers are returned (in the case of eHerkenning bewindvoering and DigiD Machtigen), should the prefill data related to the main identifier be used, or that related to the authorised person?" }, + "XXRiTx": { + "defaultMessage": "Bestandsnaam", + "description": "file component preview: file name column header", + "originalDefault": "File name" + }, + "Y4oNhH": { + "defaultMessage": "Maximale breedte", + "description": "Label for 'of.image.resize.width' builder field", + "originalDefault": "Maximum width" + }, + "Y8cmP1": { + "defaultMessage": "Bestandsgrootte", + "description": "file component preview: file size column header", + "originalDefault": "Size" + }, "YBY95E": { "defaultMessage": "Handleiding", "description": "Link to manual title", @@ -434,6 +534,11 @@ "description": "Tooltip for 'prefill.plugin' builder field", "originalDefault": "Select the plugin to use for the prefill functionality." }, + "aBADYT": { + "defaultMessage": "Maximum aantal bestanden", + "description": "Label for 'maxNumberOfFiles' builder field", + "originalDefault": "Maximum number of files" + }, "asZV7t": { "defaultMessage": "Straatnaam afleiden", "description": "Label for 'deriveStreetName' builder field", @@ -477,8 +582,13 @@ "ce1N0I": { "defaultMessage": "Plugin", "description": "Label for 'prefill.plugin' builder field", - "originalDefault": "Plugin", - "isTranslated": true + "isTranslated": true, + "originalDefault": "Plugin" + }, + "ctoEdl": { + "defaultMessage": "Titel voor het document in de Documenten API.", + "description": "Tooltip for 'registration.titel' builder field", + "originalDefault": "The name under which the INFORMATIEOBJECT is formally known." }, "czUDSQ": { "defaultMessage": "Een korte beschrijvende indicatie die meer context beschrijft voor de verwachtte waarde. Je kan hier de '<sup> en <sub>' HTML tags gebruiken.", @@ -528,14 +638,24 @@ "fRMCJI": { "defaultMessage": "Prefill", "description": "Component edit form tab title for 'Prefill' tab", - "originalDefault": "Prefill", - "isTranslated": true + "isTranslated": true, + "originalDefault": "Prefill" }, "fT6Gtt": { "defaultMessage": "Sla de waarde op onder het geselecteerde attribuut in de geselecteerde registratieplugin.", "description": "Tooltip for 'registration.attribute' builder field", "originalDefault": "Save the value as this attribute in the registration backend system." }, + "fX5VwA": { + "defaultMessage": "De maximale bestandsgrootte voor één enkele bestandsupload.", + "description": "Tooltip for 'fileMaxSize' builder field", + "originalDefault": "The maximum size of a single file to upload." + }, + "fY/8xt": { + "defaultMessage": "Informatieobjecttype", + "description": "Label for 'registration.informatieobjecttype' builder field", + "originalDefault": "Information object type" + }, "h0B9Fr": { "defaultMessage": "De eigenschapsnaam mag alleen alfanumerieke tekens, onderstrepingstekens, punten en streepjes bevatten en mag niet worden afgesloten met een streepje of punt.", "description": "Form builder 'key' pattern validation error", @@ -546,6 +666,11 @@ "description": "Component 'Rich' preview mode", "originalDefault": "Form" }, + "hduHvs": { + "defaultMessage": "(optioneel)", + "description": "placeholder for 'file.name' builder field", + "originalDefault": "(optional)" + }, "iOADkJ": { "defaultMessage": "Weergeven in overzicht", "description": "Label for 'showInSummary' builder field", @@ -611,11 +736,22 @@ "description": "Tooltip for 'operator' in relative delta date constraint validation", "originalDefault": "Specify whether to add or subtract a time delta to/from the variable." }, + "n9T2Oy": { + "defaultMessage": "Wanneer dit is ingeschakeld, dan wordt het afbeeldingsformaat gewijzigd.", + "description": "Tooltip for 'of.image.resize.apply' builder field", + "originalDefault": "When this is checked, any uploaded image(s) will be resized." + }, + "nW5g1S": { + "defaultMessage": "Bronorganisatie", + "description": "Label for 'registration.bronorganisatie' builder field", + "isTranslated": true, + "originalDefault": "Bronorganisatie" + }, "nqJbi9": { "defaultMessage": "Tooltip", "description": "Component property 'Tooltip' label", - "originalDefault": "Tooltip", - "isTranslated": true + "isTranslated": true, + "originalDefault": "Tooltip" }, "oWJX5K": { "defaultMessage": "Suffix (bijv. m²)", @@ -667,6 +803,11 @@ "description": "Title of validation error translations panel", "originalDefault": "Custom error messages" }, + "vlY36U": { + "defaultMessage": "Vertrouwelijkheidaanduiding van het document in de Documenten API. Indien leeg, dan worden algemene instellingen gebruikt.", + "description": "Tooltip for 'registration.docVertrouwelijkheidaanduiding' builder field", + "originalDefault": "Indication of the level to which extent the INFORMATIEOBJECT is meant to be public." + }, "vzhWgR": { "defaultMessage": "Geef het attribuut op wat de prefill-gegevens bevat.", "description": "Tooltip for 'prefill.attribute' builder field", @@ -682,6 +823,11 @@ "description": "Label for 'operator' in relative delta date constraint validation", "originalDefault": "Add/subtract duration" }, + "xnRkUj": { + "defaultMessage": "Wijzig JSON", + "description": "Component 'editJSON' preview mode", + "originalDefault": "Edit JSON" + }, "yD4X2y": { "defaultMessage": "Maximale lengte", "description": "Label for 'validate.maxLength' builder field", @@ -692,9 +838,19 @@ "description": "Translations: translation column header", "originalDefault": "Translations" }, + "yQUV3M": { + "defaultMessage": "RSIN van de organisatie die document registreert in de Documenten API. Indien leeg, dan worden algemene instellingen gebruikt.", + "description": "Tooltip for 'registration.bronorganisatie' builder field", + "originalDefault": "RSIN of the organization which creates the ENKELVOUDIGINFORMATIEOBJECT." + }, "yQtFln": { "defaultMessage": "Verberg een veld in het formulier.", "description": "Tooltip for 'Hidden' builder field", "originalDefault": "Hide a field from the form." + }, + "yujuQr": { + "defaultMessage": "Maximale hoogte", + "description": "Label for 'of.image.resize.height' builder field", + "originalDefault": "Maximum height" } }