@dmitryvim/form-builder 0.1.22 → 0.1.24

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,652 +1,1326 @@
1
1
  <!doctype html>
2
2
  <html lang="ru">
3
- <head>
3
+ <head>
4
4
  <meta charset="utf-8" />
5
5
  <title>Form DSL v0.3.1 — Генератор форм (патч group.repeat)</title>
6
6
  <meta name="viewport" content="width=device-width, initial-scale=1" />
7
7
  <style>
8
- :root {
9
- --bg: #0f1115; --fg: #e7e7e7; --muted: #9aa0a6; --accent: #7dd3fc;
10
- --bad: #ff6b6b; --good: #34d399; --card: #151922; --border: #2a2f3a;
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";
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;
13
51
  }
14
- * { box-sizing: border-box; }
15
- html, body { height: 100%; background: var(--bg); color: var(--fg); margin: 0; font-family: var(--sans); }
16
- h1 { margin: 16px; font-size: 18px; font-weight: 600; color: var(--fg); }
17
- .grid { display: grid; grid-template-columns: 1fr 1fr; grid-auto-rows: minmax(300px, auto); gap: 12px; padding: 12px; }
18
- @media (min-width: 1400px) { .grid { grid-template-columns: 1fr 1fr 1fr 1fr; } }
19
- @media (max-width: 900px) { .grid { grid-template-columns: 1fr; } }
20
- .card { background: var(--card); border: 1px solid var(--border); border-radius: 12px; padding: 12px; display: flex; flex-direction: column; min-height: 320px; }
21
- .card h2 { margin: 0 0 8px 0; font-size: 15px; color: var(--fg); }
22
- .toolbar { display: flex; flex-wrap: wrap; gap: 8px; margin-bottom: 8px; }
23
- .btn { background: #1e2633; color: var(--fg); border: 1px solid var(--border); padding: 6px 10px; border-radius: 8px; cursor: pointer; font-size: 13px; }
24
- .btn:hover { border-color: var(--accent); }
25
- .btn.good { background: #15342a; border-color: #1f6f52; }
26
- .btn.bad { background: #3b1e20; border-color: #6f1f1f; }
27
- .btn.ghost { background: transparent; }
28
- .textarea, .json, input[type="text"], textarea, select, input[type="number"] {
29
- width: 100%; border: 1px solid var(--border); background: #0e131b; color: var(--fg); border-radius: 8px; padding: 10px; font-family: var(--mono); font-size: 12px;
52
+ }
53
+ @media (max-width: 900px) {
54
+ .grid {
55
+ grid-template-columns: 1fr;
30
56
  }
31
- .json, .textarea { flex: 1; resize: vertical; min-height: 220px; }
32
- .hint { color: var(--muted); font-size: 12px; margin: 6px 0 0; }
33
- .errors { color: var(--bad); font-size: 12px; white-space: pre-wrap; margin-top: 6px; }
34
- .row { display: grid; grid-template-columns: 1fr 1fr; gap: 8px; }
35
- .field { margin-bottom: 10px; }
36
- .label { font-size: 13px; margin-bottom: 6px; color: var(--fg); display: flex; align-items: center; gap: 8px; }
37
- .label .req { color: var(--bad); }
38
- .invalid { border-color: var(--bad) !important; }
39
- .msg { font-size: 12px; color: var(--bad); margin-top: 4px; }
40
- .ok { color: var(--good); }
41
- .muted { color: var(--muted); }
42
- .line { height: 1px; background: var(--border); margin: 8px 0; }
43
- .pill { display: inline-flex; align-items: center; gap: 6px; background: #0e131b; border: 1px solid var(--border); border-radius: 999px; padding: 2px 8px; font-family: var(--mono); font-size: 12px; }
44
- .list { display: flex; flex-wrap: wrap; gap: 6px; }
45
- .small { font-size: 12px; }
46
- .groupHeader { display: flex; align-items: center; justify-content: space-between; margin: 4px 0 10px; }
47
- .groupItem { border: 1px dashed var(--border); border-radius: 10px; padding: 10px; margin-bottom: 10px; }
48
- .kbd { font-family: var(--mono); background: #0e131b; border: 1px solid var(--border); padding: 2px 6px; border-radius: 6px; }
49
- .footer { margin-top: auto; font-size: 11px; color: var(--muted); }
50
- .sr { position: absolute; left: -10000px; width: 1px; height: 1px; overflow: hidden; }
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
+ }
51
221
  </style>
52
- </head>
53
- <body>
54
- <h1>Form DSL v0.3.1 — Генератор форм (Schema → Form → Output → Prefill)</h1>
55
- <div class="grid">
56
- <section class="card" id="schemaCard">
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">
57
227
  <h2>1) Schema (v0.3)</h2>
58
228
  <div class="toolbar">
59
- <button class="btn" id="applySchemaBtn">Apply schema</button>
60
- <button class="btn ghost" id="resetSchemaBtn">Reset to example</button>
61
- <button class="btn ghost" id="prettySchemaBtn">Prettify</button>
62
- <button class="btn ghost" id="downloadSchemaBtn">Download schema.json</button>
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>
63
237
  </div>
64
238
  <textarea id="schemaInput" class="json" spellcheck="false"></textarea>
65
239
  <div id="schemaErrors" class="errors"></div>
66
240
  <div class="footer">
67
- Контракт: <span class="kbd">file → resourceId</span>, <span class="kbd">files → resourceId[]</span>, <span class="kbd">group.repeat → массив объектов</span>.
241
+ Контракт: <span class="kbd">file → resourceId</span>,
242
+ <span class="kbd">files → resourceId[]</span>,
243
+ <span class="kbd">group.repeat → массив объектов</span>.
68
244
  </div>
69
- </section>
245
+ </section>
70
246
 
71
- <section class="card" id="formCard">
247
+ <section class="card" id="formCard">
72
248
  <h2>2) Form (rendered from schema)</h2>
73
249
  <div id="formContainer"></div>
74
250
  <div class="toolbar">
75
- <button class="btn good" id="submitBtn">Submit → Output JSON</button>
76
- <button class="btn ghost" id="clearFormBtn">Clear values</button>
251
+ <button class="btn good" id="submitBtn">Submit → Output JSON</button>
252
+ <button class="btn ghost" id="clearFormBtn">Clear values</button>
77
253
  </div>
78
254
  <div id="formErrors" class="errors"></div>
79
- <div class="footer">Файлы загружаются «раньше сабмита» — создаём <span class="kbd">resourceId</span> локально (эмуляция).</div>
80
- </section>
255
+ <div class="footer">
256
+ Файлы загружаются «раньше сабмита» — создаём
257
+ <span class="kbd">resourceId</span> локально (эмуляция).
258
+ </div>
259
+ </section>
81
260
 
82
- <section class="card" id="outputCard">
261
+ <section class="card" id="outputCard">
83
262
  <h2>3) Output JSON (submit result)</h2>
84
263
  <div class="toolbar">
85
- <button class="btn" id="copyOutputBtn">Copy</button>
86
- <button class="btn ghost" id="downloadOutputBtn">Download data.json</button>
264
+ <button class="btn" id="copyOutputBtn">Copy</button>
265
+ <button class="btn ghost" id="downloadOutputBtn">
266
+ Download data.json
267
+ </button>
87
268
  </div>
88
269
  <textarea id="outputJson" class="json" readonly></textarea>
89
- <div class="footer">Выходит строго по ключам <span class="kbd">key</span>. Порядок соответствует схеме.</div>
90
- </section>
270
+ <div class="footer">
271
+ Выходит строго по ключам <span class="kbd">key</span>. Порядок
272
+ соответствует схеме.
273
+ </div>
274
+ </section>
91
275
 
92
- <section class="card" id="prefillCard">
276
+ <section class="card" id="prefillCard">
93
277
  <h2>4) Prefill JSON → заполнить форму</h2>
94
278
  <div class="toolbar">
95
- <button class="btn" id="loadPrefillBtn">Load Prefill</button>
96
- <button class="btn ghost" id="copyTemplateBtn">Insert current template</button>
279
+ <button class="btn" id="loadPrefillBtn">Load Prefill</button>
280
+ <button class="btn ghost" id="copyTemplateBtn">
281
+ Insert current template
282
+ </button>
97
283
  </div>
98
- <textarea id="prefillInput" class="json" spellcheck="false" placeholder='{"title":"...", "cover":"res_...", ...}'></textarea>
284
+ <textarea
285
+ id="prefillInput"
286
+ class="json"
287
+ spellcheck="false"
288
+ placeholder='{"title":"...", "cover":"res_...", ...}'
289
+ ></textarea>
99
290
  <div id="prefillErrors" class="errors"></div>
100
- <div class="footer">Префилл = тот же контракт, что и сабмит; лишние ключи игнорируются с предупреждением.</div>
101
- </section>
102
- </div>
103
-
104
- <script>
105
- const state = { schema: null, formRoot: null, resourceIndex: new Map() };
106
- const el = {
107
- schemaInput: document.getElementById('schemaInput'),
108
- schemaErrors: document.getElementById('schemaErrors'),
109
- applySchemaBtn: document.getElementById('applySchemaBtn'),
110
- resetSchemaBtn: document.getElementById('resetSchemaBtn'),
111
- prettySchemaBtn: document.getElementById('prettySchemaBtn'),
112
- downloadSchemaBtn: document.getElementById('downloadSchemaBtn'),
113
- formContainer: document.getElementById('formContainer'),
114
- formErrors: document.getElementById('formErrors'),
115
- submitBtn: document.getElementById('submitBtn'),
116
- clearFormBtn: document.getElementById('clearFormBtn'),
117
- outputJson: document.getElementById('outputJson'),
118
- copyOutputBtn: document.getElementById('copyOutputBtn'),
119
- downloadOutputBtn: document.getElementById('downloadOutputBtn'),
120
- prefillInput: document.getElementById('prefillInput'),
121
- loadPrefillBtn: document.getElementById('loadPrefillBtn'),
122
- copyTemplateBtn: document.getElementById('copyTemplateBtn'),
123
- prefillErrors: document.getElementById('prefillErrors'),
124
- };
125
-
126
- const EXAMPLE_SCHEMA = {
127
- "version": "0.3",
128
- "title": "Asset Uploader with Slides",
129
- "elements": [
130
- { "type": "file", "key": "cover", "label": "Cover image", "required": true,
131
- "accept": { "extensions": ["png","jpg","jpeg"], "mime": ["image/png","image/jpeg"] }, "maxSizeMB": 25 },
132
- { "type": "files", "key": "assets", "label": "Additional images", "required": false,
133
- "accept": { "extensions": ["png","jpg"], "mime": ["image/png","image/jpeg"] }, "minCount": 0, "maxCount": 10, "maxSizeMB": 25 },
134
- { "type": "text", "key": "title", "label": "Project title", "required": true, "minLength": 1, "maxLength": 120,
135
- "pattern": "^[A-Za-z0-9 _-]+$", "default": "My Project" },
136
- { "type": "textarea", "key": "description", "label": "Description", "required": false, "minLength": 0, "maxLength": 2000,
137
- "pattern": null, "default": "" },
138
- { "type": "select", "key": "theme", "label": "Theme", "required": true,
139
- "options": [ {"value":"light","label":"Light"}, {"value":"dark","label":"Dark"} ],
140
- "default": "dark" },
141
- { "type": "number", "key": "opacity", "label": "Opacity", "required": true, "min": 0, "max": 1, "decimals": 2, "step": 0.01, "default": 0.85 },
142
- { "type": "group", "key": "slides", "label": "Slides", "repeat": { "min": 1, "max": 5 },
143
- "elements": [
144
- { "type":"text", "key":"title", "label":"Slide title", "required":true, "minLength":1, "maxLength":80, "default":"" },
145
- { "type":"textarea", "key":"body", "label":"Slide text", "required":true, "minLength":1, "maxLength":1000, "default":"" }
146
- ] }
147
- ]
148
- };
149
-
150
- const sleep = (ms) => new Promise(r => setTimeout(r, ms));
151
- function pretty(obj){ return JSON.stringify(obj, null, 2); }
152
- function deepClone(obj){ return structuredClone ? structuredClone(obj) : JSON.parse(JSON.stringify(obj)); }
153
- function isPlainObject(v){ return Object.prototype.toString.call(v)==='[object Object]'; }
154
- function setText(node, text){ node.textContent = text || ''; }
155
- function downloadFile(filename, text){
156
- const blob = new Blob([text], { type: 'application/json' });
157
- const url = URL.createObjectURL(blob); const a = document.createElement('a');
158
- a.href = url; a.download = filename; a.click(); URL.revokeObjectURL(url);
159
- }
160
- function pathJoin(base, key){ return base ? `${base}.${key}` : key; }
161
- function assert(c, m){ if(!c) throw new Error(m); }
162
- function warn(m){ console.warn('[WARN]', m); }
163
- async function makeResourceIdFromFile(file){
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) {
164
453
  try {
165
- const buf = await file.arrayBuffer();
166
- if (crypto?.subtle?.digest) {
167
- const hash = await crypto.subtle.digest('SHA-256', buf);
168
- const hex = [...new Uint8Array(hash)].map(b=>b.toString(16).padStart(2,'0')).join('');
169
- return `res_${hex.slice(0, 24)}`;
170
- }
171
- } catch(_) {}
172
- const rnd = Math.random().toString(36).slice(2) + Math.random().toString(36).slice(2);
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);
173
466
  return `res_${rnd.slice(0, 24)}`;
174
- }
467
+ }
175
468
 
