@dmitryvim/form-builder 0.1.31 → 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/dist/demo.js +81 -5
- package/dist/elements.html +702 -0
- package/dist/elements.js +450 -0
- package/dist/form-builder.js +1208 -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
|
@@ -148,6 +148,10 @@ function renderForm(schema, prefill) {
|
|
|
148
148
|
formEl.className = "space-y-6";
|
|
149
149
|
|
|
150
150
|
schema.elements.forEach((element, _index) => {
|
|
151
|
+
// Skip rendering hidden elements
|
|
152
|
+
if (element.hidden) {
|
|
153
|
+
return;
|
|
154
|
+
}
|
|
151
155
|
const block = renderElement(element, {
|
|
152
156
|
path: "",
|
|
153
157
|
prefill: prefill || {},
|
|
@@ -209,23 +213,44 @@ function renderElement(element, ctx) {
|
|
|
209
213
|
|
|
210
214
|
switch (element.type) {
|
|
211
215
|
case "text":
|
|
212
|
-
|
|
216
|
+
if (element.multiple) {
|
|
217
|
+
renderMultipleTextElement(element, ctx, wrapper, pathKey);
|
|
218
|
+
} else {
|
|
219
|
+
renderTextElement(element, ctx, wrapper, pathKey);
|
|
220
|
+
}
|
|
213
221
|
break;
|
|
214
222
|
|
|
215
223
|
case "textarea":
|
|
216
|
-
|
|
224
|
+
if (element.multiple) {
|
|
225
|
+
renderMultipleTextareaElement(element, ctx, wrapper, pathKey);
|
|
226
|
+
} else {
|
|
227
|
+
renderTextareaElement(element, ctx, wrapper, pathKey);
|
|
228
|
+
}
|
|
217
229
|
break;
|
|
218
230
|
|
|
219
231
|
case "number":
|
|
220
|
-
|
|
232
|
+
if (element.multiple) {
|
|
233
|
+
renderMultipleNumberElement(element, ctx, wrapper, pathKey);
|
|
234
|
+
} else {
|
|
235
|
+
renderNumberElement(element, ctx, wrapper, pathKey);
|
|
236
|
+
}
|
|
221
237
|
break;
|
|
222
238
|
|
|
223
239
|
case "select":
|
|
224
|
-
|
|
240
|
+
if (element.multiple) {
|
|
241
|
+
renderMultipleSelectElement(element, ctx, wrapper, pathKey);
|
|
242
|
+
} else {
|
|
243
|
+
renderSelectElement(element, ctx, wrapper, pathKey);
|
|
244
|
+
}
|
|
225
245
|
break;
|
|
226
246
|
|
|
227
247
|
case "file":
|
|
228
|
-
|
|
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
|
+
}
|
|
229
254
|
break;
|
|
230
255
|
|
|
231
256
|
case "files":
|
|
@@ -236,6 +261,15 @@ function renderElement(element, ctx) {
|
|
|
236
261
|
renderGroupElement(element, ctx, wrapper, pathKey);
|
|
237
262
|
break;
|
|
238
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
|
+
|
|
239
273
|
default: {
|
|
240
274
|
const unsupported = document.createElement("div");
|
|
241
275
|
unsupported.className = "text-red-500 text-sm";
|
|
@@ -580,8 +614,15 @@ function renderResourcePills(container, rids, onRemove) {
|
|
|
580
614
|
|
|
581
615
|
// Add click handler to each slot
|
|
582
616
|
slot.onclick = () => {
|
|
583
|
-
// Look for file input
|
|
584
|
-
|
|
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
|
+
}
|
|
585
626
|
const fileInput = filesWrapper?.querySelector('input[type="file"]');
|
|
586
627
|
if (fileInput) fileInput.click();
|
|
587
628
|
};
|
|
@@ -598,8 +639,15 @@ function renderResourcePills(container, rids, onRemove) {
|
|
|
598
639
|
uploadLink.textContent = t("uploadText");
|
|
599
640
|
uploadLink.onclick = (e) => {
|
|
600
641
|
e.stopPropagation();
|
|
601
|
-
// Look for file input
|
|
602
|
-
|
|
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
|
+
}
|
|
603
651
|
const fileInput = filesWrapper?.querySelector('input[type="file"]');
|
|
604
652
|
if (fileInput) fileInput.click();
|
|
605
653
|
};
|
|
@@ -758,8 +806,15 @@ function renderResourcePills(container, rids, onRemove) {
|
|
|
758
806
|
slot.innerHTML =
|
|
759
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>';
|
|
760
808
|
slot.onclick = () => {
|
|
761
|
-
// Look for file input
|
|
762
|
-
|
|
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
|
+
}
|
|
763
818
|
const fileInput = filesWrapper?.querySelector('input[type="file"]');
|
|
764
819
|
if (fileInput) fileInput.click();
|
|
765
820
|
};
|
|
@@ -979,94 +1034,277 @@ function validateForm(skipValidation = false) {
|
|
|
979
1034
|
switch (element.type) {
|
|
980
1035
|
case "text":
|
|
981
1036
|
case "textarea": {
|
|
982
|
-
|
|
983
|
-
|
|
984
|
-
|
|
985
|
-
|
|
986
|
-
|
|
987
|
-
|
|
988
|
-
|
|
989
|
-
|
|
990
|
-
|
|
991
|
-
|
|
992
|
-
|
|
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
|
+
}
|
|
993
1087
|
}
|
|
994
|
-
|
|
995
|
-
|
|
996
|
-
|
|
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 "";
|
|
997
1098
|
}
|
|
998
|
-
if (
|
|
999
|
-
|
|
1000
|
-
|
|
1001
|
-
|
|
1002
|
-
|
|
1003
|
-
|
|
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");
|
|
1004
1118
|
}
|
|
1005
|
-
} catch {
|
|
1006
|
-
errors.push(`${key}: invalid pattern`);
|
|
1007
|
-
markValidity(input, "invalid pattern");
|
|
1008
1119
|
}
|
|
1120
|
+
} else if (skipValidation) {
|
|
1121
|
+
markValidity(input, null);
|
|
1122
|
+
} else {
|
|
1123
|
+
markValidity(input, null);
|
|
1009
1124
|
}
|
|
1010
|
-
|
|
1011
|
-
markValidity(input, null);
|
|
1012
|
-
} else {
|
|
1013
|
-
markValidity(input, null);
|
|
1125
|
+
return val;
|
|
1014
1126
|
}
|
|
1015
|
-
return val;
|
|
1016
1127
|
}
|
|
1017
1128
|
case "number": {
|
|
1018
|
-
|
|
1019
|
-
|
|
1020
|
-
|
|
1021
|
-
|
|
1022
|
-
|
|
1023
|
-
|
|
1024
|
-
|
|
1025
|
-
|
|
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;
|
|
1026
1212
|
markValidity(input, null);
|
|
1027
|
-
return
|
|
1028
|
-
}
|
|
1029
|
-
const v = parseFloat(raw);
|
|
1030
|
-
if (!skipValidation && !Number.isFinite(v)) {
|
|
1031
|
-
errors.push(`${key}: not a number`);
|
|
1032
|
-
markValidity(input, "not a number");
|
|
1033
|
-
return null;
|
|
1034
|
-
}
|
|
1035
|
-
if (!skipValidation && element.min !== null && v < element.min) {
|
|
1036
|
-
errors.push(`${key}: < min=${element.min}`);
|
|
1037
|
-
markValidity(input, `< min=${element.min}`);
|
|
1213
|
+
return Number(v.toFixed(d));
|
|
1038
1214
|
}
|
|
1039
|
-
if (!skipValidation && element.max !== null && v > element.max) {
|
|
1040
|
-
errors.push(`${key}: > max=${element.max}`);
|
|
1041
|
-
markValidity(input, `> max=${element.max}`);
|
|
1042
|
-
}
|
|
1043
|
-
const d = Number.isInteger(element.decimals ?? 0)
|
|
1044
|
-
? element.decimals
|
|
1045
|
-
: 0;
|
|
1046
|
-
markValidity(input, null);
|
|
1047
|
-
return Number(v.toFixed(d));
|
|
1048
1215
|
}
|
|
1049
1216
|
case "select": {
|
|
1050
|
-
|
|
1051
|
-
|
|
1052
|
-
|
|
1053
|
-
|
|
1054
|
-
|
|
1055
|
-
|
|
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;
|
|
1056
1257
|
}
|
|
1057
|
-
markValidity(input, null);
|
|
1058
|
-
return val;
|
|
1059
1258
|
}
|
|
1060
1259
|
case "file": {
|
|
1061
|
-
|
|
1062
|
-
|
|
1063
|
-
|
|
1064
|
-
|
|
1065
|
-
|
|
1066
|
-
|
|
1067
|
-
|
|
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;
|
|
1068
1307
|
}
|
|
1069
|
-
return rid || null;
|
|
1070
1308
|
}
|
|
1071
1309
|
case "files": {
|
|
1072
1310
|
// For files, we need to collect all resource IDs
|
|
@@ -1110,6 +1348,69 @@ function validateForm(skipValidation = false) {
|
|
|
1110
1348
|
}
|
|
1111
1349
|
case "group": {
|
|
1112
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
|
|
1113
1414
|
const items = [];
|
|
1114
1415
|
const itemElements = scopeRoot.querySelectorAll(`[name^="${key}["]`);
|
|
1115
1416
|
const itemCount = Math.max(
|
|
@@ -1119,35 +1420,62 @@ function validateForm(skipValidation = false) {
|
|
|
1119
1420
|
|
|
1120
1421
|
for (let i = 0; i < itemCount; i++) {
|
|
1121
1422
|
const itemData = {};
|
|
1122
|
-
// Find the specific
|
|
1423
|
+
// Find the specific container item container for scoped queries
|
|
1123
1424
|
const itemContainer =
|
|
1124
|
-
scopeRoot.querySelector(`[data-
|
|
1425
|
+
scopeRoot.querySelector(`[data-container-item="${key}[${i}]"]`) ||
|
|
1125
1426
|
scopeRoot;
|
|
1126
1427
|
element.elements.forEach((child) => {
|
|
1127
|
-
|
|
1128
|
-
|
|
1129
|
-
|
|
1130
|
-
|
|
1131
|
-
|
|
1132
|
-
|
|
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
|
+
}
|
|
1133
1439
|
});
|
|
1134
1440
|
items.push(itemData);
|
|
1135
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
|
+
|
|
1136
1459
|
return items;
|
|
1137
1460
|
} else {
|
|
1138
|
-
const
|
|
1139
|
-
// Find the specific
|
|
1140
|
-
const
|
|
1141
|
-
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;
|
|
1142
1465
|
element.elements.forEach((child) => {
|
|
1143
|
-
|
|
1144
|
-
|
|
1145
|
-
|
|
1146
|
-
|
|
1147
|
-
|
|
1148
|
-
|
|
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
|
+
}
|
|
1149
1477
|
});
|
|
1150
|
-
return
|
|
1478
|
+
return containerData;
|
|
1151
1479
|
}
|
|
1152
1480
|
}
|
|
1153
1481
|
default:
|
|
@@ -1156,7 +1484,12 @@ function validateForm(skipValidation = false) {
|
|
|
1156
1484
|
}
|
|
1157
1485
|
|
|
1158
1486
|
state.schema.elements.forEach((element) => {
|
|
1159
|
-
|
|
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
|
+
}
|
|
1160
1493
|
});
|
|
1161
1494
|
|
|
1162
1495
|
return {
|
|
@@ -1185,6 +1518,118 @@ function renderTextElement(element, ctx, wrapper, pathKey) {
|
|
|
1185
1518
|
wrapper.appendChild(textHint);
|
|
1186
1519
|
}
|
|
1187
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
|
+
|
|
1188
1633
|
function renderTextareaElement(element, ctx, wrapper, pathKey) {
|
|
1189
1634
|
const textareaInput = document.createElement("textarea");
|
|
1190
1635
|
textareaInput.className =
|
|
@@ -1203,6 +1648,118 @@ function renderTextareaElement(element, ctx, wrapper, pathKey) {
|
|
|
1203
1648
|
wrapper.appendChild(textareaHint);
|
|
1204
1649
|
}
|
|
1205
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
|
+
|
|
1206
1763
|
function renderNumberElement(element, ctx, wrapper, pathKey) {
|
|
1207
1764
|
const numberInput = document.createElement("input");
|
|
1208
1765
|
numberInput.type = "number";
|
|
@@ -1224,6 +1781,121 @@ function renderNumberElement(element, ctx, wrapper, pathKey) {
|
|
|
1224
1781
|
wrapper.appendChild(numberHint);
|
|
1225
1782
|
}
|
|
1226
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
|
+
|
|
1227
1899
|
function renderSelectElement(element, ctx, wrapper, pathKey) {
|
|
1228
1900
|
const selectInput = document.createElement("select");
|
|
1229
1901
|
selectInput.className =
|
|
@@ -1250,6 +1922,127 @@ function renderSelectElement(element, ctx, wrapper, pathKey) {
|
|
|
1250
1922
|
wrapper.appendChild(selectHint);
|
|
1251
1923
|
}
|
|
1252
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
|
+
|
|
1253
2046
|
function renderFileElement(element, ctx, wrapper, pathKey) {
|
|
1254
2047
|
if (state.config.readonly) {
|
|
1255
2048
|
// Readonly mode: use common preview function
|
|
@@ -1468,6 +2261,93 @@ function renderFilesElement(element, ctx, wrapper, pathKey) {
|
|
|
1468
2261
|
}
|
|
1469
2262
|
}
|
|
1470
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
|
+
|
|
1471
2351
|
function addPrefillFilesToIndex(initialFiles) {
|
|
1472
2352
|
if (initialFiles.length > 0) {
|
|
1473
2353
|
initialFiles.forEach((resourceId) => {
|
|
@@ -1591,13 +2471,20 @@ function renderRepeatableGroup(element, ctx, itemsWrap, left, groupWrap) {
|
|
|
1591
2471
|
const item = document.createElement("div");
|
|
1592
2472
|
item.className =
|
|
1593
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);
|
|
1594
2478
|
const subCtx = {
|
|
1595
|
-
path:
|
|
2479
|
+
path: fullPath,
|
|
1596
2480
|
prefill: prefillObj || {},
|
|
1597
2481
|
};
|
|
1598
|
-
element.elements.forEach((child) =>
|
|
1599
|
-
|
|
1600
|
-
|
|
2482
|
+
element.elements.forEach((child) => {
|
|
2483
|
+
// Skip rendering hidden child elements
|
|
2484
|
+
if (!child.hidden) {
|
|
2485
|
+
item.appendChild(renderElement(child, subCtx));
|
|
2486
|
+
}
|
|
2487
|
+
});
|
|
1601
2488
|
|
|
1602
2489
|
// Only add remove button in edit mode
|
|
1603
2490
|
if (!state.config.readonly) {
|
|
@@ -1661,13 +2548,224 @@ function renderSingleGroup(element, ctx, itemsWrap, left, groupWrap) {
|
|
|
1661
2548
|
path: pathJoin(ctx.path, element.key),
|
|
1662
2549
|
prefill: ctx.prefill?.[element.key] || {},
|
|
1663
2550
|
};
|
|
1664
|
-
element.elements.forEach((child) =>
|
|
1665
|
-
|
|
1666
|
-
|
|
2551
|
+
element.elements.forEach((child) => {
|
|
2552
|
+
// Skip rendering hidden child elements
|
|
2553
|
+
if (!child.hidden) {
|
|
2554
|
+
itemsWrap.appendChild(renderElement(child, subCtx));
|
|
2555
|
+
}
|
|
2556
|
+
});
|
|
1667
2557
|
groupWrap.appendChild(itemsWrap);
|
|
1668
2558
|
left.innerHTML = `<span>${element.label || element.key}</span>`;
|
|
1669
2559
|
}
|
|
1670
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
|
+
|
|
1671
2769
|
// Common file preview rendering function for readonly mode
|
|
1672
2770
|
function renderFilePreviewReadonly(resourceId, fileName) {
|
|
1673
2771
|
const meta = state.resourceIndex.get(resourceId);
|