Skip to content

Commit 18298a7

Browse files
Merge pull request #199 from open-formulieren/feature/99-file-upload-leftovers
Add the leftovers for the file component
2 parents 6ab9358 + 89dd371 commit 18298a7

File tree

8 files changed

+408
-193
lines changed

8 files changed

+408
-193
lines changed

src/registry/file/File.tsx

Lines changed: 194 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,194 @@
1+
import type {FileComponentSchema, FileUploadData} from '@open-formulieren/types';
2+
import {FormField} from '@utrecht/component-library-react';
3+
import {FieldArray, useFormikContext} from 'formik';
4+
import type {ArrayHelpers, FormikErrors} from 'formik';
5+
import {useId} from 'react';
6+
7+
import HelpText from '@/components/forms/HelpText';
8+
import Label from '@/components/forms/Label';
9+
import Tooltip from '@/components/forms/Tooltip';
10+
import ValidationErrors from '@/components/forms/ValidationErrors';
11+
12+
import './File.scss';
13+
import UploadInput from './UploadInput';
14+
import UploadedFileList from './UploadedFileList';
15+
import type {FileMeta} from './UploadedFileList';
16+
import {useFileUploads} from './hooks';
17+
import type {FormikFileUpload} from './types';
18+
import {getSizeInBytes} from './utils';
19+
20+
export interface FormioFileProps {
21+
componentDefinition: FileComponentSchema;
22+
}
23+
24+
const FormioFile: React.FC<FormioFileProps> = ({componentDefinition}) => {
25+
const {key: name} = componentDefinition;
26+
return (
27+
<FieldArray name={name} validateOnChange={false}>
28+
{arrayHelpers => (
29+
<Inner componentDefinition={componentDefinition} arrayHelpers={arrayHelpers} />
30+
)}
31+
</FieldArray>
32+
);
33+
};
34+
35+
interface InnerProps {
36+
componentDefinition: FileComponentSchema;
37+
arrayHelpers: ArrayHelpers<FormikFileUpload[]>;
38+
}
39+
40+
/**
41+
* Child/body for `FieldArray` wrapper so we can use hooks.
42+
*/
43+
const Inner: React.FC<InnerProps> = ({componentDefinition, arrayHelpers}) => {
44+
const {
45+
key: name,
46+
label,
47+
description,
48+
tooltip,
49+
multiple,
50+
maxNumberOfFiles,
51+
fileMaxSize,
52+
file: {type},
53+
validate = {},
54+
} = componentDefinition;
55+
const {required: isRequired = false} = validate;
56+
const {getFieldProps, getFieldMeta, getFieldHelpers} = useFormikContext();
57+
const id = useId();
58+
59+
const {value: uploads = []} = getFieldProps<FormikFileUpload[]>(name);
60+
const {touched, error: formikError} = getFieldMeta<FormikFileUpload[]>(name);
61+
const {setTouched} = getFieldHelpers<FormikFileUpload[]>(name);
62+
63+
const {onFilesAdded, onFileRemove, localUploadErrors} = useFileUploads(
64+
name,
65+
componentDefinition,
66+
arrayHelpers
67+
);
68+
69+
// We can have individual file errors (because the intrinsic value type of the
70+
// component is FileUploadData[]), a string error for the component as a whole or even
71+
// deeply nested errors for a file in value array. The types from Formik are very wrong,
72+
// which is why we need the cast. We also can't use the useFieldError hook because it's
73+
// meant for components with a single/multiple mode, but file components *always* have an
74+
// array of file uploads, even if multiple files are not allowed.
75+
const error = formikError as unknown as
76+
| undefined
77+
| string
78+
| (string | FormikErrors<FileUploadData>)[];
79+
80+
// field-level error or individual file errors
81+
const fieldError = typeof error === 'string' && error;
82+
const fileErrors = (Array.isArray(error) && error) || [];
83+
84+
const invalid =
85+
touched && Boolean(fieldError || fileErrors.length || Object.keys(localUploadErrors).length);
86+
const errorMessageId = fieldError ? `${id}-error-message` : undefined;
87+
88+
const maxFilesToSelect = maxNumberOfFiles
89+
? Math.max(maxNumberOfFiles - uploads.length, 0)
90+
: undefined;
91+
92+
return (
93+
<FormField type="file" invalid={invalid} className="utrecht-form-field--openforms">
94+
<Label
95+
id={id}
96+
isRequired={isRequired}
97+
tooltip={tooltip ? <Tooltip>{tooltip}</Tooltip> : undefined}
98+
>
99+
{label}
100+
</Label>
101+
102+
<div className="openforms-file-upload">
103+
<div className="openforms-file-upload__description">
104+
<HelpText>{description}</HelpText>
105+
</div>
106+
107+
{(!uploads.length || multiple) && (
108+
<UploadInput
109+
inputId={id}
110+
onFilesAdded={onFilesAdded}
111+
aria-describedby={errorMessageId}
112+
maxSize={fileMaxSize ? getSizeInBytes(fileMaxSize) : undefined}
113+
multiple={multiple && (maxFilesToSelect === undefined || maxFilesToSelect > 1)}
114+
maxFiles={maxNumberOfFiles ?? undefined}
115+
maxFilesToSelect={maxFilesToSelect}
116+
accept={Object.fromEntries(type.map(mimeType => [mimeType, [] satisfies string[]]))}
117+
onBlur={() => {
118+
if (!touched) setTouched(true);
119+
}}
120+
/>
121+
)}
122+
123+
{!!uploads.length && (
124+
<div className="openforms-file-upload__uploads">
125+
<UploadedFileList
126+
multipleAllowed={multiple}
127+
files={uploads.map((upload, index) => {
128+
const localUploadError = upload.clientId
129+
? localUploadErrors?.[upload.clientId]
130+
: undefined;
131+
return formikFileUploadToFileMeta(upload, fileErrors[index], localUploadError);
132+
})}
133+
onRemove={onFileRemove}
134+
/>
135+
</div>
136+
)}
137+
138+
{fieldError && errorMessageId && (
139+
<div className="openforms-file-upload__errors">
140+
<ValidationErrors error={fieldError} id={errorMessageId} />
141+
</div>
142+
)}
143+
</div>
144+
</FormField>
145+
);
146+
};
147+
148+
const formikFileUploadToFileMeta = (
149+
upload: FormikFileUpload,
150+
/**
151+
* An error specific to this file. It can be a string if it's about the upload as a
152+
* whole, can be `undefined` in case there are no errors, or could be a nested object
153+
* with errors for the individual file properties, produced by the Zod schema.
154+
*/
155+
fileErrors: string | FormikErrors<FormikFileUpload> | undefined,
156+
/**
157+
* Possible local error from the `UploadInput` component, not present in the Formik
158+
* error state.
159+
*
160+
* @see `./hooks.ts` for why these can exist.
161+
*/
162+
localError: string | undefined
163+
): FileMeta => {
164+
const {clientId, url, originalName, size, state} = upload;
165+
166+
let errors: string[] = [];
167+
168+
if (typeof fileErrors === 'string') {
169+
errors = [fileErrors];
170+
} else if (fileErrors) {
171+
// for nested property errors, just lift them up to the file level since we don't
172+
// have input fields for these sub-properties anyway.
173+
for (const err of Object.values(fileErrors)) {
174+
if (typeof err !== 'string') continue;
175+
errors.push(err);
176+
}
177+
}
178+
// check if we have any local drag & drop error state, which trumps
179+
// schema errors
180+
if (clientId && localError) errors = [localError];
181+
182+
return {
183+
// fall back to the URL for persisted uploads - these don't have a clientId
184+
uniqueId: clientId || url,
185+
name: originalName,
186+
downloadUrl: url,
187+
size,
188+
// the default success state applies to persisted uploads
189+
state: state ?? 'success',
190+
errors: errors,
191+
};
192+
};
193+
194+
export default FormioFile;
Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
@use '@utrecht/components/ordered-list';