176
- function validateSchema(schema){
469
+ function validateSchema(schema) {
177
470
  const errors = [];
178
- try { assert(schema && schema.version === '0.3', 'schema.version must be "0.3"'); } catch(e){ errors.push(e.message); }
179
- try { assert(Array.isArray(schema.elements), 'schema.elements must be an array'); } catch(e){ errors.push(e.message); }
180
- function validateElements(elements, path){
181
- const seen = new Set();
182
- elements.forEach((el, idx) => {
183
- const here = `${path}[${idx}]`;
184
- if (!el || typeof el !== 'object') { errors.push(`${here}: element must be object`); return; }
185
- if (!el.type) errors.push(`${here}: missing "type"`);
186
- if (!el.key) errors.push(`${here}: missing "key"`);
187
- if (el.key) { if (seen.has(el.key)) errors.push(`${path}: duplicate key "${el.key}"`); seen.add(el.key); }
188
- if (el.default !== undefined && (el.type === 'file' || el.type === 'files')) errors.push(`${here}: default forbidden for "${el.type}"`);
189
- if (el.type === 'text' || el.type === 'textarea') {
190
- if (el.minLength != null && el.maxLength != null && el.minLength > el.maxLength) errors.push(`${here}: minLength > maxLength`);
191
- if (el.pattern != null) { try { new RegExp(el.pattern); } catch { errors.push(`${here}: invalid pattern regex`); } }
192
- }
193
- if (el.type === 'number') {
194
- if (typeof el.min === 'number' && typeof el.max === 'number' && el.min > el.max) errors.push(`${here}: min > max`);
195
- if (el.decimals != null && (!Number.isInteger(el.decimals) || el.decimals < 0 || el.decimals > 8)) errors.push(`${here}: decimals must be 0..8`);
196
- }
197
- if (el.type === 'select') {
198
- if (!Array.isArray(el.options) || el.options.length === 0) errors.push(`${here}: select.options must be non-empty array`);
199
- else {
200
- const values = new Set(el.options.map(o => o.value));
201
- if (el.default != null && !values.has(el.default)) errors.push(`${here}: default "${el.default}" not in options`);
202
- }
203
- }
204
- if (el.type === 'file') { if (el.maxSizeMB != null && el.maxSizeMB <= 0) errors.push(`${here}: maxSizeMB must be > 0`); }
205
- if (el.type === 'files') { if (el.minCount != null && el.maxCount != null && el.minCount > el.maxCount) errors.push(`${here}: minCount > maxCount`); }
206
- if (el.type === 'group') {
207
- if (!Array.isArray(el.elements)) errors.push(`${here}: group.elements must be array`);
208
- if (el.repeat) { if (el.repeat.min != null && el.repeat.max != null && el.repeat.min > el.repeat.max) errors.push(`${here}: repeat.min > repeat.max`); }
209
- if (Array.isArray(el.elements)) validateElements(el.elements, pathJoin(path, el.key));
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`);
210
519
  }
211
- });
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
+ });
212
575
  }
213
- if (Array.isArray(schema.elements)) validateElements(schema.elements, 'elements');
576
+ if (Array.isArray(schema.elements))
577
+ validateElements(schema.elements, "elements");
214
578
  return errors;
215
- }
579
+ }
216
580
 
217
- function clear(node){ while (node.firstChild) node.removeChild(node.firstChild); }
581
+ function clear(node) {
582
+ while (node.firstChild) node.removeChild(node.firstChild);
583
+ }
218
584
 
219
- function renderForm(schema, prefill){
585
+ function renderForm(schema, prefill) {
220
586
  state.schema = deepClone(schema);
221
587
  state.formRoot = el.formContainer;
222
588
  clear(state.formRoot);
223
- el.formErrors.textContent = '';
224
- const formEl = document.createElement('form');
225
- formEl.id = 'dynamicForm';
226
- formEl.addEventListener('submit', (e) => e.preventDefault());
227
-
228
- const ctx = { path: '', prefill: prefill || {} };
229
- schema.elements.forEach(element => {
230
- const block = renderElement(element, ctx);
231
- formEl.appendChild(block);
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);
232
598
  });
233
599
 
234
600
  state.formRoot.appendChild(formEl);
235
- }
236
-
237
- function renderElement(element, ctx){
238
- const wrapper = document.createElement('div');
239
- wrapper.className = 'field';
240
- const label = document.createElement('div');
241
- label.className = 'label';
242
- const title = document.createElement('span');
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");
243
609
  title.textContent = element.label || element.key;
244
610
  label.appendChild(title);
245
- if (element.required) { const req = document.createElement('span'); req.className = 'req'; req.textContent = '*'; label.appendChild(req); }
611
+ if (element.required) {
612
+ const req = document.createElement("span");
613
+ req.className = "req";
614
+ req.textContent = "*";
615
+ label.appendChild(req);
616
+ }
246
617
  wrapper.appendChild(label);
247
618
 
248
619
  const pathKey = pathJoin(ctx.path, element.key);
249
620
 
250
621
  switch (element.type) {
251
- case 'text': {
252
- const input = document.createElement('input'); input.type = 'text'; input.name = pathKey; input.dataset.type = 'text';
253
- setTextValueFromPrefill(input, element, ctx.prefill, element.key);
254
- input.addEventListener('input', () => markValidity(input, null));
255
- wrapper.appendChild(input); wrapper.appendChild(makeFieldHint(element));
256
- break;
257
- }
258
- case 'textarea': {
259
- const ta = document.createElement('textarea'); ta.name = pathKey; ta.rows = 4; ta.dataset.type = 'textarea';
260
- setTextValueFromPrefill(ta, element, ctx.prefill, element.key);
261
- ta.addEventListener('input', () => markValidity(ta, null));
262
- wrapper.appendChild(ta); wrapper.appendChild(makeFieldHint(element));
263
- break;
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);
264
673
  }
265
- case 'number': {
266
- const input = document.createElement('input'); input.type = 'number'; input.name = pathKey; input.dataset.type = 'number';
267
- if (element.step != null) input.step = String(element.step);
268
- setNumberFromPrefill(input, element, ctx.prefill, element.key);
269
- input.addEventListener('blur', () => {
270
- if (input.value === '') return;
271
- const v = parseFloat(input.value);
272
- if (Number.isFinite(v) && Number.isInteger(element.decimals ?? 0)) input.value = String(Number(v.toFixed(element.decimals)));
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,
273
707
  });
274
- input.addEventListener('input', () => markValidity(input, null));
275
- wrapper.appendChild(input); wrapper.appendChild(makeFieldHint(element, `decimals=${element.decimals ?? 0}`));
276
- break;
277
- }
278
- case 'select': {
279
- const sel = document.createElement('select'); sel.name = pathKey; sel.dataset.type = 'select';
280
- if (!element.required) { const opt = document.createElement('option'); opt.value = ''; opt.textContent = '—'; sel.appendChild(opt); }
281
- element.options.forEach(o => { const opt = document.createElement('option'); opt.value = String(o.value); opt.textContent = o.label ?? String(o.value); sel.appendChild(opt); });
282
- setSelectFromPrefill(sel, element, ctx.prefill, element.key);
283
- sel.addEventListener('input', () => markValidity(sel, null));
284
- wrapper.appendChild(sel);
285
- break;
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]);
286
717
  }
287
- case 'file': {
288
- const hid = document.createElement('input'); hid.type = 'hidden'; hid.name = pathKey; hid.dataset.type = 'file';
289
- const list = document.createElement('div'); list.className = 'list';
290
- const picker = document.createElement('input'); picker.type = 'file';
291
- picker.addEventListener('change', async () => {
292
- if (picker.files && picker.files[0]) {
293
- const file = picker.files[0]; const err = fileValidationError(element, file);
294
- if (err) { markValidity(picker, err); return; }
295
- const rid = await makeResourceIdFromFile(file);
296
- state.resourceIndex.set(rid, { name: file.name, type: file.type, size: file.size });
297
- hid.value = rid; renderResourcePills(list, [rid]); markValidity(picker, null);
298
- }
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);
299
760
  });
300
- const pv = ctx.prefill && ctx.prefill[element.key];
301
- if (typeof pv === 'string' && pv) { hid.value = pv; renderResourcePills(list, [pv]); }
302
- wrapper.appendChild(picker); wrapper.appendChild(list); wrapper.appendChild(hid);
303
- wrapper.appendChild(makeFieldHint(element, 'value = resourceId'));
304
- break;
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
+ });
305
774
  }
306
- case 'files': {
307
- const hid = document.createElement('input'); hid.type = 'hidden'; hid.name = pathKey; hid.dataset.type = 'files';
308
- const list = document.createElement('div'); list.className = 'list';
309
- const picker = document.createElement('input'); picker.type = 'file'; picker.multiple = true;
310
- picker.addEventListener('change', async () => {
311
- let arr = parseJSONSafe(hid.value, []); if (!Array.isArray(arr)) arr = [];
312
- if (picker.files && picker.files.length) {
313
- for (const file of picker.files) { const err = fileValidationError(element, file); if (err) { markValidity(picker, err); return; } }
314
- for (const file of picker.files) {
315
- const rid = await makeResourceIdFromFile(file);
316
- state.resourceIndex.set(rid, { name: file.name, type: file.type, size: file.size });
317
- arr.push(rid);
318
- }
319
- hid.value = JSON.stringify(arr);
320
- renderResourcePills(list, arr, (ridToRemove) => {
321
- const next = arr.filter(x => x !== ridToRemove); hid.value = JSON.stringify(next); arr = next;
322
- renderResourcePills(list, next, arguments.callee);
323
- });
324
- markValidity(picker, null);
325
- }
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();
326
835
  });
327
- const pv = ctx.prefill && ctx.prefill[element.key]; let initial = Array.isArray(pv) ? pv.filter(Boolean) : [];
328
- if (initial.length) {
329
- hid.value = JSON.stringify(initial);
330
- renderResourcePills(list, initial, (ridToRemove) => {
331
- const next = initial.filter(x => x !== ridToRemove); hid.value = JSON.stringify(next); initial = next;
332
- renderResourcePills(list, next, arguments.callee);
333
- });
334
- }
335
- wrapper.appendChild(picker); wrapper.appendChild(list); wrapper.appendChild(hid);
336
- wrapper.appendChild(makeFieldHint(element, 'value = resourceId[]'));
337
- break;
338
- }
339
- case 'group': {
340
- // МАРКЕРЫ: фикс для корректного поиска контейнера при submit
341
- wrapper.dataset.group = element.key; // где группа
342
- wrapper.dataset.groupPath = pathKey;
343
- const groupWrap = document.createElement('div');
344
- const header = document.createElement('div'); header.className = 'groupHeader';
345
- const left = document.createElement('div'); left.innerHTML = `<span>${element.label || element.key}</span>`;
346
- header.appendChild(left);
347
- const right = document.createElement('div'); groupWrap.appendChild(header);
348
- const itemsWrap = document.createElement('div');
349
- itemsWrap.dataset.itemsFor = element.key; // КОНТЕЙНЕР ЭЛЕМЕНТОВ ГРУППЫ (повторяемых или одиночных)
350
-
351
- if (element.repeat && isPlainObject(element.repeat)) {
352
- const min = element.repeat.min ?? 0; const max = element.repeat.max ?? Infinity;
353
- const pre = Array.isArray(ctx.prefill?.[element.key]) ? ctx.prefill[element.key] : null;
354
- const addBtn = document.createElement('button'); addBtn.type='button'; addBtn.className='btn'; addBtn.textContent='Add';
355
- right.appendChild(addBtn); header.appendChild(right);
356
-
357
- const countItems = () => itemsWrap.querySelectorAll(':scope > .groupItem').length;
358
- const refreshControls = () => {
359
- const n = countItems();
360
- addBtn.disabled = n >= max;
361
- left.innerHTML = `<span>${element.label || element.key}</span> <span class="muted small">[${n} / ${max === Infinity ? '∞' : max}, min=${min}]</span>`;
362
- };
363
-
364
- const addItem = (prefillObj) => {
365
- const item = document.createElement('div'); item.className = 'groupItem';
366
- const subCtx = { path: pathJoin(ctx.path, element.key + `[${countItems()}]`), prefill: prefillObj || {} };
367
- element.elements.forEach(child => item.appendChild(renderElement(child, subCtx)));
368
- const rem = document.createElement('button'); rem.type='button'; rem.className='btn bad small'; rem.textContent='Remove';
369
- rem.addEventListener('click', () => {
370
- if (countItems() <= (element.repeat.min ?? 0)) return;
371
- itemsWrap.removeChild(item); refreshControls();
372
- });
373
- item.appendChild(rem); itemsWrap.appendChild(item); refreshControls();
374
- };
375
-
376
- groupWrap.appendChild(itemsWrap);
377
- if (pre && pre.length) { const n = Math.min(max, Math.max(min, pre.length)); for (let i=0;i<n;i++) addItem(pre[i]); }
378
- else { const n = Math.max(min, 0); for (let i=0;i<n;i++) addItem(null); }
379
- addBtn.addEventListener('click', () => addItem(null));
380
- } else {
381
- // SINGLE OBJECT GROUP
382
- const subCtx = { path: pathJoin(ctx.path, element.key), prefill: ctx.prefill?.[element.key] || {} };
383
- element.elements.forEach(child => itemsWrap.appendChild(renderElement(child, subCtx)));
384
- groupWrap.appendChild(itemsWrap);
385
- }
836
+ item.appendChild(rem);
837
+ itemsWrap.appendChild(item);
838
+ refreshControls();
839
+ };
386
840
 
387
- wrapper.innerHTML = ''; wrapper.appendChild(groupWrap);
388
- break;
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);
389
860
  }
390
- default:
391
- wrapper.appendChild(document.createTextNode(`Unsupported type: ${element.type}`));
861
+
862
+ wrapper.innerHTML = "";
863
+ wrapper.appendChild(groupWrap);
864
+ break;
865
+ }
866
+ default:
867
+ wrapper.appendChild(
868
+ document.createTextNode(`Unsupported type: ${element.type}`),
869
+ );
392
870
  }
393
871
  return wrapper;
394
- }
395
-
396
- function makeFieldHint(element, extra=''){
397
- const hint = document.createElement('div'); hint.className = 'hint'; const bits = [];
398
- if (element.required) bits.push('required');
399
- if (element.type === 'text' || element.type === 'textarea') {
400
- if (element.minLength != null) bits.push(`minLength=${element.minLength}`);
401
- if (element.maxLength != null) bits.push(`maxLength=${element.maxLength}`);
402
- if (element.pattern) bits.push(`pattern=/${element.pattern}/`);
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}/`);
403
885
  }
404
- if (element.type === 'number') {
405
- if (element.min != null) bits.push(`min=${element.min}`);
406
- if (element.max != null) bits.push(`max=${element.max}`);
407
- if (element.decimals != null) bits.push(`decimals=${element.decimals}`);
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}`);
408
891
  }
409
- if (element.type === 'select') bits.push(`options=${element.options.length}`);
410
- if (element.type === 'files') {
411
- if (element.minCount != null) bits.push(`minCount=${element.minCount}`);
412
- if (element.maxCount != null) bits.push(`maxCount=${element.maxCount}`);
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}`);
413
899
  }
414
- hint.textContent = [bits.join(' · '), extra].filter(Boolean).join(' | ');
900
+ hint.textContent = [bits.join(" · "), extra]
901
+ .filter(Boolean)
902
+ .join(" | ");
415
903
  return hint;
416
- }
904
+ }
417
905
 
