domma-cms 0.3.0 → 0.5.2

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.
Files changed (150) hide show
  1. package/README.md +3 -3
  2. package/admin/css/admin.css +1 -1
  3. package/admin/dist/domma/domma-tools.css +2313 -0
  4. package/admin/dist/domma/domma-tools.min.js +10 -0
  5. package/admin/index.html +4 -0
  6. package/admin/js/api.js +1 -1
  7. package/admin/js/app.js +8 -4
  8. package/admin/js/config/sidebar-config.js +1 -1
  9. package/admin/js/lib/markdown-toolbar.js +18 -10
  10. package/admin/js/templates/action-editor.html +171 -0
  11. package/admin/js/templates/actions-list.html +19 -0
  12. package/admin/js/templates/api-reference.html +1411 -0
  13. package/admin/js/templates/block-editor.html +158 -0
  14. package/admin/js/templates/blocks.html +8 -0
  15. package/admin/js/templates/collection-editor.html +47 -0
  16. package/admin/js/templates/collection-entries.html +3 -0
  17. package/admin/js/templates/collections.html +51 -4
  18. package/admin/js/templates/documentation.html +258 -0
  19. package/{plugins/form-builder/admin → admin/js}/templates/form-editor.html +238 -199
  20. package/{plugins/form-builder/admin → admin/js}/templates/form-submissions.html +30 -30
  21. package/{plugins/form-builder/admin/templates/forms-list.html → admin/js/templates/forms.html} +17 -17
  22. package/admin/js/templates/login.html +29 -4
  23. package/admin/js/templates/my-profile.html +17 -0
  24. package/admin/js/templates/page-editor.html +39 -0
  25. package/admin/js/templates/pages.html +6 -1
  26. package/admin/js/templates/pro-docs.html +259 -0
  27. package/admin/js/templates/role-editor.html +59 -0
  28. package/admin/js/templates/roles.html +10 -0
  29. package/admin/js/templates/settings.html +167 -23
  30. package/admin/js/templates/tutorials.html +81 -0
  31. package/admin/js/templates/user-editor.html +7 -0
  32. package/admin/js/templates/users.html +3 -26
  33. package/admin/js/templates/view-editor.html +201 -0
  34. package/admin/js/templates/view-preview.html +51 -0
  35. package/admin/js/templates/views-list.html +19 -0
  36. package/admin/js/views/action-editor.js +1 -0
  37. package/admin/js/views/actions-list.js +1 -0
  38. package/admin/js/views/api-reference.js +1 -0
  39. package/admin/js/views/block-editor.js +8 -0
  40. package/admin/js/views/blocks.js +4 -0
  41. package/admin/js/views/collection-editor.js +3 -3
  42. package/admin/js/views/collection-entries.js +1 -1
  43. package/admin/js/views/collections.js +1 -1
  44. package/admin/js/views/dashboard.js +1 -1
  45. package/admin/js/views/form-editor.js +8 -0
  46. package/admin/js/views/form-submissions.js +1 -0
  47. package/admin/js/views/forms.js +1 -0
  48. package/admin/js/views/index.js +1 -1
  49. package/admin/js/views/login.js +2 -2
  50. package/admin/js/views/media.js +1 -1
  51. package/admin/js/views/my-profile.js +1 -0
  52. package/admin/js/views/page-editor.js +34 -15
  53. package/admin/js/views/pages.js +5 -5
  54. package/admin/js/views/plugins.js +10 -10
  55. package/admin/js/views/pro-docs.js +1 -0
  56. package/admin/js/views/role-editor.js +1 -0
  57. package/admin/js/views/roles.js +4 -0
  58. package/admin/js/views/settings.js +3 -1
  59. package/admin/js/views/user-editor.js +1 -1
  60. package/admin/js/views/users.js +4 -7
  61. package/admin/js/views/view-editor.js +1 -0
  62. package/admin/js/views/view-preview.js +1 -0
  63. package/admin/js/views/views-list.js +1 -0
  64. package/bin/cli.js +1 -1
  65. package/config/auth.json +1 -0
  66. package/config/connections.json.bak +9 -0
  67. package/config/connections.json.example +9 -0
  68. package/config/navigation.json +5 -15
  69. package/config/plugins.json +19 -29
  70. package/config/server.json +6 -6
  71. package/config/site.json +16 -6
  72. package/package.json +25 -10
  73. package/plugins/example-analytics/stats.json +17 -12
  74. package/plugins/form-builder/data/forms/contacts.json +62 -62
  75. package/plugins/form-builder/data/forms/enquiries.json +103 -0
  76. package/plugins/form-builder/data/forms/feedback.json +17 -16
  77. package/plugins/form-builder/data/forms/notes.json +79 -0
  78. package/plugins/form-builder/data/forms/to-do.json +100 -0
  79. package/plugins/form-builder/data/submissions/contacts.json +1 -26
  80. package/plugins/form-builder/data/submissions/notes.json +1 -0
  81. package/plugins/form-builder/data/submissions/to-do.json +1 -0
  82. package/plugins/theme-roller/admin/templates/theme-roller.html +71 -0
  83. package/plugins/theme-roller/admin/views/theme-roller-view.js +403 -0
  84. package/plugins/theme-roller/config.js +1 -0
  85. package/plugins/theme-roller/plugin.js +233 -0
  86. package/plugins/theme-roller/plugin.json +31 -0
  87. package/plugins/theme-roller/public/active-theme.css +0 -0
  88. package/plugins/theme-roller/public/inject-head-late.html +1 -0
  89. package/public/css/forms.css +1 -0
  90. package/public/css/site.css +1 -1
  91. package/public/js/forms.js +1 -0
  92. package/public/js/site.js +1 -1
  93. package/scripts/build.js +194 -129
  94. package/scripts/pro.js +254 -0
  95. package/scripts/reset.js +33 -8
  96. package/scripts/seed.js +677 -128
  97. package/scripts/setup.js +1 -0
  98. package/server/middleware/auth.js +136 -120
  99. package/server/routes/api/actions.js +200 -0
  100. package/server/routes/api/auth.js +292 -146
  101. package/server/routes/api/blocks.js +84 -0
  102. package/server/routes/api/collections.js +79 -27
  103. package/{plugins/form-builder/plugin.js → server/routes/api/forms.js} +491 -505
  104. package/server/routes/api/layouts.js +49 -39
  105. package/server/routes/api/media.js +118 -92
  106. package/server/routes/api/navigation.js +40 -36
  107. package/server/routes/api/pages.js +132 -118
  108. package/server/routes/api/plugins.js +6 -3
  109. package/server/routes/api/settings.js +104 -88
  110. package/server/routes/api/users.js +27 -19
  111. package/server/routes/api/views.js +148 -0
  112. package/server/routes/public.js +124 -108
  113. package/server/server.js +269 -181
  114. package/server/services/actions.js +387 -0
  115. package/server/services/adapterRegistry.js +98 -0
  116. package/server/services/adapters/FileAdapter.js +192 -0
  117. package/server/services/adapters/MongoAdapter.js +220 -0
  118. package/server/services/blocks.js +162 -0
  119. package/server/services/collections.js +74 -86
  120. package/server/services/connectionManager.js +102 -0
  121. package/server/services/content.js +312 -307
  122. package/server/services/email.js +126 -0
  123. package/server/services/forms.js +173 -0
  124. package/server/services/markdown.js +1378 -747
  125. package/server/services/permissionRegistry.js +173 -0
  126. package/server/services/presetCollections.js +251 -0
  127. package/server/services/renderer.js +98 -2
  128. package/server/services/roles.js +227 -0
  129. package/server/services/rowAccess.js +104 -0
  130. package/server/services/userProfiles.js +199 -0
  131. package/server/services/users.js +281 -212
  132. package/server/services/views.js +280 -0
  133. package/server/templates/page.html +124 -113
  134. package/plugins/form-builder/admin/templates/form-settings.html +0 -29
  135. package/plugins/form-builder/admin/views/form-editor.js +0 -1444
  136. package/plugins/form-builder/admin/views/form-settings.js +0 -38
  137. package/plugins/form-builder/admin/views/form-submissions.js +0 -295
  138. package/plugins/form-builder/admin/views/forms-list.js +0 -164
  139. package/plugins/form-builder/config.js +0 -9
  140. package/plugins/form-builder/data/forms/consent.json +0 -104
  141. package/plugins/form-builder/data/forms/contact-details.json +0 -99
  142. package/plugins/form-builder/data/submissions/consent.json +0 -13
  143. package/plugins/form-builder/plugin.json +0 -52
  144. package/plugins/form-builder/public/inject-body.html +0 -352
  145. package/plugins/form-builder/public/inject-head.html +0 -58
  146. package/plugins/form-builder/public/package.json +0 -1
  147. package/scripts/copy-domma.js +0 -48
  148. package/server/services/userTypes.js +0 -167
  149. /package/plugins/form-builder/data/submissions/{contact-details.json → enquiries.json} +0 -0
  150. /package/{plugins/form-builder/public → public/js}/form-logic-engine.js +0 -0
