@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 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
- - `onSubmit` - Callback for form submission
73
- - `onDraft` - Callback for draft saving
74
- - `onError` - Callback for validation errors
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": "Product Form",
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": "title",
135
- "label": "Product Title",
136
- "required": true,
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": "features",
142
- "label": "Features",
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": 5
218
+ "max": 10
146
219
  },
147
220
  "elements": [
148
221
  {
149
- "type": "text",
150
- "key": "name",
151
- "label": "Feature Name",
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": "Contact Form",
29
+ "title": "Video Cover Generation",
30
30
  "elements": [
31
31
  {
32
32
  "type": "text",
33
- "key": "name",
34
- "label": "Full Name",
35
- "required": true,
36
- "minLength": 2,
37
- "maxLength": 50
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": "file",
66
- "key": "attachment",
67
- "label": "Attachment (Optional)",
68
- "required": false,
69
- "accept": {
70
- "extensions": ["pdf", "doc", "docx", "txt"],
71
- "mime": ["application/pdf", "application/msword", "text/plain"]
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
- "maxSizeMB": 5
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
  };
@@ -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>${element.label || element.key}</span> <span class="text-gray-500 text-xs">[${n} / ${max === Infinity ? '∞' : max}, min=${min}]</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-3 mb-3 bg-blue-50/30';
628
- const subCtx = {
629
- path: pathJoin(ctx.path, element.key + `[${countItems()}]`),
630
- prefill: prefillObj || {}
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
- item.appendChild(rem);
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(`${key}: required`);
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(`${key}: minLength=${element.minLength}`);
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(`${key}: maxLength=${element.maxLength}`);
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(`${key}: pattern mismatch`);
883
+ errors.push(`Field "${fullPath}" does not match required format`);
709
884
  markValidity(input, 'pattern mismatch');
710
885
  }
711
886
  } catch {
712
- errors.push(`${key}: invalid pattern`);
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 { prefill = {}, readonly = false, onSubmit, onDraft, onError } = options;
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.4';
1179
+ exports.version = '0.1.5';
971
1180
 
972
1181
  }));
package/dist/index.html CHANGED
@@ -1,4 +1,4 @@
1
- <!doctype html>
1
+ I <!doctype html>
2
2
  <html lang="en">
3
3
  <head>
4
4
  <meta charset="utf-8" />
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": "contacts",
183
- "label": "Contacts",
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": "text",
191
- "key": "name",
192
- "label": "Contact Name",
191
+ "type": "file",
192
+ "key": "image",
193
+ "label": "Slide Image",
193
194
  "required": true
194
195
  },
195
196
  {
196
197
  "type": "text",
197
- "key": "email",
198
- "label": "Email",
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
 
package/package.json CHANGED
@@ -3,7 +3,7 @@
3
3
  "publishConfig": {
4
4
  "access": "public"
5
5
  },
6
- "version": "0.1.5",
6
+ "version": "0.1.7",
7
7
  "description": "A reusable JSON schema form builder library",
8
8
  "main": "dist/form-builder.js",
9
9
  "files": [