@dmitryvim/form-builder 0.1.5 → 0.1.6

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
  };
@@ -119,6 +119,10 @@
119
119
  errors.push(`${here}: repeat.min > repeat.max`);
120
120
  }
121
121
  }
122
+ // Validate element_label if provided (optional)
123
+ if (el.element_label != null && typeof el.element_label !== 'string') {
124
+ errors.push(`${here}: element_label must be a string`);
125
+ }
122
126
  if (Array.isArray(el.elements)) validateElements(el.elements, pathJoin(path, el.key));
123
127
  }
124
128
  });
@@ -590,11 +594,17 @@
590
594
  wrapper.dataset.groupPath = pathKey;
591
595
 
592
596
  const groupWrap = document.createElement('div');
597
+
598
+ // Group title (above the whole group)
599
+ const groupTitle = document.createElement('div');
600
+ groupTitle.className = 'text-lg font-semibold text-gray-900 mb-4';
601
+ groupTitle.textContent = element.label || element.key;
602
+ groupWrap.appendChild(groupTitle);
603
+
593
604
  const header = document.createElement('div');
594
605
  header.className = 'flex items-center justify-between my-2 pb-2 border-b border-gray-200';
595
606
 
596
607
  const left = document.createElement('div');
597
- left.innerHTML = `<span>${element.label || element.key}</span>`;
598
608
  header.appendChild(left);
599
609
 
600
610
  const right = document.createElement('div');
@@ -619,18 +629,48 @@
619
629
  const refreshControls = () => {
620
630
  const n = countItems();
621
631
  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>`;
632
+ left.innerHTML = `<span class="text-sm text-gray-600">Items: ${n} / ${max === Infinity ? '∞' : max} (min: ${min})</span>`;
633
+ };
634
+
635
+ const updateItemIndexes = () => {
636
+ const items = itemsWrap.querySelectorAll(':scope > .groupItem');
637
+ items.forEach((item, index) => {
638
+ const titleElement = item.querySelector('h4');
639
+ if (titleElement) {
640
+ let labelText;
641
+ if (element.element_label) {
642
+ labelText = element.element_label.replace('$index', index + 1);
643
+ } else {
644
+ labelText = `${element.label || element.key} #${index + 1}`;
645
+ }
646
+ titleElement.textContent = labelText;
647
+ }
648
+ });
623
649
  };
624
650
 
625
651
  const addItem = (prefillObj) => {
652
+ const itemIndex = countItems() + 1;
626
653
  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)));
654
+ item.className = 'groupItem border border-dashed border-gray-300 rounded-lg p-4 mb-3 bg-blue-50/30';
655
+
656
+ // Individual item title with index
657
+ const itemTitle = document.createElement('div');
658
+ itemTitle.className = 'flex items-center justify-between mb-4 pb-2 border-b border-gray-300';
633
659
 
660
+ const itemLabel = document.createElement('h4');
661
+ itemLabel.className = 'text-md font-medium text-gray-800';
662
+
663
+ // Use element_label if provided, with $index placeholder support
664
+ let labelText;
665
+ if (element.element_label) {
666
+ labelText = element.element_label.replace('$index', itemIndex);
667
+ } else {
668
+ labelText = `${element.label || element.key} #${itemIndex}`;
669
+ }
670
+ itemLabel.textContent = labelText;
671
+ itemTitle.appendChild(itemLabel);
672
+
673
+ // Add remove button to title
634
674
  const rem = document.createElement('button');
635
675
  rem.type = 'button';
636
676
  rem.className = 'bg-red-500 hover:bg-red-600 text-white px-2 py-1 rounded text-xs font-medium transition-colors';
@@ -639,8 +679,18 @@
639
679
  if (countItems() <= (element.repeat.min ?? 0)) return;
640
680
  itemsWrap.removeChild(item);
641
681
  refreshControls();
682
+ // Re-index remaining items
683
+ updateItemIndexes();
642
684
  });
643
- item.appendChild(rem);
685
+ itemTitle.appendChild(rem);
686
+
687
+ const subCtx = {
688
+ path: pathJoin(ctx.path, element.key + `[${countItems()}]`),
689
+ prefill: prefillObj || {}
690
+ };
691
+
692
+ item.appendChild(itemTitle);
693
+ element.elements.forEach(child => item.appendChild(renderElement(child, subCtx, options)));
644
694
  itemsWrap.appendChild(item);
645
695
  refreshControls();
646
696
  };
