@dmitryvim/form-builder 0.1.5 → 0.1.7
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/README.md +120 -29
- package/dist/example.html +40 -40
- package/dist/form-builder.js +233 -24
- package/dist/index.html +1 -1
- package/docs/schema.md +10 -8
- package/package.json +1 -1
package/dist/README.md
CHANGED
|
@@ -64,19 +64,40 @@ Renders a form in the specified container.
|
|
|
64
64
|
**Parameters:**
|
|
65
65
|
- `schema` - JSON schema object (v0.3)
|
|
66
66
|
- `container` - DOM element to render form in
|
|
67
|
-
- `options` - Configuration object
|
|
67
|
+
- `options` - Configuration object (optional)
|
|
68
68
|
|
|
69
69
|
**Options:**
|
|
70
70
|
- `prefill` - Object with prefilled values
|
|
71
|
-
- `readonly` - Boolean, renders form in read-only mode
|
|
72
|
-
- `
|
|
73
|
-
- `
|
|
74
|
-
- `
|
|
71
|
+
- `readonly` - Boolean, renders form in read-only mode (hides buttons)
|
|
72
|
+
- `debug` - Boolean, enables debug logging to console
|
|
73
|
+
- `onSubmit` - Callback for form submission `(data) => {}`
|
|
74
|
+
- `onDraft` - Callback for draft saving `(data) => {}`
|
|
75
|
+
- `onError` - Callback for validation errors `(errors) => {}`
|
|
76
|
+
- `buttons` - Custom button text: `{ submit: "Start Workflow", draft: "Save Progress" }`
|
|
77
|
+
|
|
78
|
+
**Important:** Upload handlers must return a **string** resourceId, not an object.
|
|
75
79
|
|
|
76
80
|
### FormBuilder.validateSchema(schema)
|
|
77
81
|
|
|
78
82
|
Validates a JSON schema and returns array of errors.
|
|
79
83
|
|
|
84
|
+
**Returns:** `string[]` - Array of error messages (empty if valid)
|
|
85
|
+
|
|
86
|
+
### FormBuilder.collectAndValidate(schema, formElement, skipValidation)
|
|
87
|
+
|
|
88
|
+
⚠️ **Direct API for advanced users only**
|
|
89
|
+
|
|
90
|
+
Collects form data and validates it. Most users should use `onSubmit`/`onDraft` callbacks instead.
|
|
91
|
+
|
|
92
|
+
**Parameters:**
|
|
93
|
+
- `schema` - The form schema
|
|
94
|
+
- `formElement` - The form DOM element
|
|
95
|
+
- `skipValidation` - Boolean, skip validation (for drafts)
|
|
96
|
+
|
|
97
|
+
**Returns:** `{ result: object, errors: string[] }`
|
|
98
|
+
|
|
99
|
+
**Important:** This is the correct destructuring format - there is no `.valid` or `.data` property.
|
|
100
|
+
|
|
80
101
|
### FormBuilder.setUploadHandler(uploadFn)
|
|
81
102
|
|
|
82
103
|
Sets custom file upload handler:
|
|
@@ -109,47 +130,117 @@ FormBuilder.setDownloadHandler(async (resourceId, fileName) => {
|
|
|
109
130
|
- `select` - Dropdown selection
|
|
110
131
|
- `file` - Single file upload
|
|
111
132
|
- `files` - Multiple file upload
|
|
112
|
-
- `group` - Nested objects with optional repeat
|
|
133
|
+
- `group` - Nested objects with optional repeat and custom element titles
|
|
134
|
+
|
|
135
|
+
## Troubleshooting Common Issues
|
|
136
|
+
|
|
137
|
+
### ❌ Upload Handler Returns Object
|
|
138
|
+
```javascript
|
|
139
|
+
// Wrong - causes "resourceId.slice is not a function"
|
|
140
|
+
FormBuilder.setUploadHandler(async (file) => {
|
|
141
|
+
const response = await uploadFile(file);
|
|
142
|
+
return { reference: response.id, name: file.name }; // ❌ Object
|
|
143
|
+
});
|
|
144
|
+
|
|
145
|
+
// Correct - return string only
|
|
146
|
+
FormBuilder.setUploadHandler(async (file) => {
|
|
147
|
+
const response = await uploadFile(file);
|
|
148
|
+
return response.id; // ✅ String resourceId
|
|
149
|
+
});
|
|
150
|
+
```
|
|
151
|
+
|
|
152
|
+
### ❌ Wrong API Usage
|
|
153
|
+
```javascript
|
|
154
|
+
// Wrong - no .valid or .data properties
|
|
155
|
+
const result = FormBuilder.collectAndValidate(schema, form);
|
|
156
|
+
if (result.valid) { // ❌ Property doesn't exist
|
|
157
|
+
console.log(result.data); // ❌ Property doesn't exist
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
// Correct - use destructuring
|
|
161
|
+
const { result, errors } = FormBuilder.collectAndValidate(schema, form);
|
|
162
|
+
if (errors.length === 0) { // ✅ Check errors array
|
|
163
|
+
console.log(result); // ✅ Data is in result
|
|
164
|
+
}
|
|
165
|
+
```
|
|
166
|
+
|
|
167
|
+
### ❌ Group Elements Missing Elements Array
|
|
168
|
+
```javascript
|
|
169
|
+
// Wrong - causes "group.elements must be array"
|
|
170
|
+
{
|
|
171
|
+
type: 'group',
|
|
172
|
+
key: 'address',
|
|
173
|
+
// Missing elements array ❌
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
// Correct - always include elements
|
|
177
|
+
{
|
|
178
|
+
type: 'group',
|
|
179
|
+
key: 'address',
|
|
180
|
+
label: 'Address Information',
|
|
181
|
+
element_label: 'Address #$index', // ✅ Optional custom title
|
|
182
|
+
elements: [ // ✅ Required array
|
|
183
|
+
{ type: 'text', key: 'street', label: 'Street' }
|
|
184
|
+
]
|
|
185
|
+
}
|
|
186
|
+
```
|
|
187
|
+
|
|
188
|
+
### ❌ Readonly Mode Confusion
|
|
189
|
+
```javascript
|
|
190
|
+
// This only hides the default buttons, fields remain editable
|
|
191
|
+
FormBuilder.renderForm(schema, container, { readonly: true });
|
|
192
|
+
|
|
193
|
+
// For true read-only, you need to disable inputs manually or use a different approach
|
|
194
|
+
```
|
|
113
195
|
|
|
114
196
|
## Complete Example
|
|
115
197
|
|
|
116
198
|
```javascript
|
|
117
199
|
const schema = {
|
|
118
200
|
"version": "0.3",
|
|
119
|
-
"title": "
|
|
201
|
+
"title": "Video Cover Generation",
|
|
120
202
|
"elements": [
|
|
121
|
-
{
|
|
122
|
-
"type": "file",
|
|
123
|
-
"key": "cover",
|
|
124
|
-
"label": "Cover Image",
|
|
125
|
-
"required": true,
|
|
126
|
-
"accept": {
|
|
127
|
-
"extensions": ["jpg", "png"],
|
|
128
|
-
"mime": ["image/jpeg", "image/png"]
|
|
129
|
-
},
|
|
130
|
-
"maxSizeMB": 5
|
|
131
|
-
},
|
|
132
203
|
{
|
|
133
204
|
"type": "text",
|
|
134
|
-
"key": "
|
|
135
|
-
"label": "
|
|
136
|
-
"required":
|
|
137
|
-
"maxLength": 100
|
|
205
|
+
"key": "concept",
|
|
206
|
+
"label": "Animation Concept (Optional)",
|
|
207
|
+
"required": false,
|
|
208
|
+
"maxLength": 100,
|
|
209
|
+
"placeholder": "e.g., fun, elegant, dynamic, cozy..."
|
|
138
210
|
},
|
|
139
211
|
{
|
|
140
212
|
"type": "group",
|
|
141
|
-
"key": "
|
|
142
|
-
"label": "
|
|
213
|
+
"key": "slides_input",
|
|
214
|
+
"label": "Video Slides",
|
|
215
|
+
"element_label": "Slide $index", // 🆕 Custom title for each item
|
|
143
216
|
"repeat": {
|
|
144
217
|
"min": 1,
|
|
145
|
-
"max":
|
|
218
|
+
"max": 10
|
|
146
219
|
},
|
|
147
220
|
"elements": [
|
|
148
221
|
{
|
|
149
|
-
"type": "
|
|
150
|
-
"key": "
|
|
151
|
-
"label": "
|
|
152
|
-
"required": true
|
|
222
|
+
"type": "file",
|
|
223
|
+
"key": "main_image",
|
|
224
|
+
"label": "Main Image",
|
|
225
|
+
"required": true,
|
|
226
|
+
"accept": {
|
|
227
|
+
"extensions": ["png", "jpg", "jpeg", "webp"],
|
|
228
|
+
"mime": ["image/png", "image/jpeg", "image/webp"]
|
|
229
|
+
},
|
|
230
|
+
"maxSizeMB": 25
|
|
231
|
+
},
|
|
232
|
+
{
|
|
233
|
+
"type": "files",
|
|
234
|
+
"key": "elements",
|
|
235
|
+
"label": "Element Images (Optional)",
|
|
236
|
+
"required": false,
|
|
237
|
+
"accept": {
|
|
238
|
+
"extensions": ["png", "jpg", "jpeg", "webp"],
|
|
239
|
+
"mime": ["image/png", "image/jpeg", "image/webp"]
|
|
240
|
+
},
|
|
241
|
+
"minCount": 0,
|
|
242
|
+
"maxCount": 10,
|
|
243
|
+
"maxSizeMB": 10
|
|
153
244
|
}
|
|
154
245
|
]
|
|
155
246
|
}
|
package/dist/example.html
CHANGED
|
@@ -23,54 +23,54 @@
|
|
|
23
23
|
|
|
24
24
|
<script src="form-builder.js"></script>
|
|
25
25
|
<script>
|
|
26
|
-
// Example schema
|
|
26
|
+
// Example schema demonstrating element_label feature
|
|
27
27
|
const schema = {
|
|
28
28
|
"version": "0.3",
|
|
29
|
-
"title": "
|
|
29
|
+
"title": "Video Cover Generation",
|
|
30
30
|
"elements": [
|
|
31
31
|
{
|
|
32
32
|
"type": "text",
|
|
33
|
-
"key": "
|
|
34
|
-
"label": "
|
|
35
|
-
"required":
|
|
36
|
-
"
|
|
37
|
-
"
|
|
38
|
-
},
|
|
39
|
-
{
|
|
40
|
-
"type": "text",
|
|
41
|
-
"key": "email",
|
|
42
|
-
"label": "Email Address",
|
|
43
|
-
"required": true,
|
|
44
|
-
"pattern": "^[^@\\s]+@[^@\\s]+\\.[^@\\s]+$"
|
|
45
|
-
},
|
|
46
|
-
{
|
|
47
|
-
"type": "select",
|
|
48
|
-
"key": "category",
|
|
49
|
-
"label": "Category",
|
|
50
|
-
"required": true,
|
|
51
|
-
"options": [
|
|
52
|
-
{ "value": "general", "label": "General Inquiry" },
|
|
53
|
-
{ "value": "support", "label": "Technical Support" },
|
|
54
|
-
{ "value": "billing", "label": "Billing Question" }
|
|
55
|
-
]
|
|
56
|
-
},
|
|
57
|
-
{
|
|
58
|
-
"type": "textarea",
|
|
59
|
-
"key": "message",
|
|
60
|
-
"label": "Message",
|
|
61
|
-
"required": true,
|
|
62
|
-
"maxLength": 1000
|
|
33
|
+
"key": "concept",
|
|
34
|
+
"label": "Animation Concept (Optional)",
|
|
35
|
+
"required": false,
|
|
36
|
+
"maxLength": 100,
|
|
37
|
+
"placeholder": "e.g., fun, elegant, dynamic, cozy..."
|
|
63
38
|
},
|
|
64
39
|
{
|
|
65
|
-
"type": "
|
|
66
|
-
"key": "
|
|
67
|
-
"label": "
|
|
68
|
-
"
|
|
69
|
-
"
|
|
70
|
-
"
|
|
71
|
-
"
|
|
40
|
+
"type": "group",
|
|
41
|
+
"key": "slides_input",
|
|
42
|
+
"label": "Video Slides",
|
|
43
|
+
"element_label": "Slide $index",
|
|
44
|
+
"repeat": {
|
|
45
|
+
"min": 1,
|
|
46
|
+
"max": 5
|
|
72
47
|
},
|
|
73
|
-
"
|
|
48
|
+
"elements": [
|
|
49
|
+
{
|
|
50
|
+
"type": "file",
|
|
51
|
+
"key": "main_image",
|
|
52
|
+
"label": "Main Image",
|
|
53
|
+
"required": true,
|
|
54
|
+
"accept": {
|
|
55
|
+
"extensions": ["png", "jpg", "jpeg", "webp"],
|
|
56
|
+
"mime": ["image/png", "image/jpeg", "image/webp"]
|
|
57
|
+
},
|
|
58
|
+
"maxSizeMB": 25
|
|
59
|
+
},
|
|
60
|
+
{
|
|
61
|
+
"type": "files",
|
|
62
|
+
"key": "elements",
|
|
63
|
+
"label": "Element Images (Optional)",
|
|
64
|
+
"required": false,
|
|
65
|
+
"accept": {
|
|
66
|
+
"extensions": ["png", "jpg", "jpeg", "webp"],
|
|
67
|
+
"mime": ["image/png", "image/jpeg", "image/webp"]
|
|
68
|
+
},
|
|
69
|
+
"minCount": 0,
|
|
70
|
+
"maxCount": 5,
|
|
71
|
+
"maxSizeMB": 10
|
|
72
|
+
}
|
|
73
|
+
]
|
|
74
74
|
}
|
|
75
75
|
]
|
|
76
76
|
};
|
package/dist/form-builder.js
CHANGED
|
@@ -71,7 +71,7 @@
|
|
|
71
71
|
if (seen.has(el.key)) errors.push(`${path}: duplicate key "${el.key}"`);
|
|
72
72
|
seen.add(el.key);
|
|
73
73
|
}
|
|
74
|
-
if (el.default !== undefined && (el.type === 'file' || el.type === 'files')) {
|
|
74
|
+
if (el.default !== undefined && (el.type === 'file' || el.type === 'files' || el.type === 'videos')) {
|
|
75
75
|
errors.push(`${here}: default forbidden for "${el.type}"`);
|
|
76
76
|
}
|
|
77
77
|
|
|
@@ -112,6 +112,11 @@
|
|
|
112
112
|
errors.push(`${here}: minCount > maxCount`);
|
|
113
113
|
}
|
|
114
114
|
}
|
|
115
|
+
if (el.type === 'videos') {
|
|
116
|
+
if (el.minCount != null && el.maxCount != null && el.minCount > el.maxCount) {
|
|
117
|
+
errors.push(`${here}: minCount > maxCount`);
|
|
118
|
+
}
|
|
119
|
+
}
|
|
115
120
|
if (el.type === 'group') {
|
|
116
121
|
if (!Array.isArray(el.elements)) errors.push(`${here}: group.elements must be array`);
|
|
117
122
|
if (el.repeat) {
|
|
@@ -119,6 +124,10 @@
|
|
|
119
124
|
errors.push(`${here}: repeat.min > repeat.max`);
|
|
120
125
|
}
|
|
121
126
|
}
|
|
127
|
+
// Validate element_label if provided (optional)
|
|
128
|
+
if (el.element_label != null && typeof el.element_label !== 'string') {
|
|
129
|
+
errors.push(`${here}: element_label must be a string`);
|
|
130
|
+
}
|
|
122
131
|
if (Array.isArray(el.elements)) validateElements(el.elements, pathJoin(path, el.key));
|
|
123
132
|
}
|
|
124
133
|
});
|
|
@@ -220,6 +229,10 @@
|
|
|
220
229
|
if (element.minCount != null) bits.push(`minCount=${element.minCount}`);
|
|
221
230
|
if (element.maxCount != null) bits.push(`maxCount=${element.maxCount}`);
|
|
222
231
|
}
|
|
232
|
+
if (element.type === 'videos') {
|
|
233
|
+
if (element.minCount != null) bits.push(`minCount=${element.minCount}`);
|
|
234
|
+
if (element.maxCount != null) bits.push(`maxCount=${element.maxCount}`);
|
|
235
|
+
}
|
|
223
236
|
|
|
224
237
|
hint.textContent = [bits.join(' • '), extra].filter(Boolean).join(' | ');
|
|
225
238
|
return hint;
|
|
@@ -585,16 +598,137 @@
|
|
|
585
598
|
wrapper.appendChild(makeFieldHint(element, 'Multiple files return resource ID array'));
|
|
586
599
|
break;
|
|
587
600
|
}
|
|
601
|
+
case 'videos': {
|
|
602
|
+
const hid = document.createElement('input');
|
|
603
|
+
hid.type = 'hidden';
|
|
604
|
+
hid.name = pathKey;
|
|
605
|
+
hid.dataset.type = 'videos';
|
|
606
|
+
|
|
607
|
+
const list = document.createElement('div');
|
|
608
|
+
list.className = 'flex flex-col gap-3 mt-2';
|
|
609
|
+
|
|
610
|
+
const picker = document.createElement('input');
|
|
611
|
+
picker.type = 'file';
|
|
612
|
+
picker.multiple = true;
|
|
613
|
+
{
|
|
614
|
+
const acc = [];
|
|
615
|
+
if (element.accept?.mime && Array.isArray(element.accept.mime) && element.accept.mime.length) {
|
|
616
|
+
acc.push(...element.accept.mime);
|
|
617
|
+
}
|
|
618
|
+
if (element.accept?.extensions && Array.isArray(element.accept.extensions) && element.accept.extensions.length) {
|
|
619
|
+
acc.push(...element.accept.extensions.map(ext => `.${ext}`));
|
|
620
|
+
}
|
|
621
|
+
picker.accept = acc.length ? acc.join(',') : 'video/*';
|
|
622
|
+
}
|
|
623
|
+
|
|
624
|
+
const renderVideos = (rids) => {
|
|
625
|
+
list.innerHTML = '';
|
|
626
|
+
rids.forEach(rid => {
|
|
627
|
+
const meta = resourceIndex.get(rid) || {};
|
|
628
|
+
const row = document.createElement('div');
|
|
629
|
+
row.className = 'flex items-start gap-3';
|
|
630
|
+
const video = document.createElement('video');
|
|
631
|
+
video.controls = true;
|
|
632
|
+
video.className = 'w-48 max-w-full rounded border border-gray-300';
|
|
633
|
+
// Use thumbnail as poster instead of loading video src
|
|
634
|
+
if (config.getThumbnail) {
|
|
635
|
+
Promise.resolve(config.getThumbnail(rid)).then(url => {
|
|
636
|
+
if (url) {
|
|
637
|
+
video.poster = url;
|
|
638
|
+
}
|
|
639
|
+
}).catch(() => {});
|
|
640
|
+
}
|
|
641
|
+
const info = document.createElement('div');
|
|
642
|
+
info.className = 'flex-1 text-sm text-gray-700';
|
|
643
|
+
info.textContent = `${meta.name || 'video'} (${formatFileSize(meta.size || 0)})`;
|
|
644
|
+
const actions = document.createElement('div');
|
|
645
|
+
actions.className = 'flex items-center gap-2';
|
|
646
|
+
const downloadBtn = document.createElement('button');
|
|
647
|
+
downloadBtn.type = 'button';
|
|
648
|
+
downloadBtn.className = 'px-2 py-1 text-xs bg-gray-100 hover:bg-gray-200 rounded';
|
|
649
|
+
downloadBtn.textContent = 'Download';
|
|
650
|
+
downloadBtn.addEventListener('click', async () => {
|
|
651
|
+
if (config.downloadFile && typeof config.downloadFile === 'function') {
|
|
652
|
+
try { await config.downloadFile(rid, meta.name || 'video'); } catch(_) {}
|
|
653
|
+
} else {
|
|
654
|
+
console.log('Download simulated:', rid, meta.name || 'video');
|
|
655
|
+
}
|
|
656
|
+
});
|
|
657
|
+
const remove = document.createElement('button');
|
|
658
|
+
remove.type = 'button';
|
|
659
|
+
remove.className = 'px-2 py-1 text-xs bg-red-500 hover:bg-red-600 text-white rounded';
|
|
660
|
+
remove.textContent = 'Remove';
|
|
661
|
+
remove.addEventListener('click', () => {
|
|
662
|
+
const arr = parseJSONSafe(hid.value, []);
|
|
663
|
+
const next = Array.isArray(arr) ? arr.filter(x => x !== rid) : [];
|
|
664
|
+
hid.value = JSON.stringify(next);
|
|
665
|
+
renderVideos(next);
|
|
666
|
+
});
|
|
667
|
+
row.appendChild(video);
|
|
668
|
+
row.appendChild(info);
|
|
669
|
+
actions.appendChild(downloadBtn);
|
|
670
|
+
actions.appendChild(remove);
|
|
671
|
+
row.appendChild(actions);
|
|
672
|
+
list.appendChild(row);
|
|
673
|
+
});
|
|
674
|
+
};
|
|
675
|
+
|
|
676
|
+
picker.addEventListener('change', async () => {
|
|
677
|
+
let arr = parseJSONSafe(hid.value, []);
|
|
678
|
+
if (!Array.isArray(arr)) arr = [];
|
|
679
|
+
if (picker.files && picker.files.length) {
|
|
680
|
+
for (const file of picker.files) {
|
|
681
|
+
const err = fileValidationError(element, file);
|
|
682
|
+
if (err) {
|
|
683
|
+
markValidity(picker, err);
|
|
684
|
+
return;
|
|
685
|
+
}
|
|
686
|
+
// additionally ensure it's a video
|
|
687
|
+
if (!file.type.startsWith('video/')) {
|
|
688
|
+
markValidity(picker, 'mime not allowed: ' + file.type);
|
|
689
|
+
return;
|
|
690
|
+
}
|
|
691
|
+
}
|
|
692
|
+
for (const file of picker.files) {
|
|
693
|
+
const rid = await makeResourceIdFromFile(file);
|
|
694
|
+
resourceIndex.set(rid, { name: file.name, type: file.type, size: file.size });
|
|
695
|
+
arr.push(rid);
|
|
696
|
+
}
|
|
697
|
+
hid.value = JSON.stringify(arr);
|
|
698
|
+
renderVideos(arr);
|
|
699
|
+
markValidity(picker, null);
|
|
700
|
+
}
|
|
701
|
+
});
|
|
702
|
+
|
|
703
|
+
const pv = ctx.prefill && ctx.prefill[element.key];
|
|
704
|
+
let initial = Array.isArray(pv) ? pv.filter(Boolean) : [];
|
|
705
|
+
if (initial.length) {
|
|
706
|
+
hid.value = JSON.stringify(initial);
|
|
707
|
+
renderVideos(initial);
|
|
708
|
+
}
|
|
709
|
+
|
|
710
|
+
wrapper.appendChild(picker);
|
|
711
|
+
wrapper.appendChild(list);
|
|
712
|
+
wrapper.appendChild(hid);
|
|
713
|
+
wrapper.appendChild(makeFieldHint(element, 'Multiple videos return resource ID array'));
|
|
714
|
+
break;
|
|
715
|
+
}
|
|
588
716
|
case 'group': {
|
|
589
717
|
wrapper.dataset.group = element.key;
|
|
590
718
|
wrapper.dataset.groupPath = pathKey;
|
|
591
719
|
|
|
592
720
|
const groupWrap = document.createElement('div');
|
|
721
|
+
|
|
722
|
+
// Group title (above the whole group)
|
|
723
|
+
const groupTitle = document.createElement('div');
|
|
724
|
+
groupTitle.className = 'text-lg font-semibold text-gray-900 mb-4';
|
|
725
|
+
groupTitle.textContent = element.label || element.key;
|
|
726
|
+
groupWrap.appendChild(groupTitle);
|
|
727
|
+
|
|
593
728
|
const header = document.createElement('div');
|
|
594
729
|
header.className = 'flex items-center justify-between my-2 pb-2 border-b border-gray-200';
|
|
595
730
|
|
|
596
731
|
const left = document.createElement('div');
|
|
597
|
-
left.innerHTML = `<span>${element.label || element.key}</span>`;
|
|
598
732
|
header.appendChild(left);
|
|
599
733
|
|
|
600
734
|
const right = document.createElement('div');
|
|
@@ -619,18 +753,48 @@
|
|
|
619
753
|
const refreshControls = () => {
|
|
620
754
|
const n = countItems();
|
|
621
755
|
addBtn.disabled = n >= max;
|
|
622
|
-
left.innerHTML = `<span
|
|
756
|
+
left.innerHTML = `<span class="text-sm text-gray-600">Items: ${n} / ${max === Infinity ? '∞' : max} (min: ${min})</span>`;
|
|
757
|
+
};
|
|
758
|
+
|
|
759
|
+
const updateItemIndexes = () => {
|
|
760
|
+
const items = itemsWrap.querySelectorAll(':scope > .groupItem');
|
|
761
|
+
items.forEach((item, index) => {
|
|
762
|
+
const titleElement = item.querySelector('h4');
|
|
763
|
+
if (titleElement) {
|
|
764
|
+
let labelText;
|
|
765
|
+
if (element.element_label) {
|
|
766
|
+
labelText = element.element_label.replace('$index', index + 1);
|
|
767
|
+
} else {
|
|
768
|
+
labelText = `${element.label || element.key} #${index + 1}`;
|
|
769
|
+
}
|
|
770
|
+
titleElement.textContent = labelText;
|
|
771
|
+
}
|
|
772
|
+
});
|
|
623
773
|
};
|
|
624
774
|
|
|
625
775
|
const addItem = (prefillObj) => {
|
|
776
|
+
const itemIndex = countItems() + 1;
|
|
626
777
|
const item = document.createElement('div');
|
|
627
|
-
item.className = 'groupItem border border-dashed border-gray-300 rounded-lg p-
|
|
628
|
-
|
|
629
|
-
|
|
630
|
-
|
|
631
|
-
|
|
632
|
-
element.elements.forEach(child => item.appendChild(renderElement(child, subCtx, options)));
|
|
778
|
+
item.className = 'groupItem border border-dashed border-gray-300 rounded-lg p-4 mb-3 bg-blue-50/30';
|
|
779
|
+
|
|
780
|
+
// Individual item title with index
|
|
781
|
+
const itemTitle = document.createElement('div');
|
|
782
|
+
itemTitle.className = 'flex items-center justify-between mb-4 pb-2 border-b border-gray-300';
|
|
633
783
|
|
|
784
|
+
const itemLabel = document.createElement('h4');
|
|
785
|
+
itemLabel.className = 'text-md font-medium text-gray-800';
|
|
786
|
+
|
|
787
|
+
// Use element_label if provided, with $index placeholder support
|
|
788
|
+
let labelText;
|
|
789
|
+
if (element.element_label) {
|
|
790
|
+
labelText = element.element_label.replace('$index', itemIndex);
|
|
791
|
+
} else {
|
|
792
|
+
labelText = `${element.label || element.key} #${itemIndex}`;
|
|
793
|
+
}
|
|
794
|
+
itemLabel.textContent = labelText;
|
|
795
|
+
itemTitle.appendChild(itemLabel);
|
|
796
|
+
|
|
797
|
+
// Add remove button to title
|
|
634
798
|
const rem = document.createElement('button');
|
|
635
799
|
rem.type = 'button';
|
|
636
800
|
rem.className = 'bg-red-500 hover:bg-red-600 text-white px-2 py-1 rounded text-xs font-medium transition-colors';
|
|
@@ -639,8 +803,18 @@
|
|
|
639
803
|
if (countItems() <= (element.repeat.min ?? 0)) return;
|
|
640
804
|
itemsWrap.removeChild(item);
|
|
641
805
|
refreshControls();
|
|
806
|
+
// Re-index remaining items
|
|
807
|
+
updateItemIndexes();
|
|
642
808
|
});
|
|
643
|
-
|
|
809
|
+
itemTitle.appendChild(rem);
|
|
810
|
+
|
|
811
|
+
const subCtx = {
|
|
812
|
+
path: pathJoin(ctx.path, element.key + `[${countItems()}]`),
|
|
813
|
+
prefill: prefillObj || {}
|
|
814
|
+
};
|
|
815
|
+
|
|
816
|
+
item.appendChild(itemTitle);
|
|
817
|
+
element.elements.forEach(child => item.appendChild(renderElement(child, subCtx, options)));
|
|
644
818
|
itemsWrap.appendChild(item);
|
|
645
819
|
refreshControls();
|
|
646
820
|
};
|
|
@@ -681,8 +855,9 @@
|
|
|
681
855
|
function collectAndValidate(schema, formRoot, skipValidation = false) {
|
|
682
856
|
const errors = [];
|
|
683
857
|
|
|
684
|
-
function collectElement(element, scopeRoot) {
|
|
858
|
+
function collectElement(element, scopeRoot, elementPath = '') {
|
|
685
859
|
const key = element.key;
|
|
860
|
+
const fullPath = elementPath ? `${elementPath}.${key}` : key;
|
|
686
861
|
|
|
687
862
|
switch (element.type) {
|
|
688
863
|
case 'text':
|
|
@@ -690,26 +865,26 @@
|
|
|
690
865
|
const input = scopeRoot.querySelector(`[name$="${key}"]`);
|
|
691
866
|
const val = (input?.value ?? '').trim();
|
|
692
867
|
if (!skipValidation && element.required && val === '') {
|
|
693
|
-
errors.push(
|
|
868
|
+
errors.push(`Field "${fullPath}" is required`);
|
|
694
869
|
markValidity(input, 'required');
|
|
695
870
|
} else if (!skipValidation && val !== '') {
|
|
696
871
|
if (element.minLength != null && val.length < element.minLength) {
|
|
697
|
-
errors.push(
|
|
872
|
+
errors.push(`Field "${fullPath}" must be at least ${element.minLength} characters`);
|
|
698
873
|
markValidity(input, `minLength=${element.minLength}`);
|
|
699
874
|
}
|
|
700
875
|
if (element.maxLength != null && val.length > element.maxLength) {
|
|
701
|
-
errors.push(
|
|
876
|
+
errors.push(`Field "${fullPath}" must be at most ${element.maxLength} characters`);
|
|
702
877
|
markValidity(input, `maxLength=${element.maxLength}`);
|
|
703
878
|
}
|
|
704
879
|
if (element.pattern) {
|
|
705
880
|
try {
|
|
706
881
|
const re = new RegExp(element.pattern);
|
|
707
882
|
if (!re.test(val)) {
|
|
708
|
-
errors.push(
|
|
883
|
+
errors.push(`Field "${fullPath}" does not match required format`);
|
|
709
884
|
markValidity(input, 'pattern mismatch');
|
|
710
885
|
}
|
|
711
886
|
} catch {
|
|
712
|
-
errors.push(
|
|
887
|
+
errors.push(`Field "${fullPath}" has invalid validation pattern`);
|
|
713
888
|
markValidity(input, 'invalid pattern');
|
|
714
889
|
}
|
|
715
890
|
}
|
|
@@ -795,6 +970,20 @@
|
|
|
795
970
|
if (hid?.previousElementSibling) markValidity(hid.previousElementSibling, null);
|
|
796
971
|
return Array.isArray(arr) ? arr : [];
|
|
797
972
|
}
|
|
973
|
+
case 'videos': {
|
|
974
|
+
const hid = scopeRoot.querySelector(`input[type="hidden"][name$="${key}"]`);
|
|
975
|
+
const arr = parseJSONSafe(hid?.value ?? '[]', []);
|
|
976
|
+
const count = Array.isArray(arr) ? arr.length : 0;
|
|
977
|
+
if (!skipValidation && !Array.isArray(arr)) errors.push(`${key}: internal value corrupted`);
|
|
978
|
+
if (!skipValidation && element.minCount != null && count < element.minCount) {
|
|
979
|
+
errors.push(`${key}: < minCount=${element.minCount}`);
|
|
980
|
+
}
|
|
981
|
+
if (!skipValidation && element.maxCount != null && count > element.maxCount) {
|
|
982
|
+
errors.push(`${key}: > maxCount=${element.maxCount}`);
|
|
983
|
+
}
|
|
984
|
+
if (hid?.previousElementSibling) markValidity(hid.previousElementSibling, null);
|
|
985
|
+
return Array.isArray(arr) ? arr : [];
|
|
986
|
+
}
|
|
798
987
|
case 'group': {
|
|
799
988
|
const groupWrapper = scopeRoot.querySelector(`[data-group="${key}"]`);
|
|
800
989
|
if (!groupWrapper) {
|
|
@@ -815,10 +1004,10 @@
|
|
|
815
1004
|
const max = element.repeat.max ?? Infinity;
|
|
816
1005
|
if (!skipValidation && n < min) errors.push(`${key}: count < min=${min}`);
|
|
817
1006
|
if (!skipValidation && n > max) errors.push(`${key}: count > max=${max}`);
|
|
818
|
-
items.forEach(item => {
|
|
1007
|
+
items.forEach((item, index) => {
|
|
819
1008
|
const obj = {};
|
|
820
1009
|
element.elements.forEach(child => {
|
|
821
|
-
obj[child.key] = collectElement(child, item);
|
|
1010
|
+
obj[child.key] = collectElement(child, item, `${fullPath}[${index}]`);
|
|
822
1011
|
});
|
|
823
1012
|
out.push(obj);
|
|
824
1013
|
});
|
|
@@ -826,7 +1015,7 @@
|
|
|
826
1015
|
} else {
|
|
827
1016
|
const obj = {};
|
|
828
1017
|
element.elements.forEach(child => {
|
|
829
|
-
obj[child.key] = collectElement(child, itemsWrap);
|
|
1018
|
+
obj[child.key] = collectElement(child, itemsWrap, fullPath);
|
|
830
1019
|
});
|
|
831
1020
|
return obj;
|
|
832
1021
|
}
|
|
@@ -839,7 +1028,7 @@
|
|
|
839
1028
|
|
|
840
1029
|
const result = {};
|
|
841
1030
|
schema.elements.forEach(element => {
|
|
842
|
-
result[element.key] = collectElement(element, formRoot);
|
|
1031
|
+
result[element.key] = collectElement(element, formRoot, '');
|
|
843
1032
|
});
|
|
844
1033
|
|
|
845
1034
|
return { result, errors };
|
|
@@ -847,7 +1036,20 @@
|
|
|
847
1036
|
|
|
848
1037
|
// Main form rendering function
|
|
849
1038
|
function renderForm(schema, container, options = {}) {
|
|
850
|
-
const {
|
|
1039
|
+
const {
|
|
1040
|
+
prefill = {},
|
|
1041
|
+
readonly = false,
|
|
1042
|
+
debug = false,
|
|
1043
|
+
onSubmit,
|
|
1044
|
+
onDraft,
|
|
1045
|
+
onError,
|
|
1046
|
+
buttons = {}
|
|
1047
|
+
} = options;
|
|
1048
|
+
|
|
1049
|
+
if (debug) {
|
|
1050
|
+
console.log('[FormBuilder Debug] Rendering form with schema:', schema);
|
|
1051
|
+
console.log('[FormBuilder Debug] Options:', options);
|
|
1052
|
+
}
|
|
851
1053
|
|
|
852
1054
|
// Validate schema first
|
|
853
1055
|
const schemaErrors = validateSchema(schema);
|
|
@@ -877,9 +1079,11 @@
|
|
|
877
1079
|
const submitBtn = document.createElement('button');
|
|
878
1080
|
submitBtn.type = 'button';
|
|
879
1081
|
submitBtn.className = 'bg-blue-600 text-white px-4 py-2 rounded-lg hover:bg-blue-700 transition-colors';
|
|
880
|
-
submitBtn.textContent = 'Submit Form';
|
|
1082
|
+
submitBtn.textContent = buttons.submit || 'Submit Form';
|
|
881
1083
|
submitBtn.addEventListener('click', () => {
|
|
1084
|
+
if (debug) console.log('[FormBuilder Debug] Submit button clicked');
|
|
882
1085
|
const { result, errors } = collectAndValidate(schema, formEl, false);
|
|
1086
|
+
if (debug) console.log('[FormBuilder Debug] Validation result:', { result, errors });
|
|
883
1087
|
if (errors.length > 0) {
|
|
884
1088
|
if (onError) onError(errors);
|
|
885
1089
|
} else {
|
|
@@ -890,9 +1094,11 @@
|
|
|
890
1094
|
const draftBtn = document.createElement('button');
|
|
891
1095
|
draftBtn.type = 'button';
|
|
892
1096
|
draftBtn.className = 'bg-gray-600 text-white px-4 py-2 rounded-lg hover:bg-gray-700 transition-colors';
|
|
893
|
-
draftBtn.textContent = 'Save Draft';
|
|
1097
|
+
draftBtn.textContent = buttons.draft || 'Save Draft';
|
|
894
1098
|
draftBtn.addEventListener('click', () => {
|
|
1099
|
+
if (debug) console.log('[FormBuilder Debug] Draft button clicked');
|
|
895
1100
|
const { result } = collectAndValidate(schema, formEl, true); // Skip validation for drafts
|
|
1101
|
+
if (debug) console.log('[FormBuilder Debug] Draft result:', result);
|
|
896
1102
|
if (onDraft) onDraft(result);
|
|
897
1103
|
});
|
|
898
1104
|
|
|
@@ -940,6 +1146,9 @@
|
|
|
940
1146
|
case 'files':
|
|
941
1147
|
obj[el.key] = [];
|
|
942
1148
|
break;
|
|
1149
|
+
case 'videos':
|
|
1150
|
+
obj[el.key] = [];
|
|
1151
|
+
break;
|
|
943
1152
|
case 'group':
|
|
944
1153
|
if (el.repeat && isPlainObject(el.repeat)) {
|
|
945
1154
|
const sample = walk(el.elements);
|
|
@@ -967,6 +1176,6 @@
|
|
|
967
1176
|
exports.setUploadHandler = setUploadHandler;
|
|
968
1177
|
exports.setDownloadHandler = setDownloadHandler;
|
|
969
1178
|
exports.setThumbnailHandler = setThumbnailHandler;
|
|
970
|
-
exports.version = '0.1.
|
|
1179
|
+
exports.version = '0.1.5';
|
|
971
1180
|
|
|
972
1181
|
}));
|
package/dist/index.html
CHANGED
package/docs/schema.md
CHANGED
|
@@ -175,27 +175,28 @@ Nested object structure with optional repetition.
|
|
|
175
175
|
}
|
|
176
176
|
```
|
|
177
177
|
|
|
178
|
-
**With Repetition:**
|
|
178
|
+
**With Repetition and Custom Element Titles:**
|
|
179
179
|
```json
|
|
180
180
|
{
|
|
181
181
|
"type": "group",
|
|
182
|
-
"key": "
|
|
183
|
-
"label": "
|
|
182
|
+
"key": "slides",
|
|
183
|
+
"label": "Video Slides",
|
|
184
|
+
"element_label": "Slide $index",
|
|
184
185
|
"repeat": {
|
|
185
186
|
"min": 1,
|
|
186
187
|
"max": 5
|
|
187
188
|
},
|
|
188
189
|
"elements": [
|
|
189
190
|
{
|
|
190
|
-
"type": "
|
|
191
|
-
"key": "
|
|
192
|
-
"label": "
|
|
191
|
+
"type": "file",
|
|
192
|
+
"key": "image",
|
|
193
|
+
"label": "Slide Image",
|
|
193
194
|
"required": true
|
|
194
195
|
},
|
|
195
196
|
{
|
|
196
197
|
"type": "text",
|
|
197
|
-
"key": "
|
|
198
|
-
"label": "
|
|
198
|
+
"key": "title",
|
|
199
|
+
"label": "Slide Title",
|
|
199
200
|
"required": true
|
|
200
201
|
}
|
|
201
202
|
]
|
|
@@ -204,6 +205,7 @@ Nested object structure with optional repetition.
|
|
|
204
205
|
|
|
205
206
|
**Properties:**
|
|
206
207
|
- `elements` - Array of nested form elements
|
|
208
|
+
- `element_label` - Optional custom title for each group item (supports `$index` placeholder)
|
|
207
209
|
- `repeat.min` - Minimum number of repetitions
|
|
208
210
|
- `repeat.max` - Maximum number of repetitions
|
|
209
211
|
|