@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.
- package/dist/demo.js +574 -0
- package/dist/form-builder.js +1254 -1078
- package/dist/images/final_video.mp4 +0 -0
- package/dist/images/infographic_draft.jpg +0 -0
- package/dist/index.html +120 -1500
- package/package.json +3 -5
- package/dist/README.md +0 -284
- package/dist/example.html +0 -108
- package/dist/sample.html +0 -1615
package/dist/form-builder.js
CHANGED
|
@@ -1,1201 +1,1377 @@
|
|
|
1
|
-
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
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
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
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
|
-
|
|
165
|
-
|
|
166
|
-
|
|
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
|
-
|
|
174
|
-
|
|
175
|
-
|
|
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
|
-
|
|
192
|
-
|
|
193
|
-
|
|
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
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
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
|
-
|
|
242
|
-
|
|
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
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
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 (
|
|
347
|
-
|
|
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 (
|
|
354
|
-
|
|
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
|
-
|
|
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
|
-
|
|
367
|
-
|
|
368
|
-
|
|
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
|
-
|
|
371
|
-
|
|
372
|
-
const
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
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
|
-
|
|
162
|
+
const pathKey = pathJoin(ctx.path, element.key);
|
|
385
163
|
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
|
|
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
|
-
|
|
429
|
-
|
|
430
|
-
|
|
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
|
-
|
|
440
|
-
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
|
|
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
|
-
|
|
447
|
-
|
|
448
|
-
|
|
449
|
-
|
|
450
|
-
|
|
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
|
-
|
|
454
|
-
|
|
455
|
-
|
|
456
|
-
|
|
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
|
-
|
|
465
|
-
|
|
240
|
+
fileWrapper.appendChild(fileContainer);
|
|
241
|
+
fileWrapper.appendChild(picker);
|
|
466
242
|
|
|
467
|
-
//
|
|
468
|
-
const
|
|
469
|
-
|
|
470
|
-
|
|
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
|
-
|
|
473
|
-
|
|
474
|
-
|
|
475
|
-
|
|
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
|
-
|
|
479
|
-
|
|
480
|
-
|
|
481
|
-
|
|
482
|
-
|
|
483
|
-
|
|
484
|
-
|
|
485
|
-
|
|
486
|
-
|
|
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
|
-
|
|
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
|
-
|
|
512
|
-
|
|
513
|
-
|
|
514
|
-
|
|
515
|
-
|
|
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
|
-
|
|
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
|
-
|
|
524
|
-
|
|
525
|
-
|
|
526
|
-
|
|
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
|
-
|
|
529
|
-
|
|
530
|
-
|
|
531
|
-
|
|
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
|
-
|
|
534
|
-
|
|
535
|
-
|
|
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 = '
|
|
300
|
+
list.className = 'files-list';
|
|
545
301
|
|
|
546
|
-
const
|
|
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
|
-
|
|
554
|
-
|
|
555
|
-
|
|
556
|
-
|
|
557
|
-
|
|
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
|
-
|
|
322
|
+
// If uploadHandler is configured, use it to upload the file
|
|
323
|
+
if (state.config.uploadFile) {
|
|
567
324
|
try {
|
|
568
|
-
|
|
569
|
-
if (
|
|
570
|
-
|
|
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
|
-
|
|
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
|
-
|
|
583
|
-
|
|
584
|
-
|
|
585
|
-
|
|
586
|
-
|
|
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
|
-
|
|
342
|
+
initialFiles.push(rid);
|
|
590
343
|
}
|
|
344
|
+
updateFilesList();
|
|
591
345
|
});
|
|
592
346
|
|
|
593
|
-
|
|
594
|
-
|
|
595
|
-
|
|
596
|
-
|
|
597
|
-
|
|
598
|
-
|
|
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
|
-
|
|
705
|
-
if (
|
|
706
|
-
|
|
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
|
-
|
|
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
|
-
|
|
718
|
-
|
|
719
|
-
|
|
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
|
-
|
|
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
|
-
|
|
731
|
-
|
|
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
|
-
|
|
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
|
-
|
|
743
|
-
|
|
744
|
-
|
|
745
|
-
|
|
746
|
-
|
|
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
|
-
|
|
749
|
-
|
|
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
|
|
752
|
-
|
|
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
|
-
|
|
755
|
-
|
|
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
|
-
|
|
758
|
-
|
|
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
|
-
|
|
761
|
-
|
|
762
|
-
|
|
763
|
-
|
|
764
|
-
|
|
765
|
-
|
|
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
|
-
|
|
773
|
-
|
|
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
|
-
|
|
780
|
-
|
|
781
|
-
|
|
782
|
-
|
|
783
|
-
|
|
784
|
-
|
|
785
|
-
|
|
786
|
-
|
|
787
|
-
|
|
788
|
-
|
|
789
|
-
|
|
790
|
-
|
|
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
|
-
|
|
796
|
-
|
|
797
|
-
|
|
798
|
-
|
|
799
|
-
|
|
800
|
-
|
|
801
|
-
|
|
802
|
-
|
|
803
|
-
|
|
804
|
-
|
|
805
|
-
|
|
806
|
-
|
|
807
|
-
|
|
808
|
-
|
|
809
|
-
|
|
810
|
-
|
|
811
|
-
|
|
812
|
-
|
|
813
|
-
|
|
814
|
-
|
|
815
|
-
|
|
816
|
-
|
|
817
|
-
|
|
818
|
-
|
|
819
|
-
|
|
820
|
-
|
|
821
|
-
|
|
822
|
-
|
|
823
|
-
|
|
824
|
-
|
|
825
|
-
|
|
826
|
-
|
|
827
|
-
|
|
828
|
-
|
|
829
|
-
|
|
830
|
-
|
|
831
|
-
|
|
832
|
-
|
|
833
|
-
|
|
834
|
-
|
|
835
|
-
|
|
836
|
-
|
|
837
|
-
|
|
838
|
-
|
|
839
|
-
|
|
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
|
-
|
|
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
|
-
|
|
845
|
-
|
|
846
|
-
|
|
708
|
+
const url = state.config.getThumbnail(rid);
|
|
709
|
+
if (url) {
|
|
710
|
+
img.src = url;
|
|
711
|
+
slot.appendChild(img);
|
|
847
712
|
} else {
|
|
848
|
-
|
|
849
|
-
|
|
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
|
-
|
|
855
|
-
|
|
856
|
-
|
|
857
|
-
|
|
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
|
-
|
|
864
|
-
|
|
865
|
-
|
|
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
|
-
|
|
868
|
-
|
|
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
|
-
|
|
759
|
+
container.appendChild(slot);
|
|
872
760
|
}
|
|
761
|
+
}
|
|
873
762
|
|
|
874
|
-
|
|
875
|
-
|
|
876
|
-
|
|
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
|
-
|
|
879
|
-
|
|
880
|
-
|
|
881
|
-
|
|
882
|
-
|
|
883
|
-
|
|
884
|
-
|
|
885
|
-
|
|
886
|
-
|
|
887
|
-
|
|
888
|
-
|
|
889
|
-
|
|
890
|
-
|
|
891
|
-
|
|
892
|
-
|
|
893
|
-
|
|
894
|
-
|
|
895
|
-
|
|
896
|
-
|
|
897
|
-
|
|
898
|
-
|
|
899
|
-
|
|
900
|
-
|
|
901
|
-
|
|
902
|
-
|
|
903
|
-
|
|
904
|
-
|
|
905
|
-
|
|
906
|
-
|
|
907
|
-
|
|
908
|
-
|
|
909
|
-
|
|
910
|
-
|
|
911
|
-
|
|
912
|
-
|
|
913
|
-
|
|
914
|
-
|
|
915
|
-
|
|
916
|
-
|
|
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
|
-
|
|
919
|
-
|
|
920
|
-
|
|
921
|
-
|
|
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
|
-
|
|
931
|
-
|
|
932
|
-
|
|
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 (
|
|
937
|
-
|
|
938
|
-
|
|
939
|
-
|
|
940
|
-
|
|
941
|
-
|
|
942
|
-
|
|
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
|
-
|
|
945
|
-
|
|
946
|
-
|
|
962
|
+
} else if (skipValidation) {
|
|
963
|
+
markValidity(input, null);
|
|
964
|
+
} else {
|
|
947
965
|
markValidity(input, null);
|
|
948
|
-
return r;
|
|
949
966
|
}
|
|
950
|
-
|
|
951
|
-
|
|
952
|
-
|
|
953
|
-
|
|
954
|
-
|
|
955
|
-
|
|
956
|
-
|
|
957
|
-
|
|
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
|
-
|
|
968
|
-
|
|
969
|
-
|
|
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
|
-
|
|
980
|
-
|
|
981
|
-
|
|
982
|
-
|
|
983
|
-
|
|
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
|
-
|
|
994
|
-
|
|
995
|
-
|
|
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
|
-
|
|
1008
|
-
|
|
1009
|
-
|
|
1010
|
-
|
|
1011
|
-
|
|
1012
|
-
|
|
1013
|
-
|
|
1014
|
-
|
|
1015
|
-
|
|
1016
|
-
|
|
1017
|
-
|
|
1018
|
-
|
|
1019
|
-
|
|
1020
|
-
|
|
1021
|
-
|
|
1022
|
-
|
|
1023
|
-
|
|
1024
|
-
|
|
1025
|
-
|
|
1026
|
-
|
|
1027
|
-
|
|
1028
|
-
|
|
1029
|
-
|
|
1030
|
-
|
|
1031
|
-
|
|
1032
|
-
|
|
1033
|
-
|
|
1034
|
-
|
|
1035
|
-
|
|
1036
|
-
|
|
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
|
-
|
|
1038
|
+
const childKey = `${key}[${i}].${child.key}`;
|
|
1039
|
+
itemData[child.key] = validateElement({...child, key: childKey}, ctx);
|
|
1039
1040
|
});
|
|
1040
|
-
|
|
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
|
-
|
|
1058
|
-
|
|
1059
|
-
|
|
1060
|
-
|
|
1061
|
-
|
|
1062
|
-
|
|
1063
|
-
|
|
1064
|
-
|
|
1065
|
-
|
|
1066
|
-
|
|
1067
|
-
|
|
1068
|
-
|
|
1069
|
-
|
|
1070
|
-
|
|
1071
|
-
|
|
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
|
-
|
|
1075
|
-
|
|
1076
|
-
|
|
1077
|
-
|
|
1078
|
-
|
|
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
|
-
//
|
|
1082
|
-
|
|
1083
|
-
|
|
1084
|
-
|
|
1085
|
-
|
|
1086
|
-
|
|
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
|
-
|
|
1089
|
-
|
|
1090
|
-
|
|
1091
|
-
|
|
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
|
-
|
|
1095
|
-
|
|
1096
|
-
|
|
1097
|
-
|
|
1098
|
-
|
|
1099
|
-
|
|
1100
|
-
|
|
1101
|
-
|
|
1102
|
-
|
|
1103
|
-
|
|
1104
|
-
|
|
1105
|
-
|
|
1106
|
-
|
|
1107
|
-
|
|
1108
|
-
|
|
1109
|
-
|
|
1110
|
-
|
|
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
|
-
|
|
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
|
-
|
|
1135
|
-
|
|
1136
|
-
|
|
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
|
-
|
|
1140
|
-
|
|
1141
|
-
|
|
1292
|
+
// Public API
|
|
1293
|
+
function setFormRoot(element) {
|
|
1294
|
+
state.formRoot = element;
|
|
1295
|
+
}
|
|
1142
1296
|
|
|
1143
|
-
|
|
1144
|
-
|
|
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
|
-
|
|
1148
|
-
|
|
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
|
-
|
|
1152
|
-
|
|
1153
|
-
|
|
1154
|
-
|
|
1155
|
-
|
|
1156
|
-
|
|
1157
|
-
|
|
1158
|
-
|
|
1159
|
-
|
|
1160
|
-
|
|
1161
|
-
|
|
1162
|
-
|
|
1163
|
-
|
|
1164
|
-
|
|
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
|
-
|
|
1191
|
-
|
|
1192
|
-
|
|
1193
|
-
|
|
1194
|
-
|
|
1195
|
-
|
|
1196
|
-
|
|
1197
|
-
|
|
1198
|
-
|
|
1199
|
-
|
|
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
|
+
}
|