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