418
- function renderResourcePills(container, rids, onRemove){
906
+ function renderResourcePills(container, rids, onRemove) {
419
907
  clear(container);
420
- rids.forEach(rid => {
421
- const meta = state.resourceIndex.get(rid);
422
- const pill = document.createElement('span'); pill.className = 'pill'; pill.textContent = rid;
423
- if (meta) { const small = document.createElement('span'); small.className='muted'; small.textContent = ` (${meta.name ?? 'file'}, ${meta.size ?? '?'}B)`; pill.appendChild(small); }
424
- if (onRemove) { const x = document.createElement('button'); x.type='button'; x.className='btn bad small'; x.textContent='×'; x.style.padding='0 6px';
425
- x.addEventListener('click', () => onRemove(rid)); pill.appendChild(x); }
426
- container.appendChild(pill);
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);
427
929
  });
428
- }
429
-
430
- function markValidity(input, msg){
431
- const prev = input?.parentElement?.querySelector?.('.msg'); if (prev) prev.remove();
432
- if (input) input.classList.toggle('invalid', !!msg);
433
- if (msg && input?.parentElement) { const m = document.createElement('div'); m.className='msg'; m.textContent = msg; input.parentElement.appendChild(m); }
434
- }
435
- function parseJSONSafe(text, fb=null){ try { return JSON.parse(text); } catch { return fb; } }
436
- function setTextValueFromPrefill(input, element, prefillObj, key){
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) {
437
951
  let v = undefined;
438
- if (prefillObj && Object.prototype.hasOwnProperty.call(prefillObj, key)) v = prefillObj[key];
952
+ if (prefillObj && Object.prototype.hasOwnProperty.call(prefillObj, key))
953
+ v = prefillObj[key];
439
954
  else if (element.default !== undefined) v = element.default;
440
955
  if (v !== undefined) input.value = String(v);
441
- }
442
- function setNumberFromPrefill(input, element, prefillObj, key){
956
+ }
957
+ function setNumberFromPrefill(input, element, prefillObj, key) {
443
958
  let v = undefined;
444
- if (prefillObj && Object.prototype.hasOwnProperty.call(prefillObj, key)) v = prefillObj[key];
959
+ if (prefillObj && Object.prototype.hasOwnProperty.call(prefillObj, key))
960
+ v = prefillObj[key];
445
961
  else if (element.default !== undefined) v = element.default;
446
- if (v !== undefined && v !== null && v !== '') input.value = String(v);
447
- }
448
- function setSelectFromPrefill(select, element, prefillObj, key){
449
- const values = new Set(element.options.map(o => String(o.value)));
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)));
450
966
  let v = undefined;
