ultimate-jekyll-manager 1.9.0 → 1.9.1

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/CHANGELOG.md CHANGED
@@ -14,6 +14,18 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/).
14
14
  - `Fixed` for any bug fixes.
15
15
  - `Security` in case of vulnerabilities.
16
16
 
17
+ ---
18
+ ## [1.9.1] - 2026-06-17
19
+
20
+ ### Changed
21
+
22
+ - **FormManager: snapshot-and-restore disabled-state model (replaces `data-fm-keep-disabled`).** `_init()` now snapshots every element that has `disabled` in HTML markup (excluding submit buttons — those are loading guards FM takes over). Snapshotted elements stay disabled through every state transition automatically — no data attributes needed. Recommended form pattern: `<form data-form-state="initializing" onsubmit="return false">` with CSS `form[data-form-state]:not([data-form-state="ready"]) { pointer-events: none; }`. Documented in `docs/javascript-libraries.md`.
23
+
24
+ ### Added
25
+
26
+ - **Comprehensive FormManager test suite (30 page-layer tests).** Disabled-state snapshot (5 tests), getData/setData/input groups (12 tests), validation/honeypot/file-accept (13 tests). Previously FormManager had zero automated tests.
27
+ - **Visual Test 7 on the FM test page** (`/test/libraries/form-manager`) — permanently disabled elements, submit cycle demo, rapid-cycle button for manual verification.
28
+
17
29
  ---
18
30
  ## [1.9.0] - 2026-06-17
19
31
 
