@dmitryvim/form-builder 0.1.8 → 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.
@@ -1,1201 +1,1377 @@
1
- /**
2
- * Form Builder Library v0.1.4
3
- * A reusable JSON schema form builder library
4
- * https://github.com/picazru/form-builder
5
- */
6
-
7
- (function (global, factory) {
8
- typeof exports === 'object' && typeof module !== 'undefined' ? factory(exports) :
9
- typeof define === 'function' && define.amd ? define(['exports'], factory) :
10
- (global = typeof globalThis !== 'undefined' ? globalThis : global || self, factory(global.FormBuilder = {}));
11
- })(this, (function (exports) {
12
- 'use strict';
13
-
14
- // Utility functions
15
- const sleep = (ms) => new Promise(r => setTimeout(r, ms));
16
- const pretty = (obj) => JSON.stringify(obj, null, 2);
17
- const deepClone = (obj) => structuredClone ? structuredClone(obj) : JSON.parse(JSON.stringify(obj));
18
- const isPlainObject = (v) => Object.prototype.toString.call(v) === '[object Object]';
19
- const setText = (node, text) => { node.textContent = text || ''; };
20
- const pathJoin = (base, key) => base ? `${base}.${key}` : key;
21
- const assert = (c, m) => { if(!c) throw new Error(m); };
22
- const warn = (m) => console.warn('[FormBuilder]', m);
23
-
24
- function clear(node) {
25
- while (node.firstChild) node.removeChild(node.firstChild);
26
- }
27
-
28
- function parseJSONSafe(text, fallback = null) {
29
- try {
30
- return JSON.parse(text);
31
- } catch {
32
- return fallback;
33
- }
34
- }
35
-
36
- async function makeResourceIdFromFile(file) {
37
- try {
38
- const buf = await file.arrayBuffer();
39
- if (crypto?.subtle?.digest) {
40
- const hash = await crypto.subtle.digest('SHA-256', buf);
41
- const hex = [...new Uint8Array(hash)].map(b => b.toString(16).padStart(2, '0')).join('');
42
- return `res_${hex.slice(0, 24)}`;
43
- }
44
- } catch(_) {}
45
- const rnd = Math.random().toString(36).slice(2) + Math.random().toString(36).slice(2);
46
- return `res_${rnd.slice(0, 24)}`;
47
- }
48
-
49
- function formatFileSize(bytes) {
50
- if (bytes === 0) return '0 B';
51
- const k = 1024;
52
- const sizes = ['B', 'KB', 'MB', 'GB'];
53
- const i = Math.floor(Math.log(bytes) / Math.log(k));
54
- return parseFloat((bytes / Math.pow(k, i)).toFixed(1)) + ' ' + sizes[i];
55
- }
56
-
57
- // Schema validation
58
- function validateSchema(schema) {
59
- const errors = [];
60
- try { assert(schema && schema.version === '0.3', 'schema.version must be "0.3"'); } catch(e) { errors.push(e.message); }
61
- try { assert(Array.isArray(schema.elements), 'schema.elements must be an array'); } catch(e) { errors.push(e.message); }
62
-
63
- function validateElements(elements, path) {
64
- const seen = new Set();
65
- elements.forEach((el, idx) => {
66
- const here = `${path}[${idx}]`;
67
- if (!el || typeof el !== 'object') { errors.push(`${here}: element must be object`); return; }
68
- if (!el.type) errors.push(`${here}: missing "type"`);
69
- if (!el.key) errors.push(`${here}: missing "key"`);
70
- if (el.key) {
71
- if (seen.has(el.key)) errors.push(`${path}: duplicate key "${el.key}"`);
72
- seen.add(el.key);
73
- }
74
- if (el.default !== undefined && (el.type === 'file' || el.type === 'files' || el.type === 'videos')) {
75
- errors.push(`${here}: default forbidden for "${el.type}"`);
76
- }
77
-
78
- // Type-specific validation
79
- if (el.type === 'text' || el.type === 'textarea') {
80
- if (el.minLength != null && el.maxLength != null && el.minLength > el.maxLength) {
81
- errors.push(`${here}: minLength > maxLength`);
82
- }
83
- if (el.pattern != null) {
84
- try { new RegExp(el.pattern); } catch { errors.push(`${here}: invalid pattern regex`); }
85
- }
86
- }
87
- if (el.type === 'number') {
88
- if (typeof el.min === 'number' && typeof el.max === 'number' && el.min > el.max) {
89
- errors.push(`${here}: min > max`);
90
- }
91
- if (el.decimals != null && (!Number.isInteger(el.decimals) || el.decimals < 0 || el.decimals > 8)) {
92
- errors.push(`${here}: decimals must be 0..8`);
93
- }
94
- }
95
- if (el.type === 'select') {
96
- if (!Array.isArray(el.options) || el.options.length === 0) {
97
- errors.push(`${here}: select.options must be non-empty array`);
98
- } else {
99
- const values = new Set(el.options.map(o => o.value));
100
- if (el.default != null && !values.has(el.default)) {
101
- errors.push(`${here}: default "${el.default}" not in options`);
102
- }
103
- }
104
- }
105
- if (el.type === 'file') {
106
- if (el.maxSizeMB != null && el.maxSizeMB <= 0) {
107
- errors.push(`${here}: maxSizeMB must be > 0`);
108
- }
109
- }
110
- if (el.type === 'files') {
111
- if (el.minCount != null && el.maxCount != null && el.minCount > el.maxCount) {
112
- errors.push(`${here}: minCount > maxCount`);
113
- }
114
- }
115
- if (el.type === 'videos') {
116
- if (el.minCount != null && el.maxCount != null && el.minCount > el.maxCount) {
117
- errors.push(`${here}: minCount > maxCount`);
118
- }
119
- }
120
- if (el.type === 'group') {
121
- if (!Array.isArray(el.elements)) errors.push(`${here}: group.elements must be array`);
122
- if (el.repeat) {
123
- if (el.repeat.min != null && el.repeat.max != null && el.repeat.min > el.repeat.max) {
124
- errors.push(`${here}: repeat.min > repeat.max`);
125
- }
126
- }
127
- // Validate element_label if provided (optional)
128
- if (el.element_label != null && typeof el.element_label !== 'string') {
129
- errors.push(`${here}: element_label must be a string`);
130
- }
131
- if (Array.isArray(el.elements)) validateElements(el.elements, pathJoin(path, el.key));
132
- }
133
- });
134
- }
135
-
136
- if (Array.isArray(schema.elements)) validateElements(schema.elements, 'elements');
137
- return errors;
138
- }
139
-
140
- // Form rendering state
141
- let resourceIndex = new Map();
142
- let config = {
1
+ // Form Builder Library - Core API
2
+ // State management
3
+ const state = {
4
+ schema: null,
5
+ formRoot: null,
6
+ resourceIndex: new Map(),
7
+ version: '1.0.0',
8
+ config: {
9
+ // File upload configuration
143
10
  uploadFile: null,
144
11
  downloadFile: null,
145
12
  getThumbnail: null,
13
+ getDownloadUrl: null,
14
+ // Default implementations
146
15
  enableFilePreview: true,
147
- maxPreviewSize: '200px'
148
- };
149
-
150
- function setTextValueFromPrefill(input, element, prefillObj, key) {
151
- let v = undefined;
152
- if (prefillObj && Object.prototype.hasOwnProperty.call(prefillObj, key)) v = prefillObj[key];
153
- else if (element.default !== undefined) v = element.default;
154
- if (v !== undefined) input.value = String(v);
16
+ maxPreviewSize: '200px',
17
+ readonly: false
155
18
  }
19
+ };
156
20
 
157
- function setNumberFromPrefill(input, element, prefillObj, key) {
158
- let v = undefined;
159
- if (prefillObj && Object.prototype.hasOwnProperty.call(prefillObj, key)) v = prefillObj[key];
160
- else if (element.default !== undefined) v = element.default;
161
- if (v !== undefined && v !== null && v !== '') input.value = String(v);
162
- }
21
+ // Utility functions
22
+ function isPlainObject(obj) {
23
+ return obj && typeof obj === 'object' && obj.constructor === Object;
24
+ }
163
25
 
164
- function setSelectFromPrefill(select, element, prefillObj, key) {
165
- const values = new Set(element.options.map(o => String(o.value)));
166
- let v = undefined;
167
- if (prefillObj && Object.prototype.hasOwnProperty.call(prefillObj, key)) v = prefillObj[key];
168
- else if (element.default !== undefined) v = element.default;
169
- if (v !== undefined && values.has(String(v))) select.value = String(v);
170
- else if (!element.required) select.value = '';
171
- }
26
+ function pathJoin(base, key) {
27
+ return base ? `${base}.${key}` : key;
28
+ }
172
29
 
173
- function fileValidationError(element, file) {
174
- if (!file) return 'no file';
175
- if (element.maxSizeMB != null && file.size > element.maxSizeMB * 1024 * 1024) {
176
- return `file too large > ${element.maxSizeMB}MB`;
177
- }
178
- if (element.accept) {
179
- const { extensions, mime } = element.accept;
180
- if (mime && Array.isArray(mime) && mime.length && !mime.includes(file.type)) {
181
- return `mime not allowed: ${file.type}`;
182
- }
183
- if (extensions && Array.isArray(extensions) && extensions.length) {
184
- const ext = (file.name.split('.').pop() || '').toLowerCase();
185
- if (!extensions.includes(ext)) return `extension .${ext} not allowed`;
186
- }
187
- }
188
- return null;
189
- }
30
+ function pretty(obj) {
31
+ return JSON.stringify(obj, null, 2);
32
+ }
190
33
 
191
- function markValidity(input, msg) {
192
- const prev = input?.parentElement?.querySelector?.('.error-message');
193
- if (prev) prev.remove();
194
-
195
- if (input) {
196
- input.classList.toggle('border-red-500', !!msg);
197
- input.classList.toggle('border-gray-300', !msg);
198
- }
199
-
200
- if (msg && input?.parentElement) {
201
- const m = document.createElement('div');
202
- m.className = 'error-message text-red-500 text-xs mt-1';
203
- m.textContent = msg;
204
- input.parentElement.appendChild(m);
205
- }
206
- }
34
+ function clear(node) {
35
+ while (node.firstChild) node.removeChild(node.firstChild);
36
+ }
207
37
 
208
- function makeFieldHint(element, extra = '') {
209
- const hint = document.createElement('div');
210
- hint.className = 'text-gray-500 text-xs mt-1';
211
- const bits = [];
212
-
213
- if (element.required) bits.push('required');
214
-
215
- if (element.type === 'text' || element.type === 'textarea') {
216
- if (element.minLength != null) bits.push(`minLength=${element.minLength}`);
217
- if (element.maxLength != null) bits.push(`maxLength=${element.maxLength}`);
218
- if (element.pattern) bits.push(`pattern=/${element.pattern}/`);
219
- }
220
- if (element.type === 'number') {
221
- if (element.min != null) bits.push(`min=${element.min}`);
222
- if (element.max != null) bits.push(`max=${element.max}`);
223
- if (element.decimals != null) bits.push(`decimals=${element.decimals}`);
224
- }
225
- if (element.type === 'select') {
226
- bits.push(`${element.options.length} options`);
227
- }
228
- if (element.type === 'files') {
229
- if (element.minCount != null) bits.push(`minCount=${element.minCount}`);
230
- if (element.maxCount != null) bits.push(`maxCount=${element.maxCount}`);
231
- }
232
- if (element.type === 'videos') {
233
- if (element.minCount != null) bits.push(`minCount=${element.minCount}`);
234
- if (element.maxCount != null) bits.push(`maxCount=${element.maxCount}`);
235
- }
236
-
237
- hint.textContent = [bits.join(' • '), extra].filter(Boolean).join(' | ');
238
- return hint;
38
+ // Schema validation
39
+ function validateSchema(schema) {
40
+ const errors = [];
41
+
42
+ if (!schema || typeof schema !== 'object') {
43
+ errors.push('Schema must be an object');
44
+ return errors;
239
45
  }
240
-
241
- async function renderFilePreview(container, resourceId, fileName, fileType) {
242
- container.innerHTML = '';
243
-
244
- const preview = document.createElement('div');
245
- preview.className = 'flex items-center gap-3 p-2';
246
-
247
- // File icon/thumbnail
248
- const iconContainer = document.createElement('div');
249
- iconContainer.className = 'w-12 h-12 rounded-lg flex items-center justify-center bg-blue-600 text-white text-xl flex-shrink-0';
250
-
251
- if (fileType.startsWith('image/')) {
252
- const img = document.createElement('img');
253
- img.className = 'w-12 h-12 object-cover rounded-lg';
254
-
255
- // Try to get thumbnail using custom function or fallback
256
- if (config.getThumbnail && typeof config.getThumbnail === 'function') {
257
- try {
258
- const thumbnailUrl = await config.getThumbnail(resourceId);
259
- img.src = thumbnailUrl;
260
- img.onerror = () => { iconContainer.textContent = '🖼️'; };
261
- iconContainer.innerHTML = '';
262
- iconContainer.appendChild(img);
263
- } catch {
264
- iconContainer.textContent = '🖼️';
265
- }
266
- } else {
267
- iconContainer.textContent = '🖼️';
268
- }
269
- } else if (fileType.startsWith('video/')) {
270
- iconContainer.textContent = '🎥';
271
- } else if (fileType.includes('pdf')) {
272
- iconContainer.textContent = '📄';
273
- } else {
274
- iconContainer.textContent = '📎';
275
- }
276
-
277
- preview.appendChild(iconContainer);
278
-
279
- // File info
280
- const info = document.createElement('div');
281
- info.className = 'flex-1';
282
-
283
- const name = document.createElement('div');
284
- name.className = 'font-medium text-sm text-gray-900';
285
- name.textContent = fileName;
286
-
287
- const details = document.createElement('div');
288
- details.className = 'text-xs text-gray-500 mt-1';
289
- details.textContent = `${fileType} • ${resourceId.slice(0, 12)}...`;
290
-
291
- info.appendChild(name);
292
- info.appendChild(details);
293
- preview.appendChild(info);
294
-
295
- // Action buttons
296
- const actions = document.createElement('div');
297
- actions.className = 'flex gap-2';
298
-
299
- // Download button
300
- const downloadBtn = document.createElement('button');
301
- downloadBtn.className = 'px-2 py-1 text-xs bg-gray-100 hover:bg-gray-200 rounded transition-colors';
302
- downloadBtn.textContent = '⬇️';
303
- downloadBtn.title = 'Download';
304
- downloadBtn.addEventListener('click', async () => {
305
- if (config.downloadFile && typeof config.downloadFile === 'function') {
306
- try {
307
- await config.downloadFile(resourceId, fileName);
308
- } catch (error) {
309
- console.error('Download failed:', error);
310
- }
311
- } else {
312
- console.log('Download simulated:', resourceId, fileName);
313
- }
314
- });
315
-
316
- // Remove button
317
- const removeBtn = document.createElement('button');
318
- removeBtn.className = 'px-2 py-1 text-xs bg-red-500 hover:bg-red-600 text-white rounded transition-colors';
319
- removeBtn.textContent = '✕';
320
- removeBtn.title = 'Remove';
321
- removeBtn.addEventListener('click', () => {
322
- const hiddenInput = container.closest('.file-container')?.querySelector('input[type="hidden"]');
323
- if (hiddenInput) {
324
- hiddenInput.value = '';
325
- }
326
- container.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>';
327
- });
328
-
329
- actions.appendChild(downloadBtn);
330
- actions.appendChild(removeBtn);
331
- preview.appendChild(actions);
332
-
333
- container.appendChild(preview);
46
+
47
+ if (!schema.version) {
48
+ errors.push('Schema missing version');
334
49
  }
335
50
 
336
- function renderResourcePills(container, rids, onRemove) {
337
- clear(container);
338
- container.className = 'flex flex-wrap gap-1.5 mt-2';
339
-
340
- rids.forEach(rid => {
341
- const meta = resourceIndex.get(rid);
342
- const pill = document.createElement('span');
343
- pill.className = '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';
344
- pill.textContent = rid;
51
+ if (!Array.isArray(schema.elements)) {
52
+ errors.push('Schema missing elements array');
53
+ return errors;
54
+ }
55
+
56
+ function validateElements(elements, path) {
57
+ elements.forEach((element, index) => {
58
+ const elementPath = `${path}[${index}]`;
345
59
 
346
- if (meta) {
347
- const small = document.createElement('span');
348
- small.className = 'text-gray-500';
349
- small.textContent = ` (${meta.name ?? 'file'}, ${formatFileSize(meta.size ?? 0)})`;
350
- pill.appendChild(small);
60
+ if (!element.type) {
61
+ errors.push(`${elementPath}: missing type`);
351
62
  }
352
63
 
353
- if (onRemove) {
354
- const x = document.createElement('button');
355
- x.type = 'button';
356
- x.className = 'bg-red-500 hover:bg-red-600 text-white text-xs px-1.5 py-0.5 rounded ml-1.5';
357
- x.textContent = '×';
358
- x.addEventListener('click', () => onRemove(rid));
359
- pill.appendChild(x);
64
+ if (!element.key) {
65
+ errors.push(`${elementPath}: missing key`);
360
66
  }
361
67
 
362
- container.appendChild(pill);
68
+ if (element.type === 'group' && element.elements) {
69
+ validateElements(element.elements, `${elementPath}.elements`);
70
+ }
71
+
72
+ if (element.type === 'select' && element.options) {
73
+ const defaultValue = element.default;
74
+ if (defaultValue !== undefined && defaultValue !== null && defaultValue !== '') {
75
+ const hasMatchingOption = element.options.some(opt => opt.value === defaultValue);
76
+ if (!hasMatchingOption) {
77
+ errors.push(`${elementPath}: default "${defaultValue}" not in options`);
78
+ }
79
+ }
80
+ }
363
81
  });
364
82
  }
83
+
84
+ if (Array.isArray(schema.elements)) validateElements(schema.elements, 'elements');
85
+ return errors;
86
+ }
365
87
 
366
- function renderElement(element, ctx, options = {}) {
367
- const wrapper = document.createElement('div');
368
- wrapper.className = 'mb-6';
88
+ // Form rendering
89
+ function renderForm(schema, prefill) {
90
+ const errors = validateSchema(schema);
91
+ if (errors.length > 0) {
92
+ console.error('Schema validation errors:', errors);
93
+ return;
94
+ }
95
+
96
+ state.schema = schema;
97
+ if (!state.formRoot) {
98
+ console.error('No form root element set. Call setFormRoot() first.');
99
+ return;
100
+ }
101
+
102
+ clear(state.formRoot);
103
+
104
+ const formEl = document.createElement('div');
105
+ formEl.className = 'space-y-6';
106
+
107
+ schema.elements.forEach(element => {
108
+ const block = renderElement(element, {
109
+ path: '',
110
+ prefill: prefill || {}
111
+ });
112
+ formEl.appendChild(block);
113
+ });
114
+
115
+ state.formRoot.appendChild(formEl);
116
+ }
117
+
118
+ function renderElement(element, ctx) {
119
+ const wrapper = document.createElement('div');
120
+ wrapper.className = 'mb-6';
121
+
122
+ const label = document.createElement('div');
123
+ label.className = 'flex items-center mb-2';
124
+ const title = document.createElement('label');
125
+ title.className = 'text-sm font-medium text-gray-900';
126
+ title.textContent = element.label || element.key;
127
+ if (element.required) {
128
+ const req = document.createElement('span');
129
+ req.className = 'text-red-500 ml-1';
130
+ req.textContent = '*';
131
+ title.appendChild(req);
132
+ }
133
+ label.appendChild(title);
134
+
135
+ // Add info button if there's description or hint
136
+ if (element.description || element.hint) {
137
+ const infoBtn = document.createElement('button');
138
+ infoBtn.type = 'button';
139
+ infoBtn.className = 'ml-2 text-gray-400 hover:text-gray-600';
140
+ infoBtn.innerHTML = '<svg class="w-4 h-4" fill="currentColor" viewBox="0 0 24 24"><path d="M12 2C6.48 2 2 6.48 2 12s4.48 10 10 10 10-4.48 10-10S17.52 2 12 2zm1 15h-2v-6h2v6zm0-8h-2V7h2v2z"/></svg>';
369
141
 
370
- const label = document.createElement('div');
371
- label.className = 'flex items-center mb-2';
372
- const title = document.createElement('label');
373
- title.className = 'text-sm font-medium text-gray-900';
374
- title.textContent = element.label || element.key;
375
- if (element.required) {
376
- const req = document.createElement('span');
377
- req.className = 'text-red-500 ml-1';
378
- req.textContent = '*';
379
- title.appendChild(req);
380
- }
381
- label.appendChild(title);
382
- wrapper.appendChild(label);
142
+ // Create tooltip
143
+ const tooltipId = `tooltip-${element.key}-${Math.random().toString(36).substr(2, 9)}`;
144
+ const tooltip = document.createElement('div');
145
+ tooltip.id = tooltipId;
146
+ tooltip.className = 'hidden absolute z-50 bg-gray-200 text-gray-900 text-sm rounded-lg p-3 max-w-sm border border-gray-300 shadow-lg';
147
+ tooltip.style.position = 'fixed';
148
+ tooltip.textContent = element.description || element.hint || 'Field information';
149
+ document.body.appendChild(tooltip);
150
+
151
+ infoBtn.onclick = (e) => {
152
+ e.preventDefault();
153
+ e.stopPropagation();
154
+ showTooltip(tooltipId, infoBtn);
155
+ };
156
+
157
+ label.appendChild(infoBtn);
158
+ }
159
+
160
+ wrapper.appendChild(label);
383
161
 
384
- const pathKey = pathJoin(ctx.path, element.key);
162
+ const pathKey = pathJoin(ctx.path, element.key);
385
163
 
386
- switch (element.type) {
387
- case 'text': {
388
- const input = document.createElement('input');
389
- input.type = 'text';
390
- input.name = pathKey;
391
- input.dataset.type = 'text';
392
- 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';
393
- setTextValueFromPrefill(input, element, ctx.prefill, element.key);
394
- input.addEventListener('input', () => markValidity(input, null));
395
- wrapper.appendChild(input);
396
- wrapper.appendChild(makeFieldHint(element));
397
- break;
398
- }
399
- case 'textarea': {
400
- const ta = document.createElement('textarea');
401
- ta.name = pathKey;
402
- ta.rows = 4;
403
- ta.dataset.type = 'textarea';
404
- 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';
405
- setTextValueFromPrefill(ta, element, ctx.prefill, element.key);
406
- ta.addEventListener('input', () => markValidity(ta, null));
407
- wrapper.appendChild(ta);
408
- wrapper.appendChild(makeFieldHint(element));
409
- break;
410
- }
411
- case 'number': {
412
- const input = document.createElement('input');
413
- input.type = 'number';
414
- input.name = pathKey;
415
- input.dataset.type = 'number';
416
- 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';
417
- if (element.step != null) input.step = String(element.step);
418
- if (element.min != null) input.min = String(element.min);
419
- if (element.max != null) input.max = String(element.max);
420
- setNumberFromPrefill(input, element, ctx.prefill, element.key);
421
- input.addEventListener('blur', () => {
422
- if (input.value === '') return;
423
- const v = parseFloat(input.value);
424
- if (Number.isFinite(v) && Number.isInteger(element.decimals ?? 0)) {
425
- input.value = String(Number(v.toFixed(element.decimals)));
164
+ switch (element.type) {
165
+ case 'text':
166
+ renderTextElement(element, ctx, wrapper, pathKey);
167
+ break;
168
+
169
+ case 'textarea':
170
+ renderTextareaElement(element, ctx, wrapper, pathKey);
171
+ break;
172
+
173
+ case 'number':
174
+ renderNumberElement(element, ctx, wrapper, pathKey);
175
+ break;
176
+
177
+ case 'select':
178
+ renderSelectElement(element, ctx, wrapper, pathKey);
179
+ break;
180
+
181
+ case 'file':
182
+ // TODO: Extract to renderFileElement() function
183
+ if (state.config.readonly) {
184
+ // Readonly mode: use common preview function
185
+ const initial = ctx.prefill[element.key] || element.default;
186
+ if (initial) {
187
+ const filePreview = renderFilePreviewReadonly(initial);
188
+ wrapper.appendChild(filePreview);
189
+ } else {
190
+ const emptyState = document.createElement('div');
191
+ emptyState.className = 'aspect-video bg-gray-100 rounded-lg flex items-center justify-center text-gray-500';
192
+ emptyState.innerHTML = '<div class="text-center">Нет файла</div>';
193
+ wrapper.appendChild(emptyState);
194
+ }
195
+ } else {
196
+ // Edit mode: normal file input
197
+ const fileWrapper = document.createElement('div');
198
+ fileWrapper.className = 'space-y-2';
199
+
200
+ const picker = document.createElement('input');
201
+ picker.type = 'file';
202
+ picker.name = pathKey;
203
+ picker.style.display = 'none'; // Hide default input
204
+ if (element.accept) {
205
+ if (element.accept.extensions) {
206
+ picker.accept = element.accept.extensions.map(ext => `.${ext}`).join(',');
426
207
  }
427
- });
428
- input.addEventListener('input', () => markValidity(input, null));
429
- wrapper.appendChild(input);
430
- wrapper.appendChild(makeFieldHint(element, `decimals=${element.decimals ?? 0}`));
431
- break;
432
- }
433
- case 'select': {
434
- const sel = document.createElement('select');
435
- sel.name = pathKey;
436
- sel.dataset.type = 'select';
437
- 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';
208
+ }
209
+
210
+ const fileContainer = document.createElement('div');
211
+ fileContainer.className = 'file-preview-container w-full aspect-square max-w-xs bg-gray-100 rounded-lg overflow-hidden relative group cursor-pointer';
438
212
 
439
- if (!element.required) {
440
- const opt = document.createElement('option');
441
- opt.value = '';
442
- opt.textContent = '—';
443
- sel.appendChild(opt);
213
+ const initial = ctx.prefill[element.key] || element.default;
214
+ if (initial) {
215
+ renderFilePreview(fileContainer, initial, initial, '');
216
+ } else {
217
+ fileContainer.innerHTML = `
218
+ <div class="flex flex-col items-center justify-center h-full text-gray-400">
219
+ <svg class="w-6 h-6 mb-2" fill="currentColor" viewBox="0 0 24 24">
220
+ <path d="M21 19V5c0-1.1-.9-2-2-2H5c-1.1 0-2 .9-2 2v14c0 1.1.9 2 2 2h14c1.1 0 2-.9 2-2zM8.5 13.5l2.5 3.01L14.5 12l4.5 6H5l3.5-4.5z"/>
221
+ </svg>
222
+ <div class="text-sm text-center">Нажмите или перетащите файл</div>
223
+ </div>
224
+ `;
444
225
  }
445
226
 
446
- element.options.forEach(o => {
447
- const opt = document.createElement('option');
448
- opt.value = String(o.value);
449
- opt.textContent = o.label ?? String(o.value);
450
- sel.appendChild(opt);
227
+ fileContainer.onclick = () => picker.click();
228
+ setupDragAndDrop(fileContainer, (files) => {
229
+ if (files.length > 0) {
230
+ handleFileSelect(files[0], fileContainer, pathKey);
231
+ }
451
232
  });
452
233
 
453
- setSelectFromPrefill(sel, element, ctx.prefill, element.key);
454
- sel.addEventListener('input', () => markValidity(sel, null));
455
- wrapper.appendChild(sel);
456
- break;
457
- }
458
- case 'file': {
459
- const hid = document.createElement('input');
460
- hid.type = 'hidden';
461
- hid.name = pathKey;
462
- hid.dataset.type = 'file';
234
+ picker.onchange = () => {
235
+ if (picker.files.length > 0) {
236
+ handleFileSelect(picker.files[0], fileContainer, pathKey);
237
+ }
238
+ };
463
239
 
464
- const container = document.createElement('div');
465
- container.className = 'file-container';
240
+ fileWrapper.appendChild(fileContainer);
241
+ fileWrapper.appendChild(picker);
466
242
 
467
- // Preview container
468
- const previewContainer = document.createElement('div');
469
- previewContainer.className = 'aspect-square w-full max-w-xs bg-gray-100 rounded-lg overflow-hidden border-2 border-dashed border-gray-300 hover:border-gray-400 transition-colors relative group cursor-pointer mb-3';
470
- previewContainer.onclick = () => picker.click();
243
+ // Add upload text
244
+ const uploadText = document.createElement('p');
245
+ uploadText.className = 'text-xs text-gray-600 mt-2 text-center';
246
+ uploadText.innerHTML = `<span class="underline cursor-pointer">Загрузите</span> или перетащите файл`;
247
+ uploadText.querySelector('span').onclick = () => picker.click();
248
+ fileWrapper.appendChild(uploadText);
471
249
 
472
- const picker = document.createElement('input');
473
- picker.type = 'file';
474
- if (element.accept?.extensions) {
475
- picker.accept = element.accept.extensions.map(ext => `.${ext}`).join(',');
476
- }
250
+ // Add hint
251
+ const fileHint = document.createElement('p');
252
+ fileHint.className = 'text-xs text-gray-500 mt-1 text-center';
253
+ fileHint.textContent = makeFieldHint(element);
254
+ fileWrapper.appendChild(fileHint);
477
255
 
478
- const handleFileSelect = async (file) => {
479
- const err = fileValidationError(element, file);
480
- if (err) {
481
- markValidity(picker, err);
482
- return;
483
- }
484
-
485
- try {
486
- let resourceId;
487
-
488
- // Use custom upload function if provided
489
- if (config.uploadFile && typeof config.uploadFile === 'function') {
490
- resourceId = await config.uploadFile(file);
491
- } else {
492
- // Fallback to simulated resource ID
493
- resourceId = await makeResourceIdFromFile(file);
494
- resourceIndex.set(resourceId, { name: file.name, type: file.type, size: file.size });
495
- }
496
-
497
- hid.value = resourceId;
498
- await renderFilePreview(previewContainer, resourceId, file.name, file.type);
499
- markValidity(picker, null);
500
- } catch (error) {
501
- markValidity(picker, `Upload failed: ${error.message}`);
502
- }
503
- };
256
+ wrapper.appendChild(fileWrapper);
257
+ }
258
+ break;
259
+
260
+ case 'files':
261
+ // TODO: Extract to renderFilesElement() function
262
+ if (state.config.readonly) {
263
+ // Readonly mode: render as results list like in workflow-preview.html
264
+ const resultsWrapper = document.createElement('div');
265
+ resultsWrapper.className = 'space-y-4';
504
266
 
505
- picker.addEventListener('change', async () => {
506
- if (picker.files && picker.files[0]) {
507
- await handleFileSelect(picker.files[0]);
508
- }
509
- });
267
+ const initialFiles = ctx.prefill[element.key] || [];
510
268
 
511
- // Handle prefilled values
512
- const pv = ctx.prefill && ctx.prefill[element.key];
513
- if (typeof pv === 'string' && pv) {
514
- hid.value = pv;
515
- // Try to render preview for existing resource
516
- const fileName = `file_${pv.slice(-8)}`;
517
- renderFilePreview(previewContainer, pv, fileName, 'application/octet-stream');
269
+ if (initialFiles.length > 0) {
270
+ initialFiles.forEach(resourceId => {
271
+ const filePreview = renderFilePreviewReadonly(resourceId);
272
+ resultsWrapper.appendChild(filePreview);
273
+ });
518
274
  } else {
519
- // Show upload prompt
520
- 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>';
275
+ resultsWrapper.innerHTML = `<div class="aspect-video bg-gray-100 rounded-lg flex items-center justify-center text-gray-500"><div class="text-center">Нет файлов</div></div>`;
521
276
  }
522
277
 
523
- const helpText = document.createElement('p');
524
- helpText.className = 'text-xs text-gray-600 mt-2 text-center';
525
- helpText.innerHTML = '<span class="underline cursor-pointer">Upload</span> or drag and drop file';
526
- helpText.onclick = () => picker.click();
278
+ wrapper.appendChild(resultsWrapper);
279
+ } else {
280
+ // Edit mode: normal files input
281
+ const filesWrapper = document.createElement('div');
282
+ filesWrapper.className = 'space-y-2';
527
283
 
528
- container.appendChild(previewContainer);
529
- container.appendChild(helpText);
530
- container.appendChild(picker);
531
- container.appendChild(hid);
284
+ const filesPicker = document.createElement('input');
285
+ filesPicker.type = 'file';
286
+ filesPicker.name = pathKey;
287
+ filesPicker.multiple = true;
288
+ filesPicker.style.display = 'none'; // Hide default input
289
+ if (element.accept) {
290
+ if (element.accept.extensions) {
291
+ filesPicker.accept = element.accept.extensions.map(ext => `.${ext}`).join(',');
292
+ }
293
+ }
532
294
 
533
- wrapper.appendChild(container);
534
- wrapper.appendChild(makeFieldHint(element, 'Returns resource ID for download/submission'));
535
- break;
536
- }
537
- case 'files': {
538
- const hid = document.createElement('input');
539
- hid.type = 'hidden';
540
- hid.name = pathKey;
541
- hid.dataset.type = 'files';
295
+ // Create container with border like in workflow-preview
296
+ const filesContainer = document.createElement('div');
297
+ filesContainer.className = 'border-2 border-dashed border-gray-300 rounded-lg p-3 hover:border-gray-400 transition-colors';
542
298
 
543
299
  const list = document.createElement('div');
544
- list.className = 'flex flex-wrap gap-1.5 mt-2';
300
+ list.className = 'files-list';
545
301
 
546
- const picker = document.createElement('input');
547
- picker.type = 'file';
548
- picker.multiple = true;
549
- if (element.accept?.extensions) {
550
- picker.accept = element.accept.extensions.map(ext => `.${ext}`).join(',');
551
- }
302
+ const initialFiles = ctx.prefill[element.key] || [];
552
303
 
553
- picker.addEventListener('change', async () => {
554
- let arr = parseJSONSafe(hid.value, []);
555
- if (!Array.isArray(arr)) arr = [];
556
-
557
- if (picker.files && picker.files.length) {
558
- for (const file of picker.files) {
559
- const err = fileValidationError(element, file);
560
- if (err) {
561
- markValidity(picker, err);
562
- return;
563
- }
304
+ function updateFilesList() {
305
+ renderResourcePills(list, initialFiles, (ridToRemove) => {
306
+ const index = initialFiles.indexOf(ridToRemove);
307
+ if (index > -1) {
308
+ initialFiles.splice(index, 1);
564
309
  }
310
+ updateFilesList(); // Re-render after removal
311
+ });
312
+ }
313
+
314
+ // Initial render
315
+ updateFilesList();
316
+
317
+ setupDragAndDrop(filesContainer, async (files) => {
318
+ const arr = Array.from(files);
319
+ for (const file of arr) {
320
+ let rid;
565
321
 
566
- for (const file of picker.files) {
322
+ // If uploadHandler is configured, use it to upload the file
323
+ if (state.config.uploadFile) {
567
324
  try {
568
- let resourceId;
569
- if (config.uploadFile && typeof config.uploadFile === 'function') {
570
- resourceId = await config.uploadFile(file);
571
- } else {
572
- resourceId = await makeResourceIdFromFile(file);
325
+ rid = await state.config.uploadFile(file);
326
+ if (typeof rid !== 'string') {
327
+ throw new Error('Upload handler must return a string resource ID');
573
328
  }
574
- resourceIndex.set(resourceId, { name: file.name, type: file.type, size: file.size });
575
- arr.push(resourceId);
576
329
  } catch (error) {
577
- markValidity(picker, `Upload failed: ${error.message}`);
578
- return;
330
+ throw new Error(`File upload failed: ${error.message}`);
579
331
  }
332
+ } else {
333
+ throw new Error('No upload handler configured. Set uploadHandler via FormBuilder.setUploadHandler()');
580
334
  }
581
335
 
582
- hid.value = JSON.stringify(arr);
583
- renderResourcePills(list, arr, (ridToRemove) => {
584
- const next = arr.filter(x => x !== ridToRemove);
585
- hid.value = JSON.stringify(next);
586
- arr = next;
587
- renderResourcePills(list, next, arguments.callee);
336
+ state.resourceIndex.set(rid, {
337
+ name: file.name,
338
+ type: file.type,
339
+ size: file.size,
340
+ file: null // Files are always uploaded, never stored locally
588
341
  });
589
- markValidity(picker, null);
342
+ initialFiles.push(rid);
590
343
  }
344
+ updateFilesList();
591
345
  });
592
346
 
593
- const pv = ctx.prefill && ctx.prefill[element.key];
594
- let initial = Array.isArray(pv) ? pv.filter(Boolean) : [];
595
- if (initial.length) {
596
- hid.value = JSON.stringify(initial);
597
- renderResourcePills(list, initial, (ridToRemove) => {
598
- const next = initial.filter(x => x !== ridToRemove);
599
- hid.value = JSON.stringify(next);
600
- initial = next;
601
- renderResourcePills(list, next, arguments.callee);
602
- });
603
- }
604
-
605
- wrapper.appendChild(picker);
606
- wrapper.appendChild(list);
607
- wrapper.appendChild(hid);
608
- wrapper.appendChild(makeFieldHint(element, 'Multiple files return resource ID array'));
609
- break;
610
- }
611
- case 'videos': {
612
- const hid = document.createElement('input');
613
- hid.type = 'hidden';
614
- hid.name = pathKey;
615
- hid.dataset.type = 'videos';
616
-
617
- const list = document.createElement('div');
618
- list.className = 'flex flex-col gap-3 mt-2';
619
-
620
- const picker = document.createElement('input');
621
- picker.type = 'file';
622
- picker.multiple = true;
623
- {
624
- const acc = [];
625
- if (element.accept?.mime && Array.isArray(element.accept.mime) && element.accept.mime.length) {
626
- acc.push(...element.accept.mime);
627
- }
628
- if (element.accept?.extensions && Array.isArray(element.accept.extensions) && element.accept.extensions.length) {
629
- acc.push(...element.accept.extensions.map(ext => `.${ext}`));
630
- }
631
- picker.accept = acc.length ? acc.join(',') : 'video/*';
632
- }
633
-
634
- const renderVideos = (rids) => {
635
- list.innerHTML = '';
636
- rids.forEach(rid => {
637
- const meta = resourceIndex.get(rid) || {};
638
- const row = document.createElement('div');
639
- row.className = 'flex items-start gap-3';
640
- const video = document.createElement('video');
641
- video.controls = true;
642
- video.className = 'w-48 max-w-full rounded border border-gray-300';
643
- // Use thumbnail as poster instead of loading video src
644
- if (config.getThumbnail) {
645
- Promise.resolve(config.getThumbnail(rid)).then(url => {
646
- if (url) {
647
- video.poster = url;
648
- }
649
- }).catch(() => {});
650
- }
651
- const info = document.createElement('div');
652
- info.className = 'flex-1 text-sm text-gray-700';
653
- info.textContent = `${meta.name || 'video'} (${formatFileSize(meta.size || 0)})`;
654
- const actions = document.createElement('div');
655
- actions.className = 'flex items-center gap-2';
656
- const downloadBtn = document.createElement('button');
657
- downloadBtn.type = 'button';
658
- downloadBtn.className = 'px-2 py-1 text-xs bg-gray-100 hover:bg-gray-200 rounded';
659
- downloadBtn.textContent = 'Download';
660
- downloadBtn.addEventListener('click', async () => {
661
- if (config.downloadFile && typeof config.downloadFile === 'function') {
662
- try { await config.downloadFile(rid, meta.name || 'video'); } catch(_) {}
663
- } else {
664
- console.log('Download simulated:', rid, meta.name || 'video');
665
- }
666
- });
667
- const remove = document.createElement('button');
668
- remove.type = 'button';
669
- remove.className = 'px-2 py-1 text-xs bg-red-500 hover:bg-red-600 text-white rounded';
670
- remove.textContent = 'Remove';
671
- remove.addEventListener('click', () => {
672
- const arr = parseJSONSafe(hid.value, []);
673
- const next = Array.isArray(arr) ? arr.filter(x => x !== rid) : [];
674
- hid.value = JSON.stringify(next);
675
- renderVideos(next);
676
- });
677
- row.appendChild(video);
678
- row.appendChild(info);
679
- actions.appendChild(downloadBtn);
680
- actions.appendChild(remove);
681
- row.appendChild(actions);
682
- list.appendChild(row);
683
- });
684
- };
685
-
686
- picker.addEventListener('change', async () => {
687
- let arr = parseJSONSafe(hid.value, []);
688
- if (!Array.isArray(arr)) arr = [];
689
- if (picker.files && picker.files.length) {
690
- for (const file of picker.files) {
691
- const err = fileValidationError(element, file);
692
- if (err) {
693
- markValidity(picker, err);
694
- return;
695
- }
696
- // additionally ensure it's a video
697
- if (!file.type.startsWith('video/')) {
698
- markValidity(picker, 'mime not allowed: ' + file.type);
699
- return;
700
- }
701
- }
702
- for (const file of picker.files) {
347
+ filesPicker.onchange = async () => {
348
+ for (const file of Array.from(filesPicker.files)) {
349
+ let rid;
350
+
351
+ // If uploadHandler is configured, use it to upload the file
352
+ if (state.config.uploadFile) {
703
353
  try {
704
- let resourceId;
705
- if (config.uploadFile && typeof config.uploadFile === 'function') {
706
- resourceId = await config.uploadFile(file);
707
- } else {
708
- resourceId = await makeResourceIdFromFile(file);
354
+ rid = await state.config.uploadFile(file);
355
+ if (typeof rid !== 'string') {
356
+ throw new Error('Upload handler must return a string resource ID');
709
357
  }
710
- resourceIndex.set(resourceId, { name: file.name, type: file.type, size: file.size });
711
- arr.push(resourceId);
712
358
  } catch (error) {
713
- markValidity(picker, `Upload failed: ${error.message}`);
714
- return;
359
+ throw new Error(`File upload failed: ${error.message}`);
715
360
  }
361
+ } else {
362
+ throw new Error('No upload handler configured. Set uploadHandler via FormBuilder.setUploadHandler()');
716
363
  }
717
- hid.value = JSON.stringify(arr);
718
- renderVideos(arr);
719
- markValidity(picker, null);
364
+
365
+ state.resourceIndex.set(rid, {
366
+ name: file.name,
367
+ type: file.type,
368
+ size: file.size,
369
+ file: null // Files are always uploaded, never stored locally
370
+ });
371
+ initialFiles.push(rid);
720
372
  }
721
- });
373
+ updateFilesList();
374
+ // Clear the file input
375
+ filesPicker.value = '';
376
+ };
722
377
 
723
- const pv = ctx.prefill && ctx.prefill[element.key];
724
- let initial = Array.isArray(pv) ? pv.filter(Boolean) : [];
725
- if (initial.length) {
726
- hid.value = JSON.stringify(initial);
727
- renderVideos(initial);
728
- }
378
+ filesContainer.appendChild(list);
729
379
 
730
- wrapper.appendChild(picker);
731
- wrapper.appendChild(list);
732
- wrapper.appendChild(hid);
733
- wrapper.appendChild(makeFieldHint(element, 'Multiple videos return resource ID array'));
734
- break;
735
- }
736
- case 'group': {
737
- wrapper.dataset.group = element.key;
738
- wrapper.dataset.groupPath = pathKey;
380
+ filesWrapper.appendChild(filesContainer);
381
+ filesWrapper.appendChild(filesPicker);
739
382
 
740
- const groupWrap = document.createElement('div');
383
+ // Add hint
384
+ const filesHint = document.createElement('p');
385
+ filesHint.className = 'text-xs text-gray-500 mt-1 text-center';
386
+ filesHint.textContent = makeFieldHint(element);
387
+ filesWrapper.appendChild(filesHint);
741
388
 
742
- // Group title (above the whole group)
743
- const groupTitle = document.createElement('div');
744
- groupTitle.className = 'text-lg font-semibold text-gray-900 mb-4';
745
- groupTitle.textContent = element.label || element.key;
746
- groupWrap.appendChild(groupTitle);
389
+ wrapper.appendChild(filesWrapper);
390
+ }
391
+ break;
392
+
393
+ case 'group':
394
+ // TODO: Extract to renderGroupElement() function
395
+ const groupWrap = document.createElement('div');
396
+ groupWrap.className = 'border border-gray-200 rounded-lg p-4 bg-gray-50';
397
+
398
+ const header = document.createElement('div');
399
+ header.className = 'flex justify-between items-center mb-4';
400
+
401
+ const left = document.createElement('div');
402
+ left.className = 'flex-1';
403
+
404
+ const right = document.createElement('div');
405
+ right.className = 'flex gap-2';
406
+
407
+ const itemsWrap = document.createElement('div');
408
+ itemsWrap.className = 'space-y-4';
409
+
410
+ groupWrap.appendChild(header);
411
+ header.appendChild(left);
412
+ header.appendChild(right);
413
+
414
+ if (element.repeat && isPlainObject(element.repeat)) {
415
+ const min = element.repeat.min ?? 0;
416
+ const max = element.repeat.max ?? Infinity;
417
+ const pre = Array.isArray(ctx.prefill?.[element.key]) ? ctx.prefill[element.key] : null;
418
+
419
+ header.appendChild(right);
420
+
421
+ const countItems = () => itemsWrap.querySelectorAll(':scope > .groupItem').length;
422
+
423
+ const addItem = (prefillObj) => {
424
+ const item = document.createElement('div');
425
+ 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';
426
+ const subCtx = {
427
+ path: pathJoin(ctx.path, element.key + `[${countItems()}]`),
428
+ prefill: prefillObj || {}
429
+ };
430
+ element.elements.forEach(child => item.appendChild(renderElement(child, subCtx)));
431
+
432
+ const rem = document.createElement('button');
433
+ rem.type = 'button';
434
+ rem.className = 'bg-red-500 hover:bg-red-600 text-white px-2 py-1 rounded text-xs font-medium transition-colors';
435
+ rem.textContent = 'Удалить';
436
+ rem.addEventListener('click', () => {
437
+ if (countItems() <= (element.repeat.min ?? 0)) return;
438
+ itemsWrap.removeChild(item);
439
+ refreshControls();
440
+ });
441
+ item.appendChild(rem);
442
+ itemsWrap.appendChild(item);
443
+ refreshControls();
444
+ };
445
+
446
+ groupWrap.appendChild(itemsWrap);
747
447
 
748
- const header = document.createElement('div');
749
- header.className = 'flex items-center justify-between my-2 pb-2 border-b border-gray-200';
448
+ // Add button after items
449
+ const addBtn = document.createElement('button');
450
+ addBtn.type = 'button';
451
+ addBtn.className = 'w-full py-2 px-4 border border-gray-300 rounded-lg text-gray-700 hover:bg-gray-50 flex items-center justify-center mt-3';
452
+ addBtn.innerHTML = '<svg class="w-4 h-4 mr-2" fill="currentColor" viewBox="0 0 24 24"><path d="M19 13h-6v6h-2v-6H5v-2h6V5h2v6h6v2z"/></svg>Добавить элемент';
453
+ groupWrap.appendChild(addBtn);
750
454
 
751
- const left = document.createElement('div');
752
- header.appendChild(left);
455
+ const refreshControls = () => {
456
+ const n = countItems();
457
+ addBtn.disabled = n >= max;
458
+ 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>`;
459
+ };
753
460
 
754
- const right = document.createElement('div');
755
- groupWrap.appendChild(header);
461
+ if (pre && pre.length) {
462
+ const n = Math.min(max, Math.max(min, pre.length));
463
+ for (let i = 0; i < n; i++) addItem(pre[i]);
464
+ } else {
465
+ const n = Math.max(min, 0);
466
+ for (let i = 0; i < n; i++) addItem(null);
467
+ }
756
468
 
757
- const itemsWrap = document.createElement('div');
758
- itemsWrap.dataset.itemsFor = element.key;
469
+ addBtn.addEventListener('click', () => addItem(null));
470
+ } else {
471
+ // Single object group
472
+ const subCtx = {
473
+ path: pathJoin(ctx.path, element.key),
474
+ prefill: ctx.prefill?.[element.key] || {}
475
+ };
476
+ element.elements.forEach(child => itemsWrap.appendChild(renderElement(child, subCtx)));
477
+ groupWrap.appendChild(itemsWrap);
478
+ left.innerHTML = `<span>${element.label || element.key}</span>`;
479
+ }
480
+
481
+ wrapper.appendChild(groupWrap);
482
+ break;
759
483
 
760
- if (element.repeat && isPlainObject(element.repeat)) {
761
- const min = element.repeat.min ?? 0;
762
- const max = element.repeat.max ?? Infinity;
763
- const pre = Array.isArray(ctx.prefill?.[element.key]) ? ctx.prefill[element.key] : null;
764
-
765
- const addBtn = document.createElement('button');
766
- addBtn.type = 'button';
767
- addBtn.className = 'bg-blue-600 hover:bg-blue-700 text-white px-3 py-1.5 rounded-md text-sm font-medium transition-colors';
768
- addBtn.textContent = 'Add';
769
- right.appendChild(addBtn);
770
- header.appendChild(right);
484
+ default:
485
+ const unsupported = document.createElement('div');
486
+ unsupported.className = 'text-red-500 text-sm';
487
+ unsupported.textContent = `Unsupported field type: ${element.type}`;
488
+ wrapper.appendChild(unsupported);
489
+ }
771
490
 
772
- const countItems = () => itemsWrap.querySelectorAll(':scope > .groupItem').length;
773
- const refreshControls = () => {
774
- const n = countItems();
775
- addBtn.disabled = n >= max;
776
- left.innerHTML = `<span class="text-sm text-gray-600">Items: ${n} / ${max === Infinity ? '∞' : max} (min: ${min})</span>`;
777
- };
491
+ return wrapper;
492
+ }
778
493
 
779
- const updateItemIndexes = () => {
780
- const items = itemsWrap.querySelectorAll(':scope > .groupItem');
781
- items.forEach((item, index) => {
782
- const titleElement = item.querySelector('h4');
783
- if (titleElement) {
784
- let labelText;
785
- if (element.element_label) {
786
- labelText = element.element_label.replace('$index', index + 1);
787
- } else {
788
- labelText = `${element.label || element.key} #${index + 1}`;
789
- }
790
- titleElement.textContent = labelText;
791
- }
792
- });
793
- };
494
+ function makeFieldHint(element) {
495
+ const parts = [];
496
+
497
+ if (element.required) {
498
+ parts.push('required');
499
+ } else {
500
+ parts.push('optional');
501
+ }
502
+
503
+ if (element.minLength != null || element.maxLength != null) {
504
+ if (element.minLength != null && element.maxLength != null) {
505
+ parts.push(`length=${element.minLength}-${element.maxLength} characters`);
506
+ } else if (element.maxLength != null) {
507
+ parts.push(`max=${element.maxLength} characters`);
508
+ } else if (element.minLength != null) {
509
+ parts.push(`min=${element.minLength} characters`);
510
+ }
511
+ }
512
+
513
+ if (element.min != null || element.max != null) {
514
+ if (element.min != null && element.max != null) {
515
+ parts.push(`range=${element.min}-${element.max}`);
516
+ } else if (element.max != null) {
517
+ parts.push(`max=${element.max}`);
518
+ } else if (element.min != null) {
519
+ parts.push(`min=${element.min}`);
520
+ }
521
+ }
522
+
523
+ if (element.maxSizeMB) {
524
+ parts.push(`max_size=${element.maxSizeMB}MB`);
525
+ }
526
+
527
+ if (element.accept && element.accept.extensions) {
528
+ parts.push(`formats=${element.accept.extensions.map(ext => ext.toUpperCase()).join(',')}`);
529
+ }
530
+
531
+ if (element.pattern && !element.pattern.includes('А-Я')) {
532
+ parts.push('plain text only');
533
+ } else if (element.pattern && element.pattern.includes('А-Я')) {
534
+ parts.push('text with punctuation');
535
+ }
536
+
537
+ return parts.join(' • ');
538
+ }
794
539
 
795
- const addItem = (prefillObj) => {
796
- const itemIndex = countItems() + 1;
797
- const item = document.createElement('div');
798
- item.className = 'groupItem border border-dashed border-gray-300 rounded-lg p-4 mb-3 bg-blue-50/30';
799
-
800
- // Individual item title with index
801
- const itemTitle = document.createElement('div');
802
- itemTitle.className = 'flex items-center justify-between mb-4 pb-2 border-b border-gray-300';
803
-
804
- const itemLabel = document.createElement('h4');
805
- itemLabel.className = 'text-md font-medium text-gray-800';
806
-
807
- // Use element_label if provided, with $index placeholder support
808
- let labelText;
809
- if (element.element_label) {
810
- labelText = element.element_label.replace('$index', itemIndex);
811
- } else {
812
- labelText = `${element.label || element.key} #${itemIndex}`;
813
- }
814
- itemLabel.textContent = labelText;
815
- itemTitle.appendChild(itemLabel);
816
-
817
- // Add remove button to title
818
- const rem = document.createElement('button');
819
- rem.type = 'button';
820
- rem.className = 'bg-red-500 hover:bg-red-600 text-white px-2 py-1 rounded text-xs font-medium transition-colors';
821
- rem.textContent = 'Remove';
822
- rem.addEventListener('click', () => {
823
- if (countItems() <= (element.repeat.min ?? 0)) return;
824
- itemsWrap.removeChild(item);
825
- refreshControls();
826
- // Re-index remaining items
827
- updateItemIndexes();
828
- });
829
- itemTitle.appendChild(rem);
830
-
831
- const subCtx = {
832
- path: pathJoin(ctx.path, element.key + `[${countItems()}]`),
833
- prefill: prefillObj || {}
834
- };
835
-
836
- item.appendChild(itemTitle);
837
- element.elements.forEach(child => item.appendChild(renderElement(child, subCtx, options)));
838
- itemsWrap.appendChild(item);
839
- refreshControls();
540
+ async function renderFilePreview(container, resourceId, fileName, fileType, isReadonly = false) {
541
+ // Don't change container className - preserve max-w-xs and other styling
542
+
543
+ // Clear container content first
544
+ clear(container);
545
+
546
+ if (isReadonly) {
547
+ container.classList.add('cursor-pointer');
548
+ }
549
+
550
+ const img = document.createElement('img');
551
+ img.className = 'w-full h-full object-contain';
552
+ img.alt = fileName || 'Preview';
553
+
554
+ // Use stored file from resourceIndex if available, or try getThumbnail
555
+ const meta = state.resourceIndex.get(resourceId);
556
+ if (meta && meta.file && meta.file instanceof File) {
557
+ // For local files, use FileReader to display preview
558
+ if (meta.type && meta.type.startsWith('image/')) {
559
+ const reader = new FileReader();
560
+ reader.onload = (e) => {
561
+ img.src = e.target.result;
562
+ };
563
+ reader.readAsDataURL(meta.file);
564
+ container.appendChild(img);
565
+ } else {
566
+ // Non-image file
567
+ container.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">' + fileName + '</div></div>';
568
+ }
569
+ } else if (state.config.getThumbnail) {
570
+ // Try to get thumbnail from config for uploaded files
571
+ try {
572
+ const thumbnailUrl = await state.config.getThumbnail(resourceId);
573
+ if (thumbnailUrl) {
574
+ img.src = thumbnailUrl;
575
+ container.appendChild(img);
576
+ } else {
577
+ // Fallback to file icon
578
+ container.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">' + fileName + '</div></div>';
579
+ }
580
+ } catch (error) {
581
+ console.warn('Thumbnail loading failed:', error);
582
+ container.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">' + fileName + '</div></div>';
583
+ }
584
+ } else {
585
+ // No file and no getThumbnail config - fallback
586
+ container.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">' + fileName + '</div></div>';
587
+ }
588
+
589
+ // Add overlay with download/remove buttons if needed
590
+ if (!isReadonly) {
591
+ const overlay = document.createElement('div');
592
+ overlay.className = 'absolute inset-0 bg-black bg-opacity-50 opacity-0 group-hover:opacity-100 transition-opacity flex items-center justify-center';
593
+ const buttonContainer = document.createElement('div');
594
+ buttonContainer.className = 'flex gap-2';
595
+
596
+ const removeBtn = document.createElement('button');
597
+ removeBtn.className = 'bg-red-600 text-white px-2 py-1 rounded text-xs';
598
+ removeBtn.textContent = 'Удалить';
599
+ removeBtn.onclick = (e) => {
600
+ e.stopPropagation();
601
+ const hiddenInput = container.parentElement.querySelector('input[type="hidden"]');
602
+ if (hiddenInput) hiddenInput.value = '';
603
+ container.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 or drop to upload</div></div>';
604
+ };
605
+
606
+ buttonContainer.appendChild(removeBtn);
607
+ overlay.appendChild(buttonContainer);
608
+ container.appendChild(overlay);
609
+ } else if (isReadonly && state.config.downloadFile) {
610
+ // Add click handler for download in readonly mode
611
+ container.style.cursor = 'pointer';
612
+ container.onclick = () => {
613
+ if (state.config.downloadFile) {
614
+ state.config.downloadFile(resourceId, fileName);
615
+ }
616
+ };
617
+ }
618
+ }
619
+
620
+ function renderResourcePills(container, rids, onRemove) {
621
+ clear(container);
622
+
623
+ // Show empty placeholder if no files
624
+ if (!rids || rids.length === 0) {
625
+ container.innerHTML = `
626
+ <div class="grid grid-cols-4 gap-3 mb-3">
627
+ <div class="aspect-square bg-gray-100 border-2 border-dashed border-gray-300 rounded flex items-center justify-center cursor-pointer hover:border-gray-400 transition-colors">
628
+ <svg class="w-6 h-6 text-gray-400" fill="currentColor" viewBox="0 0 24 24">
629
+ <path d="M21 19V5c0-1.1-.9-2-2-2H5c-1.1 0-2 .9-2 2v14c0 1.1.9 2 2 2h14c1.1 0 2-.9 2-2zM8.5 13.5l2.5 3.01L14.5 12l4.5 6H5l3.5-4.5z"/>
630
+ </svg>
631
+ </div>
632
+ <div class="aspect-square bg-gray-100 border-2 border-dashed border-gray-300 rounded flex items-center justify-center cursor-pointer hover:border-gray-400 transition-colors">
633
+ <svg class="w-6 h-6 text-gray-400" fill="currentColor" viewBox="0 0 24 24">
634
+ <path d="M21 19V5c0-1.1-.9-2-2-2H5c-1.1 0-2 .9-2 2v14c0 1.1.9 2 2 2h14c1.1 0 2-.9 2-2zM8.5 13.5l2.5 3.01L14.5 12l4.5 6H5l3.5-4.5z"/>
635
+ </svg>
636
+ </div>
637
+ <div class="aspect-square bg-gray-100 border-2 border-dashed border-gray-300 rounded flex items-center justify-center cursor-pointer hover:border-gray-400 transition-colors">
638
+ <svg class="w-6 h-6 text-gray-400" fill="currentColor" viewBox="0 0 24 24">
639
+ <path d="M21 19V5c0-1.1-.9-2-2-2H5c-1.1 0-2 .9-2 2v14c0 1.1.9 2 2 2h14c1.1 0 2-.9 2-2zM8.5 13.5l2.5 3.01L14.5 12l4.5 6H5l3.5-4.5z"/>
640
+ </svg>
641
+ </div>
642
+ <div class="aspect-square bg-gray-100 border-2 border-dashed border-gray-300 rounded flex items-center justify-center cursor-pointer hover:border-gray-400 transition-colors">
643
+ <svg class="w-6 h-6 text-gray-400" fill="currentColor" viewBox="0 0 24 24">
644
+ <path d="M21 19V5c0-1.1-.9-2-2-2H5c-1.1 0-2 .9-2 2v14c0 1.1.9 2 2 2h14c1.1 0 2-.9 2-2zM8.5 13.5l2.5 3.01L14.5 12l4.5 6H5l3.5-4.5z"/>
645
+ </svg>
646
+ </div>
647
+ </div>
648
+ <div class="text-center text-xs text-gray-600">
649
+ <span class="underline cursor-pointer">Загрузите</span> или перетащите файлы
650
+ </div>
651
+ `;
652
+ // Add click handler to the entire placeholder
653
+ container.onclick = () => {
654
+ const fileInput = container.parentElement?.querySelector('input[type="file"]');
655
+ if (fileInput) fileInput.click();
656
+ };
657
+
658
+ // Also add specific handler to the "Загрузите" link
659
+ const uploadLink = container.querySelector('span.underline');
660
+ if (uploadLink) {
661
+ uploadLink.onclick = (e) => {
662
+ e.stopPropagation(); // Prevent double trigger
663
+ const fileInput = container.parentElement?.querySelector('input[type="file"]');
664
+ if (fileInput) fileInput.click();
665
+ };
666
+ }
667
+ return;
668
+ }
669
+
670
+ // Show files grid
671
+ container.className = 'grid grid-cols-4 gap-3 mt-2';
672
+
673
+ // Calculate how many slots we need (at least 4, then expand by rows of 4)
674
+ const minSlots = 4;
675
+ const currentImagesCount = rids.length;
676
+ const slotsNeeded = Math.max(minSlots, Math.ceil((currentImagesCount + 1) / 4) * 4);
677
+
678
+ // Add all slots (filled and empty)
679
+ for (let i = 0; i < slotsNeeded; i++) {
680
+ const slot = document.createElement('div');
681
+
682
+ if (i < rids.length) {
683
+ // Filled slot with image preview
684
+ const rid = rids[i];
685
+ const meta = state.resourceIndex.get(rid);
686
+ slot.className = 'aspect-square bg-gray-100 rounded-lg overflow-hidden relative group border border-gray-300';
687
+
688
+ // Add image or file content
689
+ if (meta && meta.type?.startsWith('image/')) {
690
+ if (meta.file && meta.file instanceof File) {
691
+ // Use FileReader for local files
692
+ const img = document.createElement('img');
693
+ img.className = 'w-full h-full object-contain';
694
+ img.alt = meta.name;
695
+
696
+ const reader = new FileReader();
697
+ reader.onload = (e) => {
698
+ img.src = e.target.result;
840
699
  };
841
-
842
- groupWrap.appendChild(itemsWrap);
700
+ reader.readAsDataURL(meta.file);
701
+ slot.appendChild(img);
702
+ } else if (state.config.getThumbnail) {
703
+ // Use getThumbnail for uploaded files
704
+ const img = document.createElement('img');
705
+ img.className = 'w-full h-full object-contain';
706
+ img.alt = meta.name;
843
707
 
844
- if (pre && pre.length) {
845
- const n = Math.min(max, Math.max(min, pre.length));
846
- for (let i = 0; i < n; i++) addItem(pre[i]);
708
+ const url = state.config.getThumbnail(rid);
709
+ if (url) {
710
+ img.src = url;
711
+ slot.appendChild(img);
847
712
  } else {
848
- const n = Math.max(min, 0);
849
- for (let i = 0; i < n; i++) addItem(null);
713
+ slot.innerHTML = `<div class="flex flex-col items-center justify-center h-full text-gray-400">
714
+ <svg class="w-6 h-6" fill="currentColor" viewBox="0 0 24 24">
715
+ <path d="M21 19V5c0-1.1-.9-2-2-2H5c-1.1 0-2 .9-2 2v14c0 1.1.9 2 2 2h14c1.1 0 2-.9 2-2zM8.5 13.5l2.5 3.01L14.5 12l4.5 6H5l3.5-4.5z"/>
716
+ </svg>
717
+ </div>`;
850
718
  }
851
-
852
- addBtn.addEventListener('click', () => addItem(null));
853
719
  } else {
854
- // Single object group
855
- const subCtx = {
856
- path: pathJoin(ctx.path, element.key),
857
- prefill: ctx.prefill?.[element.key] || {}
858
- };
859
- element.elements.forEach(child => itemsWrap.appendChild(renderElement(child, subCtx, options)));
860
- groupWrap.appendChild(itemsWrap);
720
+ slot.innerHTML = `<div class="flex flex-col items-center justify-center h-full text-gray-400">
721
+ <svg class="w-6 h-6" fill="currentColor" viewBox="0 0 24 24">
722
+ <path d="M21 19V5c0-1.1-.9-2-2-2H5c-1.1 0-2 .9-2 2v14c0 1.1.9 2 2 2h14c1.1 0 2-.9 2-2zM8.5 13.5l2.5 3.01L14.5 12l4.5 6H5l3.5-4.5z"/>
723
+ </svg>
724
+ </div>`;
861
725
  }
862
-
863
- wrapper.innerHTML = '';
864
- wrapper.appendChild(groupWrap);
865
- break;
726
+ } else {
727
+ slot.innerHTML = `<div class="flex flex-col items-center justify-center h-full text-gray-400">
728
+ <div class="text-2xl mb-1">📁</div>
729
+ <div class="text-xs">${meta?.name || 'File'}</div>
730
+ </div>`;
866
731
  }
867
- default:
868
- wrapper.appendChild(document.createTextNode(`Unsupported type: ${element.type}`));
732
+
733
+ // Add remove button overlay (similar to file field)
734
+ if (onRemove) {
735
+ const overlay = document.createElement('div');
736
+ overlay.className = 'absolute inset-0 bg-black bg-opacity-50 opacity-0 group-hover:opacity-100 transition-opacity flex items-center justify-center';
737
+
738
+ const removeBtn = document.createElement('button');
739
+ removeBtn.className = 'bg-red-600 text-white px-2 py-1 rounded text-xs';
740
+ removeBtn.textContent = 'Удалить';
741
+ removeBtn.onclick = (e) => {
742
+ e.stopPropagation();
743
+ onRemove(rid);
744
+ };
745
+
746
+ overlay.appendChild(removeBtn);
747
+ slot.appendChild(overlay);
748
+ }
749
+ } else {
750
+ // Empty slot placeholder
751
+ slot.className = 'aspect-square bg-gray-100 border-2 border-dashed border-gray-300 rounded-lg flex items-center justify-center cursor-pointer hover:border-gray-400 transition-colors';
752
+ slot.innerHTML = '<svg class="w-6 h-6 text-gray-400" fill="currentColor" viewBox="0 0 24 24"><path d="M21 19V5c0-1.1-.9-2-2-2H5c-1.1 0-2 .9-2 2v14c0 1.1.9 2 2 2h14c1.1 0 2-.9 2-2zM8.5 13.5l2.5 3.01L14.5 12l4.5 6H5l3.5-4.5z"/></svg>';
753
+ slot.onclick = () => {
754
+ const fileInput = container.parentElement?.querySelector('input[type="file"]');
755
+ if (fileInput) fileInput.click();
756
+ };
869
757
  }
870
758
 
871
- return wrapper;
759
+ container.appendChild(slot);
872
760
  }
761
+ }
873
762
 
874
- // Form data collection and validation
875
- function collectAndValidate(schema, formRoot, skipValidation = false) {
876
- const errors = [];
763
+ function formatFileSize(bytes) {
764
+ if (bytes === 0) return '0 B';
765
+ const k = 1024;
766
+ const sizes = ['B', 'KB', 'MB', 'GB'];
767
+ const i = Math.floor(Math.log(bytes) / Math.log(k));
768
+ return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + ' ' + sizes[i];
769
+ }
877
770
 
878
- function collectElement(element, scopeRoot, elementPath = '') {
879
- const key = element.key;
880
- const fullPath = elementPath ? `${elementPath}.${key}` : key;
881
-
882
- switch (element.type) {
883
- case 'text':
884
- case 'textarea': {
885
- const input = scopeRoot.querySelector(`[name$="${key}"]`);
886
- const val = (input?.value ?? '').trim();
887
- if (!skipValidation && element.required && val === '') {
888
- errors.push(`Field "${fullPath}" is required`);
889
- markValidity(input, 'required');
890
- } else if (!skipValidation && val !== '') {
891
- if (element.minLength != null && val.length < element.minLength) {
892
- errors.push(`Field "${fullPath}" must be at least ${element.minLength} characters`);
893
- markValidity(input, `minLength=${element.minLength}`);
894
- }
895
- if (element.maxLength != null && val.length > element.maxLength) {
896
- errors.push(`Field "${fullPath}" must be at most ${element.maxLength} characters`);
897
- markValidity(input, `maxLength=${element.maxLength}`);
898
- }
899
- if (element.pattern) {
900
- try {
901
- const re = new RegExp(element.pattern);
902
- if (!re.test(val)) {
903
- errors.push(`Field "${fullPath}" does not match required format`);
904
- markValidity(input, 'pattern mismatch');
905
- }
906
- } catch {
907
- errors.push(`Field "${fullPath}" has invalid validation pattern`);
908
- markValidity(input, 'invalid pattern');
909
- }
910
- }
911
- } else if (skipValidation) {
912
- markValidity(input, null);
913
- } else {
914
- markValidity(input, null);
915
- }
916
- return val;
771
+ function generateResourceId() {
772
+ return 'res_' + Math.random().toString(36).substr(2, 9) + Date.now().toString(36);
773
+ }
774
+
775
+ async function handleFileSelect(file, container, fieldName) {
776
+ let rid;
777
+
778
+ // If uploadHandler is configured, use it to upload the file
779
+ if (state.config.uploadFile) {
780
+ try {
781
+ rid = await state.config.uploadFile(file);
782
+ if (typeof rid !== 'string') {
783
+ throw new Error('Upload handler must return a string resource ID');
784
+ }
785
+ } catch (error) {
786
+ throw new Error(`File upload failed: ${error.message}`);
787
+ }
788
+ } else {
789
+ throw new Error('No upload handler configured. Set uploadHandler via FormBuilder.setUploadHandler()');
790
+ }
791
+
792
+ state.resourceIndex.set(rid, {
793
+ name: file.name,
794
+ type: file.type,
795
+ size: file.size,
796
+ file: null // Files are always uploaded, never stored locally
797
+ });
798
+
799
+ // Create hidden input to store the resource ID
800
+ let hiddenInput = container.parentElement.querySelector('input[type="hidden"]');
801
+ if (!hiddenInput) {
802
+ hiddenInput = document.createElement('input');
803
+ hiddenInput.type = 'hidden';
804
+ hiddenInput.name = fieldName;
805
+ container.parentElement.appendChild(hiddenInput);
806
+ }
807
+ hiddenInput.value = rid;
808
+
809
+ renderFilePreview(container, rid, file.name, file.type);
810
+ }
811
+
812
+ function setupDragAndDrop(element, dropHandler) {
813
+ element.addEventListener('dragover', (e) => {
814
+ e.preventDefault();
815
+ element.classList.add('border-blue-500', 'bg-blue-50');
816
+ });
817
+
818
+ element.addEventListener('dragleave', (e) => {
819
+ e.preventDefault();
820
+ element.classList.remove('border-blue-500', 'bg-blue-50');
821
+ });
822
+
823
+ element.addEventListener('drop', (e) => {
824
+ e.preventDefault();
825
+ element.classList.remove('border-blue-500', 'bg-blue-50');
826
+ dropHandler(e.dataTransfer.files);
827
+ });
828
+ }
829
+
830
+ function showTooltip(tooltipId, button) {
831
+ const tooltip = document.getElementById(tooltipId);
832
+ const isCurrentlyVisible = !tooltip.classList.contains('hidden');
833
+
834
+ // Hide all tooltips first
835
+ document.querySelectorAll('[id^="tooltip-"]').forEach(t => {
836
+ t.classList.add('hidden');
837
+ });
838
+
839
+ // If the tooltip was already visible, keep it hidden (toggle behavior)
840
+ if (isCurrentlyVisible) {
841
+ return;
842
+ }
843
+
844
+ // Position the tooltip intelligently
845
+ const rect = button.getBoundingClientRect();
846
+ const viewportWidth = window.innerWidth;
847
+ const viewportHeight = window.innerHeight;
848
+
849
+ // Ensure tooltip is appended to body for proper positioning
850
+ if (tooltip && tooltip.parentElement !== document.body) {
851
+ document.body.appendChild(tooltip);
852
+ }
853
+
854
+ // Show tooltip temporarily to measure its size
855
+ tooltip.style.visibility = 'hidden';
856
+ tooltip.style.position = 'fixed';
857
+ tooltip.classList.remove('hidden');
858
+ const tooltipRect = tooltip.getBoundingClientRect();
859
+ tooltip.classList.add('hidden');
860
+ tooltip.style.visibility = 'visible';
861
+
862
+ let left = rect.left;
863
+ let top = rect.bottom + 5;
864
+
865
+ // Adjust horizontal position if tooltip would go off-screen
866
+ if (left + tooltipRect.width > viewportWidth) {
867
+ left = rect.right - tooltipRect.width;
868
+ }
869
+
870
+ // Adjust vertical position if tooltip would go off-screen
871
+ if (top + tooltipRect.height > viewportHeight) {
872
+ top = rect.top - tooltipRect.height - 5;
873
+ }
874
+
875
+ // Ensure tooltip doesn't go off the left edge
876
+ if (left < 10) {
877
+ left = 10;
878
+ }
879
+
880
+ // Ensure tooltip doesn't go off the top edge
881
+ if (top < 10) {
882
+ top = rect.bottom + 5;
883
+ }
884
+
885
+ tooltip.style.left = left + 'px';
886
+ tooltip.style.top = top + 'px';
887
+
888
+ // Show the tooltip
889
+ tooltip.classList.remove('hidden');
890
+
891
+ // Hide after 25 seconds
892
+ setTimeout(() => {
893
+ tooltip.classList.add('hidden');
894
+ }, 25000);
895
+ }
896
+
897
+ // Close tooltips when clicking outside
898
+ document.addEventListener('click', function(e) {
899
+ const isInfoButton = e.target.closest('button') && e.target.closest('button').onclick;
900
+ const isTooltip = e.target.closest('[id^="tooltip-"]');
901
+
902
+ if (!isInfoButton && !isTooltip) {
903
+ document.querySelectorAll('[id^="tooltip-"]').forEach(tooltip => {
904
+ tooltip.classList.add('hidden');
905
+ });
906
+ }
907
+ });
908
+
909
+ // Form validation and data extraction
910
+ function validateForm(skipValidation = false) {
911
+ if (!state.schema || !state.formRoot) return { valid: true, data: {} };
912
+
913
+ const errors = [];
914
+ const data = {};
915
+
916
+ function markValidity(input, errorMessage) {
917
+ if (!input) return;
918
+ if (errorMessage) {
919
+ input.classList.add('invalid');
920
+ input.title = errorMessage;
921
+ } else {
922
+ input.classList.remove('invalid');
923
+ input.title = '';
924
+ }
925
+ }
926
+
927
+ function validateElement(element, ctx) {
928
+ const key = element.key;
929
+ const scopeRoot = state.formRoot;
930
+
931
+ switch (element.type) {
932
+ case 'text':
933
+ case 'textarea': {
934
+ const input = scopeRoot.querySelector(`[name$="${key}"]`);
935
+ const val = input?.value ?? '';
936
+ if (!skipValidation && element.required && val === '') {
937
+ errors.push(`${key}: required`);
938
+ markValidity(input, 'required');
939
+ return '';
917
940
  }
918
- case 'number': {
919
- const input = scopeRoot.querySelector(`[name$="${key}"]`);
920
- const raw = input?.value ?? '';
921
- if (!skipValidation && element.required && raw === '') {
922
- errors.push(`${key}: required`);
923
- markValidity(input, 'required');
924
- return null;
925
- }
926
- if (raw === '') {
927
- markValidity(input, null);
928
- return null;
941
+ if (!skipValidation && val) {
942
+ if (element.minLength != null && val.length < element.minLength) {
943
+ errors.push(`${key}: minLength=${element.minLength}`);
944
+ markValidity(input, `minLength=${element.minLength}`);
929
945
  }
930
- const v = parseFloat(raw);
931
- if (!skipValidation && !Number.isFinite(v)) {
932
- errors.push(`${key}: not a number`);
933
- markValidity(input, 'not a number');
934
- return null;
946
+ if (element.maxLength != null && val.length > element.maxLength) {
947
+ errors.push(`${key}: maxLength=${element.maxLength}`);
948
+ markValidity(input, `maxLength=${element.maxLength}`);
935
949
  }
936
- if (!skipValidation && element.min != null && v < element.min) {
937
- errors.push(`${key}: < min=${element.min}`);
938
- markValidity(input, `< min=${element.min}`);
939
- }
940
- if (!skipValidation && element.max != null && v > element.max) {
941
- errors.push(`${key}: > max=${element.max}`);
942
- markValidity(input, `> max=${element.max}`);
950
+ if (element.pattern) {
951
+ try {
952
+ const re = new RegExp(element.pattern);
953
+ if (!re.test(val)) {
954
+ errors.push(`${key}: pattern mismatch`);
955
+ markValidity(input, 'pattern mismatch');
956
+ }
957
+ } catch {
958
+ errors.push(`${key}: invalid pattern`);
959
+ markValidity(input, 'invalid pattern');
960
+ }
943
961
  }
944
- const d = Number.isInteger(element.decimals ?? 0) ? element.decimals : 0;
945
- const r = Number(v.toFixed(d));
946
- input.value = String(r);
962
+ } else if (skipValidation) {
963
+ markValidity(input, null);
964
+ } else {
947
965
  markValidity(input, null);
948
- return r;
949
966
  }
950
- case 'select': {
951
- const sel = scopeRoot.querySelector(`select[name$="${key}"]`);
952
- const val = sel?.value ?? '';
953
- const values = new Set(element.options.map(o => String(o.value)));
954
- if (!skipValidation && element.required && val === '') {
955
- errors.push(`${key}: required`);
956
- markValidity(sel, 'required');
957
- return '';
958
- }
959
- if (!skipValidation && val !== '' && !values.has(String(val))) {
960
- errors.push(`${key}: value not in options`);
961
- markValidity(sel, 'not in options');
962
- } else {
963
- markValidity(sel, null);
964
- }
965
- return val === '' ? null : val;
967
+ return val;
968
+ }
969
+ case 'number': {
970
+ const input = scopeRoot.querySelector(`[name$="${key}"]`);
971
+ const raw = input?.value ?? '';
972
+ if (!skipValidation && element.required && raw === '') {
973
+ errors.push(`${key}: required`);
974
+ markValidity(input, 'required');
975
+ return null;
966
976
  }
967
- case 'file': {
968
- const hid = scopeRoot.querySelector(`input[type="hidden"][name$="${key}"]`);
969
- const rid = hid?.value ?? '';
970
- if (!skipValidation && element.required && !rid) {
971
- errors.push(`${key}: required (file missing)`);
972
- const picker = hid?.previousElementSibling;
973
- if (picker) markValidity(picker, 'required');
974
- } else {
975
- if (hid?.previousElementSibling) markValidity(hid.previousElementSibling, null);
976
- }
977
- return rid || null;
977
+ if (raw === '') {
978
+ markValidity(input, null);
979
+ return null;
978
980
  }
979
- case 'files': {
980
- const hid = scopeRoot.querySelector(`input[type="hidden"][name$="${key}"]`);
981
- const arr = parseJSONSafe(hid?.value ?? '[]', []);
982
- const count = Array.isArray(arr) ? arr.length : 0;
983
- if (!skipValidation && !Array.isArray(arr)) errors.push(`${key}: internal value corrupted`);
984
- if (!skipValidation && element.minCount != null && count < element.minCount) {
985
- errors.push(`${key}: < minCount=${element.minCount}`);
986
- }
987
- if (!skipValidation && element.maxCount != null && count > element.maxCount) {
988
- errors.push(`${key}: > maxCount=${element.maxCount}`);
989
- }
990
- if (hid?.previousElementSibling) markValidity(hid.previousElementSibling, null);
991
- return Array.isArray(arr) ? arr : [];
981
+ const v = parseFloat(raw);
982
+ if (!skipValidation && !Number.isFinite(v)) {
983
+ errors.push(`${key}: not a number`);
984
+ markValidity(input, 'not a number');
985
+ return null;
992
986
  }
993
- case 'videos': {
994
- const hid = scopeRoot.querySelector(`input[type="hidden"][name$="${key}"]`);
995
- const arr = parseJSONSafe(hid?.value ?? '[]', []);
996
- const count = Array.isArray(arr) ? arr.length : 0;
997
- if (!skipValidation && !Array.isArray(arr)) errors.push(`${key}: internal value corrupted`);
998
- if (!skipValidation && element.minCount != null && count < element.minCount) {
999
- errors.push(`${key}: < minCount=${element.minCount}`);
1000
- }
1001
- if (!skipValidation && element.maxCount != null && count > element.maxCount) {
1002
- errors.push(`${key}: > maxCount=${element.maxCount}`);
1003
- }
1004
- if (hid?.previousElementSibling) markValidity(hid.previousElementSibling, null);
1005
- return Array.isArray(arr) ? arr : [];
987
+ if (!skipValidation && element.min != null && v < element.min) {
988
+ errors.push(`${key}: < min=${element.min}`);
989
+ markValidity(input, `< min=${element.min}`);
1006
990
  }
1007
- case 'group': {
1008
- const groupWrapper = scopeRoot.querySelector(`[data-group="${key}"]`);
1009
- if (!groupWrapper) {
1010
- errors.push(`${key}: internal group wrapper not found`);
1011
- return element.repeat ? [] : {};
1012
- }
1013
- const itemsWrap = groupWrapper.querySelector(`[data-items-for="${key}"]`);
1014
- if (!itemsWrap) {
1015
- errors.push(`${key}: internal items container not found`);
1016
- return element.repeat ? [] : {};
1017
- }
1018
-
1019
- if (element.repeat && isPlainObject(element.repeat)) {
1020
- const items = itemsWrap.querySelectorAll(':scope > .groupItem');
1021
- const out = [];
1022
- const n = items.length;
1023
- const min = element.repeat.min ?? 0;
1024
- const max = element.repeat.max ?? Infinity;
1025
- if (!skipValidation && n < min) errors.push(`${key}: count < min=${min}`);
1026
- if (!skipValidation && n > max) errors.push(`${key}: count > max=${max}`);
1027
- items.forEach((item, index) => {
1028
- const obj = {};
1029
- element.elements.forEach(child => {
1030
- obj[child.key] = collectElement(child, item, `${fullPath}[${index}]`);
1031
- });
1032
- out.push(obj);
1033
- });
1034
- return out;
1035
- } else {
1036
- const obj = {};
991
+ if (!skipValidation && element.max != null && v > element.max) {
992
+ errors.push(`${key}: > max=${element.max}`);
993
+ markValidity(input, `> max=${element.max}`);
994
+ }
995
+ const d = Number.isInteger(element.decimals ?? 0) ? element.decimals : 0;
996
+ markValidity(input, null);
997
+ return Number(v.toFixed(d));
998
+ }
999
+ case 'select': {
1000
+ const input = scopeRoot.querySelector(`[name$="${key}"]`);
1001
+ const val = input?.value ?? '';
1002
+ if (!skipValidation && element.required && val === '') {
1003
+ errors.push(`${key}: required`);
1004
+ markValidity(input, 'required');
1005
+ return '';
1006
+ }
1007
+ markValidity(input, null);
1008
+ return val;
1009
+ }
1010
+ case 'file': {
1011
+ const input = scopeRoot.querySelector(`input[name$="${key}"][type="hidden"]`);
1012
+ const rid = input?.value ?? '';
1013
+ if (!skipValidation && element.required && rid === '') {
1014
+ errors.push(`${key}: required`);
1015
+ return null;
1016
+ }
1017
+ return rid || null;
1018
+ }
1019
+ case 'files': {
1020
+ // For files, we need to collect all resource IDs
1021
+ const container = scopeRoot.querySelector(`[name$="${key}"]`)?.parentElement?.querySelector('.files-list');
1022
+ const rids = [];
1023
+ if (container) {
1024
+ // Extract resource IDs from the current state
1025
+ // This is a simplified approach - in practice you'd track this better
1026
+ }
1027
+ return rids;
1028
+ }
1029
+ case 'group': {
1030
+ if (element.repeat && isPlainObject(element.repeat)) {
1031
+ const items = [];
1032
+ const itemElements = scopeRoot.querySelectorAll(`[name^="${key}["]`);
1033
+ const itemCount = Math.max(0, Math.floor(itemElements.length / element.elements.length));
1034
+
1035
+ for (let i = 0; i < itemCount; i++) {
1036
+ const itemData = {};
1037
1037
  element.elements.forEach(child => {
1038
- obj[child.key] = collectElement(child, itemsWrap, fullPath);
1038
+ const childKey = `${key}[${i}].${child.key}`;
1039
+ itemData[child.key] = validateElement({...child, key: childKey}, ctx);
1039
1040
  });
1040
- return obj;
1041
+ items.push(itemData);
1041
1042
  }
1043
+ return items;
1044
+ } else {
1045
+ const groupData = {};
1046
+ element.elements.forEach(child => {
1047
+ const childKey = `${key}.${child.key}`;
1048
+ groupData[child.key] = validateElement({...child, key: childKey}, ctx);
1049
+ });
1050
+ return groupData;
1042
1051
  }
1043
- default:
1044
- errors.push(`${key}: unsupported type ${element.type}`);
1045
- return null;
1046
1052
  }
1053
+ default:
1054
+ return null;
1047
1055
  }
1048
-
1049
- const result = {};
1050
- schema.elements.forEach(element => {
1051
- result[element.key] = collectElement(element, formRoot, '');
1052
- });
1053
-
1054
- return { result, errors };
1055
1056
  }
1057
+
1058
+ state.schema.elements.forEach(element => {
1059
+ data[element.key] = validateElement(element, { path: '' });
1060
+ });
1061
+
1062
+ return {
1063
+ valid: errors.length === 0,
1064
+ errors,
1065
+ data
1066
+ };
1067
+ }
1056
1068
 
1057
- // Main form rendering function
1058
- function renderForm(schema, container, options = {}) {
1059
- const {
1060
- prefill = {},
1061
- readonly = false,
1062
- debug = false,
1063
- onSubmit,
1064
- onDraft,
1065
- onError,
1066
- buttons = {}
1067
- } = options;
1068
-
1069
- if (debug) {
1070
- console.log('[FormBuilder Debug] Rendering form with schema:', schema);
1071
- console.log('[FormBuilder Debug] Options:', options);
1069
+ // Element rendering functions
1070
+ function renderTextElement(element, ctx, wrapper, pathKey) {
1071
+ const textInput = document.createElement('input');
1072
+ textInput.type = 'text';
1073
+ textInput.className = 'w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500';
1074
+ textInput.name = pathKey;
1075
+ textInput.placeholder = element.placeholder || 'Введите текст';
1076
+ textInput.value = ctx.prefill[element.key] || element.default || '';
1077
+ textInput.readOnly = state.config.readonly;
1078
+ wrapper.appendChild(textInput);
1079
+
1080
+ // Add hint
1081
+ const textHint = document.createElement('p');
1082
+ textHint.className = 'text-xs text-gray-500 mt-1';
1083
+ textHint.textContent = makeFieldHint(element);
1084
+ wrapper.appendChild(textHint);
1085
+ }
1086
+
1087
+ function renderTextareaElement(element, ctx, wrapper, pathKey) {
1088
+ const textareaInput = document.createElement('textarea');
1089
+ textareaInput.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';
1090
+ textareaInput.name = pathKey;
1091
+ textareaInput.placeholder = element.placeholder || 'Введите текст';
1092
+ textareaInput.rows = element.rows || 4;
1093
+ textareaInput.value = ctx.prefill[element.key] || element.default || '';
1094
+ textareaInput.readOnly = state.config.readonly;
1095
+ wrapper.appendChild(textareaInput);
1096
+
1097
+ // Add hint
1098
+ const textareaHint = document.createElement('p');
1099
+ textareaHint.className = 'text-xs text-gray-500 mt-1';
1100
+ textareaHint.textContent = makeFieldHint(element);
1101
+ wrapper.appendChild(textareaHint);
1102
+ }
1103
+
1104
+ function renderNumberElement(element, ctx, wrapper, pathKey) {
1105
+ const numberInput = document.createElement('input');
1106
+ numberInput.type = 'number';
1107
+ numberInput.className = 'w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500';
1108
+ numberInput.name = pathKey;
1109
+ numberInput.placeholder = element.placeholder || '0';
1110
+ if (element.min !== undefined) numberInput.min = element.min;
1111
+ if (element.max !== undefined) numberInput.max = element.max;
1112
+ if (element.step !== undefined) numberInput.step = element.step;
1113
+ numberInput.value = ctx.prefill[element.key] || element.default || '';
1114
+ numberInput.readOnly = state.config.readonly;
1115
+ wrapper.appendChild(numberInput);
1116
+
1117
+ // Add hint
1118
+ const numberHint = document.createElement('p');
1119
+ numberHint.className = 'text-xs text-gray-500 mt-1';
1120
+ numberHint.textContent = makeFieldHint(element);
1121
+ wrapper.appendChild(numberHint);
1122
+ }
1123
+
1124
+ function renderSelectElement(element, ctx, wrapper, pathKey) {
1125
+ const selectInput = document.createElement('select');
1126
+ selectInput.className = 'w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500';
1127
+ selectInput.name = pathKey;
1128
+ selectInput.disabled = state.config.readonly;
1129
+
1130
+ (element.options || []).forEach(option => {
1131
+ const optionEl = document.createElement('option');
1132
+ optionEl.value = option.value;
1133
+ optionEl.textContent = option.label;
1134
+ if ((ctx.prefill[element.key] || element.default) === option.value) {
1135
+ optionEl.selected = true;
1072
1136
  }
1073
-
1074
- // Validate schema first
1075
- const schemaErrors = validateSchema(schema);
1076
- if (schemaErrors.length > 0) {
1077
- if (onError) onError(schemaErrors);
1078
- throw new Error('Schema validation failed: ' + schemaErrors.join(', '));
1137
+ selectInput.appendChild(optionEl);
1138
+ });
1139
+
1140
+ wrapper.appendChild(selectInput);
1141
+
1142
+ // Add hint
1143
+ const selectHint = document.createElement('p');
1144
+ selectHint.className = 'text-xs text-gray-500 mt-1';
1145
+ selectHint.textContent = makeFieldHint(element);
1146
+ wrapper.appendChild(selectHint);
1147
+ }
1148
+
1149
+ // Common file preview rendering function for readonly mode
1150
+ function renderFilePreviewReadonly(resourceId, fileName) {
1151
+ const meta = state.resourceIndex.get(resourceId);
1152
+ const actualFileName = fileName || meta?.name || 'file';
1153
+
1154
+ // Individual file result container
1155
+ const fileResult = document.createElement('div');
1156
+ fileResult.className = 'space-y-3';
1157
+
1158
+ // Large preview container
1159
+ const previewContainer = document.createElement('div');
1160
+ previewContainer.className = 'bg-gray-100 rounded-lg overflow-hidden cursor-pointer hover:opacity-90 transition-opacity';
1161
+
1162
+ // Check file type and render appropriate preview
1163
+ if (meta?.type?.startsWith('image/') || actualFileName.toLowerCase().match(/\.(jpg|jpeg|png|gif|webp)$/)) {
1164
+ // Image preview
1165
+ if (state.config.getThumbnail) {
1166
+ const thumbnailUrl = state.config.getThumbnail(resourceId);
1167
+ if (thumbnailUrl) {
1168
+ previewContainer.innerHTML = `<img src="${thumbnailUrl}" alt="${actualFileName}" class="w-full h-auto">`;
1169
+ } else {
1170
+ previewContainer.innerHTML = `<div class="aspect-video flex items-center justify-center text-gray-400"><div class="text-center"><div class="text-4xl mb-2">🖼️</div><div class="text-sm">${actualFileName}</div></div></div>`;
1171
+ }
1172
+ } else {
1173
+ previewContainer.innerHTML = `<div class="aspect-video flex items-center justify-center text-gray-400"><div class="text-center"><div class="text-4xl mb-2">🖼️</div><div class="text-sm">${actualFileName}</div></div></div>`;
1079
1174
  }
1080
-
1081
- // Clear container
1082
- clear(container);
1083
-
1084
- // Create form element
1085
- const formEl = document.createElement('form');
1086
- formEl.addEventListener('submit', (e) => e.preventDefault());
1175
+ } else if (meta?.type?.startsWith('video/') || actualFileName.toLowerCase().match(/\.(mp4|webm|avi|mov)$/)) {
1176
+ // Video preview
1177
+ if (state.config.getThumbnail) {
1178
+ const thumbnailUrl = state.config.getThumbnail(resourceId);
1179
+ if (thumbnailUrl) {
1180
+ previewContainer.innerHTML = `
1181
+ <div class="relative group">
1182
+ <video class="w-full h-auto" controls preload="auto" muted>
1183
+ <source src="${thumbnailUrl}" type="${meta?.type || 'video/mp4'}">
1184
+ Ваш браузер не поддерживает видео.
1185
+ </video>
1186
+ <div class="absolute inset-0 bg-black bg-opacity-20 opacity-0 group-hover:opacity-100 transition-opacity flex items-center justify-center pointer-events-none">
1187
+ <div class="bg-white bg-opacity-90 rounded-full p-3">
1188
+ <svg class="w-8 h-8 text-gray-800" fill="currentColor" viewBox="0 0 24 24">
1189
+ <path d="M8 5v14l11-7z"/>
1190
+ </svg>
1191
+ </div>
1192
+ </div>
1193
+ </div>
1194
+ `;
1195
+ } else {
1196
+ previewContainer.innerHTML = `<div class="aspect-video flex items-center justify-center text-gray-400"><div class="text-center"><div class="text-4xl mb-2">🎥</div><div class="text-sm">${actualFileName}</div></div></div>`;
1197
+ }
1198
+ } else {
1199
+ previewContainer.innerHTML = `<div class="aspect-video flex items-center justify-center text-gray-400"><div class="text-center"><div class="text-4xl mb-2">🎥</div><div class="text-sm">${actualFileName}</div></div></div>`;
1200
+ }
1201
+ } else {
1202
+ // Other file types
1203
+ previewContainer.innerHTML = `<div class="aspect-video flex items-center justify-center text-gray-400"><div class="text-center"><div class="text-4xl mb-2">📁</div><div class="text-sm">${actualFileName}</div></div></div>`;
1204
+ }
1205
+
1206
+ // File name
1207
+ const fileNameElement = document.createElement('p');
1208
+ fileNameElement.className = 'text-sm font-medium text-gray-900 text-center';
1209
+ fileNameElement.textContent = actualFileName;
1210
+
1211
+ // Download button
1212
+ const downloadButton = document.createElement('button');
1213
+ downloadButton.className = 'w-full px-3 py-2 text-sm bg-blue-600 text-white rounded-lg hover:bg-blue-700 transition-colors';
1214
+ downloadButton.textContent = 'Скачать';
1215
+ downloadButton.onclick = (e) => {
1216
+ e.preventDefault();
1217
+ e.stopPropagation();
1218
+ if (state.config.downloadFile) {
1219
+ state.config.downloadFile(resourceId, actualFileName);
1220
+ } else {
1221
+ forceDownload(resourceId, actualFileName);
1222
+ }
1223
+ };
1224
+
1225
+ fileResult.appendChild(previewContainer);
1226
+ fileResult.appendChild(fileNameElement);
1227
+ fileResult.appendChild(downloadButton);
1228
+
1229
+ return fileResult;
1230
+ }
1087
1231
 
1088
- const ctx = { path: '', prefill: prefill || {} };
1089
- schema.elements.forEach(element => {
1090
- const block = renderElement(element, ctx, { readonly });
1091
- formEl.appendChild(block);
1092
- });
1232
+ // TODO: Extract large file, files, and group rendering logic to separate functions:
1233
+ // - renderFileElement(element, ctx, wrapper, pathKey)
1234
+ // - renderFilesElement(element, ctx, wrapper, pathKey)
1235
+ // - renderGroupElement(element, ctx, wrapper, pathKey)
1093
1236
 
1094
- // Add action buttons if not readonly
1095
- if (!readonly) {
1096
- const buttonContainer = document.createElement('div');
1097
- buttonContainer.className = 'flex gap-2 pt-4 border-t border-gray-200 mt-6';
1098
-
1099
- const submitBtn = document.createElement('button');
1100
- submitBtn.type = 'button';
1101
- submitBtn.className = 'bg-blue-600 text-white px-4 py-2 rounded-lg hover:bg-blue-700 transition-colors';
1102
- submitBtn.textContent = buttons.submit || 'Submit Form';
1103
- submitBtn.addEventListener('click', () => {
1104
- if (debug) console.log('[FormBuilder Debug] Submit button clicked');
1105
- const { result, errors } = collectAndValidate(schema, formEl, false);
1106
- if (debug) console.log('[FormBuilder Debug] Validation result:', { result, errors });
1107
- if (errors.length > 0) {
1108
- if (onError) onError(errors);
1109
- } else {
1110
- if (onSubmit) onSubmit(result);
1237
+ // Force download helper function
1238
+ function forceDownload(resourceId, fileName) {
1239
+ // Try to get URL from thumbnail handler first
1240
+ let fileUrl = null;
1241
+ if (state.config.getThumbnail) {
1242
+ fileUrl = state.config.getThumbnail(resourceId);
1243
+ }
1244
+
1245
+ if (fileUrl) {
1246
+ // Always try to fetch and create blob for true download behavior
1247
+ // This prevents files from opening in browser
1248
+ const finalUrl = fileUrl.startsWith('http') ? fileUrl : new URL(fileUrl, window.location.href).href;
1249
+
1250
+ fetch(finalUrl)
1251
+ .then(response => {
1252
+ if (!response.ok) {
1253
+ throw new Error(`HTTP error! status: ${response.status}`);
1111
1254
  }
1255
+ return response.blob();
1256
+ })
1257
+ .then(blob => {
1258
+ downloadBlob(blob, fileName);
1259
+ })
1260
+ .catch(error => {
1261
+ throw new Error(`File download failed: ${error.message}`);
1112
1262
  });
1113
-
1114
- const draftBtn = document.createElement('button');
1115
- draftBtn.type = 'button';
1116
- draftBtn.className = 'bg-gray-600 text-white px-4 py-2 rounded-lg hover:bg-gray-700 transition-colors';
1117
- draftBtn.textContent = buttons.draft || 'Save Draft';
1118
- draftBtn.addEventListener('click', () => {
1119
- if (debug) console.log('[FormBuilder Debug] Draft button clicked');
1120
- const { result } = collectAndValidate(schema, formEl, true); // Skip validation for drafts
1121
- if (debug) console.log('[FormBuilder Debug] Draft result:', result);
1122
- if (onDraft) onDraft(result);
1123
- });
1124
-
1125
- buttonContainer.appendChild(submitBtn);
1126
- buttonContainer.appendChild(draftBtn);
1127
- formEl.appendChild(buttonContainer);
1128
- }
1129
-
1130
- container.appendChild(formEl);
1131
- return formEl;
1263
+ } else {
1264
+ console.warn('No download URL available for resource:', resourceId);
1132
1265
  }
1266
+ }
1133
1267
 
1134
- // Configuration functions
1135
- function setConfig(newConfig) {
1136
- Object.assign(config, newConfig);
1268
+ // Helper to download blob with proper cleanup
1269
+ function downloadBlob(blob, fileName) {
1270
+ try {
1271
+ const blobUrl = URL.createObjectURL(blob);
1272
+ const link = document.createElement('a');
1273
+ link.href = blobUrl;
1274
+ link.download = fileName;
1275
+ link.style.display = 'none';
1276
+
1277
+ // Important: add to DOM, click, then remove
1278
+ document.body.appendChild(link);
1279
+ link.click();
1280
+ document.body.removeChild(link);
1281
+
1282
+ // Clean up blob URL after download
1283
+ setTimeout(() => {
1284
+ URL.revokeObjectURL(blobUrl);
1285
+ }, 100);
1286
+
1287
+ } catch (error) {
1288
+ throw new Error(`Blob download failed: ${error.message}`);
1137
1289
  }
1290
+ }
1138
1291
 
1139
- function setUploadHandler(uploadFn) {
1140
- config.uploadFile = uploadFn;
1141
- }
1292
+ // Public API
1293
+ function setFormRoot(element) {
1294
+ state.formRoot = element;
1295
+ }
1142
1296
 
1143
- function setDownloadHandler(downloadFn) {
1144
- config.downloadFile = downloadFn;
1145
- }
1297
+ function configure(config) {
1298
+ Object.assign(state.config, config);
1299
+ }
1300
+
1301
+ function setUploadHandler(uploadFn) {
1302
+ state.config.uploadFile = uploadFn;
1303
+ }
1146
1304
 
1147
- function setThumbnailHandler(thumbnailFn) {
1148
- config.getThumbnail = thumbnailFn;
1305
+ function setDownloadHandler(downloadFn) {
1306
+ state.config.downloadFile = downloadFn;
1307
+ }
1308
+
1309
+ function setThumbnailHandler(thumbnailFn) {
1310
+ state.config.getThumbnail = thumbnailFn;
1311
+ }
1312
+
1313
+ function setMode(mode) {
1314
+ if (mode === 'readonly') {
1315
+ state.config.readonly = true;
1316
+ } else {
1317
+ state.config.readonly = false;
1149
1318
  }
1319
+ }
1150
1320
 
1151
- // Generate prefill template
1152
- function generatePrefillTemplate(schema) {
1153
- function walk(elements) {
1154
- const obj = {};
1155
- for (const el of elements) {
1156
- switch (el.type) {
1157
- case 'text':
1158
- case 'textarea':
1159
- case 'select':
1160
- case 'number':
1161
- obj[el.key] = el.default ?? null;
1162
- break;
1163
- case 'file':
1164
- obj[el.key] = null;
1165
- break;
1166
- case 'files':
1167
- obj[el.key] = [];
1168
- break;
1169
- case 'videos':
1170
- obj[el.key] = [];
1171
- break;
1172
- case 'group':
1173
- if (el.repeat && isPlainObject(el.repeat)) {
1174
- const sample = walk(el.elements);
1175
- const n = Math.max(el.repeat.min ?? 0, 1);
1176
- obj[el.key] = Array.from({ length: n }, () => deepClone(sample));
1177
- } else {
1178
- obj[el.key] = walk(el.elements);
1179
- }
1180
- break;
1181
- default:
1182
- obj[el.key] = null;
1183
- }
1184
- }
1185
- return obj;
1321
+ function getFormData() {
1322
+ return validateForm(false);
1323
+ }
1324
+
1325
+ function submitForm() {
1326
+ const result = validateForm(false);
1327
+ if (result.valid) {
1328
+ // Emit event for successful submission
1329
+ if (typeof window !== 'undefined' && window.parent) {
1330
+ window.parent.postMessage({
1331
+ type: 'formSubmit',
1332
+ data: result.data,
1333
+ schema: state.schema
1334
+ }, '*');
1186
1335
  }
1187
- return walk(schema.elements);
1188
1336
  }
1337
+ return result;
1338
+ }
1189
1339
 
1190
- // Public API
1191
- exports.renderForm = renderForm;
1192
- exports.validateSchema = validateSchema;
1193
- exports.collectAndValidate = collectAndValidate;
1194
- exports.generatePrefillTemplate = generatePrefillTemplate;
1195
- exports.setConfig = setConfig;
1196
- exports.setUploadHandler = setUploadHandler;
1197
- exports.setDownloadHandler = setDownloadHandler;
1198
- exports.setThumbnailHandler = setThumbnailHandler;
1199
- exports.version = '0.1.5';
1340
+ function saveDraft() {
1341
+ const result = validateForm(true); // Skip validation for drafts
1342
+ // Emit event for draft save
1343
+ if (typeof window !== 'undefined' && window.parent) {
1344
+ window.parent.postMessage({
1345
+ type: 'formDraft',
1346
+ data: result.data,
1347
+ schema: state.schema
1348
+ }, '*');
1349
+ }
1350
+ return result;
1351
+ }
1200
1352
 
1201
- }));
1353
+ function clearForm() {
1354
+ if (state.formRoot) {
1355
+ clear(state.formRoot);
1356
+ }
1357
+ }
1358
+
1359
+ // Export the API
1360
+ if (typeof window !== 'undefined') {
1361
+ window.FormBuilder = {
1362
+ setFormRoot,
1363
+ renderForm,
1364
+ configure,
1365
+ setUploadHandler,
1366
+ setDownloadHandler,
1367
+ setThumbnailHandler,
1368
+ setMode,
1369
+ getFormData,
1370
+ submitForm,
1371
+ saveDraft,
1372
+ clearForm,
1373
+ validateSchema,
1374
+ pretty,
1375
+ state
1376
+ };
1377
+ }