451
- if (prefillObj && Object.prototype.hasOwnProperty.call(prefillObj, key)) v = prefillObj[key];
967
+ if (prefillObj && Object.prototype.hasOwnProperty.call(prefillObj, key))
968
+ v = prefillObj[key];
452
969
  else if (element.default !== undefined) v = element.default;
453
970
  if (v !== undefined && values.has(String(v))) select.value = String(v);
454
- else if (!element.required) select.value = '';
455
- }
971
+ else if (!element.required) select.value = "";
972
+ }
456
973
 
457
- function fileValidationError(element, file){
458
- if (!file) return 'no file';
459
- if (element.maxSizeMB != null && file.size > element.maxSizeMB * 1024 * 1024) return `file too large > ${element.maxSizeMB}MB`;
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`;
460
981
  if (element.accept) {
461
- const { extensions, mime } = element.accept;
462
- if (mime && Array.isArray(mime) && mime.length && !mime.includes(file.type)) return `mime not allowed: ${file.type}`;
463
- if (extensions && Array.isArray(extensions) && extensions.length) {
464
- const ext = (file.name.split('.').pop() || '').toLowerCase();
465
- if (!extensions.includes(ext)) return `extension .${ext} not allowed`;
466
- }
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
+ }
467
995
  }
468
996
  return null;
469
- }
997
+ }
470
998
 
471
- function collectAndValidate(schema){
472
- const form = document.getElementById('dynamicForm');
999
+ function collectAndValidate(schema) {
1000
+ const form = document.getElementById("dynamicForm");
473
1001
  const errors = [];
474
1002
 
475
- function collectElement(element, scopeRoot){
476
- const key = element.key;
477
- switch (element.type) {
478
- case 'text':
479
- case 'textarea': {
480
- const input = scopeRoot.querySelector(`[name$="${key}"]`);
481
- const val = (input?.value ?? '').trim();
482
- if (element.required && val === '') { errors.push(`${key}: required`); markValidity(input, 'required'); }
483
- else if (val !== '') {
484
- if (element.minLength != null && val.length < element.minLength) { errors.push(`${key}: minLength=${element.minLength}`); markValidity(input, `minLength=${element.minLength}`); }
485
- if (element.maxLength != null && val.length > element.maxLength) { errors.push(`${key}: maxLength=${element.maxLength}`); markValidity(input, `maxLength=${element.maxLength}`); }
486
- if (element.pattern) { try { const re = new RegExp(element.pattern); if (!re.test(val)) { errors.push(`${key}: pattern mismatch`); markValidity(input, 'pattern mismatch'); } }
487
- catch { errors.push(`${key}: invalid pattern`); markValidity(input, 'invalid pattern'); } }
488
- } else { markValidity(input, null); }
489
- return val;
490
- }
491
- case 'number': {
492
- const input = scopeRoot.querySelector(`[name$="${key}"]`);
493
- const raw = input?.value ?? '';
494
- if (element.required && raw === '') { errors.push(`${key}: required`); markValidity(input, 'required'); return null; }
495
- if (raw === '') { markValidity(input, null); return null; }
496
- const v = parseFloat(raw);
497
- if (!Number.isFinite(v)) { errors.push(`${key}: not a number`); markValidity(input, 'not a number'); return null; }
498
- if (element.min != null && v < element.min) { errors.push(`${key}: < min=${element.min}`); markValidity(input, `< min=${element.min}`); }
499
- if (element.max != null && v > element.max) { errors.push(`${key}: > max=${element.max}`); markValidity(input, `> max=${element.max}`); }
500
- const d = Number.isInteger(element.decimals ?? 0) ? element.decimals : 0; const r = Number(v.toFixed(d));
501
- input.value = String(r); markValidity(input, null); return r;
502
- }
503
- case 'select': {
504
- const sel = scopeRoot.querySelector(`select[name$="${key}"]`);
505
- const val = sel?.value ?? ''; const values = new Set(element.options.map(o => String(o.value)));
506
- if (element.required && val === '') { errors.push(`${key}: required`); markValidity(sel, 'required'); return ''; }
507
- if (val !== '' && !values.has(String(val))) { errors.push(`${key}: value not in options`); markValidity(sel, 'not in options'); }
508
- else { markValidity(sel, null); }
509
- return val === '' ? null : val;
510
- }
511
- case 'file': {
512
- const hid = scopeRoot.querySelector(`input[type="hidden"][name$="${key}"]`);
513
- const rid = hid?.value ?? '';
514
- if (element.required && !rid) { errors.push(`${key}: required (resourceId missing)`); const picker = hid?.previousElementSibling; if (picker) markValidity(picker, 'required'); }
515
- else { if (hid?.previousElementSibling) markValidity(hid.previousElementSibling, null); }
516
- return rid || null;
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}`);
517
1020
  }