@@ -681,8 +731,9 @@
681
731
  function collectAndValidate(schema, formRoot, skipValidation = false) {
682
732
  const errors = [];
683
733
 
684
- function collectElement(element, scopeRoot) {
734
+ function collectElement(element, scopeRoot, elementPath = '') {
685
735
  const key = element.key;
736
+ const fullPath = elementPath ? `${elementPath}.${key}` : key;
686
737
 
687
738
  switch (element.type) {
688
739
  case 'text':
@@ -690,26 +741,26 @@
690
741
  const input = scopeRoot.querySelector(`[name$="${key}"]`);
691
742
  const val = (input?.value ?? '').trim();
692
743
  if (!skipValidation && element.required && val === '') {
693
- errors.push(`${key}: required`);
744
+ errors.push(`Field "${fullPath}" is required`);
694
745
  markValidity(input, 'required');
695
746
  } else if (!skipValidation && val !== '') {
696
747
  if (element.minLength != null && val.length < element.minLength) {
697
- errors.push(`${key}: minLength=${element.minLength}`);
748
+ errors.push(`Field "${fullPath}" must be at least ${element.minLength} characters`);
698
749
  markValidity(input, `minLength=${element.minLength}`);
699
750
  }
700
751
  if (element.maxLength != null && val.length > element.maxLength) {
701
- errors.push(`${key}: maxLength=${element.maxLength}`);
752
+ errors.push(`Field "${fullPath}" must be at most ${element.maxLength} characters`);
702
753
  markValidity(input, `maxLength=${element.maxLength}`);
703
754
  }
704
755
  if (element.pattern) {
705
756
  try {
706
757
  const re = new RegExp(element.pattern);
707
758
  if (!re.test(val)) {
708
- errors.push(`${key}: pattern mismatch`);
759
+ errors.push(`Field "${fullPath}" does not match required format`);
709
760
  markValidity(input, 'pattern mismatch');
710
761
  }
711
762
  } catch {
712
- errors.push(`${key}: invalid pattern`);
763
+ errors.push(`Field "${fullPath}" has invalid validation pattern`);
713
764
  markValidity(input, 'invalid pattern');
714
765
  }
715
766
  }
@@ -815,10 +866,10 @@
815
866
  const max = element.repeat.max ?? Infinity;
816
867
  if (!skipValidation && n < min) errors.push(`${key}: count < min=${min}`);
817
868
  if (!skipValidation && n > max) errors.push(`${key}: count > max=${max}`);
818
- items.forEach(item => {
869
+ items.forEach((item, index) => {
819
870
  const obj = {};
820
871
  element.elements.forEach(child => {
821
- obj[child.key] = collectElement(child, item);
872
+ obj[child.key] = collectElement(child, item, `${fullPath}[${index}]`);
822
873
  });
823
874
  out.push(obj);
824
875
  });
@@ -826,7 +877,7 @@
826
877
  } else {
827
878
  const obj = {};
828
879
  element.elements.forEach(child => {
829
- obj[child.key] = collectElement(child, itemsWrap);
880
+ obj[child.key] = collectElement(child, itemsWrap, fullPath);
830
881
  });
831
882
  return obj;
832
883
  }
@@ -839,7 +890,7 @@
839
890
 
840
891
  const result = {};
841
892
  schema.elements.forEach(element => {
842
- result[element.key] = collectElement(element, formRoot);
893
+ result[element.key] = collectElement(element, formRoot, '');
843
894
  });
844
895
 
845
896
  return { result, errors };
@@ -847,7 +898,20 @@
847
898
 
848
899
  // Main form rendering function
849
900
  function renderForm(schema, container, options = {}) {
850
- const { prefill = {}, readonly = false, onSubmit, onDraft, onError } = options;
901
+ const {
902
+ prefill = {},
903
+ readonly = false,
904
+ debug = false,
905
+ onSubmit,
906
+ onDraft,
907
+ onError,
908
+ buttons = {}
909
+ } = options;
910
+
911
+ if (debug) {
912
+ console.log('[FormBuilder Debug] Rendering form with schema:', schema);
913
+ console.log('[FormBuilder Debug] Options:', options);
914
+ }
851
915
 
852
916
  // Validate schema first
853
917
  const schemaErrors = validateSchema(schema);
@@ -877,9 +941,11 @@
877
941
  const submitBtn = document.createElement('button');
878
942
  submitBtn.type = 'button';
879
943
  submitBtn.className = 'bg-blue-600 text-white px-4 py-2 rounded-lg hover:bg-blue-700 transition-colors';
880
- submitBtn.textContent = 'Submit Form';
944
+ submitBtn.textContent = buttons.submit || 'Submit Form';
881
945
  submitBtn.addEventListener('click', () => {
946
+ if (debug) console.log('[FormBuilder Debug] Submit button clicked');
882
947
  const { result, errors } = collectAndValidate(schema, formEl, false);
948
+ if (debug) console.log('[FormBuilder Debug] Validation result:', { result, errors });
883
949
  if (errors.length > 0) {
884
950
  if (onError) onError(errors);
885
951
  } else {
@@ -890,9 +956,11 @@
890
956
  const draftBtn = document.createElement('button');
891
957
  draftBtn.type = 'button';
892
958
  draftBtn.className = 'bg-gray-600 text-white px-4 py-2 rounded-lg hover:bg-gray-700 transition-colors';
893
- draftBtn.textContent = 'Save Draft';
959
+ draftBtn.textContent = buttons.draft || 'Save Draft';
894
960
  draftBtn.addEventListener('click', () => {
961
+ if (debug) console.log('[FormBuilder Debug] Draft button clicked');
895
962
  const { result } = collectAndValidate(schema, formEl, true); // Skip validation for drafts
963
+ if (debug) console.log('[FormBuilder Debug] Draft result:', result);
896
964
  if (onDraft) onDraft(result);
897
965
  });
898
966
 
@@ -967,6 +1035,6 @@
967
1035
  exports.setUploadHandler = setUploadHandler;
968
1036
  exports.setDownloadHandler = setDownloadHandler;
969
1037
  exports.setThumbnailHandler = setThumbnailHandler;
970
- exports.version = '0.1.4';
1038
+ exports.version = '0.1.5';
971
1039
 
972
1040
  }));
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.6",
7
7
  "description": "A reusable JSON schema form builder library",
8
8
  "main": "dist/form-builder.js",
9
9
  "files": [