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