package/PROGRESS.md ADDED
@@ -0,0 +1,24 @@
1
+ # Project Progress Tracker
2
+ > Agents and maintainers should update this file regularly to reflect the current state of the project.
3
+
4
+ ## Current Focus
5
+ * **Goal:** FormManager disabled-state refactor (snapshot-and-restore)
6
+ * **Current Phase:** Phase 1 — implementation + tests complete, docs pending
7
+ * **Priority:** Medium
8
+ * **Last Updated:** 2026-06-17 6:05 PM PDT
9
+ * **Notes:** FM disabled-state refactor done + comprehensive FM test suite added (110 tests total, up from 80). Covers getData/setData/input groups/validation/honeypot/file-accept/disabled snapshot. Docs (javascript-libraries.md, CHANGELOG) still need updating before shipping.
10
+
11
+ ## Active Task List
12
+ * [ ] Phase 1: FormManager disabled-state snapshot-and-restore
13
+ * [x] Task 1.1: Refactor `_setDisabled` to use snapshot instead of `data-fm-keep-disabled`
14
+ * [x] Task 1.2: Add `_permanentlyDisabled` Set, populated in `_init()` before first disable
15
+ * [x] Task 1.3: Write page-layer tests (5 tests: snapshot capture, full disable, selective re-enable, cycle durability, onsubmit HTML guard)
16
+ * [x] Task 1.4: All 85 tests passing
17
+ * [x] Task 1.5: Add visual Test 7 to FM test page (form-manager.html + JS) with permanently disabled fields + rapid-cycle demo
18
+ * [x] Task 1.6: Write comprehensive FM page-layer tests — getData/setData (12 tests), validation/honeypot/file-accept (13 tests). 110 total.
19
+ * [ ] Task 1.7: Update `docs/javascript-libraries.md` — replace `data-fm-keep-disabled` docs with new snapshot pattern + `onsubmit="return false"` + `data-form-state="initializing"` CSS guard
20
+ * [ ] Task 1.8: Update CHANGELOG with the change
21
+ * [ ] Task 1.9: Ship (commit, push, publish)
22
+
23
+ ## Completed Task List
24
+ * [x] Phase 0: v1.9.0 release — MCP OAuth flow + CDP debugging docs + dev-URL updates
@@ -59,6 +59,7 @@ export class FormManager {
59
59
  // State
60
60
  this.state = 'initializing';
61
61
  this._isDirty = false;
62
+ this._permanentlyDisabled = new Set();
62
63
 
63
64
  // Event listeners
64
65
  this._listeners = {
@@ -92,6 +93,17 @@ export class FormManager {
92
93
  * Initialize the form manager
93
94
  */
94
95
  _init() {
96
+ // Snapshot elements that are disabled in HTML markup BEFORE the first
97
+ // blanket disable. These are business-logic disabled (e.g. "coming soon"
98
+ // options) and must stay disabled through every state transition.
99
+ // Submit buttons are excluded — disabled submit buttons in HTML are
100
+ // loading guards that FM takes over.
101
+ this.$form.querySelectorAll('button, input, select, textarea').forEach(($el) => {
102
+ if ($el.disabled && $el.type !== 'submit') {
103
+ this._permanentlyDisabled.add($el);
104
+ }
105
+ });
106
+
95
107
  // Disable form during initialization
96
108
  this._setDisabled(true);
97
109
 
@@ -782,9 +794,8 @@ export class FormManager {
782
794
  }
783
795
 
784
796
  /**
785
- * Enable/disable form controls. Fields marked data-fm-keep-disabled stay
786
- * disabled permanently (e.g. "coming soon" options rendered inside a
787
- * managed form) — the toggle never re-enables them.
797
+ * Enable/disable form controls. Elements snapshotted as permanently
798
+ * disabled during _init() are never re-enabled.
788
799
  */
789
800
  _setDisabled(disabled) {
790
801
  /* @dev-only:start */
@@ -794,7 +805,7 @@ export class FormManager {
794
805
  /* @dev-only:end */
795
806
 
796
807
  this.$form.querySelectorAll('button, input, select, textarea').forEach(($el) => {
797
- if ($el.dataset.fmKeepDisabled !== undefined) {
808
+ if (this._permanentlyDisabled.has($el)) {
798
809
  $el.disabled = true;
799
810
  return;
800
811
  }
@@ -19,6 +19,7 @@ export default () => {
19
19
  initTestFormManual();
20
20
  initTestFormGroups();
21
21
  initTestFormFileDrop();
22
+ initTestFormSnapshot();
22
23
 
23
24
  // Resolve after initialization
24
25
  return resolve();
@@ -245,6 +246,58 @@ function initTestFormGroups() {
245
246
  });
246
247
  }
247
248
 
249
+ // Test 7: Disabled-State Snapshot
250
+ function initTestFormSnapshot() {
251
+ const formManager = new FormManager('#test-form-snapshot');
252
+ const $status = document.getElementById('snapshot-status');
253
+ const $cycleCount = document.getElementById('snapshot-cycle-count');
254
+ const $output = document.getElementById('snapshot-output');
255
+ const $cycleBtn = document.getElementById('snapshot-cycle');
256
+
257
+ let cycles = 0;
258
+
259
+ function logStates() {
260
+ const form = document.getElementById('test-form-snapshot');
261
+ const lines = [];
262
+ form.querySelectorAll('input, select, textarea, button').forEach(($el) => {
263
+ const label = $el.name || $el.type || $el.tagName.toLowerCase();
264
+ lines.push(`${label}: disabled=${$el.disabled}`);
265
+ });
266
+ $output.textContent = lines.join('\n');
267
+ }
268
+
269
+ formManager.on('statechange', ({ state }) => {
270
+ $status.textContent = `Status: ${state}`;
271
+ logStates();
272
+ });
273
+
274
+ formManager.on('submit', async ({ data }) => {
275
+ console.log('[Test 7] Submitting:', data);
276
+ logStates();
277
+ await simulateApi(2000);
278
+ cycles++;
279
+ $cycleCount.textContent = `Cycles: ${cycles}`;
280
+ formManager.showSuccess('Done! Permanently disabled fields should still be disabled.');
281
+ });
282
+
283
+ // Rapid cycle button — triggers 5 fast disable/enable cycles
284
+ $cycleBtn.addEventListener('click', async () => {
285
+ $cycleBtn.disabled = true;
286
+ for (let i = 0; i < 5; i++) {
287
+ formManager._setDisabled(true);
288
+ logStates();
289
+ await simulateApi(300);
290
+ formManager._setDisabled(false);
291
+ logStates();
292
+ await simulateApi(300);
293
+ cycles++;
294
+ }
295
+ $cycleCount.textContent = `Cycles: ${cycles}`;
296
+ $cycleBtn.disabled = false;
297
+ formManager.showSuccess('5 rapid cycles complete. Check that Enterprise/Region/Notes stayed disabled.');
298
+ });
299
+ }
300
+
248
301
  // Test 6: File Drop
249
302
  function initTestFormFileDrop() {
250
303
  const formManager = new FormManager('#test-form-file-drop');
@@ -301,6 +301,78 @@ meta:
301
301
  </div>
302
302
  </div>
303
303
 
304
+ <!-- Test 7: Disabled-State Snapshot -->
305
+ <div class="col-lg-8">
306
+ <div class="card">
307
+ <div class="card-header">
308
+ <h5 class="mb-0">Test 7: Disabled-State Snapshot</h5>
309
+ <small class="text-muted">Elements disabled in HTML stay disabled through every FM state transition. Submit buttons are loading guards — FM takes them over.</small>
310
+ </div>
311
+ <div class="card-body">
312
+ <form id="test-form-snapshot" data-form-state="initializing" onsubmit="return false">
313
+ <div class="row">
314
+ <div class="col-md-6">
315
+ <h6 class="text-muted mb-3">FM-managed <span class="badge bg-success">enabled on ready</span></h6>
316
+ <div class="mb-3">
317
+ <label for="snapshot-name" class="form-label">Name</label>
318
+ <input type="text" class="form-control" id="snapshot-name" name="name" value="Ian">
319
+ </div>
320
+ <div class="mb-3">
321
+ <label for="snapshot-email" class="form-label">Email</label>
322
+ <input type="email" class="form-control" id="snapshot-email" name="email" value="ian@example.com">
323
+ </div>
324
+ <div class="mb-3">
325
+ <label class="form-label d-block">Plan</label>
326
+ <div class="form-check">
327
+ <input type="radio" class="form-check-input" id="snapshot-plan-free" name="plan" value="free" checked>
328
+ <label class="form-check-label" for="snapshot-plan-free">Free</label>
329
+ </div>
330
+ <div class="form-check">
331
+ <input type="radio" class="form-check-input" id="snapshot-plan-pro" name="plan" value="pro">
332
+ <label class="form-check-label" for="snapshot-plan-pro">Pro</label>
333
+ </div>
334
+ <div class="form-check">
335
+ <input type="radio" class="form-check-input" id="snapshot-plan-enterprise" name="plan" value="enterprise" disabled>
336
+ <label class="form-check-label" for="snapshot-plan-enterprise">Enterprise <span class="badge bg-secondary">Coming soon</span></label>
337
+ </div>
338
+ </div>
339
+ </div>
340
+ <div class="col-md-6">
341
+ <h6 class="text-muted mb-3">Permanently disabled <span class="badge bg-danger">stays disabled</span></h6>
342
+ <div class="mb-3">
343
+ <label for="snapshot-region" class="form-label">Region <span class="badge bg-secondary">Coming soon</span></label>
344
+ <select class="form-select" id="snapshot-region" name="region" disabled>
345
+ <option>US (coming soon)</option>
346
+ <option>EU (coming soon)</option>
347
+ </select>
348
+ </div>
349
+ <div class="mb-3">
350
+ <label for="snapshot-notes" class="form-label">Internal notes <span class="badge bg-secondary">Admin only</span></label>
351
+ <textarea class="form-control" id="snapshot-notes" name="notes" rows="2" disabled>Restricted field</textarea>
352
+ </div>
353
+ <div class="alert alert-info small mb-0">
354
+ Enterprise radio, Region select, and Notes textarea have <code>disabled</code> in the HTML. FM snapshots them at init and never re-enables them. The other fields start without <code>disabled</code> — the form-level <code>data-form-state</code> + CSS blocks interaction until FM is ready.
355
+ </div>
356
+ </div>
357
+ </div>
358
+ <div class="d-flex gap-2">
359
+ <button type="submit" class="btn btn-primary" disabled>
360
+ <span class="button-text">Submit (watch disabled states)</span>
361
+ </button>
362
+ <button type="button" id="snapshot-cycle" class="btn btn-outline-warning">Rapid cycle (5x)</button>
363
+ </div>
364
+ </form>
365
+ <div class="mt-3 d-flex gap-3">
366
+ <small class="text-muted" id="snapshot-status">Status: initializing</small>
367
+ <small class="text-muted" id="snapshot-cycle-count">Cycles: 0</small>
368
+ </div>
369
+ <div class="mt-2">
370
+ <pre id="snapshot-output" class="bg-body-secondary p-2 rounded small" style="max-height: 160px; overflow: auto;">Submit to see disabled states toggle. Enterprise radio + Region select should NEVER re-enable.</pre>
371
+ </div>
372
+ </div>
373
+ </div>
374
+ </div>
375
+
304
376
  </div>
305
377
  </div>
306
378
  </section>
@@ -0,0 +1,418 @@
1
+ // FormManager data collection and population: getData() with dot notation,
2
+ // radio/checkbox groups, honeypot exclusion, input-group filtering, and
3
+ // setData() reverse population. Logic inlined from form-manager.js since
4
+ // page-layer tests can't import ESM modules.
5
+
6
+ module.exports = {
7
+ layer: 'page',
8
+ description: 'FormManager getData / setData / input groups',
9
+ type: 'group',
10
+ tests: [
11
+ // ── _setNested: dot-notation path → nested object ──────────────────
12
+ {
13
+ name: '_setNested builds nested objects from dot paths',
14
+ run: async (ctx) => {
15
+ function setNested(obj, path, value) {
16
+ var keys = path.split('.');
17
+ var lastKey = keys.pop();
18
+ var current = obj;
19
+ for (var i = 0; i < keys.length; i++) {
20
+ if (!current[keys[i]] || typeof current[keys[i]] !== 'object') {
21
+ current[keys[i]] = {};
22
+ }
23
+ current = current[keys[i]];
24
+ }
25
+ if (current[lastKey] !== undefined) {
26
+ if (!Array.isArray(current[lastKey])) {
27
+ current[lastKey] = [current[lastKey]];
28
+ }
29
+ current[lastKey].push(value);
30
+ } else {
31
+ current[lastKey] = value;
32
+ }
33
+ }
34
+
35
+ var obj = {};
36
+ setNested(obj, 'user.name', 'Ian');
37
+ setNested(obj, 'user.address.city', 'NYC');
38
+ setNested(obj, 'plan', 'pro');
39
+
40
+ ctx.expect(obj.user.name).toBe('Ian');
41
+ ctx.expect(obj.user.address.city).toBe('NYC');
42
+ ctx.expect(obj.plan).toBe('pro');
43
+ },
44
+ },
45
+ {
46
+ name: '_setNested accumulates duplicate keys into arrays',
47
+ run: async (ctx) => {
48
+ function setNested(obj, path, value) {
49
+ var keys = path.split('.');
50
+ var lastKey = keys.pop();
51
+ var current = obj;
52
+ for (var i = 0; i < keys.length; i++) {
53
+ if (!current[keys[i]] || typeof current[keys[i]] !== 'object') {
54
+ current[keys[i]] = {};
55
+ }
56
+ current = current[keys[i]];
57
+ }
58
+ if (current[lastKey] !== undefined) {
59
+ if (!Array.isArray(current[lastKey])) {
60
+ current[lastKey] = [current[lastKey]];
61
+ }
62
+ current[lastKey].push(value);
63
+ } else {
64
+ current[lastKey] = value;
65
+ }
66
+ }
67
+
68
+ var obj = {};
69
+ setNested(obj, 'tags', 'a');
70
+ setNested(obj, 'tags', 'b');
71
+ setNested(obj, 'tags', 'c');
72
+
73
+ ctx.expect(Array.isArray(obj.tags)).toBe(true);
74
+ ctx.expect(obj.tags.length).toBe(3);
75
+ ctx.expect(obj.tags).toEqual(['a', 'b', 'c']);
76
+ },
77
+ },
78
+
79
+ // ── _getNested: read from nested object via dot path ────────────────
80
+ {
81
+ name: '_getNested reads nested values and returns undefined for missing paths',
82
+ run: async (ctx) => {
83
+ function getNested(obj, path) {
84
+ return path.split('.').reduce(function (current, key) {
85
+ return current && current[key] !== undefined ? current[key] : undefined;
86
+ }, obj);
87
+ }
88
+
89
+ var obj = { user: { address: { city: 'NYC' } }, plan: 'pro' };
90
+
91
+ ctx.expect(getNested(obj, 'user.address.city')).toBe('NYC');
92
+ ctx.expect(getNested(obj, 'plan')).toBe('pro');
93
+ ctx.expect(getNested(obj, 'user.phone')).toBe(undefined);
94
+ ctx.expect(getNested(obj, 'nonexistent.deep.path')).toBe(undefined);
95
+ },
96
+ },
97
+
98
+ // ── _flattenObject: nested → dot-notation flat ─────────────────────
99
+ {
100
+ name: '_flattenObject converts nested objects to dot paths',
101
+ run: async (ctx) => {
102
+ function flattenObject(obj, prefix) {
103
+ prefix = prefix || '';
104
+ var result = {};
105
+ for (var key in obj) {
106
+ var value = obj[key];
107
+ var path = prefix ? prefix + '.' + key : key;
108
+ if (value !== null && typeof value === 'object' && !Array.isArray(value)) {
109
+ var isCheckboxGroup = Object.values(value).every(function (v) { return typeof v === 'boolean'; });
110
+ if (isCheckboxGroup) {
111
+ result[path] = value;
112
+ } else {
113
+ Object.assign(result, flattenObject(value, path));
114
+ }
115
+ } else {
116
+ result[path] = value;
117
+ }
118
+ }
119
+ return result;
120
+ }
121
+
122
+ var nested = {
123
+ user: { name: 'Ian', address: { city: 'NYC' } },
124
+ plan: 'pro',
125
+ features: { dark: true, beta: false },
126
+ };
127
+
128
+ var flat = flattenObject(nested);
129
+
130
+ ctx.expect(flat['user.name']).toBe('Ian');
131
+ ctx.expect(flat['user.address.city']).toBe('NYC');
132
+ ctx.expect(flat['plan']).toBe('pro');
133
+ // Boolean-valued objects treated as checkbox groups — kept as objects
134
+ ctx.expect(flat['features'].dark).toBe(true);
135
+ ctx.expect(flat['features'].beta).toBe(false);
136
+ },
137
+ },
138
+
139
+ // ── getData: full integration with real form ───────────────────────
140
+ {
141
+ name: 'getData collects text, select, radio, and textarea values with dot notation',
142
+ run: async (ctx) => {
143
+ var form = document.createElement('form');
144
+ form.innerHTML = '<input type="text" name="user.name" value="Ian">'
145
+ + '<input type="email" name="user.email" value="ian@test.com">'
146
+ + '<select name="settings.theme"><option value="light">L</option><option value="dark" selected>D</option></select>'
147
+ + '<textarea name="notes">Hello</textarea>'
148
+ + '<input type="radio" name="pref" value="a">'
149
+ + '<input type="radio" name="pref" value="b" checked>';
150
+ document.body.appendChild(form);
151
+
152
+ // Inline getData logic (simplified — no group filter, no honeypot)
153
+ function setNested(obj, path, value) {
154
+ var keys = path.split('.');
155
+ var lastKey = keys.pop();
156
+ var current = obj;
157
+ for (var i = 0; i < keys.length; i++) {
158
+ if (!current[keys[i]] || typeof current[keys[i]] !== 'object') current[keys[i]] = {};
159
+ current = current[keys[i]];
160
+ }
161
+ if (current[lastKey] !== undefined) {
162
+ if (!Array.isArray(current[lastKey])) current[lastKey] = [current[lastKey]];
163
+ current[lastKey].push(value);
164
+ } else {
165
+ current[lastKey] = value;
166
+ }
167
+ }
168
+
169
+ var data = {};
170
+ form.querySelectorAll('input, select, textarea').forEach(function ($f) {
171
+ if (!$f.name) return;
172
+ if ($f.type === 'checkbox') return;
173
+ if ($f.type === 'radio' && !$f.checked) return;
174
+ setNested(data, $f.name, $f.value);
175
+ });
176
+
177
+ ctx.expect(data.user.name).toBe('Ian');
178
+ ctx.expect(data.user.email).toBe('ian@test.com');
179
+ ctx.expect(data.settings.theme).toBe('dark');
180
+ ctx.expect(data.notes).toBe('Hello');
181
+ ctx.expect(data.pref).toBe('b');
182
+
183
+ form.remove();
184
+ },
185
+ },
186
+ {
187
+ name: 'getData handles single checkbox (boolean) and checkbox groups (object)',
188
+ run: async (ctx) => {
189
+ var form = document.createElement('form');
190
+ form.innerHTML = '<input type="checkbox" name="subscribe" checked>'
191
+ + '<input type="checkbox" name="features" value="dark" checked>'
192
+ + '<input type="checkbox" name="features" value="beta">'
193
+ + '<input type="checkbox" name="features" value="analytics" checked>';
194
+ document.body.appendChild(form);
195
+
196
+ var data = {};
197
+ var fields = form.querySelectorAll('input');
198
+ var checkboxCounts = {};
199
+ fields.forEach(function ($f) {
200
+ if ($f.type === 'checkbox') checkboxCounts[$f.name] = (checkboxCounts[$f.name] || 0) + 1;
201
+ });
202
+
203
+ // Single checkboxes
204
+ fields.forEach(function ($f) {
205
+ if ($f.type !== 'checkbox') return;
206
+ if (checkboxCounts[$f.name] === 1) {
207
+ data[$f.name] = $f.checked;
208
+ }
209
+ });
210
+
211
+ // Checkbox groups
212
+ var processed = {};
213
+ fields.forEach(function ($f) {
214
+ if ($f.type !== 'checkbox') return;
215
+ if (checkboxCounts[$f.name] === 1) return;
216
+ if (processed[$f.name]) return;
217
+ processed[$f.name] = true;
218
+ var values = {};
219
+ form.querySelectorAll('input[type="checkbox"][name="' + $f.name + '"]').forEach(function ($cb) {
220
+ values[$cb.value] = $cb.checked;
221
+ });
222
+ data[$f.name] = values;
223
+ });
224
+
225
+ // Single checkbox → boolean
226
+ ctx.expect(data.subscribe).toBe(true);
227
+
228
+ // Checkbox group → object with value: boolean
229
+ ctx.expect(data.features.dark).toBe(true);
230
+ ctx.expect(data.features.beta).toBe(false);
231
+ ctx.expect(data.features.analytics).toBe(true);
232
+
233
+ form.remove();
234
+ },
235
+ },
236
+ {
237
+ name: 'getData excludes honeypot fields',
238
+ run: async (ctx) => {
239
+ var form = document.createElement('form');
240
+ form.innerHTML = '<input type="text" name="email" value="real@test.com">'
241
+ + '<input type="text" name="honey" data-honey value="bot-filled">'
242
+ + '<input type="text" name="trap" value="legit">';
243
+ document.body.appendChild(form);
244
+
245
+ var HONEYPOT_SELECTOR = '[data-honey], [name="honey"]';
246
+ var data = {};
247
+ form.querySelectorAll('input').forEach(function ($f) {
248
+ if (!$f.name) return;
249
+ if ($f.matches(HONEYPOT_SELECTOR)) return;
250
+ data[$f.name] = $f.value;
251
+ });
252
+
253
+ ctx.expect(data.email).toBe('real@test.com');
254
+ ctx.expect(data.honey).toBe(undefined);
255
+ ctx.expect(data.trap).toBe('legit');
256
+
257
+ form.remove();
258
+ },
259
+ },
260
+
261
+ // ── Input group filtering ──────────────────────────────────────────
262
+ {
263
+ name: 'input group filter includes matching + global fields, excludes others',
264
+ run: async (ctx) => {
265
+ var form = document.createElement('form');
266
+ form.innerHTML = '<input type="text" name="global_name" value="always">'
267
+ + '<input type="text" name="group_a_field" data-input-group="a" value="from-a">'
268
+ + '<input type="text" name="group_b_field" data-input-group="b" value="from-b">';
269
+ document.body.appendChild(form);
270
+
271
+ var allowedGroups = ['a'];
272
+
273
+ function isFieldInGroup($field) {
274
+ if (!allowedGroups) return true;
275
+ var fieldGroup = $field.getAttribute('data-input-group');
276
+ if (!fieldGroup || fieldGroup.trim() === '') return true;
277
+ return allowedGroups.indexOf(fieldGroup.toLowerCase()) !== -1;
278
+ }
279
+
280
+ var data = {};
281
+ form.querySelectorAll('input').forEach(function ($f) {
282
+ if (!$f.name || !isFieldInGroup($f)) return;
283
+ data[$f.name] = $f.value;
284
+ });
285
+
286
+ ctx.expect(data.global_name).toBe('always');
287
+ ctx.expect(data.group_a_field).toBe('from-a');
288
+ ctx.expect(data.group_b_field).toBe(undefined);
289
+
290
+ form.remove();
291
+ },
292
+ },
293
+ {
294
+ name: 'input group filter with multiple groups includes all matching',
295
+ run: async (ctx) => {
296
+ var form = document.createElement('form');
297
+ form.innerHTML = '<input type="text" name="a_field" data-input-group="a" value="A">'
298
+ + '<input type="text" name="b_field" data-input-group="b" value="B">'
299
+ + '<input type="text" name="c_field" data-input-group="c" value="C">';
300
+ document.body.appendChild(form);
301
+
302
+ var allowedGroups = ['a', 'b'];
303
+
304
+ function isFieldInGroup($field) {
305
+ if (!allowedGroups) return true;
306
+ var fieldGroup = $field.getAttribute('data-input-group');
307
+ if (!fieldGroup || fieldGroup.trim() === '') return true;
308
+ return allowedGroups.indexOf(fieldGroup.toLowerCase()) !== -1;
309
+ }
310
+
311
+ var data = {};
312
+ form.querySelectorAll('input').forEach(function ($f) {
313
+ if (!$f.name || !isFieldInGroup($f)) return;
314
+ data[$f.name] = $f.value;
315
+ });
316
+
317
+ ctx.expect(data.a_field).toBe('A');
318
+ ctx.expect(data.b_field).toBe('B');
319
+ ctx.expect(data.c_field).toBe(undefined);
320
+
321
+ form.remove();
322
+ },
323
+ },
324
+
325
+ // ── setData: populate form from object ─────────────────────────────
326
+ {
327
+ name: 'setData populates text, select, textarea fields',
328
+ run: async (ctx) => {
329
+ var form = document.createElement('form');
330
+ form.innerHTML = '<input type="text" name="user.name">'
331
+ + '<input type="email" name="user.email">'
332
+ + '<select name="theme"><option value="light">L</option><option value="dark">D</option></select>'
333
+ + '<textarea name="notes"></textarea>';
334
+ document.body.appendChild(form);
335
+
336
+ function setFieldValue(name, value) {
337
+ var fields = form.querySelectorAll('[name="' + name + '"]');
338
+ if (fields.length === 0) return;
339
+ fields[0].value = value;
340
+ }
341
+
342
+ function flattenObject(obj, prefix) {
343
+ prefix = prefix || '';
344
+ var result = {};
345
+ for (var key in obj) {
346
+ var value = obj[key];
347
+ var path = prefix ? prefix + '.' + key : key;
348
+ if (value !== null && typeof value === 'object' && !Array.isArray(value)) {
349
+ var isCbGroup = Object.values(value).every(function (v) { return typeof v === 'boolean'; });
350
+ if (isCbGroup) { result[path] = value; } else { Object.assign(result, flattenObject(value, path)); }
351
+ } else {
352
+ result[path] = value;
353
+ }
354
+ }
355
+ return result;
356
+ }
357
+
358
+ var flat = flattenObject({ user: { name: 'John', email: 'j@test.com' }, theme: 'dark', notes: 'Hi' });
359
+ for (var path in flat) {
360
+ setFieldValue(path, flat[path]);
361
+ }
362
+
363
+ ctx.expect(form.querySelector('[name="user.name"]').value).toBe('John');
364
+ ctx.expect(form.querySelector('[name="user.email"]').value).toBe('j@test.com');
365
+ ctx.expect(form.querySelector('[name="theme"]').value).toBe('dark');
366
+ ctx.expect(form.querySelector('[name="notes"]').value).toBe('Hi');
367
+
368
+ form.remove();
369
+ },
370
+ },
371
+ {
372
+ name: 'setData sets radio group to matching value',
373
+ run: async (ctx) => {
374
+ var form = document.createElement('form');
375
+ form.innerHTML = '<input type="radio" name="plan" value="free" checked>'
376
+ + '<input type="radio" name="plan" value="pro">'
377
+ + '<input type="radio" name="plan" value="enterprise">';
378
+ document.body.appendChild(form);
379
+
380
+ // Set radio group
381
+ form.querySelectorAll('[name="plan"]').forEach(function ($r) {
382
+ $r.checked = ($r.value === 'pro');
383
+ });
384
+
385
+ ctx.expect(form.querySelector('[value="free"]').checked).toBe(false);
386
+ ctx.expect(form.querySelector('[value="pro"]').checked).toBe(true);
387
+ ctx.expect(form.querySelector('[value="enterprise"]').checked).toBe(false);
388
+
389
+ form.remove();
390
+ },
391
+ },
392
+ {
393
+ name: 'setData sets single checkbox boolean and checkbox group values',
394
+ run: async (ctx) => {
395
+ var form = document.createElement('form');
396
+ form.innerHTML = '<input type="checkbox" name="subscribe">'
397
+ + '<input type="checkbox" name="features" value="dark">'
398
+ + '<input type="checkbox" name="features" value="beta">';
399
+ document.body.appendChild(form);
400
+
401
+ // Single checkbox
402
+ form.querySelector('[name="subscribe"]').checked = true;
403
+
404
+ // Checkbox group
405
+ var groupValues = { dark: true, beta: false };
406
+ form.querySelectorAll('[name="features"]').forEach(function ($cb) {
407
+ $cb.checked = !!groupValues[$cb.value];
408
+ });
409
+
410
+ ctx.expect(form.querySelector('[name="subscribe"]').checked).toBe(true);
411
+ ctx.expect(form.querySelector('[value="dark"]').checked).toBe(true);
412
+ ctx.expect(form.querySelector('[value="beta"]').checked).toBe(false);
413
+
414
+ form.remove();
415
+ },
416
+ },
417
+ ],
418
+ };