@dmitryvim/form-builder 0.1.29 → 0.1.33
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 +30 -7
- package/dist/demo.js +140 -6
- package/dist/elements.html +702 -0
- package/dist/elements.js +450 -0
- package/dist/form-builder.js +1253 -110
- package/dist/index.html +3 -0
- package/package.json +2 -2
- package/dist/images/final_video.mp4 +0 -0
- package/dist/images/infographic_draft.jpg +0 -0
package/dist/form-builder.js
CHANGED
|
@@ -11,6 +11,8 @@ const state = {
|
|
|
11
11
|
downloadFile: null,
|
|
12
12
|
getThumbnail: null,
|
|
13
13
|
getDownloadUrl: null,
|
|
14
|
+
// Action handler
|
|
15
|
+
actionHandler: null,
|
|
14
16
|
// Default implementations
|
|
15
17
|
enableFilePreview: true,
|
|
16
18
|
maxPreviewSize: "200px",
|
|
@@ -146,6 +148,10 @@ function renderForm(schema, prefill) {
|
|
|
146
148
|
formEl.className = "space-y-6";
|
|
147
149
|
|
|
148
150
|
schema.elements.forEach((element, _index) => {
|
|
151
|
+
// Skip rendering hidden elements
|
|
152
|
+
if (element.hidden) {
|
|
153
|
+
return;
|
|
154
|
+
}
|
|
149
155
|
const block = renderElement(element, {
|
|
150
156
|
path: "",
|
|
151
157
|
prefill: prefill || {},
|
|
@@ -207,23 +213,44 @@ function renderElement(element, ctx) {
|
|
|
207
213
|
|
|
208
214
|
switch (element.type) {
|
|
209
215
|
case "text":
|
|
210
|
-
|
|
216
|
+
if (element.multiple) {
|
|
217
|
+
renderMultipleTextElement(element, ctx, wrapper, pathKey);
|
|
218
|
+
} else {
|
|
219
|
+
renderTextElement(element, ctx, wrapper, pathKey);
|
|
220
|
+
}
|
|
211
221
|
break;
|
|
212
222
|
|
|
213
223
|
case "textarea":
|
|
214
|
-
|
|
224
|
+
if (element.multiple) {
|
|
225
|
+
renderMultipleTextareaElement(element, ctx, wrapper, pathKey);
|
|
226
|
+
} else {
|
|
227
|
+
renderTextareaElement(element, ctx, wrapper, pathKey);
|
|
228
|
+
}
|
|
215
229
|
break;
|
|
216
230
|
|
|
217
231
|
case "number":
|
|
218
|
-
|
|
232
|
+
if (element.multiple) {
|
|
233
|
+
renderMultipleNumberElement(element, ctx, wrapper, pathKey);
|
|
234
|
+
} else {
|
|
235
|
+
renderNumberElement(element, ctx, wrapper, pathKey);
|
|
236
|
+
}
|
|
219
237
|
break;
|
|
220
238
|
|
|
221
239
|
case "select":
|
|
222
|
-
|
|
240
|
+
if (element.multiple) {
|
|
241
|
+
renderMultipleSelectElement(element, ctx, wrapper, pathKey);
|
|
242
|
+
} else {
|
|
243
|
+
renderSelectElement(element, ctx, wrapper, pathKey);
|
|
244
|
+
}
|
|
223
245
|
break;
|
|
224
246
|
|
|
225
247
|
case "file":
|
|
226
|
-
|
|
248
|
+
// Handle multiple files with file type using multiple property
|
|
249
|
+
if (element.multiple) {
|
|
250
|
+
renderMultipleFileElement(element, ctx, wrapper, pathKey);
|
|
251
|
+
} else {
|
|
252
|
+
renderFileElement(element, ctx, wrapper, pathKey);
|
|
253
|
+
}
|
|
227
254
|
break;
|
|
228
255
|
|
|
229
256
|
case "files":
|
|
@@ -234,6 +261,15 @@ function renderElement(element, ctx) {
|
|
|
234
261
|
renderGroupElement(element, ctx, wrapper, pathKey);
|
|
235
262
|
break;
|
|
236
263
|
|
|
264
|
+
case "container":
|
|
265
|
+
// Handle containers with multiple property like groups
|
|
266
|
+
if (element.multiple) {
|
|
267
|
+
renderMultipleContainerElement(element, ctx, wrapper, pathKey);
|
|
268
|
+
} else {
|
|
269
|
+
renderSingleContainerElement(element, ctx, wrapper, pathKey);
|
|
270
|
+
}
|
|
271
|
+
break;
|
|
272
|
+
|
|
237
273
|
default: {
|
|
238
274
|
const unsupported = document.createElement("div");
|
|
239
275
|
unsupported.className = "text-red-500 text-sm";
|
|
@@ -242,6 +278,43 @@ function renderElement(element, ctx) {
|
|
|
242
278
|
}
|
|
243
279
|
}
|
|
244
280
|
|
|
281
|
+
// Add action buttons in readonly mode
|
|
282
|
+
if (
|
|
283
|
+
state.config.readonly &&
|
|
284
|
+
element.actions &&
|
|
285
|
+
Array.isArray(element.actions) &&
|
|
286
|
+
element.actions.length > 0
|
|
287
|
+
) {
|
|
288
|
+
const actionsContainer = document.createElement("div");
|
|
289
|
+
actionsContainer.className = "mt-3 flex flex-wrap gap-2";
|
|
290
|
+
|
|
291
|
+
element.actions.forEach((action) => {
|
|
292
|
+
if (action.value && action.label) {
|
|
293
|
+
const actionBtn = document.createElement("button");
|
|
294
|
+
actionBtn.type = "button";
|
|
295
|
+
actionBtn.className =
|
|
296
|
+
"px-3 py-1.5 text-sm bg-blue-50 border border-blue-200 text-blue-700 rounded-lg hover:bg-blue-100 transition-colors";
|
|
297
|
+
actionBtn.textContent = action.label;
|
|
298
|
+
|
|
299
|
+
actionBtn.addEventListener("click", (e) => {
|
|
300
|
+
e.preventDefault();
|
|
301
|
+
e.stopPropagation();
|
|
302
|
+
|
|
303
|
+
if (
|
|
304
|
+
state.config.actionHandler &&
|
|
305
|
+
typeof state.config.actionHandler === "function"
|
|
306
|
+
) {
|
|
307
|
+
state.config.actionHandler(action.value);
|
|
308
|
+
}
|
|
309
|
+
});
|
|
310
|
+
|
|
311
|
+
actionsContainer.appendChild(actionBtn);
|
|
312
|
+
}
|
|
313
|
+
});
|
|
314
|
+
|
|
315
|
+
wrapper.appendChild(actionsContainer);
|
|
316
|
+
}
|
|
317
|
+
|
|
245
318
|
return wrapper;
|
|
246
319
|
}
|
|
247
320
|
|
|
@@ -541,8 +614,15 @@ function renderResourcePills(container, rids, onRemove) {
|
|
|
541
614
|
|
|
542
615
|
// Add click handler to each slot
|
|
543
616
|
slot.onclick = () => {
|
|
544
|
-
// Look for file input
|
|
545
|
-
|
|
617
|
+
// Look for file input - check parent containers that have space-y-2 class
|
|
618
|
+
let filesWrapper = container.parentElement;
|
|
619
|
+
while (filesWrapper && !filesWrapper.classList.contains('space-y-2')) {
|
|
620
|
+
filesWrapper = filesWrapper.parentElement;
|
|
621
|
+
}
|
|
622
|
+
// If no parent with space-y-2, container itself might be the wrapper
|
|
623
|
+
if (!filesWrapper && container.classList.contains('space-y-2')) {
|
|
624
|
+
filesWrapper = container;
|
|
625
|
+
}
|
|
546
626
|
const fileInput = filesWrapper?.querySelector('input[type="file"]');
|
|
547
627
|
if (fileInput) fileInput.click();
|
|
548
628
|
};
|
|
@@ -559,8 +639,15 @@ function renderResourcePills(container, rids, onRemove) {
|
|
|
559
639
|
uploadLink.textContent = t("uploadText");
|
|
560
640
|
uploadLink.onclick = (e) => {
|
|
561
641
|
e.stopPropagation();
|
|
562
|
-
// Look for file input
|
|
563
|
-
|
|
642
|
+
// Look for file input - check parent containers that have space-y-2 class
|
|
643
|
+
let filesWrapper = container.parentElement;
|
|
644
|
+
while (filesWrapper && !filesWrapper.classList.contains('space-y-2')) {
|
|
645
|
+
filesWrapper = filesWrapper.parentElement;
|
|
646
|
+
}
|
|
647
|
+
// If no parent with space-y-2, container itself might be the wrapper
|
|
648
|
+
if (!filesWrapper && container.classList.contains('space-y-2')) {
|
|
649
|
+
filesWrapper = container;
|
|
650
|
+
}
|
|
564
651
|
const fileInput = filesWrapper?.querySelector('input[type="file"]');
|
|
565
652
|
if (fileInput) fileInput.click();
|
|
566
653
|
};
|
|
@@ -719,8 +806,15 @@ function renderResourcePills(container, rids, onRemove) {
|
|
|
719
806
|
slot.innerHTML =
|
|
720
807
|
'<svg class="w-12 h-12 text-gray-400" fill="currentColor" viewBox="0 0 24 24"><path d="M21 19V5c0-1.1-.9-2-2-2H5c-1.1 0-2 .9-2 2v14c0 1.1.9 2 2 2h14c1.1 0 2-.9 2-2zM8.5 13.5l2.5 3.01L14.5 12l4.5 6H5l3.5-4.5z"/></svg>';
|
|
721
808
|
slot.onclick = () => {
|
|
722
|
-
// Look for file input
|
|
723
|
-
|
|
809
|
+
// Look for file input - check parent containers that have space-y-2 class
|
|
810
|
+
let filesWrapper = container.parentElement;
|
|
811
|
+
while (filesWrapper && !filesWrapper.classList.contains('space-y-2')) {
|
|
812
|
+
filesWrapper = filesWrapper.parentElement;
|
|
813
|
+
}
|
|
814
|
+
// If no parent with space-y-2, container itself might be the wrapper
|
|
815
|
+
if (!filesWrapper && container.classList.contains('space-y-2')) {
|
|
816
|
+
filesWrapper = container;
|
|
817
|
+
}
|
|
724
818
|
const fileInput = filesWrapper?.querySelector('input[type="file"]');
|
|
725
819
|
if (fileInput) fileInput.click();
|
|
726
820
|
};
|
|
@@ -940,94 +1034,277 @@ function validateForm(skipValidation = false) {
|
|
|
940
1034
|
switch (element.type) {
|
|
941
1035
|
case "text":
|
|
942
1036
|
case "textarea": {
|
|
943
|
-
|
|
944
|
-
|
|
945
|
-
|
|
946
|
-
|
|
947
|
-
|
|
948
|
-
|
|
949
|
-
|
|
950
|
-
|
|
951
|
-
|
|
952
|
-
|
|
953
|
-
|
|
1037
|
+
if (element.multiple) {
|
|
1038
|
+
// Handle multiple text/textarea fields
|
|
1039
|
+
const inputs = scopeRoot.querySelectorAll(`[name^="${key}["]`);
|
|
1040
|
+
const values = [];
|
|
1041
|
+
|
|
1042
|
+
inputs.forEach((input, index) => {
|
|
1043
|
+
const val = input?.value ?? "";
|
|
1044
|
+
values.push(val);
|
|
1045
|
+
|
|
1046
|
+
if (!skipValidation && val) {
|
|
1047
|
+
if (element.minLength !== null && val.length < element.minLength) {
|
|
1048
|
+
errors.push(`${key}[${index}]: minLength=${element.minLength}`);
|
|
1049
|
+
markValidity(input, `minLength=${element.minLength}`);
|
|
1050
|
+
}
|
|
1051
|
+
if (element.maxLength !== null && val.length > element.maxLength) {
|
|
1052
|
+
errors.push(`${key}[${index}]: maxLength=${element.maxLength}`);
|
|
1053
|
+
markValidity(input, `maxLength=${element.maxLength}`);
|
|
1054
|
+
}
|
|
1055
|
+
if (element.pattern) {
|
|
1056
|
+
try {
|
|
1057
|
+
const re = new RegExp(element.pattern);
|
|
1058
|
+
if (!re.test(val)) {
|
|
1059
|
+
errors.push(`${key}[${index}]: pattern mismatch`);
|
|
1060
|
+
markValidity(input, "pattern mismatch");
|
|
1061
|
+
}
|
|
1062
|
+
} catch {
|
|
1063
|
+
errors.push(`${key}[${index}]: invalid pattern`);
|
|
1064
|
+
markValidity(input, "invalid pattern");
|
|
1065
|
+
}
|
|
1066
|
+
}
|
|
1067
|
+
} else {
|
|
1068
|
+
markValidity(input, null);
|
|
1069
|
+
}
|
|
1070
|
+
});
|
|
1071
|
+
|
|
1072
|
+
// Validate minCount/maxCount constraints
|
|
1073
|
+
if (!skipValidation) {
|
|
1074
|
+
const minCount = element.minCount ?? 1;
|
|
1075
|
+
const maxCount = element.maxCount ?? 10;
|
|
1076
|
+
const nonEmptyValues = values.filter(v => v.trim() !== "");
|
|
1077
|
+
|
|
1078
|
+
if (element.required && nonEmptyValues.length === 0) {
|
|
1079
|
+
errors.push(`${key}: required`);
|
|
1080
|
+
}
|
|
1081
|
+
if (nonEmptyValues.length < minCount) {
|
|
1082
|
+
errors.push(`${key}: minimum ${minCount} items required`);
|
|
1083
|
+
}
|
|
1084
|
+
if (nonEmptyValues.length > maxCount) {
|
|
1085
|
+
errors.push(`${key}: maximum ${maxCount} items allowed`);
|
|
1086
|
+
}
|
|
954
1087
|
}
|
|
955
|
-
|
|
956
|
-
|
|
957
|
-
|
|
1088
|
+
|
|
1089
|
+
return values;
|
|
1090
|
+
} else {
|
|
1091
|
+
// Handle single text/textarea field
|
|
1092
|
+
const input = scopeRoot.querySelector(`[name$="${key}"]`);
|
|
1093
|
+
const val = input?.value ?? "";
|
|
1094
|
+
if (!skipValidation && element.required && val === "") {
|
|
1095
|
+
errors.push(`${key}: required`);
|
|
1096
|
+
markValidity(input, "required");
|
|
1097
|
+
return "";
|
|
958
1098
|
}
|
|
959
|
-
if (
|
|
960
|
-
|
|
961
|
-
|
|
962
|
-
|
|
963
|
-
|
|
964
|
-
|
|
1099
|
+
if (!skipValidation && val) {
|
|
1100
|
+
if (element.minLength !== null && val.length < element.minLength) {
|
|
1101
|
+
errors.push(`${key}: minLength=${element.minLength}`);
|
|
1102
|
+
markValidity(input, `minLength=${element.minLength}`);
|
|
1103
|
+
}
|
|
1104
|
+
if (element.maxLength !== null && val.length > element.maxLength) {
|
|
1105
|
+
errors.push(`${key}: maxLength=${element.maxLength}`);
|
|
1106
|
+
markValidity(input, `maxLength=${element.maxLength}`);
|
|
1107
|
+
}
|
|
1108
|
+
if (element.pattern) {
|
|
1109
|
+
try {
|
|
1110
|
+
const re = new RegExp(element.pattern);
|
|
1111
|
+
if (!re.test(val)) {
|
|
1112
|
+
errors.push(`${key}: pattern mismatch`);
|
|
1113
|
+
markValidity(input, "pattern mismatch");
|
|
1114
|
+
}
|
|
1115
|
+
} catch {
|
|
1116
|
+
errors.push(`${key}: invalid pattern`);
|
|
1117
|
+
markValidity(input, "invalid pattern");
|
|
965
1118
|
}
|
|
966
|
-
} catch {
|
|
967
|
-
errors.push(`${key}: invalid pattern`);
|
|
968
|
-
markValidity(input, "invalid pattern");
|
|
969
1119
|
}
|
|
1120
|
+
} else if (skipValidation) {
|
|
1121
|
+
markValidity(input, null);
|
|
1122
|
+
} else {
|
|
1123
|
+
markValidity(input, null);
|
|
970
1124
|
}
|
|
971
|
-
|
|
972
|
-
markValidity(input, null);
|
|
973
|
-
} else {
|
|
974
|
-
markValidity(input, null);
|
|
1125
|
+
return val;
|
|
975
1126
|
}
|
|
976
|
-
return val;
|
|
977
1127
|
}
|
|
978
1128
|
case "number": {
|
|
979
|
-
|
|
980
|
-
|
|
981
|
-
|
|
982
|
-
|
|
983
|
-
|
|
984
|
-
|
|
985
|
-
|
|
986
|
-
|
|
1129
|
+
if (element.multiple) {
|
|
1130
|
+
// Handle multiple number fields
|
|
1131
|
+
const inputs = scopeRoot.querySelectorAll(`[name^="${key}["]`);
|
|
1132
|
+
const values = [];
|
|
1133
|
+
|
|
1134
|
+
inputs.forEach((input, index) => {
|
|
1135
|
+
const raw = input?.value ?? "";
|
|
1136
|
+
if (raw === "") {
|
|
1137
|
+
values.push(null);
|
|
1138
|
+
markValidity(input, null);
|
|
1139
|
+
return;
|
|
1140
|
+
}
|
|
1141
|
+
|
|
1142
|
+
const v = parseFloat(raw);
|
|
1143
|
+
if (!skipValidation && !Number.isFinite(v)) {
|
|
1144
|
+
errors.push(`${key}[${index}]: not a number`);
|
|
1145
|
+
markValidity(input, "not a number");
|
|
1146
|
+
values.push(null);
|
|
1147
|
+
return;
|
|
1148
|
+
}
|
|
1149
|
+
|
|
1150
|
+
if (!skipValidation && element.min !== null && v < element.min) {
|
|
1151
|
+
errors.push(`${key}[${index}]: < min=${element.min}`);
|
|
1152
|
+
markValidity(input, `< min=${element.min}`);
|
|
1153
|
+
}
|
|
1154
|
+
if (!skipValidation && element.max !== null && v > element.max) {
|
|
1155
|
+
errors.push(`${key}[${index}]: > max=${element.max}`);
|
|
1156
|
+
markValidity(input, `> max=${element.max}`);
|
|
1157
|
+
}
|
|
1158
|
+
|
|
1159
|
+
const d = Number.isInteger(element.decimals ?? 0) ? element.decimals : 0;
|
|
1160
|
+
markValidity(input, null);
|
|
1161
|
+
values.push(Number(v.toFixed(d)));
|
|
1162
|
+
});
|
|
1163
|
+
|
|
1164
|
+
// Validate minCount/maxCount constraints
|
|
1165
|
+
if (!skipValidation) {
|
|
1166
|
+
const minCount = element.minCount ?? 1;
|
|
1167
|
+
const maxCount = element.maxCount ?? 10;
|
|
1168
|
+
const nonNullValues = values.filter(v => v !== null);
|
|
1169
|
+
|
|
1170
|
+
if (element.required && nonNullValues.length === 0) {
|
|
1171
|
+
errors.push(`${key}: required`);
|
|
1172
|
+
}
|
|
1173
|
+
if (nonNullValues.length < minCount) {
|
|
1174
|
+
errors.push(`${key}: minimum ${minCount} items required`);
|
|
1175
|
+
}
|
|
1176
|
+
if (nonNullValues.length > maxCount) {
|
|
1177
|
+
errors.push(`${key}: maximum ${maxCount} items allowed`);
|
|
1178
|
+
}
|
|
1179
|
+
}
|
|
1180
|
+
|
|
1181
|
+
return values;
|
|
1182
|
+
} else {
|
|
1183
|
+
// Handle single number field
|
|
1184
|
+
const input = scopeRoot.querySelector(`[name$="${key}"]`);
|
|
1185
|
+
const raw = input?.value ?? "";
|
|
1186
|
+
if (!skipValidation && element.required && raw === "") {
|
|
1187
|
+
errors.push(`${key}: required`);
|
|
1188
|
+
markValidity(input, "required");
|
|
1189
|
+
return null;
|
|
1190
|
+
}
|
|
1191
|
+
if (raw === "") {
|
|
1192
|
+
markValidity(input, null);
|
|
1193
|
+
return null;
|
|
1194
|
+
}
|
|
1195
|
+
const v = parseFloat(raw);
|
|
1196
|
+
if (!skipValidation && !Number.isFinite(v)) {
|
|
1197
|
+
errors.push(`${key}: not a number`);
|
|
1198
|
+
markValidity(input, "not a number");
|
|
1199
|
+
return null;
|
|
1200
|
+
}
|
|
1201
|
+
if (!skipValidation && element.min !== null && v < element.min) {
|
|
1202
|
+
errors.push(`${key}: < min=${element.min}`);
|
|
1203
|
+
markValidity(input, `< min=${element.min}`);
|
|
1204
|
+
}
|
|
1205
|
+
if (!skipValidation && element.max !== null && v > element.max) {
|
|
1206
|
+
errors.push(`${key}: > max=${element.max}`);
|
|
1207
|
+
markValidity(input, `> max=${element.max}`);
|
|
1208
|
+
}
|
|
1209
|
+
const d = Number.isInteger(element.decimals ?? 0)
|
|
1210
|
+
? element.decimals
|
|
1211
|
+
: 0;
|
|
987
1212
|
markValidity(input, null);
|
|
988
|
-
return
|
|
989
|
-
}
|
|
990
|
-
const v = parseFloat(raw);
|
|
991
|
-
if (!skipValidation && !Number.isFinite(v)) {
|
|
992
|
-
errors.push(`${key}: not a number`);
|
|
993
|
-
markValidity(input, "not a number");
|
|
994
|
-
return null;
|
|
995
|
-
}
|
|
996
|
-
if (!skipValidation && element.min !== null && v < element.min) {
|
|
997
|
-
errors.push(`${key}: < min=${element.min}`);
|
|
998
|
-
markValidity(input, `< min=${element.min}`);
|
|
1213
|
+
return Number(v.toFixed(d));
|
|
999
1214
|
}
|
|
1000
|
-
if (!skipValidation && element.max !== null && v > element.max) {
|
|
1001
|
-
errors.push(`${key}: > max=${element.max}`);
|
|
1002
|
-
markValidity(input, `> max=${element.max}`);
|
|
1003
|
-
}
|
|
1004
|
-
const d = Number.isInteger(element.decimals ?? 0)
|
|
1005
|
-
? element.decimals
|
|
1006
|
-
: 0;
|
|
1007
|
-
markValidity(input, null);
|
|
1008
|
-
return Number(v.toFixed(d));
|
|
1009
1215
|
}
|
|
1010
1216
|
case "select": {
|
|
1011
|
-
|
|
1012
|
-
|
|
1013
|
-
|
|
1014
|
-
|
|
1015
|
-
|
|
1016
|
-
|
|
1217
|
+
if (element.multiple) {
|
|
1218
|
+
// Handle multiple select fields
|
|
1219
|
+
const inputs = scopeRoot.querySelectorAll(`[name^="${key}["]`);
|
|
1220
|
+
const values = [];
|
|
1221
|
+
|
|
1222
|
+
inputs.forEach((input) => {
|
|
1223
|
+
const val = input?.value ?? "";
|
|
1224
|
+
values.push(val);
|
|
1225
|
+
markValidity(input, null);
|
|
1226
|
+
});
|
|
1227
|
+
|
|
1228
|
+
// Validate minCount/maxCount constraints
|
|
1229
|
+
if (!skipValidation) {
|
|
1230
|
+
const minCount = element.minCount ?? 1;
|
|
1231
|
+
const maxCount = element.maxCount ?? 10;
|
|
1232
|
+
const nonEmptyValues = values.filter(v => v !== "");
|
|
1233
|
+
|
|
1234
|
+
if (element.required && nonEmptyValues.length === 0) {
|
|
1235
|
+
errors.push(`${key}: required`);
|
|
1236
|
+
}
|
|
1237
|
+
if (nonEmptyValues.length < minCount) {
|
|
1238
|
+
errors.push(`${key}: minimum ${minCount} items required`);
|
|
1239
|
+
}
|
|
1240
|
+
if (nonEmptyValues.length > maxCount) {
|
|
1241
|
+
errors.push(`${key}: maximum ${maxCount} items allowed`);
|
|
1242
|
+
}
|
|
1243
|
+
}
|
|
1244
|
+
|
|
1245
|
+
return values;
|
|
1246
|
+
} else {
|
|
1247
|
+
// Handle single select field
|
|
1248
|
+
const input = scopeRoot.querySelector(`[name$="${key}"]`);
|
|
1249
|
+
const val = input?.value ?? "";
|
|
1250
|
+
if (!skipValidation && element.required && val === "") {
|
|
1251
|
+
errors.push(`${key}: required`);
|
|
1252
|
+
markValidity(input, "required");
|
|
1253
|
+
return "";
|
|
1254
|
+
}
|
|
1255
|
+
markValidity(input, null);
|
|
1256
|
+
return val;
|
|
1017
1257
|
}
|
|
1018
|
-
markValidity(input, null);
|
|
1019
|
-
return val;
|
|
1020
1258
|
}
|
|
1021
1259
|
case "file": {
|
|
1022
|
-
|
|
1023
|
-
|
|
1024
|
-
|
|
1025
|
-
|
|
1026
|
-
|
|
1027
|
-
|
|
1028
|
-
|
|
1260
|
+
if (element.multiple) {
|
|
1261
|
+
// Handle file with multiple property like files type
|
|
1262
|
+
// Find the files list by locating the specific file input for this field
|
|
1263
|
+
const fullKey = pathJoin(ctx.path, key);
|
|
1264
|
+
const pickerInput = scopeRoot.querySelector(`input[type="file"][name="${fullKey}"]`);
|
|
1265
|
+
const filesWrapper = pickerInput?.closest('.space-y-2');
|
|
1266
|
+
const container = filesWrapper?.querySelector('.files-list') || null;
|
|
1267
|
+
|
|
1268
|
+
const resourceIds = [];
|
|
1269
|
+
if (container) {
|
|
1270
|
+
const pills = container.querySelectorAll(".resource-pill");
|
|
1271
|
+
pills.forEach((pill) => {
|
|
1272
|
+
const resourceId = pill.dataset.resourceId;
|
|
1273
|
+
if (resourceId) {
|
|
1274
|
+
resourceIds.push(resourceId);
|
|
1275
|
+
}
|
|
1276
|
+
});
|
|
1277
|
+
}
|
|
1278
|
+
|
|
1279
|
+
// Validate minCount/maxCount constraints
|
|
1280
|
+
if (!skipValidation) {
|
|
1281
|
+
const minFiles = element.minCount ?? 0;
|
|
1282
|
+
const maxFiles = element.maxCount ?? Infinity;
|
|
1283
|
+
|
|
1284
|
+
if (element.required && resourceIds.length === 0) {
|
|
1285
|
+
errors.push(`${key}: required`);
|
|
1286
|
+
}
|
|
1287
|
+
if (resourceIds.length < minFiles) {
|
|
1288
|
+
errors.push(`${key}: minimum ${minFiles} files required`);
|
|
1289
|
+
}
|
|
1290
|
+
if (resourceIds.length > maxFiles) {
|
|
1291
|
+
errors.push(`${key}: maximum ${maxFiles} files allowed`);
|
|
1292
|
+
}
|
|
1293
|
+
}
|
|
1294
|
+
|
|
1295
|
+
return resourceIds;
|
|
1296
|
+
} else {
|
|
1297
|
+
// Handle single file
|
|
1298
|
+
const input = scopeRoot.querySelector(
|
|
1299
|
+
`input[name$="${key}"][type="hidden"]`,
|
|
1300
|
+
);
|
|
1301
|
+
const rid = input?.value ?? "";
|
|
1302
|
+
if (!skipValidation && element.required && rid === "") {
|
|
1303
|
+
errors.push(`${key}: required`);
|
|
1304
|
+
return null;
|
|
1305
|
+
}
|
|
1306
|
+
return rid || null;
|
|
1029
1307
|
}
|
|
1030
|
-
return rid || null;
|
|
1031
1308
|
}
|
|
1032
1309
|
case "files": {
|
|
1033
1310
|
// For files, we need to collect all resource IDs
|
|
@@ -1071,6 +1348,69 @@ function validateForm(skipValidation = false) {
|
|
|
1071
1348
|
}
|
|
1072
1349
|
case "group": {
|
|
1073
1350
|
if (element.repeat && isPlainObject(element.repeat)) {
|
|
1351
|
+
const items = [];
|
|
1352
|
+
// Use full path for nested group element search
|
|
1353
|
+
const fullKey = pathJoin(ctx.path, key);
|
|
1354
|
+
const itemElements = scopeRoot.querySelectorAll(`[name^="${fullKey}["]`);
|
|
1355
|
+
|
|
1356
|
+
// Extract actual indices from DOM element names instead of assuming sequential numbering
|
|
1357
|
+
const actualIndices = new Set();
|
|
1358
|
+
itemElements.forEach((el) => {
|
|
1359
|
+
const match = el.name.match(new RegExp(`^${fullKey.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}\\[(\\d+)\\]`));
|
|
1360
|
+
if (match) {
|
|
1361
|
+
actualIndices.add(parseInt(match[1]));
|
|
1362
|
+
}
|
|
1363
|
+
});
|
|
1364
|
+
|
|
1365
|
+
const sortedIndices = Array.from(actualIndices).sort((a, b) => a - b);
|
|
1366
|
+
|
|
1367
|
+
sortedIndices.forEach((actualIndex) => {
|
|
1368
|
+
const itemData = {};
|
|
1369
|
+
// Find the specific group item container for scoped queries - use full path
|
|
1370
|
+
const fullItemPath = `${fullKey}[${actualIndex}]`;
|
|
1371
|
+
const itemContainer =
|
|
1372
|
+
scopeRoot.querySelector(`[data-group-item="${fullItemPath}"]`) ||
|
|
1373
|
+
scopeRoot;
|
|
1374
|
+
element.elements.forEach((child) => {
|
|
1375
|
+
if (child.hidden) {
|
|
1376
|
+
// For hidden child elements, use their default value
|
|
1377
|
+
itemData[child.key] = child.default !== undefined ? child.default : "";
|
|
1378
|
+
} else {
|
|
1379
|
+
const childKey = `${fullKey}[${actualIndex}].${child.key}`;
|
|
1380
|
+
itemData[child.key] = validateElement(
|
|
1381
|
+
{ ...child, key: childKey },
|
|
1382
|
+
ctx,
|
|
1383
|
+
itemContainer,
|
|
1384
|
+
);
|
|
1385
|
+
}
|
|
1386
|
+
});
|
|
1387
|
+
items.push(itemData);
|
|
1388
|
+
});
|
|
1389
|
+
return items;
|
|
1390
|
+
} else {
|
|
1391
|
+
const groupData = {};
|
|
1392
|
+
// Find the specific group container for scoped queries
|
|
1393
|
+
const groupContainer =
|
|
1394
|
+
scopeRoot.querySelector(`[data-group="${key}"]`) || scopeRoot;
|
|
1395
|
+
element.elements.forEach((child) => {
|
|
1396
|
+
if (child.hidden) {
|
|
1397
|
+
// For hidden child elements, use their default value
|
|
1398
|
+
groupData[child.key] = child.default !== undefined ? child.default : "";
|
|
1399
|
+
} else {
|
|
1400
|
+
const childKey = `${key}.${child.key}`;
|
|
1401
|
+
groupData[child.key] = validateElement(
|
|
1402
|
+
{ ...child, key: childKey },
|
|
1403
|
+
ctx,
|
|
1404
|
+
groupContainer,
|
|
1405
|
+
);
|
|
1406
|
+
}
|
|
1407
|
+
});
|
|
1408
|
+
return groupData;
|
|
1409
|
+
}
|
|
1410
|
+
}
|
|
1411
|
+
case "container": {
|
|
1412
|
+
if (element.multiple) {
|
|
1413
|
+
// Handle multiple containers like repeating groups
|
|
1074
1414
|
const items = [];
|
|
1075
1415
|
const itemElements = scopeRoot.querySelectorAll(`[name^="${key}["]`);
|
|
1076
1416
|
const itemCount = Math.max(
|
|
@@ -1080,35 +1420,62 @@ function validateForm(skipValidation = false) {
|
|
|
1080
1420
|
|
|
1081
1421
|
for (let i = 0; i < itemCount; i++) {
|
|
1082
1422
|
const itemData = {};
|
|
1083
|
-
// Find the specific
|
|
1423
|
+
// Find the specific container item container for scoped queries
|
|
1084
1424
|
const itemContainer =
|
|
1085
|
-
scopeRoot.querySelector(`[data-
|
|
1425
|
+
scopeRoot.querySelector(`[data-container-item="${key}[${i}]"]`) ||
|
|
1086
1426
|
scopeRoot;
|
|
1087
1427
|
element.elements.forEach((child) => {
|
|
1088
|
-
|
|
1089
|
-
|
|
1090
|
-
|
|
1091
|
-
|
|
1092
|
-
|
|
1093
|
-
|
|
1428
|
+
if (child.hidden) {
|
|
1429
|
+
// For hidden child elements, use their default value
|
|
1430
|
+
itemData[child.key] = child.default !== undefined ? child.default : "";
|
|
1431
|
+
} else {
|
|
1432
|
+
const childKey = `${key}[${i}].${child.key}`;
|
|
1433
|
+
itemData[child.key] = validateElement(
|
|
1434
|
+
{ ...child, key: childKey },
|
|
1435
|
+
ctx,
|
|
1436
|
+
itemContainer,
|
|
1437
|
+
);
|
|
1438
|
+
}
|
|
1094
1439
|
});
|
|
1095
1440
|
items.push(itemData);
|
|
1096
1441
|
}
|
|
1442
|
+
|
|
1443
|
+
// Validate minCount/maxCount constraints
|
|
1444
|
+
if (!skipValidation) {
|
|
1445
|
+
const minItems = element.minCount ?? 0;
|
|
1446
|
+
const maxItems = element.maxCount ?? Infinity;
|
|
1447
|
+
|
|
1448
|
+
if (element.required && items.length === 0) {
|
|
1449
|
+
errors.push(`${key}: required`);
|
|
1450
|
+
}
|
|
1451
|
+
if (items.length < minItems) {
|
|
1452
|
+
errors.push(`${key}: minimum ${minItems} items required`);
|
|
1453
|
+
}
|
|
1454
|
+
if (items.length > maxItems) {
|
|
1455
|
+
errors.push(`${key}: maximum ${maxItems} items allowed`);
|
|
1456
|
+
}
|
|
1457
|
+
}
|
|
1458
|
+
|
|
1097
1459
|
return items;
|
|
1098
1460
|
} else {
|
|
1099
|
-
const
|
|
1100
|
-
// Find the specific
|
|
1101
|
-
const
|
|
1102
|
-
scopeRoot.querySelector(`[data-
|
|
1461
|
+
const containerData = {};
|
|
1462
|
+
// Find the specific container container for scoped queries
|
|
1463
|
+
const containerContainer =
|
|
1464
|
+
scopeRoot.querySelector(`[data-container="${key}"]`) || scopeRoot;
|
|
1103
1465
|
element.elements.forEach((child) => {
|
|
1104
|
-
|
|
1105
|
-
|
|
1106
|
-
|
|
1107
|
-
|
|
1108
|
-
|
|
1109
|
-
|
|
1466
|
+
if (child.hidden) {
|
|
1467
|
+
// For hidden child elements, use their default value
|
|
1468
|
+
containerData[child.key] = child.default !== undefined ? child.default : "";
|
|
1469
|
+
} else {
|
|
1470
|
+
const childKey = `${key}.${child.key}`;
|
|
1471
|
+
containerData[child.key] = validateElement(
|
|
1472
|
+
{ ...child, key: childKey },
|
|
1473
|
+
ctx,
|
|
1474
|
+
containerContainer,
|
|
1475
|
+
);
|
|
1476
|
+
}
|
|
1110
1477
|
});
|
|
1111
|
-
return
|
|
1478
|
+
return containerData;
|
|
1112
1479
|
}
|
|
1113
1480
|
}
|
|
1114
1481
|
default:
|
|
@@ -1117,7 +1484,12 @@ function validateForm(skipValidation = false) {
|
|
|
1117
1484
|
}
|
|
1118
1485
|
|
|
1119
1486
|
state.schema.elements.forEach((element) => {
|
|
1120
|
-
|
|
1487
|
+
// Handle hidden elements - use their default value instead of reading from DOM
|
|
1488
|
+
if (element.hidden) {
|
|
1489
|
+
data[element.key] = element.default !== undefined ? element.default : "";
|
|
1490
|
+
} else {
|
|
1491
|
+
data[element.key] = validateElement(element, { path: "" });
|
|
1492
|
+
}
|
|
1121
1493
|
});
|
|
1122
1494
|
|
|
1123
1495
|
return {
|
|
@@ -1146,6 +1518,118 @@ function renderTextElement(element, ctx, wrapper, pathKey) {
|
|
|
1146
1518
|
wrapper.appendChild(textHint);
|
|
1147
1519
|
}
|
|
1148
1520
|
|
|
1521
|
+
function renderMultipleTextElement(element, ctx, wrapper, pathKey) {
|
|
1522
|
+
const prefillValues = ctx.prefill[element.key] || [];
|
|
1523
|
+
const values = Array.isArray(prefillValues) ? [...prefillValues] : [];
|
|
1524
|
+
|
|
1525
|
+
// Ensure minimum count
|
|
1526
|
+
const minCount = element.minCount ?? 1;
|
|
1527
|
+
const maxCount = element.maxCount ?? 10;
|
|
1528
|
+
|
|
1529
|
+
while (values.length < minCount) {
|
|
1530
|
+
values.push(element.default || "");
|
|
1531
|
+
}
|
|
1532
|
+
|
|
1533
|
+
const container = document.createElement("div");
|
|
1534
|
+
container.className = "space-y-2";
|
|
1535
|
+
wrapper.appendChild(container);
|
|
1536
|
+
|
|
1537
|
+
function updateIndices() {
|
|
1538
|
+
const items = container.querySelectorAll(".multiple-text-item");
|
|
1539
|
+
items.forEach((item, index) => {
|
|
1540
|
+
const input = item.querySelector("input");
|
|
1541
|
+
if (input) {
|
|
1542
|
+
input.name = `${pathKey}[${index}]`;
|
|
1543
|
+
}
|
|
1544
|
+
});
|
|
1545
|
+
}
|
|
1546
|
+
|
|
1547
|
+
function addTextItem(value = "", index = -1) {
|
|
1548
|
+
const itemWrapper = document.createElement("div");
|
|
1549
|
+
itemWrapper.className = "multiple-text-item flex items-center gap-2";
|
|
1550
|
+
|
|
1551
|
+
const textInput = document.createElement("input");
|
|
1552
|
+
textInput.type = "text";
|
|
1553
|
+
textInput.className = "flex-1 px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500";
|
|
1554
|
+
textInput.placeholder = element.placeholder || "Enter text";
|
|
1555
|
+
textInput.value = value;
|
|
1556
|
+
textInput.readOnly = state.config.readonly;
|
|
1557
|
+
|
|
1558
|
+
itemWrapper.appendChild(textInput);
|
|
1559
|
+
|
|
1560
|
+
// Remove buttons are managed centrally via updateRemoveButtons()
|
|
1561
|
+
|
|
1562
|
+
if (index === -1) {
|
|
1563
|
+
container.appendChild(itemWrapper);
|
|
1564
|
+
} else {
|
|
1565
|
+
container.insertBefore(itemWrapper, container.children[index]);
|
|
1566
|
+
}
|
|
1567
|
+
|
|
1568
|
+
updateIndices();
|
|
1569
|
+
return itemWrapper;
|
|
1570
|
+
}
|
|
1571
|
+
|
|
1572
|
+
function updateRemoveButtons() {
|
|
1573
|
+
if (state.config.readonly) return;
|
|
1574
|
+
const items = container.querySelectorAll('.multiple-text-item');
|
|
1575
|
+
const currentCount = items.length;
|
|
1576
|
+
items.forEach((item) => {
|
|
1577
|
+
let removeBtn = item.querySelector('.remove-item-btn');
|
|
1578
|
+
if (!removeBtn) {
|
|
1579
|
+
removeBtn = document.createElement('button');
|
|
1580
|
+
removeBtn.type = 'button';
|
|
1581
|
+
removeBtn.className = 'remove-item-btn px-2 py-1 text-red-600 hover:bg-red-50 rounded';
|
|
1582
|
+
removeBtn.innerHTML = '✕';
|
|
1583
|
+
removeBtn.onclick = () => {
|
|
1584
|
+
const currentIndex = Array.from(container.children).indexOf(item);
|
|
1585
|
+
if (container.children.length > minCount) {
|
|
1586
|
+
values.splice(currentIndex, 1);
|
|
1587
|
+
item.remove();
|
|
1588
|
+
updateIndices();
|
|
1589
|
+
updateAddButton();
|
|
1590
|
+
updateRemoveButtons();
|
|
1591
|
+
}
|
|
1592
|
+
};
|
|
1593
|
+
item.appendChild(removeBtn);
|
|
1594
|
+
}
|
|
1595
|
+
const disabled = currentCount <= minCount;
|
|
1596
|
+
removeBtn.disabled = disabled;
|
|
1597
|
+
removeBtn.style.opacity = disabled ? '0.5' : '1';
|
|
1598
|
+
removeBtn.style.pointerEvents = disabled ? 'none' : 'auto';
|
|
1599
|
+
});
|
|
1600
|
+
}
|
|
1601
|
+
|
|
1602
|
+
function updateAddButton() {
|
|
1603
|
+
const existingAddBtn = wrapper.querySelector(".add-text-btn");
|
|
1604
|
+
if (existingAddBtn) existingAddBtn.remove();
|
|
1605
|
+
|
|
1606
|
+
if (!state.config.readonly && values.length < maxCount) {
|
|
1607
|
+
const addBtn = document.createElement("button");
|
|
1608
|
+
addBtn.type = "button";
|
|
1609
|
+
addBtn.className = "add-text-btn mt-2 px-3 py-1 text-blue-600 border border-blue-300 rounded hover:bg-blue-50 text-sm";
|
|
1610
|
+
addBtn.textContent = `+ Add ${element.label || 'Text'}`;
|
|
1611
|
+
addBtn.onclick = () => {
|
|
1612
|
+
values.push(element.default || "");
|
|
1613
|
+
addTextItem(element.default || "");
|
|
1614
|
+
updateAddButton();
|
|
1615
|
+
updateRemoveButtons();
|
|
1616
|
+
};
|
|
1617
|
+
wrapper.appendChild(addBtn);
|
|
1618
|
+
}
|
|
1619
|
+
}
|
|
1620
|
+
|
|
1621
|
+
// Render initial items
|
|
1622
|
+
values.forEach(value => addTextItem(value));
|
|
1623
|
+
updateAddButton();
|
|
1624
|
+
updateRemoveButtons();
|
|
1625
|
+
|
|
1626
|
+
// Add hint
|
|
1627
|
+
const hint = document.createElement("p");
|
|
1628
|
+
hint.className = "text-xs text-gray-500 mt-1";
|
|
1629
|
+
hint.textContent = makeFieldHint(element);
|
|
1630
|
+
wrapper.appendChild(hint);
|
|
1631
|
+
}
|
|
1632
|
+
|
|
1149
1633
|
function renderTextareaElement(element, ctx, wrapper, pathKey) {
|
|
1150
1634
|
const textareaInput = document.createElement("textarea");
|
|
1151
1635
|
textareaInput.className =
|
|
@@ -1164,6 +1648,118 @@ function renderTextareaElement(element, ctx, wrapper, pathKey) {
|
|
|
1164
1648
|
wrapper.appendChild(textareaHint);
|
|
1165
1649
|
}
|
|
1166
1650
|
|
|
1651
|
+
function renderMultipleTextareaElement(element, ctx, wrapper, pathKey) {
|
|
1652
|
+
const prefillValues = ctx.prefill[element.key] || [];
|
|
1653
|
+
const values = Array.isArray(prefillValues) ? [...prefillValues] : [];
|
|
1654
|
+
|
|
1655
|
+
// Ensure minimum count
|
|
1656
|
+
const minCount = element.minCount ?? 1;
|
|
1657
|
+
const maxCount = element.maxCount ?? 10;
|
|
1658
|
+
|
|
1659
|
+
while (values.length < minCount) {
|
|
1660
|
+
values.push(element.default || "");
|
|
1661
|
+
}
|
|
1662
|
+
|
|
1663
|
+
const container = document.createElement("div");
|
|
1664
|
+
container.className = "space-y-2";
|
|
1665
|
+
wrapper.appendChild(container);
|
|
1666
|
+
|
|
1667
|
+
function updateIndices() {
|
|
1668
|
+
const items = container.querySelectorAll(".multiple-textarea-item");
|
|
1669
|
+
items.forEach((item, index) => {
|
|
1670
|
+
const textarea = item.querySelector("textarea");
|
|
1671
|
+
if (textarea) {
|
|
1672
|
+
textarea.name = `${pathKey}[${index}]`;
|
|
1673
|
+
}
|
|
1674
|
+
});
|
|
1675
|
+
}
|
|
1676
|
+
|
|
1677
|
+
function addTextareaItem(value = "", index = -1) {
|
|
1678
|
+
const itemWrapper = document.createElement("div");
|
|
1679
|
+
itemWrapper.className = "multiple-textarea-item";
|
|
1680
|
+
|
|
1681
|
+
const textareaInput = document.createElement("textarea");
|
|
1682
|
+
textareaInput.className = "w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500 resize-none";
|
|
1683
|
+
textareaInput.placeholder = element.placeholder || "Enter text";
|
|
1684
|
+
textareaInput.rows = element.rows || 4;
|
|
1685
|
+
textareaInput.value = value;
|
|
1686
|
+
textareaInput.readOnly = state.config.readonly;
|
|
1687
|
+
|
|
1688
|
+
itemWrapper.appendChild(textareaInput);
|
|
1689
|
+
|
|
1690
|
+
// Remove buttons are managed centrally via updateRemoveButtons()
|
|
1691
|
+
|
|
1692
|
+
if (index === -1) {
|
|
1693
|
+
container.appendChild(itemWrapper);
|
|
1694
|
+
} else {
|
|
1695
|
+
container.insertBefore(itemWrapper, container.children[index]);
|
|
1696
|
+
}
|
|
1697
|
+
|
|
1698
|
+
updateIndices();
|
|
1699
|
+
return itemWrapper;
|
|
1700
|
+
}
|
|
1701
|
+
|
|
1702
|
+
function updateRemoveButtons() {
|
|
1703
|
+
if (state.config.readonly) return;
|
|
1704
|
+
const items = container.querySelectorAll('.multiple-textarea-item');
|
|
1705
|
+
const currentCount = items.length;
|
|
1706
|
+
items.forEach((item) => {
|
|
1707
|
+
let removeBtn = item.querySelector('.remove-item-btn');
|
|
1708
|
+
if (!removeBtn) {
|
|
1709
|
+
removeBtn = document.createElement('button');
|
|
1710
|
+
removeBtn.type = 'button';
|
|
1711
|
+
removeBtn.className = 'remove-item-btn mt-1 px-2 py-1 text-red-600 hover:bg-red-50 rounded text-sm';
|
|
1712
|
+
removeBtn.innerHTML = '✕ Remove';
|
|
1713
|
+
removeBtn.onclick = () => {
|
|
1714
|
+
const currentIndex = Array.from(container.children).indexOf(item);
|
|
1715
|
+
if (container.children.length > minCount) {
|
|
1716
|
+
values.splice(currentIndex, 1);
|
|
1717
|
+
item.remove();
|
|
1718
|
+
updateIndices();
|
|
1719
|
+
updateAddButton();
|
|
1720
|
+
updateRemoveButtons();
|
|
1721
|
+
}
|
|
1722
|
+
};
|
|
1723
|
+
item.appendChild(removeBtn);
|
|
1724
|
+
}
|
|
1725
|
+
const disabled = currentCount <= minCount;
|
|
1726
|
+
removeBtn.disabled = disabled;
|
|
1727
|
+
removeBtn.style.opacity = disabled ? '0.5' : '1';
|
|
1728
|
+
removeBtn.style.pointerEvents = disabled ? 'none' : 'auto';
|
|
1729
|
+
});
|
|
1730
|
+
}
|
|
1731
|
+
|
|
1732
|
+
function updateAddButton() {
|
|
1733
|
+
const existingAddBtn = wrapper.querySelector(".add-textarea-btn");
|
|
1734
|
+
if (existingAddBtn) existingAddBtn.remove();
|
|
1735
|
+
|
|
1736
|
+
if (!state.config.readonly && values.length < maxCount) {
|
|
1737
|
+
const addBtn = document.createElement("button");
|
|
1738
|
+
addBtn.type = "button";
|
|
1739
|
+
addBtn.className = "add-textarea-btn mt-2 px-3 py-1 text-blue-600 border border-blue-300 rounded hover:bg-blue-50 text-sm";
|
|
1740
|
+
addBtn.textContent = `+ Add ${element.label || 'Textarea'}`;
|
|
1741
|
+
addBtn.onclick = () => {
|
|
1742
|
+
values.push(element.default || "");
|
|
1743
|
+
addTextareaItem(element.default || "");
|
|
1744
|
+
updateAddButton();
|
|
1745
|
+
updateRemoveButtons();
|
|
1746
|
+
};
|
|
1747
|
+
wrapper.appendChild(addBtn);
|
|
1748
|
+
}
|
|
1749
|
+
}
|
|
1750
|
+
|
|
1751
|
+
// Render initial items
|
|
1752
|
+
values.forEach(value => addTextareaItem(value));
|
|
1753
|
+
updateAddButton();
|
|
1754
|
+
updateRemoveButtons();
|
|
1755
|
+
|
|
1756
|
+
// Add hint
|
|
1757
|
+
const hint = document.createElement("p");
|
|
1758
|
+
hint.className = "text-xs text-gray-500 mt-1";
|
|
1759
|
+
hint.textContent = makeFieldHint(element);
|
|
1760
|
+
wrapper.appendChild(hint);
|
|
1761
|
+
}
|
|
1762
|
+
|
|
1167
1763
|
function renderNumberElement(element, ctx, wrapper, pathKey) {
|
|
1168
1764
|
const numberInput = document.createElement("input");
|
|
1169
1765
|
numberInput.type = "number";
|
|
@@ -1185,6 +1781,121 @@ function renderNumberElement(element, ctx, wrapper, pathKey) {
|
|
|
1185
1781
|
wrapper.appendChild(numberHint);
|
|
1186
1782
|
}
|
|
1187
1783
|
|
|
1784
|
+
function renderMultipleNumberElement(element, ctx, wrapper, pathKey) {
|
|
1785
|
+
const prefillValues = ctx.prefill[element.key] || [];
|
|
1786
|
+
const values = Array.isArray(prefillValues) ? [...prefillValues] : [];
|
|
1787
|
+
|
|
1788
|
+
// Ensure minimum count
|
|
1789
|
+
const minCount = element.minCount ?? 1;
|
|
1790
|
+
const maxCount = element.maxCount ?? 10;
|
|
1791
|
+
|
|
1792
|
+
while (values.length < minCount) {
|
|
1793
|
+
values.push(element.default || "");
|
|
1794
|
+
}
|
|
1795
|
+
|
|
1796
|
+
const container = document.createElement("div");
|
|
1797
|
+
container.className = "space-y-2";
|
|
1798
|
+
wrapper.appendChild(container);
|
|
1799
|
+
|
|
1800
|
+
function updateIndices() {
|
|
1801
|
+
const items = container.querySelectorAll(".multiple-number-item");
|
|
1802
|
+
items.forEach((item, index) => {
|
|
1803
|
+
const input = item.querySelector("input");
|
|
1804
|
+
if (input) {
|
|
1805
|
+
input.name = `${pathKey}[${index}]`;
|
|
1806
|
+
}
|
|
1807
|
+
});
|
|
1808
|
+
}
|
|
1809
|
+
|
|
1810
|
+
function addNumberItem(value = "", index = -1) {
|
|
1811
|
+
const itemWrapper = document.createElement("div");
|
|
1812
|
+
itemWrapper.className = "multiple-number-item flex items-center gap-2";
|
|
1813
|
+
|
|
1814
|
+
const numberInput = document.createElement("input");
|
|
1815
|
+
numberInput.type = "number";
|
|
1816
|
+
numberInput.className = "flex-1 px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500";
|
|
1817
|
+
numberInput.placeholder = element.placeholder || "0";
|
|
1818
|
+
if (element.min !== undefined) numberInput.min = element.min;
|
|
1819
|
+
if (element.max !== undefined) numberInput.max = element.max;
|
|
1820
|
+
if (element.step !== undefined) numberInput.step = element.step;
|
|
1821
|
+
numberInput.value = value;
|
|
1822
|
+
numberInput.readOnly = state.config.readonly;
|
|
1823
|
+
|
|
1824
|
+
itemWrapper.appendChild(numberInput);
|
|
1825
|
+
|
|
1826
|
+
// Remove buttons are managed centrally via updateRemoveButtons()
|
|
1827
|
+
|
|
1828
|
+
if (index === -1) {
|
|
1829
|
+
container.appendChild(itemWrapper);
|
|
1830
|
+
} else {
|
|
1831
|
+
container.insertBefore(itemWrapper, container.children[index]);
|
|
1832
|
+
}
|
|
1833
|
+
|
|
1834
|
+
updateIndices();
|
|
1835
|
+
return itemWrapper;
|
|
1836
|
+
}
|
|
1837
|
+
|
|
1838
|
+
function updateRemoveButtons() {
|
|
1839
|
+
if (state.config.readonly) return;
|
|
1840
|
+
const items = container.querySelectorAll('.multiple-number-item');
|
|
1841
|
+
const currentCount = items.length;
|
|
1842
|
+
items.forEach((item) => {
|
|
1843
|
+
let removeBtn = item.querySelector('.remove-item-btn');
|
|
1844
|
+
if (!removeBtn) {
|
|
1845
|
+
removeBtn = document.createElement('button');
|
|
1846
|
+
removeBtn.type = 'button';
|
|
1847
|
+
removeBtn.className = 'remove-item-btn px-2 py-1 text-red-600 hover:bg-red-50 rounded';
|
|
1848
|
+
removeBtn.innerHTML = '✕';
|
|
1849
|
+
removeBtn.onclick = () => {
|
|
1850
|
+
const currentIndex = Array.from(container.children).indexOf(item);
|
|
1851
|
+
if (container.children.length > minCount) {
|
|
1852
|
+
values.splice(currentIndex, 1);
|
|
1853
|
+
item.remove();
|
|
1854
|
+
updateIndices();
|
|
1855
|
+
updateAddButton();
|
|
1856
|
+
updateRemoveButtons();
|
|
1857
|
+
}
|
|
1858
|
+
};
|
|
1859
|
+
item.appendChild(removeBtn);
|
|
1860
|
+
}
|
|
1861
|
+
const disabled = currentCount <= minCount;
|
|
1862
|
+
removeBtn.disabled = disabled;
|
|
1863
|
+
removeBtn.style.opacity = disabled ? '0.5' : '1';
|
|
1864
|
+
removeBtn.style.pointerEvents = disabled ? 'none' : 'auto';
|
|
1865
|
+
});
|
|
1866
|
+
}
|
|
1867
|
+
|
|
1868
|
+
function updateAddButton() {
|
|
1869
|
+
const existingAddBtn = wrapper.querySelector(".add-number-btn");
|
|
1870
|
+
if (existingAddBtn) existingAddBtn.remove();
|
|
1871
|
+
|
|
1872
|
+
if (!state.config.readonly && values.length < maxCount) {
|
|
1873
|
+
const addBtn = document.createElement("button");
|
|
1874
|
+
addBtn.type = "button";
|
|
1875
|
+
addBtn.className = "add-number-btn mt-2 px-3 py-1 text-blue-600 border border-blue-300 rounded hover:bg-blue-50 text-sm";
|
|
1876
|
+
addBtn.textContent = `+ Add ${element.label || 'Number'}`;
|
|
1877
|
+
addBtn.onclick = () => {
|
|
1878
|
+
values.push(element.default || "");
|
|
1879
|
+
addNumberItem(element.default || "");
|
|
1880
|
+
updateAddButton();
|
|
1881
|
+
updateRemoveButtons();
|
|
1882
|
+
};
|
|
1883
|
+
wrapper.appendChild(addBtn);
|
|
1884
|
+
}
|
|
1885
|
+
}
|
|
1886
|
+
|
|
1887
|
+
// Render initial items
|
|
1888
|
+
values.forEach(value => addNumberItem(value));
|
|
1889
|
+
updateAddButton();
|
|
1890
|
+
updateRemoveButtons();
|
|
1891
|
+
|
|
1892
|
+
// Add hint
|
|
1893
|
+
const hint = document.createElement("p");
|
|
1894
|
+
hint.className = "text-xs text-gray-500 mt-1";
|
|
1895
|
+
hint.textContent = makeFieldHint(element);
|
|
1896
|
+
wrapper.appendChild(hint);
|
|
1897
|
+
}
|
|
1898
|
+
|
|
1188
1899
|
function renderSelectElement(element, ctx, wrapper, pathKey) {
|
|
1189
1900
|
const selectInput = document.createElement("select");
|
|
1190
1901
|
selectInput.className =
|
|
@@ -1211,6 +1922,127 @@ function renderSelectElement(element, ctx, wrapper, pathKey) {
|
|
|
1211
1922
|
wrapper.appendChild(selectHint);
|
|
1212
1923
|
}
|
|
1213
1924
|
|
|
1925
|
+
function renderMultipleSelectElement(element, ctx, wrapper, pathKey) {
|
|
1926
|
+
const prefillValues = ctx.prefill[element.key] || [];
|
|
1927
|
+
const values = Array.isArray(prefillValues) ? [...prefillValues] : [];
|
|
1928
|
+
|
|
1929
|
+
// Ensure minimum count
|
|
1930
|
+
const minCount = element.minCount ?? 1;
|
|
1931
|
+
const maxCount = element.maxCount ?? 10;
|
|
1932
|
+
|
|
1933
|
+
while (values.length < minCount) {
|
|
1934
|
+
values.push(element.default || (element.options?.[0]?.value || ""));
|
|
1935
|
+
}
|
|
1936
|
+
|
|
1937
|
+
const container = document.createElement("div");
|
|
1938
|
+
container.className = "space-y-2";
|
|
1939
|
+
wrapper.appendChild(container);
|
|
1940
|
+
|
|
1941
|
+
function updateIndices() {
|
|
1942
|
+
const items = container.querySelectorAll(".multiple-select-item");
|
|
1943
|
+
items.forEach((item, index) => {
|
|
1944
|
+
const select = item.querySelector("select");
|
|
1945
|
+
if (select) {
|
|
1946
|
+
select.name = `${pathKey}[${index}]`;
|
|
1947
|
+
}
|
|
1948
|
+
});
|
|
1949
|
+
}
|
|
1950
|
+
|
|
1951
|
+
function addSelectItem(value = "", index = -1) {
|
|
1952
|
+
const itemWrapper = document.createElement("div");
|
|
1953
|
+
itemWrapper.className = "multiple-select-item flex items-center gap-2";
|
|
1954
|
+
|
|
1955
|
+
const selectInput = document.createElement("select");
|
|
1956
|
+
selectInput.className = "flex-1 px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500";
|
|
1957
|
+
selectInput.disabled = state.config.readonly;
|
|
1958
|
+
|
|
1959
|
+
// Add options
|
|
1960
|
+
(element.options || []).forEach((option) => {
|
|
1961
|
+
const optionElement = document.createElement("option");
|
|
1962
|
+
optionElement.value = option.value;
|
|
1963
|
+
optionElement.textContent = option.label;
|
|
1964
|
+
if (value === option.value) {
|
|
1965
|
+
optionElement.selected = true;
|
|
1966
|
+
}
|
|
1967
|
+
selectInput.appendChild(optionElement);
|
|
1968
|
+
});
|
|
1969
|
+
|
|
1970
|
+
itemWrapper.appendChild(selectInput);
|
|
1971
|
+
|
|
1972
|
+
// Remove buttons are managed centrally via updateRemoveButtons()
|
|
1973
|
+
|
|
1974
|
+
if (index === -1) {
|
|
1975
|
+
container.appendChild(itemWrapper);
|
|
1976
|
+
} else {
|
|
1977
|
+
container.insertBefore(itemWrapper, container.children[index]);
|
|
1978
|
+
}
|
|
1979
|
+
|
|
1980
|
+
updateIndices();
|
|
1981
|
+
return itemWrapper;
|
|
1982
|
+
}
|
|
1983
|
+
|
|
1984
|
+
function updateRemoveButtons() {
|
|
1985
|
+
if (state.config.readonly) return;
|
|
1986
|
+
const items = container.querySelectorAll('.multiple-select-item');
|
|
1987
|
+
const currentCount = items.length;
|
|
1988
|
+
items.forEach((item) => {
|
|
1989
|
+
let removeBtn = item.querySelector('.remove-item-btn');
|
|
1990
|
+
if (!removeBtn) {
|
|
1991
|
+
removeBtn = document.createElement('button');
|
|
1992
|
+
removeBtn.type = 'button';
|
|
1993
|
+
removeBtn.className = 'remove-item-btn px-2 py-1 text-red-600 hover:bg-red-50 rounded';
|
|
1994
|
+
removeBtn.innerHTML = '✕';
|
|
1995
|
+
removeBtn.onclick = () => {
|
|
1996
|
+
const currentIndex = Array.from(container.children).indexOf(item);
|
|
1997
|
+
if (container.children.length > minCount) {
|
|
1998
|
+
values.splice(currentIndex, 1);
|
|
1999
|
+
item.remove();
|
|
2000
|
+
updateIndices();
|
|
2001
|
+
updateAddButton();
|
|
2002
|
+
updateRemoveButtons();
|
|
2003
|
+
}
|
|
2004
|
+
};
|
|
2005
|
+
item.appendChild(removeBtn);
|
|
2006
|
+
}
|
|
2007
|
+
const disabled = currentCount <= minCount;
|
|
2008
|
+
removeBtn.disabled = disabled;
|
|
2009
|
+
removeBtn.style.opacity = disabled ? '0.5' : '1';
|
|
2010
|
+
removeBtn.style.pointerEvents = disabled ? 'none' : 'auto';
|
|
2011
|
+
});
|
|
2012
|
+
}
|
|
2013
|
+
|
|
2014
|
+
function updateAddButton() {
|
|
2015
|
+
const existingAddBtn = wrapper.querySelector(".add-select-btn");
|
|
2016
|
+
if (existingAddBtn) existingAddBtn.remove();
|
|
2017
|
+
|
|
2018
|
+
if (!state.config.readonly && values.length < maxCount) {
|
|
2019
|
+
const addBtn = document.createElement("button");
|
|
2020
|
+
addBtn.type = "button";
|
|
2021
|
+
addBtn.className = "add-select-btn mt-2 px-3 py-1 text-blue-600 border border-blue-300 rounded hover:bg-blue-50 text-sm";
|
|
2022
|
+
addBtn.textContent = `+ Add ${element.label || 'Selection'}`;
|
|
2023
|
+
addBtn.onclick = () => {
|
|
2024
|
+
const defaultValue = element.default || (element.options?.[0]?.value || "");
|
|
2025
|
+
values.push(defaultValue);
|
|
2026
|
+
addSelectItem(defaultValue);
|
|
2027
|
+
updateAddButton();
|
|
2028
|
+
updateRemoveButtons();
|
|
2029
|
+
};
|
|
2030
|
+
wrapper.appendChild(addBtn);
|
|
2031
|
+
}
|
|
2032
|
+
}
|
|
2033
|
+
|
|
2034
|
+
// Render initial items
|
|
2035
|
+
values.forEach(value => addSelectItem(value));
|
|
2036
|
+
updateAddButton();
|
|
2037
|
+
updateRemoveButtons();
|
|
2038
|
+
|
|
2039
|
+
// Add hint
|
|
2040
|
+
const hint = document.createElement("p");
|
|
2041
|
+
hint.className = "text-xs text-gray-500 mt-1";
|
|
2042
|
+
hint.textContent = makeFieldHint(element);
|
|
2043
|
+
wrapper.appendChild(hint);
|
|
2044
|
+
}
|
|
2045
|
+
|
|
1214
2046
|
function renderFileElement(element, ctx, wrapper, pathKey) {
|
|
1215
2047
|
if (state.config.readonly) {
|
|
1216
2048
|
// Readonly mode: use common preview function
|
|
@@ -1429,6 +2261,93 @@ function renderFilesElement(element, ctx, wrapper, pathKey) {
|
|
|
1429
2261
|
}
|
|
1430
2262
|
}
|
|
1431
2263
|
|
|
2264
|
+
function renderMultipleFileElement(element, ctx, wrapper, pathKey) {
|
|
2265
|
+
// Use the same logic as renderFilesElement but with minCount/maxCount from element properties
|
|
2266
|
+
const minFiles = element.minCount ?? 0;
|
|
2267
|
+
const maxFiles = element.maxCount ?? Infinity;
|
|
2268
|
+
|
|
2269
|
+
if (state.config.readonly) {
|
|
2270
|
+
// Readonly mode: render as results list
|
|
2271
|
+
const resultsWrapper = document.createElement("div");
|
|
2272
|
+
resultsWrapper.className = "space-y-4";
|
|
2273
|
+
|
|
2274
|
+
const initialFiles = ctx.prefill[element.key] || [];
|
|
2275
|
+
|
|
2276
|
+
if (initialFiles.length > 0) {
|
|
2277
|
+
initialFiles.forEach((resourceId) => {
|
|
2278
|
+
const filePreview = renderFilePreviewReadonly(resourceId);
|
|
2279
|
+
resultsWrapper.appendChild(filePreview);
|
|
2280
|
+
});
|
|
2281
|
+
} else {
|
|
2282
|
+
resultsWrapper.innerHTML = `<div class="aspect-video bg-gray-100 rounded-lg flex items-center justify-center text-gray-500"><div class="text-center">${t("noFilesSelected")}</div></div>`;
|
|
2283
|
+
}
|
|
2284
|
+
|
|
2285
|
+
wrapper.appendChild(resultsWrapper);
|
|
2286
|
+
} else {
|
|
2287
|
+
// Edit mode: multiple file input with min/max validation
|
|
2288
|
+
const filesWrapper = document.createElement("div");
|
|
2289
|
+
filesWrapper.className = "space-y-2";
|
|
2290
|
+
|
|
2291
|
+
const filesPicker = document.createElement("input");
|
|
2292
|
+
filesPicker.type = "file";
|
|
2293
|
+
filesPicker.name = pathKey;
|
|
2294
|
+
filesPicker.multiple = true;
|
|
2295
|
+
filesPicker.style.display = "none"; // Hide default input
|
|
2296
|
+
if (element.accept?.extensions) {
|
|
2297
|
+
filesPicker.accept = element.accept.extensions
|
|
2298
|
+
.map((ext) => `.${ext}`)
|
|
2299
|
+
.join(",");
|
|
2300
|
+
}
|
|
2301
|
+
|
|
2302
|
+
const filesContainer = document.createElement("div");
|
|
2303
|
+
filesContainer.className = "files-list space-y-2";
|
|
2304
|
+
|
|
2305
|
+
filesWrapper.appendChild(filesPicker);
|
|
2306
|
+
filesWrapper.appendChild(filesContainer);
|
|
2307
|
+
|
|
2308
|
+
const initialFiles = Array.isArray(ctx.prefill[element.key])
|
|
2309
|
+
? [...ctx.prefill[element.key]]
|
|
2310
|
+
: [];
|
|
2311
|
+
|
|
2312
|
+
// Add initial files to resource index
|
|
2313
|
+
addPrefillFilesToIndex(initialFiles);
|
|
2314
|
+
|
|
2315
|
+
const updateFilesDisplay = () => {
|
|
2316
|
+
renderResourcePills(filesContainer, initialFiles, (index) => {
|
|
2317
|
+
initialFiles.splice(index, 1);
|
|
2318
|
+
updateFilesDisplay();
|
|
2319
|
+
});
|
|
2320
|
+
|
|
2321
|
+
// Show count and min/max info
|
|
2322
|
+
const countInfo = document.createElement("div");
|
|
2323
|
+
countInfo.className = "text-xs text-gray-500 mt-2";
|
|
2324
|
+
const countText = `${initialFiles.length} file${initialFiles.length !== 1 ? 's' : ''}`;
|
|
2325
|
+
const minMaxText = minFiles > 0 || maxFiles < Infinity
|
|
2326
|
+
? ` (${minFiles}-${maxFiles} allowed)`
|
|
2327
|
+
: '';
|
|
2328
|
+
countInfo.textContent = countText + minMaxText;
|
|
2329
|
+
|
|
2330
|
+
// Remove previous count info
|
|
2331
|
+
const existingCount = filesWrapper.querySelector('.file-count-info');
|
|
2332
|
+
if (existingCount) existingCount.remove();
|
|
2333
|
+
|
|
2334
|
+
countInfo.className += ' file-count-info';
|
|
2335
|
+
filesWrapper.appendChild(countInfo);
|
|
2336
|
+
};
|
|
2337
|
+
|
|
2338
|
+
// Set up drag and drop
|
|
2339
|
+
setupFilesDropHandler(filesContainer, initialFiles, updateFilesDisplay);
|
|
2340
|
+
|
|
2341
|
+
// Set up file picker
|
|
2342
|
+
setupFilesPickerHandler(filesPicker, initialFiles, updateFilesDisplay);
|
|
2343
|
+
|
|
2344
|
+
// Initial display
|
|
2345
|
+
updateFilesDisplay();
|
|
2346
|
+
|
|
2347
|
+
wrapper.appendChild(filesWrapper);
|
|
2348
|
+
}
|
|
2349
|
+
}
|
|
2350
|
+
|
|
1432
2351
|
function addPrefillFilesToIndex(initialFiles) {
|
|
1433
2352
|
if (initialFiles.length > 0) {
|
|
1434
2353
|
initialFiles.forEach((resourceId) => {
|
|
@@ -1552,13 +2471,20 @@ function renderRepeatableGroup(element, ctx, itemsWrap, left, groupWrap) {
|
|
|
1552
2471
|
const item = document.createElement("div");
|
|
1553
2472
|
item.className =
|
|
1554
2473
|
"groupItem border border-dashed border-slate-300 dark:border-slate-600 rounded-lg p-3 mb-3 bg-blue-50/30 dark:bg-blue-900/10";
|
|
2474
|
+
const itemIndex = countItems();
|
|
2475
|
+
const fullPath = pathJoin(ctx.path, `${element.key}[${itemIndex}]`);
|
|
2476
|
+
// Add data-group-item attribute for validation scoping - use full path
|
|
2477
|
+
item.setAttribute("data-group-item", fullPath);
|
|
1555
2478
|
const subCtx = {
|
|
1556
|
-
path:
|
|
2479
|
+
path: fullPath,
|
|
1557
2480
|
prefill: prefillObj || {},
|
|
1558
2481
|
};
|
|
1559
|
-
element.elements.forEach((child) =>
|
|
1560
|
-
|
|
1561
|
-
|
|
2482
|
+
element.elements.forEach((child) => {
|
|
2483
|
+
// Skip rendering hidden child elements
|
|
2484
|
+
if (!child.hidden) {
|
|
2485
|
+
item.appendChild(renderElement(child, subCtx));
|
|
2486
|
+
}
|
|
2487
|
+
});
|
|
1562
2488
|
|
|
1563
2489
|
// Only add remove button in edit mode
|
|
1564
2490
|
if (!state.config.readonly) {
|
|
@@ -1622,13 +2548,224 @@ function renderSingleGroup(element, ctx, itemsWrap, left, groupWrap) {
|
|
|
1622
2548
|
path: pathJoin(ctx.path, element.key),
|
|
1623
2549
|
prefill: ctx.prefill?.[element.key] || {},
|
|
1624
2550
|
};
|
|
1625
|
-
element.elements.forEach((child) =>
|
|
1626
|
-
|
|
1627
|
-
|
|
2551
|
+
element.elements.forEach((child) => {
|
|
2552
|
+
// Skip rendering hidden child elements
|
|
2553
|
+
if (!child.hidden) {
|
|
2554
|
+
itemsWrap.appendChild(renderElement(child, subCtx));
|
|
2555
|
+
}
|
|
2556
|
+
});
|
|
1628
2557
|
groupWrap.appendChild(itemsWrap);
|
|
1629
2558
|
left.innerHTML = `<span>${element.label || element.key}</span>`;
|
|
1630
2559
|
}
|
|
1631
2560
|
|
|
2561
|
+
function renderSingleContainerElement(element, ctx, wrapper, pathKey) {
|
|
2562
|
+
// Same as renderSingleGroup but with updated naming
|
|
2563
|
+
const containerWrap = document.createElement("div");
|
|
2564
|
+
containerWrap.className = "border border-gray-200 rounded-lg p-4 bg-gray-50";
|
|
2565
|
+
containerWrap.setAttribute("data-container", pathKey);
|
|
2566
|
+
|
|
2567
|
+
const header = document.createElement("div");
|
|
2568
|
+
header.className = "flex justify-between items-center mb-4";
|
|
2569
|
+
|
|
2570
|
+
const left = document.createElement("div");
|
|
2571
|
+
left.className = "flex-1";
|
|
2572
|
+
|
|
2573
|
+
const itemsWrap = document.createElement("div");
|
|
2574
|
+
itemsWrap.className = "space-y-4";
|
|
2575
|
+
|
|
2576
|
+
containerWrap.appendChild(header);
|
|
2577
|
+
header.appendChild(left);
|
|
2578
|
+
|
|
2579
|
+
// Single object container
|
|
2580
|
+
const subCtx = {
|
|
2581
|
+
path: pathJoin(ctx.path, element.key),
|
|
2582
|
+
prefill: ctx.prefill?.[element.key] || {},
|
|
2583
|
+
};
|
|
2584
|
+
element.elements.forEach((child) => {
|
|
2585
|
+
// Skip rendering hidden child elements
|
|
2586
|
+
if (!child.hidden) {
|
|
2587
|
+
itemsWrap.appendChild(renderElement(child, subCtx));
|
|
2588
|
+
}
|
|
2589
|
+
});
|
|
2590
|
+
containerWrap.appendChild(itemsWrap);
|
|
2591
|
+
left.innerHTML = `<span>${element.label || element.key}</span>`;
|
|
2592
|
+
|
|
2593
|
+
wrapper.appendChild(containerWrap);
|
|
2594
|
+
}
|
|
2595
|
+
|
|
2596
|
+
function renderMultipleContainerElement(element, ctx, wrapper, _pathKey) {
|
|
2597
|
+
// Same as renderRepeatableGroup but with minCount/maxCount from element properties
|
|
2598
|
+
const containerWrap = document.createElement("div");
|
|
2599
|
+
containerWrap.className = "border border-gray-200 rounded-lg p-4 bg-gray-50";
|
|
2600
|
+
|
|
2601
|
+
const header = document.createElement("div");
|
|
2602
|
+
header.className = "flex justify-between items-center mb-4";
|
|
2603
|
+
|
|
2604
|
+
const left = document.createElement("div");
|
|
2605
|
+
left.className = "flex-1";
|
|
2606
|
+
|
|
2607
|
+
const right = document.createElement("div");
|
|
2608
|
+
right.className = "flex gap-2";
|
|
2609
|
+
|
|
2610
|
+
const itemsWrap = document.createElement("div");
|
|
2611
|
+
itemsWrap.className = "space-y-4";
|
|
2612
|
+
|
|
2613
|
+
containerWrap.appendChild(header);
|
|
2614
|
+
header.appendChild(left);
|
|
2615
|
+
if (!state.config.readonly) {
|
|
2616
|
+
header.appendChild(right);
|
|
2617
|
+
}
|
|
2618
|
+
|
|
2619
|
+
const min = element.minCount ?? 0;
|
|
2620
|
+
const max = element.maxCount ?? Infinity;
|
|
2621
|
+
const pre = Array.isArray(ctx.prefill?.[element.key])
|
|
2622
|
+
? ctx.prefill[element.key]
|
|
2623
|
+
: null;
|
|
2624
|
+
|
|
2625
|
+
const countItems = () =>
|
|
2626
|
+
itemsWrap.querySelectorAll(":scope > .containerItem").length;
|
|
2627
|
+
|
|
2628
|
+
const createAddButton = () => {
|
|
2629
|
+
const add = document.createElement("button");
|
|
2630
|
+
add.type = "button";
|
|
2631
|
+
add.className =
|
|
2632
|
+
"px-3 py-1.5 bg-blue-600 text-white text-sm rounded-lg hover:bg-blue-700 transition-colors";
|
|
2633
|
+
add.textContent = t("addElement");
|
|
2634
|
+
add.onclick = () => {
|
|
2635
|
+
if (countItems() < max) {
|
|
2636
|
+
const idx = countItems();
|
|
2637
|
+
const subCtx = {
|
|
2638
|
+
path: pathJoin(ctx.path, `${element.key}[${idx}]`),
|
|
2639
|
+
prefill: {},
|
|
2640
|
+
};
|
|
2641
|
+
const item = document.createElement("div");
|
|
2642
|
+
item.className = "containerItem border border-gray-300 rounded-lg p-4 bg-white";
|
|
2643
|
+
item.setAttribute("data-container-item", `${element.key}[${idx}]`);
|
|
2644
|
+
|
|
2645
|
+
element.elements.forEach((child) => {
|
|
2646
|
+
// Skip rendering hidden child elements
|
|
2647
|
+
if (!child.hidden) {
|
|
2648
|
+
item.appendChild(renderElement(child, subCtx));
|
|
2649
|
+
}
|
|
2650
|
+
});
|
|
2651
|
+
|
|
2652
|
+
// Only add remove button in edit mode
|
|
2653
|
+
if (!state.config.readonly) {
|
|
2654
|
+
const rem = document.createElement("button");
|
|
2655
|
+
rem.type = "button";
|
|
2656
|
+
rem.className =
|
|
2657
|
+
"absolute top-2 right-2 w-6 h-6 bg-red-500 text-white rounded-full text-xs hover:bg-red-600 transition-colors";
|
|
2658
|
+
rem.textContent = "×";
|
|
2659
|
+
rem.onclick = () => {
|
|
2660
|
+
item.remove();
|
|
2661
|
+
updateAddButton();
|
|
2662
|
+
};
|
|
2663
|
+
item.style.position = "relative";
|
|
2664
|
+
item.appendChild(rem);
|
|
2665
|
+
}
|
|
2666
|
+
|
|
2667
|
+
itemsWrap.appendChild(item);
|
|
2668
|
+
updateAddButton();
|
|
2669
|
+
}
|
|
2670
|
+
};
|
|
2671
|
+
return add;
|
|
2672
|
+
};
|
|
2673
|
+
|
|
2674
|
+
const updateAddButton = () => {
|
|
2675
|
+
const currentCount = countItems();
|
|
2676
|
+
const addBtn = right.querySelector("button");
|
|
2677
|
+
if (addBtn) {
|
|
2678
|
+
addBtn.disabled = currentCount >= max;
|
|
2679
|
+
addBtn.style.opacity = currentCount >= max ? "0.5" : "1";
|
|
2680
|
+
}
|
|
2681
|
+
left.innerHTML = `<span>${element.label || element.key}</span> <span class="text-sm text-gray-500">(${currentCount}/${max === Infinity ? '∞' : max})</span>`;
|
|
2682
|
+
};
|
|
2683
|
+
|
|
2684
|
+
if (!state.config.readonly) {
|
|
2685
|
+
right.appendChild(createAddButton());
|
|
2686
|
+
}
|
|
2687
|
+
|
|
2688
|
+
// Pre-fill initial items
|
|
2689
|
+
if (pre && Array.isArray(pre)) {
|
|
2690
|
+
pre.forEach((prefillObj, idx) => {
|
|
2691
|
+
const subCtx = {
|
|
2692
|
+
path: pathJoin(ctx.path, `${element.key}[${idx}]`),
|
|
2693
|
+
prefill: prefillObj || {},
|
|
2694
|
+
};
|
|
2695
|
+
const item = document.createElement("div");
|
|
2696
|
+
item.className = "containerItem border border-gray-300 rounded-lg p-4 bg-white";
|
|
2697
|
+
item.setAttribute("data-container-item", `${element.key}[${idx}]`);
|
|
2698
|
+
|
|
2699
|
+
element.elements.forEach((child) => {
|
|
2700
|
+
// Skip rendering hidden child elements
|
|
2701
|
+
if (!child.hidden) {
|
|
2702
|
+
item.appendChild(renderElement(child, subCtx));
|
|
2703
|
+
}
|
|
2704
|
+
});
|
|
2705
|
+
|
|
2706
|
+
// Only add remove button in edit mode
|
|
2707
|
+
if (!state.config.readonly) {
|
|
2708
|
+
const rem = document.createElement("button");
|
|
2709
|
+
rem.type = "button";
|
|
2710
|
+
rem.className =
|
|
2711
|
+
"absolute top-2 right-2 w-6 h-6 bg-red-500 text-white rounded-full text-xs hover:bg-red-600 transition-colors";
|
|
2712
|
+
rem.textContent = "×";
|
|
2713
|
+
rem.onclick = () => {
|
|
2714
|
+
item.remove();
|
|
2715
|
+
updateAddButton();
|
|
2716
|
+
};
|
|
2717
|
+
item.style.position = "relative";
|
|
2718
|
+
item.appendChild(rem);
|
|
2719
|
+
}
|
|
2720
|
+
|
|
2721
|
+
itemsWrap.appendChild(item);
|
|
2722
|
+
});
|
|
2723
|
+
}
|
|
2724
|
+
|
|
2725
|
+
// Ensure minimum items
|
|
2726
|
+
if (!state.config.readonly) {
|
|
2727
|
+
while (countItems() < min) {
|
|
2728
|
+
const idx = countItems();
|
|
2729
|
+
const subCtx = {
|
|
2730
|
+
path: pathJoin(ctx.path, `${element.key}[${idx}]`),
|
|
2731
|
+
prefill: {},
|
|
2732
|
+
};
|
|
2733
|
+
const item = document.createElement("div");
|
|
2734
|
+
item.className = "containerItem border border-gray-300 rounded-lg p-4 bg-white";
|
|
2735
|
+
item.setAttribute("data-container-item", `${element.key}[${idx}]`);
|
|
2736
|
+
|
|
2737
|
+
element.elements.forEach((child) => {
|
|
2738
|
+
// Skip rendering hidden child elements
|
|
2739
|
+
if (!child.hidden) {
|
|
2740
|
+
item.appendChild(renderElement(child, subCtx));
|
|
2741
|
+
}
|
|
2742
|
+
});
|
|
2743
|
+
|
|
2744
|
+
// Remove button - but disabled if we're at minimum
|
|
2745
|
+
const rem = document.createElement("button");
|
|
2746
|
+
rem.type = "button";
|
|
2747
|
+
rem.className =
|
|
2748
|
+
"absolute top-2 right-2 w-6 h-6 bg-red-500 text-white rounded-full text-xs hover:bg-red-600 transition-colors";
|
|
2749
|
+
rem.textContent = "×";
|
|
2750
|
+
rem.onclick = () => {
|
|
2751
|
+
if (countItems() > min) {
|
|
2752
|
+
item.remove();
|
|
2753
|
+
updateAddButton();
|
|
2754
|
+
}
|
|
2755
|
+
};
|
|
2756
|
+
item.style.position = "relative";
|
|
2757
|
+
item.appendChild(rem);
|
|
2758
|
+
|
|
2759
|
+
itemsWrap.appendChild(item);
|
|
2760
|
+
}
|
|
2761
|
+
}
|
|
2762
|
+
|
|
2763
|
+
containerWrap.appendChild(itemsWrap);
|
|
2764
|
+
updateAddButton();
|
|
2765
|
+
|
|
2766
|
+
wrapper.appendChild(containerWrap);
|
|
2767
|
+
}
|
|
2768
|
+
|
|
1632
2769
|
// Common file preview rendering function for readonly mode
|
|
1633
2770
|
function renderFilePreviewReadonly(resourceId, fileName) {
|
|
1634
2771
|
const meta = state.resourceIndex.get(resourceId);
|
|
@@ -1817,6 +2954,10 @@ function setThumbnailHandler(thumbnailFn) {
|
|
|
1817
2954
|
state.config.getThumbnail = thumbnailFn;
|
|
1818
2955
|
}
|
|
1819
2956
|
|
|
2957
|
+
function setActionHandler(actionFn) {
|
|
2958
|
+
state.config.actionHandler = actionFn;
|
|
2959
|
+
}
|
|
2960
|
+
|
|
1820
2961
|
function setMode(mode) {
|
|
1821
2962
|
state.config.readonly = mode === "readonly";
|
|
1822
2963
|
}
|
|
@@ -1879,6 +3020,7 @@ const formBuilderAPI = {
|
|
|
1879
3020
|
setUploadHandler,
|
|
1880
3021
|
setDownloadHandler,
|
|
1881
3022
|
setThumbnailHandler,
|
|
3023
|
+
setActionHandler,
|
|
1882
3024
|
setMode,
|
|
1883
3025
|
setLocale,
|
|
1884
3026
|
getFormData,
|
|
@@ -1903,6 +3045,7 @@ export {
|
|
|
1903
3045
|
setUploadHandler,
|
|
1904
3046
|
setDownloadHandler,
|
|
1905
3047
|
setThumbnailHandler,
|
|
3048
|
+
setActionHandler,
|
|
1906
3049
|
setMode,
|
|
1907
3050
|
setLocale,
|
|
1908
3051
|
getFormData,
|