@verbb/formie-browser 1.0.5 → 1.0.6
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +14 -0
- package/dist/chunks/{calculations-CgVJcQ2P.js → calculations-CeDgB9jt.js} +13 -13
- package/dist/chunks/{conditions-CZ3FU5QO.js → conditions-DjZOtHfS.js} +3 -3
- package/dist/chunks/dist-BQ4a9hmy.js +2659 -0
- package/dist/chunks/rich-text-D0sb8bmX.js +349 -0
- package/dist/chunks/{signature-CpgqHCEx.js → signature-xz4QO7CR.js} +107 -34
- package/dist/chunks/{text-limit-B09elPDB.js → text-limit-DbgEFHLO.js} +1 -1
- package/dist/chunks/upload-manager-BpqWSHmU.js +2558 -0
- package/dist/css/theme/_buttons.css +16 -0
- package/dist/css/theme/_tokens.css +2 -0
- package/dist/css/theme/fields/_signature.css +19 -0
- package/dist/css/theme/fields/_upload-manager.css +279 -0
- package/dist/index.js +6 -5
- package/dist/js/modules/fields/index.d.ts.map +1 -1
- package/dist/js/modules/fields/signature.d.ts.map +1 -1
- package/dist/js/modules/fields/upload-manager.d.ts +3 -0
- package/dist/js/modules/fields/upload-manager.d.ts.map +1 -0
- package/package.json +4 -2
- package/src/css/theme/_buttons.css +16 -0
- package/src/css/theme/_tokens.css +2 -0
- package/src/css/theme/fields/_signature.css +19 -0
- package/src/css/theme/fields/_upload-manager.css +279 -0
- package/src/js/modules/fields/index.ts +1 -0
- package/src/js/modules/fields/signature.ts +199 -15
- package/src/js/modules/fields/upload-manager.ts +1277 -0
- package/src/vendor.d.ts +1 -1
- package/dist/chunks/dist-BF5GU3_9.js +0 -2674
- package/dist/chunks/rich-text-Cv8ADyk-.js +0 -349
|
@@ -0,0 +1,1277 @@
|
|
|
1
|
+
import Uppy from '@uppy/core';
|
|
2
|
+
import XHRUpload from '@uppy/xhr-upload';
|
|
3
|
+
import uploadManagerCss from '#theme-css/fields/_upload-manager.css?inline';
|
|
4
|
+
|
|
5
|
+
import type { FormieModuleDefinition } from '#contracts/modules';
|
|
6
|
+
import { dispatchFieldEvent, releaseFormValidators, retainFormValidators } from '#modules/fields/shared';
|
|
7
|
+
import { ensureModuleStyles } from '#modules/styles';
|
|
8
|
+
import { getFieldModuleEventName, getFormStateEventName } from '#utils/event-names';
|
|
9
|
+
import { requestJson } from '#utils/http';
|
|
10
|
+
import { createDebug } from '#utils/debug';
|
|
11
|
+
|
|
12
|
+
const ROOT_SELECTOR = '[data-formie-upload-manager-root]';
|
|
13
|
+
const DROPZONE_SELECTOR = '[data-formie-upload-manager]';
|
|
14
|
+
const BROWSE_BUTTON_SELECTOR = '[data-formie-upload-manager-browse]';
|
|
15
|
+
const BROWSE_INPUT_SELECTOR = '[data-formie-upload-manager-input]';
|
|
16
|
+
const STATUS_INPUT_SELECTOR = '[data-formie-upload-manager-status]';
|
|
17
|
+
const FILE_LIST_SELECTOR = '[data-formie-upload-manager-list]';
|
|
18
|
+
const SORT_CONTROLS_SELECTOR = '[data-formie-upload-manager-sort-controls]';
|
|
19
|
+
const SORT_UP_SELECTOR = '[data-formie-upload-manager-sort="up"]';
|
|
20
|
+
const SORT_DOWN_SELECTOR = '[data-formie-upload-manager-sort="down"]';
|
|
21
|
+
const HIDDEN_INPUT_ANCHOR_ATTR = 'data-formie-file-upload-anchor';
|
|
22
|
+
const HIDDEN_INPUT_VALUE_ATTR = 'data-formie-file-upload-asset-id';
|
|
23
|
+
const FORM_RESET_EVENT = getFormStateEventName('reset');
|
|
24
|
+
const REPEATER_INIT_ROW_EVENT = getFieldModuleEventName('repeater', 'init-row');
|
|
25
|
+
const MODULE_ID = 'upload-manager';
|
|
26
|
+
const VALIDATOR_SCOPE = 'upload-manager';
|
|
27
|
+
const UPLOAD_VALIDATORS = ['uploadManagerRequired', 'uploadManagerFileLimit'] as const;
|
|
28
|
+
const UPLOAD_COMPLETE_DISPLAY_MS = 900;
|
|
29
|
+
const UPLOAD_TRANSFER_MAX = 95;
|
|
30
|
+
const UPLOAD_XHR_TIMEOUT_MS = 120_000;
|
|
31
|
+
|
|
32
|
+
const debug = createDebug('fields', 'upload-manager');
|
|
33
|
+
|
|
34
|
+
ensureModuleStyles(MODULE_ID, [uploadManagerCss]);
|
|
35
|
+
|
|
36
|
+
type UploadManagerOptions = {
|
|
37
|
+
uploadEndpoint?: string;
|
|
38
|
+
deleteEndpoint?: string;
|
|
39
|
+
hydrateEndpoint?: string;
|
|
40
|
+
limitFiles?: number | null;
|
|
41
|
+
sizeMinLimit?: number | null;
|
|
42
|
+
sizeLimit?: number | null;
|
|
43
|
+
accept?: string | null;
|
|
44
|
+
restrictFiles?: boolean;
|
|
45
|
+
};
|
|
46
|
+
|
|
47
|
+
type UploadResponse = {
|
|
48
|
+
success?: boolean;
|
|
49
|
+
assetId?: number;
|
|
50
|
+
filename?: string;
|
|
51
|
+
url?: string | null;
|
|
52
|
+
errors?: Record<string, string[]>;
|
|
53
|
+
};
|
|
54
|
+
|
|
55
|
+
type DeleteResponse = {
|
|
56
|
+
success?: boolean;
|
|
57
|
+
};
|
|
58
|
+
|
|
59
|
+
type HydrateResponse = {
|
|
60
|
+
success?: boolean;
|
|
61
|
+
assets?: Array<{
|
|
62
|
+
assetId?: number;
|
|
63
|
+
filename?: string;
|
|
64
|
+
url?: string | null;
|
|
65
|
+
}>;
|
|
66
|
+
};
|
|
67
|
+
|
|
68
|
+
type ManagedFile = {
|
|
69
|
+
assetId: number | null;
|
|
70
|
+
filename: string;
|
|
71
|
+
uppyFileId: string | null;
|
|
72
|
+
listItem: HTMLElement;
|
|
73
|
+
};
|
|
74
|
+
|
|
75
|
+
type UploadManagerState = {
|
|
76
|
+
field: HTMLElement;
|
|
77
|
+
dropzone: HTMLElement;
|
|
78
|
+
browseInput: HTMLInputElement;
|
|
79
|
+
statusInput: HTMLInputElement | null;
|
|
80
|
+
fileList: HTMLElement;
|
|
81
|
+
anchorInput: HTMLInputElement;
|
|
82
|
+
assetInputName: string;
|
|
83
|
+
uppy: Uppy;
|
|
84
|
+
files: ManagedFile[];
|
|
85
|
+
};
|
|
86
|
+
|
|
87
|
+
function isRecord(value: unknown): value is Record<string, unknown> {
|
|
88
|
+
return !!value && typeof value === 'object' && !Array.isArray(value);
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
function toPositiveInt(value: unknown): number | null {
|
|
92
|
+
const parsed = Number(value);
|
|
93
|
+
return Number.isInteger(parsed) && parsed > 0 ? parsed : null;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
function toTrimmedString(value: unknown): string {
|
|
97
|
+
return typeof value === 'string' ? value.trim() : '';
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
function getFieldHandle(field: HTMLElement): string {
|
|
101
|
+
return field.getAttribute('data-formie-field-handle')?.trim() || '';
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
function getFormHandle(form: HTMLFormElement | null): string {
|
|
105
|
+
if (!form) {
|
|
106
|
+
return '';
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
const handleInput = form.querySelector('input[name="handle"]');
|
|
110
|
+
|
|
111
|
+
if (handleInput instanceof HTMLInputElement && handleInput.value.trim()) {
|
|
112
|
+
return handleInput.value.trim();
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
return form.getAttribute('data-formie-handle')?.trim() || '';
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
function getHiddenInputs(field: HTMLElement): HTMLInputElement[] {
|
|
119
|
+
return Array.from(field.querySelectorAll('input[type="hidden"]')).filter((input): input is HTMLInputElement => {
|
|
120
|
+
return input instanceof HTMLInputElement;
|
|
121
|
+
});
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
function getAnchorInput(field: HTMLElement, assetInputName: string): HTMLInputElement {
|
|
125
|
+
const existingAnchor = getHiddenInputs(field).find((hiddenInput) => {
|
|
126
|
+
return hiddenInput.hasAttribute(HIDDEN_INPUT_ANCHOR_ATTR)
|
|
127
|
+
|| (hiddenInput.name === assetInputName.replace('[]', '') && hiddenInput.value === '');
|
|
128
|
+
});
|
|
129
|
+
|
|
130
|
+
if (existingAnchor) {
|
|
131
|
+
existingAnchor.setAttribute(HIDDEN_INPUT_ANCHOR_ATTR, 'true');
|
|
132
|
+
return existingAnchor;
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
const anchor = document.createElement('input');
|
|
136
|
+
anchor.type = 'hidden';
|
|
137
|
+
anchor.name = assetInputName.replace('[]', '');
|
|
138
|
+
anchor.value = '';
|
|
139
|
+
anchor.setAttribute(HIDDEN_INPUT_ANCHOR_ATTR, 'true');
|
|
140
|
+
field.prepend(anchor);
|
|
141
|
+
|
|
142
|
+
return anchor;
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
function getUploadedAssetInputs(field: HTMLElement, assetInputName: string): HTMLInputElement[] {
|
|
146
|
+
return getHiddenInputs(field).filter((hiddenInput) => {
|
|
147
|
+
return hiddenInput.name === assetInputName && hiddenInput.value.trim() !== '';
|
|
148
|
+
});
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
function readAssetIdsFromDom(field: HTMLElement, assetInputName: string): number[] {
|
|
152
|
+
return getUploadedAssetInputs(field, assetInputName).map((input) => {
|
|
153
|
+
return toPositiveInt(input.value);
|
|
154
|
+
}).filter((assetId): assetId is number => {
|
|
155
|
+
return assetId !== null;
|
|
156
|
+
});
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
function syncHiddenAssetInputs(field: HTMLElement, anchorInput: HTMLInputElement, assetInputName: string, assetIds: number[]): void {
|
|
160
|
+
let insertionPoint: HTMLInputElement = anchorInput;
|
|
161
|
+
|
|
162
|
+
getUploadedAssetInputs(field, assetInputName).forEach((hiddenInput) => {
|
|
163
|
+
hiddenInput.remove();
|
|
164
|
+
});
|
|
165
|
+
|
|
166
|
+
assetIds.forEach((assetId) => {
|
|
167
|
+
const hiddenInput = document.createElement('input');
|
|
168
|
+
hiddenInput.type = 'hidden';
|
|
169
|
+
hiddenInput.name = assetInputName;
|
|
170
|
+
hiddenInput.value = String(assetId);
|
|
171
|
+
hiddenInput.setAttribute(HIDDEN_INPUT_VALUE_ATTR, 'true');
|
|
172
|
+
insertionPoint.insertAdjacentElement('afterend', hiddenInput);
|
|
173
|
+
insertionPoint = hiddenInput;
|
|
174
|
+
});
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
function getUploadContext(form: HTMLFormElement | null, field: HTMLElement, dropzone: HTMLElement) {
|
|
178
|
+
const inputKey = dropzone.getAttribute('data-formie-upload-key')?.trim()
|
|
179
|
+
|| dropzone.getAttribute('data-formie-input-id')?.trim()
|
|
180
|
+
|| '';
|
|
181
|
+
|
|
182
|
+
const context: Record<string, string> = {
|
|
183
|
+
handle: getFormHandle(form),
|
|
184
|
+
fieldHandle: getFieldHandle(field),
|
|
185
|
+
inputKey,
|
|
186
|
+
};
|
|
187
|
+
|
|
188
|
+
if (!form) {
|
|
189
|
+
return context;
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
const passthroughNames = ['renderId', 'draftContextToken', 'draftContext', 'submissionId'] as const;
|
|
193
|
+
|
|
194
|
+
passthroughNames.forEach((name) => {
|
|
195
|
+
const input = form.querySelector(`input[name="${name}"]`);
|
|
196
|
+
|
|
197
|
+
if (input instanceof HTMLInputElement && input.value.trim()) {
|
|
198
|
+
context[name] = input.value.trim();
|
|
199
|
+
}
|
|
200
|
+
});
|
|
201
|
+
|
|
202
|
+
const csrfInput = form.querySelector('input[name="CRAFT_CSRF_TOKEN"]');
|
|
203
|
+
|
|
204
|
+
if (csrfInput instanceof HTMLInputElement && csrfInput.value.trim()) {
|
|
205
|
+
context.CRAFT_CSRF_TOKEN = csrfInput.value.trim();
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
return context;
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
function buildHydrateFormData(form: HTMLFormElement | null, field: HTMLElement, assetIds: number[]): FormData {
|
|
212
|
+
const body = new FormData();
|
|
213
|
+
const handle = getFormHandle(form);
|
|
214
|
+
const fieldHandle = getFieldHandle(field);
|
|
215
|
+
|
|
216
|
+
if (handle) {
|
|
217
|
+
body.append('handle', handle);
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
if (fieldHandle) {
|
|
221
|
+
body.append('fieldHandle', fieldHandle);
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
const submissionUidInput = form?.querySelector('input[name="submissionUid"]');
|
|
225
|
+
|
|
226
|
+
if (submissionUidInput instanceof HTMLInputElement && submissionUidInput.value.trim()) {
|
|
227
|
+
body.append('submissionUid', submissionUidInput.value.trim());
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
assetIds.forEach((assetId) => {
|
|
231
|
+
body.append('assetIds[]', String(assetId));
|
|
232
|
+
});
|
|
233
|
+
|
|
234
|
+
return body;
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
function parseAcceptTypes(accept: string | null | undefined): string[] | null {
|
|
238
|
+
if (!accept) {
|
|
239
|
+
return null;
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
const types = accept.split(',').map((entry) => {
|
|
243
|
+
return entry.trim();
|
|
244
|
+
}).filter(Boolean);
|
|
245
|
+
|
|
246
|
+
return types.length ? types : null;
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
function getEndpoint(dropzone: HTMLElement, optionValue: string | undefined, attribute: string, fallback: string): string {
|
|
250
|
+
return optionValue?.trim()
|
|
251
|
+
|| dropzone.getAttribute(attribute)?.trim()
|
|
252
|
+
|| fallback;
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
function getProgressElements(listItem: HTMLElement): {
|
|
256
|
+
track: HTMLElement | null;
|
|
257
|
+
bar: HTMLElement | null;
|
|
258
|
+
label: HTMLElement | null;
|
|
259
|
+
} {
|
|
260
|
+
const progressRoot = listItem.querySelector('[data-formie-upload-manager-progress]');
|
|
261
|
+
|
|
262
|
+
if (!(progressRoot instanceof HTMLElement)) {
|
|
263
|
+
return { track: null, bar: null, label: null };
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
const track = progressRoot.querySelector('[data-formie-upload-manager-progress-track]');
|
|
267
|
+
const bar = progressRoot.querySelector('[data-formie-upload-manager-progress-bar]');
|
|
268
|
+
const label = progressRoot.querySelector('[data-formie-upload-manager-progress-label]');
|
|
269
|
+
|
|
270
|
+
return {
|
|
271
|
+
track: track instanceof HTMLElement ? track : null,
|
|
272
|
+
bar: bar instanceof HTMLElement ? bar : null,
|
|
273
|
+
label: label instanceof HTMLElement ? label : null,
|
|
274
|
+
};
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
function setIndeterminateUploadProgress(
|
|
278
|
+
listItem: HTMLElement,
|
|
279
|
+
labelText: string,
|
|
280
|
+
phase: 'preparing' | 'uploading' | 'processing' | 'retrying' = 'uploading',
|
|
281
|
+
): void {
|
|
282
|
+
const { track, bar, label } = getProgressElements(listItem);
|
|
283
|
+
|
|
284
|
+
listItem.classList.add('is-uploading');
|
|
285
|
+
listItem.classList.remove('is-upload-complete', 'is-complete', 'is-processing', 'is-preparing');
|
|
286
|
+
|
|
287
|
+
if (phase === 'processing') {
|
|
288
|
+
listItem.classList.add('is-processing');
|
|
289
|
+
} else if (phase === 'preparing') {
|
|
290
|
+
listItem.classList.add('is-preparing');
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
if (track) {
|
|
294
|
+
track.setAttribute('data-indeterminate', 'true');
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
if (bar) {
|
|
298
|
+
bar.style.removeProperty('width');
|
|
299
|
+
bar.setAttribute('data-progress', phase);
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
if (label) {
|
|
303
|
+
label.textContent = labelText;
|
|
304
|
+
}
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
function setUploadProgress(listItem: HTMLElement, percent: number, labelText?: string): void {
|
|
308
|
+
const { track, bar, label } = getProgressElements(listItem);
|
|
309
|
+
const clamped = Math.max(0, Math.min(UPLOAD_TRANSFER_MAX, Math.round(percent)));
|
|
310
|
+
|
|
311
|
+
listItem.classList.add('is-uploading');
|
|
312
|
+
listItem.classList.remove('is-upload-complete', 'is-complete', 'is-processing', 'is-preparing');
|
|
313
|
+
|
|
314
|
+
if (track) {
|
|
315
|
+
track.setAttribute('data-indeterminate', 'false');
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
if (bar) {
|
|
319
|
+
bar.style.width = `${clamped}%`;
|
|
320
|
+
bar.setAttribute('data-progress', String(clamped));
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
if (label) {
|
|
324
|
+
if (labelText) {
|
|
325
|
+
label.textContent = labelText;
|
|
326
|
+
} else if (clamped > 0) {
|
|
327
|
+
label.textContent = `Uploading… ${clamped}%`;
|
|
328
|
+
} else {
|
|
329
|
+
label.textContent = 'Uploading…';
|
|
330
|
+
}
|
|
331
|
+
}
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
function applyUploadTransferProgress(
|
|
335
|
+
listItem: HTMLElement,
|
|
336
|
+
rawPercent: number,
|
|
337
|
+
options: { loaded?: number; total?: number } = {},
|
|
338
|
+
): void {
|
|
339
|
+
const { loaded = 0, total = 0 } = options;
|
|
340
|
+
const bytesSent = total > 0 ? loaded >= total : rawPercent >= 100;
|
|
341
|
+
|
|
342
|
+
if (bytesSent || rawPercent >= 100) {
|
|
343
|
+
setIndeterminateUploadProgress(listItem, 'Processing…', 'processing');
|
|
344
|
+
return;
|
|
345
|
+
}
|
|
346
|
+
|
|
347
|
+
if (rawPercent <= 0 && loaded <= 0) {
|
|
348
|
+
setIndeterminateUploadProgress(listItem, 'Uploading…', 'uploading');
|
|
349
|
+
return;
|
|
350
|
+
}
|
|
351
|
+
|
|
352
|
+
const displayPercent = Math.min(
|
|
353
|
+
UPLOAD_TRANSFER_MAX,
|
|
354
|
+
Math.max(1, Math.round(rawPercent)),
|
|
355
|
+
);
|
|
356
|
+
|
|
357
|
+
setUploadProgress(
|
|
358
|
+
listItem,
|
|
359
|
+
displayPercent,
|
|
360
|
+
`Uploading… ${displayPercent}%`,
|
|
361
|
+
);
|
|
362
|
+
}
|
|
363
|
+
|
|
364
|
+
function parseUploadErrorMessage(response: unknown, error: unknown): string {
|
|
365
|
+
if (response instanceof XMLHttpRequest && response.responseText) {
|
|
366
|
+
try {
|
|
367
|
+
const body = JSON.parse(response.responseText) as {
|
|
368
|
+
message?: string;
|
|
369
|
+
errors?: Record<string, string[]>;
|
|
370
|
+
};
|
|
371
|
+
|
|
372
|
+
if (body.message) {
|
|
373
|
+
return body.message;
|
|
374
|
+
}
|
|
375
|
+
|
|
376
|
+
if (body.errors) {
|
|
377
|
+
const firstError = Object.values(body.errors).flat().find(Boolean);
|
|
378
|
+
|
|
379
|
+
if (firstError) {
|
|
380
|
+
return firstError;
|
|
381
|
+
}
|
|
382
|
+
}
|
|
383
|
+
} catch {
|
|
384
|
+
// Fall through to generic messaging.
|
|
385
|
+
}
|
|
386
|
+
}
|
|
387
|
+
|
|
388
|
+
if (error instanceof Error && error.message) {
|
|
389
|
+
return error.message;
|
|
390
|
+
}
|
|
391
|
+
|
|
392
|
+
return 'Upload failed.';
|
|
393
|
+
}
|
|
394
|
+
|
|
395
|
+
function shouldRetryUpload(xhr: XMLHttpRequest): boolean {
|
|
396
|
+
if (xhr.status === 0) {
|
|
397
|
+
return true;
|
|
398
|
+
}
|
|
399
|
+
|
|
400
|
+
return xhr.status >= 500;
|
|
401
|
+
}
|
|
402
|
+
|
|
403
|
+
function markUploadComplete(listItem: HTMLElement): void {
|
|
404
|
+
const { track, bar, label } = getProgressElements(listItem);
|
|
405
|
+
|
|
406
|
+
listItem.classList.add('is-uploading', 'is-upload-complete');
|
|
407
|
+
listItem.classList.remove('is-complete', 'is-processing', 'is-preparing');
|
|
408
|
+
|
|
409
|
+
if (track) {
|
|
410
|
+
track.setAttribute('data-indeterminate', 'false');
|
|
411
|
+
}
|
|
412
|
+
|
|
413
|
+
if (bar) {
|
|
414
|
+
bar.style.width = '100%';
|
|
415
|
+
bar.setAttribute('data-progress', '100');
|
|
416
|
+
}
|
|
417
|
+
|
|
418
|
+
if (label) {
|
|
419
|
+
label.textContent = 'Complete';
|
|
420
|
+
}
|
|
421
|
+
|
|
422
|
+
window.setTimeout(() => {
|
|
423
|
+
listItem.classList.add('is-complete');
|
|
424
|
+
listItem.classList.remove('is-uploading', 'is-upload-complete');
|
|
425
|
+
}, UPLOAD_COMPLETE_DISPLAY_MS);
|
|
426
|
+
}
|
|
427
|
+
|
|
428
|
+
function createSortButton(direction: 'up' | 'down'): HTMLButtonElement {
|
|
429
|
+
const label = direction === 'up' ? 'Move file up' : 'Move file down';
|
|
430
|
+
const button = document.createElement('button');
|
|
431
|
+
|
|
432
|
+
button.type = 'button';
|
|
433
|
+
button.className = 'formie-upload-manager-sort-button';
|
|
434
|
+
button.setAttribute('data-formie-upload-manager-sort', direction);
|
|
435
|
+
button.setAttribute('data-formie-icon', direction === 'up' ? 'arrow-up' : 'arrow-down');
|
|
436
|
+
button.setAttribute('aria-label', label);
|
|
437
|
+
button.setAttribute('title', label);
|
|
438
|
+
button.textContent = label;
|
|
439
|
+
|
|
440
|
+
return button;
|
|
441
|
+
}
|
|
442
|
+
|
|
443
|
+
function createListItem(field: HTMLElement, filename: string): {
|
|
444
|
+
listItem: HTMLElement;
|
|
445
|
+
filenameEl: HTMLElement;
|
|
446
|
+
errorEl: HTMLElement;
|
|
447
|
+
removeButton: HTMLButtonElement;
|
|
448
|
+
sortUpButton: HTMLButtonElement;
|
|
449
|
+
sortDownButton: HTMLButtonElement;
|
|
450
|
+
} {
|
|
451
|
+
const listItem = document.createElement('li');
|
|
452
|
+
listItem.className = 'formie-upload-manager-item';
|
|
453
|
+
listItem.setAttribute('data-formie-upload-manager-item', 'true');
|
|
454
|
+
|
|
455
|
+
const filenameEl = document.createElement('span');
|
|
456
|
+
filenameEl.className = 'formie-upload-manager-filename';
|
|
457
|
+
filenameEl.setAttribute('data-formie-upload-manager-filename', 'true');
|
|
458
|
+
filenameEl.textContent = filename;
|
|
459
|
+
|
|
460
|
+
const progressRoot = document.createElement('div');
|
|
461
|
+
progressRoot.className = 'formie-upload-manager-progress';
|
|
462
|
+
progressRoot.setAttribute('data-formie-upload-manager-progress', 'true');
|
|
463
|
+
progressRoot.setAttribute('aria-live', 'polite');
|
|
464
|
+
|
|
465
|
+
const progressTrack = document.createElement('div');
|
|
466
|
+
progressTrack.className = 'formie-upload-manager-progress-track';
|
|
467
|
+
progressTrack.setAttribute('data-formie-upload-manager-progress-track', 'true');
|
|
468
|
+
progressTrack.setAttribute('data-indeterminate', 'true');
|
|
469
|
+
|
|
470
|
+
const progressBar = document.createElement('div');
|
|
471
|
+
progressBar.className = 'formie-upload-manager-progress-bar';
|
|
472
|
+
progressBar.setAttribute('data-formie-upload-manager-progress-bar', 'true');
|
|
473
|
+
progressBar.setAttribute('data-progress', '0');
|
|
474
|
+
progressBar.style.width = '0%';
|
|
475
|
+
|
|
476
|
+
const progressLabel = document.createElement('span');
|
|
477
|
+
progressLabel.className = 'formie-upload-manager-progress-label';
|
|
478
|
+
progressLabel.setAttribute('data-formie-upload-manager-progress-label', 'true');
|
|
479
|
+
progressLabel.textContent = 'Preparing…';
|
|
480
|
+
|
|
481
|
+
progressTrack.append(progressBar);
|
|
482
|
+
progressRoot.append(progressTrack, progressLabel);
|
|
483
|
+
|
|
484
|
+
const sortControls = document.createElement('div');
|
|
485
|
+
sortControls.className = 'formie-upload-manager-sort-controls';
|
|
486
|
+
sortControls.setAttribute('data-formie-upload-manager-sort-controls', 'true');
|
|
487
|
+
sortControls.hidden = true;
|
|
488
|
+
|
|
489
|
+
const sortUpButton = createSortButton('up');
|
|
490
|
+
const sortDownButton = createSortButton('down');
|
|
491
|
+
sortControls.append(sortUpButton, sortDownButton);
|
|
492
|
+
|
|
493
|
+
const actionsRoot = document.createElement('div');
|
|
494
|
+
actionsRoot.className = 'formie-upload-manager-actions';
|
|
495
|
+
actionsRoot.setAttribute('data-formie-upload-manager-actions', 'true');
|
|
496
|
+
|
|
497
|
+
const removeButton = document.createElement('button');
|
|
498
|
+
removeButton.type = 'button';
|
|
499
|
+
removeButton.className = 'formie-upload-manager-action-button formie-upload-manager-remove-button';
|
|
500
|
+
removeButton.setAttribute('data-formie-upload-manager-remove', 'true');
|
|
501
|
+
removeButton.setAttribute('aria-label', 'Remove file');
|
|
502
|
+
removeButton.setAttribute('title', 'Remove file');
|
|
503
|
+
removeButton.setAttribute('data-formie-icon', 'close');
|
|
504
|
+
removeButton.textContent = 'Remove';
|
|
505
|
+
|
|
506
|
+
actionsRoot.append(sortControls, removeButton);
|
|
507
|
+
|
|
508
|
+
const errorEl = document.createElement('span');
|
|
509
|
+
errorEl.className = 'formie-upload-manager-error';
|
|
510
|
+
errorEl.setAttribute('data-formie-upload-manager-error', 'true');
|
|
511
|
+
errorEl.hidden = true;
|
|
512
|
+
|
|
513
|
+
listItem.append(filenameEl, progressRoot, actionsRoot, errorEl);
|
|
514
|
+
setIndeterminateUploadProgress(listItem, 'Preparing…', 'preparing');
|
|
515
|
+
|
|
516
|
+
return {
|
|
517
|
+
listItem,
|
|
518
|
+
filenameEl,
|
|
519
|
+
errorEl,
|
|
520
|
+
removeButton,
|
|
521
|
+
sortUpButton,
|
|
522
|
+
sortDownButton,
|
|
523
|
+
};
|
|
524
|
+
}
|
|
525
|
+
|
|
526
|
+
function getAssetInputName(field: HTMLElement): string {
|
|
527
|
+
const existingAssetInput = getHiddenInputs(field).find((input) => {
|
|
528
|
+
return input.hasAttribute(HIDDEN_INPUT_VALUE_ATTR);
|
|
529
|
+
});
|
|
530
|
+
|
|
531
|
+
if (existingAssetInput?.name) {
|
|
532
|
+
return existingAssetInput.name;
|
|
533
|
+
}
|
|
534
|
+
|
|
535
|
+
const fieldHandle = getFieldHandle(field);
|
|
536
|
+
|
|
537
|
+
return fieldHandle ? `fields[${fieldHandle}][]` : 'fields[fileUpload][]';
|
|
538
|
+
}
|
|
539
|
+
|
|
540
|
+
function countManagedAssets(state: UploadManagerState): number {
|
|
541
|
+
return state.files.filter((file) => {
|
|
542
|
+
return file.assetId !== null;
|
|
543
|
+
}).length;
|
|
544
|
+
}
|
|
545
|
+
|
|
546
|
+
function syncUploadedAssetsEvent(state: UploadManagerState): void {
|
|
547
|
+
const assetIds = state.files.map((file) => {
|
|
548
|
+
return file.assetId;
|
|
549
|
+
}).filter((assetId): assetId is number => {
|
|
550
|
+
return assetId !== null;
|
|
551
|
+
});
|
|
552
|
+
|
|
553
|
+
syncHiddenAssetInputs(state.field, state.anchorInput, state.assetInputName, assetIds);
|
|
554
|
+
|
|
555
|
+
if (state.statusInput) {
|
|
556
|
+
state.statusInput.value = assetIds.length ? 'uploaded' : '';
|
|
557
|
+
}
|
|
558
|
+
|
|
559
|
+
dispatchFieldEvent(state.field, 'file-upload', 'uploaded-assets-sync', {
|
|
560
|
+
assets: assetIds.map((assetId) => {
|
|
561
|
+
const managed = state.files.find((file) => {
|
|
562
|
+
return file.assetId === assetId;
|
|
563
|
+
});
|
|
564
|
+
|
|
565
|
+
return {
|
|
566
|
+
assetId,
|
|
567
|
+
filename: managed?.filename || '',
|
|
568
|
+
};
|
|
569
|
+
}),
|
|
570
|
+
});
|
|
571
|
+
}
|
|
572
|
+
|
|
573
|
+
function registerValidators(form: HTMLFormElement | null): void {
|
|
574
|
+
retainFormValidators(form, VALIDATOR_SCOPE, (validator) => {
|
|
575
|
+
validator.addValidator('uploadManagerRequired', ({ input }) => {
|
|
576
|
+
if (!input.matches(STATUS_INPUT_SELECTOR)) {
|
|
577
|
+
return true;
|
|
578
|
+
}
|
|
579
|
+
|
|
580
|
+
const field = input.closest('[data-formie-field-handle]');
|
|
581
|
+
|
|
582
|
+
if (!(field instanceof HTMLElement)) {
|
|
583
|
+
return true;
|
|
584
|
+
}
|
|
585
|
+
|
|
586
|
+
if (input.getAttribute('data-formie-validation-required') !== 'true') {
|
|
587
|
+
return true;
|
|
588
|
+
}
|
|
589
|
+
|
|
590
|
+
return readAssetIdsFromDom(field, getAssetInputName(field)).length > 0;
|
|
591
|
+
}, ({ input, label, t }) => {
|
|
592
|
+
return input.getAttribute('data-formie-required-message')
|
|
593
|
+
?? t('{label} cannot be blank.', { label });
|
|
594
|
+
});
|
|
595
|
+
|
|
596
|
+
validator.addValidator('uploadManagerFileLimit', ({ input }) => {
|
|
597
|
+
if (!input.matches(STATUS_INPUT_SELECTOR)) {
|
|
598
|
+
return true;
|
|
599
|
+
}
|
|
600
|
+
|
|
601
|
+
const field = input.closest('[data-formie-field-handle]');
|
|
602
|
+
|
|
603
|
+
if (!(field instanceof HTMLElement)) {
|
|
604
|
+
return true;
|
|
605
|
+
}
|
|
606
|
+
|
|
607
|
+
const dropzone = field.querySelector(DROPZONE_SELECTOR);
|
|
608
|
+
|
|
609
|
+
if (!(dropzone instanceof HTMLElement)) {
|
|
610
|
+
return true;
|
|
611
|
+
}
|
|
612
|
+
|
|
613
|
+
const limit = parseInt(dropzone.getAttribute('data-formie-file-limit') || '', 10);
|
|
614
|
+
|
|
615
|
+
if (!limit) {
|
|
616
|
+
return true;
|
|
617
|
+
}
|
|
618
|
+
|
|
619
|
+
return readAssetIdsFromDom(field, getAssetInputName(field)).length <= limit;
|
|
620
|
+
}, ({ input, t }) => {
|
|
621
|
+
const field = input.closest('[data-formie-field-handle]');
|
|
622
|
+
const dropzone = field?.querySelector(DROPZONE_SELECTOR);
|
|
623
|
+
|
|
624
|
+
return dropzone?.getAttribute('data-formie-validation-max-files-message')
|
|
625
|
+
?? t('Choose up to {files} files.', {
|
|
626
|
+
files: dropzone?.getAttribute('data-formie-file-limit') || '',
|
|
627
|
+
});
|
|
628
|
+
});
|
|
629
|
+
});
|
|
630
|
+
}
|
|
631
|
+
|
|
632
|
+
function unregisterValidators(form: HTMLFormElement | null): void {
|
|
633
|
+
releaseFormValidators(form, VALIDATOR_SCOPE, UPLOAD_VALIDATORS);
|
|
634
|
+
}
|
|
635
|
+
|
|
636
|
+
function resolveFormElement(form: HTMLFormElement | null, field: HTMLElement): HTMLFormElement | null {
|
|
637
|
+
if (form instanceof HTMLFormElement) {
|
|
638
|
+
return form;
|
|
639
|
+
}
|
|
640
|
+
|
|
641
|
+
const closestForm = field.closest('form');
|
|
642
|
+
|
|
643
|
+
return closestForm instanceof HTMLFormElement ? closestForm : null;
|
|
644
|
+
}
|
|
645
|
+
|
|
646
|
+
function bindUploadManagerField(field: HTMLElement, form: HTMLFormElement | null, options: UploadManagerOptions): () => void {
|
|
647
|
+
const root = field.querySelector(ROOT_SELECTOR);
|
|
648
|
+
|
|
649
|
+
if (!(root instanceof HTMLElement)) {
|
|
650
|
+
return () => { };
|
|
651
|
+
}
|
|
652
|
+
|
|
653
|
+
const dropzone = root.querySelector(DROPZONE_SELECTOR);
|
|
654
|
+
|
|
655
|
+
if (!(dropzone instanceof HTMLElement)) {
|
|
656
|
+
return () => { };
|
|
657
|
+
}
|
|
658
|
+
|
|
659
|
+
const browseInput = root.querySelector(BROWSE_INPUT_SELECTOR);
|
|
660
|
+
const browseButton = root.querySelector(BROWSE_BUTTON_SELECTOR);
|
|
661
|
+
const fileList = root.querySelector(FILE_LIST_SELECTOR);
|
|
662
|
+
const statusInput = root.querySelector(STATUS_INPUT_SELECTOR);
|
|
663
|
+
|
|
664
|
+
if (!(browseInput instanceof HTMLInputElement) || !(fileList instanceof HTMLElement)) {
|
|
665
|
+
return () => { };
|
|
666
|
+
}
|
|
667
|
+
|
|
668
|
+
const resolvedForm = resolveFormElement(form, field);
|
|
669
|
+
const resolvedStatusInput = statusInput instanceof HTMLInputElement ? statusInput : null;
|
|
670
|
+
|
|
671
|
+
const assetInputName = getAssetInputName(field);
|
|
672
|
+
const anchorInput = getAnchorInput(field, assetInputName);
|
|
673
|
+
const uploadEndpoint = getEndpoint(dropzone, options.uploadEndpoint, 'data-formie-file-upload-upload-endpoint', '/actions/formie/file-upload/upload');
|
|
674
|
+
const deleteEndpoint = getEndpoint(dropzone, options.deleteEndpoint, 'data-formie-file-upload-delete-endpoint', '/actions/formie/file-upload/delete');
|
|
675
|
+
const hydrateEndpoint = getEndpoint(dropzone, options.hydrateEndpoint, 'data-formie-file-upload-hydrate-endpoint', '/actions/formie/file-upload/hydrate');
|
|
676
|
+
const limitFiles = options.limitFiles ?? toPositiveInt(dropzone.getAttribute('data-formie-file-limit'));
|
|
677
|
+
const sizeLimit = options.sizeLimit ?? parseFloat(dropzone.getAttribute('data-formie-size-max-limit') || '');
|
|
678
|
+
const sizeMinLimit = options.sizeMinLimit ?? parseFloat(dropzone.getAttribute('data-formie-size-min-limit') || '');
|
|
679
|
+
const accept = options.accept ?? dropzone.getAttribute('accept') ?? browseInput.accept;
|
|
680
|
+
const allowedFileTypes = parseAcceptTypes(accept);
|
|
681
|
+
|
|
682
|
+
const restrictions: {
|
|
683
|
+
maxNumberOfFiles?: number;
|
|
684
|
+
maxFileSize?: number;
|
|
685
|
+
minFileSize?: number;
|
|
686
|
+
allowedFileTypes?: string[] | null;
|
|
687
|
+
} = {};
|
|
688
|
+
|
|
689
|
+
if (limitFiles) {
|
|
690
|
+
restrictions.maxNumberOfFiles = limitFiles;
|
|
691
|
+
}
|
|
692
|
+
|
|
693
|
+
if (sizeLimit) {
|
|
694
|
+
restrictions.maxFileSize = sizeLimit * 1000 * 1000;
|
|
695
|
+
}
|
|
696
|
+
|
|
697
|
+
if (sizeMinLimit) {
|
|
698
|
+
restrictions.minFileSize = sizeMinLimit * 1000 * 1000;
|
|
699
|
+
}
|
|
700
|
+
|
|
701
|
+
if (allowedFileTypes) {
|
|
702
|
+
restrictions.allowedFileTypes = allowedFileTypes;
|
|
703
|
+
}
|
|
704
|
+
|
|
705
|
+
const uppy = new Uppy({
|
|
706
|
+
autoProceed: true,
|
|
707
|
+
restrictions,
|
|
708
|
+
});
|
|
709
|
+
|
|
710
|
+
uppy.use(XHRUpload, {
|
|
711
|
+
endpoint: uploadEndpoint,
|
|
712
|
+
fieldName: 'file',
|
|
713
|
+
formData: true,
|
|
714
|
+
withCredentials: true,
|
|
715
|
+
timeout: UPLOAD_XHR_TIMEOUT_MS,
|
|
716
|
+
shouldRetry: shouldRetryUpload,
|
|
717
|
+
headers: {
|
|
718
|
+
Accept: 'application/json',
|
|
719
|
+
},
|
|
720
|
+
allowedMetaFields: [
|
|
721
|
+
'handle',
|
|
722
|
+
'fieldHandle',
|
|
723
|
+
'inputKey',
|
|
724
|
+
'renderId',
|
|
725
|
+
'draftContextToken',
|
|
726
|
+
'draftContext',
|
|
727
|
+
'submissionId',
|
|
728
|
+
'CRAFT_CSRF_TOKEN',
|
|
729
|
+
],
|
|
730
|
+
getResponseData(xhr) {
|
|
731
|
+
try {
|
|
732
|
+
return JSON.parse(xhr.responseText) as UploadResponse;
|
|
733
|
+
} catch {
|
|
734
|
+
return {};
|
|
735
|
+
}
|
|
736
|
+
},
|
|
737
|
+
onBeforeRequest(xhr, _retryCount, files) {
|
|
738
|
+
const uppyFile = files[0];
|
|
739
|
+
|
|
740
|
+
if (!uppyFile?.id || !uppyFile.size) {
|
|
741
|
+
return;
|
|
742
|
+
}
|
|
743
|
+
|
|
744
|
+
// Uppy XHRUpload ignores upload progress unless `lengthComputable` is true.
|
|
745
|
+
// Multipart FormData uploads often report `lengthComputable: false`, so we
|
|
746
|
+
// estimate from bytes sent vs file size to keep the bar moving during upload.
|
|
747
|
+
xhr.upload.addEventListener('progress', (event: ProgressEvent) => {
|
|
748
|
+
const managedFile = getManagedFileByUppyId(uppyFile.id);
|
|
749
|
+
|
|
750
|
+
if (!managedFile) {
|
|
751
|
+
return;
|
|
752
|
+
}
|
|
753
|
+
|
|
754
|
+
let percent = 0;
|
|
755
|
+
|
|
756
|
+
if (event.lengthComputable && event.total > 0) {
|
|
757
|
+
percent = Math.round((event.loaded / event.total) * 100);
|
|
758
|
+
} else if (event.loaded > 0 && uppyFile.size) {
|
|
759
|
+
percent = Math.min(100, Math.round((event.loaded / uppyFile.size) * 100));
|
|
760
|
+
}
|
|
761
|
+
|
|
762
|
+
applyUploadTransferProgress(managedFile.listItem, percent, {
|
|
763
|
+
loaded: event.loaded,
|
|
764
|
+
total: event.lengthComputable ? event.total : (uppyFile.size ?? undefined),
|
|
765
|
+
});
|
|
766
|
+
});
|
|
767
|
+
},
|
|
768
|
+
});
|
|
769
|
+
|
|
770
|
+
const state: UploadManagerState = {
|
|
771
|
+
field,
|
|
772
|
+
dropzone,
|
|
773
|
+
browseInput,
|
|
774
|
+
statusInput: resolvedStatusInput,
|
|
775
|
+
fileList,
|
|
776
|
+
anchorInput,
|
|
777
|
+
assetInputName,
|
|
778
|
+
uppy,
|
|
779
|
+
files: [],
|
|
780
|
+
};
|
|
781
|
+
|
|
782
|
+
const remainingUploadCapacity = (): number | null => {
|
|
783
|
+
if (!limitFiles) {
|
|
784
|
+
return null;
|
|
785
|
+
}
|
|
786
|
+
|
|
787
|
+
return Math.max(0, limitFiles - countManagedAssets(state));
|
|
788
|
+
};
|
|
789
|
+
|
|
790
|
+
const setDropzoneActive = (active: boolean) => {
|
|
791
|
+
dropzone.classList.toggle('formie-upload-manager-dropzone-active', active);
|
|
792
|
+
};
|
|
793
|
+
|
|
794
|
+
const isSortableManagedFile = (managedFile: ManagedFile): boolean => {
|
|
795
|
+
return managedFile.assetId !== null
|
|
796
|
+
&& !managedFile.listItem.classList.contains('is-error');
|
|
797
|
+
};
|
|
798
|
+
|
|
799
|
+
const syncSortControls = () => {
|
|
800
|
+
const showSort = state.files.length >= 2;
|
|
801
|
+
|
|
802
|
+
state.files.forEach((managedFile, index) => {
|
|
803
|
+
const sortControls = managedFile.listItem.querySelector(SORT_CONTROLS_SELECTOR);
|
|
804
|
+
const sortUpButton = managedFile.listItem.querySelector(SORT_UP_SELECTOR);
|
|
805
|
+
const sortDownButton = managedFile.listItem.querySelector(SORT_DOWN_SELECTOR);
|
|
806
|
+
|
|
807
|
+
if (!(sortControls instanceof HTMLElement)) {
|
|
808
|
+
return;
|
|
809
|
+
}
|
|
810
|
+
|
|
811
|
+
const canSort = showSort && isSortableManagedFile(managedFile);
|
|
812
|
+
sortControls.hidden = !canSort;
|
|
813
|
+
|
|
814
|
+
if (sortUpButton instanceof HTMLButtonElement) {
|
|
815
|
+
sortUpButton.disabled = !canSort || index === 0;
|
|
816
|
+
}
|
|
817
|
+
|
|
818
|
+
if (sortDownButton instanceof HTMLButtonElement) {
|
|
819
|
+
sortDownButton.disabled = !canSort || index >= state.files.length - 1;
|
|
820
|
+
}
|
|
821
|
+
});
|
|
822
|
+
};
|
|
823
|
+
|
|
824
|
+
const moveManagedFile = (managedFile: ManagedFile, direction: -1 | 1) => {
|
|
825
|
+
const index = state.files.indexOf(managedFile);
|
|
826
|
+
const targetIndex = index + direction;
|
|
827
|
+
|
|
828
|
+
if (index < 0 || targetIndex < 0 || targetIndex >= state.files.length) {
|
|
829
|
+
return;
|
|
830
|
+
}
|
|
831
|
+
|
|
832
|
+
const targetFile = state.files[targetIndex];
|
|
833
|
+
state.files[index] = targetFile;
|
|
834
|
+
state.files[targetIndex] = managedFile;
|
|
835
|
+
|
|
836
|
+
if (direction === -1) {
|
|
837
|
+
managedFile.listItem.before(targetFile.listItem);
|
|
838
|
+
} else {
|
|
839
|
+
managedFile.listItem.after(targetFile.listItem);
|
|
840
|
+
}
|
|
841
|
+
|
|
842
|
+
syncUploadedAssetsEvent(state);
|
|
843
|
+
syncSortControls();
|
|
844
|
+
|
|
845
|
+
dispatchFieldEvent(state.field, 'file-upload', 'uploaded-assets-reordered', {
|
|
846
|
+
assets: state.files.filter((file) => {
|
|
847
|
+
return file.assetId !== null;
|
|
848
|
+
}).map((file) => {
|
|
849
|
+
return {
|
|
850
|
+
assetId: file.assetId as number,
|
|
851
|
+
filename: file.filename,
|
|
852
|
+
};
|
|
853
|
+
}),
|
|
854
|
+
});
|
|
855
|
+
};
|
|
856
|
+
|
|
857
|
+
const registerManagedFile = (
|
|
858
|
+
managedFile: ManagedFile,
|
|
859
|
+
removeButton: HTMLButtonElement,
|
|
860
|
+
sortUpButton: HTMLButtonElement,
|
|
861
|
+
sortDownButton: HTMLButtonElement,
|
|
862
|
+
) => {
|
|
863
|
+
removeButton.addEventListener('click', () => {
|
|
864
|
+
void removeManagedFile(managedFile);
|
|
865
|
+
});
|
|
866
|
+
|
|
867
|
+
sortUpButton.addEventListener('click', () => {
|
|
868
|
+
moveManagedFile(managedFile, -1);
|
|
869
|
+
});
|
|
870
|
+
|
|
871
|
+
sortDownButton.addEventListener('click', () => {
|
|
872
|
+
moveManagedFile(managedFile, 1);
|
|
873
|
+
});
|
|
874
|
+
};
|
|
875
|
+
|
|
876
|
+
const removeManagedFile = async(managedFile: ManagedFile) => {
|
|
877
|
+
if (managedFile.uppyFileId) {
|
|
878
|
+
uppy.removeFile(managedFile.uppyFileId);
|
|
879
|
+
}
|
|
880
|
+
|
|
881
|
+
if (managedFile.assetId) {
|
|
882
|
+
const body = new FormData();
|
|
883
|
+
const context = getUploadContext(resolvedForm, field, dropzone);
|
|
884
|
+
|
|
885
|
+
Object.entries(context).forEach(([key, value]) => {
|
|
886
|
+
body.append(key, value);
|
|
887
|
+
});
|
|
888
|
+
|
|
889
|
+
body.append('assetId', String(managedFile.assetId));
|
|
890
|
+
|
|
891
|
+
try {
|
|
892
|
+
await requestJson<DeleteResponse>(deleteEndpoint, {
|
|
893
|
+
method: 'POST',
|
|
894
|
+
body,
|
|
895
|
+
});
|
|
896
|
+
} catch (error) {
|
|
897
|
+
debug.warn('Failed to delete uploaded asset.', { assetId: managedFile.assetId, error });
|
|
898
|
+
}
|
|
899
|
+
}
|
|
900
|
+
|
|
901
|
+
managedFile.listItem.remove();
|
|
902
|
+
state.files = state.files.filter((file) => {
|
|
903
|
+
return file !== managedFile;
|
|
904
|
+
});
|
|
905
|
+
syncUploadedAssetsEvent(state);
|
|
906
|
+
syncSortControls();
|
|
907
|
+
};
|
|
908
|
+
|
|
909
|
+
const addManagedFileFromAsset = (assetId: number, filename: string) => {
|
|
910
|
+
const { listItem, removeButton, sortUpButton, sortDownButton } = createListItem(field, filename);
|
|
911
|
+
listItem.classList.add('is-complete');
|
|
912
|
+
fileList.append(listItem);
|
|
913
|
+
|
|
914
|
+
const managedFile: ManagedFile = {
|
|
915
|
+
assetId,
|
|
916
|
+
filename,
|
|
917
|
+
uppyFileId: null,
|
|
918
|
+
listItem,
|
|
919
|
+
};
|
|
920
|
+
|
|
921
|
+
registerManagedFile(managedFile, removeButton, sortUpButton, sortDownButton);
|
|
922
|
+
state.files.push(managedFile);
|
|
923
|
+
syncSortControls();
|
|
924
|
+
};
|
|
925
|
+
|
|
926
|
+
const hydrateExistingAssets = async() => {
|
|
927
|
+
const assetIds = readAssetIdsFromDom(field, assetInputName);
|
|
928
|
+
|
|
929
|
+
if (!assetIds.length) {
|
|
930
|
+
return;
|
|
931
|
+
}
|
|
932
|
+
|
|
933
|
+
try {
|
|
934
|
+
const response = await requestJson<HydrateResponse>(hydrateEndpoint, {
|
|
935
|
+
method: 'POST',
|
|
936
|
+
body: buildHydrateFormData(resolvedForm, field, assetIds),
|
|
937
|
+
});
|
|
938
|
+
|
|
939
|
+
const assets = response.assets || [];
|
|
940
|
+
const assetsById = new Map(assets.map((asset) => {
|
|
941
|
+
return [toPositiveInt(asset.assetId), asset] as const;
|
|
942
|
+
}).filter(([assetId]) => {
|
|
943
|
+
return assetId !== null;
|
|
944
|
+
}));
|
|
945
|
+
|
|
946
|
+
assetIds.forEach((assetId) => {
|
|
947
|
+
if (state.files.some((file) => {
|
|
948
|
+
return file.assetId === assetId;
|
|
949
|
+
})) {
|
|
950
|
+
return;
|
|
951
|
+
}
|
|
952
|
+
|
|
953
|
+
const asset = assetsById.get(assetId);
|
|
954
|
+
|
|
955
|
+
if (!asset) {
|
|
956
|
+
return;
|
|
957
|
+
}
|
|
958
|
+
|
|
959
|
+
addManagedFileFromAsset(assetId, asset.filename || `Asset #${assetId}`);
|
|
960
|
+
});
|
|
961
|
+
|
|
962
|
+
syncUploadedAssetsEvent(state);
|
|
963
|
+
syncSortControls();
|
|
964
|
+
} catch (error) {
|
|
965
|
+
debug.warn('Failed to hydrate uploaded assets.', { error });
|
|
966
|
+
}
|
|
967
|
+
};
|
|
968
|
+
|
|
969
|
+
const getManagedFileByUppyId = (uppyFileId: string | undefined): ManagedFile | null => {
|
|
970
|
+
if (!uppyFileId) {
|
|
971
|
+
return null;
|
|
972
|
+
}
|
|
973
|
+
|
|
974
|
+
return state.files.find((file) => {
|
|
975
|
+
return file.uppyFileId === uppyFileId;
|
|
976
|
+
}) || null;
|
|
977
|
+
};
|
|
978
|
+
|
|
979
|
+
uppy.on('upload-progress', (uppyFile, progress) => {
|
|
980
|
+
const managedFile = getManagedFileByUppyId(uppyFile?.id);
|
|
981
|
+
|
|
982
|
+
if (!managedFile) {
|
|
983
|
+
return;
|
|
984
|
+
}
|
|
985
|
+
|
|
986
|
+
const total = progress.bytesTotal || 0;
|
|
987
|
+
const uploaded = progress.bytesUploaded || 0;
|
|
988
|
+
const percent = total > 0 ? Math.round((uploaded / total) * 100) : 0;
|
|
989
|
+
|
|
990
|
+
applyUploadTransferProgress(managedFile.listItem, percent, {
|
|
991
|
+
loaded: uploaded,
|
|
992
|
+
total,
|
|
993
|
+
});
|
|
994
|
+
});
|
|
995
|
+
|
|
996
|
+
uppy.on('upload-retry', (retryFile) => {
|
|
997
|
+
const managedFile = getManagedFileByUppyId(retryFile?.id);
|
|
998
|
+
|
|
999
|
+
if (!managedFile) {
|
|
1000
|
+
return;
|
|
1001
|
+
}
|
|
1002
|
+
|
|
1003
|
+
setIndeterminateUploadProgress(managedFile.listItem, 'Retrying…', 'retrying');
|
|
1004
|
+
});
|
|
1005
|
+
|
|
1006
|
+
uppy.on('upload-success', (successFile, response) => {
|
|
1007
|
+
const managedFile = getManagedFileByUppyId(successFile?.id);
|
|
1008
|
+
|
|
1009
|
+
if (!managedFile) {
|
|
1010
|
+
return;
|
|
1011
|
+
}
|
|
1012
|
+
|
|
1013
|
+
const errorEl = managedFile.listItem.querySelector('[data-formie-upload-manager-error]');
|
|
1014
|
+
const body = isRecord(response?.body) ? response.body as UploadResponse : {};
|
|
1015
|
+
const assetId = toPositiveInt(body.assetId);
|
|
1016
|
+
|
|
1017
|
+
if (!body.success || !assetId) {
|
|
1018
|
+
managedFile.listItem.classList.add('is-error');
|
|
1019
|
+
managedFile.listItem.classList.remove('is-uploading', 'is-upload-complete', 'is-processing', 'is-preparing');
|
|
1020
|
+
|
|
1021
|
+
if (errorEl instanceof HTMLElement) {
|
|
1022
|
+
errorEl.hidden = false;
|
|
1023
|
+
errorEl.textContent = parseUploadErrorMessage(null, { message: 'Upload failed.' });
|
|
1024
|
+
const validationError = body.errors
|
|
1025
|
+
? Object.values(body.errors).flat().find(Boolean)
|
|
1026
|
+
: null;
|
|
1027
|
+
|
|
1028
|
+
if (validationError) {
|
|
1029
|
+
errorEl.textContent = validationError;
|
|
1030
|
+
}
|
|
1031
|
+
}
|
|
1032
|
+
|
|
1033
|
+
return;
|
|
1034
|
+
}
|
|
1035
|
+
|
|
1036
|
+
managedFile.assetId = assetId;
|
|
1037
|
+
managedFile.filename = body.filename || managedFile.filename;
|
|
1038
|
+
markUploadComplete(managedFile.listItem);
|
|
1039
|
+
syncUploadedAssetsEvent(state);
|
|
1040
|
+
syncSortControls();
|
|
1041
|
+
});
|
|
1042
|
+
|
|
1043
|
+
uppy.on('upload-error', (errorFile, error, response) => {
|
|
1044
|
+
const managedFile = getManagedFileByUppyId(errorFile?.id);
|
|
1045
|
+
|
|
1046
|
+
if (!managedFile) {
|
|
1047
|
+
return;
|
|
1048
|
+
}
|
|
1049
|
+
|
|
1050
|
+
const errorEl = managedFile.listItem.querySelector('[data-formie-upload-manager-error]');
|
|
1051
|
+
managedFile.listItem.classList.add('is-error');
|
|
1052
|
+
managedFile.listItem.classList.remove('is-uploading', 'is-upload-complete', 'is-processing', 'is-preparing');
|
|
1053
|
+
|
|
1054
|
+
if (errorEl instanceof HTMLElement) {
|
|
1055
|
+
errorEl.hidden = false;
|
|
1056
|
+
errorEl.textContent = parseUploadErrorMessage(response, error);
|
|
1057
|
+
}
|
|
1058
|
+
});
|
|
1059
|
+
|
|
1060
|
+
uppy.on('file-added', (file) => {
|
|
1061
|
+
const capacity = remainingUploadCapacity();
|
|
1062
|
+
|
|
1063
|
+
if (capacity !== null && capacity <= 0) {
|
|
1064
|
+
uppy.removeFile(file.id);
|
|
1065
|
+
uppy.info('File limit reached.', 'error', 3000);
|
|
1066
|
+
return;
|
|
1067
|
+
}
|
|
1068
|
+
|
|
1069
|
+
uppy.setFileMeta(file.id, getUploadContext(resolvedForm, field, dropzone));
|
|
1070
|
+
|
|
1071
|
+
const { listItem, removeButton, sortUpButton, sortDownButton } = createListItem(field, file.name || 'Upload');
|
|
1072
|
+
fileList.append(listItem);
|
|
1073
|
+
|
|
1074
|
+
const managedFile: ManagedFile = {
|
|
1075
|
+
assetId: null,
|
|
1076
|
+
filename: file.name || 'Upload',
|
|
1077
|
+
uppyFileId: file.id,
|
|
1078
|
+
listItem,
|
|
1079
|
+
};
|
|
1080
|
+
|
|
1081
|
+
registerManagedFile(managedFile, removeButton, sortUpButton, sortDownButton);
|
|
1082
|
+
state.files.push(managedFile);
|
|
1083
|
+
syncSortControls();
|
|
1084
|
+
});
|
|
1085
|
+
|
|
1086
|
+
const openBrowseDialog = () => {
|
|
1087
|
+
browseInput.click();
|
|
1088
|
+
};
|
|
1089
|
+
|
|
1090
|
+
browseButton?.addEventListener('click', (event) => {
|
|
1091
|
+
event.preventDefault();
|
|
1092
|
+
event.stopPropagation();
|
|
1093
|
+
openBrowseDialog();
|
|
1094
|
+
});
|
|
1095
|
+
|
|
1096
|
+
dropzone.addEventListener('click', (event) => {
|
|
1097
|
+
if (event.target instanceof Element && event.target.closest(BROWSE_BUTTON_SELECTOR)) {
|
|
1098
|
+
return;
|
|
1099
|
+
}
|
|
1100
|
+
|
|
1101
|
+
openBrowseDialog();
|
|
1102
|
+
});
|
|
1103
|
+
|
|
1104
|
+
dropzone.addEventListener('keydown', (event) => {
|
|
1105
|
+
if (event.key === 'Enter' || event.key === ' ') {
|
|
1106
|
+
event.preventDefault();
|
|
1107
|
+
openBrowseDialog();
|
|
1108
|
+
}
|
|
1109
|
+
});
|
|
1110
|
+
|
|
1111
|
+
browseInput.addEventListener('change', () => {
|
|
1112
|
+
if (!browseInput.files?.length) {
|
|
1113
|
+
return;
|
|
1114
|
+
}
|
|
1115
|
+
|
|
1116
|
+
const files = Array.from(browseInput.files);
|
|
1117
|
+
browseInput.value = '';
|
|
1118
|
+
|
|
1119
|
+
try {
|
|
1120
|
+
uppy.addFiles(files.map((file) => {
|
|
1121
|
+
return {
|
|
1122
|
+
name: file.name,
|
|
1123
|
+
type: file.type,
|
|
1124
|
+
data: file,
|
|
1125
|
+
};
|
|
1126
|
+
}));
|
|
1127
|
+
} catch (error) {
|
|
1128
|
+
if (error instanceof Error) {
|
|
1129
|
+
uppy.info(error.message, 'error', 3000);
|
|
1130
|
+
}
|
|
1131
|
+
}
|
|
1132
|
+
});
|
|
1133
|
+
|
|
1134
|
+
['dragenter', 'dragover'].forEach((eventName) => {
|
|
1135
|
+
dropzone.addEventListener(eventName, (event) => {
|
|
1136
|
+
event.preventDefault();
|
|
1137
|
+
event.stopPropagation();
|
|
1138
|
+
setDropzoneActive(true);
|
|
1139
|
+
});
|
|
1140
|
+
});
|
|
1141
|
+
|
|
1142
|
+
['dragleave', 'drop'].forEach((eventName) => {
|
|
1143
|
+
dropzone.addEventListener(eventName, (event) => {
|
|
1144
|
+
event.preventDefault();
|
|
1145
|
+
event.stopPropagation();
|
|
1146
|
+
setDropzoneActive(false);
|
|
1147
|
+
});
|
|
1148
|
+
});
|
|
1149
|
+
|
|
1150
|
+
dropzone.addEventListener('drop', (event) => {
|
|
1151
|
+
const transfer = event.dataTransfer;
|
|
1152
|
+
|
|
1153
|
+
if (!transfer?.files?.length) {
|
|
1154
|
+
return;
|
|
1155
|
+
}
|
|
1156
|
+
|
|
1157
|
+
const files = Array.from(transfer.files);
|
|
1158
|
+
|
|
1159
|
+
try {
|
|
1160
|
+
uppy.addFiles(files.map((file) => {
|
|
1161
|
+
return {
|
|
1162
|
+
name: file.name,
|
|
1163
|
+
type: file.type,
|
|
1164
|
+
data: file,
|
|
1165
|
+
};
|
|
1166
|
+
}));
|
|
1167
|
+
} catch (error) {
|
|
1168
|
+
if (error instanceof Error) {
|
|
1169
|
+
uppy.info(error.message, 'error', 3000);
|
|
1170
|
+
}
|
|
1171
|
+
}
|
|
1172
|
+
});
|
|
1173
|
+
|
|
1174
|
+
const resetHandler = () => {
|
|
1175
|
+
state.files.forEach((managedFile) => {
|
|
1176
|
+
managedFile.listItem.remove();
|
|
1177
|
+
});
|
|
1178
|
+
state.files = [];
|
|
1179
|
+
uppy.cancelAll();
|
|
1180
|
+
syncHiddenAssetInputs(field, anchorInput, assetInputName, []);
|
|
1181
|
+
};
|
|
1182
|
+
|
|
1183
|
+
resolvedForm?.addEventListener(FORM_RESET_EVENT, resetHandler);
|
|
1184
|
+
|
|
1185
|
+
void hydrateExistingAssets();
|
|
1186
|
+
|
|
1187
|
+
return () => {
|
|
1188
|
+
resolvedForm?.removeEventListener(FORM_RESET_EVENT, resetHandler);
|
|
1189
|
+
uppy.destroy();
|
|
1190
|
+
state.files = [];
|
|
1191
|
+
};
|
|
1192
|
+
}
|
|
1193
|
+
|
|
1194
|
+
export const uploadManagerModule: FormieModuleDefinition = {
|
|
1195
|
+
id: MODULE_ID,
|
|
1196
|
+
kind: 'field',
|
|
1197
|
+
match: (ctx) => {
|
|
1198
|
+
return !!ctx.target.querySelector(ROOT_SELECTOR);
|
|
1199
|
+
},
|
|
1200
|
+
setup: async(ctx) => {
|
|
1201
|
+
const options = (ctx.options || {}) as UploadManagerOptions;
|
|
1202
|
+
const form = ctx.form;
|
|
1203
|
+
const boundFields = new WeakSet<HTMLElement>();
|
|
1204
|
+
const unbinds: Array<() => void> = [];
|
|
1205
|
+
|
|
1206
|
+
registerValidators(form);
|
|
1207
|
+
|
|
1208
|
+
const bindFieldsInRoot = (root: ParentNode) => {
|
|
1209
|
+
const fieldCandidates = new Set<HTMLElement>();
|
|
1210
|
+
|
|
1211
|
+
if (root instanceof HTMLElement && root.hasAttribute('data-formie-field-handle')) {
|
|
1212
|
+
fieldCandidates.add(root);
|
|
1213
|
+
}
|
|
1214
|
+
|
|
1215
|
+
if (root instanceof Element) {
|
|
1216
|
+
root.querySelectorAll('[data-formie-field-handle]').forEach((element) => {
|
|
1217
|
+
if (element instanceof HTMLElement) {
|
|
1218
|
+
fieldCandidates.add(element);
|
|
1219
|
+
}
|
|
1220
|
+
});
|
|
1221
|
+
|
|
1222
|
+
root.querySelectorAll(ROOT_SELECTOR).forEach((element) => {
|
|
1223
|
+
const field = element.closest('[data-formie-field-handle]');
|
|
1224
|
+
|
|
1225
|
+
if (field instanceof HTMLElement) {
|
|
1226
|
+
fieldCandidates.add(field);
|
|
1227
|
+
}
|
|
1228
|
+
});
|
|
1229
|
+
}
|
|
1230
|
+
|
|
1231
|
+
fieldCandidates.forEach((field) => {
|
|
1232
|
+
if (boundFields.has(field) || !field.querySelector(ROOT_SELECTOR)) {
|
|
1233
|
+
return;
|
|
1234
|
+
}
|
|
1235
|
+
|
|
1236
|
+
boundFields.add(field);
|
|
1237
|
+
unbinds.push(bindUploadManagerField(field, form, options));
|
|
1238
|
+
});
|
|
1239
|
+
};
|
|
1240
|
+
|
|
1241
|
+
bindFieldsInRoot(ctx.target);
|
|
1242
|
+
|
|
1243
|
+
const repeaterInitRowHandler = (event: Event) => {
|
|
1244
|
+
const detail = (event as CustomEvent<unknown>).detail;
|
|
1245
|
+
|
|
1246
|
+
if (!isRecord(detail)) {
|
|
1247
|
+
return;
|
|
1248
|
+
}
|
|
1249
|
+
|
|
1250
|
+
const row = detail.row;
|
|
1251
|
+
|
|
1252
|
+
if (row instanceof HTMLElement) {
|
|
1253
|
+
bindFieldsInRoot(row);
|
|
1254
|
+
}
|
|
1255
|
+
};
|
|
1256
|
+
|
|
1257
|
+
form?.addEventListener(REPEATER_INIT_ROW_EVENT, repeaterInitRowHandler as EventListener);
|
|
1258
|
+
|
|
1259
|
+
debug.log('Module setup.', { count: unbinds.length });
|
|
1260
|
+
|
|
1261
|
+
await ctx.emit('formie:module:upload-manager:init', {
|
|
1262
|
+
count: unbinds.length,
|
|
1263
|
+
});
|
|
1264
|
+
|
|
1265
|
+
return {
|
|
1266
|
+
destroy: () => {
|
|
1267
|
+
form?.removeEventListener(REPEATER_INIT_ROW_EVENT, repeaterInitRowHandler as EventListener);
|
|
1268
|
+
unbinds.forEach((unbind) => {
|
|
1269
|
+
unbind();
|
|
1270
|
+
});
|
|
1271
|
+
unregisterValidators(form);
|
|
1272
|
+
debug.log('Module destroy.');
|
|
1273
|
+
void ctx.emit('formie:module:upload-manager:destroy', {});
|
|
1274
|
+
},
|
|
1275
|
+
};
|
|
1276
|
+
},
|
|
1277
|
+
};
|