src/registry/file/ValueDisplay.tsx

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,33 @@
1+
import type {FileComponentSchema} from '@open-formulieren/types';
2+
import {OrderedList, OrderedListItem} from '@utrecht/component-library-react';
3+
4+
import './ValueDisplay.scss';
5+
import type {FormikFileUpload} from './types';
6+
7+
export interface ValueDisplayProps {
8+
componentDefinition: FileComponentSchema;
9+
value: FormikFileUpload[] | undefined;
10+
}
11+
12+
const ValueDisplay: React.FC<ValueDisplayProps> = ({
13+
componentDefinition: {multiple = false},
14+
value,
15+
}) => {
16+
const normalizedValue = value ?? [];
17+
if (!normalizedValue.length) return '';
18+
19+
// if only a single upload is allowed, there are 0 or 1 uploads. Grab the first.
20+
if (!multiple) {
21+
return normalizedValue[0].originalName;
22+
}
23+
24+
return (
25+
<OrderedList>
26+
{normalizedValue.map(item => (
27+
<OrderedListItem key={`${item.clientId || item.url}`}>{item.originalName}</OrderedListItem>
28+
))}
29+
</OrderedList>
30+
);
31+
};
32+
33+
export default ValueDisplay;

src/registry/file/empty.spec.ts

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,30 @@
1+
import type {FileComponentSchema} from '@open-formulieren/types';
2+
import {expect, test} from 'vitest';
3+
4+
import isEmpty from './empty';
5+
import {FILE_COMPONENT_BOILERPLATE, buildFile, getFileConfiguration} from './test-utils';
6+
import type {FormikFileUpload} from './types';
7+
8+
test.each([
9+
// Empty states
10+
[undefined, true],
11+
[[], true],
12+
// Non-empty states
13+
[[{}], false],
14+
[[buildFile({name: 'not-empty.pdf', type: 'application/pdf', size: 1, state: undefined})], false],
15+
])(
16+
'file isEmpty compares against defined, non-empty array state of value',
17+
(valueToTest: FormikFileUpload[] | undefined, expected: boolean) => {
18+
const component: FileComponentSchema = {
19+
...FILE_COMPONENT_BOILERPLATE,
20+
...getFileConfiguration(['application/pdf']),
21+
id: 'component1',
22+
type: 'file',
23+
key: 'attachments',
24+
label: 'Attachments',
25+
};
26+
27+
const result = isEmpty(component, valueToTest);
28+
expect(result).toBe(expected);
29+
}
30+
);

src/registry/file/empty.ts

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
1+
import type {FileComponentSchema} from '@open-formulieren/types';
2+
3+
import type {IsEmpty} from '@/registry/types';
4+
5+
import type {FormikFileUpload} from './types';
6+
7+
const isEmpty: IsEmpty<FileComponentSchema, Partial<FormikFileUpload>[]> = (
8+
_componentDefinition,
9+
value
10+
) => {
11+
// Similar to Formio generic isEmpty https://github.com/formio/formio.js/blob/29939fc9d66f2b95527c90d3cf7729570c3d3010/src/components/_classes/component/Component.js#L3757
12+
// The value of a file component must be an array with 1 or more entries.
13+
const hasValue = Array.isArray(value) && value.length >= 1;
14+
return !hasValue;
15+
};
16+
17+
export default isEmpty;

0 commit comments

Comments
 (0)