@@ -1,1444 +0,0 @@
1
- /**
2
- * Form Builder Plugin — Form Editor View
3
- * List-based visual field editor with add/remove/reorder, live preview, and save.
4
- * Supports "Page Break" pseudo-fields for wizard forms.
5
- */
6
- import {apiRequest} from '/admin/js/api.js';
7
-
8
- const FIELD_TYPES = [
9
- { value: 'string', label: 'Text (single line)' },
10
- { value: 'email', label: 'Email' },
11
- { value: 'tel', label: 'Phone' },
12
- { value: 'number', label: 'Number' },
13
- { value: 'textarea', label: 'Textarea (multi-line)' },
14
- { value: 'select', label: 'Dropdown (select)' },
15
- { value: 'radio', label: 'Radio buttons' },
16
- { value: 'checkbox', label: 'Single checkbox' },
17
- { value: 'checkbox-group', label: 'Checkbox group' },
18
- { value: 'date', label: 'Date' },
19
- { value: 'time', label: 'Time' },
20
- { value: 'url', label: 'URL' },
21
- { value: 'password', label: 'Password' },
22
- { value: 'file', label: 'File upload' },
23
- { value: 'hidden', label: 'Hidden field' }
24
- ];
25
-
26
- const OPTION_TYPES = new Set(['select', 'radio', 'checkbox-group']);
27
-
28
- let fields = [];
29
- let formSlug = null;
30
-
31
- function slugify(str) {
32
- return str.toLowerCase().replace(/[^a-z0-9]+/g, '_').replace(/^_|_$/g, '');
33
- }
34
-
35
- function getTypeLabel(type) {
36
- return FIELD_TYPES.find(t => t.value === type)?.label || type;
37
- }
38
-
39
- function hasLogic(logic) {
40
- if (!logic) return false;
41
- if (logic.visibility?.conditions?.length) return true;
42
- if (logic.requirement?.conditions?.length) return true;
43
- if (logic.validation?.length) return true;
44
- if (logic.cascade?.sourceField) return true;
45
- return false;
46
- }
47
-
48
- // ---------------------------------------------------------------------------
49
- // Read field values from DOM back into array
50
- // ---------------------------------------------------------------------------
51
-
52
- function collectFieldFromDOM(idx) {
53
- const field = { ...fields[idx] };
54
-
55
- if (field.type === 'spacer') return field;
56
-
57
- // Page-break cards only have label + description inputs
58
- if (field.type === 'page-break') {
59
- const labelEl = document.getElementById(`fb-pb-label-${idx}`);
60
- const descEl = document.getElementById(`fb-pb-desc-${idx}`);
61
- if (labelEl) field.label = labelEl.value.trim() || field.label;
62
- if (descEl) field.description = descEl.value.trim();
63
- return field;
64
- }
65
-
66
- const label = document.getElementById(`fb-label-${idx}`);
67
- const name = document.getElementById(`fb-name-${idx}`);
68
- const type = document.getElementById(`fb-type-${idx}`);
69
- const required = document.getElementById(`fb-required-${idx}`);
70
- const placeholder = document.getElementById(`fb-placeholder-${idx}`);
71
- const helper = document.getElementById(`fb-helper-${idx}`);
72
-
73
- if (label) field.label = label.value.trim() || field.label;
74
- if (name) field.name = name.value.trim() || field.name;
75
- if (type) field.type = type.value || field.type;
76
- if (required) field.required = required.checked;
77
- if (placeholder) field.placeholder = placeholder.value.trim();
78
- if (helper) field.helper = helper.value.trim();
79
-
80
- if (OPTION_TYPES.has(field.type)) {
81
- const ta = document.getElementById(`fb-options-${idx}`);
82
- if (ta) {
83
- field.options = ta.value.split('\n').filter(l => l.trim()).map(line => {
84
- const [v, ...rest] = line.split(':');
85
- return { value: v.trim(), label: rest.join(':').trim() || v.trim() };
86
- });
87
- }
88
- }
89
-
90
- if (field.type === 'textarea') {
91
- const rows = parseInt(document.getElementById(`fb-rows-${idx}`)?.value, 10);
92
- if (rows > 0) field.formConfig = { rows };
93
- }
94
-
95
- const minLen = document.getElementById(`fb-minlength-${idx}`)?.value;
96
- if (minLen) field.minLength = parseInt(minLen, 10);
97
- const maxLen = document.getElementById(`fb-maxlength-${idx}`)?.value;
98
- if (maxLen) field.maxLength = parseInt(maxLen, 10);
99
-
100
- const minNum = document.getElementById(`fb-min-${idx}`)?.value;
101
- if (minNum !== '' && minNum !== undefined) field.min = parseFloat(minNum);
102
- const maxNum = document.getElementById(`fb-max-${idx}`)?.value;
103
- if (maxNum !== '' && maxNum !== undefined) field.max = parseFloat(maxNum);
104
-
105
- // Collect conditional logic from the logic builder UI
106
- const collectedLogic = collectLogicFromCard(idx);
107
- if (collectedLogic) {
108
- field.logic = collectedLogic;
109
- } else {
110
- delete field.logic;
111
- }
112
-
113
- return field;
114
- }
115
-
116
- function collectLogicFromCard(idx) {
117
- const card = document.querySelector(`.fb-field-card[data-index="${idx}"]`);
118
- if (!card) return undefined;
119
- const logicSection = card.querySelector('.fb-field-logic');
120
- if (!logicSection) return undefined;
121
-
122
- const logic = {};
123
- let hasAnyLogic = false;
124
-
125
- // Visibility
126
- const visSection = logicSection.querySelector('[data-logic-section="visibility"]');
127
- if (visSection) {
128
- const defaultSel = visSection.querySelector('.fb-logic-vis-default');
129
- const transSel = visSection.querySelector('.fb-logic-vis-transition');
130
- const visRules = Array.from(visSection.querySelectorAll('.fb-logic-cond-row')).map(row => {
131
- const fieldSel = row.querySelector('.fb-logic-cond-field');
132
- const opSel = row.querySelector('.fb-logic-cond-op');
133
- const valInp = row.querySelector('.fb-logic-cond-val');
134
- const thenSel = row.querySelector('.fb-logic-vis-then');
135
- if (!fieldSel?.value) return null;
136
- return {
137
- when: { all: [{ field: fieldSel.value, operator: opSel.value, value: valInp.value }] },
138
- then: thenSel.value
139
- };
140
- }).filter(Boolean);
141
- const visDefault = defaultSel?.value || 'visible';
142
- const transition = transSel?.value || 'none';
143
- if (visDefault !== 'visible' || visRules.length > 0 || transition !== 'none') {
144
- logic.visibility = { default: visDefault, conditions: visRules };
145
- if (transition !== 'none') logic.visibility.transition = transition;
146
- hasAnyLogic = true;
147
- }
148
- }
149
-
150
- // Requirement
151
- const reqSection = logicSection.querySelector('[data-logic-section="requirement"]');
152
- if (reqSection) {
153
- const defaultCb = reqSection.querySelector('.fb-logic-req-default');
154
- const reqRules = Array.from(reqSection.querySelectorAll('.fb-logic-cond-row')).map(row => {
155
- const fieldSel = row.querySelector('.fb-logic-cond-field');
156
- const opSel = row.querySelector('.fb-logic-cond-op');
157
- const valInp = row.querySelector('.fb-logic-cond-val');
158
- const thenSel = row.querySelector('.fb-logic-req-then');
159
- if (!fieldSel?.value) return null;
160
- return {
161
- when: { all: [{ field: fieldSel.value, operator: opSel.value, value: valInp.value }] },
162
- then: thenSel.value === 'true'
163
- };
164
- }).filter(Boolean);
165
- if (reqRules.length > 0) {
166
- logic.requirement = { default: defaultCb?.checked === true, conditions: reqRules };
167
- hasAnyLogic = true;
168
- }
169
- }
170
-
171
- // Validation
172
- const valSection = logicSection.querySelector('[data-logic-section="validation"]');
173
- if (valSection) {
174
- const valRules = Array.from(valSection.querySelectorAll('.fb-logic-val-rule')).map(row => {
175
- const typeSel = row.querySelector('.fb-logic-val-type');
176
- const patternInp = row.querySelector('.fb-logic-val-pattern');
177
- const flagsInp = row.querySelector('.fb-logic-val-flags');
178
- const msgInp = row.querySelector('.fb-logic-val-message');
179
- if (!patternInp?.value.trim()) return null;
180
- const type = typeSel?.value || 'regex';
181
- const rule = { type, message: msgInp?.value.trim() || 'Invalid value.' };
182
- if (type === 'regex') {
183
- rule.pattern = patternInp.value.trim();
184
- if (flagsInp?.value.trim()) rule.flags = flagsInp.value.trim();
185
- } else {
186
- rule.field = patternInp.value.trim();
187
- }
188
- return rule;
189
- }).filter(Boolean);
190
- if (valRules.length > 0) {
191
- logic.validation = valRules;
192
- hasAnyLogic = true;
193
- }
194
- }
195
-
196
- // Cascade
197
- const cascSection = logicSection.querySelector('[data-logic-section="cascade"]');
198
- if (cascSection) {
199
- const srcSel = cascSection.querySelector('.fb-logic-cascade-source');
200
- const mapTa = cascSection.querySelector('.fb-logic-cascade-mapping');
201
- const defTa = cascSection.querySelector('.fb-logic-cascade-defaults');
202
- const sourceField = srcSel?.value?.trim();
203
- if (sourceField) {
204
- let mapping = {};
205
- try { mapping = JSON.parse(mapTa?.value || '{}'); } catch { /* invalid json */ }
206
- const defaultOptions = (defTa?.value || '').split('\n').filter(l => l.trim()).map(line => {
207
- const [v, ...rest] = line.split(':');
208
- return { value: v.trim(), label: rest.join(':').trim() || v.trim() };
209
- });
210
- logic.cascade = { sourceField, mapping, defaultOptions };
211
- hasAnyLogic = true;
212
- }
213
- }
214
-
215
- return hasAnyLogic ? logic : undefined;
216
- }
217
-
218
- function syncAllFieldsFromDOM() {
219
- fields = fields.map((_, idx) => collectFieldFromDOM(idx));
220
- }
221
-
222
- // ---------------------------------------------------------------------------
223
- // Field list renderer — attaches all events directly to elements
224
- // ---------------------------------------------------------------------------
225
-
226
- function renderFieldList($container) {
227
- const listEl = $container.find('#fields-list').get(0);
228
- const emptyMsg = $container.find('#fields-empty-msg').get(0);
229
- if (!listEl) return;
230
-
231
- // Remove old field cards
232
- Array.from(listEl.querySelectorAll('.fb-field-card')).forEach(el => el.remove());
233
-
234
- if (fields.length === 0) {
235
- if (emptyMsg) emptyMsg.style.display = '';
236
- return;
237
- }
238
- if (emptyMsg) emptyMsg.style.display = 'none';
239
-
240
- fields.forEach((field, idx) => {
241
- const card = field.type === 'page-break'
242
- ? buildPageBreakCard(field, idx, $container)
243
- : field.type === 'spacer'
244
- ? buildSpacerCard(field, idx, $container)
245
- : buildFieldCard(field, idx, $container);
246
- listEl.appendChild(card);
247
- });
248
- }
249
-
250
- // ---------------------------------------------------------------------------
251
- // Page Break card
252
- // ---------------------------------------------------------------------------
253
-
254
- function buildPageBreakCard(field, idx, $container) {
255
- const card = document.createElement('div');
256
- card.className = 'fb-field-card';
257
- card.dataset.index = idx;
258
- card.style.cssText = 'border:2px dashed var(--border-color,#444);border-radius:6px;overflow:hidden;margin-bottom:.5rem;background:var(--card-bg,rgba(255,255,255,.02));';
259
-
260
- // Header
261
- const header = document.createElement('div');
262
- header.style.cssText = 'display:flex;align-items:center;gap:.6rem;padding:.6rem .8rem;cursor:pointer;user-select:none;';
263
-
264
- const badge = document.createElement('span');
265
- badge.textContent = '— Page Break —';
266
- badge.style.cssText = 'font-size:.7rem;padding:.15rem .5rem;border-radius:999px;background:rgba(120,120,120,.2);color:var(--text-muted,#888);white-space:nowrap;flex-shrink:0;font-style:italic;';
267
-
268
- const labelSpan = document.createElement('span');
269
- labelSpan.textContent = field.label || 'Untitled Step';
270
- labelSpan.style.cssText = 'flex:1;font-weight:600;font-size:.9rem;min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;color:var(--text-muted,#888);';
271
-
272
- const controls = document.createElement('div');
273
- controls.style.cssText = 'display:flex;gap:.25rem;flex-shrink:0;margin-left:.5rem;';
274
-
275
- if (idx > 0) {
276
- const upBtn = document.createElement('button');
277
- upBtn.className = 'btn btn-xs btn-ghost';
278
- upBtn.title = 'Move up';
279
- upBtn.textContent = '↑';
280
- upBtn.addEventListener('click', (e) => {
281
- e.stopPropagation();
282
- syncAllFieldsFromDOM();
283
- [fields[idx - 1], fields[idx]] = [fields[idx], fields[idx - 1]];
284
- renderFieldList($container);
285
- });
286
- controls.appendChild(upBtn);
287
- }
288
- if (idx < fields.length - 1) {
289
- const downBtn = document.createElement('button');
290
- downBtn.className = 'btn btn-xs btn-ghost';
291
- downBtn.title = 'Move down';
292
- downBtn.textContent = '↓';
293
- downBtn.addEventListener('click', (e) => {
294
- e.stopPropagation();
295
- syncAllFieldsFromDOM();
296
- [fields[idx], fields[idx + 1]] = [fields[idx + 1], fields[idx]];
297
- renderFieldList($container);
298
- });
299
- controls.appendChild(downBtn);
300
- }
301
-
302
- const toggleBtn = document.createElement('button');
303
- toggleBtn.className = 'btn btn-xs btn-ghost';
304
- toggleBtn.title = 'Edit step';
305
- toggleBtn.textContent = '⋯';
306
- toggleBtn.addEventListener('click', (e) => {
307
- e.stopPropagation();
308
- body.style.display = body.style.display === 'none' ? '' : 'none';
309
- });
310
- controls.appendChild(toggleBtn);
311
-
312
- const removeBtn = document.createElement('button');
313
- removeBtn.className = 'btn btn-xs btn-danger';
314
- removeBtn.title = 'Remove page break';
315
- removeBtn.textContent = '✕';
316
- removeBtn.addEventListener('click', async (e) => {
317
- e.stopPropagation();
318
- const confirmed = await E.confirm('Remove this page break?');
319
- if (!confirmed) return;
320
- syncAllFieldsFromDOM();
321
- fields.splice(idx, 1);
322
- renderFieldList($container);
323
- });
324
- controls.appendChild(removeBtn);
325
-
326
- header.appendChild(badge);
327
- header.appendChild(labelSpan);
328
- header.appendChild(controls);
329
-
330
- header.addEventListener('click', () => {
331
- body.style.display = body.style.display === 'none' ? '' : 'none';
332
- });
333
-
334
- // Body
335
- const body = document.createElement('div');
336
- body.className = 'fb-field-body';
337
- body.style.cssText = 'padding:.8rem;border-top:1px dashed var(--border-color,#444);display:none;';
338
-
339
- const row1 = buildRow([
340
- buildInputGroup('Step Title', `fb-pb-label-${idx}`, 'text', field.label || '', 'Shown as the wizard step heading'),
341
- buildInputGroup('Step Description', `fb-pb-desc-${idx}`, 'text', field.description || '', 'Optional sub-heading')
342
- ]);
343
-
344
- const labelInp = row1.querySelector(`#fb-pb-label-${idx}`);
345
- if (labelInp) {
346
- labelInp.addEventListener('input', () => {
347
- labelSpan.textContent = labelInp.value || 'Untitled Step';
348
- });
349
- }
350
-
351
- body.appendChild(row1);
352
- card.appendChild(header);
353
- card.appendChild(body);
354
- return card;
355
- }
356
-
357
- // ---------------------------------------------------------------------------
358
- // Spacer card
359
- // ---------------------------------------------------------------------------
360
-
361
- function buildSpacerCard(field, idx, $container) {
362
- const card = document.createElement('div');
363
- card.className = 'fb-field-card';
364
- card.dataset.index = idx;
365
- card.style.cssText = 'border:1px dashed var(--border-color,#444);border-radius:6px;margin-bottom:.5rem;background:transparent;';
366
-
367
- const header = document.createElement('div');
368
- header.style.cssText = 'display:flex;align-items:center;gap:.6rem;padding:.4rem .8rem;';
369
-
370
- const line = document.createElement('div');
371
- line.style.cssText = 'flex:1;height:1px;background:var(--border-color,#444);';
372
-
373
- const label = document.createElement('span');
374
- label.textContent = 'Spacer';
375
- label.style.cssText = 'font-size:.7rem;color:var(--text-muted,#888);white-space:nowrap;padding:0 .4rem;font-style:italic;';
376
-
377
- const line2 = document.createElement('div');
378
- line2.style.cssText = 'flex:1;height:1px;background:var(--border-color,#444);';
379
-
380
- const controls = document.createElement('div');
381
- controls.style.cssText = 'display:flex;gap:.25rem;flex-shrink:0;';
382
-
383
- if (idx > 0) {
384
- const upBtn = document.createElement('button');
385
- upBtn.className = 'btn btn-xs btn-ghost';
386
- upBtn.title = 'Move up';
387
- upBtn.textContent = '↑';
388
- upBtn.addEventListener('click', (e) => {
389
- e.stopPropagation();
390
- syncAllFieldsFromDOM();
391
- [fields[idx - 1], fields[idx]] = [fields[idx], fields[idx - 1]];
392
- renderFieldList($container);
393
- });
394
- controls.appendChild(upBtn);
395
- }
396
- if (idx < fields.length - 1) {
397
- const downBtn = document.createElement('button');
398
- downBtn.className = 'btn btn-xs btn-ghost';
399
- downBtn.title = 'Move down';
400
- downBtn.textContent = '↓';
401
- downBtn.addEventListener('click', (e) => {
402
- e.stopPropagation();
403
- syncAllFieldsFromDOM();
404
- [fields[idx], fields[idx + 1]] = [fields[idx + 1], fields[idx]];
405
- renderFieldList($container);
406
- });
407
- controls.appendChild(downBtn);
408
- }
409
-
410
- const removeBtn = document.createElement('button');
411
- removeBtn.className = 'btn btn-xs btn-danger';
412
- removeBtn.title = 'Remove spacer';
413
- removeBtn.textContent = '✕';
414
- removeBtn.addEventListener('click', async (e) => {
415
- e.stopPropagation();
416
- syncAllFieldsFromDOM();
417
- fields.splice(idx, 1);
418
- renderFieldList($container);
419
- });
420
- controls.appendChild(removeBtn);
421
-
422
- header.appendChild(line);
423
- header.appendChild(label);
424
- header.appendChild(line2);
425
- header.appendChild(controls);
426
- card.appendChild(header);
427
- return card;
428
- }
429
-
430
- // ---------------------------------------------------------------------------
431
- // Regular field card
432
- // ---------------------------------------------------------------------------
433
-
434
- function buildFieldCard(field, idx, $container) {
435
- const card = document.createElement('div');
436
- card.className = 'fb-field-card';
437
- card.dataset.index = idx;
438
- card.style.cssText = 'border:1px solid var(--border-color,#333);border-radius:6px;overflow:hidden;margin-bottom:.5rem;';
439
-
440
- // --- Header ---
441
- const header = document.createElement('div');
442
- header.style.cssText = 'display:flex;align-items:center;gap:.6rem;padding:.6rem .8rem;background:var(--card-header-bg,rgba(255,255,255,.04));cursor:pointer;user-select:none;';
443
-
444
- const typeBadge = document.createElement('span');
445
- typeBadge.textContent = getTypeLabel(field.type);
446
- typeBadge.style.cssText = 'font-size:.7rem;padding:.15rem .45rem;border-radius:999px;background:var(--primary-soft,rgba(99,102,241,.15));color:var(--primary,#6366f1);white-space:nowrap;flex-shrink:0;';
447
-
448
- const labelSpan = document.createElement('span');
449
- labelSpan.textContent = field.label || '(unlabelled)';
450
- labelSpan.style.cssText = 'flex:1;font-weight:600;font-size:.9rem;min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;';
451
-
452
- const controls = document.createElement('div');
453
- controls.style.cssText = 'display:flex;gap:.25rem;flex-shrink:0;margin-left:.5rem;';
454
-
455
- if (idx > 0) {
456
- const upBtn = document.createElement('button');
457
- upBtn.className = 'btn btn-xs btn-ghost';
458
- upBtn.title = 'Move up';
459
- upBtn.textContent = '↑';
460
- upBtn.addEventListener('click', (e) => {
461
- e.stopPropagation();
462
- syncAllFieldsFromDOM();
463
- [fields[idx - 1], fields[idx]] = [fields[idx], fields[idx - 1]];
464
- renderFieldList($container);
465
- });
466
- controls.appendChild(upBtn);
467
- }
468
- if (idx < fields.length - 1) {
469
- const downBtn = document.createElement('button');
470
- downBtn.className = 'btn btn-xs btn-ghost';
471
- downBtn.title = 'Move down';
472
- downBtn.textContent = '↓';
473
- downBtn.addEventListener('click', (e) => {
474
- e.stopPropagation();
475
- syncAllFieldsFromDOM();
476
- [fields[idx], fields[idx + 1]] = [fields[idx + 1], fields[idx]];
477
- renderFieldList($container);
478
- });
479
- controls.appendChild(downBtn);
480
- }
481
-
482
- const toggleBtn = document.createElement('button');
483
- toggleBtn.className = 'btn btn-xs btn-ghost';
484
- toggleBtn.title = 'Edit field';
485
- toggleBtn.textContent = '⋯';
486
- toggleBtn.addEventListener('click', (e) => {
487
- e.stopPropagation();
488
- body.style.display = body.style.display === 'none' ? '' : 'none';
489
- });
490
- controls.appendChild(toggleBtn);
491
-
492
- const removeBtn = document.createElement('button');
493
- removeBtn.className = 'btn btn-xs btn-danger';
494
- removeBtn.title = 'Remove field';
495
- removeBtn.textContent = '✕';
496
- removeBtn.addEventListener('click', async (e) => {
497
- e.stopPropagation();
498
- const confirmed = await E.confirm('Remove this field?');
499
- if (!confirmed) return;
500
- syncAllFieldsFromDOM();
501
- fields.splice(idx, 1);
502
- renderFieldList($container);
503
- });
504
- controls.appendChild(removeBtn);
505
-
506
- header.appendChild(typeBadge);
507
- header.appendChild(labelSpan);
508
- if (field.required) {
509
- const req = document.createElement('span');
510
- req.textContent = 'required';
511
- req.style.cssText = 'font-size:.7rem;color:var(--danger,#ef4444);flex-shrink:0;';
512
- header.appendChild(req);
513
- }
514
- if (hasLogic(field.logic)) {
515
- const logicBadge = document.createElement('span');
516
- logicBadge.textContent = '⚡';
517
- logicBadge.title = 'Has conditional logic';
518
- logicBadge.style.cssText = 'font-size:.75rem;color:var(--primary,#6366f1);flex-shrink:0;';
519
- header.appendChild(logicBadge);
520
- }
521
- header.appendChild(controls);
522
-
523
- // Click header to toggle body
524
- header.addEventListener('click', () => {
525
- body.style.display = body.style.display === 'none' ? '' : 'none';
526
- });
527
-
528
- // --- Body (hidden by default, auto-expanded for new fields) ---
529
- const body = buildFieldBody(field, idx, labelSpan);
530
- body.style.display = 'none';
531
-
532
- card.appendChild(header);
533
- card.appendChild(body);
534
- return card;
535
- }
536
-
537
- function buildFieldBody(field, idx, labelSpan) {
538
- const body = document.createElement('div');
539
- body.className = 'fb-field-body';
540
- body.style.cssText = 'padding:.8rem;border-top:1px solid var(--border-color,#333);';
541
-
542
- const row1 = buildRow([
543
- buildInputGroup('Label', `fb-label-${idx}`, 'text', field.label || '', 'Shown above the field'),
544
- buildInputGroup('Field Name', `fb-name-${idx}`, 'text', field.name || '', 'Used as data key')
545
- ]);
546
- const row2 = buildRow([
547
- buildSelectGroup('Type', `fb-type-${idx}`, FIELD_TYPES, field.type || 'string'),
548
- buildCheckboxGroup('Required', `fb-required-${idx}`, field.required || false)
549
- ]);
550
- const row3 = buildRow([
551
- buildInputGroup('Placeholder', `fb-placeholder-${idx}`, 'text', field.placeholder || '', 'Hint text inside the field'),
552
- buildInputGroup('Helper Text', `fb-helper-${idx}`, 'text', field.helper || '', 'Shown below the field')
553
- ]);
554
-
555
- body.appendChild(row1);
556
- body.appendChild(row2);
557
- body.appendChild(row3);
558
-
559
- // Auto-update header label and field name as user types
560
- const labelInp = body.querySelector(`#fb-label-${idx}`);
561
- const nameInp = body.querySelector(`#fb-name-${idx}`);
562
- if (labelInp) {
563
- labelInp.addEventListener('input', () => {
564
- if (labelSpan) labelSpan.textContent = labelInp.value || '(unlabelled)';
565
- if (nameInp && !nameInp.dataset.manuallyEdited) {
566
- nameInp.value = slugify(labelInp.value);
567
- }
568
- });
569
- }
570
- if (nameInp) {
571
- nameInp.addEventListener('input', () => { nameInp.dataset.manuallyEdited = '1'; });
572
- }
573
-
574
- // Re-render body extras when type changes (options, rows, min/max)
575
- const typeSelect = body.querySelector(`#fb-type-${idx}`);
576
- if (typeSelect) {
577
- typeSelect.addEventListener('change', () => {
578
- // Update badge in header
579
- const card = body.closest('.fb-field-card');
580
- if (card) {
581
- const badge = card.querySelector('span');
582
- if (badge) badge.textContent = getTypeLabel(typeSelect.value);
583
- }
584
- // Rebuild extras section
585
- const extrasEl = body.querySelector('.fb-field-extras');
586
- if (extrasEl) extrasEl.remove();
587
- const extras = buildFieldExtras(typeSelect.value, field, idx);
588
- if (extras) body.appendChild(extras);
589
- });
590
- }
591
-
592
- // Type-specific extras
593
- const extras = buildFieldExtras(field.type, field, idx);
594
- if (extras) body.appendChild(extras);
595
-
596
- // Conditional logic builder
597
- body.appendChild(buildFieldLogic(field, idx));
598
-
599
- return body;
600
- }
601
-
602
- // ---------------------------------------------------------------------------
603
- // Conditional Logic Builder UI
604
- // ---------------------------------------------------------------------------
605
-
606
- const CONDITION_OPERATORS = [
607
- { value: 'equals', label: 'equals' },
608
- { value: 'not_equals', label: 'does not equal' },
609
- { value: 'contains', label: 'contains' },
610
- { value: 'not_contains', label: 'does not contain' },
611
- { value: 'starts_with', label: 'starts with' },
612
- { value: 'ends_with', label: 'ends with' },
613
- { value: 'greater_than', label: 'is greater than' },
614
- { value: 'less_than', label: 'is less than' },
615
- { value: 'is_empty', label: 'is empty' },
616
- { value: 'is_not_empty', label: 'is not empty' },
617
- { value: 'in', label: 'is one of (comma sep)' },
618
- { value: 'not_in', label: 'is not one of (comma sep)' },
619
- { value: 'matches_regex', label: 'matches regex' },
620
- ];
621
-
622
- const NO_VALUE_OPS = new Set(['is_empty', 'is_not_empty']);
623
-
624
- function buildLogicSectionHeader(text) {
625
- const p = document.createElement('p');
626
- p.textContent = text;
627
- p.style.cssText = 'font-size:.75rem;font-weight:700;color:var(--text-muted,#888);margin:.6rem 0 .3rem;text-transform:uppercase;letter-spacing:.04em;';
628
- return p;
629
- }
630
-
631
- function buildConditionRow(condition, fieldOptions, thenOptions, thenClass, thenSelected) {
632
- const row = document.createElement('div');
633
- row.className = 'fb-logic-cond-row';
634
- row.style.cssText = 'display:flex;gap:.35rem;align-items:center;margin-bottom:.35rem;flex-wrap:wrap;';
635
-
636
- const whenLabel = document.createElement('span');
637
- whenLabel.textContent = 'When';
638
- whenLabel.style.cssText = 'font-size:.73rem;color:var(--text-muted,#888);flex-shrink:0;';
639
-
640
- const fieldSel = document.createElement('select');
641
- fieldSel.className = 'form-input fb-logic-cond-field';
642
- fieldSel.style.cssText = 'flex:2;min-width:80px;font-size:.78rem;padding:.2rem .35rem;';
643
- fieldOptions.forEach(opt => {
644
- const o = document.createElement('option');
645
- o.value = opt.value;
646
- o.textContent = opt.label;
647
- if (condition && opt.value === condition.field) o.selected = true;
648
- fieldSel.appendChild(o);
649
- });
650
-
651
- const opSel = document.createElement('select');
652
- opSel.className = 'form-input fb-logic-cond-op';
653
- opSel.style.cssText = 'flex:2;min-width:80px;font-size:.78rem;padding:.2rem .35rem;';
654
- CONDITION_OPERATORS.forEach(op => {
655
- const o = document.createElement('option');
656
- o.value = op.value;
657
- o.textContent = op.label;
658
- if (condition && op.value === condition.operator) o.selected = true;
659
- opSel.appendChild(o);
660
- });
661
-
662
- const valInp = document.createElement('input');
663
- valInp.type = 'text';
664
- valInp.className = 'form-input fb-logic-cond-val';
665
- valInp.placeholder = 'value';
666
- valInp.style.cssText = 'flex:2;min-width:60px;font-size:.78rem;padding:.2rem .35rem;';
667
- valInp.value = condition?.value || '';
668
- if (condition && NO_VALUE_OPS.has(condition.operator)) valInp.style.display = 'none';
669
- opSel.addEventListener('change', () => {
670
- valInp.style.display = NO_VALUE_OPS.has(opSel.value) ? 'none' : '';
671
- });
672
-
673
- const arrow = document.createElement('span');
674
- arrow.textContent = '→';
675
- arrow.style.cssText = 'font-size:.73rem;color:var(--text-muted,#888);flex-shrink:0;';
676
-
677
- const thenSel = document.createElement('select');
678
- thenSel.className = `form-input ${thenClass}`;
679
- thenSel.style.cssText = 'flex:2;min-width:80px;font-size:.78rem;padding:.2rem .35rem;';
680
- thenOptions.forEach(opt => {
681
- const o = document.createElement('option');
682
- o.value = opt.value;
683
- o.textContent = opt.label;
684
- if (opt.value === thenSelected) o.selected = true;
685
- thenSel.appendChild(o);
686
- });
687
-
688
- const removeBtn = document.createElement('button');
689
- removeBtn.type = 'button';
690
- removeBtn.className = 'btn btn-xs btn-danger';
691
- removeBtn.textContent = '✕';
692
- removeBtn.style.flexShrink = '0';
693
- removeBtn.addEventListener('click', () => row.remove());
694
-
695
- row.appendChild(whenLabel);
696
- row.appendChild(fieldSel);
697
- row.appendChild(opSel);
698
- row.appendChild(valInp);
699
- row.appendChild(arrow);
700
- row.appendChild(thenSel);
701
- row.appendChild(removeBtn);
702
- return row;
703
- }
704
-
705
- function buildVisibilitySection(vis, idx, otherFields) {
706
- const section = document.createElement('div');
707
- section.dataset.logicSection = 'visibility';
708
- section.appendChild(buildLogicSectionHeader('Visibility'));
709
-
710
- const defaultRow = document.createElement('div');
711
- defaultRow.style.cssText = 'display:flex;align-items:center;gap:.5rem;margin-bottom:.4rem;';
712
- const defaultLabel = document.createElement('span');
713
- defaultLabel.textContent = 'Default:';
714
- defaultLabel.style.cssText = 'font-size:.8rem;flex-shrink:0;';
715
- const defaultSel = document.createElement('select');
716
- defaultSel.className = 'form-input fb-logic-vis-default';
717
- defaultSel.style.cssText = 'font-size:.8rem;padding:.25rem .4rem;';
718
- [{ value: 'visible', label: 'Visible' }, { value: 'hidden', label: 'Hidden' }].forEach(opt => {
719
- const o = document.createElement('option');
720
- o.value = opt.value;
721
- o.textContent = opt.label;
722
- if (opt.value === (vis.default || 'visible')) o.selected = true;
723
- defaultSel.appendChild(o);
724
- });
725
- defaultRow.appendChild(defaultLabel);
726
- defaultRow.appendChild(defaultSel);
727
- section.appendChild(defaultRow);
728
-
729
- // Transition type dropdown
730
- const transRow = document.createElement('div');
731
- transRow.style.cssText = 'display:flex;align-items:center;gap:.5rem;margin-bottom:.4rem;';
732
- const transLabel = document.createElement('span');
733
- transLabel.textContent = 'Transition:';
734
- transLabel.style.cssText = 'font-size:.8rem;flex-shrink:0;';
735
- const transSel = document.createElement('select');
736
- transSel.className = 'form-input fb-logic-vis-transition';
737
- transSel.style.cssText = 'font-size:.8rem;padding:.25rem .4rem;';
738
- [
739
- {value: 'none', label: 'None (instant)'},
740
- {value: 'fade', label: 'Fade'},
741
- {value: 'slide', label: 'Slide'},
742
- {value: 'scale', label: 'Scale'},
743
- ].forEach(opt => {
744
- const o = document.createElement('option');
745
- o.value = opt.value;
746
- o.textContent = opt.label;
747
- if (opt.value === (vis.transition || 'none')) o.selected = true;
748
- transSel.appendChild(o);
749
- });
750
- transRow.appendChild(transLabel);
751
- transRow.appendChild(transSel);
752
- section.appendChild(transRow);
753
-
754
- const rulesContainer = document.createElement('div');
755
- rulesContainer.className = 'fb-logic-vis-rules';
756
- const fieldOpts = otherFields.map(f => ({ value: f.name, label: f.label || f.name }));
757
- const thenOpts = [{ value: 'visible', label: 'Show' }, { value: 'hidden', label: 'Hide' }];
758
-
759
- (vis.conditions || []).forEach(rule => {
760
- const cond = (rule.when?.all || rule.when?.any || [])[0];
761
- const thenVal = rule.then === 'hidden' ? 'hidden' : 'visible';
762
- if (fieldOpts.length > 0) rulesContainer.appendChild(buildConditionRow(cond, fieldOpts, thenOpts, 'fb-logic-vis-then', thenVal));
763
- });
764
- section.appendChild(rulesContainer);
765
-
766
- if (fieldOpts.length > 0) {
767
- const addBtn = document.createElement('button');
768
- addBtn.type = 'button';
769
- addBtn.className = 'btn btn-xs btn-ghost';
770
- addBtn.style.cssText = 'font-size:.73rem;margin-top:.2rem;';
771
- addBtn.textContent = '+ Add visibility rule';
772
- addBtn.addEventListener('click', () => rulesContainer.appendChild(buildConditionRow(null, fieldOpts, thenOpts, 'fb-logic-vis-then', 'visible')));
773
- section.appendChild(addBtn);
774
- }
775
- return section;
776
- }
777
-
778
- function buildRequirementSection(req, idx, otherFields) {
779
- const section = document.createElement('div');
780
- section.dataset.logicSection = 'requirement';
781
- section.appendChild(buildLogicSectionHeader('Conditional Requirement'));
782
-
783
- const cbLabel = document.createElement('label');
784
- cbLabel.style.cssText = 'display:flex;align-items:center;gap:.4rem;font-size:.8rem;cursor:pointer;margin-bottom:.4rem;';
785
- const cb = document.createElement('input');
786
- cb.type = 'checkbox';
787
- cb.className = 'fb-logic-req-default';
788
- cb.checked = req.default === true;
789
- cbLabel.appendChild(cb);
790
- cbLabel.appendChild(document.createTextNode('Required by default'));
791
- section.appendChild(cbLabel);
792
-
793
- const rulesContainer = document.createElement('div');
794
- rulesContainer.className = 'fb-logic-req-rules';
795
- const fieldOpts = otherFields.map(f => ({ value: f.name, label: f.label || f.name }));
796
- const thenOpts = [{ value: 'true', label: 'Make required' }, { value: 'false', label: 'Make optional' }];
797
-
798
- (req.conditions || []).forEach(rule => {
799
- const cond = (rule.when?.all || rule.when?.any || [])[0];
800
- const thenVal = rule.then === true ? 'true' : 'false';
801
- if (fieldOpts.length > 0) rulesContainer.appendChild(buildConditionRow(cond, fieldOpts, thenOpts, 'fb-logic-req-then', thenVal));
802
- });
803
- section.appendChild(rulesContainer);
804
-
805
- if (fieldOpts.length > 0) {
806
- const addBtn = document.createElement('button');
807
- addBtn.type = 'button';
808
- addBtn.className = 'btn btn-xs btn-ghost';
809
- addBtn.style.cssText = 'font-size:.73rem;margin-top:.2rem;';
810
- addBtn.textContent = '+ Add requirement rule';
811
- addBtn.addEventListener('click', () => rulesContainer.appendChild(buildConditionRow(null, fieldOpts, thenOpts, 'fb-logic-req-then', 'true')));
812
- section.appendChild(addBtn);
813
- }
814
- return section;
815
- }
816
-
817
- function buildValidationRuleRow(rule) {
818
- const row = document.createElement('div');
819
- row.className = 'fb-logic-val-rule';
820
- row.style.cssText = 'display:flex;gap:.35rem;align-items:center;margin-bottom:.35rem;flex-wrap:wrap;';
821
-
822
- const typeSel = document.createElement('select');
823
- typeSel.className = 'form-input fb-logic-val-type';
824
- typeSel.style.cssText = 'flex:0 0 auto;font-size:.78rem;padding:.2rem .35rem;';
825
- [{ value: 'regex', label: 'Regex' }, { value: 'match', label: 'Match field' }].forEach(opt => {
826
- const o = document.createElement('option');
827
- o.value = opt.value;
828
- o.textContent = opt.label;
829
- if (opt.value === (rule?.type || 'regex')) o.selected = true;
830
- typeSel.appendChild(o);
831
- });
832
-
833
- const patternInp = document.createElement('input');
834
- patternInp.type = 'text';
835
- patternInp.className = 'form-input fb-logic-val-pattern';
836
- patternInp.placeholder = rule?.type === 'match' ? 'field name' : 'pattern';
837
- patternInp.value = rule?.pattern || rule?.field || '';
838
- patternInp.style.cssText = 'flex:3;font-size:.78rem;padding:.2rem .35rem;';
839
-
840
- const flagsInp = document.createElement('input');
841
- flagsInp.type = 'text';
842
- flagsInp.className = 'form-input fb-logic-val-flags';
843
- flagsInp.placeholder = 'flags';
844
- flagsInp.value = rule?.flags || '';
845
- flagsInp.style.cssText = 'flex:0 0 55px;font-size:.78rem;padding:.2rem .35rem;';
846
- if (rule?.type === 'match') flagsInp.style.display = 'none';
847
-
848
- typeSel.addEventListener('change', () => {
849
- flagsInp.style.display = typeSel.value === 'match' ? 'none' : '';
850
- patternInp.placeholder = typeSel.value === 'match' ? 'field name' : 'pattern';
851
- });
852
-
853
- const msgInp = document.createElement('input');
854
- msgInp.type = 'text';
855
- msgInp.className = 'form-input fb-logic-val-message';
856
- msgInp.placeholder = 'Error message';
857
- msgInp.value = rule?.message || '';
858
- msgInp.style.cssText = 'flex:4;font-size:.78rem;padding:.2rem .35rem;';
859
-
860
- const removeBtn = document.createElement('button');
861
- removeBtn.type = 'button';
862
- removeBtn.className = 'btn btn-xs btn-danger';
863
- removeBtn.textContent = '✕';
864
- removeBtn.style.flexShrink = '0';
865
- removeBtn.addEventListener('click', () => row.remove());
866
-
867
- row.appendChild(typeSel);
868
- row.appendChild(patternInp);
869
- row.appendChild(flagsInp);
870
- row.appendChild(msgInp);
871
- row.appendChild(removeBtn);
872
- return row;
873
- }
874
-
875
- function buildValidationSection(validationRules, idx, otherFields) {
876
- const section = document.createElement('div');
877
- section.dataset.logicSection = 'validation';
878
- section.appendChild(buildLogicSectionHeader('Custom Validation'));
879
-
880
- const rulesContainer = document.createElement('div');
881
- rulesContainer.className = 'fb-logic-val-rules';
882
- (validationRules || []).forEach(rule => rulesContainer.appendChild(buildValidationRuleRow(rule)));
883
- section.appendChild(rulesContainer);
884
-
885
- const addBtn = document.createElement('button');
886
- addBtn.type = 'button';
887
- addBtn.className = 'btn btn-xs btn-ghost';
888
- addBtn.style.cssText = 'font-size:.73rem;margin-top:.2rem;';
889
- addBtn.textContent = '+ Add validation rule';
890
- addBtn.addEventListener('click', () => rulesContainer.appendChild(buildValidationRuleRow(null)));
891
- section.appendChild(addBtn);
892
- return section;
893
- }
894
-
895
- function buildCascadeSection(cascade, idx, otherFields) {
896
- const section = document.createElement('div');
897
- section.dataset.logicSection = 'cascade';
898
- section.appendChild(buildLogicSectionHeader('Cascade Options'));
899
-
900
- const srcRow = document.createElement('div');
901
- srcRow.style.cssText = 'display:flex;align-items:center;gap:.5rem;margin-bottom:.5rem;';
902
- const srcLabel = document.createElement('span');
903
- srcLabel.textContent = 'Source field:';
904
- srcLabel.style.cssText = 'font-size:.8rem;flex-shrink:0;';
905
- const srcSel = document.createElement('select');
906
- srcSel.className = 'form-input fb-logic-cascade-source';
907
- srcSel.style.cssText = 'flex:1;font-size:.8rem;padding:.25rem .4rem;';
908
- const emptyOpt = document.createElement('option');
909
- emptyOpt.value = '';
910
- emptyOpt.textContent = '— none —';
911
- srcSel.appendChild(emptyOpt);
912
- otherFields.forEach(f => {
913
- const o = document.createElement('option');
914
- o.value = f.name;
915
- o.textContent = f.label || f.name;
916
- if (f.name === cascade.sourceField) o.selected = true;
917
- srcSel.appendChild(o);
918
- });
919
- srcRow.appendChild(srcLabel);
920
- srcRow.appendChild(srcSel);
921
- section.appendChild(srcRow);
922
-
923
- const mapLabel = document.createElement('p');
924
- mapLabel.textContent = 'Mapping JSON — {"value":[{"value":"...","label":"..."}]}';
925
- mapLabel.style.cssText = 'font-size:.73rem;color:var(--text-muted,#888);margin:.3rem 0 .2rem;';
926
- const mapTa = document.createElement('textarea');
927
- mapTa.className = 'form-input fb-logic-cascade-mapping';
928
- mapTa.rows = 4;
929
- mapTa.style.cssText = 'font-family:monospace;font-size:.78rem;';
930
- mapTa.placeholder = '{"uk": [{"value": "london", "label": "London"}]}';
931
- mapTa.value = cascade.mapping ? JSON.stringify(cascade.mapping, null, 2) : '';
932
- section.appendChild(mapLabel);
933
- section.appendChild(mapTa);
934
-
935
- const defLabel = document.createElement('p');
936
- defLabel.textContent = 'Default options (one per line: value:Label)';
937
- defLabel.style.cssText = 'font-size:.73rem;color:var(--text-muted,#888);margin:.3rem 0 .2rem;';
938
- const defTa = document.createElement('textarea');
939
- defTa.className = 'form-input fb-logic-cascade-defaults';
940
- defTa.rows = 3;
941
- defTa.style.cssText = 'font-family:monospace;font-size:.78rem;';
942
- defTa.placeholder = 'option1:Option 1\noption2:Option 2';
943
- defTa.value = (cascade.defaultOptions || []).map(o => o.value === o.label ? o.value : `${o.value}:${o.label}`).join('\n');
944
- section.appendChild(defLabel);
945
- section.appendChild(defTa);
946
- return section;
947
- }
948
-
949
- function buildFieldLogic(field, idx) {
950
- const logic = field.logic || {};
951
- const otherFields = fields.filter((f, i) => i !== idx && f.type !== 'page-break' && f.type !== 'spacer');
952
-
953
- const section = document.createElement('div');
954
- section.className = 'fb-field-logic';
955
- section.style.cssText = 'margin-top:.75rem;border-top:1px solid var(--border-color,#333);padding-top:.5rem;';
956
-
957
- const toggleHeader = document.createElement('div');
958
- toggleHeader.style.cssText = 'display:flex;align-items:center;justify-content:space-between;cursor:pointer;padding:.15rem 0;';
959
- const toggleLabel = document.createElement('span');
960
- toggleLabel.style.cssText = 'font-size:.8rem;font-weight:600;color:var(--text-muted,#888);';
961
- toggleLabel.textContent = '⚡ Conditional Logic';
962
- const isOpen = hasLogic(logic);
963
- const toggleBtn = document.createElement('button');
964
- toggleBtn.type = 'button';
965
- toggleBtn.className = 'btn btn-xs btn-ghost';
966
- toggleBtn.textContent = isOpen ? '▾' : '▸';
967
-
968
- const logicBody = document.createElement('div');
969
- logicBody.className = 'fb-logic-body';
970
- logicBody.style.cssText = 'padding:.25rem 0 .25rem;' + (isOpen ? '' : 'display:none;');
971
-
972
- toggleHeader.addEventListener('click', () => {
973
- const collapsed = logicBody.style.display === 'none';
974
- logicBody.style.display = collapsed ? '' : 'none';
975
- toggleBtn.textContent = collapsed ? '▾' : '▸';
976
- });
977
- toggleHeader.appendChild(toggleLabel);
978
- toggleHeader.appendChild(toggleBtn);
979
- section.appendChild(toggleHeader);
980
-
981
- logicBody.appendChild(buildVisibilitySection(logic.visibility || {}, idx, otherFields));
982
- logicBody.appendChild(buildRequirementSection(logic.requirement || {}, idx, otherFields));
983
- logicBody.appendChild(buildValidationSection(logic.validation || [], idx, otherFields));
984
-
985
- const currentType = document.getElementById(`fb-type-${idx}`)?.value || field.type;
986
- if (OPTION_TYPES.has(currentType)) {
987
- logicBody.appendChild(buildCascadeSection(logic.cascade || {}, idx, otherFields));
988
- }
989
-
990
- section.appendChild(logicBody);
991
- return section;
992
- }
993
-
994
- // ---------------------------------------------------------------------------
995
- // Field type-specific extras
996
- // ---------------------------------------------------------------------------
997
-
998
- function buildFieldExtras(type, field, idx) {
999
- const wrap = document.createElement('div');
1000
- wrap.className = 'fb-field-extras';
1001
-
1002
- if (OPTION_TYPES.has(type)) {
1003
- wrap.appendChild(buildOptionsEditor(field.options || [], idx));
1004
- }
1005
- if (type === 'textarea') {
1006
- wrap.appendChild(buildRow([
1007
- buildInputGroup('Rows', `fb-rows-${idx}`, 'number', field.formConfig?.rows || 4, 'Height of textarea')
1008
- ]));
1009
- }
1010
- if (type === 'string' || type === 'textarea') {
1011
- wrap.appendChild(buildRow([
1012
- buildInputGroup('Min Length', `fb-minlength-${idx}`, 'number', field.minLength || '', ''),
1013
- buildInputGroup('Max Length', `fb-maxlength-${idx}`, 'number', field.maxLength || '', '')
1014
- ]));
1015
- }
1016
- if (type === 'number') {
1017
- wrap.appendChild(buildRow([
1018
- buildInputGroup('Min', `fb-min-${idx}`, 'number', field.min ?? '', ''),
1019
- buildInputGroup('Max', `fb-max-${idx}`, 'number', field.max ?? '', '')
1020
- ]));
1021
- }
1022
-
1023
- return wrap.children.length ? wrap : null;
1024
- }
1025
-
1026
- // ---------------------------------------------------------------------------
1027
- // DOM builders
1028
- // ---------------------------------------------------------------------------
1029
-
1030
- function buildRow(children) {
1031
- const row = document.createElement('div');
1032
- row.style.cssText = 'display:flex;gap:.75rem;margin-bottom:.6rem;';
1033
- children.forEach(c => { if (c) row.appendChild(c); });
1034
- return row;
1035
- }
1036
-
1037
- function buildInputGroup(label, id, type, value, hint) {
1038
- const wrap = document.createElement('div');
1039
- wrap.style.flex = '1';
1040
-
1041
- const lbl = document.createElement('label');
1042
- lbl.htmlFor = id;
1043
- lbl.className = 'form-label';
1044
- lbl.textContent = label;
1045
- lbl.style.fontSize = '.8rem';
1046
-
1047
- const inp = document.createElement('input');
1048
- inp.id = id;
1049
- inp.type = type || 'text';
1050
- inp.className = 'form-input';
1051
- inp.value = value ?? '';
1052
-
1053
- wrap.appendChild(lbl);
1054
- wrap.appendChild(inp);
1055
-
1056
- if (hint) {
1057
- const h = document.createElement('p');
1058
- h.className = 'form-hint text-muted';
1059
- h.textContent = hint;
1060
- h.style.cssText = 'font-size:.73rem;margin-top:.2rem;';
1061
- wrap.appendChild(h);
1062
- }
1063
- return wrap;
1064
- }
1065
-
1066
- function buildSelectGroup(label, id, options, selected) {
1067
- const wrap = document.createElement('div');
1068
- wrap.style.flex = '1';
1069
-
1070
- const lbl = document.createElement('label');
1071
- lbl.htmlFor = id;
1072
- lbl.className = 'form-label';
1073
- lbl.textContent = label;
1074
- lbl.style.fontSize = '.8rem';
1075
-
1076
- const sel = document.createElement('select');
1077
- sel.id = id;
1078
- sel.className = 'form-input';
1079
- options.forEach(opt => {
1080
- const o = document.createElement('option');
1081
- o.value = opt.value;
1082
- o.textContent = opt.label;
1083
- if (opt.value === selected) o.selected = true;
1084
- sel.appendChild(o);
1085
- });
1086
-
1087
- wrap.appendChild(lbl);
1088
- wrap.appendChild(sel);
1089
- return wrap;
1090
- }
1091
-
1092
- function buildCheckboxGroup(label, id, checked) {
1093
- const wrap = document.createElement('div');
1094
- wrap.style.cssText = 'flex:0;min-width:80px;display:flex;flex-direction:column;justify-content:flex-end;';
1095
-
1096
- const lbl = document.createElement('label');
1097
- lbl.style.cssText = 'display:flex;align-items:center;gap:.4rem;cursor:pointer;font-size:.8rem;white-space:nowrap;';
1098
-
1099
- const inp = document.createElement('input');
1100
- inp.id = id;
1101
- inp.type = 'checkbox';
1102
- inp.checked = checked;
1103
-
1104
- lbl.appendChild(inp);
1105
- lbl.appendChild(document.createTextNode(label));
1106
- wrap.appendChild(lbl);
1107
- return wrap;
1108
- }
1109
-
1110
- function buildOptionsEditor(options, idx) {
1111
- const wrap = document.createElement('div');
1112
- wrap.style.cssText = 'margin-top:.4rem;';
1113
-
1114
- const title = document.createElement('p');
1115
- title.textContent = 'Options (one per line: value or value:Label)';
1116
- title.style.cssText = 'font-size:.8rem;font-weight:600;margin-bottom:.3rem;';
1117
-
1118
- const ta = document.createElement('textarea');
1119
- ta.id = `fb-options-${idx}`;
1120
- ta.className = 'form-input';
1121
- ta.rows = 4;
1122
- ta.placeholder = 'yes:Yes\nno:No\nmaybe:Maybe';
1123
- ta.value = (options || []).map(o => o.value === o.label ? o.value : `${o.value}:${o.label}`).join('\n');
1124
- ta.style.fontFamily = 'monospace';
1125
-
1126
- wrap.appendChild(title);
1127
- wrap.appendChild(ta);
1128
- return wrap;
1129
- }
1130
-
1131
- // ---------------------------------------------------------------------------
1132
- // Collect full form data
1133
- // ---------------------------------------------------------------------------
1134
-
1135
- function collectFormData($container) {
1136
- syncAllFieldsFromDOM();
1137
- return {
1138
- title: $container.find('#field-title').val().trim(),
1139
- slug: $container.find('#field-slug').val().trim(),
1140
- description: $container.find('#field-description').val().trim(),
1141
- fields,
1142
- settings: {
1143
- submitText: $container.find('#setting-submit-text').val().trim() || 'Submit',
1144
- successMessage: $container.find('#setting-success-message').val().trim() || 'Thank you.',
1145
- layout: $container.find('#setting-layout').val() || 'stacked',
1146
- honeypot: $container.find('#setting-honeypot').prop('checked'),
1147
- rateLimitPerMinute: parseInt($container.find('#setting-rate-limit').val(), 10) || 3
1148
- },
1149
- actions: {
1150
- email: {
1151
- enabled: $container.find('#action-email-enabled').prop('checked'),
1152
- recipients: $container.find('#action-email-recipients').val().trim(),
1153
- subjectPrefix: $container.find('#action-email-subject-prefix').val().trim()
1154
- },
1155
- webhook: {
1156
- enabled: $container.find('#action-webhook-enabled').prop('checked'),
1157
- url: $container.find('#action-webhook-url').val().trim(),
1158
- method: $container.find('#action-webhook-method').val()
1159
- }
1160
- }
1161
- };
1162
- }
1163
-
1164
- // ---------------------------------------------------------------------------
1165
- // Wizard preview helper
1166
- // ---------------------------------------------------------------------------
1167
-
1168
- function buildWizardSteps(formFields) {
1169
- const steps = [];
1170
- let currentGroup = [];
1171
- let stepTitle = 'Step 1';
1172
- let stepDesc = '';
1173
-
1174
- formFields.forEach(f => {
1175
- if (f.type === 'page-break') {
1176
- steps.push({ title: stepTitle, description: stepDesc, fields: currentGroup });
1177
- currentGroup = [];
1178
- stepTitle = f.label || `Step ${steps.length + 1}`;
1179
- stepDesc = f.description || '';
1180
- } else if (f.type !== 'spacer') {
1181
- currentGroup.push(f);
1182
- }
1183
- });
1184
- if (currentGroup.length || steps.length === 0) {
1185
- steps.push({ title: stepTitle, description: stepDesc, fields: currentGroup });
1186
- }
1187
- return steps;
1188
- }
1189
-
1190
- function buildBlueprintFromFields(fieldList) {
1191
- const blueprint = {};
1192
- fieldList.forEach(field => {
1193
- if (field.type === 'page-break' || field.type === 'spacer') return;
1194
- blueprint[field.name] = {
1195
- type: field.type,
1196
- label: field.label,
1197
- required: field.required,
1198
- placeholder: field.placeholder,
1199
- helper: field.helper,
1200
- options: field.options,
1201
- ...(field.formConfig || {})
1202
- };
1203
- });
1204
- return blueprint;
1205
- }
1206
-
1207
- // ---------------------------------------------------------------------------
1208
- // View
1209
- // ---------------------------------------------------------------------------
1210
-
1211
- export const formEditorView = {
1212
- templateUrl: '/plugins/form-builder/admin/templates/form-editor.html',
1213
-
1214
- async onMount($container) {
1215
- fields = [];
1216
- formSlug = null;
1217
-
1218
- const hash = window.location.hash;
1219
- const editMatch = hash.match(/\/plugins\/form-builder\/edit\/([^/?#]+)/);
1220
- formSlug = editMatch ? editMatch[1] : null;
1221
-
1222
- let form = null;
1223
- if (formSlug) {
1224
- try {
1225
- form = await apiRequest(`/plugins/form-builder/forms/${formSlug}`);
1226
- fields = form.fields || [];
1227
- } catch {
1228
- E.toast('Could not load form.', { type: 'error' });
1229
- }
1230
- }
1231
-
1232
- if (form) {
1233
- $container.find('#editor-title').get(0).textContent = `Edit: ${form.title}`;
1234
- $container.find('#field-title').val(form.title);
1235
- $container.find('#field-slug').val(form.slug);
1236
- $container.find('#field-description').val(form.description || '');
1237
-
1238
- const s = form.settings || {};
1239
- $container.find('#setting-submit-text').val(s.submitText || 'Submit');
1240
- $container.find('#setting-success-message').val(s.successMessage || '');
1241
- $container.find('#setting-layout').val(s.layout || 'stacked');
1242
- $container.find('#setting-honeypot').prop('checked', s.honeypot !== false);
1243
- $container.find('#setting-rate-limit').val(s.rateLimitPerMinute || 3);
1244
-
1245
- const e = form.actions?.email || {};
1246
- $container.find('#action-email-enabled').prop('checked', e.enabled || false);
1247
- $container.find('#action-email-recipients').val(e.recipients || '');
1248
- $container.find('#action-email-subject-prefix').val(e.subjectPrefix || '');
1249
-
1250
- const w = form.actions?.webhook || {};
1251
- $container.find('#action-webhook-enabled').prop('checked', w.enabled || false);
1252
- $container.find('#action-webhook-url').val(w.url || '');
1253
- $container.find('#action-webhook-method').val(w.method || 'POST');
1254
- } else {
1255
- $container.find('#editor-title').get(0).textContent = 'New Form';
1256
- }
1257
-
1258
- // Auto-slug from title for new forms
1259
- if (!formSlug) {
1260
- $container.find('#field-title').get(0).addEventListener('input', function () {
1261
- $container.find('#field-slug').val(slugify(this.value));
1262
- });
1263
- }
1264
-
1265
- E.tabs($container.find('#editor-tabs').get(0));
1266
-
1267
- renderFieldList($container);
1268
-
1269
- // --- Add dropdown toggle ---
1270
- const addMenuEl = $container.find('#add-element-menu').get(0);
1271
- $container.find('#add-element-btn').get(0).addEventListener('click', (e) => {
1272
- e.stopPropagation();
1273
- addMenuEl.style.display = addMenuEl.style.display === 'none' ? '' : 'none';
1274
- });
1275
- document.addEventListener('click', () => {
1276
- if (addMenuEl) addMenuEl.style.display = 'none';
1277
- });
1278
-
1279
- // --- Add field ---
1280
- $container.find('#add-field-btn').get(0).addEventListener('click', () => {
1281
- if (addMenuEl) addMenuEl.style.display = 'none';
1282
- syncAllFieldsFromDOM();
1283
- const newIdx = fields.length;
1284
- fields.push({
1285
- name: `field_${newIdx + 1}`,
1286
- type: 'string',
1287
- label: 'New Field',
1288
- required: false,
1289
- placeholder: ''
1290
- });
1291
- renderFieldList($container);
1292
- // Auto-expand the newly added field
1293
- const listEl = $container.find('#fields-list').get(0);
1294
- const newCard = listEl?.lastElementChild;
1295
- if (newCard) {
1296
- const body = newCard.querySelector('.fb-field-body');
1297
- if (body) body.style.display = '';
1298
- }
1299
- });
1300
-
1301
- // --- Add spacer ---
1302
- $container.find('#add-spacer-btn').get(0).addEventListener('click', () => {
1303
- if (addMenuEl) addMenuEl.style.display = 'none';
1304
- syncAllFieldsFromDOM();
1305
- fields.push({type: 'spacer'});
1306
- renderFieldList($container);
1307
- });
1308
-
1309
- // --- Add page break ---
1310
- $container.find('#add-page-break-btn').get(0).addEventListener('click', () => {
1311
- if (addMenuEl) addMenuEl.style.display = 'none';
1312
- syncAllFieldsFromDOM();
1313
- const stepNum = fields.filter(f => f.type === 'page-break').length + 2;
1314
- fields.push({
1315
- type: 'page-break',
1316
- label: `Step ${stepNum}`,
1317
- description: ''
1318
- });
1319
- renderFieldList($container);
1320
- });
1321
-
1322
- // --- Save ---
1323
- $container.find('#save-form-btn').get(0).addEventListener('click', async () => {
1324
- const data = collectFormData($container);
1325
- if (!data.title) {
1326
- E.toast('Please enter a form title.', { type: 'error' });
1327
- return;
1328
- }
1329
- try {
1330
- if (formSlug) {
1331
- await apiRequest(`/plugins/form-builder/forms/${formSlug}`, {
1332
- method: 'PUT',
1333
- body: JSON.stringify(data)
1334
- });
1335
- E.toast('Form saved.', { type: 'success' });
1336
- } else {
1337
- const created = await apiRequest('/plugins/form-builder/forms', {
1338
- method: 'POST',
1339
- body: JSON.stringify({ title: data.title, slug: data.slug })
1340
- });
1341
- await apiRequest(`/plugins/form-builder/forms/${created.slug}`, {
1342
- method: 'PUT',
1343
- body: JSON.stringify({ ...data, slug: created.slug })
1344
- });
1345
- formSlug = created.slug;
1346
- R.navigate(`/plugins/form-builder/edit/${formSlug}`);
1347
- E.toast('Form created.', { type: 'success' });
1348
- }
1349
- } catch (err) {
1350
- E.toast(err.message || 'Failed to save form.', { type: 'error' });
1351
- }
1352
- });
1353
-
1354
- // --- Preview / Test ---
1355
- $container.find('#preview-btn').get(0).addEventListener('click', () => {
1356
- const data = collectFormData($container);
1357
- const previewEl = $container.find('#preview-container').get(0);
1358
- if (!previewEl) return;
1359
-
1360
- // Reset result area
1361
- const resultEl = $container.find('#preview-test-result').get(0);
1362
- const testBadge = $container.find('#preview-test-badge').get(0);
1363
- if (resultEl) {
1364
- resultEl.style.display = 'none';
1365
- resultEl.textContent = '';
1366
- }
1367
- if (testBadge) testBadge.style.display = formSlug ? '' : 'none';
1368
-
1369
- $container.find('#preview-card').get(0).style.display = '';
1370
- previewEl.textContent = '';
1371
-
1372
- const formEl = document.createElement('div');
1373
- formEl.id = 'fb-preview-form';
1374
- previewEl.appendChild(formEl);
1375
-
1376
- // If saved: wire up real submit; otherwise no-op
1377
- const onSubmit = formSlug
1378
- ? async (formData) => {
1379
- if (resultEl) {
1380
- resultEl.style.display = 'none';
1381
- resultEl.textContent = '';
1382
- }
1383
- try {
1384
- const res = await fetch(`/api/plugins/form-builder/submit/${formSlug}`, {
1385
- method: 'POST',
1386
- headers: {'Content-Type': 'application/json'},
1387
- body: JSON.stringify(formData)
1388
- });
1389
- const json = await res.json();
1390
- if (!res.ok) throw new Error(json.error || 'Submission failed.');
1391
- if (resultEl) {
1392
- resultEl.textContent = json.message || data.settings?.successMessage || 'Submitted successfully.';
1393
- resultEl.style.cssText = 'display:block;margin-top:.75rem;padding:.6rem .9rem;border-radius:6px;font-size:.9rem;background:rgba(34,197,94,.12);color:var(--success,#22c55e);';
1394
- }
1395
- E.toast('Test submission stored.', {type: 'success'});
1396
- } catch (err) {
1397
- if (resultEl) {
1398
- resultEl.textContent = err.message;
1399
- resultEl.style.cssText = 'display:block;margin-top:.75rem;padding:.6rem .9rem;border-radius:6px;font-size:.9rem;background:rgba(239,68,68,.12);color:var(--danger,#ef4444);';
1400
- }
1401
- E.toast(err.message, {type: 'error'});
1402
- }
1403
- return false;
1404
- }
1405
- : () => false;
1406
-
1407
- const hasPageBreaks = data.fields.some(f => f.type === 'page-break');
1408
-
1409
- if (typeof F !== 'undefined') {
1410
- if (hasPageBreaks && F.wizard) {
1411
- const steps = buildWizardSteps(data.fields).map(step => ({
1412
- title: step.title,
1413
- description: step.description,
1414
- fields: buildBlueprintFromFields(step.fields)
1415
- }));
1416
- F.wizard('#fb-preview-form', {schema: {steps}, onSubmit});
1417
- } else if (F.render) {
1418
- const blueprint = buildBlueprintFromFields(data.fields);
1419
- F.render('#fb-preview-form', blueprint, {}, {
1420
- submitText: data.settings?.submitText || 'Submit',
1421
- onSubmit
1422
- });
1423
- }
1424
- // Init conditional logic engine on preview form
1425
- if (window.FormLogicEngine && data.fields.some(f => f.logic)) {
1426
- requestAnimationFrame(() => {
1427
- const runtime = new window.FormLogicEngine.FormLogicRuntime(
1428
- { fields: data.fields },
1429
- formEl
1430
- );
1431
- runtime.init();
1432
- });
1433
- }
1434
- } else {
1435
- const msg = document.createElement('p');
1436
- msg.textContent = `${data.fields.filter(f => f.type !== 'page-break').length} field(s): ${data.fields.filter(f => f.type !== 'page-break').map(f => f.label).join(', ')}`;
1437
- msg.style.cssText = 'color:var(--muted);font-style:italic;';
1438
- formEl.appendChild(msg);
1439
- }
1440
-
1441
- $container.find('#preview-card').get(0).scrollIntoView({ behavior: 'smooth', block: 'start' });
1442
- });
1443
- }
1444
- };