@dmitryvim/form-builder 0.1.22 → 0.1.25
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 +13 -6
- package/dist/demo.js +488 -473
- package/dist/form-builder.js +1433 -1291
- package/dist/index.html +270 -142
- package/docs/13_form_builder.html +1217 -543
- package/docs/REQUIREMENTS.md +46 -14
- package/docs/integration.md +241 -206
- package/docs/schema.md +37 -31
- package/package.json +14 -2
|
@@ -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
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
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
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
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
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
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
|
-
|
|
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
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
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
|
-
|
|
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
|
-
|
|
245
|
+
</section>
|
|
70
246
|
|
|
71
|
-
|
|
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
|
-
|
|
76
|
-
|
|
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"
|
|
80
|
-
|
|
255
|
+
<div class="footer">
|
|
256
|
+
Файлы загружаются «раньше сабмита» — создаём
|
|
257
|
+
<span class="kbd">resourceId</span> локально (эмуляция).
|
|
258
|
+
</div>
|
|
259
|
+
</section>
|
|
81
260
|
|
|
82
|
-
|
|
261
|
+
<section class="card" id="outputCard">
|
|
83
262
|
<h2>3) Output JSON (submit result)</h2>
|
|
84
263
|
<div class="toolbar">
|
|
85
|
-
|
|
86
|
-
|
|
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"
|
|
90
|
-
|
|
270
|
+
<div class="footer">
|
|
271
|
+
Выходит строго по ключам <span class="kbd">key</span>. Порядок
|
|
272
|
+
соответствует схеме.
|
|
273
|
+
</div>
|
|
274
|
+
</section>
|
|
91
275
|
|
|
92
|
-
|
|
276
|
+
<section class="card" id="prefillCard">
|
|
93
277
|
<h2>4) Prefill JSON → заполнить форму</h2>
|
|
94
278
|
<div class="toolbar">
|
|
95
|
-
|
|
96
|
-
|
|
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
|
|
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"
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
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
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
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
|
-
|
|
469
|
+
function validateSchema(schema) {
|
|
177
470
|
const errors = [];
|
|
178
|
-
try {
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
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))
|
|
576
|
+
if (Array.isArray(schema.elements))
|
|
577
|
+
validateElements(schema.elements, "elements");
|
|
214
578
|
return errors;
|
|
215
|
-
|
|
579
|
+
}
|
|
216
580
|
|
|
217
|
-
|
|
581
|
+
function clear(node) {
|
|
582
|
+
while (node.firstChild) node.removeChild(node.firstChild);
|
|
583
|
+
}
|
|
218
584
|
|
|
219
|
-
|
|
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(
|
|
225
|
-
formEl.id =
|
|
226
|
-
formEl.addEventListener(
|
|
227
|
-
|
|
228
|
-
const ctx = { path:
|
|
229
|
-
schema.elements.forEach(element => {
|
|
230
|
-
|
|
231
|
-
|
|
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
|
-
|
|
238
|
-
const wrapper = document.createElement(
|
|
239
|
-
wrapper.className =
|
|
240
|
-
const label = document.createElement(
|
|
241
|
-
label.className =
|
|
242
|
-
const title = document.createElement(
|
|
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) {
|
|
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
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
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
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
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
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
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
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
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
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
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
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
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
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
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
|
-
|
|
388
|
-
|
|
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
|
-
|
|
391
|
-
|
|
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
|
-
|
|
397
|
-
const hint = document.createElement(
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
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 ===
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
|
|
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 ===
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
|
|
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(
|
|
900
|
+
hint.textContent = [bits.join(" · "), extra]
|
|
901
|
+
.filter(Boolean)
|
|
902
|
+
.join(" | ");
|
|
415
903
|
return hint;
|
|
416
|
-
|
|
904
|
+
}
|
|
417
905
|
|
|
418
|
-
|
|
906
|
+
function renderResourcePills(container, rids, onRemove) {
|
|
419
907
|
clear(container);
|
|
420
|
-
rids.forEach(rid => {
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
|
|
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
|
-
|
|
431
|
-
const prev = input?.parentElement?.querySelector?.(
|
|
432
|
-
if (
|
|
433
|
-
if (
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
|
|
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))
|
|
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
|
-
|
|
956
|
+
}
|
|
957
|
+
function setNumberFromPrefill(input, element, prefillObj, key) {
|
|
443
958
|
let v = undefined;
|
|
444
|
-
if (prefillObj && Object.prototype.hasOwnProperty.call(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 !==
|
|
447
|
-
|
|
448
|
-
|
|
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))
|
|
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
|
-
|
|
458
|
-
if (!file) return
|
|
459
|
-
if (
|
|
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
|
-
|
|
462
|
-
|
|
463
|
-
|
|
464
|
-
|
|
465
|
-
|
|
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
|
-
|
|
472
|
-
const form = document.getElementById(
|
|
999
|
+
function collectAndValidate(schema) {
|
|
1000
|
+
const form = document.getElementById("dynamicForm");
|
|
473
1001
|
const errors = [];
|
|
474
1002
|
|
|
475
|
-
function collectElement(element, scopeRoot){
|
|
476
|
-
|
|
477
|
-
|
|
478
|
-
|
|
479
|
-
|
|
480
|
-
|
|
481
|
-
|
|
482
|
-
|
|
483
|
-
|
|
484
|
-
|
|
485
|
-
|
|
486
|
-
|
|
487
|
-
|
|
488
|
-
|
|
489
|
-
|
|
490
|
-
|
|
491
|
-
|
|
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
|
-
|
|
519
|
-
|
|
520
|
-
|
|
521
|
-
|
|
522
|
-
|
|
523
|
-
|
|
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
|
-
|
|
529
|
-
|
|
530
|
-
|
|
531
|
-
|
|
532
|
-
|
|
533
|
-
|
|
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
|
-
|
|
556
|
-
|
|
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 => {
|
|
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
|
-
|
|
566
|
-
el.schemaErrors.textContent = '';
|
|
1208
|
+
el.prettySchemaBtn.addEventListener("click", () => {
|
|
567
1209
|
try {
|
|
568
|
-
|
|
569
|
-
|
|
570
|
-
|
|
571
|
-
|
|
572
|
-
|
|
573
|
-
|
|
574
|
-
|
|
575
|
-
|
|
576
|
-
|
|
577
|
-
|
|
578
|
-
|
|
579
|
-
|
|
580
|
-
|
|
581
|
-
|
|
582
|
-
|
|
583
|
-
|
|
584
|
-
|
|
585
|
-
|
|
586
|
-
|
|
587
|
-
|
|
588
|
-
|
|
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) {
|
|
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
|
-
|
|
596
|
-
if (!state.schema) return;
|
|
597
|
-
|
|
1239
|
+
el.clearFormBtn.addEventListener("click", () => {
|
|
1240
|
+
if (!state.schema) return;
|
|
1241
|
+
renderForm(state.schema, {});
|
|
1242
|
+
el.formErrors.textContent = "";
|
|
1243
|
+
});
|
|
598
1244
|
|
|
599
|
-
|
|
600
|
-
|
|
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
|
-
|
|
603
|
-
el.prefillErrors.textContent =
|
|
604
|
-
if (!state.schema) {
|
|
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
|
-
|
|
607
|
-
|
|
608
|
-
|
|
609
|
-
|
|
610
|
-
|
|
611
|
-
|
|
612
|
-
|
|
613
|
-
|
|
614
|
-
|
|
615
|
-
|
|
616
|
-
|
|
617
|
-
|
|
618
|
-
|
|
619
|
-
|
|
620
|
-
|
|
621
|
-
|
|
622
|
-
|
|
623
|
-
|
|
624
|
-
|
|
625
|
-
|
|
626
|
-
|
|
627
|
-
|
|
628
|
-
|
|
629
|
-
|
|
630
|
-
|
|
631
|
-
|
|
632
|
-
|
|
633
|
-
|
|
634
|
-
|
|
635
|
-
|
|
636
|
-
|
|
637
|
-
|
|
638
|
-
|
|
639
|
-
|
|
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
|
-
|
|
1312
|
+
}
|
|
1313
|
+
return obj;
|
|
643
1314
|
}
|
|
644
1315
|
return walk(schema.elements);
|
|
645
|
-
|
|
1316
|
+
}
|
|
646
1317
|
|
|
647
|
-
|
|
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>
|