fix(classifier): parse add-folder names into nested levels; controls back to right/hover

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) <noreply@anthropic.com>
This commit is contained in:
ZDDC 2026-06-10 09:58:42 -05:00
parent 8f839fc0c9
commit 055f4cf4e0
4 changed files with 88 additions and 13 deletions

View file

@ -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;

View file

@ -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

View file

@ -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;

View file

@ -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
});