@dmitryvim/form-builder 0.1.9 → 0.1.11

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/dist/sample.html DELETED
@@ -1,1703 +0,0 @@
1
- <!doctype html>
2
- <html lang="en">
3
- <head>
4
- <meta charset="utf-8" />
5
- <title>Form Builder - JSON Schema to Dynamic Forms</title>
6
- <meta name="viewport" content="width=device-width, initial-scale=1" />
7
- <script src="https://cdn.tailwindcss.com"></script>
8
- <script>
9
- tailwind.config = {
10
- darkMode: 'media',
11
- theme: {
12
- extend: {
13
- fontFamily: {
14
- mono: ['ui-monospace', 'SFMono-Regular', 'Menlo', 'Monaco', 'Consolas', '"Liberation Mono"', '"Courier New"', 'monospace']
15
- }
16
- }
17
- }
18
- }
19
- </script>
20
- <style>
21
- /* Custom styles for form validation states */
22
- .invalid {
23
- @apply border-red-500 !important;
24
- }
25
- .field-hint {
26
- @apply text-gray-500 text-xs mt-1;
27
- }
28
- .error-message {
29
- @apply text-red-500 text-xs mt-1;
30
- }
31
- .file-preview-container {
32
- @apply mb-3 p-3 border border-dashed border-gray-300 rounded-lg min-h-[60px] flex items-center justify-center bg-blue-50;
33
- }
34
- .dark .file-preview-container {
35
- @apply bg-blue-900/20 border-gray-600;
36
- }
37
- .resource-pill {
38
- @apply inline-flex items-center gap-1.5 bg-blue-50 border border-gray-300 rounded-full px-2.5 py-1 font-mono text-xs m-0.5;
39
- }
40
- .dark .resource-pill {
41
- @apply bg-blue-900/20 border-gray-600;
42
- }
43
- </style>
44
- </head>
45
- <body class="bg-gray-50">
46
- <!-- Header -->
47
- <div class="bg-white border-b border-gray-200 px-6 py-4">
48
- <div class="flex items-center justify-between">
49
- <div class="flex items-center space-x-3">
50
- <div class="w-8 h-8 bg-blue-600 rounded-lg flex items-center justify-center">
51
- <span class="text-white font-bold text-sm">FB</span>
52
- </div>
53
- <div>
54
- <h1 class="text-lg font-bold text-gray-800">Form Builder</h1>
55
- <p class="text-xs text-gray-500">JSON Schema → Dynamic Forms</p>
56
- </div>
57
- </div>
58
- <div class="hidden bg-blue-50 border border-blue-200 rounded-lg p-2 text-xs text-blue-800" id="urlInfo">
59
- <strong>💡 URL Schema:</strong> Add <code class="bg-blue-100 px-1 py-0.5 rounded font-mono">?schema=BASE64</code>
60
- </div>
61
- </div>
62
- </div>
63
-
64
- <!-- Main Content -->
65
- <div class="p-4">
66
- <!-- Two Column Layout -->
67
- <div class="flex gap-4 h-full">
68
- <!-- Left Column: Schema & Form -->
69
- <div class="flex-1 space-y-4">
70
- <!-- Schema Input Card -->
71
- <div class="bg-white rounded-lg shadow-sm border border-gray-200">
72
- <div class="p-4 border-b border-gray-200">
73
- <h3 class="text-lg font-semibold text-gray-900">JSON Schema</h3>
74
- </div>
75
- <div class="p-4 space-y-4">
76
- <div class="space-y-2">
77
- <div class="flex gap-2 flex-wrap">
78
- <button class="bg-blue-600 text-white px-3 py-2 rounded-lg hover:bg-blue-700 transition-colors text-sm" id="applySchemaBtn">Apply Schema</button>
79
- <button class="border border-gray-300 text-gray-700 px-3 py-2 rounded-lg hover:bg-gray-50 transition-colors text-sm" id="resetSchemaBtn">Reset Example</button>
80
- <button class="border border-gray-300 text-gray-700 px-3 py-2 rounded-lg hover:bg-gray-50 transition-colors text-sm" id="prettySchemaBtn">Format</button>
81
- <button class="border border-gray-300 text-gray-700 px-3 py-2 rounded-lg hover:bg-gray-50 transition-colors text-sm" id="downloadSchemaBtn">Download</button>
82
- </div>
83
- <textarea id="schemaInput" class="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500 font-mono text-sm resize-y min-h-[300px]" spellcheck="false" placeholder="Paste your JSON schema here..."></textarea>
84
- <div id="schemaErrors" class="hidden text-red-600 text-sm bg-red-50 border border-red-200 rounded-lg p-3"></div>
85
- </div>
86
- </div>
87
- </div>
88
-
89
- <!-- Generated Form Card -->
90
- <div class="bg-white rounded-lg shadow-sm border border-gray-200">
91
- <div class="p-4 border-b border-gray-200">
92
- <h3 class="text-lg font-semibold text-gray-900">Generated Form</h3>
93
- </div>
94
- <div class="p-4 space-y-4">
95
- <div id="formContainer" class="min-h-[200px] max-h-[500px] overflow-y-auto">
96
- <div class="text-center text-gray-500 py-8">
97
- Apply a schema to generate the form
98
- </div>
99
- </div>
100
- <div class="flex gap-2 flex-wrap pt-4 border-t border-gray-200">
101
- <button class="bg-blue-600 text-white px-4 py-2 rounded-lg hover:bg-blue-700 transition-colors" id="submitBtn">Submit Form</button>
102
- <button class="bg-gray-600 text-white px-4 py-2 rounded-lg hover:bg-gray-700 transition-colors" id="saveDraftBtn">Save Draft</button>
103
- <button class="border border-gray-300 text-gray-700 px-4 py-2 rounded-lg hover:bg-gray-50 transition-colors" id="clearFormBtn">Clear Values</button>
104
- </div>
105
- <div id="formErrors" class="hidden text-red-600 text-sm bg-red-50 border border-red-200 rounded-lg p-3"></div>
106
- </div>
107
- </div>
108
- </div>
109
-
110
- <!-- Right Column: Output & Prefill -->
111
- <div class="flex-1 space-y-4">
112
- <!-- Form Output Card -->
113
- <div class="bg-white rounded-lg shadow-sm border border-gray-200">
114
- <div class="p-4 border-b border-gray-200">
115
- <h3 class="text-lg font-semibold text-gray-900">Form Output</h3>
116
- </div>
117
- <div class="p-4 space-y-4">
118
- <div class="space-y-2">
119
- <div class="flex gap-2 flex-wrap">
120
- <button class="bg-gray-600 text-white px-3 py-2 rounded-lg hover:bg-gray-700 transition-colors text-sm" id="copyOutputBtn">Copy JSON</button>
121
- <button class="border border-gray-300 text-gray-700 px-3 py-2 rounded-lg hover:bg-gray-50 transition-colors text-sm" id="downloadOutputBtn">Download</button>
122
- <button class="border border-gray-300 text-gray-700 px-3 py-2 rounded-lg hover:bg-gray-50 transition-colors text-sm" id="shareUrlBtn">Share URL</button>
123
- </div>
124
- <textarea id="outputJson" class="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500 font-mono text-sm resize-y min-h-[300px]" readonly placeholder="Submit the form to see the output JSON here..."></textarea>
125
- </div>
126
- </div>
127
- </div>
128
-
129
- <!-- Prefill Data Card -->
130
- <div class="bg-white rounded-lg shadow-sm border border-gray-200">
131
- <div class="p-4 border-b border-gray-200">
132
- <h3 class="text-lg font-semibold text-gray-900">Prefill Data</h3>
133
- </div>
134
- <div class="p-4 space-y-4">
135
- <div class="space-y-2">
136
- <div class="flex gap-2 flex-wrap">
137
- <button class="bg-gray-600 text-white px-3 py-2 rounded-lg hover:bg-gray-700 transition-colors text-sm" id="loadPrefillBtn">Load Prefill</button>
138
- <button class="border border-gray-300 text-gray-700 px-3 py-2 rounded-lg hover:bg-gray-50 transition-colors text-sm" id="copyTemplateBtn">Generate Template</button>
139
- </div>
140
- <textarea id="prefillInput" class="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500 font-mono text-sm resize-y min-h-[200px]" spellcheck="false" placeholder='{"field1": "value1", "field2": "value2", ...}'></textarea>
141
- <div id="prefillErrors" class="hidden text-red-600 text-sm bg-red-50 border border-red-200 rounded-lg p-3"></div>
142
- </div>
143
- </div>
144
- </div>
145
- </div>
146
- </div>
147
- </div>
148
-
149
- <script>
150
- // State management
151
- const state = {
152
- schema: null,
153
- formRoot: null,
154
- resourceIndex: new Map(),
155
- version: '1.0.0',
156
- config: {
157
- // File upload configuration
158
- uploadFile: null,
159
- downloadFile: null,
160
- getThumbnail: null,
161
- // Default implementations
162
- enableFilePreview: true,
163
- maxPreviewSize: '200px'
164
- }
165
- };
166
-
167
- // DOM element references
168
- const el = {
169
- schemaInput: document.getElementById('schemaInput'),
170
- schemaErrors: document.getElementById('schemaErrors'),
171
- applySchemaBtn: document.getElementById('applySchemaBtn'),
172
- resetSchemaBtn: document.getElementById('resetSchemaBtn'),
173
- prettySchemaBtn: document.getElementById('prettySchemaBtn'),
174
- downloadSchemaBtn: document.getElementById('downloadSchemaBtn'),
175
- formContainer: document.getElementById('formContainer'),
176
- formErrors: document.getElementById('formErrors'),
177
- submitBtn: document.getElementById('submitBtn'),
178
- saveDraftBtn: document.getElementById('saveDraftBtn'),
179
- clearFormBtn: document.getElementById('clearFormBtn'),
180
- outputJson: document.getElementById('outputJson'),
181
- copyOutputBtn: document.getElementById('copyOutputBtn'),
182
- downloadOutputBtn: document.getElementById('downloadOutputBtn'),
183
- shareUrlBtn: document.getElementById('shareUrlBtn'),
184
- prefillInput: document.getElementById('prefillInput'),
185
- loadPrefillBtn: document.getElementById('loadPrefillBtn'),
186
- copyTemplateBtn: document.getElementById('copyTemplateBtn'),
187
- prefillErrors: document.getElementById('prefillErrors'),
188
- urlInfo: document.getElementById('urlInfo')
189
- };
190
-
191
- // Example schema for demonstration (from docs/13_form_builder.html)
192
- const EXAMPLE_SCHEMA = {
193
- "version": "0.3",
194
- "title": "Asset Uploader with Slides",
195
- "elements": [
196
- {
197
- "type": "file",
198
- "key": "cover",
199
- "label": "Cover image",
200
- "required": true,
201
- "accept": {
202
- "extensions": ["png", "jpg", "jpeg"],
203
- "mime": ["image/png", "image/jpeg"]
204
- },
205
- "maxSizeMB": 25
206
- },
207
- {
208
- "type": "files",
209
- "key": "assets",
210
- "label": "Additional images",
211
- "required": false,
212
- "accept": {
213
- "extensions": ["png", "jpg"],
214
- "mime": ["image/png", "image/jpeg"]
215
- },
216
- "minCount": 0,
217
- "maxCount": 10,
218
- "maxSizeMB": 25
219
- },
220
- {
221
- "type": "text",
222
- "key": "title",
223
- "label": "Project title",
224
- "required": true,
225
- "minLength": 1,
226
- "maxLength": 120,
227
- "pattern": "^[A-Za-z0-9 _-]+$",
228
- "default": "My Project"
229
- },
230
- {
231
- "type": "textarea",
232
- "key": "description",
233
- "label": "Description",
234
- "required": false,
235
- "minLength": 0,
236
- "maxLength": 2000,
237
- "pattern": null,
238
- "default": ""
239
- },
240
- {
241
- "type": "select",
242
- "key": "theme",
243
- "label": "Theme",
244
- "required": true,
245
- "options": [
246
- {"value": "light", "label": "Light"},
247
- {"value": "dark", "label": "Dark"}
248
- ],
249
- "default": "dark"
250
- },
251
- {
252
- "type": "number",
253
- "key": "opacity",
254
- "label": "Opacity",
255
- "required": true,
256
- "min": 0,
257
- "max": 1,
258
- "decimals": 2,
259
- "step": 0.01,
260
- "default": 0.85
261
- },
262
- {
263
- "type": "group",
264
- "key": "slides",
265
- "label": "Slides",
266
- "repeat": {"min": 1, "max": 5},
267
- "elements": [
268
- {
269
- "type": "text",
270
- "key": "title",
271
- "label": "Slide title",
272
- "required": true,
273
- "minLength": 1,
274
- "maxLength": 80,
275
- "default": ""
276
- },
277
- {
278
- "type": "textarea",
279
- "key": "body",
280
- "label": "Slide text",
281
- "required": true,
282
- "minLength": 1,
283
- "maxLength": 1000,
284
- "default": ""
285
- }
286
- ]
287
- }
288
- ]
289
- };
290
-
291
- // Utility functions
292
- const sleep = (ms) => new Promise(r => setTimeout(r, ms));
293
- const pretty = (obj) => JSON.stringify(obj, null, 2);
294
- const deepClone = (obj) => structuredClone ? structuredClone(obj) : JSON.parse(JSON.stringify(obj));
295
- const isPlainObject = (v) => Object.prototype.toString.call(v) === '[object Object]';
296
- const setText = (node, text) => { node.textContent = text || ''; };
297
- const pathJoin = (base, key) => base ? `${base}.${key}` : key;
298
- const assert = (c, m) => { if(!c) throw new Error(m); };
299
- const warn = (m) => console.warn('[WARN]', m);
300
-
301
- function downloadFile(filename, text) {
302
- const blob = new Blob([text], { type: 'application/json' });
303
- const url = URL.createObjectURL(blob);
304
- const a = document.createElement('a');
305
- a.href = url;
306
- a.download = filename;
307
- a.click();
308
- URL.revokeObjectURL(url);
309
- }
310
-
311
- async function makeResourceIdFromFile(file) {
312
- try {
313
- const buf = await file.arrayBuffer();
314
- if (crypto?.subtle?.digest) {
315
- const hash = await crypto.subtle.digest('SHA-256', buf);
316
- const hex = [...new Uint8Array(hash)].map(b => b.toString(16).padStart(2, '0')).join('');
317
- return `res_${hex.slice(0, 24)}`;
318
- }
319
- } catch(_) {}
320
- const rnd = Math.random().toString(36).slice(2) + Math.random().toString(36).slice(2);
321
- return `res_${rnd.slice(0, 24)}`;
322
- }
323
-
324
- function showError(container, message) {
325
- container.classList.remove('hidden');
326
- container.textContent = message;
327
- }
328
-
329
- function clearError(container) {
330
- container.classList.add('hidden');
331
- container.textContent = '';
332
- }
333
-
334
- // Schema validation
335
- function validateSchema(schema) {
336
- const errors = [];
337
- try { assert(schema && schema.version === '0.3', 'schema.version must be "0.3"'); } catch(e) { errors.push(e.message); }
338
- try { assert(Array.isArray(schema.elements), 'schema.elements must be an array'); } catch(e) { errors.push(e.message); }
339
-
340
- function validateElements(elements, path) {
341
- const seen = new Set();
342
- elements.forEach((el, idx) => {
343
- const here = `${path}[${idx}]`;
344
- if (!el || typeof el !== 'object') { errors.push(`${here}: element must be object`); return; }
345
- if (!el.type) errors.push(`${here}: missing "type"`);
346
- if (!el.key) errors.push(`${here}: missing "key"`);
347
- if (el.key) {
348
- if (seen.has(el.key)) errors.push(`${path}: duplicate key "${el.key}"`);
349
- seen.add(el.key);
350
- }
351
- if (el.default !== undefined && (el.type === 'file' || el.type === 'files')) {
352
- errors.push(`${here}: default forbidden for "${el.type}"`);
353
- }
354
-
355
- // Type-specific validation
356
- if (el.type === 'text' || el.type === 'textarea') {
357
- if (el.minLength != null && el.maxLength != null && el.minLength > el.maxLength) {
358
- errors.push(`${here}: minLength > maxLength`);
359
- }
360
- if (el.pattern != null) {
361
- try { new RegExp(el.pattern); } catch { errors.push(`${here}: invalid pattern regex`); }
362
- }
363
- }
364
- if (el.type === 'number') {
365
- if (typeof el.min === 'number' && typeof el.max === 'number' && el.min > el.max) {
366
- errors.push(`${here}: min > max`);
367
- }
368
- if (el.decimals != null && (!Number.isInteger(el.decimals) || el.decimals < 0 || el.decimals > 8)) {
369
- errors.push(`${here}: decimals must be 0..8`);
370
- }
371
- }
372
- if (el.type === 'select') {
373
- if (!Array.isArray(el.options) || el.options.length === 0) {
374
- errors.push(`${here}: select.options must be non-empty array`);
375
- } else {
376
- const values = new Set(el.options.map(o => o.value));
377
- if (el.default != null && !values.has(el.default)) {
378
- errors.push(`${here}: default "${el.default}" not in options`);
379
- }
380
- }
381
- }
382
- if (el.type === 'file') {
383
- if (el.maxSizeMB != null && el.maxSizeMB <= 0) {
384
- errors.push(`${here}: maxSizeMB must be > 0`);
385
- }
386
- }
387
- if (el.type === 'files') {
388
- if (el.minCount != null && el.maxCount != null && el.minCount > el.maxCount) {
389
- errors.push(`${here}: minCount > maxCount`);
390
- }
391
- }
392
- if (el.type === 'group') {
393
- if (!Array.isArray(el.elements)) errors.push(`${here}: group.elements must be array`);
394
- if (el.repeat) {
395
- if (el.repeat.min != null && el.repeat.max != null && el.repeat.min > el.repeat.max) {
396
- errors.push(`${here}: repeat.min > repeat.max`);
397
- }
398
- }
399
- if (Array.isArray(el.elements)) validateElements(el.elements, pathJoin(path, el.key));
400
- }
401
- });
402
- }
403
-
404
- if (Array.isArray(schema.elements)) validateElements(schema.elements, 'elements');
405
- return errors;
406
- }
407
-
408
- function clear(node) {
409
- while (node.firstChild) node.removeChild(node.firstChild);
410
- }
411
-
412
- // Form rendering
413
- function renderForm(schema, prefill) {
414
- state.schema = deepClone(schema);
415
- state.formRoot = el.formContainer;
416
- clear(state.formRoot);
417
- clearError(el.formErrors);
418
-
419
- const formEl = document.createElement('form');
420
- formEl.id = 'dynamicForm';
421
- formEl.addEventListener('submit', (e) => e.preventDefault());
422
-
423
- const ctx = { path: '', prefill: prefill || {}, readonly: state.config.readonly || false };
424
- schema.elements.forEach(element => {
425
- const block = renderElement(element, ctx);
426
- formEl.appendChild(block);
427
- });
428
-
429
- state.formRoot.appendChild(formEl);
430
- }
431
-
432
- function renderElement(element, ctx) {
433
- const wrapper = document.createElement('div');
434
- wrapper.className = 'mb-6';
435
-
436
- const label = document.createElement('div');
437
- label.className = 'flex items-center mb-2';
438
- const title = document.createElement('label');
439
- title.className = 'text-sm font-medium text-gray-900';
440
- title.textContent = element.label || element.key;
441
- if (element.required) {
442
- const req = document.createElement('span');
443
- req.className = 'text-red-500 ml-1';
444
- req.textContent = '*';
445
- title.appendChild(req);
446
- }
447
- label.appendChild(title);
448
- wrapper.appendChild(label);
449
-
450
- const pathKey = pathJoin(ctx.path, element.key);
451
-
452
- switch (element.type) {
453
- case 'text': {
454
- const input = document.createElement('input');
455
- input.type = 'text';
456
- input.name = pathKey;
457
- input.dataset.type = 'text';
458
- input.className = 'w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500';
459
- setTextValueFromPrefill(input, element, ctx.prefill, element.key);
460
- input.addEventListener('input', () => markValidity(input, null));
461
- wrapper.appendChild(input);
462
- wrapper.appendChild(makeFieldHint(element));
463
- break;
464
- }
465
- case 'textarea': {
466
- const ta = document.createElement('textarea');
467
- ta.name = pathKey;
468
- ta.rows = 4;
469
- ta.dataset.type = 'textarea';
470
- ta.className = 'w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500 resize-none';
471
- setTextValueFromPrefill(ta, element, ctx.prefill, element.key);
472
- ta.addEventListener('input', () => markValidity(ta, null));
473
- wrapper.appendChild(ta);
474
- wrapper.appendChild(makeFieldHint(element));
475
- break;
476
- }
477
- case 'number': {
478
- const input = document.createElement('input');
479
- input.type = 'number';
480
- input.name = pathKey;
481
- input.dataset.type = 'number';
482
- input.className = 'w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500';
483
- if (element.step != null) input.step = String(element.step);
484
- if (element.min != null) input.min = String(element.min);
485
- if (element.max != null) input.max = String(element.max);
486
- setNumberFromPrefill(input, element, ctx.prefill, element.key);
487
- input.addEventListener('blur', () => {
488
- if (input.value === '') return;
489
- const v = parseFloat(input.value);
490
- if (Number.isFinite(v) && Number.isInteger(element.decimals ?? 0)) {
491
- input.value = String(Number(v.toFixed(element.decimals)));
492
- }
493
- });
494
- input.addEventListener('input', () => markValidity(input, null));
495
- wrapper.appendChild(input);
496
- wrapper.appendChild(makeFieldHint(element, `decimals=${element.decimals ?? 0}`));
497
- break;
498
- }
499
- case 'select': {
500
- const sel = document.createElement('select');
501
- sel.name = pathKey;
502
- sel.dataset.type = 'select';
503
- sel.className = 'w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500';
504
-
505
- if (!element.required) {
506
- const opt = document.createElement('option');
507
- opt.value = '';
508
- opt.textContent = '—';
509
- sel.appendChild(opt);
510
- }
511
-
512
- element.options.forEach(o => {
513
- const opt = document.createElement('option');
514
- opt.value = String(o.value);
515
- opt.textContent = o.label ?? String(o.value);
516
- sel.appendChild(opt);
517
- });
518
-
519
- setSelectFromPrefill(sel, element, ctx.prefill, element.key);
520
- sel.addEventListener('input', () => markValidity(sel, null));
521
- wrapper.appendChild(sel);
522
- break;
523
- }
524
- case 'file': {
525
- const hid = document.createElement('input');
526
- hid.type = 'hidden';
527
- hid.name = pathKey;
528
- hid.dataset.type = 'file';
529
-
530
- const container = document.createElement('div');
531
-
532
- // Preview container
533
- const previewContainer = document.createElement('div');
534
- previewContainer.className = 'aspect-square w-full max-w-xs bg-gray-100 rounded-lg overflow-hidden border-2 border-gray-300 transition-colors relative mb-3';
535
-
536
- // Read-only rendering: show preview + download only
537
- if (ctx.readonly === true) {
538
- previewContainer.dataset.readonly = 'true';
539
- const pv = ctx.prefill && ctx.prefill[element.key];
540
- if (typeof pv === 'string' && pv) {
541
- hid.value = pv;
542
- const fileName = `file_${pv.slice(-8)}`;
543
- renderFilePreview(previewContainer, pv, fileName, 'application/octet-stream');
544
- } else {
545
- previewContainer.innerHTML = '<div class="flex flex-col items-center justify-center h-full text-gray-400"><div class="text-2xl mb-2">📁</div><div class="text-sm">No file</div></div>';
546
- }
547
- container.appendChild(previewContainer);
548
- container.appendChild(hid);
549
- wrapper.appendChild(container);
550
- wrapper.appendChild(makeFieldHint(element, 'Read-only'));
551
- break;
552
- }
553
-
554
- // Editable mode
555
- previewContainer.className += ' border-dashed hover:border-gray-400 cursor-pointer group';
556
- const picker = document.createElement('input');
557
- picker.type = 'file';
558
- if (element.accept?.extensions) {
559
- picker.accept = element.accept.extensions.map(ext => `.${ext}`).join(',');
560
- }
561
- previewContainer.onclick = () => picker.click();
562
-
563
- const handleFileSelect = async (file) => {
564
- const err = fileValidationError(element, file);
565
- if (err) {
566
- markValidity(picker, err);
567
- return;
568
- }
569
-
570
- try {
571
- let resourceId;
572
-
573
- // Use custom upload function if provided
574
- if (state.config.uploadFile && typeof state.config.uploadFile === 'function') {
575
- resourceId = await state.config.uploadFile(file);
576
- } else {
577
- // Fallback to simulated resource ID
578
- resourceId = await makeResourceIdFromFile(file);
579
- state.resourceIndex.set(resourceId, { name: file.name, type: file.type, size: file.size });
580
- }
581
-
582
- hid.value = resourceId;
583
- await renderFilePreview(previewContainer, resourceId, file.name, file.type);
584
- markValidity(picker, null);
585
- } catch (error) {
586
- markValidity(picker, `Upload failed: ${error.message}`);
587
- }
588
- };
589
-
590
- picker.addEventListener('change', async () => {
591
- if (picker.files && picker.files[0]) {
592
- await handleFileSelect(picker.files[0]);
593
- }
594
- });
595
-
596
- // Handle prefilled values
597
- const pv = ctx.prefill && ctx.prefill[element.key];
598
- if (typeof pv === 'string' && pv) {
599
- hid.value = pv;
600
- // Try to render preview for existing resource
601
- const fileName = `file_${pv.slice(-8)}`;
602
- renderFilePreview(previewContainer, pv, fileName, 'application/octet-stream');
603
- } else {
604
- // Show upload prompt
605
- previewContainer.innerHTML = '<div class="flex flex-col items-center justify-center h-full text-gray-400"><div class="text-2xl mb-2">📁</div><div class="text-sm">Click to upload</div></div>';
606
- }
607
-
608
- const helpText = document.createElement('p');
609
- helpText.className = 'text-xs text-gray-600 mt-2 text-center';
610
- helpText.innerHTML = '<span class="underline cursor-pointer">Upload</span> or drag and drop file';
611
- helpText.onclick = () => picker.click();
612
-
613
- container.appendChild(previewContainer);
614
- container.appendChild(helpText);
615
- container.appendChild(picker);
616
- container.appendChild(hid);
617
-
618
- wrapper.appendChild(container);
619
- wrapper.appendChild(makeFieldHint(element, 'Returns resource ID for download/submission'));
620
- break;
621
- }
622
- case 'files': {
623
- const hid = document.createElement('input');
624
- hid.type = 'hidden';
625
- hid.name = pathKey;
626
- hid.dataset.type = 'files';
627
-
628
- const list = document.createElement('div');
629
- list.className = 'list';
630
-
631
- // Read-only rendering: show list of previews with download buttons
632
- if (ctx.readonly === true) {
633
- const pv = ctx.prefill && ctx.prefill[element.key];
634
- const rids = Array.isArray(pv) ? pv.filter(Boolean) : [];
635
- const renderReadonlyList = async (ridsArr) => {
636
- list.innerHTML = '';
637
- ridsArr.forEach(async (rid) => {
638
- const row = document.createElement('div');
639
- row.className = 'flex items-center gap-3 p-2 border border-gray-200 rounded';
640
- const icon = document.createElement('div');
641
- icon.className = 'w-12 h-12 rounded-lg flex items-center justify-center bg-blue-600 text-white text-xl flex-shrink-0';
642
- // Try thumbnail
643
- if (state.config.getThumbnail && typeof state.config.getThumbnail === 'function') {
644
- try {
645
- const thumb = await state.config.getThumbnail(rid);
646
- if (thumb) {
647
- const img = document.createElement('img');
648
- img.className = 'w-12 h-12 object-cover rounded-lg';
649
- img.src = thumb;
650
- icon.innerHTML = '';
651
- icon.appendChild(img);
652
- } else {
653
- icon.textContent = '📎';
654
- }
655
- } catch {
656
- icon.textContent = '📎';
657
- }
658
- } else {
659
- icon.textContent = '📎';
660
- }
661
- const info = document.createElement('div');
662
- info.className = 'flex-1 text-sm text-gray-700';
663
- info.textContent = rid;
664
- const actions = document.createElement('div');
665
- actions.className = 'flex items-center gap-2';
666
- const downloadBtn = document.createElement('button');
667
- downloadBtn.type = 'button';
668
- downloadBtn.className = 'px-2 py-1 text-xs bg-gray-100 hover:bg-gray-200 rounded';
669
- downloadBtn.textContent = 'Download';
670
- downloadBtn.addEventListener('click', async () => {
671
- if (state.config.downloadFile && typeof state.config.downloadFile === 'function') {
672
- try { await state.config.downloadFile(rid, rid); } catch(_) {}
673
- } else {
674
- console.log('Download simulated:', rid);
675
- }
676
- });
677
- actions.appendChild(downloadBtn);
678
- row.appendChild(icon);
679
- row.appendChild(info);
680
- row.appendChild(actions);
681
- list.appendChild(row);
682
- });
683
- };
684
- if (rids.length) {
685
- hid.value = JSON.stringify(rids);
686
- renderReadonlyList(rids);
687
- } else {
688
- list.innerHTML = '<div class="text-gray-400 text-sm">No files</div>';
689
- }
690
- wrapper.appendChild(list);
691
- wrapper.appendChild(hid);
692
- wrapper.appendChild(makeFieldHint(element, 'Read-only'));
693
- break;
694
- }
695
-
696
- // Editable mode
697
- const picker = document.createElement('input');
698
- picker.type = 'file';
699
- picker.multiple = true;
700
- if (element.accept?.extensions) {
701
- picker.accept = element.accept.extensions.map(ext => `.${ext}`).join(',');
702
- }
703
-
704
- picker.addEventListener('change', async () => {
705
- let arr = parseJSONSafe(hid.value, []);
706
- if (!Array.isArray(arr)) arr = [];
707
-
708
- if (picker.files && picker.files.length) {
709
- for (const file of picker.files) {
710
- const err = fileValidationError(element, file);
711
- if (err) {
712
- markValidity(picker, err);
713
- return;
714
- }
715
- }
716
-
717
- for (const file of picker.files) {
718
- const rid = await makeResourceIdFromFile(file);
719
- state.resourceIndex.set(rid, { name: file.name, type: file.type, size: file.size });
720
- arr.push(rid);
721
- }
722
-
723
- hid.value = JSON.stringify(arr);
724
- renderResourcePills(list, arr, (ridToRemove) => {
725
- const next = arr.filter(x => x !== ridToRemove);
726
- hid.value = JSON.stringify(next);
727
- arr = next;
728
- renderResourcePills(list, next, arguments.callee);
729
- });
730
- markValidity(picker, null);
731
- }
732
- });
733
-
734
- const pv = ctx.prefill && ctx.prefill[element.key];
735
- let initial = Array.isArray(pv) ? pv.filter(Boolean) : [];
736
- if (initial.length) {
737
- hid.value = JSON.stringify(initial);
738
- renderResourcePills(list, initial, (ridToRemove) => {
739
- const next = initial.filter(x => x !== ridToRemove);
740
- hid.value = JSON.stringify(next);
741
- initial = next;
742
- renderResourcePills(list, next, arguments.callee);
743
- });
744
- }
745
-
746
- wrapper.appendChild(picker);
747
- wrapper.appendChild(list);
748
- wrapper.appendChild(hid);
749
- wrapper.appendChild(makeFieldHint(element, 'Multiple files return resource ID array'));
750
- break;
751
- }
752
- case 'group': {
753
- wrapper.dataset.group = element.key;
754
- wrapper.dataset.groupPath = pathKey;
755
-
756
- const groupWrap = document.createElement('div');
757
- const header = document.createElement('div');
758
- header.className = 'flex items-center justify-between my-2 pb-2 border-b border-slate-200 dark:border-slate-700';
759
-
760
- const left = document.createElement('div');
761
- left.innerHTML = `<span>${element.label || element.key}</span>`;
762
- header.appendChild(left);
763
-
764
- const right = document.createElement('div');
765
- groupWrap.appendChild(header);
766
-
767
- const itemsWrap = document.createElement('div');
768
- itemsWrap.dataset.itemsFor = element.key;
769
-
770
- if (element.repeat && isPlainObject(element.repeat)) {
771
- const min = element.repeat.min ?? 0;
772
- const max = element.repeat.max ?? Infinity;
773
- const pre = Array.isArray(ctx.prefill?.[element.key]) ? ctx.prefill[element.key] : null;
774
-
775
- const addBtn = document.createElement('button');
776
- addBtn.type = 'button';
777
- addBtn.className = 'bg-blue-600 hover:bg-blue-700 text-white px-3 py-1.5 rounded-md text-sm font-medium transition-colors';
778
- addBtn.textContent = 'Add';
779
- right.appendChild(addBtn);
780
- header.appendChild(right);
781
-
782
- const countItems = () => itemsWrap.querySelectorAll(':scope > .groupItem').length;
783
- const refreshControls = () => {
784
- const n = countItems();
785
- addBtn.disabled = n >= max;
786
- left.innerHTML = `<span>${element.label || element.key}</span> <span class="text-slate-500 dark:text-slate-400 text-xs">[${n} / ${max === Infinity ? '∞' : max}, min=${min}]</span>`;
787
- };
788
-
789
- const addItem = (prefillObj) => {
790
- const item = document.createElement('div');
791
- item.className = 'groupItem border border-dashed border-slate-300 dark:border-slate-600 rounded-lg p-3 mb-3 bg-blue-50/30 dark:bg-blue-900/10';
792
- const subCtx = {
793
- path: pathJoin(ctx.path, element.key + `[${countItems()}]`),
794
- prefill: prefillObj || {}
795
- };
796
- element.elements.forEach(child => item.appendChild(renderElement(child, subCtx)));
797
-
798
- const rem = document.createElement('button');
799
- rem.type = 'button';
800
- rem.className = 'bg-red-500 hover:bg-red-600 text-white px-2 py-1 rounded text-xs font-medium transition-colors';
801
- rem.textContent = 'Remove';
802
- rem.addEventListener('click', () => {
803
- if (countItems() <= (element.repeat.min ?? 0)) return;
804
- itemsWrap.removeChild(item);
805
- refreshControls();
806
- });
807
- item.appendChild(rem);
808
- itemsWrap.appendChild(item);
809
- refreshControls();
810
- };
811
-
812
- groupWrap.appendChild(itemsWrap);
813
-
814
- if (pre && pre.length) {
815
- const n = Math.min(max, Math.max(min, pre.length));
816
- for (let i = 0; i < n; i++) addItem(pre[i]);
817
- } else {
818
- const n = Math.max(min, 0);
819
- for (let i = 0; i < n; i++) addItem(null);
820
- }
821
-
822
- addBtn.addEventListener('click', () => addItem(null));
823
- } else {
824
- // Single object group
825
- const subCtx = {
826
- path: pathJoin(ctx.path, element.key),
827
- prefill: ctx.prefill?.[element.key] || {}
828
- };
829
- element.elements.forEach(child => itemsWrap.appendChild(renderElement(child, subCtx)));
830
- groupWrap.appendChild(itemsWrap);
831
- }
832
-
833
- wrapper.innerHTML = '';
834
- wrapper.appendChild(groupWrap);
835
- break;
836
- }
837
- default:
838
- wrapper.appendChild(document.createTextNode(`Unsupported type: ${element.type}`));
839
- }
840
-
841
- return wrapper;
842
- }
843
-
844
- function makeFieldHint(element, extra = '') {
845
- const hint = document.createElement('div');
846
- hint.className = 'text-gray-500 text-xs mt-1';
847
- const bits = [];
848
-
849
- if (element.required) bits.push('required');
850
-
851
- if (element.type === 'text' || element.type === 'textarea') {
852
- if (element.minLength != null) bits.push(`minLength=${element.minLength}`);
853
- if (element.maxLength != null) bits.push(`maxLength=${element.maxLength}`);
854
- if (element.pattern) bits.push(`pattern=/${element.pattern}/`);
855
- }
856
- if (element.type === 'number') {
857
- if (element.min != null) bits.push(`min=${element.min}`);
858
- if (element.max != null) bits.push(`max=${element.max}`);
859
- if (element.decimals != null) bits.push(`decimals=${element.decimals}`);
860
- }
861
- if (element.type === 'select') {
862
- bits.push(`${element.options.length} options`);
863
- }
864
- if (element.type === 'files') {
865
- if (element.minCount != null) bits.push(`minCount=${element.minCount}`);
866
- if (element.maxCount != null) bits.push(`maxCount=${element.maxCount}`);
867
- }
868
-
869
- hint.textContent = [bits.join(' • '), extra].filter(Boolean).join(' | ');
870
- return hint;
871
- }
872
-
873
- async function renderFilePreview(container, resourceId, fileName, fileType) {
874
- container.innerHTML = '';
875
-
876
- const preview = document.createElement('div');
877
- preview.style.cssText = 'display: flex; align-items: center; gap: 12px; padding: 8px;';
878
- const isReadonly = state.config.readonly || container.dataset.readonly === 'true';
879
-
880
- // File icon/thumbnail
881
- const iconContainer = document.createElement('div');
882
- iconContainer.style.cssText = `width: 48px; height: 48px; border-radius: 8px; display: flex; align-items: center; justify-content: center; background: var(--accent); color: white; font-size: 20px; flex-shrink: 0;`;
883
-
884
- if (fileType.startsWith('image/')) {
885
- const img = document.createElement('img');
886
- img.style.cssText = 'width: 48px; height: 48px; object-cover: cover; border-radius: 8px;';
887
-
888
- // Try to get thumbnail using custom function or fallback
889
- if (state.config.getThumbnail && typeof state.config.getThumbnail === 'function') {
890
- try {
891
- const thumbnailUrl = await state.config.getThumbnail(resourceId);
892
- img.src = thumbnailUrl;
893
- img.onerror = () => { iconContainer.textContent = '🖼️'; };
894
- iconContainer.innerHTML = '';
895
- iconContainer.appendChild(img);
896
- } catch {
897
- iconContainer.textContent = '🖼️';
898
- }
899
- } else {
900
- iconContainer.textContent = '🖼️';
901
- }
902
- } else if (fileType.startsWith('video/')) {
903
- iconContainer.textContent = '🎥';
904
- } else if (fileType.includes('pdf')) {
905
- iconContainer.textContent = '📄';
906
- } else {
907
- iconContainer.textContent = '📎';
908
- }
909
-
910
- preview.appendChild(iconContainer);
911
-
912
- // File info
913
- const info = document.createElement('div');
914
- info.style.cssText = 'flex: 1;';
915
-
916
- const name = document.createElement('div');
917
- name.style.cssText = 'font-weight: 500; font-size: 14px; color: var(--fg);';
918
- name.textContent = fileName;
919
-
920
- const details = document.createElement('div');
921
- details.style.cssText = 'font-size: 12px; color: var(--muted); margin-top: 4px;';
922
- details.textContent = `${fileType} • ${resourceId.slice(0, 12)}...`;
923
-
924
- info.appendChild(name);
925
- info.appendChild(details);
926
- preview.appendChild(info);
927
-
928
- // Action buttons
929
- const actions = document.createElement('div');
930
- actions.style.cssText = 'display: flex; gap: 8px;';
931
-
932
- // Download button
933
- const downloadBtn = document.createElement('button');
934
- downloadBtn.className = 'btn';
935
- downloadBtn.style.cssText = 'padding: 6px 10px; font-size: 12px;';
936
- downloadBtn.textContent = '⬇️';
937
- downloadBtn.title = 'Download';
938
- downloadBtn.addEventListener('click', async () => {
939
- if (state.config.downloadFile && typeof state.config.downloadFile === 'function') {
940
- try {
941
- await state.config.downloadFile(resourceId, fileName);
942
- } catch (error) {
943
- console.error('Download failed:', error);
944
- }
945
- } else {
946
- console.log('Download simulated:', resourceId, fileName);
947
- }
948
- });
949
-
950
- actions.appendChild(downloadBtn);
951
- if (!isReadonly) {
952
- // Remove button (editable only)
953
- const removeBtn = document.createElement('button');
954
- removeBtn.className = 'btn bad';
955
- removeBtn.style.cssText = 'padding: 6px 10px; font-size: 12px;';
956
- removeBtn.textContent = '✕';
957
- removeBtn.title = 'Remove';
958
- removeBtn.addEventListener('click', () => {
959
- const hiddenInput = container.parentElement.querySelector('input[type="hidden"]');
960
- if (hiddenInput) {
961
- hiddenInput.value = '';
962
- }
963
- container.innerHTML = '<div style="color: var(--muted); font-size: 14px;">📁 Click "Choose File" to upload</div>';
964
- });
965
- actions.appendChild(removeBtn);
966
- }
967
- preview.appendChild(actions);
968
-
969
- container.appendChild(preview);
970
- }
971
-
972
- function renderResourcePills(container, rids, onRemove) {
973
- clear(container);
974
- container.className = 'flex flex-wrap gap-1.5 mt-2';
975
-
976
- rids.forEach(rid => {
977
- const meta = state.resourceIndex.get(rid);
978
- const pill = document.createElement('span');
979
- pill.className = 'resource-pill';
980
- pill.textContent = rid;
981
-
982
- if (meta) {
983
- const small = document.createElement('span');
984
- small.className = 'text-slate-500 dark:text-slate-400';
985
- small.textContent = ` (${meta.name ?? 'file'}, ${formatFileSize(meta.size ?? 0)})`;
986
- pill.appendChild(small);
987
- }
988
-
989
- if (onRemove) {
990
- const x = document.createElement('button');
991
- x.type = 'button';
992
- x.className = 'bg-red-500 hover:bg-red-600 text-white text-xs px-1.5 py-0.5 rounded ml-1.5';
993
- x.textContent = '×';
994
- x.addEventListener('click', () => onRemove(rid));
995
- pill.appendChild(x);
996
- }
997
-
998
- container.appendChild(pill);
999
- });
1000
- }
1001
-
1002
- function formatFileSize(bytes) {
1003
- if (bytes === 0) return '0 B';
1004
- const k = 1024;
1005
- const sizes = ['B', 'KB', 'MB', 'GB'];
1006
- const i = Math.floor(Math.log(bytes) / Math.log(k));
1007
- return parseFloat((bytes / Math.pow(k, i)).toFixed(1)) + ' ' + sizes[i];
1008
- }
1009
-
1010
- function markValidity(input, msg) {
1011
- const prev = input?.parentElement?.querySelector?.('.error-message');
1012
- if (prev) prev.remove();
1013
-
1014
- if (input) input.classList.toggle('invalid', !!msg);
1015
-
1016
- if (msg && input?.parentElement) {
1017
- const m = document.createElement('div');
1018
- m.className = 'error-message text-red-500 text-xs mt-1';
1019
- m.textContent = msg;
1020
- input.parentElement.appendChild(m);
1021
- }
1022
- }
1023
-
1024
- function parseJSONSafe(text, fallback = null) {
1025
- try {
1026
- return JSON.parse(text);
1027
- } catch {
1028
- return fallback;
1029
- }
1030
- }
1031
-
1032
- function setTextValueFromPrefill(input, element, prefillObj, key) {
1033
- let v = undefined;
1034
- if (prefillObj && Object.prototype.hasOwnProperty.call(prefillObj, key)) v = prefillObj[key];
1035
- else if (element.default !== undefined) v = element.default;
1036
- if (v !== undefined) input.value = String(v);
1037
- }
1038
-
1039
- function setNumberFromPrefill(input, element, prefillObj, key) {
1040
- let v = undefined;
1041
- if (prefillObj && Object.prototype.hasOwnProperty.call(prefillObj, key)) v = prefillObj[key];
1042
- else if (element.default !== undefined) v = element.default;
1043
- if (v !== undefined && v !== null && v !== '') input.value = String(v);
1044
- }
1045
-
1046
- function setSelectFromPrefill(select, element, prefillObj, key) {
1047
- const values = new Set(element.options.map(o => String(o.value)));
1048
- let v = undefined;
1049
- if (prefillObj && Object.prototype.hasOwnProperty.call(prefillObj, key)) v = prefillObj[key];
1050
- else if (element.default !== undefined) v = element.default;
1051
- if (v !== undefined && values.has(String(v))) select.value = String(v);
1052
- else if (!element.required) select.value = '';
1053
- }
1054
-
1055
- function fileValidationError(element, file) {
1056
- if (!file) return 'no file';
1057
- if (element.maxSizeMB != null && file.size > element.maxSizeMB * 1024 * 1024) {
1058
- return `file too large > ${element.maxSizeMB}MB`;
1059
- }
1060
- if (element.accept) {
1061
- const { extensions, mime } = element.accept;
1062
- if (mime && Array.isArray(mime) && mime.length && !mime.includes(file.type)) {
1063
- return `mime not allowed: ${file.type}`;
1064
- }
1065
- if (extensions && Array.isArray(extensions) && extensions.length) {
1066
- const ext = (file.name.split('.').pop() || '').toLowerCase();
1067
- if (!extensions.includes(ext)) return `extension .${ext} not allowed`;
1068
- }
1069
- }
1070
- return null;
1071
- }
1072
-
1073
- // Form data collection and validation
1074
- function collectAndValidate(schema, skipValidation = false) {
1075
- const form = document.getElementById('dynamicForm');
1076
- const errors = [];
1077
-
1078
- function collectElement(element, scopeRoot) {
1079
- const key = element.key;
1080
-
1081
- switch (element.type) {
1082
- case 'text':
1083
- case 'textarea': {
1084
- const input = scopeRoot.querySelector(`[name$="${key}"]`);
1085
- const val = (input?.value ?? '').trim();
1086
- if (!skipValidation && element.required && val === '') {
1087
- errors.push(`${key}: required`);
1088
- markValidity(input, 'required');
1089
- } else if (!skipValidation && val !== '') {
1090
- if (element.minLength != null && val.length < element.minLength) {
1091
- errors.push(`${key}: minLength=${element.minLength}`);
1092
- markValidity(input, `minLength=${element.minLength}`);
1093
- }
1094
- if (element.maxLength != null && val.length > element.maxLength) {
1095
- errors.push(`${key}: maxLength=${element.maxLength}`);
1096
- markValidity(input, `maxLength=${element.maxLength}`);
1097
- }
1098
- if (element.pattern) {
1099
- try {
1100
- const re = new RegExp(element.pattern);
1101
- if (!re.test(val)) {
1102
- errors.push(`${key}: pattern mismatch`);
1103
- markValidity(input, 'pattern mismatch');
1104
- }
1105
- } catch {
1106
- errors.push(`${key}: invalid pattern`);
1107
- markValidity(input, 'invalid pattern');
1108
- }
1109
- }
1110
- } else if (skipValidation) {
1111
- markValidity(input, null);
1112
- } else {
1113
- markValidity(input, null);
1114
- }
1115
- return val;
1116
- }
1117
- case 'number': {
1118
- const input = scopeRoot.querySelector(`[name$="${key}"]`);
1119
- const raw = input?.value ?? '';
1120
- if (!skipValidation && element.required && raw === '') {
1121
- errors.push(`${key}: required`);
1122
- markValidity(input, 'required');
1123
- return null;
1124
- }
1125
- if (raw === '') {
1126
- markValidity(input, null);
1127
- return null;
1128
- }
1129
- const v = parseFloat(raw);
1130
- if (!skipValidation && !Number.isFinite(v)) {
1131
- errors.push(`${key}: not a number`);
1132
- markValidity(input, 'not a number');
1133
- return null;
1134
- }
1135
- if (!skipValidation && element.min != null && v < element.min) {
1136
- errors.push(`${key}: < min=${element.min}`);
1137
- markValidity(input, `< min=${element.min}`);
1138
- }
1139
- if (!skipValidation && element.max != null && v > element.max) {
1140
- errors.push(`${key}: > max=${element.max}`);
1141
- markValidity(input, `> max=${element.max}`);
1142
- }
1143
- const d = Number.isInteger(element.decimals ?? 0) ? element.decimals : 0;
1144
- const r = Number(v.toFixed(d));
1145
- input.value = String(r);
1146
- markValidity(input, null);
1147
- return r;
1148
- }
1149
- case 'select': {
1150
- const sel = scopeRoot.querySelector(`select[name$="${key}"]`);
1151
- const val = sel?.value ?? '';
1152
- const values = new Set(element.options.map(o => String(o.value)));
1153
- if (!skipValidation && element.required && val === '') {
1154
- errors.push(`${key}: required`);
1155
- markValidity(sel, 'required');
1156
- return '';
1157
- }
1158
- if (!skipValidation && val !== '' && !values.has(String(val))) {
1159
- errors.push(`${key}: value not in options`);
1160
- markValidity(sel, 'not in options');
1161
- } else {
1162
- markValidity(sel, null);
1163
- }
1164
- return val === '' ? null : val;
1165
- }
1166
- case 'file': {
1167
- const hid = scopeRoot.querySelector(`input[type="hidden"][name$="${key}"]`);
1168
- const rid = hid?.value ?? '';
1169
- if (!skipValidation && element.required && !rid) {
1170
- errors.push(`${key}: required (file missing)`);
1171
- const picker = hid?.previousElementSibling;
1172
- if (picker) markValidity(picker, 'required');
1173
- } else {
1174
- if (hid?.previousElementSibling) markValidity(hid.previousElementSibling, null);
1175
- }
1176
- return rid || null;
1177
- }
1178
- case 'files': {
1179
- const hid = scopeRoot.querySelector(`input[type="hidden"][name$="${key}"]`);
1180
- const arr = parseJSONSafe(hid?.value ?? '[]', []);
1181
- const count = Array.isArray(arr) ? arr.length : 0;
1182
- if (!skipValidation && !Array.isArray(arr)) errors.push(`${key}: internal value corrupted`);
1183
- if (!skipValidation && element.minCount != null && count < element.minCount) {
1184
- errors.push(`${key}: < minCount=${element.minCount}`);
1185
- }
1186
- if (!skipValidation && element.maxCount != null && count > element.maxCount) {
1187
- errors.push(`${key}: > maxCount=${element.maxCount}`);
1188
- }
1189
- if (hid?.previousElementSibling) markValidity(hid.previousElementSibling, null);
1190
- return Array.isArray(arr) ? arr : [];
1191
- }
1192
- case 'group': {
1193
- const groupWrapper = scopeRoot.querySelector(`[data-group="${key}"]`);
1194
- if (!groupWrapper) {
1195
- errors.push(`${key}: internal group wrapper not found`);
1196
- return element.repeat ? [] : {};
1197
- }
1198
- const itemsWrap = groupWrapper.querySelector(`[data-items-for="${key}"]`);
1199
- if (!itemsWrap) {
1200
- errors.push(`${key}: internal items container not found`);
1201
- return element.repeat ? [] : {};
1202
- }
1203
-
1204
- if (element.repeat && isPlainObject(element.repeat)) {
1205
- const items = itemsWrap.querySelectorAll(':scope > .groupItem');
1206
- const out = [];
1207
- const n = items.length;
1208
- const min = element.repeat.min ?? 0;
1209
- const max = element.repeat.max ?? Infinity;
1210
- if (!skipValidation && n < min) errors.push(`${key}: count < min=${min}`);
1211
- if (!skipValidation && n > max) errors.push(`${key}: count > max=${max}`);
1212
- items.forEach(item => {
1213
- const obj = {};
1214
- element.elements.forEach(child => {
1215
- obj[child.key] = collectElement(child, item);
1216
- });
1217
- out.push(obj);
1218
- });
1219
- return out;
1220
- } else {
1221
- const obj = {};
1222
- element.elements.forEach(child => {
1223
- obj[child.key] = collectElement(child, itemsWrap);
1224
- });
1225
- return obj;
1226
- }
1227
- }
1228
- default:
1229
- errors.push(`${key}: unsupported type ${element.type}`);
1230
- return null;
1231
- }
1232
- }
1233
-
1234
- const result = {};
1235
- state.schema.elements.forEach(element => {
1236
- result[element.key] = collectElement(element, form);
1237
- });
1238
-
1239
- return { result, errors };
1240
- }
1241
-
1242
- // URL parameter handling
1243
- function loadSchemaFromURL() {
1244
- const params = new URLSearchParams(window.location.search);
1245
- const schemaParam = params.get('schema');
1246
-
1247
- if (schemaParam) {
1248
- el.urlInfo.classList.remove('hidden');
1249
- try {
1250
- const schemaJson = atob(schemaParam);
1251
- const schema = JSON.parse(schemaJson);
1252
- el.schemaInput.value = pretty(schema);
1253
- const errors = validateSchema(schema);
1254
- if (errors.length === 0) {
1255
- renderForm(schema, {});
1256
- } else {
1257
- showError(el.schemaErrors, errors.join('\n'));
1258
- }
1259
- } catch (e) {
1260
- showError(el.schemaErrors, 'Invalid schema in URL parameter: ' + e.message);
1261
- }
1262
- }
1263
- }
1264
-
1265
- // Event handlers
1266
- el.applySchemaBtn.addEventListener('click', () => {
1267
- clearError(el.schemaErrors);
1268
- try {
1269
- const parsed = JSON.parse(el.schemaInput.value);
1270
- const errs = validateSchema(parsed);
1271
- if (errs.length) {
1272
- showError(el.schemaErrors, errs.join('\n'));
1273
- return;
1274
- }
1275
- renderForm(parsed, {});
1276
- } catch (e) {
1277
- showError(el.schemaErrors, 'JSON parse error: ' + e.message);
1278
- }
1279
- });
1280
-
1281
- el.resetSchemaBtn.addEventListener('click', () => {
1282
- el.schemaInput.value = pretty(EXAMPLE_SCHEMA);
1283
- clearError(el.schemaErrors);
1284
- renderForm(EXAMPLE_SCHEMA, {});
1285
- el.outputJson.value = '';
1286
- el.prefillInput.value = '';
1287
- clearError(el.prefillErrors);
1288
- });
1289
-
1290
- el.prettySchemaBtn.addEventListener('click', () => {
1291
- try {
1292
- const parsed = JSON.parse(el.schemaInput.value);
1293
- el.schemaInput.value = pretty(parsed);
1294
- } catch (e) {
1295
- showError(el.schemaErrors, 'Prettify: JSON parse error: ' + e.message);
1296
- }
1297
- });
1298
-
1299
- el.downloadSchemaBtn.addEventListener('click', () => {
1300
- downloadFile('schema.json', el.schemaInput.value || pretty(EXAMPLE_SCHEMA));
1301
- });
1302
-
1303
- // Submit handler is now defined as submitFormEnhanced below
1304
-
1305
- el.clearFormBtn.addEventListener('click', () => {
1306
- if (!state.schema) return;
1307
- renderForm(state.schema, {});
1308
- clearError(el.formErrors);
1309
- });
1310
-
1311
- el.copyOutputBtn.addEventListener('click', async () => {
1312
- try {
1313
- await navigator.clipboard.writeText(el.outputJson.value || '');
1314
- el.copyOutputBtn.textContent = 'Copied!';
1315
- setTimeout(() => {
1316
- el.copyOutputBtn.textContent = 'Copy JSON';
1317
- }, 1000);
1318
- } catch (e) {
1319
- console.warn('Copy failed:', e);
1320
- }
1321
- });
1322
-
1323
- el.downloadOutputBtn.addEventListener('click', () => {
1324
- downloadFile('form-data.json', el.outputJson.value || '{}');
1325
- });
1326
-
1327
- el.shareUrlBtn.addEventListener('click', () => {
1328
- try {
1329
- const schema = JSON.parse(el.schemaInput.value);
1330
- const schemaBase64 = btoa(JSON.stringify(schema));
1331
- const url = `${window.location.origin}${window.location.pathname}?schema=${schemaBase64}`;
1332
- navigator.clipboard.writeText(url);
1333
- el.shareUrlBtn.textContent = 'URL Copied!';
1334
- setTimeout(() => {
1335
- el.shareUrlBtn.textContent = 'Share URL';
1336
- }, 2000);
1337
- } catch (e) {
1338
- alert('Please apply a valid schema first');
1339
- }
1340
- });
1341
-
1342
- el.loadPrefillBtn.addEventListener('click', () => {
1343
- clearError(el.prefillErrors);
1344
- if (!state.schema) {
1345
- showError(el.prefillErrors, 'Schema not applied');
1346
- return;
1347
- }
1348
- try {
1349
- const pre = JSON.parse(el.prefillInput.value || '{}');
1350
- const allowed = new Set(state.schema.elements.map(e => e.key));
1351
- const unknown = Object.keys(pre).filter(k => !allowed.has(k));
1352
- if (unknown.length) {
1353
- warn('prefill unknown keys: ' + unknown.join(', '));
1354
- }
1355
- renderForm(state.schema, pre);
1356
- } catch (e) {
1357
- showError(el.prefillErrors, 'Prefill parse error: ' + e.message);
1358
- }
1359
- });
1360
-
1361
- el.copyTemplateBtn.addEventListener('click', () => {
1362
- if (!state.schema) {
1363
- showError(el.prefillErrors, 'Apply schema first');
1364
- return;
1365
- }
1366
- const tpl = makePrefillTemplate(state.schema);
1367
- el.prefillInput.value = pretty(tpl);
1368
- });
1369
-
1370
- function makePrefillTemplate(schema) {
1371
- function walk(elements) {
1372
- const obj = {};
1373
- for (const el of elements) {
1374
- switch (el.type) {
1375
- case 'text':
1376
- case 'textarea':
1377
- case 'select':
1378
- case 'number':
1379
- obj[el.key] = el.default ?? null;
1380
- break;
1381
- case 'file':
1382
- obj[el.key] = null;
1383
- break;
1384
- case 'files':
1385
- obj[el.key] = [];
1386
- break;
1387
- case 'group':
1388
- if (el.repeat && isPlainObject(el.repeat)) {
1389
- const sample = walk(el.elements);
1390
- const n = Math.max(el.repeat.min ?? 0, 1);
1391
- obj[el.key] = Array.from({ length: n }, () => deepClone(sample));
1392
- } else {
1393
- obj[el.key] = walk(el.elements);
1394
- }
1395
- break;
1396
- default:
1397
- obj[el.key] = null;
1398
- }
1399
- }
1400
- return obj;
1401
- }
1402
- return walk(schema.elements);
1403
- }
1404
-
1405
- // Configuration API
1406
- window.FormBuilderConfig = {
1407
- setUploadHandler: (uploadFn) => {
1408
- state.config.uploadFile = uploadFn;
1409
- },
1410
- setDownloadHandler: (downloadFn) => {
1411
- state.config.downloadFile = downloadFn;
1412
- },
1413
- setThumbnailHandler: (thumbnailFn) => {
1414
- state.config.getThumbnail = thumbnailFn;
1415
- },
1416
- setConfig: (config) => {
1417
- Object.assign(state.config, config);
1418
- }
1419
- };
1420
-
1421
- // Geppetto Integration - Message Handler
1422
- function setupGeppettoMessaging() {
1423
- // Listen for messages from parent window (Geppetto SPA)
1424
- window.addEventListener('message', (event) => {
1425
- // Security check - adjust origins as needed for your deployment
1426
- const allowedOrigins = [
1427
- 'https://your-geppetto-domain.com',
1428
- 'http://localhost:3000',
1429
- 'http://localhost:8080',
1430
- window.location.origin
1431
- ];
1432
-
1433
- if (!allowedOrigins.some(origin => event.origin.startsWith(origin))) {
1434
- return;
1435
- }
1436
-
1437
- handleGeppettoMessage(event.data);
1438
- });
1439
-
1440
- // Notify parent that form builder is ready
1441
- if (window.parent !== window) {
1442
- window.parent.postMessage({
1443
- type: 'formBuilderReady'
1444
- }, '*');
1445
- }
1446
- }
1447
-
1448
- function handleGeppettoMessage(data) {
1449
- switch (data.type) {
1450
- case 'configure':
1451
- if (data.config) {
1452
- if (data.config.uploadHandler) {
1453
- state.config.uploadFile = data.config.uploadHandler;
1454
- }
1455
- if (data.config.downloadHandler) {
1456
- state.config.downloadFile = data.config.downloadHandler;
1457
- }
1458
- if (data.config.thumbnailHandler) {
1459
- state.config.getThumbnail = data.config.thumbnailHandler;
1460
- }
1461
- Object.assign(state.config, data.config);
1462
- }
1463
-
1464
- if (data.options) {
1465
- if (data.options.readonly) {
1466
- enableReadOnlyMode();
1467
- }
1468
- if (data.options.theme) {
1469
- setTheme(data.options.theme);
1470
- }
1471
- }
1472
- break;
1473
-
1474
- case 'setSchema':
1475
- if (data.schema) {
1476
- el.schemaInput.value = pretty(data.schema);
1477
- applySchema();
1478
- }
1479
- break;
1480
-
1481
- case 'setData':
1482
- if (data.data && state.formRoot) {
1483
- loadFormData(data.data);
1484
- }
1485
- break;
1486
-
1487
- case 'getData':
1488
- const currentData = getFormData();
1489
- window.parent.postMessage({
1490
- type: 'currentData',
1491
- data: currentData
1492
- }, '*');
1493
- break;
1494
-
1495
- case 'validate':
1496
- const isValid = validateForm();
1497
- window.parent.postMessage({
1498
- type: 'validationResult',
1499
- isValid: isValid
1500
- }, '*');
1501
- break;
1502
- }
1503
- }
1504
-
1505
- function enableReadOnlyMode() {
1506
- state.config.readonly = true;
1507
-
1508
- // Hide editing controls
1509
- const editingElements = [
1510
- el.applySchemaBtn, el.resetSchemaBtn, el.prettySchemaBtn,
1511
- el.downloadSchemaBtn, el.submitBtn, el.clearFormBtn,
1512
- el.loadPrefillBtn, el.copyTemplateBtn, el.saveDraftBtn
1513
- ];
1514
-
1515
- editingElements.forEach(element => {
1516
- if (element) element.classList.add('hidden');
1517
- });
1518
-
1519
- // Make schema input readonly
1520
- if (el.schemaInput) {
1521
- el.schemaInput.readOnly = true;
1522
- el.schemaInput.classList.add('bg-gray-100', 'cursor-not-allowed');
1523
- }
1524
-
1525
- // Make prefill input readonly
1526
- if (el.prefillInput) {
1527
- el.prefillInput.readOnly = true;
1528
- el.prefillInput.classList.add('bg-gray-100', 'cursor-not-allowed');
1529
- }
1530
-
1531
- // Disable form inputs
1532
- if (state.formRoot) {
1533
- const inputs = state.formRoot.querySelectorAll('input, textarea, select, button');
1534
- inputs.forEach(input => {
1535
- input.disabled = true;
1536
- input.classList.add('opacity-60', 'cursor-not-allowed');
1537
- });
1538
- }
1539
-
1540
- // Add read-only indicator to header
1541
- const header = document.querySelector('.bg-white.border-b');
1542
- if (header) {
1543
- const indicator = document.createElement('div');
1544
- indicator.className = 'bg-gray-100 border border-gray-300 rounded-lg px-3 py-1 text-xs text-gray-600 font-medium';
1545
- indicator.textContent = '👁️ Read-Only Mode';
1546
- header.querySelector('.flex.items-center.justify-between').appendChild(indicator);
1547
- }
1548
- }
1549
-
1550
- function setTheme(theme) {
1551
- document.documentElement.setAttribute('data-theme', theme);
1552
-
1553
- if (theme === 'dark') {
1554
- document.documentElement.style.colorScheme = 'dark';
1555
- } else if (theme === 'light') {
1556
- document.documentElement.style.colorScheme = 'light';
1557
- } else {
1558
- document.documentElement.style.colorScheme = 'light dark';
1559
- }
1560
- }
1561
-
1562
- function getFormData() {
1563
- if (!state.formRoot) return {};
1564
-
1565
- try {
1566
- const { result } = collectAndValidate(state.schema);
1567
- return result;
1568
- } catch (error) {
1569
- console.error('Error getting form data:', error);
1570
- return {};
1571
- }
1572
- }
1573
-
1574
- function loadFormData(data) {
1575
- if (!state.formRoot || !data) return;
1576
-
1577
- Object.keys(data).forEach(key => {
1578
- const input = state.formRoot.querySelector(`[data-key="${key}"]`);
1579
- if (!input) return;
1580
-
1581
- if (input.type === 'checkbox') {
1582
- input.checked = Boolean(data[key]);
1583
- } else if (input.type === 'file') {
1584
- // For file inputs, store the value in dataset
1585
- if (data[key]) {
1586
- input.dataset.fileInfo = JSON.stringify(data[key]);
1587
- // Trigger preview update if file preview is available
1588
- const event = new Event('change');
1589
- input.dispatchEvent(event);
1590
- }
1591
- } else {
1592
- input.value = data[key] || '';
1593
- }
1594
- });
1595
- }
1596
-
1597
- function validateForm() {
1598
- if (!state.formRoot || !state.schema) return true;
1599
-
1600
- try {
1601
- const { errors } = collectAndValidate(state.schema);
1602
- return errors.length === 0;
1603
- } catch {
1604
- return false;
1605
- }
1606
- }
1607
-
1608
- // Enhanced form submission to notify parent
1609
- function submitFormEnhanced() {
1610
- clearError(el.formErrors);
1611
- if (!state.schema) {
1612
- showError(el.formErrors, 'Schema not applied');
1613
- return;
1614
- }
1615
-
1616
- try {
1617
- const { result, errors } = collectAndValidate(state.schema);
1618
-
1619
- if (errors.length > 0) {
1620
- showError(el.formErrors, errors.join('\n'));
1621
- return;
1622
- }
1623
-
1624
- // Update output display
1625
- el.outputJson.value = pretty(result);
1626
-
1627
- // Notify parent window if embedded
1628
- if (window.parent !== window) {
1629
- window.parent.postMessage({
1630
- type: 'formSubmit',
1631
- data: result,
1632
- schema: state.schema
1633
- }, '*');
1634
- }
1635
-
1636
- // Dispatch custom event for direct integration
1637
- document.dispatchEvent(new CustomEvent('formSubmit', {
1638
- detail: { data: result, schema: state.schema }
1639
- }));
1640
-
1641
- } catch (error) {
1642
- showError(el.formErrors, 'Submission error: ' + error.message);
1643
- }
1644
- }
1645
-
1646
- // Replace original submit handler
1647
- el.submitBtn.addEventListener('click', submitFormEnhanced);
1648
-
1649
- // Draft save handler
1650
- el.saveDraftBtn.addEventListener('click', () => {
1651
- clearError(el.formErrors);
1652
- if (!state.schema) {
1653
- showError(el.formErrors, 'Schema not applied');
1654
- return;
1655
- }
1656
-
1657
- try {
1658
- const { result } = collectAndValidate(state.schema, true); // Skip validation for drafts
1659
-
1660
- // Update output display
1661
- el.outputJson.value = pretty(result);
1662
-
1663
- // Notify parent window if embedded
1664
- if (window.parent !== window) {
1665
- window.parent.postMessage({
1666
- type: 'draftSaved',
1667
- data: result,
1668
- schema: state.schema
1669
- }, '*');
1670
- }
1671
-
1672
- // Dispatch custom event for direct integration
1673
- document.dispatchEvent(new CustomEvent('draftSaved', {
1674
- detail: { data: result, schema: state.schema }
1675
- }));
1676
-
1677
- // Visual feedback
1678
- el.saveDraftBtn.textContent = 'Draft Saved!';
1679
- setTimeout(() => {
1680
- el.saveDraftBtn.textContent = 'Save Draft';
1681
- }, 2000);
1682
-
1683
- } catch (error) {
1684
- showError(el.formErrors, 'Draft save error: ' + error.message);
1685
- }
1686
- });
1687
-
1688
- // Initialize
1689
- function init() {
1690
- el.schemaInput.value = pretty(EXAMPLE_SCHEMA);
1691
- renderForm(EXAMPLE_SCHEMA, {});
1692
- loadSchemaFromURL();
1693
- setupGeppettoMessaging();
1694
-
1695
- // Expose configuration API
1696
- window.dispatchEvent(new CustomEvent('formBuilderReady', { detail: window.FormBuilderConfig }));
1697
- }
1698
-
1699
- // Start the application
1700
- init();
1701
- </script>
1702
- </body>
1703
- </html>