@dmitryvim/form-builder 0.1.1
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/LICENSE +21 -0
- package/README.md +175 -0
- package/dist/index.html +1279 -0
- package/package.json +34 -0
package/dist/index.html
ADDED
|
@@ -0,0 +1,1279 @@
|
|
|
1
|
+
<!doctype html>
|
|
2
|
+
<html lang="en">
|
|
3
|
+
<head>
|
|
4
|
+
<meta charset="utf-8" />
|
|
5
|
+
<title>Form Builder - JSON Schema to Dynamic Forms</title>
|
|
6
|
+
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
|
7
|
+
<style>
|
|
8
|
+
:root {
|
|
9
|
+
--bg: #f8fafc; --fg: #1e293b; --muted: #64748b; --accent: #3b82f6;
|
|
10
|
+
--bad: #ef4444; --good: #10b981; --card: #ffffff; --border: #e2e8f0;
|
|
11
|
+
--mono: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono","Courier New", monospace;
|
|
12
|
+
--sans: Inter, system-ui, -apple-system, Segoe UI, Roboto, Helvetica, Arial, "Apple Color Emoji","Segoe UI Emoji";
|
|
13
|
+
}
|
|
14
|
+
@media (prefers-color-scheme: dark) {
|
|
15
|
+
:root {
|
|
16
|
+
--bg: #0f1115; --fg: #e7e7e7; --muted: #9aa0a6; --accent: #7dd3fc;
|
|
17
|
+
--bad: #ff6b6b; --good: #34d399; --card: #151922; --border: #2a2f3a;
|
|
18
|
+
}
|
|
19
|
+
}
|
|
20
|
+
* { box-sizing: border-box; }
|
|
21
|
+
html, body { height: 100%; background: var(--bg); color: var(--fg); margin: 0; font-family: var(--sans); }
|
|
22
|
+
h1 { margin: 16px; font-size: 24px; font-weight: 600; color: var(--fg); text-align: center; }
|
|
23
|
+
.subtitle { text-align: center; color: var(--muted); margin: -8px 16px 24px; font-size: 14px; }
|
|
24
|
+
.grid { display: grid; grid-template-columns: 1fr 1fr; grid-auto-rows: minmax(300px, auto); gap: 12px; padding: 12px; }
|
|
25
|
+
@media (min-width: 1400px) { .grid { grid-template-columns: 1fr 1fr 1fr 1fr; } }
|
|
26
|
+
@media (max-width: 900px) { .grid { grid-template-columns: 1fr; } }
|
|
27
|
+
.card { background: var(--card); border: 1px solid var(--border); border-radius: 12px; padding: 16px; display: flex; flex-direction: column; min-height: 320px; box-shadow: 0 2px 4px rgba(0,0,0,0.05); }
|
|
28
|
+
.card h2 { margin: 0 0 12px 0; font-size: 18px; color: var(--fg); font-weight: 600; }
|
|
29
|
+
.toolbar { display: flex; flex-wrap: wrap; gap: 8px; margin-bottom: 12px; }
|
|
30
|
+
.btn { background: var(--card); color: var(--fg); border: 1px solid var(--border); padding: 8px 16px; border-radius: 8px; cursor: pointer; font-size: 13px; font-weight: 500; transition: all 0.2s; }
|
|
31
|
+
.btn:hover { border-color: var(--accent); box-shadow: 0 1px 3px rgba(0,0,0,0.1); }
|
|
32
|
+
.btn.good { background: var(--good); color: white; border-color: var(--good); }
|
|
33
|
+
.btn.good:hover { filter: brightness(0.9); }
|
|
34
|
+
.btn.bad { background: var(--bad); color: white; border-color: var(--bad); }
|
|
35
|
+
.btn.bad:hover { filter: brightness(0.9); }
|
|
36
|
+
.btn.ghost { background: transparent; }
|
|
37
|
+
.btn:disabled { opacity: 0.5; cursor: not-allowed; }
|
|
38
|
+
.textarea, .json, input[type="text"], textarea, select, input[type="number"], input[type="file"] {
|
|
39
|
+
width: 100%; border: 1px solid var(--border); background: var(--card); color: var(--fg); border-radius: 8px; padding: 10px; font-family: var(--mono); font-size: 13px; transition: border-color 0.2s;
|
|
40
|
+
}
|
|
41
|
+
.json, .textarea { flex: 1; resize: vertical; min-height: 220px; }
|
|
42
|
+
input[type="text"], input[type="number"], select { font-family: var(--sans); }
|
|
43
|
+
input:focus, textarea:focus, select:focus { outline: none; border-color: var(--accent); }
|
|
44
|
+
.hint { color: var(--muted); font-size: 12px; margin: 6px 0 0; }
|
|
45
|
+
.errors { color: var(--bad); font-size: 12px; white-space: pre-wrap; margin-top: 8px; background: rgba(239, 68, 68, 0.1); padding: 8px; border-radius: 6px; border-left: 3px solid var(--bad); }
|
|
46
|
+
.field { margin-bottom: 16px; }
|
|
47
|
+
.label { font-size: 14px; margin-bottom: 8px; color: var(--fg); display: flex; align-items: center; gap: 8px; font-weight: 500; }
|
|
48
|
+
.label .req { color: var(--bad); font-weight: 600; }
|
|
49
|
+
.invalid { border-color: var(--bad) !important; }
|
|
50
|
+
.msg { font-size: 12px; color: var(--bad); margin-top: 6px; }
|
|
51
|
+
.ok { color: var(--good); }
|
|
52
|
+
.muted { color: var(--muted); }
|
|
53
|
+
.pill { display: inline-flex; align-items: center; gap: 6px; background: rgba(59, 130, 246, 0.1); border: 1px solid var(--border); border-radius: 999px; padding: 4px 10px; font-family: var(--mono); font-size: 12px; margin: 2px; }
|
|
54
|
+
.list { display: flex; flex-wrap: wrap; gap: 6px; margin-top: 8px; }
|
|
55
|
+
.groupHeader { display: flex; align-items: center; justify-content: space-between; margin: 8px 0 12px; padding-bottom: 8px; border-bottom: 1px solid var(--border); }
|
|
56
|
+
.groupItem { border: 1px dashed var(--border); border-radius: 10px; padding: 12px; margin-bottom: 12px; background: rgba(59, 130, 246, 0.02); }
|
|
57
|
+
.footer { margin-top: auto; font-size: 11px; color: var(--muted); padding-top: 12px; border-top: 1px solid var(--border); }
|
|
58
|
+
.url-params { background: rgba(59, 130, 246, 0.1); border: 1px solid var(--accent); border-radius: 8px; padding: 12px; margin: 16px; font-size: 13px; }
|
|
59
|
+
.url-params code { background: rgba(0,0,0,0.1); padding: 2px 4px; border-radius: 4px; font-family: var(--mono); }
|
|
60
|
+
</style>
|
|
61
|
+
</head>
|
|
62
|
+
<body>
|
|
63
|
+
<h1>🚀 Form Builder</h1>
|
|
64
|
+
<div class="subtitle">JSON Schema → Dynamic Forms → Structured Output</div>
|
|
65
|
+
|
|
66
|
+
<div class="url-params" id="urlInfo" style="display: none;">
|
|
67
|
+
<strong>💡 URL Parameters:</strong> Add <code>?schema=BASE64_ENCODED_SCHEMA</code> to auto-load a schema.
|
|
68
|
+
</div>
|
|
69
|
+
|
|
70
|
+
<div class="grid">
|
|
71
|
+
<section class="card" id="schemaCard">
|
|
72
|
+
<h2>1️⃣ JSON Schema</h2>
|
|
73
|
+
<div class="toolbar">
|
|
74
|
+
<button class="btn good" id="applySchemaBtn">Apply Schema</button>
|
|
75
|
+
<button class="btn ghost" id="resetSchemaBtn">Reset to Example</button>
|
|
76
|
+
<button class="btn ghost" id="prettySchemaBtn">Format JSON</button>
|
|
77
|
+
<button class="btn ghost" id="downloadSchemaBtn">Download</button>
|
|
78
|
+
</div>
|
|
79
|
+
<textarea id="schemaInput" class="json" spellcheck="false" placeholder="Paste your JSON schema here..."></textarea>
|
|
80
|
+
<div id="schemaErrors" class="errors" style="display: none;"></div>
|
|
81
|
+
<div class="footer">
|
|
82
|
+
Schema defines form structure. Supports: text, textarea, number, select, file, files, and nested groups.
|
|
83
|
+
</div>
|
|
84
|
+
</section>
|
|
85
|
+
|
|
86
|
+
<section class="card" id="formCard">
|
|
87
|
+
<h2>2️⃣ Generated Form</h2>
|
|
88
|
+
<div id="formContainer" style="max-height: 400px; overflow-y: auto;">
|
|
89
|
+
<div style="text-align: center; color: var(--muted); padding: 40px;">
|
|
90
|
+
Apply a schema to generate the form
|
|
91
|
+
</div>
|
|
92
|
+
</div>
|
|
93
|
+
<div class="toolbar">
|
|
94
|
+
<button class="btn good" id="submitBtn">Submit Form</button>
|
|
95
|
+
<button class="btn ghost" id="clearFormBtn">Clear Values</button>
|
|
96
|
+
</div>
|
|
97
|
+
<div id="formErrors" class="errors" style="display: none;"></div>
|
|
98
|
+
<div class="footer">Interactive form generated from your schema. Files are simulated with resource IDs.</div>
|
|
99
|
+
</section>
|
|
100
|
+
|
|
101
|
+
<section class="card" id="outputCard">
|
|
102
|
+
<h2>3️⃣ Form Output</h2>
|
|
103
|
+
<div class="toolbar">
|
|
104
|
+
<button class="btn" id="copyOutputBtn">Copy JSON</button>
|
|
105
|
+
<button class="btn ghost" id="downloadOutputBtn">Download</button>
|
|
106
|
+
<button class="btn ghost" id="shareUrlBtn">Share URL</button>
|
|
107
|
+
</div>
|
|
108
|
+
<textarea id="outputJson" class="json" readonly placeholder="Submit the form to see the output JSON here..."></textarea>
|
|
109
|
+
<div class="footer">Structured output matching your schema. Ready for API consumption or processing.</div>
|
|
110
|
+
</section>
|
|
111
|
+
|
|
112
|
+
<section class="card" id="prefillCard">
|
|
113
|
+
<h2>4️⃣ Prefill Data</h2>
|
|
114
|
+
<div class="toolbar">
|
|
115
|
+
<button class="btn" id="loadPrefillBtn">Load Prefill</button>
|
|
116
|
+
<button class="btn ghost" id="copyTemplateBtn">Generate Template</button>
|
|
117
|
+
</div>
|
|
118
|
+
<textarea id="prefillInput" class="json" spellcheck="false" placeholder='{"field1": "value1", "field2": "value2", ...}'></textarea>
|
|
119
|
+
<div id="prefillErrors" class="errors" style="display: none;"></div>
|
|
120
|
+
<div class="footer">Prefill form fields with existing data. Useful for editing or setting default values.</div>
|
|
121
|
+
</section>
|
|
122
|
+
</div>
|
|
123
|
+
|
|
124
|
+
<script>
|
|
125
|
+
// State management
|
|
126
|
+
const state = {
|
|
127
|
+
schema: null,
|
|
128
|
+
formRoot: null,
|
|
129
|
+
resourceIndex: new Map(),
|
|
130
|
+
version: '1.0.0',
|
|
131
|
+
config: {
|
|
132
|
+
// File upload configuration
|
|
133
|
+
uploadFile: null,
|
|
134
|
+
downloadFile: null,
|
|
135
|
+
getThumbnail: null,
|
|
136
|
+
// Default implementations
|
|
137
|
+
enableFilePreview: true,
|
|
138
|
+
maxPreviewSize: '200px'
|
|
139
|
+
}
|
|
140
|
+
};
|
|
141
|
+
|
|
142
|
+
// DOM element references
|
|
143
|
+
const el = {
|
|
144
|
+
schemaInput: document.getElementById('schemaInput'),
|
|
145
|
+
schemaErrors: document.getElementById('schemaErrors'),
|
|
146
|
+
applySchemaBtn: document.getElementById('applySchemaBtn'),
|
|
147
|
+
resetSchemaBtn: document.getElementById('resetSchemaBtn'),
|
|
148
|
+
prettySchemaBtn: document.getElementById('prettySchemaBtn'),
|
|
149
|
+
downloadSchemaBtn: document.getElementById('downloadSchemaBtn'),
|
|
150
|
+
formContainer: document.getElementById('formContainer'),
|
|
151
|
+
formErrors: document.getElementById('formErrors'),
|
|
152
|
+
submitBtn: document.getElementById('submitBtn'),
|
|
153
|
+
clearFormBtn: document.getElementById('clearFormBtn'),
|
|
154
|
+
outputJson: document.getElementById('outputJson'),
|
|
155
|
+
copyOutputBtn: document.getElementById('copyOutputBtn'),
|
|
156
|
+
downloadOutputBtn: document.getElementById('downloadOutputBtn'),
|
|
157
|
+
shareUrlBtn: document.getElementById('shareUrlBtn'),
|
|
158
|
+
prefillInput: document.getElementById('prefillInput'),
|
|
159
|
+
loadPrefillBtn: document.getElementById('loadPrefillBtn'),
|
|
160
|
+
copyTemplateBtn: document.getElementById('copyTemplateBtn'),
|
|
161
|
+
prefillErrors: document.getElementById('prefillErrors'),
|
|
162
|
+
urlInfo: document.getElementById('urlInfo')
|
|
163
|
+
};
|
|
164
|
+
|
|
165
|
+
// Example schema for demonstration
|
|
166
|
+
const EXAMPLE_SCHEMA = {
|
|
167
|
+
"version": "0.3",
|
|
168
|
+
"title": "User Profile Form",
|
|
169
|
+
"elements": [
|
|
170
|
+
{
|
|
171
|
+
"type": "text",
|
|
172
|
+
"key": "name",
|
|
173
|
+
"label": "Full Name",
|
|
174
|
+
"required": true,
|
|
175
|
+
"minLength": 2,
|
|
176
|
+
"maxLength": 50,
|
|
177
|
+
"default": ""
|
|
178
|
+
},
|
|
179
|
+
{
|
|
180
|
+
"type": "textarea",
|
|
181
|
+
"key": "bio",
|
|
182
|
+
"label": "Biography",
|
|
183
|
+
"required": false,
|
|
184
|
+
"maxLength": 500,
|
|
185
|
+
"default": ""
|
|
186
|
+
},
|
|
187
|
+
{
|
|
188
|
+
"type": "number",
|
|
189
|
+
"key": "age",
|
|
190
|
+
"label": "Age",
|
|
191
|
+
"required": true,
|
|
192
|
+
"min": 18,
|
|
193
|
+
"max": 120,
|
|
194
|
+
"decimals": 0
|
|
195
|
+
},
|
|
196
|
+
{
|
|
197
|
+
"type": "select",
|
|
198
|
+
"key": "role",
|
|
199
|
+
"label": "Role",
|
|
200
|
+
"required": true,
|
|
201
|
+
"options": [
|
|
202
|
+
{"value": "user", "label": "User"},
|
|
203
|
+
{"value": "admin", "label": "Administrator"},
|
|
204
|
+
{"value": "moderator", "label": "Moderator"}
|
|
205
|
+
],
|
|
206
|
+
"default": "user"
|
|
207
|
+
},
|
|
208
|
+
{
|
|
209
|
+
"type": "file",
|
|
210
|
+
"key": "avatar",
|
|
211
|
+
"label": "Profile Picture",
|
|
212
|
+
"required": false,
|
|
213
|
+
"accept": {
|
|
214
|
+
"extensions": ["jpg", "jpeg", "png"],
|
|
215
|
+
"mime": ["image/jpeg", "image/png"]
|
|
216
|
+
},
|
|
217
|
+
"maxSizeMB": 5
|
|
218
|
+
}
|
|
219
|
+
]
|
|
220
|
+
};
|
|
221
|
+
|
|
222
|
+
// Utility functions
|
|
223
|
+
const sleep = (ms) => new Promise(r => setTimeout(r, ms));
|
|
224
|
+
const pretty = (obj) => JSON.stringify(obj, null, 2);
|
|
225
|
+
const deepClone = (obj) => structuredClone ? structuredClone(obj) : JSON.parse(JSON.stringify(obj));
|
|
226
|
+
const isPlainObject = (v) => Object.prototype.toString.call(v) === '[object Object]';
|
|
227
|
+
const setText = (node, text) => { node.textContent = text || ''; };
|
|
228
|
+
const pathJoin = (base, key) => base ? `${base}.${key}` : key;
|
|
229
|
+
const assert = (c, m) => { if(!c) throw new Error(m); };
|
|
230
|
+
const warn = (m) => console.warn('[WARN]', m);
|
|
231
|
+
|
|
232
|
+
function downloadFile(filename, text) {
|
|
233
|
+
const blob = new Blob([text], { type: 'application/json' });
|
|
234
|
+
const url = URL.createObjectURL(blob);
|
|
235
|
+
const a = document.createElement('a');
|
|
236
|
+
a.href = url;
|
|
237
|
+
a.download = filename;
|
|
238
|
+
a.click();
|
|
239
|
+
URL.revokeObjectURL(url);
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
async function makeResourceIdFromFile(file) {
|
|
243
|
+
try {
|
|
244
|
+
const buf = await file.arrayBuffer();
|
|
245
|
+
if (crypto?.subtle?.digest) {
|
|
246
|
+
const hash = await crypto.subtle.digest('SHA-256', buf);
|
|
247
|
+
const hex = [...new Uint8Array(hash)].map(b => b.toString(16).padStart(2, '0')).join('');
|
|
248
|
+
return `res_${hex.slice(0, 24)}`;
|
|
249
|
+
}
|
|
250
|
+
} catch(_) {}
|
|
251
|
+
const rnd = Math.random().toString(36).slice(2) + Math.random().toString(36).slice(2);
|
|
252
|
+
return `res_${rnd.slice(0, 24)}`;
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
function showError(container, message) {
|
|
256
|
+
container.style.display = 'block';
|
|
257
|
+
container.textContent = message;
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
function clearError(container) {
|
|
261
|
+
container.style.display = 'none';
|
|
262
|
+
container.textContent = '';
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
// Schema validation
|
|
266
|
+
function validateSchema(schema) {
|
|
267
|
+
const errors = [];
|
|
268
|
+
try { assert(schema && schema.version === '0.3', 'schema.version must be "0.3"'); } catch(e) { errors.push(e.message); }
|
|
269
|
+
try { assert(Array.isArray(schema.elements), 'schema.elements must be an array'); } catch(e) { errors.push(e.message); }
|
|
270
|
+
|
|
271
|
+
function validateElements(elements, path) {
|
|
272
|
+
const seen = new Set();
|
|
273
|
+
elements.forEach((el, idx) => {
|
|
274
|
+
const here = `${path}[${idx}]`;
|
|
275
|
+
if (!el || typeof el !== 'object') { errors.push(`${here}: element must be object`); return; }
|
|
276
|
+
if (!el.type) errors.push(`${here}: missing "type"`);
|
|
277
|
+
if (!el.key) errors.push(`${here}: missing "key"`);
|
|
278
|
+
if (el.key) {
|
|
279
|
+
if (seen.has(el.key)) errors.push(`${path}: duplicate key "${el.key}"`);
|
|
280
|
+
seen.add(el.key);
|
|
281
|
+
}
|
|
282
|
+
if (el.default !== undefined && (el.type === 'file' || el.type === 'files')) {
|
|
283
|
+
errors.push(`${here}: default forbidden for "${el.type}"`);
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
// Type-specific validation
|
|
287
|
+
if (el.type === 'text' || el.type === 'textarea') {
|
|
288
|
+
if (el.minLength != null && el.maxLength != null && el.minLength > el.maxLength) {
|
|
289
|
+
errors.push(`${here}: minLength > maxLength`);
|
|
290
|
+
}
|
|
291
|
+
if (el.pattern != null) {
|
|
292
|
+
try { new RegExp(el.pattern); } catch { errors.push(`${here}: invalid pattern regex`); }
|
|
293
|
+
}
|
|
294
|
+
}
|
|
295
|
+
if (el.type === 'number') {
|
|
296
|
+
if (typeof el.min === 'number' && typeof el.max === 'number' && el.min > el.max) {
|
|
297
|
+
errors.push(`${here}: min > max`);
|
|
298
|
+
}
|
|
299
|
+
if (el.decimals != null && (!Number.isInteger(el.decimals) || el.decimals < 0 || el.decimals > 8)) {
|
|
300
|
+
errors.push(`${here}: decimals must be 0..8`);
|
|
301
|
+
}
|
|
302
|
+
}
|
|
303
|
+
if (el.type === 'select') {
|
|
304
|
+
if (!Array.isArray(el.options) || el.options.length === 0) {
|
|
305
|
+
errors.push(`${here}: select.options must be non-empty array`);
|
|
306
|
+
} else {
|
|
307
|
+
const values = new Set(el.options.map(o => o.value));
|
|
308
|
+
if (el.default != null && !values.has(el.default)) {
|
|
309
|
+
errors.push(`${here}: default "${el.default}" not in options`);
|
|
310
|
+
}
|
|
311
|
+
}
|
|
312
|
+
}
|
|
313
|
+
if (el.type === 'file') {
|
|
314
|
+
if (el.maxSizeMB != null && el.maxSizeMB <= 0) {
|
|
315
|
+
errors.push(`${here}: maxSizeMB must be > 0`);
|
|
316
|
+
}
|
|
317
|
+
}
|
|
318
|
+
if (el.type === 'files') {
|
|
319
|
+
if (el.minCount != null && el.maxCount != null && el.minCount > el.maxCount) {
|
|
320
|
+
errors.push(`${here}: minCount > maxCount`);
|
|
321
|
+
}
|
|
322
|
+
}
|
|
323
|
+
if (el.type === 'group') {
|
|
324
|
+
if (!Array.isArray(el.elements)) errors.push(`${here}: group.elements must be array`);
|
|
325
|
+
if (el.repeat) {
|
|
326
|
+
if (el.repeat.min != null && el.repeat.max != null && el.repeat.min > el.repeat.max) {
|
|
327
|
+
errors.push(`${here}: repeat.min > repeat.max`);
|
|
328
|
+
}
|
|
329
|
+
}
|
|
330
|
+
if (Array.isArray(el.elements)) validateElements(el.elements, pathJoin(path, el.key));
|
|
331
|
+
}
|
|
332
|
+
});
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
if (Array.isArray(schema.elements)) validateElements(schema.elements, 'elements');
|
|
336
|
+
return errors;
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
function clear(node) {
|
|
340
|
+
while (node.firstChild) node.removeChild(node.firstChild);
|
|
341
|
+
}
|
|
342
|
+
|
|
343
|
+
// Form rendering
|
|
344
|
+
function renderForm(schema, prefill) {
|
|
345
|
+
state.schema = deepClone(schema);
|
|
346
|
+
state.formRoot = el.formContainer;
|
|
347
|
+
clear(state.formRoot);
|
|
348
|
+
clearError(el.formErrors);
|
|
349
|
+
|
|
350
|
+
const formEl = document.createElement('form');
|
|
351
|
+
formEl.id = 'dynamicForm';
|
|
352
|
+
formEl.addEventListener('submit', (e) => e.preventDefault());
|
|
353
|
+
|
|
354
|
+
const ctx = { path: '', prefill: prefill || {} };
|
|
355
|
+
schema.elements.forEach(element => {
|
|
356
|
+
const block = renderElement(element, ctx);
|
|
357
|
+
formEl.appendChild(block);
|
|
358
|
+
});
|
|
359
|
+
|
|
360
|
+
state.formRoot.appendChild(formEl);
|
|
361
|
+
}
|
|
362
|
+
|
|
363
|
+
function renderElement(element, ctx) {
|
|
364
|
+
const wrapper = document.createElement('div');
|
|
365
|
+
wrapper.className = 'field';
|
|
366
|
+
|
|
367
|
+
const label = document.createElement('div');
|
|
368
|
+
label.className = 'label';
|
|
369
|
+
const title = document.createElement('span');
|
|
370
|
+
title.textContent = element.label || element.key;
|
|
371
|
+
label.appendChild(title);
|
|
372
|
+
|
|
373
|
+
if (element.required) {
|
|
374
|
+
const req = document.createElement('span');
|
|
375
|
+
req.className = 'req';
|
|
376
|
+
req.textContent = '*';
|
|
377
|
+
label.appendChild(req);
|
|
378
|
+
}
|
|
379
|
+
wrapper.appendChild(label);
|
|
380
|
+
|
|
381
|
+
const pathKey = pathJoin(ctx.path, element.key);
|
|
382
|
+
|
|
383
|
+
switch (element.type) {
|
|
384
|
+
case 'text': {
|
|
385
|
+
const input = document.createElement('input');
|
|
386
|
+
input.type = 'text';
|
|
387
|
+
input.name = pathKey;
|
|
388
|
+
input.dataset.type = 'text';
|
|
389
|
+
setTextValueFromPrefill(input, element, ctx.prefill, element.key);
|
|
390
|
+
input.addEventListener('input', () => markValidity(input, null));
|
|
391
|
+
wrapper.appendChild(input);
|
|
392
|
+
wrapper.appendChild(makeFieldHint(element));
|
|
393
|
+
break;
|
|
394
|
+
}
|
|
395
|
+
case 'textarea': {
|
|
396
|
+
const ta = document.createElement('textarea');
|
|
397
|
+
ta.name = pathKey;
|
|
398
|
+
ta.rows = 4;
|
|
399
|
+
ta.dataset.type = 'textarea';
|
|
400
|
+
setTextValueFromPrefill(ta, element, ctx.prefill, element.key);
|
|
401
|
+
ta.addEventListener('input', () => markValidity(ta, null));
|
|
402
|
+
wrapper.appendChild(ta);
|
|
403
|
+
wrapper.appendChild(makeFieldHint(element));
|
|
404
|
+
break;
|
|
405
|
+
}
|
|
406
|
+
case 'number': {
|
|
407
|
+
const input = document.createElement('input');
|
|
408
|
+
input.type = 'number';
|
|
409
|
+
input.name = pathKey;
|
|
410
|
+
input.dataset.type = 'number';
|
|
411
|
+
if (element.step != null) input.step = String(element.step);
|
|
412
|
+
if (element.min != null) input.min = String(element.min);
|
|
413
|
+
if (element.max != null) input.max = String(element.max);
|
|
414
|
+
setNumberFromPrefill(input, element, ctx.prefill, element.key);
|
|
415
|
+
input.addEventListener('blur', () => {
|
|
416
|
+
if (input.value === '') return;
|
|
417
|
+
const v = parseFloat(input.value);
|
|
418
|
+
if (Number.isFinite(v) && Number.isInteger(element.decimals ?? 0)) {
|
|
419
|
+
input.value = String(Number(v.toFixed(element.decimals)));
|
|
420
|
+
}
|
|
421
|
+
});
|
|
422
|
+
input.addEventListener('input', () => markValidity(input, null));
|
|
423
|
+
wrapper.appendChild(input);
|
|
424
|
+
wrapper.appendChild(makeFieldHint(element, `decimals=${element.decimals ?? 0}`));
|
|
425
|
+
break;
|
|
426
|
+
}
|
|
427
|
+
case 'select': {
|
|
428
|
+
const sel = document.createElement('select');
|
|
429
|
+
sel.name = pathKey;
|
|
430
|
+
sel.dataset.type = 'select';
|
|
431
|
+
|
|
432
|
+
if (!element.required) {
|
|
433
|
+
const opt = document.createElement('option');
|
|
434
|
+
opt.value = '';
|
|
435
|
+
opt.textContent = '—';
|
|
436
|
+
sel.appendChild(opt);
|
|
437
|
+
}
|
|
438
|
+
|
|
439
|
+
element.options.forEach(o => {
|
|
440
|
+
const opt = document.createElement('option');
|
|
441
|
+
opt.value = String(o.value);
|
|
442
|
+
opt.textContent = o.label ?? String(o.value);
|
|
443
|
+
sel.appendChild(opt);
|
|
444
|
+
});
|
|
445
|
+
|
|
446
|
+
setSelectFromPrefill(sel, element, ctx.prefill, element.key);
|
|
447
|
+
sel.addEventListener('input', () => markValidity(sel, null));
|
|
448
|
+
wrapper.appendChild(sel);
|
|
449
|
+
break;
|
|
450
|
+
}
|
|
451
|
+
case 'file': {
|
|
452
|
+
const hid = document.createElement('input');
|
|
453
|
+
hid.type = 'hidden';
|
|
454
|
+
hid.name = pathKey;
|
|
455
|
+
hid.dataset.type = 'file';
|
|
456
|
+
|
|
457
|
+
const container = document.createElement('div');
|
|
458
|
+
|
|
459
|
+
// Preview container
|
|
460
|
+
const previewContainer = document.createElement('div');
|
|
461
|
+
previewContainer.className = 'file-preview-container';
|
|
462
|
+
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);';
|
|
463
|
+
|
|
464
|
+
const picker = document.createElement('input');
|
|
465
|
+
picker.type = 'file';
|
|
466
|
+
if (element.accept?.extensions) {
|
|
467
|
+
picker.accept = element.accept.extensions.map(ext => `.${ext}`).join(',');
|
|
468
|
+
}
|
|
469
|
+
|
|
470
|
+
const handleFileSelect = async (file) => {
|
|
471
|
+
const err = fileValidationError(element, file);
|
|
472
|
+
if (err) {
|
|
473
|
+
markValidity(picker, err);
|
|
474
|
+
return;
|
|
475
|
+
}
|
|
476
|
+
|
|
477
|
+
try {
|
|
478
|
+
let resourceId;
|
|
479
|
+
|
|
480
|
+
// Use custom upload function if provided
|
|
481
|
+
if (state.config.uploadFile && typeof state.config.uploadFile === 'function') {
|
|
482
|
+
resourceId = await state.config.uploadFile(file);
|
|
483
|
+
} else {
|
|
484
|
+
// Fallback to simulated resource ID
|
|
485
|
+
resourceId = await makeResourceIdFromFile(file);
|
|
486
|
+
state.resourceIndex.set(resourceId, { name: file.name, type: file.type, size: file.size });
|
|
487
|
+
}
|
|
488
|
+
|
|
489
|
+
hid.value = resourceId;
|
|
490
|
+
await renderFilePreview(previewContainer, resourceId, file.name, file.type);
|
|
491
|
+
markValidity(picker, null);
|
|
492
|
+
} catch (error) {
|
|
493
|
+
markValidity(picker, `Upload failed: ${error.message}`);
|
|
494
|
+
}
|
|
495
|
+
};
|
|
496
|
+
|
|
497
|
+
picker.addEventListener('change', async () => {
|
|
498
|
+
if (picker.files && picker.files[0]) {
|
|
499
|
+
await handleFileSelect(picker.files[0]);
|
|
500
|
+
}
|
|
501
|
+
});
|
|
502
|
+
|
|
503
|
+
// Handle prefilled values
|
|
504
|
+
const pv = ctx.prefill && ctx.prefill[element.key];
|
|
505
|
+
if (typeof pv === 'string' && pv) {
|
|
506
|
+
hid.value = pv;
|
|
507
|
+
// Try to render preview for existing resource
|
|
508
|
+
const fileName = `file_${pv.slice(-8)}`;
|
|
509
|
+
renderFilePreview(previewContainer, pv, fileName, 'application/octet-stream');
|
|
510
|
+
} else {
|
|
511
|
+
// Show upload prompt
|
|
512
|
+
previewContainer.innerHTML = '<div style="color: var(--muted); font-size: 14px;">📁 Click "Choose File" to upload</div>';
|
|
513
|
+
}
|
|
514
|
+
|
|
515
|
+
container.appendChild(previewContainer);
|
|
516
|
+
container.appendChild(picker);
|
|
517
|
+
container.appendChild(hid);
|
|
518
|
+
|
|
519
|
+
wrapper.appendChild(container);
|
|
520
|
+
wrapper.appendChild(makeFieldHint(element, 'Files are uploaded to S3 and referenced by URL'));
|
|
521
|
+
break;
|
|
522
|
+
}
|
|
523
|
+
case 'files': {
|
|
524
|
+
const hid = document.createElement('input');
|
|
525
|
+
hid.type = 'hidden';
|
|
526
|
+
hid.name = pathKey;
|
|
527
|
+
hid.dataset.type = 'files';
|
|
528
|
+
|
|
529
|
+
const list = document.createElement('div');
|
|
530
|
+
list.className = 'list';
|
|
531
|
+
|
|
532
|
+
const picker = document.createElement('input');
|
|
533
|
+
picker.type = 'file';
|
|
534
|
+
picker.multiple = true;
|
|
535
|
+
if (element.accept?.extensions) {
|
|
536
|
+
picker.accept = element.accept.extensions.map(ext => `.${ext}`).join(',');
|
|
537
|
+
}
|
|
538
|
+
|
|
539
|
+
picker.addEventListener('change', async () => {
|
|
540
|
+
let arr = parseJSONSafe(hid.value, []);
|
|
541
|
+
if (!Array.isArray(arr)) arr = [];
|
|
542
|
+
|
|
543
|
+
if (picker.files && picker.files.length) {
|
|
544
|
+
for (const file of picker.files) {
|
|
545
|
+
const err = fileValidationError(element, file);
|
|
546
|
+
if (err) {
|
|
547
|
+
markValidity(picker, err);
|
|
548
|
+
return;
|
|
549
|
+
}
|
|
550
|
+
}
|
|
551
|
+
|
|
552
|
+
for (const file of picker.files) {
|
|
553
|
+
const rid = await makeResourceIdFromFile(file);
|
|
554
|
+
state.resourceIndex.set(rid, { name: file.name, type: file.type, size: file.size });
|
|
555
|
+
arr.push(rid);
|
|
556
|
+
}
|
|
557
|
+
|
|
558
|
+
hid.value = JSON.stringify(arr);
|
|
559
|
+
renderResourcePills(list, arr, (ridToRemove) => {
|
|
560
|
+
const next = arr.filter(x => x !== ridToRemove);
|
|
561
|
+
hid.value = JSON.stringify(next);
|
|
562
|
+
arr = next;
|
|
563
|
+
renderResourcePills(list, next, arguments.callee);
|
|
564
|
+
});
|
|
565
|
+
markValidity(picker, null);
|
|
566
|
+
}
|
|
567
|
+
});
|
|
568
|
+
|
|
569
|
+
const pv = ctx.prefill && ctx.prefill[element.key];
|
|
570
|
+
let initial = Array.isArray(pv) ? pv.filter(Boolean) : [];
|
|
571
|
+
if (initial.length) {
|
|
572
|
+
hid.value = JSON.stringify(initial);
|
|
573
|
+
renderResourcePills(list, initial, (ridToRemove) => {
|
|
574
|
+
const next = initial.filter(x => x !== ridToRemove);
|
|
575
|
+
hid.value = JSON.stringify(next);
|
|
576
|
+
initial = next;
|
|
577
|
+
renderResourcePills(list, next, arguments.callee);
|
|
578
|
+
});
|
|
579
|
+
}
|
|
580
|
+
|
|
581
|
+
wrapper.appendChild(picker);
|
|
582
|
+
wrapper.appendChild(list);
|
|
583
|
+
wrapper.appendChild(hid);
|
|
584
|
+
wrapper.appendChild(makeFieldHint(element, 'Multiple files → resource ID array'));
|
|
585
|
+
break;
|
|
586
|
+
}
|
|
587
|
+
case 'group': {
|
|
588
|
+
wrapper.dataset.group = element.key;
|
|
589
|
+
wrapper.dataset.groupPath = pathKey;
|
|
590
|
+
|
|
591
|
+
const groupWrap = document.createElement('div');
|
|
592
|
+
const header = document.createElement('div');
|
|
593
|
+
header.className = 'groupHeader';
|
|
594
|
+
|
|
595
|
+
const left = document.createElement('div');
|
|
596
|
+
left.innerHTML = `<span>${element.label || element.key}</span>`;
|
|
597
|
+
header.appendChild(left);
|
|
598
|
+
|
|
599
|
+
const right = document.createElement('div');
|
|
600
|
+
groupWrap.appendChild(header);
|
|
601
|
+
|
|
602
|
+
const itemsWrap = document.createElement('div');
|
|
603
|
+
itemsWrap.dataset.itemsFor = element.key;
|
|
604
|
+
|
|
605
|
+
if (element.repeat && isPlainObject(element.repeat)) {
|
|
606
|
+
const min = element.repeat.min ?? 0;
|
|
607
|
+
const max = element.repeat.max ?? Infinity;
|
|
608
|
+
const pre = Array.isArray(ctx.prefill?.[element.key]) ? ctx.prefill[element.key] : null;
|
|
609
|
+
|
|
610
|
+
const addBtn = document.createElement('button');
|
|
611
|
+
addBtn.type = 'button';
|
|
612
|
+
addBtn.className = 'btn';
|
|
613
|
+
addBtn.textContent = 'Add';
|
|
614
|
+
right.appendChild(addBtn);
|
|
615
|
+
header.appendChild(right);
|
|
616
|
+
|
|
617
|
+
const countItems = () => itemsWrap.querySelectorAll(':scope > .groupItem').length;
|
|
618
|
+
const refreshControls = () => {
|
|
619
|
+
const n = countItems();
|
|
620
|
+
addBtn.disabled = n >= max;
|
|
621
|
+
left.innerHTML = `<span>${element.label || element.key}</span> <span class="muted" style="font-size: 12px;">[${n} / ${max === Infinity ? '∞' : max}, min=${min}]</span>`;
|
|
622
|
+
};
|
|
623
|
+
|
|
624
|
+
const addItem = (prefillObj) => {
|
|
625
|
+
const item = document.createElement('div');
|
|
626
|
+
item.className = 'groupItem';
|
|
627
|
+
const subCtx = {
|
|
628
|
+
path: pathJoin(ctx.path, element.key + `[${countItems()}]`),
|
|
629
|
+
prefill: prefillObj || {}
|
|
630
|
+
};
|
|
631
|
+
element.elements.forEach(child => item.appendChild(renderElement(child, subCtx)));
|
|
632
|
+
|
|
633
|
+
const rem = document.createElement('button');
|
|
634
|
+
rem.type = 'button';
|
|
635
|
+
rem.className = 'btn bad';
|
|
636
|
+
rem.textContent = 'Remove';
|
|
637
|
+
rem.style.fontSize = '12px';
|
|
638
|
+
rem.addEventListener('click', () => {
|
|
639
|
+
if (countItems() <= (element.repeat.min ?? 0)) return;
|
|
640
|
+
itemsWrap.removeChild(item);
|
|
641
|
+
refreshControls();
|
|
642
|
+
});
|
|
643
|
+
item.appendChild(rem);
|
|
644
|
+
itemsWrap.appendChild(item);
|
|
645
|
+
refreshControls();
|
|
646
|
+
};
|
|
647
|
+
|
|
648
|
+
groupWrap.appendChild(itemsWrap);
|
|
649
|
+
|
|
650
|
+
if (pre && pre.length) {
|
|
651
|
+
const n = Math.min(max, Math.max(min, pre.length));
|
|
652
|
+
for (let i = 0; i < n; i++) addItem(pre[i]);
|
|
653
|
+
} else {
|
|
654
|
+
const n = Math.max(min, 0);
|
|
655
|
+
for (let i = 0; i < n; i++) addItem(null);
|
|
656
|
+
}
|
|
657
|
+
|
|
658
|
+
addBtn.addEventListener('click', () => addItem(null));
|
|
659
|
+
} else {
|
|
660
|
+
// Single object group
|
|
661
|
+
const subCtx = {
|
|
662
|
+
path: pathJoin(ctx.path, element.key),
|
|
663
|
+
prefill: ctx.prefill?.[element.key] || {}
|
|
664
|
+
};
|
|
665
|
+
element.elements.forEach(child => itemsWrap.appendChild(renderElement(child, subCtx)));
|
|
666
|
+
groupWrap.appendChild(itemsWrap);
|
|
667
|
+
}
|
|
668
|
+
|
|
669
|
+
wrapper.innerHTML = '';
|
|
670
|
+
wrapper.appendChild(groupWrap);
|
|
671
|
+
break;
|
|
672
|
+
}
|
|
673
|
+
default:
|
|
674
|
+
wrapper.appendChild(document.createTextNode(`Unsupported type: ${element.type}`));
|
|
675
|
+
}
|
|
676
|
+
|
|
677
|
+
return wrapper;
|
|
678
|
+
}
|
|
679
|
+
|
|
680
|
+
function makeFieldHint(element, extra = '') {
|
|
681
|
+
const hint = document.createElement('div');
|
|
682
|
+
hint.className = 'hint';
|
|
683
|
+
const bits = [];
|
|
684
|
+
|
|
685
|
+
if (element.required) bits.push('required');
|
|
686
|
+
|
|
687
|
+
if (element.type === 'text' || element.type === 'textarea') {
|
|
688
|
+
if (element.minLength != null) bits.push(`minLength=${element.minLength}`);
|
|
689
|
+
if (element.maxLength != null) bits.push(`maxLength=${element.maxLength}`);
|
|
690
|
+
if (element.pattern) bits.push(`pattern=/${element.pattern}/`);
|
|
691
|
+
}
|
|
692
|
+
if (element.type === 'number') {
|
|
693
|
+
if (element.min != null) bits.push(`min=${element.min}`);
|
|
694
|
+
if (element.max != null) bits.push(`max=${element.max}`);
|
|
695
|
+
if (element.decimals != null) bits.push(`decimals=${element.decimals}`);
|
|
696
|
+
}
|
|
697
|
+
if (element.type === 'select') {
|
|
698
|
+
bits.push(`${element.options.length} options`);
|
|
699
|
+
}
|
|
700
|
+
if (element.type === 'files') {
|
|
701
|
+
if (element.minCount != null) bits.push(`minCount=${element.minCount}`);
|
|
702
|
+
if (element.maxCount != null) bits.push(`maxCount=${element.maxCount}`);
|
|
703
|
+
}
|
|
704
|
+
|
|
705
|
+
hint.textContent = [bits.join(' • '), extra].filter(Boolean).join(' | ');
|
|
706
|
+
return hint;
|
|
707
|
+
}
|
|
708
|
+
|
|
709
|
+
async function renderFilePreview(container, resourceId, fileName, fileType) {
|
|
710
|
+
container.innerHTML = '';
|
|
711
|
+
|
|
712
|
+
const preview = document.createElement('div');
|
|
713
|
+
preview.style.cssText = 'display: flex; align-items: center; gap: 12px; padding: 8px;';
|
|
714
|
+
|
|
715
|
+
// File icon/thumbnail
|
|
716
|
+
const iconContainer = document.createElement('div');
|
|
717
|
+
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;`;
|
|
718
|
+
|
|
719
|
+
if (fileType.startsWith('image/')) {
|
|
720
|
+
const img = document.createElement('img');
|
|
721
|
+
img.style.cssText = 'width: 48px; height: 48px; object-cover: cover; border-radius: 8px;';
|
|
722
|
+
|
|
723
|
+
// Try to get thumbnail using custom function or fallback
|
|
724
|
+
if (state.config.getThumbnail && typeof state.config.getThumbnail === 'function') {
|
|
725
|
+
try {
|
|
726
|
+
const thumbnailUrl = await state.config.getThumbnail(resourceId);
|
|
727
|
+
img.src = thumbnailUrl;
|
|
728
|
+
img.onerror = () => { iconContainer.textContent = '🖼️'; };
|
|
729
|
+
iconContainer.innerHTML = '';
|
|
730
|
+
iconContainer.appendChild(img);
|
|
731
|
+
} catch {
|
|
732
|
+
iconContainer.textContent = '🖼️';
|
|
733
|
+
}
|
|
734
|
+
} else {
|
|
735
|
+
iconContainer.textContent = '🖼️';
|
|
736
|
+
}
|
|
737
|
+
} else if (fileType.startsWith('video/')) {
|
|
738
|
+
iconContainer.textContent = '🎥';
|
|
739
|
+
} else if (fileType.includes('pdf')) {
|
|
740
|
+
iconContainer.textContent = '📄';
|
|
741
|
+
} else {
|
|
742
|
+
iconContainer.textContent = '📎';
|
|
743
|
+
}
|
|
744
|
+
|
|
745
|
+
preview.appendChild(iconContainer);
|
|
746
|
+
|
|
747
|
+
// File info
|
|
748
|
+
const info = document.createElement('div');
|
|
749
|
+
info.style.cssText = 'flex: 1;';
|
|
750
|
+
|
|
751
|
+
const name = document.createElement('div');
|
|
752
|
+
name.style.cssText = 'font-weight: 500; font-size: 14px; color: var(--fg);';
|
|
753
|
+
name.textContent = fileName;
|
|
754
|
+
|
|
755
|
+
const details = document.createElement('div');
|
|
756
|
+
details.style.cssText = 'font-size: 12px; color: var(--muted); margin-top: 4px;';
|
|
757
|
+
details.textContent = `${fileType} • ${resourceId.slice(0, 12)}...`;
|
|
758
|
+
|
|
759
|
+
info.appendChild(name);
|
|
760
|
+
info.appendChild(details);
|
|
761
|
+
preview.appendChild(info);
|
|
762
|
+
|
|
763
|
+
// Action buttons
|
|
764
|
+
const actions = document.createElement('div');
|
|
765
|
+
actions.style.cssText = 'display: flex; gap: 8px;';
|
|
766
|
+
|
|
767
|
+
// Download button
|
|
768
|
+
const downloadBtn = document.createElement('button');
|
|
769
|
+
downloadBtn.className = 'btn';
|
|
770
|
+
downloadBtn.style.cssText = 'padding: 6px 10px; font-size: 12px;';
|
|
771
|
+
downloadBtn.textContent = '⬇️';
|
|
772
|
+
downloadBtn.title = 'Download';
|
|
773
|
+
downloadBtn.addEventListener('click', async () => {
|
|
774
|
+
if (state.config.downloadFile && typeof state.config.downloadFile === 'function') {
|
|
775
|
+
try {
|
|
776
|
+
await state.config.downloadFile(resourceId, fileName);
|
|
777
|
+
} catch (error) {
|
|
778
|
+
console.error('Download failed:', error);
|
|
779
|
+
}
|
|
780
|
+
} else {
|
|
781
|
+
console.log('Download simulated:', resourceId, fileName);
|
|
782
|
+
}
|
|
783
|
+
});
|
|
784
|
+
|
|
785
|
+
// Remove button
|
|
786
|
+
const removeBtn = document.createElement('button');
|
|
787
|
+
removeBtn.className = 'btn bad';
|
|
788
|
+
removeBtn.style.cssText = 'padding: 6px 10px; font-size: 12px;';
|
|
789
|
+
removeBtn.textContent = '✕';
|
|
790
|
+
removeBtn.title = 'Remove';
|
|
791
|
+
removeBtn.addEventListener('click', () => {
|
|
792
|
+
const hiddenInput = container.parentElement.querySelector('input[type="hidden"]');
|
|
793
|
+
if (hiddenInput) {
|
|
794
|
+
hiddenInput.value = '';
|
|
795
|
+
}
|
|
796
|
+
container.innerHTML = '<div style="color: var(--muted); font-size: 14px;">📁 Click "Choose File" to upload</div>';
|
|
797
|
+
});
|
|
798
|
+
|
|
799
|
+
actions.appendChild(downloadBtn);
|
|
800
|
+
actions.appendChild(removeBtn);
|
|
801
|
+
preview.appendChild(actions);
|
|
802
|
+
|
|
803
|
+
container.appendChild(preview);
|
|
804
|
+
}
|
|
805
|
+
|
|
806
|
+
function renderResourcePills(container, rids, onRemove) {
|
|
807
|
+
clear(container);
|
|
808
|
+
rids.forEach(rid => {
|
|
809
|
+
const meta = state.resourceIndex.get(rid);
|
|
810
|
+
const pill = document.createElement('span');
|
|
811
|
+
pill.className = 'pill';
|
|
812
|
+
pill.textContent = rid;
|
|
813
|
+
|
|
814
|
+
if (meta) {
|
|
815
|
+
const small = document.createElement('span');
|
|
816
|
+
small.className = 'muted';
|
|
817
|
+
small.textContent = ` (${meta.name ?? 'file'}, ${formatFileSize(meta.size ?? 0)})`;
|
|
818
|
+
pill.appendChild(small);
|
|
819
|
+
}
|
|
820
|
+
|
|
821
|
+
if (onRemove) {
|
|
822
|
+
const x = document.createElement('button');
|
|
823
|
+
x.type = 'button';
|
|
824
|
+
x.className = 'btn bad';
|
|
825
|
+
x.textContent = '×';
|
|
826
|
+
x.style.padding = '2px 6px';
|
|
827
|
+
x.style.marginLeft = '6px';
|
|
828
|
+
x.addEventListener('click', () => onRemove(rid));
|
|
829
|
+
pill.appendChild(x);
|
|
830
|
+
}
|
|
831
|
+
|
|
832
|
+
container.appendChild(pill);
|
|
833
|
+
});
|
|
834
|
+
}
|
|
835
|
+
|
|
836
|
+
function formatFileSize(bytes) {
|
|
837
|
+
if (bytes === 0) return '0 B';
|
|
838
|
+
const k = 1024;
|
|
839
|
+
const sizes = ['B', 'KB', 'MB', 'GB'];
|
|
840
|
+
const i = Math.floor(Math.log(bytes) / Math.log(k));
|
|
841
|
+
return parseFloat((bytes / Math.pow(k, i)).toFixed(1)) + ' ' + sizes[i];
|
|
842
|
+
}
|
|
843
|
+
|
|
844
|
+
function markValidity(input, msg) {
|
|
845
|
+
const prev = input?.parentElement?.querySelector?.('.msg');
|
|
846
|
+
if (prev) prev.remove();
|
|
847
|
+
|
|
848
|
+
if (input) input.classList.toggle('invalid', !!msg);
|
|
849
|
+
|
|
850
|
+
if (msg && input?.parentElement) {
|
|
851
|
+
const m = document.createElement('div');
|
|
852
|
+
m.className = 'msg';
|
|
853
|
+
m.textContent = msg;
|
|
854
|
+
input.parentElement.appendChild(m);
|
|
855
|
+
}
|
|
856
|
+
}
|
|
857
|
+
|
|
858
|
+
function parseJSONSafe(text, fallback = null) {
|
|
859
|
+
try {
|
|
860
|
+
return JSON.parse(text);
|
|
861
|
+
} catch {
|
|
862
|
+
return fallback;
|
|
863
|
+
}
|
|
864
|
+
}
|
|
865
|
+
|
|
866
|
+
function setTextValueFromPrefill(input, element, prefillObj, key) {
|
|
867
|
+
let v = undefined;
|
|
868
|
+
if (prefillObj && Object.prototype.hasOwnProperty.call(prefillObj, key)) v = prefillObj[key];
|
|
869
|
+
else if (element.default !== undefined) v = element.default;
|
|
870
|
+
if (v !== undefined) input.value = String(v);
|
|
871
|
+
}
|
|
872
|
+
|
|
873
|
+
function setNumberFromPrefill(input, element, prefillObj, key) {
|
|
874
|
+
let v = undefined;
|
|
875
|
+
if (prefillObj && Object.prototype.hasOwnProperty.call(prefillObj, key)) v = prefillObj[key];
|
|
876
|
+
else if (element.default !== undefined) v = element.default;
|
|
877
|
+
if (v !== undefined && v !== null && v !== '') input.value = String(v);
|
|
878
|
+
}
|
|
879
|
+
|
|
880
|
+
function setSelectFromPrefill(select, element, prefillObj, key) {
|
|
881
|
+
const values = new Set(element.options.map(o => String(o.value)));
|
|
882
|
+
let v = undefined;
|
|
883
|
+
if (prefillObj && Object.prototype.hasOwnProperty.call(prefillObj, key)) v = prefillObj[key];
|
|
884
|
+
else if (element.default !== undefined) v = element.default;
|
|
885
|
+
if (v !== undefined && values.has(String(v))) select.value = String(v);
|
|
886
|
+
else if (!element.required) select.value = '';
|
|
887
|
+
}
|
|
888
|
+
|
|
889
|
+
function fileValidationError(element, file) {
|
|
890
|
+
if (!file) return 'no file';
|
|
891
|
+
if (element.maxSizeMB != null && file.size > element.maxSizeMB * 1024 * 1024) {
|
|
892
|
+
return `file too large > ${element.maxSizeMB}MB`;
|
|
893
|
+
}
|
|
894
|
+
if (element.accept) {
|
|
895
|
+
const { extensions, mime } = element.accept;
|
|
896
|
+
if (mime && Array.isArray(mime) && mime.length && !mime.includes(file.type)) {
|
|
897
|
+
return `mime not allowed: ${file.type}`;
|
|
898
|
+
}
|
|
899
|
+
if (extensions && Array.isArray(extensions) && extensions.length) {
|
|
900
|
+
const ext = (file.name.split('.').pop() || '').toLowerCase();
|
|
901
|
+
if (!extensions.includes(ext)) return `extension .${ext} not allowed`;
|
|
902
|
+
}
|
|
903
|
+
}
|
|
904
|
+
return null;
|
|
905
|
+
}
|
|
906
|
+
|
|
907
|
+
// Form data collection and validation
|
|
908
|
+
function collectAndValidate(schema) {
|
|
909
|
+
const form = document.getElementById('dynamicForm');
|
|
910
|
+
const errors = [];
|
|
911
|
+
|
|
912
|
+
function collectElement(element, scopeRoot) {
|
|
913
|
+
const key = element.key;
|
|
914
|
+
|
|
915
|
+
switch (element.type) {
|
|
916
|
+
case 'text':
|
|
917
|
+
case 'textarea': {
|
|
918
|
+
const input = scopeRoot.querySelector(`[name$="${key}"]`);
|
|
919
|
+
const val = (input?.value ?? '').trim();
|
|
920
|
+
if (element.required && val === '') {
|
|
921
|
+
errors.push(`${key}: required`);
|
|
922
|
+
markValidity(input, 'required');
|
|
923
|
+
} else if (val !== '') {
|
|
924
|
+
if (element.minLength != null && val.length < element.minLength) {
|
|
925
|
+
errors.push(`${key}: minLength=${element.minLength}`);
|
|
926
|
+
markValidity(input, `minLength=${element.minLength}`);
|
|
927
|
+
}
|
|
928
|
+
if (element.maxLength != null && val.length > element.maxLength) {
|
|
929
|
+
errors.push(`${key}: maxLength=${element.maxLength}`);
|
|
930
|
+
markValidity(input, `maxLength=${element.maxLength}`);
|
|
931
|
+
}
|
|
932
|
+
if (element.pattern) {
|
|
933
|
+
try {
|
|
934
|
+
const re = new RegExp(element.pattern);
|
|
935
|
+
if (!re.test(val)) {
|
|
936
|
+
errors.push(`${key}: pattern mismatch`);
|
|
937
|
+
markValidity(input, 'pattern mismatch');
|
|
938
|
+
}
|
|
939
|
+
} catch {
|
|
940
|
+
errors.push(`${key}: invalid pattern`);
|
|
941
|
+
markValidity(input, 'invalid pattern');
|
|
942
|
+
}
|
|
943
|
+
}
|
|
944
|
+
} else {
|
|
945
|
+
markValidity(input, null);
|
|
946
|
+
}
|
|
947
|
+
return val;
|
|
948
|
+
}
|
|
949
|
+
case 'number': {
|
|
950
|
+
const input = scopeRoot.querySelector(`[name$="${key}"]`);
|
|
951
|
+
const raw = input?.value ?? '';
|
|
952
|
+
if (element.required && raw === '') {
|
|
953
|
+
errors.push(`${key}: required`);
|
|
954
|
+
markValidity(input, 'required');
|
|
955
|
+
return null;
|
|
956
|
+
}
|
|
957
|
+
if (raw === '') {
|
|
958
|
+
markValidity(input, null);
|
|
959
|
+
return null;
|
|
960
|
+
}
|
|
961
|
+
const v = parseFloat(raw);
|
|
962
|
+
if (!Number.isFinite(v)) {
|
|
963
|
+
errors.push(`${key}: not a number`);
|
|
964
|
+
markValidity(input, 'not a number');
|
|
965
|
+
return null;
|
|
966
|
+
}
|
|
967
|
+
if (element.min != null && v < element.min) {
|
|
968
|
+
errors.push(`${key}: < min=${element.min}`);
|
|
969
|
+
markValidity(input, `< min=${element.min}`);
|
|
970
|
+
}
|
|
971
|
+
if (element.max != null && v > element.max) {
|
|
972
|
+
errors.push(`${key}: > max=${element.max}`);
|
|
973
|
+
markValidity(input, `> max=${element.max}`);
|
|
974
|
+
}
|
|
975
|
+
const d = Number.isInteger(element.decimals ?? 0) ? element.decimals : 0;
|
|
976
|
+
const r = Number(v.toFixed(d));
|
|
977
|
+
input.value = String(r);
|
|
978
|
+
markValidity(input, null);
|
|
979
|
+
return r;
|
|
980
|
+
}
|
|
981
|
+
case 'select': {
|
|
982
|
+
const sel = scopeRoot.querySelector(`select[name$="${key}"]`);
|
|
983
|
+
const val = sel?.value ?? '';
|
|
984
|
+
const values = new Set(element.options.map(o => String(o.value)));
|
|
985
|
+
if (element.required && val === '') {
|
|
986
|
+
errors.push(`${key}: required`);
|
|
987
|
+
markValidity(sel, 'required');
|
|
988
|
+
return '';
|
|
989
|
+
}
|
|
990
|
+
if (val !== '' && !values.has(String(val))) {
|
|
991
|
+
errors.push(`${key}: value not in options`);
|
|
992
|
+
markValidity(sel, 'not in options');
|
|
993
|
+
} else {
|
|
994
|
+
markValidity(sel, null);
|
|
995
|
+
}
|
|
996
|
+
return val === '' ? null : val;
|
|
997
|
+
}
|
|
998
|
+
case 'file': {
|
|
999
|
+
const hid = scopeRoot.querySelector(`input[type="hidden"][name$="${key}"]`);
|
|
1000
|
+
const rid = hid?.value ?? '';
|
|
1001
|
+
if (element.required && !rid) {
|
|
1002
|
+
errors.push(`${key}: required (file missing)`);
|
|
1003
|
+
const picker = hid?.previousElementSibling;
|
|
1004
|
+
if (picker) markValidity(picker, 'required');
|
|
1005
|
+
} else {
|
|
1006
|
+
if (hid?.previousElementSibling) markValidity(hid.previousElementSibling, null);
|
|
1007
|
+
}
|
|
1008
|
+
return rid || null;
|
|
1009
|
+
}
|
|
1010
|
+
case 'files': {
|
|
1011
|
+
const hid = scopeRoot.querySelector(`input[type="hidden"][name$="${key}"]`);
|
|
1012
|
+
const arr = parseJSONSafe(hid?.value ?? '[]', []);
|
|
1013
|
+
const count = Array.isArray(arr) ? arr.length : 0;
|
|
1014
|
+
if (!Array.isArray(arr)) errors.push(`${key}: internal value corrupted`);
|
|
1015
|
+
if (element.minCount != null && count < element.minCount) {
|
|
1016
|
+
errors.push(`${key}: < minCount=${element.minCount}`);
|
|
1017
|
+
}
|
|
1018
|
+
if (element.maxCount != null && count > element.maxCount) {
|
|
1019
|
+
errors.push(`${key}: > maxCount=${element.maxCount}`);
|
|
1020
|
+
}
|
|
1021
|
+
if (hid?.previousElementSibling) markValidity(hid.previousElementSibling, null);
|
|
1022
|
+
return Array.isArray(arr) ? arr : [];
|
|
1023
|
+
}
|
|
1024
|
+
case 'group': {
|
|
1025
|
+
const groupWrapper = scopeRoot.querySelector(`[data-group="${key}"]`);
|
|
1026
|
+
if (!groupWrapper) {
|
|
1027
|
+
errors.push(`${key}: internal group wrapper not found`);
|
|
1028
|
+
return element.repeat ? [] : {};
|
|
1029
|
+
}
|
|
1030
|
+
const itemsWrap = groupWrapper.querySelector(`[data-items-for="${key}"]`);
|
|
1031
|
+
if (!itemsWrap) {
|
|
1032
|
+
errors.push(`${key}: internal items container not found`);
|
|
1033
|
+
return element.repeat ? [] : {};
|
|
1034
|
+
}
|
|
1035
|
+
|
|
1036
|
+
if (element.repeat && isPlainObject(element.repeat)) {
|
|
1037
|
+
const items = itemsWrap.querySelectorAll(':scope > .groupItem');
|
|
1038
|
+
const out = [];
|
|
1039
|
+
const n = items.length;
|
|
1040
|
+
const min = element.repeat.min ?? 0;
|
|
1041
|
+
const max = element.repeat.max ?? Infinity;
|
|
1042
|
+
if (n < min) errors.push(`${key}: count < min=${min}`);
|
|
1043
|
+
if (n > max) errors.push(`${key}: count > max=${max}`);
|
|
1044
|
+
items.forEach(item => {
|
|
1045
|
+
const obj = {};
|
|
1046
|
+
element.elements.forEach(child => {
|
|
1047
|
+
obj[child.key] = collectElement(child, item);
|
|
1048
|
+
});
|
|
1049
|
+
out.push(obj);
|
|
1050
|
+
});
|
|
1051
|
+
return out;
|
|
1052
|
+
} else {
|
|
1053
|
+
const obj = {};
|
|
1054
|
+
element.elements.forEach(child => {
|
|
1055
|
+
obj[child.key] = collectElement(child, itemsWrap);
|
|
1056
|
+
});
|
|
1057
|
+
return obj;
|
|
1058
|
+
}
|
|
1059
|
+
}
|
|
1060
|
+
default:
|
|
1061
|
+
errors.push(`${key}: unsupported type ${element.type}`);
|
|
1062
|
+
return null;
|
|
1063
|
+
}
|
|
1064
|
+
}
|
|
1065
|
+
|
|
1066
|
+
const result = {};
|
|
1067
|
+
state.schema.elements.forEach(element => {
|
|
1068
|
+
result[element.key] = collectElement(element, form);
|
|
1069
|
+
});
|
|
1070
|
+
|
|
1071
|
+
return { result, errors };
|
|
1072
|
+
}
|
|
1073
|
+
|
|
1074
|
+
// URL parameter handling
|
|
1075
|
+
function loadSchemaFromURL() {
|
|
1076
|
+
const params = new URLSearchParams(window.location.search);
|
|
1077
|
+
const schemaParam = params.get('schema');
|
|
1078
|
+
|
|
1079
|
+
if (schemaParam) {
|
|
1080
|
+
el.urlInfo.style.display = 'block';
|
|
1081
|
+
try {
|
|
1082
|
+
const schemaJson = atob(schemaParam);
|
|
1083
|
+
const schema = JSON.parse(schemaJson);
|
|
1084
|
+
el.schemaInput.value = pretty(schema);
|
|
1085
|
+
const errors = validateSchema(schema);
|
|
1086
|
+
if (errors.length === 0) {
|
|
1087
|
+
renderForm(schema, {});
|
|
1088
|
+
} else {
|
|
1089
|
+
showError(el.schemaErrors, errors.join('\n'));
|
|
1090
|
+
}
|
|
1091
|
+
} catch (e) {
|
|
1092
|
+
showError(el.schemaErrors, 'Invalid schema in URL parameter: ' + e.message);
|
|
1093
|
+
}
|
|
1094
|
+
}
|
|
1095
|
+
}
|
|
1096
|
+
|
|
1097
|
+
// Event handlers
|
|
1098
|
+
el.applySchemaBtn.addEventListener('click', () => {
|
|
1099
|
+
clearError(el.schemaErrors);
|
|
1100
|
+
try {
|
|
1101
|
+
const parsed = JSON.parse(el.schemaInput.value);
|
|
1102
|
+
const errs = validateSchema(parsed);
|
|
1103
|
+
if (errs.length) {
|
|
1104
|
+
showError(el.schemaErrors, errs.join('\n'));
|
|
1105
|
+
return;
|
|
1106
|
+
}
|
|
1107
|
+
renderForm(parsed, {});
|
|
1108
|
+
} catch (e) {
|
|
1109
|
+
showError(el.schemaErrors, 'JSON parse error: ' + e.message);
|
|
1110
|
+
}
|
|
1111
|
+
});
|
|
1112
|
+
|
|
1113
|
+
el.resetSchemaBtn.addEventListener('click', () => {
|
|
1114
|
+
el.schemaInput.value = pretty(EXAMPLE_SCHEMA);
|
|
1115
|
+
clearError(el.schemaErrors);
|
|
1116
|
+
renderForm(EXAMPLE_SCHEMA, {});
|
|
1117
|
+
el.outputJson.value = '';
|
|
1118
|
+
el.prefillInput.value = '';
|
|
1119
|
+
clearError(el.prefillErrors);
|
|
1120
|
+
});
|
|
1121
|
+
|
|
1122
|
+
el.prettySchemaBtn.addEventListener('click', () => {
|
|
1123
|
+
try {
|
|
1124
|
+
const parsed = JSON.parse(el.schemaInput.value);
|
|
1125
|
+
el.schemaInput.value = pretty(parsed);
|
|
1126
|
+
} catch (e) {
|
|
1127
|
+
showError(el.schemaErrors, 'Prettify: JSON parse error: ' + e.message);
|
|
1128
|
+
}
|
|
1129
|
+
});
|
|
1130
|
+
|
|
1131
|
+
el.downloadSchemaBtn.addEventListener('click', () => {
|
|
1132
|
+
downloadFile('schema.json', el.schemaInput.value || pretty(EXAMPLE_SCHEMA));
|
|
1133
|
+
});
|
|
1134
|
+
|
|
1135
|
+
el.submitBtn.addEventListener('click', () => {
|
|
1136
|
+
clearError(el.formErrors);
|
|
1137
|
+
if (!state.schema) {
|
|
1138
|
+
showError(el.formErrors, 'Schema not applied');
|
|
1139
|
+
return;
|
|
1140
|
+
}
|
|
1141
|
+
const { result, errors } = collectAndValidate(state.schema);
|
|
1142
|
+
if (errors.length) {
|
|
1143
|
+
showError(el.formErrors, errors.join('\n'));
|
|
1144
|
+
return;
|
|
1145
|
+
}
|
|
1146
|
+
el.outputJson.value = pretty(result);
|
|
1147
|
+
});
|
|
1148
|
+
|
|
1149
|
+
el.clearFormBtn.addEventListener('click', () => {
|
|
1150
|
+
if (!state.schema) return;
|
|
1151
|
+
renderForm(state.schema, {});
|
|
1152
|
+
clearError(el.formErrors);
|
|
1153
|
+
});
|
|
1154
|
+
|
|
1155
|
+
el.copyOutputBtn.addEventListener('click', async () => {
|
|
1156
|
+
try {
|
|
1157
|
+
await navigator.clipboard.writeText(el.outputJson.value || '');
|
|
1158
|
+
el.copyOutputBtn.textContent = 'Copied!';
|
|
1159
|
+
setTimeout(() => {
|
|
1160
|
+
el.copyOutputBtn.textContent = 'Copy JSON';
|
|
1161
|
+
}, 1000);
|
|
1162
|
+
} catch (e) {
|
|
1163
|
+
console.warn('Copy failed:', e);
|
|
1164
|
+
}
|
|
1165
|
+
});
|
|
1166
|
+
|
|
1167
|
+
el.downloadOutputBtn.addEventListener('click', () => {
|
|
1168
|
+
downloadFile('form-data.json', el.outputJson.value || '{}');
|
|
1169
|
+
});
|
|
1170
|
+
|
|
1171
|
+
el.shareUrlBtn.addEventListener('click', () => {
|
|
1172
|
+
try {
|
|
1173
|
+
const schema = JSON.parse(el.schemaInput.value);
|
|
1174
|
+
const schemaBase64 = btoa(JSON.stringify(schema));
|
|
1175
|
+
const url = `${window.location.origin}${window.location.pathname}?schema=${schemaBase64}`;
|
|
1176
|
+
navigator.clipboard.writeText(url);
|
|
1177
|
+
el.shareUrlBtn.textContent = 'URL Copied!';
|
|
1178
|
+
setTimeout(() => {
|
|
1179
|
+
el.shareUrlBtn.textContent = 'Share URL';
|
|
1180
|
+
}, 2000);
|
|
1181
|
+
} catch (e) {
|
|
1182
|
+
alert('Please apply a valid schema first');
|
|
1183
|
+
}
|
|
1184
|
+
});
|
|
1185
|
+
|
|
1186
|
+
el.loadPrefillBtn.addEventListener('click', () => {
|
|
1187
|
+
clearError(el.prefillErrors);
|
|
1188
|
+
if (!state.schema) {
|
|
1189
|
+
showError(el.prefillErrors, 'Schema not applied');
|
|
1190
|
+
return;
|
|
1191
|
+
}
|
|
1192
|
+
try {
|
|
1193
|
+
const pre = JSON.parse(el.prefillInput.value || '{}');
|
|
1194
|
+
const allowed = new Set(state.schema.elements.map(e => e.key));
|
|
1195
|
+
const unknown = Object.keys(pre).filter(k => !allowed.has(k));
|
|
1196
|
+
if (unknown.length) {
|
|
1197
|
+
warn('prefill unknown keys: ' + unknown.join(', '));
|
|
1198
|
+
}
|
|
1199
|
+
renderForm(state.schema, pre);
|
|
1200
|
+
} catch (e) {
|
|
1201
|
+
showError(el.prefillErrors, 'Prefill parse error: ' + e.message);
|
|
1202
|
+
}
|
|
1203
|
+
});
|
|
1204
|
+
|
|
1205
|
+
el.copyTemplateBtn.addEventListener('click', () => {
|
|
1206
|
+
if (!state.schema) {
|
|
1207
|
+
showError(el.prefillErrors, 'Apply schema first');
|
|
1208
|
+
return;
|
|
1209
|
+
}
|
|
1210
|
+
const tpl = makePrefillTemplate(state.schema);
|
|
1211
|
+
el.prefillInput.value = pretty(tpl);
|
|
1212
|
+
});
|
|
1213
|
+
|
|
1214
|
+
function makePrefillTemplate(schema) {
|
|
1215
|
+
function walk(elements) {
|
|
1216
|
+
const obj = {};
|
|
1217
|
+
for (const el of elements) {
|
|
1218
|
+
switch (el.type) {
|
|
1219
|
+
case 'text':
|
|
1220
|
+
case 'textarea':
|
|
1221
|
+
case 'select':
|
|
1222
|
+
case 'number':
|
|
1223
|
+
obj[el.key] = el.default ?? null;
|
|
1224
|
+
break;
|
|
1225
|
+
case 'file':
|
|
1226
|
+
obj[el.key] = null;
|
|
1227
|
+
break;
|
|
1228
|
+
case 'files':
|
|
1229
|
+
obj[el.key] = [];
|
|
1230
|
+
break;
|
|
1231
|
+
case 'group':
|
|
1232
|
+
if (el.repeat && isPlainObject(el.repeat)) {
|
|
1233
|
+
const sample = walk(el.elements);
|
|
1234
|
+
const n = Math.max(el.repeat.min ?? 0, 1);
|
|
1235
|
+
obj[el.key] = Array.from({ length: n }, () => deepClone(sample));
|
|
1236
|
+
} else {
|
|
1237
|
+
obj[el.key] = walk(el.elements);
|
|
1238
|
+
}
|
|
1239
|
+
break;
|
|
1240
|
+
default:
|
|
1241
|
+
obj[el.key] = null;
|
|
1242
|
+
}
|
|
1243
|
+
}
|
|
1244
|
+
return obj;
|
|
1245
|
+
}
|
|
1246
|
+
return walk(schema.elements);
|
|
1247
|
+
}
|
|
1248
|
+
|
|
1249
|
+
// Configuration API
|
|
1250
|
+
window.FormBuilderConfig = {
|
|
1251
|
+
setUploadHandler: (uploadFn) => {
|
|
1252
|
+
state.config.uploadFile = uploadFn;
|
|
1253
|
+
},
|
|
1254
|
+
setDownloadHandler: (downloadFn) => {
|
|
1255
|
+
state.config.downloadFile = downloadFn;
|
|
1256
|
+
},
|
|
1257
|
+
setThumbnailHandler: (thumbnailFn) => {
|
|
1258
|
+
state.config.getThumbnail = thumbnailFn;
|
|
1259
|
+
},
|
|
1260
|
+
setConfig: (config) => {
|
|
1261
|
+
Object.assign(state.config, config);
|
|
1262
|
+
}
|
|
1263
|
+
};
|
|
1264
|
+
|
|
1265
|
+
// Initialize
|
|
1266
|
+
function init() {
|
|
1267
|
+
el.schemaInput.value = pretty(EXAMPLE_SCHEMA);
|
|
1268
|
+
renderForm(EXAMPLE_SCHEMA, {});
|
|
1269
|
+
loadSchemaFromURL();
|
|
1270
|
+
|
|
1271
|
+
// Expose configuration API
|
|
1272
|
+
window.dispatchEvent(new CustomEvent('formBuilderReady', { detail: window.FormBuilderConfig }));
|
|
1273
|
+
}
|
|
1274
|
+
|
|
1275
|
+
// Start the application
|
|
1276
|
+
init();
|
|
1277
|
+
</script>
|
|
1278
|
+
</body>
|
|
1279
|
+
</html>
|