@dmitryvim/form-builder 0.1.35 → 0.1.38
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/README.md +10 -5
- package/dist/demo.js +182 -54
- package/dist/elements.html +588 -160
- package/dist/elements.js +270 -232
- package/dist/form-builder.js +426 -134
- package/dist/index.html +70 -20
- package/package.json +1 -1
- package/docs/13_form_builder.html +0 -1337
- package/docs/REQUIREMENTS.md +0 -313
- package/docs/integration.md +0 -480
- package/docs/schema.md +0 -433
|
@@ -1,1337 +0,0 @@
|
|
|
1
|
-
<!doctype html>
|
|
2
|
-
<html lang="ru">
|
|
3
|
-
<head>
|
|
4
|
-
<meta charset="utf-8" />
|
|
5
|
-
<title>Form DSL v0.3.1 — Генератор форм (патч group.repeat)</title>
|
|
6
|
-
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
|
7
|
-
<style>
|
|
8
|
-
:root {
|
|
9
|
-
--bg: #0f1115;
|
|
10
|
-
--fg: #e7e7e7;
|
|
11
|
-
--muted: #9aa0a6;
|
|
12
|
-
--accent: #7dd3fc;
|
|
13
|
-
--bad: #ff6b6b;
|
|
14
|
-
--good: #34d399;
|
|
15
|
-
--card: #151922;
|
|
16
|
-
--border: #2a2f3a;
|
|
17
|
-
--mono:
|
|
18
|
-
ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas,
|
|
19
|
-
"Liberation Mono", "Courier New", monospace;
|
|
20
|
-
--sans:
|
|
21
|
-
Inter, system-ui, -apple-system, Segoe UI, Roboto, Helvetica, Arial,
|
|
22
|
-
"Apple Color Emoji", "Segoe UI Emoji";
|
|
23
|
-
}
|
|
24
|
-
* {
|
|
25
|
-
box-sizing: border-box;
|
|
26
|
-
}
|
|
27
|
-
html,
|
|
28
|
-
body {
|
|
29
|
-
height: 100%;
|
|
30
|
-
background: var(--bg);
|
|
31
|
-
color: var(--fg);
|
|
32
|
-
margin: 0;
|
|
33
|
-
font-family: var(--sans);
|
|
34
|
-
}
|
|
35
|
-
h1 {
|
|
36
|
-
margin: 16px;
|
|
37
|
-
font-size: 18px;
|
|
38
|
-
font-weight: 600;
|
|
39
|
-
color: var(--fg);
|
|
40
|
-
}
|
|
41
|
-
.grid {
|
|
42
|
-
display: grid;
|
|
43
|
-
grid-template-columns: 1fr 1fr;
|
|
44
|
-
grid-auto-rows: minmax(300px, auto);
|
|
45
|
-
gap: 12px;
|
|
46
|
-
padding: 12px;
|
|
47
|
-
}
|
|
48
|
-
@media (min-width: 1400px) {
|
|
49
|
-
.grid {
|
|
50
|
-
grid-template-columns: 1fr 1fr 1fr 1fr;
|
|
51
|
-
}
|
|
52
|
-
}
|
|
53
|
-
@media (max-width: 900px) {
|
|
54
|
-
.grid {
|
|
55
|
-
grid-template-columns: 1fr;
|
|
56
|
-
}
|
|
57
|
-
}
|
|
58
|
-
.card {
|
|
59
|
-
background: var(--card);
|
|
60
|
-
border: 1px solid var(--border);
|
|
61
|
-
border-radius: 12px;
|
|
62
|
-
padding: 12px;
|
|
63
|
-
display: flex;
|
|
64
|
-
flex-direction: column;
|
|
65
|
-
min-height: 320px;
|
|
66
|
-
}
|
|
67
|
-
.card h2 {
|
|
68
|
-
margin: 0 0 8px 0;
|
|
69
|
-
font-size: 15px;
|
|
70
|
-
color: var(--fg);
|
|
71
|
-
}
|
|
72
|
-
.toolbar {
|
|
73
|
-
display: flex;
|
|
74
|
-
flex-wrap: wrap;
|
|
75
|
-
gap: 8px;
|
|
76
|
-
margin-bottom: 8px;
|
|
77
|
-
}
|
|
78
|
-
.btn {
|
|
79
|
-
background: #1e2633;
|
|
80
|
-
color: var(--fg);
|
|
81
|
-
border: 1px solid var(--border);
|
|
82
|
-
padding: 6px 10px;
|
|
83
|
-
border-radius: 8px;
|
|
84
|
-
cursor: pointer;
|
|
85
|
-
font-size: 13px;
|
|
86
|
-
}
|
|
87
|
-
.btn:hover {
|
|
88
|
-
border-color: var(--accent);
|
|
89
|
-
}
|
|
90
|
-
.btn.good {
|
|
91
|
-
background: #15342a;
|
|
92
|
-
border-color: #1f6f52;
|
|
93
|
-
}
|
|
94
|
-
.btn.bad {
|
|
95
|
-
background: #3b1e20;
|
|
96
|
-
border-color: #6f1f1f;
|
|
97
|
-
}
|
|
98
|
-
.btn.ghost {
|
|
99
|
-
background: transparent;
|
|
100
|
-
}
|
|
101
|
-
.textarea,
|
|
102
|
-
.json,
|
|
103
|
-
input[type="text"],
|
|
104
|
-
textarea,
|
|
105
|
-
select,
|
|
106
|
-
input[type="number"] {
|
|
107
|
-
width: 100%;
|
|
108
|
-
border: 1px solid var(--border);
|
|
109
|
-
background: #0e131b;
|
|
110
|
-
color: var(--fg);
|
|
111
|
-
border-radius: 8px;
|
|
112
|
-
padding: 10px;
|
|
113
|
-
font-family: var(--mono);
|
|
114
|
-
font-size: 12px;
|
|
115
|
-
}
|
|
116
|
-
.json,
|
|
117
|
-
.textarea {
|
|
118
|
-
flex: 1;
|
|
119
|
-
resize: vertical;
|
|
120
|
-
min-height: 220px;
|
|
121
|
-
}
|
|
122
|
-
.hint {
|
|
123
|
-
color: var(--muted);
|
|
124
|
-
font-size: 12px;
|
|
125
|
-
margin: 6px 0 0;
|
|
126
|
-
}
|
|
127
|
-
.errors {
|
|
128
|
-
color: var(--bad);
|
|
129
|
-
font-size: 12px;
|
|
130
|
-
white-space: pre-wrap;
|
|
131
|
-
margin-top: 6px;
|
|
132
|
-
}
|
|
133
|
-
.row {
|
|
134
|
-
display: grid;
|
|
135
|
-
grid-template-columns: 1fr 1fr;
|
|
136
|
-
gap: 8px;
|
|
137
|
-
}
|
|
138
|
-
.field {
|
|
139
|
-
margin-bottom: 10px;
|
|
140
|
-
}
|
|
141
|
-
.label {
|
|
142
|
-
font-size: 13px;
|
|
143
|
-
margin-bottom: 6px;
|
|
144
|
-
color: var(--fg);
|
|
145
|
-
display: flex;
|
|
146
|
-
align-items: center;
|
|
147
|
-
gap: 8px;
|
|
148
|
-
}
|
|
149
|
-
.label .req {
|
|
150
|
-
color: var(--bad);
|
|
151
|
-
}
|
|
152
|
-
.invalid {
|
|
153
|
-
border-color: var(--bad) !important;
|
|
154
|
-
}
|
|
155
|
-
.msg {
|
|
156
|
-
font-size: 12px;
|
|
157
|
-
color: var(--bad);
|
|
158
|
-
margin-top: 4px;
|
|
159
|
-
}
|
|
160
|
-
.ok {
|
|
161
|
-
color: var(--good);
|
|
162
|
-
}
|
|
163
|
-
.muted {
|
|
164
|
-
color: var(--muted);
|
|
165
|
-
}
|
|
166
|
-
.line {
|
|
167
|
-
height: 1px;
|
|
168
|
-
background: var(--border);
|
|
169
|
-
margin: 8px 0;
|
|
170
|
-
}
|
|
171
|
-
.pill {
|
|
172
|
-
display: inline-flex;
|
|
173
|
-
align-items: center;
|
|
174
|
-
gap: 6px;
|
|
175
|
-
background: #0e131b;
|
|
176
|
-
border: 1px solid var(--border);
|
|
177
|
-
border-radius: 999px;
|
|
178
|
-
padding: 2px 8px;
|
|
179
|
-
font-family: var(--mono);
|
|
180
|
-
font-size: 12px;
|
|
181
|
-
}
|
|
182
|
-
.list {
|
|
183
|
-
display: flex;
|
|
184
|
-
flex-wrap: wrap;
|
|
185
|
-
gap: 6px;
|
|
186
|
-
}
|
|
187
|
-
.small {
|
|
188
|
-
font-size: 12px;
|
|
189
|
-
}
|
|
190
|
-
.groupHeader {
|
|
191
|
-
display: flex;
|
|
192
|
-
align-items: center;
|
|
193
|
-
justify-content: space-between;
|
|
194
|
-
margin: 4px 0 10px;
|
|
195
|
-
}
|
|
196
|
-
.groupItem {
|
|
197
|
-
border: 1px dashed var(--border);
|
|
198
|
-
border-radius: 10px;
|
|
199
|
-
padding: 10px;
|
|
200
|
-
margin-bottom: 10px;
|
|
201
|
-
}
|
|
202
|
-
.kbd {
|
|
203
|
-
font-family: var(--mono);
|
|
204
|
-
background: #0e131b;
|
|
205
|
-
border: 1px solid var(--border);
|
|
206
|
-
padding: 2px 6px;
|
|
207
|
-
border-radius: 6px;
|
|
208
|
-
}
|
|
209
|
-
.footer {
|
|
210
|
-
margin-top: auto;
|
|
211
|
-
font-size: 11px;
|
|
212
|
-
color: var(--muted);
|
|
213
|
-
}
|
|
214
|
-
.sr {
|
|
215
|
-
position: absolute;
|
|
216
|
-
left: -10000px;
|
|
217
|
-
width: 1px;
|
|
218
|
-
height: 1px;
|
|
219
|
-
overflow: hidden;
|
|
220
|
-
}
|
|
221
|
-
</style>
|
|
222
|
-
</head>
|
|
223
|
-
<body>
|
|
224
|
-
<h1>Form DSL v0.3.1 — Генератор форм (Schema → Form → Output → Prefill)</h1>
|
|
225
|
-
<div class="grid">
|
|
226
|
-
<section class="card" id="schemaCard">
|
|
227
|
-
<h2>1) Schema (v0.3)</h2>
|
|
228
|
-
<div class="toolbar">
|
|
229
|
-
<button class="btn" id="applySchemaBtn">Apply schema</button>
|
|
230
|
-
<button class="btn ghost" id="resetSchemaBtn">
|
|
231
|
-
Reset to example
|
|
232
|
-
</button>
|
|
233
|
-
<button class="btn ghost" id="prettySchemaBtn">Prettify</button>
|
|
234
|
-
<button class="btn ghost" id="downloadSchemaBtn">
|
|
235
|
-
Download schema.json
|
|
236
|
-
</button>
|
|
237
|
-
</div>
|
|
238
|
-
<textarea id="schemaInput" class="json" spellcheck="false"></textarea>
|
|
239
|
-
<div id="schemaErrors" class="errors"></div>
|
|
240
|
-
<div class="footer">
|
|
241
|
-
Контракт: <span class="kbd">file → resourceId</span>,
|
|
242
|
-
<span class="kbd">files → resourceId[]</span>,
|
|
243
|
-
<span class="kbd">group.repeat → массив объектов</span>.
|
|
244
|
-
</div>
|
|
245
|
-
</section>
|
|
246
|
-
|
|
247
|
-
<section class="card" id="formCard">
|
|
248
|
-
<h2>2) Form (rendered from schema)</h2>
|
|
249
|
-
<div id="formContainer"></div>
|
|
250
|
-
<div class="toolbar">
|
|
251
|
-
<button class="btn good" id="submitBtn">Submit → Output JSON</button>
|
|
252
|
-
<button class="btn ghost" id="clearFormBtn">Clear values</button>
|
|
253
|
-
</div>
|
|
254
|
-
<div id="formErrors" class="errors"></div>
|
|
255
|
-
<div class="footer">
|
|
256
|
-
Файлы загружаются «раньше сабмита» — создаём
|
|
257
|
-
<span class="kbd">resourceId</span> локально (эмуляция).
|
|
258
|
-
</div>
|
|
259
|
-
</section>
|
|
260
|
-
|
|
261
|
-
<section class="card" id="outputCard">
|
|
262
|
-
<h2>3) Output JSON (submit result)</h2>
|
|
263
|
-
<div class="toolbar">
|
|
264
|
-
<button class="btn" id="copyOutputBtn">Copy</button>
|
|
265
|
-
<button class="btn ghost" id="downloadOutputBtn">
|
|
266
|
-
Download data.json
|
|
267
|
-
</button>
|
|
268
|
-
</div>
|
|
269
|
-
<textarea id="outputJson" class="json" readonly></textarea>
|
|
270
|
-
<div class="footer">
|
|
271
|
-
Выходит строго по ключам <span class="kbd">key</span>. Порядок
|
|
272
|
-
соответствует схеме.
|
|
273
|
-
</div>
|
|
274
|
-
</section>
|
|
275
|
-
|
|
276
|
-
<section class="card" id="prefillCard">
|
|
277
|
-
<h2>4) Prefill JSON → заполнить форму</h2>
|
|
278
|
-
<div class="toolbar">
|
|
279
|
-
<button class="btn" id="loadPrefillBtn">Load Prefill</button>
|
|
280
|
-
<button class="btn ghost" id="copyTemplateBtn">
|
|
281
|
-
Insert current template
|
|
282
|
-
</button>
|
|
283
|
-
</div>
|
|
284
|
-
<textarea
|
|
285
|
-
id="prefillInput"
|
|
286
|
-
class="json"
|
|
287
|
-
spellcheck="false"
|
|
288
|
-
placeholder='{"title":"...", "cover":"res_...", ...}'
|
|
289
|
-
></textarea>
|
|
290
|
-
<div id="prefillErrors" class="errors"></div>
|
|
291
|
-
<div class="footer">
|
|
292
|
-
Префилл = тот же контракт, что и сабмит; лишние ключи игнорируются с
|
|
293
|
-
предупреждением.
|
|
294
|
-
</div>
|
|
295
|
-
</section>
|
|
296
|
-
</div>
|
|
297
|
-
|
|
298
|
-
<script>
|
|
299
|
-
const state = { schema: null, formRoot: null, resourceIndex: new Map() };
|
|
300
|
-
const el = {
|
|
301
|
-
schemaInput: document.getElementById("schemaInput"),
|
|
302
|
-
schemaErrors: document.getElementById("schemaErrors"),
|
|
303
|
-
applySchemaBtn: document.getElementById("applySchemaBtn"),
|
|
304
|
-
resetSchemaBtn: document.getElementById("resetSchemaBtn"),
|
|
305
|
-
prettySchemaBtn: document.getElementById("prettySchemaBtn"),
|
|
306
|
-
downloadSchemaBtn: document.getElementById("downloadSchemaBtn"),
|
|
307
|
-
formContainer: document.getElementById("formContainer"),
|
|
308
|
-
formErrors: document.getElementById("formErrors"),
|
|
309
|
-
submitBtn: document.getElementById("submitBtn"),
|
|
310
|
-
clearFormBtn: document.getElementById("clearFormBtn"),
|
|
311
|
-
outputJson: document.getElementById("outputJson"),
|
|
312
|
-
copyOutputBtn: document.getElementById("copyOutputBtn"),
|
|
313
|
-
downloadOutputBtn: document.getElementById("downloadOutputBtn"),
|
|
314
|
-
prefillInput: document.getElementById("prefillInput"),
|
|
315
|
-
loadPrefillBtn: document.getElementById("loadPrefillBtn"),
|
|
316
|
-
copyTemplateBtn: document.getElementById("copyTemplateBtn"),
|
|
317
|
-
prefillErrors: document.getElementById("prefillErrors"),
|
|
318
|
-
};
|
|
319
|
-
|
|
320
|
-
const EXAMPLE_SCHEMA = {
|
|
321
|
-
version: "0.3",
|
|
322
|
-
title: "Asset Uploader with Slides",
|
|
323
|
-
elements: [
|
|
324
|
-
{
|
|
325
|
-
type: "file",
|
|
326
|
-
key: "cover",
|
|
327
|
-
label: "Cover image",
|
|
328
|
-
required: true,
|
|
329
|
-
accept: {
|
|
330
|
-
extensions: ["png", "jpg", "jpeg"],
|
|
331
|
-
mime: ["image/png", "image/jpeg"],
|
|
332
|
-
},
|
|
333
|
-
maxSizeMB: 25,
|
|
334
|
-
},
|
|
335
|
-
{
|
|
336
|
-
type: "files",
|
|
337
|
-
key: "assets",
|
|
338
|
-
label: "Additional images",
|
|
339
|
-
required: false,
|
|
340
|
-
accept: {
|
|
341
|
-
extensions: ["png", "jpg"],
|
|
342
|
-
mime: ["image/png", "image/jpeg"],
|
|
343
|
-
},
|
|
344
|
-
minCount: 0,
|
|
345
|
-
maxCount: 10,
|
|
346
|
-
maxSizeMB: 25,
|
|
347
|
-
},
|
|
348
|
-
{
|
|
349
|
-
type: "text",
|
|
350
|
-
key: "title",
|
|
351
|
-
label: "Project title",
|
|
352
|
-
required: true,
|
|
353
|
-
minLength: 1,
|
|
354
|
-
maxLength: 120,
|
|
355
|
-
pattern: "^[A-Za-z0-9 _-]+$",
|
|
356
|
-
default: "My Project",
|
|
357
|
-
},
|
|
358
|
-
{
|
|
359
|
-
type: "textarea",
|
|
360
|
-
key: "description",
|
|
361
|
-
label: "Description",
|
|
362
|
-
required: false,
|
|
363
|
-
minLength: 0,
|
|
364
|
-
maxLength: 2000,
|
|
365
|
-
pattern: null,
|
|
366
|
-
default: "",
|
|
367
|
-
},
|
|
368
|
-
{
|
|
369
|
-
type: "select",
|
|
370
|
-
key: "theme",
|
|
371
|
-
label: "Theme",
|
|
372
|
-
required: true,
|
|
373
|
-
options: [
|
|
374
|
-
{ value: "light", label: "Light" },
|
|
375
|
-
{ value: "dark", label: "Dark" },
|
|
376
|
-
],
|
|
377
|
-
default: "dark",
|
|
378
|
-
},
|
|
379
|
-
{
|
|
380
|
-
type: "number",
|
|
381
|
-
key: "opacity",
|
|
382
|
-
label: "Opacity",
|
|
383
|
-
required: true,
|
|
384
|
-
min: 0,
|
|
385
|
-
max: 1,
|
|
386
|
-
decimals: 2,
|
|
387
|
-
step: 0.01,
|
|
388
|
-
default: 0.85,
|
|
389
|
-
},
|
|
390
|
-
{
|
|
391
|
-
type: "group",
|
|
392
|
-
key: "slides",
|
|
393
|
-
label: "Slides",
|
|
394
|
-
repeat: { min: 1, max: 5 },
|
|
395
|
-
elements: [
|
|
396
|
-
{
|
|
397
|
-
type: "text",
|
|
398
|
-
key: "title",
|
|
399
|
-
label: "Slide title",
|
|
400
|
-
required: true,
|
|
401
|
-
minLength: 1,
|
|
402
|
-
maxLength: 80,
|
|
403
|
-
default: "",
|
|
404
|
-
},
|
|
405
|
-
{
|
|
406
|
-
type: "textarea",
|
|
407
|
-
key: "body",
|
|
408
|
-
label: "Slide text",
|
|
409
|
-
required: true,
|
|
410
|
-
minLength: 1,
|
|
411
|
-
maxLength: 1000,
|
|
412
|
-
default: "",
|
|
413
|
-
},
|
|
414
|
-
],
|
|
415
|
-
},
|
|
416
|
-
],
|
|
417
|
-
};
|
|
418
|
-
|
|
419
|
-
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
|
|
420
|
-
function pretty(obj) {
|
|
421
|
-
return JSON.stringify(obj, null, 2);
|
|
422
|
-
}
|
|
423
|
-
function deepClone(obj) {
|
|
424
|
-
return structuredClone
|
|
425
|
-
? structuredClone(obj)
|
|
426
|
-
: JSON.parse(JSON.stringify(obj));
|
|
427
|
-
}
|
|
428
|
-
function isPlainObject(v) {
|
|
429
|
-
return Object.prototype.toString.call(v) === "[object Object]";
|
|
430
|
-
}
|
|
431
|
-
function setText(node, text) {
|
|
432
|
-
node.textContent = text || "";
|
|
433
|
-
}
|
|
434
|
-
function downloadFile(filename, text) {
|
|
435
|
-
const blob = new Blob([text], { type: "application/json" });
|
|
436
|
-
const url = URL.createObjectURL(blob);
|
|
437
|
-
const a = document.createElement("a");
|
|
438
|
-
a.href = url;
|
|
439
|
-
a.download = filename;
|
|
440
|
-
a.click();
|
|
441
|
-
URL.revokeObjectURL(url);
|
|
442
|
-
}
|
|
443
|
-
function pathJoin(base, key) {
|
|
444
|
-
return base ? `${base}.${key}` : key;
|
|
445
|
-
}
|
|
446
|
-
function assert(c, m) {
|
|
447
|
-
if (!c) throw new Error(m);
|
|
448
|
-
}
|
|
449
|
-
function warn(m) {
|
|
450
|
-
console.warn("[WARN]", m);
|
|
451
|
-
}
|
|
452
|
-
async function makeResourceIdFromFile(file) {
|
|
453
|
-
try {
|
|
454
|
-
const buf = await file.arrayBuffer();
|
|
455
|
-
if (crypto?.subtle?.digest) {
|
|
456
|
-
const hash = await crypto.subtle.digest("SHA-256", buf);
|
|
457
|
-
const hex = [...new Uint8Array(hash)]
|
|
458
|
-
.map((b) => b.toString(16).padStart(2, "0"))
|
|
459
|
-
.join("");
|
|
460
|
-
return `res_${hex.slice(0, 24)}`;
|
|
461
|
-
}
|
|
462
|
-
} catch (_) {}
|
|
463
|
-
const rnd =
|
|
464
|
-
Math.random().toString(36).slice(2) +
|
|
465
|
-
Math.random().toString(36).slice(2);
|
|
466
|
-
return `res_${rnd.slice(0, 24)}`;
|
|
467
|
-
}
|
|
468
|
-
|
|
469
|
-
function validateSchema(schema) {
|
|
470
|
-
const errors = [];
|
|
471
|
-
try {
|
|
472
|
-
assert(
|
|
473
|
-
schema && schema.version === "0.3",
|
|
474
|
-
'schema.version must be "0.3"',
|
|
475
|
-
);
|
|
476
|
-
} catch (e) {
|
|
477
|
-
errors.push(e.message);
|
|
478
|
-
}
|
|
479
|
-
try {
|
|
480
|
-
assert(
|
|
481
|
-
Array.isArray(schema.elements),
|
|
482
|
-
"schema.elements must be an array",
|
|
483
|
-
);
|
|
484
|
-
} catch (e) {
|
|
485
|
-
errors.push(e.message);
|
|
486
|
-
}
|
|
487
|
-
function validateElements(elements, path) {
|
|
488
|
-
const seen = new Set();
|
|
489
|
-
elements.forEach((el, idx) => {
|
|
490
|
-
const here = `${path}[${idx}]`;
|
|
491
|
-
if (!el || typeof el !== "object") {
|
|
492
|
-
errors.push(`${here}: element must be object`);
|
|
493
|
-
return;
|
|
494
|
-
}
|
|
495
|
-
if (!el.type) errors.push(`${here}: missing "type"`);
|
|
496
|
-
if (!el.key) errors.push(`${here}: missing "key"`);
|
|
497
|
-
if (el.key) {
|
|
498
|
-
if (seen.has(el.key))
|
|
499
|
-
errors.push(`${path}: duplicate key "${el.key}"`);
|
|
500
|
-
seen.add(el.key);
|
|
501
|
-
}
|
|
502
|
-
if (
|
|
503
|
-
el.default !== undefined &&
|
|
504
|
-
(el.type === "file" || el.type === "files")
|
|
505
|
-
)
|
|
506
|
-
errors.push(`${here}: default forbidden for "${el.type}"`);
|
|
507
|
-
if (el.type === "text" || el.type === "textarea") {
|
|
508
|
-
if (
|
|
509
|
-
el.minLength != null &&
|
|
510
|
-
el.maxLength != null &&
|
|
511
|
-
el.minLength > el.maxLength
|
|
512
|
-
)
|
|
513
|
-
errors.push(`${here}: minLength > maxLength`);
|
|
514
|
-
if (el.pattern != null) {
|
|
515
|
-
try {
|
|
516
|
-
new RegExp(el.pattern);
|
|
517
|
-
} catch {
|
|
518
|
-
errors.push(`${here}: invalid pattern regex`);
|
|
519
|
-
}
|
|
520
|
-
}
|
|
521
|
-
}
|
|
522
|
-
if (el.type === "number") {
|
|
523
|
-
if (
|
|
524
|
-
typeof el.min === "number" &&
|
|
525
|
-
typeof el.max === "number" &&
|
|
526
|
-
el.min > el.max
|
|
527
|
-
)
|
|
528
|
-
errors.push(`${here}: min > max`);
|
|
529
|
-
if (
|
|
530
|
-
el.decimals != null &&
|
|
531
|
-
(!Number.isInteger(el.decimals) ||
|
|
532
|
-
el.decimals < 0 ||
|
|
533
|
-
el.decimals > 8)
|
|
534
|
-
)
|
|
535
|
-
errors.push(`${here}: decimals must be 0..8`);
|
|
536
|
-
}
|
|
537
|
-
if (el.type === "select") {
|
|
538
|
-
if (!Array.isArray(el.options) || el.options.length === 0)
|
|
539
|
-
errors.push(`${here}: select.options must be non-empty array`);
|
|
540
|
-
else {
|
|
541
|
-
const values = new Set(el.options.map((o) => o.value));
|
|
542
|
-
if (el.default != null && !values.has(el.default))
|
|
543
|
-
errors.push(
|
|
544
|
-
`${here}: default "${el.default}" not in options`,
|
|
545
|
-
);
|
|
546
|
-
}
|
|
547
|
-
}
|
|
548
|
-
if (el.type === "file") {
|
|
549
|
-
if (el.maxSizeMB != null && el.maxSizeMB <= 0)
|
|
550
|
-
errors.push(`${here}: maxSizeMB must be > 0`);
|
|
551
|
-
}
|
|
552
|
-
if (el.type === "files") {
|
|
553
|
-
if (
|
|
554
|
-
el.minCount != null &&
|
|
555
|
-
el.maxCount != null &&
|
|
556
|
-
el.minCount > el.maxCount
|
|
557
|
-
)
|
|
558
|
-
errors.push(`${here}: minCount > maxCount`);
|
|
559
|
-
}
|
|
560
|
-
if (el.type === "group") {
|
|
561
|
-
if (!Array.isArray(el.elements))
|
|
562
|
-
errors.push(`${here}: group.elements must be array`);
|
|
563
|
-
if (el.repeat) {
|
|
564
|
-
if (
|
|
565
|
-
el.repeat.min != null &&
|
|
566
|
-
el.repeat.max != null &&
|
|
567
|
-
el.repeat.min > el.repeat.max
|
|
568
|
-
)
|
|
569
|
-
errors.push(`${here}: repeat.min > repeat.max`);
|
|
570
|
-
}
|
|
571
|
-
if (Array.isArray(el.elements))
|
|
572
|
-
validateElements(el.elements, pathJoin(path, el.key));
|
|
573
|
-
}
|
|
574
|
-
});
|
|
575
|
-
}
|
|
576
|
-
if (Array.isArray(schema.elements))
|
|
577
|
-
validateElements(schema.elements, "elements");
|
|
578
|
-
return errors;
|
|
579
|
-
}
|
|
580
|
-
|
|
581
|
-
function clear(node) {
|
|
582
|
-
while (node.firstChild) node.removeChild(node.firstChild);
|
|
583
|
-
}
|
|
584
|
-
|
|
585
|
-
function renderForm(schema, prefill) {
|
|
586
|
-
state.schema = deepClone(schema);
|
|
587
|
-
state.formRoot = el.formContainer;
|
|
588
|
-
clear(state.formRoot);
|
|
589
|
-
el.formErrors.textContent = "";
|
|
590
|
-
const formEl = document.createElement("form");
|
|
591
|
-
formEl.id = "dynamicForm";
|
|
592
|
-
formEl.addEventListener("submit", (e) => e.preventDefault());
|
|
593
|
-
|
|
594
|
-
const ctx = { path: "", prefill: prefill || {} };
|
|
595
|
-
schema.elements.forEach((element) => {
|
|
596
|
-
const block = renderElement(element, ctx);
|
|
597
|
-
formEl.appendChild(block);
|
|
598
|
-
});
|
|
599
|
-
|
|
600
|
-
state.formRoot.appendChild(formEl);
|
|
601
|
-
}
|
|
602
|
-
|
|
603
|
-
function renderElement(element, ctx) {
|
|
604
|
-
const wrapper = document.createElement("div");
|
|
605
|
-
wrapper.className = "field";
|
|
606
|
-
const label = document.createElement("div");
|
|
607
|
-
label.className = "label";
|
|
608
|
-
const title = document.createElement("span");
|
|
609
|
-
title.textContent = element.label || element.key;
|
|
610
|
-
label.appendChild(title);
|
|
611
|
-
if (element.required) {
|
|
612
|
-
const req = document.createElement("span");
|
|
613
|
-
req.className = "req";
|
|
614
|
-
req.textContent = "*";
|
|
615
|
-
label.appendChild(req);
|
|
616
|
-
}
|
|
617
|
-
wrapper.appendChild(label);
|
|
618
|
-
|
|
619
|
-
const pathKey = pathJoin(ctx.path, element.key);
|
|
620
|
-
|
|
621
|
-
switch (element.type) {
|
|
622
|
-
case "text": {
|
|
623
|
-
const input = document.createElement("input");
|
|
624
|
-
input.type = "text";
|
|
625
|
-
input.name = pathKey;
|
|
626
|
-
input.dataset.type = "text";
|
|
627
|
-
setTextValueFromPrefill(input, element, ctx.prefill, element.key);
|
|
628
|
-
input.addEventListener("input", () => markValidity(input, null));
|
|
629
|
-
wrapper.appendChild(input);
|
|
630
|
-
wrapper.appendChild(makeFieldHint(element));
|
|
631
|
-
break;
|
|
632
|
-
}
|
|
633
|
-
case "textarea": {
|
|
634
|
-
const ta = document.createElement("textarea");
|
|
635
|
-
ta.name = pathKey;
|
|
636
|
-
ta.rows = 4;
|
|
637
|
-
ta.dataset.type = "textarea";
|
|
638
|
-
setTextValueFromPrefill(ta, element, ctx.prefill, element.key);
|
|
639
|
-
ta.addEventListener("input", () => markValidity(ta, null));
|
|
640
|
-
wrapper.appendChild(ta);
|
|
641
|
-
wrapper.appendChild(makeFieldHint(element));
|
|
642
|
-
break;
|
|
643
|
-
}
|
|
644
|
-
case "number": {
|
|
645
|
-
const input = document.createElement("input");
|
|
646
|
-
input.type = "number";
|
|
647
|
-
input.name = pathKey;
|
|
648
|
-
input.dataset.type = "number";
|
|
649
|
-
if (element.step != null) input.step = String(element.step);
|
|
650
|
-
setNumberFromPrefill(input, element, ctx.prefill, element.key);
|
|
651
|
-
input.addEventListener("blur", () => {
|
|
652
|
-
if (input.value === "") return;
|
|
653
|
-
const v = parseFloat(input.value);
|
|
654
|
-
if (Number.isFinite(v) && Number.isInteger(element.decimals ?? 0))
|
|
655
|
-
input.value = String(Number(v.toFixed(element.decimals)));
|
|
656
|
-
});
|
|
657
|
-
input.addEventListener("input", () => markValidity(input, null));
|
|
658
|
-
wrapper.appendChild(input);
|
|
659
|
-
wrapper.appendChild(
|
|
660
|
-
makeFieldHint(element, `decimals=${element.decimals ?? 0}`),
|
|
661
|
-
);
|
|
662
|
-
break;
|
|
663
|
-
}
|
|
664
|
-
case "select": {
|
|
665
|
-
const sel = document.createElement("select");
|
|
666
|
-
sel.name = pathKey;
|
|
667
|
-
sel.dataset.type = "select";
|
|
668
|
-
if (!element.required) {
|
|
669
|
-
const opt = document.createElement("option");
|
|
670
|
-
opt.value = "";
|
|
671
|
-
opt.textContent = "—";
|
|
672
|
-
sel.appendChild(opt);
|
|
673
|
-
}
|
|
674
|
-
element.options.forEach((o) => {
|
|
675
|
-
const opt = document.createElement("option");
|
|
676
|
-
opt.value = String(o.value);
|
|
677
|
-
opt.textContent = o.label ?? String(o.value);
|
|
678
|
-
sel.appendChild(opt);
|
|
679
|
-
});
|
|
680
|
-
setSelectFromPrefill(sel, element, ctx.prefill, element.key);
|
|
681
|
-
sel.addEventListener("input", () => markValidity(sel, null));
|
|
682
|
-
wrapper.appendChild(sel);
|
|
683
|
-
break;
|
|
684
|
-
}
|
|
685
|
-
case "file": {
|
|
686
|
-
const hid = document.createElement("input");
|
|
687
|
-
hid.type = "hidden";
|
|
688
|
-
hid.name = pathKey;
|
|
689
|
-
hid.dataset.type = "file";
|
|
690
|
-
const list = document.createElement("div");
|
|
691
|
-
list.className = "list";
|
|
692
|
-
const picker = document.createElement("input");
|
|
693
|
-
picker.type = "file";
|
|
694
|
-
picker.addEventListener("change", async () => {
|
|
695
|
-
if (picker.files && picker.files[0]) {
|
|
696
|
-
const file = picker.files[0];
|
|
697
|
-
const err = fileValidationError(element, file);
|
|
698
|
-
if (err) {
|
|
699
|
-
markValidity(picker, err);
|
|
700
|
-
return;
|
|
701
|
-
}
|
|
702
|
-
const rid = await makeResourceIdFromFile(file);
|
|
703
|
-
state.resourceIndex.set(rid, {
|
|
704
|
-
name: file.name,
|
|
705
|
-
type: file.type,
|
|
706
|
-
size: file.size,
|
|
707
|
-
});
|
|
708
|
-
hid.value = rid;
|
|
709
|
-
renderResourcePills(list, [rid]);
|
|
710
|
-
markValidity(picker, null);
|
|
711
|
-
}
|
|
712
|
-
});
|
|
713
|
-
const pv = ctx.prefill && ctx.prefill[element.key];
|
|
714
|
-
if (typeof pv === "string" && pv) {
|
|
715
|
-
hid.value = pv;
|
|
716
|
-
renderResourcePills(list, [pv]);
|
|
717
|
-
}
|
|
718
|
-
wrapper.appendChild(picker);
|
|
719
|
-
wrapper.appendChild(list);
|
|
720
|
-
wrapper.appendChild(hid);
|
|
721
|
-
wrapper.appendChild(makeFieldHint(element, "value = resourceId"));
|
|
722
|
-
break;
|
|
723
|
-
}
|
|
724
|
-
case "files": {
|
|
725
|
-
const hid = document.createElement("input");
|
|
726
|
-
hid.type = "hidden";
|
|
727
|
-
hid.name = pathKey;
|
|
728
|
-
hid.dataset.type = "files";
|
|
729
|
-
const list = document.createElement("div");
|
|
730
|
-
list.className = "list";
|
|
731
|
-
const picker = document.createElement("input");
|
|
732
|
-
picker.type = "file";
|
|
733
|
-
picker.multiple = true;
|
|
734
|
-
picker.addEventListener("change", async () => {
|
|
735
|
-
let arr = parseJSONSafe(hid.value, []);
|
|
736
|
-
if (!Array.isArray(arr)) arr = [];
|
|
737
|
-
if (picker.files && picker.files.length) {
|
|
738
|
-
for (const file of picker.files) {
|
|
739
|
-
const err = fileValidationError(element, file);
|
|
740
|
-
if (err) {
|
|
741
|
-
markValidity(picker, err);
|
|
742
|
-
return;
|
|
743
|
-
}
|
|
744
|
-
}
|
|
745
|
-
for (const file of picker.files) {
|
|
746
|
-
const rid = await makeResourceIdFromFile(file);
|
|
747
|
-
state.resourceIndex.set(rid, {
|
|
748
|
-
name: file.name,
|
|
749
|
-
type: file.type,
|
|
750
|
-
size: file.size,
|
|
751
|
-
});
|
|
752
|
-
arr.push(rid);
|
|
753
|
-
}
|
|
754
|
-
hid.value = JSON.stringify(arr);
|
|
755
|
-
renderResourcePills(list, arr, (ridToRemove) => {
|
|
756
|
-
const next = arr.filter((x) => x !== ridToRemove);
|
|
757
|
-
hid.value = JSON.stringify(next);
|
|
758
|
-
arr = next;
|
|
759
|
-
renderResourcePills(list, next, arguments.callee);
|
|
760
|
-
});
|
|
761
|
-
markValidity(picker, null);
|
|
762
|
-
}
|
|
763
|
-
});
|
|
764
|
-
const pv = ctx.prefill && ctx.prefill[element.key];
|
|
765
|
-
let initial = Array.isArray(pv) ? pv.filter(Boolean) : [];
|
|
766
|
-
if (initial.length) {
|
|
767
|
-
hid.value = JSON.stringify(initial);
|
|
768
|
-
renderResourcePills(list, initial, (ridToRemove) => {
|
|
769
|
-
const next = initial.filter((x) => x !== ridToRemove);
|
|
770
|
-
hid.value = JSON.stringify(next);
|
|
771
|
-
initial = next;
|
|
772
|
-
renderResourcePills(list, next, arguments.callee);
|
|
773
|
-
});
|
|
774
|
-
}
|
|
775
|
-
wrapper.appendChild(picker);
|
|
776
|
-
wrapper.appendChild(list);
|
|
777
|
-
wrapper.appendChild(hid);
|
|
778
|
-
wrapper.appendChild(makeFieldHint(element, "value = resourceId[]"));
|
|
779
|
-
break;
|
|
780
|
-
}
|
|
781
|
-
case "group": {
|
|
782
|
-
// МАРКЕРЫ: фикс для корректного поиска контейнера при submit
|
|
783
|
-
wrapper.dataset.group = element.key; // где группа
|
|
784
|
-
wrapper.dataset.groupPath = pathKey;
|
|
785
|
-
const groupWrap = document.createElement("div");
|
|
786
|
-
const header = document.createElement("div");
|
|
787
|
-
header.className = "groupHeader";
|
|
788
|
-
const left = document.createElement("div");
|
|
789
|
-
left.innerHTML = `<span>${element.label || element.key}</span>`;
|
|
790
|
-
header.appendChild(left);
|
|
791
|
-
const right = document.createElement("div");
|
|
792
|
-
groupWrap.appendChild(header);
|
|
793
|
-
const itemsWrap = document.createElement("div");
|
|
794
|
-
itemsWrap.dataset.itemsFor = element.key; // КОНТЕЙНЕР ЭЛЕМЕНТОВ ГРУППЫ (повторяемых или одиночных)
|
|
795
|
-
|
|
796
|
-
if (element.repeat && isPlainObject(element.repeat)) {
|
|
797
|
-
const min = element.repeat.min ?? 0;
|
|
798
|
-
const max = element.repeat.max ?? Infinity;
|
|
799
|
-
const pre = Array.isArray(ctx.prefill?.[element.key])
|
|
800
|
-
? ctx.prefill[element.key]
|
|
801
|
-
: null;
|
|
802
|
-
const addBtn = document.createElement("button");
|
|
803
|
-
addBtn.type = "button";
|
|
804
|
-
addBtn.className = "btn";
|
|
805
|
-
addBtn.textContent = "Add";
|
|
806
|
-
right.appendChild(addBtn);
|
|
807
|
-
header.appendChild(right);
|
|
808
|
-
|
|
809
|
-
const countItems = () =>
|
|
810
|
-
itemsWrap.querySelectorAll(":scope > .groupItem").length;
|
|
811
|
-
const refreshControls = () => {
|
|
812
|
-
const n = countItems();
|
|
813
|
-
addBtn.disabled = n >= max;
|
|
814
|
-
left.innerHTML = `<span>${element.label || element.key}</span> <span class="muted small">[${n} / ${max === Infinity ? "∞" : max}, min=${min}]</span>`;
|
|
815
|
-
};
|
|
816
|
-
|
|
817
|
-
const addItem = (prefillObj) => {
|
|
818
|
-
const item = document.createElement("div");
|
|
819
|
-
item.className = "groupItem";
|
|
820
|
-
const subCtx = {
|
|
821
|
-
path: pathJoin(ctx.path, element.key + `[${countItems()}]`),
|
|
822
|
-
prefill: prefillObj || {},
|
|
823
|
-
};
|
|
824
|
-
element.elements.forEach((child) =>
|
|
825
|
-
item.appendChild(renderElement(child, subCtx)),
|
|
826
|
-
);
|
|
827
|
-
const rem = document.createElement("button");
|
|
828
|
-
rem.type = "button";
|
|
829
|
-
rem.className = "btn bad small";
|
|
830
|
-
rem.textContent = "Remove";
|
|
831
|
-
rem.addEventListener("click", () => {
|
|
832
|
-
if (countItems() <= (element.repeat.min ?? 0)) return;
|
|
833
|
-
itemsWrap.removeChild(item);
|
|
834
|
-
refreshControls();
|
|
835
|
-
});
|
|
836
|
-
item.appendChild(rem);
|
|
837
|
-
itemsWrap.appendChild(item);
|
|
838
|
-
refreshControls();
|
|
839
|
-
};
|
|
840
|
-
|
|
841
|
-
groupWrap.appendChild(itemsWrap);
|
|
842
|
-
if (pre && pre.length) {
|
|
843
|
-
const n = Math.min(max, Math.max(min, pre.length));
|
|
844
|
-
for (let i = 0; i < n; i++) addItem(pre[i]);
|
|
845
|
-
} else {
|
|
846
|
-
const n = Math.max(min, 0);
|
|
847
|
-
for (let i = 0; i < n; i++) addItem(null);
|
|
848
|
-
}
|
|
849
|
-
addBtn.addEventListener("click", () => addItem(null));
|
|
850
|
-
} else {
|
|
851
|
-
// SINGLE OBJECT GROUP
|
|
852
|
-
const subCtx = {
|
|
853
|
-
path: pathJoin(ctx.path, element.key),
|
|
854
|
-
prefill: ctx.prefill?.[element.key] || {},
|
|
855
|
-
};
|
|
856
|
-
element.elements.forEach((child) =>
|
|
857
|
-
itemsWrap.appendChild(renderElement(child, subCtx)),
|
|
858
|
-
);
|
|
859
|
-
groupWrap.appendChild(itemsWrap);
|
|
860
|
-
}
|
|
861
|
-
|
|
862
|
-
wrapper.innerHTML = "";
|
|
863
|
-
wrapper.appendChild(groupWrap);
|
|
864
|
-
break;
|
|
865
|
-
}
|
|
866
|
-
default:
|
|
867
|
-
wrapper.appendChild(
|
|
868
|
-
document.createTextNode(`Unsupported type: ${element.type}`),
|
|
869
|
-
);
|
|
870
|
-
}
|
|
871
|
-
return wrapper;
|
|
872
|
-
}
|
|
873
|
-
|
|
874
|
-
function makeFieldHint(element, extra = "") {
|
|
875
|
-
const hint = document.createElement("div");
|
|
876
|
-
hint.className = "hint";
|
|
877
|
-
const bits = [];
|
|
878
|
-
if (element.required) bits.push("required");
|
|
879
|
-
if (element.type === "text" || element.type === "textarea") {
|
|
880
|
-
if (element.minLength != null)
|
|
881
|
-
bits.push(`minLength=${element.minLength}`);
|
|
882
|
-
if (element.maxLength != null)
|
|
883
|
-
bits.push(`maxLength=${element.maxLength}`);
|
|
884
|
-
if (element.pattern) bits.push(`pattern=/${element.pattern}/`);
|
|
885
|
-
}
|
|
886
|
-
if (element.type === "number") {
|
|
887
|
-
if (element.min != null) bits.push(`min=${element.min}`);
|
|
888
|
-
if (element.max != null) bits.push(`max=${element.max}`);
|
|
889
|
-
if (element.decimals != null)
|
|
890
|
-
bits.push(`decimals=${element.decimals}`);
|
|
891
|
-
}
|
|
892
|
-
if (element.type === "select")
|
|
893
|
-
bits.push(`options=${element.options.length}`);
|
|
894
|
-
if (element.type === "files") {
|
|
895
|
-
if (element.minCount != null)
|
|
896
|
-
bits.push(`minCount=${element.minCount}`);
|
|
897
|
-
if (element.maxCount != null)
|
|
898
|
-
bits.push(`maxCount=${element.maxCount}`);
|
|
899
|
-
}
|
|
900
|
-
hint.textContent = [bits.join(" · "), extra]
|
|
901
|
-
.filter(Boolean)
|
|
902
|
-
.join(" | ");
|
|
903
|
-
return hint;
|
|
904
|
-
}
|
|
905
|
-
|
|
906
|
-
function renderResourcePills(container, rids, onRemove) {
|
|
907
|
-
clear(container);
|
|
908
|
-
rids.forEach((rid) => {
|
|
909
|
-
const meta = state.resourceIndex.get(rid);
|
|
910
|
-
const pill = document.createElement("span");
|
|
911
|
-
pill.className = "pill";
|
|
912
|
-
pill.textContent = rid;
|
|
913
|
-
if (meta) {
|
|
914
|
-
const small = document.createElement("span");
|
|
915
|
-
small.className = "muted";
|
|
916
|
-
small.textContent = ` (${meta.name ?? "file"}, ${meta.size ?? "?"}B)`;
|
|
917
|
-
pill.appendChild(small);
|
|
918
|
-
}
|
|
919
|
-
if (onRemove) {
|
|
920
|
-
const x = document.createElement("button");
|
|
921
|
-
x.type = "button";
|
|
922
|
-
x.className = "btn bad small";
|
|
923
|
-
x.textContent = "×";
|
|
924
|
-
x.style.padding = "0 6px";
|
|
925
|
-
x.addEventListener("click", () => onRemove(rid));
|
|
926
|
-
pill.appendChild(x);
|
|
927
|
-
}
|
|
928
|
-
container.appendChild(pill);
|
|
929
|
-
});
|
|
930
|
-
}
|
|
931
|
-
|
|
932
|
-
function markValidity(input, msg) {
|
|
933
|
-
const prev = input?.parentElement?.querySelector?.(".msg");
|
|
934
|
-
if (prev) prev.remove();
|
|
935
|
-
if (input) input.classList.toggle("invalid", !!msg);
|
|
936
|
-
if (msg && input?.parentElement) {
|
|
937
|
-
const m = document.createElement("div");
|
|
938
|
-
m.className = "msg";
|
|
939
|
-
m.textContent = msg;
|
|
940
|
-
input.parentElement.appendChild(m);
|
|
941
|
-
}
|
|
942
|
-
}
|
|
943
|
-
function parseJSONSafe(text, fb = null) {
|
|
944
|
-
try {
|
|
945
|
-
return JSON.parse(text);
|
|
946
|
-
} catch {
|
|
947
|
-
return fb;
|
|
948
|
-
}
|
|
949
|
-
}
|
|
950
|
-
function setTextValueFromPrefill(input, element, prefillObj, key) {
|
|
951
|
-
let v = undefined;
|
|
952
|
-
if (prefillObj && Object.prototype.hasOwnProperty.call(prefillObj, key))
|
|
953
|
-
v = prefillObj[key];
|
|
954
|
-
else if (element.default !== undefined) v = element.default;
|
|
955
|
-
if (v !== undefined) input.value = String(v);
|
|
956
|
-
}
|
|
957
|
-
function setNumberFromPrefill(input, element, prefillObj, key) {
|
|
958
|
-
let v = undefined;
|
|
959
|
-
if (prefillObj && Object.prototype.hasOwnProperty.call(prefillObj, key))
|
|
960
|
-
v = prefillObj[key];
|
|
961
|
-
else if (element.default !== undefined) v = element.default;
|
|
962
|
-
if (v !== undefined && v !== null && v !== "") input.value = String(v);
|
|
963
|
-
}
|
|
964
|
-
function setSelectFromPrefill(select, element, prefillObj, key) {
|
|
965
|
-
const values = new Set(element.options.map((o) => String(o.value)));
|
|
966
|
-
let v = undefined;
|
|
967
|
-
if (prefillObj && Object.prototype.hasOwnProperty.call(prefillObj, key))
|
|
968
|
-
v = prefillObj[key];
|
|
969
|
-
else if (element.default !== undefined) v = element.default;
|
|
970
|
-
if (v !== undefined && values.has(String(v))) select.value = String(v);
|
|
971
|
-
else if (!element.required) select.value = "";
|
|
972
|
-
}
|
|
973
|
-
|
|
974
|
-
function fileValidationError(element, file) {
|
|
975
|
-
if (!file) return "no file";
|
|
976
|
-
if (
|
|
977
|
-
element.maxSizeMB != null &&
|
|
978
|
-
file.size > element.maxSizeMB * 1024 * 1024
|
|
979
|
-
)
|
|
980
|
-
return `file too large > ${element.maxSizeMB}MB`;
|
|
981
|
-
if (element.accept) {
|
|
982
|
-
const { extensions, mime } = element.accept;
|
|
983
|
-
if (
|
|
984
|
-
mime &&
|
|
985
|
-
Array.isArray(mime) &&
|
|
986
|
-
mime.length &&
|
|
987
|
-
!mime.includes(file.type)
|
|
988
|
-
)
|
|
989
|
-
return `mime not allowed: ${file.type}`;
|
|
990
|
-
if (extensions && Array.isArray(extensions) && extensions.length) {
|
|
991
|
-
const ext = (file.name.split(".").pop() || "").toLowerCase();
|
|
992
|
-
if (!extensions.includes(ext))
|
|
993
|
-
return `extension .${ext} not allowed`;
|
|
994
|
-
}
|
|
995
|
-
}
|
|
996
|
-
return null;
|
|
997
|
-
}
|
|
998
|
-
|
|
999
|
-
function collectAndValidate(schema) {
|
|
1000
|
-
const form = document.getElementById("dynamicForm");
|
|
1001
|
-
const errors = [];
|
|
1002
|
-
|
|
1003
|
-
function collectElement(element, scopeRoot) {
|
|
1004
|
-
const key = element.key;
|
|
1005
|
-
switch (element.type) {
|
|
1006
|
-
case "text":
|
|
1007
|
-
case "textarea": {
|
|
1008
|
-
const input = scopeRoot.querySelector(`[name$="${key}"]`);
|
|
1009
|
-
const val = (input?.value ?? "").trim();
|
|
1010
|
-
if (element.required && val === "") {
|
|
1011
|
-
errors.push(`${key}: required`);
|
|
1012
|
-
markValidity(input, "required");
|
|
1013
|
-
} else if (val !== "") {
|
|
1014
|
-
if (
|
|
1015
|
-
element.minLength != null &&
|
|
1016
|
-
val.length < element.minLength
|
|
1017
|
-
) {
|
|
1018
|
-
errors.push(`${key}: minLength=${element.minLength}`);
|
|
1019
|
-
markValidity(input, `minLength=${element.minLength}`);
|
|
1020
|
-
}
|
|
1021
|
-
if (
|
|
1022
|
-
element.maxLength != null &&
|
|
1023
|
-
val.length > element.maxLength
|
|
1024
|
-
) {
|
|
1025
|
-
errors.push(`${key}: maxLength=${element.maxLength}`);
|
|
1026
|
-
markValidity(input, `maxLength=${element.maxLength}`);
|
|
1027
|
-
}
|
|
1028
|
-
if (element.pattern) {
|
|
1029
|
-
try {
|
|
1030
|
-
const re = new RegExp(element.pattern);
|
|
1031
|
-
if (!re.test(val)) {
|
|
1032
|
-
errors.push(`${key}: pattern mismatch`);
|
|
1033
|
-
markValidity(input, "pattern mismatch");
|
|
1034
|
-
}
|
|
1035
|
-
} catch {
|
|
1036
|
-
errors.push(`${key}: invalid pattern`);
|
|
1037
|
-
markValidity(input, "invalid pattern");
|
|
1038
|
-
}
|
|
1039
|
-
}
|
|
1040
|
-
} else {
|
|
1041
|
-
markValidity(input, null);
|
|
1042
|
-
}
|
|
1043
|
-
return val;
|
|
1044
|
-
}
|
|
1045
|
-
case "number": {
|
|
1046
|
-
const input = scopeRoot.querySelector(`[name$="${key}"]`);
|
|
1047
|
-
const raw = input?.value ?? "";
|
|
1048
|
-
if (element.required && raw === "") {
|
|
1049
|
-
errors.push(`${key}: required`);
|
|
1050
|
-
markValidity(input, "required");
|
|
1051
|
-
return null;
|
|
1052
|
-
}
|
|
1053
|
-
if (raw === "") {
|
|
1054
|
-
markValidity(input, null);
|
|
1055
|
-
return null;
|
|
1056
|
-
}
|
|
1057
|
-
const v = parseFloat(raw);
|
|
1058
|
-
if (!Number.isFinite(v)) {
|
|
1059
|
-
errors.push(`${key}: not a number`);
|
|
1060
|
-
markValidity(input, "not a number");
|
|
1061
|
-
return null;
|
|
1062
|
-
}
|
|
1063
|
-
if (element.min != null && v < element.min) {
|
|
1064
|
-
errors.push(`${key}: < min=${element.min}`);
|
|
1065
|
-
markValidity(input, `< min=${element.min}`);
|
|
1066
|
-
}
|
|
1067
|
-
if (element.max != null && v > element.max) {
|
|
1068
|
-
errors.push(`${key}: > max=${element.max}`);
|
|
1069
|
-
markValidity(input, `> max=${element.max}`);
|
|
1070
|
-
}
|
|
1071
|
-
const d = Number.isInteger(element.decimals ?? 0)
|
|
1072
|
-
? element.decimals
|
|
1073
|
-
: 0;
|
|
1074
|
-
const r = Number(v.toFixed(d));
|
|
1075
|
-
input.value = String(r);
|
|
1076
|
-
markValidity(input, null);
|
|
1077
|
-
return r;
|
|
1078
|
-
}
|
|
1079
|
-
case "select": {
|
|
1080
|
-
const sel = scopeRoot.querySelector(`select[name$="${key}"]`);
|
|
1081
|
-
const val = sel?.value ?? "";
|
|
1082
|
-
const values = new Set(
|
|
1083
|
-
element.options.map((o) => String(o.value)),
|
|
1084
|
-
);
|
|
1085
|
-
if (element.required && val === "") {
|
|
1086
|
-
errors.push(`${key}: required`);
|
|
1087
|
-
markValidity(sel, "required");
|
|
1088
|
-
return "";
|
|
1089
|
-
}
|
|
1090
|
-
if (val !== "" && !values.has(String(val))) {
|
|
1091
|
-
errors.push(`${key}: value not in options`);
|
|
1092
|
-
markValidity(sel, "not in options");
|
|
1093
|
-
} else {
|
|
1094
|
-
markValidity(sel, null);
|
|
1095
|
-
}
|
|
1096
|
-
return val === "" ? null : val;
|
|
1097
|
-
}
|
|
1098
|
-
case "file": {
|
|
1099
|
-
const hid = scopeRoot.querySelector(
|
|
1100
|
-
`input[type="hidden"][name$="${key}"]`,
|
|
1101
|
-
);
|
|
1102
|
-
const rid = hid?.value ?? "";
|
|
1103
|
-
if (element.required && !rid) {
|
|
1104
|
-
errors.push(`${key}: required (resourceId missing)`);
|
|
1105
|
-
const picker = hid?.previousElementSibling;
|
|
1106
|
-
if (picker) markValidity(picker, "required");
|
|
1107
|
-
} else {
|
|
1108
|
-
if (hid?.previousElementSibling)
|
|
1109
|
-
markValidity(hid.previousElementSibling, null);
|
|
1110
|
-
}
|
|
1111
|
-
return rid || null;
|
|
1112
|
-
}
|
|
1113
|
-
case "files": {
|
|
1114
|
-
const hid = scopeRoot.querySelector(
|
|
1115
|
-
`input[type="hidden"][name$="${key}"]`,
|
|
1116
|
-
);
|
|
1117
|
-
const arr = parseJSONSafe(hid?.value ?? "[]", []);
|
|
1118
|
-
const count = Array.isArray(arr) ? arr.length : 0;
|
|
1119
|
-
if (!Array.isArray(arr))
|
|
1120
|
-
errors.push(`${key}: internal value corrupted`);
|
|
1121
|
-
if (element.minCount != null && count < element.minCount)
|
|
1122
|
-
errors.push(`${key}: < minCount=${element.minCount}`);
|
|
1123
|
-
if (element.maxCount != null && count > element.maxCount)
|
|
1124
|
-
errors.push(`${key}: > maxCount=${element.maxCount}`);
|
|
1125
|
-
if (hid?.previousElementSibling)
|
|
1126
|
-
markValidity(hid.previousElementSibling, null);
|
|
1127
|
-
return Array.isArray(arr) ? arr : [];
|
|
1128
|
-
}
|
|
1129
|
-
case "group": {
|
|
1130
|
-
// КЛЮЧЕВОЙ ФИКС: собираем по реальному контейнеру группы,
|
|
1131
|
-
// а не по всему scopeRoot.
|
|
1132
|
-
const groupWrapper = scopeRoot.querySelector(
|
|
1133
|
-
`[data-group="${key}"]`,
|
|
1134
|
-
);
|
|
1135
|
-
if (!groupWrapper) {
|
|
1136
|
-
errors.push(`${key}: internal group wrapper not found`);
|
|
1137
|
-
return element.repeat ? [] : {};
|
|
1138
|
-
}
|
|
1139
|
-
const itemsWrap = groupWrapper.querySelector(
|
|
1140
|
-
`[data-items-for="${key}"]`,
|
|
1141
|
-
);
|
|
1142
|
-
if (!itemsWrap) {
|
|
1143
|
-
errors.push(`${key}: internal items container not found`);
|
|
1144
|
-
return element.repeat ? [] : {};
|
|
1145
|
-
}
|
|
1146
|
-
|
|
1147
|
-
if (element.repeat && isPlainObject(element.repeat)) {
|
|
1148
|
-
const items = itemsWrap.querySelectorAll(":scope > .groupItem");
|
|
1149
|
-
const out = [];
|
|
1150
|
-
const n = items.length;
|
|
1151
|
-
const min = element.repeat.min ?? 0;
|
|
1152
|
-
const max = element.repeat.max ?? Infinity;
|
|
1153
|
-
if (n < min) errors.push(`${key}: count < min=${min}`);
|
|
1154
|
-
if (n > max) errors.push(`${key}: count > max=${max}`);
|
|
1155
|
-
items.forEach((item) => {
|
|
1156
|
-
const obj = {};
|
|
1157
|
-
element.elements.forEach((child) => {
|
|
1158
|
-
obj[child.key] = collectElement(child, item);
|
|
1159
|
-
});
|
|
1160
|
-
out.push(obj);
|
|
1161
|
-
});
|
|
1162
|
-
return out;
|
|
1163
|
-
} else {
|
|
1164
|
-
const obj = {};
|
|
1165
|
-
element.elements.forEach((child) => {
|
|
1166
|
-
obj[child.key] = collectElement(child, itemsWrap);
|
|
1167
|
-
});
|
|
1168
|
-
return obj;
|
|
1169
|
-
}
|
|
1170
|
-
}
|
|
1171
|
-
default:
|
|
1172
|
-
errors.push(`${key}: unsupported type ${element.type}`);
|
|
1173
|
-
return null;
|
|
1174
|
-
}
|
|
1175
|
-
}
|
|
1176
|
-
|
|
1177
|
-
const result = {};
|
|
1178
|
-
state.schema.elements.forEach((element) => {
|
|
1179
|
-
result[element.key] = collectElement(element, form);
|
|
1180
|
-
});
|
|
1181
|
-
return { result, errors };
|
|
1182
|
-
}
|
|
1183
|
-
|
|
1184
|
-
el.applySchemaBtn.addEventListener("click", () => {
|
|
1185
|
-
el.schemaErrors.textContent = "";
|
|
1186
|
-
try {
|
|
1187
|
-
const parsed = JSON.parse(el.schemaInput.value);
|
|
1188
|
-
const errs = validateSchema(parsed);
|
|
1189
|
-
if (errs.length) {
|
|
1190
|
-
el.schemaErrors.textContent = errs.join("\n");
|
|
1191
|
-
return;
|
|
1192
|
-
}
|
|
1193
|
-
renderForm(parsed, {});
|
|
1194
|
-
} catch (e) {
|
|
1195
|
-
el.schemaErrors.textContent = "JSON parse error: " + e.message;
|
|
1196
|
-
}
|
|
1197
|
-
});
|
|
1198
|
-
|
|
1199
|
-
el.resetSchemaBtn.addEventListener("click", () => {
|
|
1200
|
-
el.schemaInput.value = pretty(EXAMPLE_SCHEMA);
|
|
1201
|
-
el.schemaErrors.textContent = "";
|
|
1202
|
-
renderForm(EXAMPLE_SCHEMA, {});
|
|
1203
|
-
el.outputJson.value = "";
|
|
1204
|
-
el.prefillInput.value = "";
|
|
1205
|
-
el.prefillErrors.textContent = "";
|
|
1206
|
-
});
|
|
1207
|
-
|
|
1208
|
-
el.prettySchemaBtn.addEventListener("click", () => {
|
|
1209
|
-
try {
|
|
1210
|
-
const parsed = JSON.parse(el.schemaInput.value);
|
|
1211
|
-
el.schemaInput.value = pretty(parsed);
|
|
1212
|
-
} catch (e) {
|
|
1213
|
-
el.schemaErrors.textContent =
|
|
1214
|
-
"Prettify: JSON parse error: " + e.message;
|
|
1215
|
-
}
|
|
1216
|
-
});
|
|
1217
|
-
|
|
1218
|
-
el.downloadSchemaBtn.addEventListener("click", () =>
|
|
1219
|
-
downloadFile(
|
|
1220
|
-
"schema.json",
|
|
1221
|
-
el.schemaInput.value || pretty(EXAMPLE_SCHEMA),
|
|
1222
|
-
),
|
|
1223
|
-
);
|
|
1224
|
-
|
|
1225
|
-
el.submitBtn.addEventListener("click", () => {
|
|
1226
|
-
el.formErrors.textContent = "";
|
|
1227
|
-
if (!state.schema) {
|
|
1228
|
-
el.formErrors.textContent = "Schema not applied";
|
|
1229
|
-
return;
|
|
1230
|
-
}
|
|
1231
|
-
const { result, errors } = collectAndValidate(state.schema);
|
|
1232
|
-
if (errors.length) {
|
|
1233
|
-
el.formErrors.textContent = errors.join("\n");
|
|
1234
|
-
return;
|
|
1235
|
-
}
|
|
1236
|
-
el.outputJson.value = pretty(result);
|
|
1237
|
-
});
|
|
1238
|
-
|
|
1239
|
-
el.clearFormBtn.addEventListener("click", () => {
|
|
1240
|
-
if (!state.schema) return;
|
|
1241
|
-
renderForm(state.schema, {});
|
|
1242
|
-
el.formErrors.textContent = "";
|
|
1243
|
-
});
|
|
1244
|
-
|
|
1245
|
-
el.copyOutputBtn.addEventListener("click", async () => {
|
|
1246
|
-
try {
|
|
1247
|
-
await navigator.clipboard.writeText(el.outputJson.value || "");
|
|
1248
|
-
} catch (_) {}
|
|
1249
|
-
});
|
|
1250
|
-
el.downloadOutputBtn.addEventListener("click", () =>
|
|
1251
|
-
downloadFile("data.json", el.outputJson.value || "{}"),
|
|
1252
|
-
);
|
|
1253
|
-
|
|
1254
|
-
el.loadPrefillBtn.addEventListener("click", () => {
|
|
1255
|
-
el.prefillErrors.textContent = "";
|
|
1256
|
-
if (!state.schema) {
|
|
1257
|
-
el.prefillErrors.textContent = "Schema not applied";
|
|
1258
|
-
return;
|
|
1259
|
-
}
|
|
1260
|
-
try {
|
|
1261
|
-
const pre = JSON.parse(el.prefillInput.value || "{}");
|
|
1262
|
-
const allowed = new Set(state.schema.elements.map((e) => e.key));
|
|
1263
|
-
const unknown = Object.keys(pre).filter((k) => !allowed.has(k));
|
|
1264
|
-
if (unknown.length)
|
|
1265
|
-
warn("prefill unknown keys: " + unknown.join(", "));
|
|
1266
|
-
renderForm(state.schema, pre);
|
|
1267
|
-
} catch (e) {
|
|
1268
|
-
el.prefillErrors.textContent = "Prefill parse error: " + e.message;
|
|
1269
|
-
}
|
|
1270
|
-
});
|
|
1271
|
-
|
|
1272
|
-
el.copyTemplateBtn.addEventListener("click", () => {
|
|
1273
|
-
if (!state.schema) {
|
|
1274
|
-
el.prefillErrors.textContent = "Apply schema first";
|
|
1275
|
-
return;
|
|
1276
|
-
}
|
|
1277
|
-
const tpl = makePrefillTemplate(state.schema);
|
|
1278
|
-
el.prefillInput.value = pretty(tpl);
|
|
1279
|
-
});
|
|
1280
|
-
|
|
1281
|
-
function makePrefillTemplate(schema) {
|
|
1282
|
-
function walk(elements) {
|
|
1283
|
-
const obj = {};
|
|
1284
|
-
for (const el of elements) {
|
|
1285
|
-
switch (el.type) {
|
|
1286
|
-
case "text":
|
|
1287
|
-
case "textarea":
|
|
1288
|
-
case "select":
|
|
1289
|
-
case "number":
|
|
1290
|
-
obj[el.key] = el.default ?? null;
|
|
1291
|
-
break;
|
|
1292
|
-
case "file":
|
|
1293
|
-
obj[el.key] = null;
|
|
1294
|
-
break;
|
|
1295
|
-
case "files":
|
|
1296
|
-
obj[el.key] = [];
|
|
1297
|
-
break;
|
|
1298
|
-
case "group":
|
|
1299
|
-
if (el.repeat && isPlainObject(el.repeat)) {
|
|
1300
|
-
const sample = walk(el.elements);
|
|
1301
|
-
const n = Math.max(el.repeat.min ?? 0, 1);
|
|
1302
|
-
obj[el.key] = Array.from({ length: n }, () =>
|
|
1303
|
-
deepClone(sample),
|
|
1304
|
-
);
|
|
1305
|
-
} else {
|
|
1306
|
-
obj[el.key] = walk(el.elements);
|
|
1307
|
-
}
|
|
1308
|
-
break;
|
|
1309
|
-
default:
|
|
1310
|
-
obj[el.key] = null;
|
|
1311
|
-
}
|
|
1312
|
-
}
|
|
1313
|
-
return obj;
|
|
1314
|
-
}
|
|
1315
|
-
return walk(schema.elements);
|
|
1316
|
-
}
|
|
1317
|
-
|
|
1318
|
-
(function init() {
|
|
1319
|
-
el.schemaInput.value = pretty(EXAMPLE_SCHEMA);
|
|
1320
|
-
renderForm(EXAMPLE_SCHEMA, {});
|
|
1321
|
-
})();
|
|
1322
|
-
|
|
1323
|
-
/*
|
|
1324
|
-
[TASK-01] Constraints Compiler: path-based overrides ("slides[].title") → merge → validate.
|
|
1325
|
-
[TASK-02] Prod Upload: presigned PUT + register → resourceId; UI state: idle|uploading|uploaded|error; submit → only resourceId(s).
|
|
1326
|
-
[TASK-03] Cross-field Rules: when/then → require/disable/show/hide; dynamic min/max.
|
|
1327
|
-
[TASK-04] i18n: labels/hints via dictionaries by path.
|
|
1328
|
-
[TASK-05] A11y: for/id, aria-invalid, keyboard nav в группах.
|
|
1329
|
-
[TASK-06] Snapshot & Audit: сохранять snapshot схемы вместе с данными.
|
|
1330
|
-
[TASK-07] Perf: виртуализация для форм >200 полей, debounce валидации.
|
|
1331
|
-
[TASK-08] Security: MIME/ext whitelist, checksum on backend, no dataURL ever.
|
|
1332
|
-
[TASK-09] Schema Registry: version bump + transforms, backend validation.
|
|
1333
|
-
[TASK-10] UX Groups: DnD reorder, Duplicate item, строгие min/max.
|
|
1334
|
-
*/
|
|
1335
|
-
</script>
|
|
1336
|
-
</body>
|
|
1337
|
-
</html>
|