From 5d2141ec885be0de1b98eb0a220347179bc44c0c Mon Sep 17 00:00:00 2001 From: Do Siki Date: Wed, 19 Aug 2026 13:30:53 +0200 Subject: [PATCH] fix(cms): capture array container before node removal so reindex runs MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The ❌ delete buttons called wrap.closest('.array-items') AFTER remove(); a detached node has no ancestors, so closest() returned null and reindexItems() silently skipped. Remaining items kept their old indices, collect() produced sparse arrays (null holes) and saves failed schema validation, e.g. '$.details.services[1].specs.items[0]: string érték szükséges'. Capture the container before remove() for both str-item and obj-card delete handlers. Regression test runs the real browser script in jsdom and clicks the actual delete buttons (nested string array + object card reindexing). Closes MITHOME-67 --- proto/src/__tests__/cms-editor-client.test.ts | 91 +++++++++++++++++++ scripts/cms-editor-client.js | 16 +++- 2 files changed, 105 insertions(+), 2 deletions(-) create mode 100644 proto/src/__tests__/cms-editor-client.test.ts diff --git a/proto/src/__tests__/cms-editor-client.test.ts b/proto/src/__tests__/cms-editor-client.test.ts new file mode 100644 index 0000000..21748e7 --- /dev/null +++ b/proto/src/__tests__/cms-editor-client.test.ts @@ -0,0 +1,91 @@ +/** + * Regression test for the Content Editor browser script: deleting an array + * item via its ❌ button must reindex the remaining items, otherwise collect() + * produces sparse arrays (null holes) that fail schema validation + * ("$.details.services[1].specs.items[0]: string érték szükséges"). + * + * Runs the REAL scripts/cms-editor-client.js in jsdom and clicks the actual + * delete buttons — earlier coverage only exercised reindexItems() directly, + * which missed that the onclick handler removed the node BEFORE looking up + * its container (detached node → closest() === null → no reindex). + */ +import fs from 'fs' +import path from 'path' + +const clientJs = fs.readFileSync(path.join(__dirname, '../../../scripts/cms-editor-client.js'), 'utf8') + +const service = (n: number) => ({ + id: `svc-${n}`, + title: `Szolgáltatás ${n}`, + description: `Leírás ${n}`, + icon: '🔧', + features: [`feature ${n}`], + ctaText: 'CTA', +}) + +const makeData = () => ({ + details: { + title: 'Részletek', + subtitle: 'Alcím', + services: [ + { icon: 'a', title: 's0', description: 'd0', specs: { title: 't0', items: ['a0', 'b0', 'c0'] } }, + { icon: 'b', title: 's1', description: 'd1', specs: { title: 't1', items: ['a1', 'b1', 'c1'] } }, + { icon: 'c', title: 's2', description: 'd2', specs: { title: 't2', items: ['a2', 'b2', 'c2'] } }, + ], + }, +}) + +function bootClient(data: unknown) { + ;(global as any).DATA = data + ;(global as any).FILE = 'services' + ;(global as any).CSRF_TOKEN = 'test-token' + ;(global as any).fetch = jest.fn() + document.body.innerHTML = '
' + // sloppy-mode eval publishes the script's functions on the global object + ;(0, eval)(clientJs) +} + +function deleteButtonFor(dataPath: string): HTMLButtonElement { + const field = document.querySelector(`[data-path="${CSS.escape(dataPath)}"]`) as HTMLElement + expect(field).not.toBeNull() + const wrap = field.closest('.str-item') as HTMLElement + expect(wrap).not.toBeNull() + return wrap.querySelector('.btn-del') as HTMLButtonElement +} + +afterEach(() => { + delete (global as any).DATA + delete (global as any).FILE + delete (global as any).CSRF_TOKEN +}) + +describe('Content Editor client delete/reindex', () => { + it('deleting a nested string array item keeps the remaining items dense', () => { + const data = makeData() + bootClient(data) + + deleteButtonFor('details.services[1].specs.items[0]').click() + + const collected = (global as any).collect() + expect(collected.details.services[1].specs.items).toEqual(['b1', 'c1']) + expect(collected.details.services[0].specs.items).toEqual(['a0', 'b0', 'c0']) + expect(collected.details.services[2].specs.items).toEqual(['a2', 'b2', 'c2']) + }) + + it('deleting an object card reindexes the outer array', () => { + const data = makeData() + bootClient(data) + + const cardHeader = Array.from(document.querySelectorAll('.card-header')) + .find(h => h.textContent === 'details.services[1]') as HTMLElement + expect(cardHeader).not.toBeNull() + const card = cardHeader.closest('.obj-card') as HTMLElement + ;(card.querySelector('.btn-del-card') as HTMLButtonElement).click() + + const collected = (global as any).collect() + expect(collected.details.services).toHaveLength(2) + expect(collected.details.services[0].title).toBe('s0') + expect(collected.details.services[1].title).toBe('s2') + expect(collected.details.services[1].specs.items).toEqual(['a2', 'b2', 'c2']) + }) +}) diff --git a/scripts/cms-editor-client.js b/scripts/cms-editor-client.js index 22c53ea..d904953 100644 --- a/scripts/cms-editor-client.js +++ b/scripts/cms-editor-client.js @@ -116,7 +116,14 @@ function makeStrItem(val, idx, path) { del.className = 'btn-del'; del.textContent = '❌'; del.title = 'Törlés'; - del.onclick = () => { wrap.remove(); reindexItems(wrap.closest('.array-items')); }; + del.onclick = () => { + // WHY: capture the container BEFORE removing — a detached node has no + // ancestors, so closest() would return null and reindexing would silently + // not run (sparse arrays → schema errors on save). + const container = wrap.closest('.array-items'); + wrap.remove(); + reindexItems(container); + }; wrap.appendChild(ta); wrap.appendChild(del); return wrap; @@ -135,7 +142,12 @@ function makeObjCard(obj, idx, path) { const del = document.createElement('button'); del.className = 'btn-del-card'; del.textContent = '❌ Törlés'; - del.onclick = () => { card.remove(); reindexItems(card.closest('.array-items')); }; + del.onclick = () => { + // Same as above: capture before detaching, or reindexing is skipped. + const container = card.closest('.array-items'); + card.remove(); + reindexItems(container); + }; card.appendChild(del); return card; }