From 055f4cf4e0d31bce2071919469d9a7f2af32661a Mon Sep 17 00:00:00 2001 From: ZDDC Date: Wed, 10 Jun 2026 09:58:42 -0500 Subject: [PATCH] fix(classifier): parse add-folder names into nested levels; controls back to right/hover MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Follow-up to the Classify & Copy add-folder work: - Add-folder now parses each (brace-expanded) name into the nested tracking levels it represents — split on "-", then the FINAL "_" splits the leaf revision. "CPO-0001_0 (IFU)" → CPO / 0001 / 0 (IFU); a braced pattern nests every expansion and shares common ancestors. New classify.parseFolderLevels + addTrackingPath (ensure-path with name reuse). - Node add/edit/delete controls moved back to the RIGHT of the level name and revealed on hover (was left + always-visible). Tests: parseFolderLevels cases + a nested-chain/shared-ancestor test; updated the "+ Root folder" test for the new nesting (classify.spec.js -> 31 passed). Co-Authored-By: Claude Opus 4.8 (1M context) --- classifier/css/layout.css | 6 +++--- classifier/js/classify.js | 38 ++++++++++++++++++++++++++++++++++ classifier/js/target-tree.js | 17 ++++++++------- tests/classify.spec.js | 40 +++++++++++++++++++++++++++++++++--- 4 files changed, 88 insertions(+), 13 deletions(-) diff --git a/classifier/css/layout.css b/classifier/css/layout.css index c0ce6e8..e126724 100644 --- a/classifier/css/layout.css +++ b/classifier/css/layout.css @@ -405,9 +405,9 @@ background: var(--primary); color: var(--bg); border-radius: 999px; padding: 0 0.4rem; font-size: 0.7rem; font-weight: 600; } -/* Node CRUD controls sit to the LEFT of the level name (always visible) so - building/pruning the tree is a one-click affordance, not a hover-hunt. */ -.tnode__actions { display: inline-flex; gap: 0.1rem; margin-right: 0.15rem; flex: 0 0 auto; } +/* Node CRUD controls sit to the right of the level name, revealed on hover. */ +.tnode__actions { display: inline-flex; gap: 0.1rem; margin-left: 0.3rem; flex: 0 0 auto; opacity: 0; transition: opacity 0.12s; } +.tnode__row:hover .tnode__actions, .tslot__row:hover .tnode__actions { opacity: 1; } .tnode__act { border: 1px solid var(--border); background: var(--bg); border-radius: var(--radius); cursor: pointer; diff --git a/classifier/js/classify.js b/classifier/js/classify.js index fc2b286..a705a84 100644 --- a/classifier/js/classify.js +++ b/classifier/js/classify.js @@ -449,6 +449,43 @@ return results.map(function (r) { return r.trim(); }).filter(Boolean); } + // Parse one (already brace-expanded) folder name into the nested tracking + // levels it represents: split on "-" into tracking-number segments, then + // split the FINAL segment once on "_" to separate the last tracking segment + // from the "REV (STATUS)" leaf. So "CPO-0001_0 (IFU)" → ["CPO","0001","0 (IFU)"] + // and "BMB-187023-PM-MOM-0001_A (IFR)" → ["BMB","187023","PM","MOM","0001","A (IFR)"]. + // A name with no "-"/"_" is a single level (e.g. adding a leaf "A (IFR)"). + function parseFolderLevels(name) { + var s = String(name == null ? '' : name).trim(); + if (!s) return []; + var segs = s.split('-'); + var last = segs.pop(); + var u = last.indexOf('_'); + if (u >= 0) { segs.push(last.slice(0, u)); segs.push(last.slice(u + 1)); } + else { segs.push(last); } + return segs.map(function (x) { return x.trim(); }).filter(Boolean); + } + // Children array for a tracking node (or the roots for null), or null. + function trackingChildren(parentId) { + if (!parentId) return state.trackingTree; + var info = infoFor(parentId); + return (info && info.kind === 'tracking') ? info.node.children : null; + } + // Ensure a nested chain of tracking folders exists under parentId, reusing + // an existing child when one already has that name (so sibling leaves share + // ancestors). Returns the leaf node id. + function addTrackingPath(parentId, segments) { + var cur = parentId || null; + (segments || []).forEach(function (seg) { + var name = (seg || '').trim(); + if (!name) return; + var kids = trackingChildren(cur) || []; + var existing = kids.filter(function (n) { return n.name === name; })[0]; + cur = existing ? existing.id : addTrackingNode(cur, name); + }); + return cur; + } + // ── mode ───────────────────────────────────────────────────────────────── function setEnabled(on) { state.enabled = !!on; notify(); } function isEnabled() { return state.enabled; } @@ -468,6 +505,7 @@ addTrackingNode: addTrackingNode, addParty: addParty, addTransmittalBin: addTransmittalBin, renameNode: renameNode, deleteNode: deleteNode, expandFolderPattern: expandFolderPattern, + parseFolderLevels: parseFolderLevels, addTrackingPath: addTrackingPath, getNode: getNode, getTrackingTree: function () { return state.trackingTree; }, getTransmittalTree: function () { return state.transmittalTree; }, // derive + reverse diff --git a/classifier/js/target-tree.js b/classifier/js/target-tree.js index ce7e763..f866be5 100644 --- a/classifier/js/target-tree.js +++ b/classifier/js/target-tree.js @@ -110,7 +110,9 @@ if (names.length > 8) shown += '\n…and ' + (names.length - 8) + ' more'; if (!confirm('Create ' + names.length + ' folders?\n\n' + shown)) return; } - names.forEach(function (nm) { C().addTrackingNode(parentId, nm); }); + // Each expanded name is parsed into nested tracking levels (split on + // "-", final "_" splits the leaf rev), reusing shared ancestors. + names.forEach(function (nm) { C().addTrackingPath(parentId, C().parseFolderLevels(nm)); }); } // ── render ─────────────────────────────────────────────────────────────── @@ -187,15 +189,16 @@ var toggle = el('button', 'tnode__toggle', isLeaf ? '·' : (collapsed[n.id] ? '▸' : '▾')); if (!isLeaf) toggle.dataset.act = 'toggle'; row.appendChild(toggle); + row.appendChild(el('span', 'tnode__name', n.name)); + + var placed = placedMap[n.id] || []; + if (placed.length) row.appendChild(el('span', 'tnode__badge', String(placed.length))); + row.appendChild(nodeActions([ { act: 'add', label: '+', title: 'Add child folder' }, { act: 'rename', label: '✎', title: 'Rename' }, { act: 'del', label: '🗑', title: 'Delete' }, ])); - row.appendChild(el('span', 'tnode__name', n.name)); - - var placed = placedMap[n.id] || []; - if (placed.length) row.appendChild(el('span', 'tnode__badge', String(placed.length))); wrap.appendChild(row); if (placed.length) wrap.appendChild(fileList(placed)); @@ -221,11 +224,11 @@ wrap.dataset.id = party.id; var row = el('div', 'tnode__row'); row.appendChild(el('span', 'tnode__icon', '🏢')); + row.appendChild(el('span', 'tnode__name', party.name)); row.appendChild(nodeActions([ { act: 'rename-party', label: '✎', title: 'Rename party' }, { act: 'del-party', label: '🗑', title: 'Delete party' }, ])); - row.appendChild(el('span', 'tnode__name', party.name)); wrap.appendChild(row); SLOTS.forEach(function (slot) { @@ -254,10 +257,10 @@ var wrap = el('div', 'tnode tnode--bin'); wrap.dataset.id = bin.id; var row = el('div', 'tnode__row'); - row.appendChild(nodeActions([{ act: 'del', label: '🗑', title: 'Delete transmittal' }])); row.appendChild(el('span', 'tnode__name', bin.name || '(invalid — set date/seq)')); var placed = placedMap[bin.id] || []; if (placed.length) row.appendChild(el('span', 'tnode__badge', String(placed.length))); + row.appendChild(nodeActions([{ act: 'del', label: '🗑', title: 'Delete transmittal' }])); wrap.appendChild(row); if (placed.length) wrap.appendChild(fileList(placed)); return wrap; diff --git a/tests/classify.spec.js b/tests/classify.spec.js index 20c7888..23df6c0 100644 --- a/tests/classify.spec.js +++ b/tests/classify.spec.js @@ -170,11 +170,14 @@ test('target tree renders structure and tabs switch', async ({ page }) => { await expect(page.locator('#transmittalTree .tnode--bin .tnode__name', { hasText: 'ClientCorp-TRN-0007' })).toBeVisible(); }); -test('"+ Root folder" button (prompt) adds a tracking node', async ({ page }) => { +test('"+ Root folder" button (prompt) parses a name into nested levels', async ({ page }) => { await page.click('#modeClassifyBtn'); - page.once('dialog', (d) => d.accept('ACME-PROJ')); + page.once('dialog', (d) => d.accept('CPO-0001_0 (IFU)')); await page.click('#addTrackingRootBtn'); - await expect(page.locator('#trackingTree .tnode__name', { hasText: 'ACME-PROJ' })).toBeVisible(); + // "CPO-0001_0 (IFU)" → CPO / 0001 / 0 (IFU) (three nested levels). + await expect(page.locator('#trackingTree .tnode__name', { hasText: 'CPO' })).toBeVisible(); + await expect(page.locator('#trackingTree .tnode__name', { hasText: '0001' })).toBeVisible(); + await expect(page.locator('#trackingTree .tnode__name', { hasText: '0 (IFU)' })).toBeVisible(); }); // ── Phase 3: drag-and-drop assignment (drop handler) ─────────────────────── @@ -583,3 +586,34 @@ test('Hide Assigned: hides files dealt-with on the active axis and folders left expect(after.folderB).toBe(true); // B still needs a tracking number → stays expect(after.files).toEqual(['b1.pdf']); }); + +test('parseFolderLevels: split by - then a final _ into nested levels', async ({ page }) => { + const r = await page.evaluate(() => { + const c = window.app.modules.classify; + return { + three: c.parseFolderLevels('CPO-0001_0 (IFU)'), + full: c.parseFolderLevels('BMB-187023-PM-MOM-0001_A (IFR)'), + leafOnly: c.parseFolderLevels('A (IFR)'), + noRev: c.parseFolderLevels('CPO-0001'), + }; + }); + expect(r.three).toEqual(['CPO', '0001', '0 (IFU)']); + expect(r.full).toEqual(['BMB', '187023', 'PM', 'MOM', '0001', 'A (IFR)']); + expect(r.leafOnly).toEqual(['A (IFR)']); + expect(r.noRev).toEqual(['CPO', '0001']); +}); + +test('add-folder builds a nested chain sharing common ancestors', async ({ page }) => { + const r = await page.evaluate(() => { + const c = window.app.modules.classify; + c.reset(); + // Brace-expand then nest: two leaves under shared CPO/000x ancestors. + c.expandFolderPattern('CPO-{0001,0002}_0 (IFU)').forEach((nm) => + c.addTrackingPath(null, c.parseFolderLevels(nm))); + return JSON.parse(JSON.stringify(c.getTrackingTree(), (k, v) => (k === 'id' ? undefined : v))); + }); + expect(r.length).toBe(1); // one shared CPO root + expect(r[0].name).toBe('CPO'); + expect(r[0].children.map((n) => n.name)).toEqual(['0001', '0002']); + expect(r[0].children[0].children[0].name).toBe('0 (IFU)'); // leaf rev under each number +});