518
- case 'files': {
519
- const hid = scopeRoot.querySelector(`input[type="hidden"][name$="${key}"]`);
520
- const arr = parseJSONSafe(hid?.value ?? '[]', []);
521
- const count = Array.isArray(arr) ? arr.length : 0;
522
- if (!Array.isArray(arr)) errors.push(`${key}: internal value corrupted`);
523
- if (element.minCount != null && count < element.minCount) errors.push(`${key}: < minCount=${element.minCount}`);
524
- if (element.maxCount != null && count > element.maxCount) errors.push(`${key}: > maxCount=${element.maxCount}`);
525
- if (hid?.previousElementSibling) markValidity(hid.previousElementSibling, null);
526
- return Array.isArray(arr) ? arr : [];
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}`);
527
1027
  }
528
- case 'group': {
529
- // КЛЮЧЕВОЙ ФИКС: собираем по реальному контейнеру группы,
530
- // а не по всему scopeRoot.
531
- const groupWrapper = scopeRoot.querySelector(`[data-group="${key}"]`);
532
- if (!groupWrapper) { errors.push(`${key}: internal group wrapper not found`); return element.repeat ? [] : {}; }
533
- const itemsWrap = groupWrapper.querySelector(`[data-items-for="${key}"]`);
534
- if (!itemsWrap) { errors.push(`${key}: internal items container not found`); return element.repeat ? [] : {}; }
535
-
536
- if (element.repeat && isPlainObject(element.repeat)) {
537
- const items = itemsWrap.querySelectorAll(':scope > .groupItem');
538
- const out = [];
539
- const n = items.length;
540
- const min = element.repeat.min ?? 0; const max = element.repeat.max ?? Infinity;
541
- if (n < min) errors.push(`${key}: count < min=${min}`);
542
- if (n > max) errors.push(`${key}: count > max=${max}`);
543
- items.forEach(item => {
544
- const obj = {};
545
- element.elements.forEach(child => { obj[child.key] = collectElement(child, item); });
546
- out.push(obj);
547
- });
548
- return out;
549
- } else {
550
- const obj = {};
551
- element.elements.forEach(child => { obj[child.key] = collectElement(child, itemsWrap); });
552
- return obj;
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");
553
1034
  }
1035
+ } catch {
1036
+ errors.push(`${key}: invalid pattern`);
1037
+ markValidity(input, "invalid pattern");
1038
+ }
554
1039
  }
555
- default:
556
- errors.push(`${key}: unsupported type ${element.type}`); return null;
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
+ }
557
1170
  }
1171
+ default:
1172
+ errors.push(`${key}: unsupported type ${element.type}`);
1173
+ return null;
1174
+ }
558
1175
  }
559
1176
 
560
1177
  const result = {};
561
- state.schema.elements.forEach(element => { result[element.key] = collectElement(element, form); });
1178
+ state.schema.elements.forEach((element) => {
1179
+ result[element.key] = collectElement(element, form);
1180
+ });
562
1181
  return { result, errors };
563
- }
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
+ });
564
1207
 
565
- el.applySchemaBtn.addEventListener('click', () => {
566
- el.schemaErrors.textContent = '';
1208
+ el.prettySchemaBtn.addEventListener("click", () => {
567
1209
  try {
568
- const parsed = JSON.parse(el.schemaInput.value);
569
- const errs = validateSchema(parsed);
570
- if (errs.length) { el.schemaErrors.textContent = errs.join('\n'); return; }
571
- renderForm(parsed, {});
572
- } catch(e) { el.schemaErrors.textContent = 'JSON parse error: ' + e.message; }
573
- });
574
-
575
- el.resetSchemaBtn.addEventListener('click', () => {
576
- el.schemaInput.value = pretty(EXAMPLE_SCHEMA); el.schemaErrors.textContent = '';
577
- renderForm(EXAMPLE_SCHEMA, {}); el.outputJson.value = ''; el.prefillInput.value = ''; el.prefillErrors.textContent = '';
578
- });
579
-
580
- el.prettySchemaBtn.addEventListener('click', () => {
581
- try { const parsed = JSON.parse(el.schemaInput.value); el.schemaInput.value = pretty(parsed); }
582
- catch(e){ el.schemaErrors.textContent = 'Prettify: JSON parse error: ' + e.message; }
583
- });
584
-
585
- el.downloadSchemaBtn.addEventListener('click', () => downloadFile('schema.json', el.schemaInput.value || pretty(EXAMPLE_SCHEMA)));
586
-
587
- el.submitBtn.addEventListener('click', () => {
588
- el.formErrors.textContent = '';
589
- if (!state.schema) { el.formErrors.textContent = 'Schema not applied'; return; }
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
+ }
590
1231
  const { result, errors } = collectAndValidate(state.schema);
591
- if (errors.length) { el.formErrors.textContent = errors.join('\n'); return; }
1232
+ if (errors.length) {
1233
+ el.formErrors.textContent = errors.join("\n");
1234
+ return;
1235
+ }
592
1236
  el.outputJson.value = pretty(result);
593
- });
1237
+ });
594
1238
 
595
- el.clearFormBtn.addEventListener('click', () => {
596
- if (!state.schema) return; renderForm(state.schema, {}); el.formErrors.textContent = '';
597
- });
1239
+ el.clearFormBtn.addEventListener("click", () => {
1240
+ if (!state.schema) return;
1241
+ renderForm(state.schema, {});
1242
+ el.formErrors.textContent = "";
1243
+ });
598
1244
 
599
- el.copyOutputBtn.addEventListener('click', async () => { try { await navigator.clipboard.writeText(el.outputJson.value || ''); } catch(_){} });
600
- el.downloadOutputBtn.addEventListener('click', () => downloadFile('data.json', el.outputJson.value || '{}'));
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
+ );
601
1253
 
602
- el.loadPrefillBtn.addEventListener('click', () => {
603
- el.prefillErrors.textContent = '';
604
- if (!state.schema) { el.prefillErrors.textContent = 'Schema not applied'; return; }
1254
+ el.loadPrefillBtn.addEventListener("click", () => {
1255
+ el.prefillErrors.textContent = "";
1256
+ if (!state.schema) {
1257
+ el.prefillErrors.textContent = "Schema not applied";
1258
+ return;
1259
+ }
605
1260
  try {
606
- const pre = JSON.parse(el.prefillInput.value || '{}');
607
- const allowed = new Set(state.schema.elements.map(e => e.key));
608
- const unknown = Object.keys(pre).filter(k => !allowed.has(k));
609
- if (unknown.length) warn('prefill unknown keys: ' + unknown.join(', '));
610
- renderForm(state.schema, pre);
611
- } catch(e) { el.prefillErrors.textContent = 'Prefill parse error: ' + e.message; }
612
- });
613
-
614
- el.copyTemplateBtn.addEventListener('click', () => {
615
- if (!state.schema) { el.prefillErrors.textContent = 'Apply schema first'; return; }
616
- const tpl = makePrefillTemplate(state.schema); el.prefillInput.value = pretty(tpl);
617
- });
618
-
619
- function makePrefillTemplate(schema){
620
- function walk(elements){
621
- const obj = {};
622
- for (const el of elements) {
623
- switch (el.type) {
624
- case 'text':
625
- case 'textarea':
626
- case 'select':
627
- case 'number': obj[el.key] = el.default ?? null; break;
628
- case 'file': obj[el.key] = null; break;
629
- case 'files': obj[el.key] = []; break;
630
- case 'group':
631
- if (el.repeat && isPlainObject(el.repeat)) {
632
- const sample = walk(el.elements);
633
- const n = Math.max(el.repeat.min ?? 0, 1);
634
- obj[el.key] = Array.from({length: n}, ()=> deepClone(sample));
635
- } else {
636
- obj[el.key] = walk(el.elements);
637
- }
638
- break;
639
- default: obj[el.key] = null;
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);
640
1307
  }
1308
+ break;
1309
+ default:
1310
+ obj[el.key] = null;
641
1311
  }
642
- return obj;
1312
+ }
1313
+ return obj;
643
1314
  }
644
1315
  return walk(schema.elements);
645
- }
1316
+ }
646
1317
 
647
- (function init(){ el.schemaInput.value = pretty(EXAMPLE_SCHEMA); renderForm(EXAMPLE_SCHEMA, {}); })();
1318
+ (function init() {
1319
+ el.schemaInput.value = pretty(EXAMPLE_SCHEMA);
1320
+ renderForm(EXAMPLE_SCHEMA, {});
1321
+ })();
648
1322
 
649
- /*
1323
+ /*
650
1324
  [TASK-01] Constraints Compiler: path-based overrides ("slides[].title") → merge → validate.
651
1325
  [TASK-02] Prod Upload: presigned PUT + register → resourceId; UI state: idle|uploading|uploaded|error; submit → only resourceId(s).
652
1326
  [TASK-03] Cross-field Rules: when/then → require/disable/show/hide; dynamic min/max.
@@ -658,6 +1332,6 @@
658
1332
  [TASK-09] Schema Registry: version bump + transforms, backend validation.
659
1333
  [TASK-10] UX Groups: DnD reorder, Duplicate item, строгие min/max.
660
1334
  */
661
- </script>
662
- </body>
663
- </html>
1335
+ </script>
1336
+ </body>
1337
+ </html>