feat(classifier): By-tracking as a merged-cell table (pattern config + sticky)

The nested tracking tree wasted huge vertical space showing the hierarchy by
indentation. Render it as a spreadsheet-style table instead: each configured
field is a column, ancestor cells span (rowspan) their descendants' rows, the
revision gets its own aligned column, and each placed file is a row. Far denser,
and the hierarchy reads left-to-right.

- classify.js: a per-workspace pattern config { trackingFields[{name,optional}],
  statuses, modifiers }, default ORIG·PROJ·DISC·TYPE·SEQ + optional SUFFIX,
  seeded statuses from the shared enum. Serialized with the workspace; reset
  keeps it (it's a setting, not data). getConfig/setConfig/getTrackingFields.
- target-tree.js: tree → flat rows → merged-cell <table>. A revision leaf is
  detected by its "(STATUS)" suffix, so SEQ "0001" and revision "A (IFR)" land
  in the right columns. Drop targets, CRUD, editable ZDDC name, validation,
  filter and cross-reveal all preserved on the cells.
- css: sticky header + sticky merged-cell values (the value stays pinned under
  the header while you scroll a tall group, so a span never reads blank).
- The original filename moves to a hover tooltip on the editable name.

Next: the revision context-menu (status / draft ~ / +Cn toggles + stepper) and
the settings UI to edit the pattern/status/modifier lists.

Tests: merged ancestors get the right rowspan; revisions align in one column;
the date revision stays intact (49 green).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
ZDDC 2026-06-10 15:56:00 -05:00
parent 8d8094bb9f
commit 1631e2b8ca
4 changed files with 274 additions and 55 deletions

View file

@ -584,3 +584,49 @@ input.tfile__name:focus { border-color: var(--primary); background: var(--bg); o
opacity: 0.5; opacity: 0.5;
cursor: wait; cursor: wait;
} }
/* ── By-tracking merged-cell table ──────────────────────────────────────── */
#trackingTree { padding: 0; } /* table reaches the edges; cells carry padding */
.ttable { border-collapse: separate; border-spacing: 0; width: 100%; font-size: 0.82rem; }
.ttable th, .ttable td {
border-right: 1px solid var(--border);
border-bottom: 1px solid var(--border);
vertical-align: top;
padding: 0;
}
.ttable thead th {
position: sticky; top: 0; z-index: 3;
background: var(--bg-secondary, var(--bg));
color: var(--text-muted);
font-size: 0.68rem; font-weight: 700; letter-spacing: 0.04em; text-transform: uppercase;
text-align: left; padding: 0.3rem 0.5rem; white-space: nowrap;
border-top: 1px solid var(--border);
}
.ttable__rh { color: var(--primary); }
.ttable__fileh { width: 99%; } /* the files column soaks up remaining width */
.ttable__cell--empty { background: var(--bg-secondary, var(--bg)); }
/* The merged-cell value stays pinned just under the header while you scroll the
group, so a tall rowspan never reads as a blank column. */
.tcell__inner {
position: sticky; top: 1.6rem;
display: flex; align-items: center; gap: 0.3rem;
padding: 0.25rem 0.5rem; white-space: nowrap;
}
.tcell__name { font-weight: 600; }
.trev__inner .tcell__name { color: var(--primary); }
.ttable__cell:hover .tnode__actions, .ttable__rev:hover .tnode__actions { opacity: 1; }
.ttable .drop-hover { outline: 2px solid var(--primary); outline-offset: -2px; }
.ttable__file { padding: 0.1rem 0.4rem; }
.ttable__drop { color: var(--text-muted); font-style: italic; font-size: 0.75rem; }
.ttable .tfile { gap: 0.3rem; align-items: center; }
.ttable .tfile__name {
flex: 1; min-width: 8rem; max-width: 24rem;
padding: 0.15rem 0.35rem; border: 1px solid transparent; border-radius: var(--radius);
background: transparent; color: var(--text); font-size: 0.8rem;
}
.ttable .tfile__name:hover, .ttable .tfile__name:focus { border-color: var(--border); background: var(--bg); }
.ttable .tfile__name--err { color: var(--danger); }
.ttable .tfile--err::before { content: none; } /* we render our own badge instead */
.tfile__badge { font-size: 0.78rem; flex: 0 0 auto; }
.tfile__badge--ok { color: var(--success, #16a34a); }
.tfile__badge--err { color: var(--danger); }

View file

@ -29,6 +29,26 @@
return 'n' + (Date.now().toString(36)) + '-' + (++_idSeq).toString(36); return 'n' + (Date.now().toString(36)) + '-' + (++_idSeq).toString(36);
} }
// Per-workspace tracking-number PATTERN config. Drives the By-tracking
// table columns + (later) revision-modifier menus. Editable by the user.
var DEFAULT_FIELDS = [
{ name: 'ORIG', optional: false },
{ name: 'PROJ', optional: false },
{ name: 'DISC', optional: false },
{ name: 'TYPE', optional: false },
{ name: 'SEQ', optional: false },
{ name: 'SUFFIX', optional: true },
];
var DEFAULT_STATUSES = (window.zddc && window.zddc.STATUSES) ? window.zddc.STATUSES.slice() : ['---'];
var DEFAULT_MODIFIERS = ['B', 'C', 'N', 'Q'];
function defaultConfig() {
return {
trackingFields: DEFAULT_FIELDS.map(function (f) { return { name: f.name, optional: !!f.optional }; }),
statuses: DEFAULT_STATUSES.slice(),
modifiers: DEFAULT_MODIFIERS.slice(),
};
}
// ── state ──────────────────────────────────────────────────────────────── // ── state ────────────────────────────────────────────────────────────────
var state = { var state = {
enabled: false, // classify mode on/off enabled: false, // classify mode on/off
@ -36,6 +56,7 @@
trackingTree: [], // [ { id, name, children:[] } ] (leaf = no children) trackingTree: [], // [ { id, name, children:[] } ] (leaf = no children)
transmittalTree: [], // [ { id, kind:'party', name, children:[ slot ] } ] transmittalTree: [], // [ { id, kind:'party', name, children:[ slot ] } ]
outputName: null, // remembered output directory display name outputName: null, // remembered output directory display name
config: defaultConfig(), // tracking-number pattern (fields/statuses/modifiers)
}; };
// id -> { node, kind:'tracking'|'party'|'slot'|'transmittal', parent } // id -> { node, kind:'tracking'|'party'|'slot'|'transmittal', parent }
@ -385,6 +406,7 @@
trackingTree: state.trackingTree, trackingTree: state.trackingTree,
transmittalTree: state.transmittalTree, transmittalTree: state.transmittalTree,
outputName: state.outputName, outputName: state.outputName,
config: state.config,
}; };
} }
function load(obj) { function load(obj) {
@ -393,9 +415,12 @@
state.trackingTree = obj.trackingTree || []; state.trackingTree = obj.trackingTree || [];
state.transmittalTree = obj.transmittalTree || []; state.transmittalTree = obj.transmittalTree || [];
state.outputName = obj.outputName || null; state.outputName = obj.outputName || null;
state.config = normalizeConfig(obj.config);
rebuildIndex(); rebuildIndex();
notify(); notify();
} }
// Reset clears the CLASSIFICATION but keeps the pattern config — it's a
// per-project setting, not part of the data being cleared.
function reset() { function reset() {
state.assignments = {}; state.trackingTree = []; state.transmittalTree = []; state.assignments = {}; state.trackingTree = []; state.transmittalTree = [];
state.outputName = null; state.outputName = null;
@ -403,6 +428,23 @@
notify(); notify();
} }
// ── pattern config ───────────────────────────────────────────────────────
function normalizeConfig(c) {
var d = defaultConfig();
if (!c || typeof c !== 'object') return d;
var fields = Array.isArray(c.trackingFields) && c.trackingFields.length
? c.trackingFields.map(function (f) { return { name: String(f && f.name || '').trim() || '?', optional: !!(f && f.optional) }; })
: d.trackingFields;
return {
trackingFields: fields,
statuses: Array.isArray(c.statuses) && c.statuses.length ? c.statuses.slice() : d.statuses,
modifiers: Array.isArray(c.modifiers) && c.modifiers.length ? c.modifiers.slice() : d.modifiers,
};
}
function getConfig() { return state.config; }
function getTrackingFields() { return state.config.trackingFields; }
function setConfig(c) { state.config = normalizeConfig(c); notify(); }
// ── add-folder pattern expansion ───────────────────────────────────────── // ── add-folder pattern expansion ─────────────────────────────────────────
// Brace expansion for the add-folder box. Supports (non-nested) groups: // Brace expansion for the add-folder box. Supports (non-nested) groups:
// {a,b,c} → alternation: a | b | c // {a,b,c} → alternation: a | b | c
@ -575,6 +617,7 @@
trackingNodeComplete: trackingNodeComplete, trackingPathLabel: trackingPathLabel, trackingNodeComplete: trackingNodeComplete, trackingPathLabel: trackingPathLabel,
transmittalRecord: transmittalRecord, transmittalRecord: transmittalRecord,
findOrAddParty: findOrAddParty, findOrAddTransmittalBin: findOrAddTransmittalBin, findOrAddParty: findOrAddParty, findOrAddTransmittalBin: findOrAddTransmittalBin,
getConfig: getConfig, setConfig: setConfig, getTrackingFields: getTrackingFields,
getNode: getNode, getTrackingTree: function () { return state.trackingTree; }, getNode: getNode, getTrackingTree: function () { return state.trackingTree; },
getTransmittalTree: function () { return state.transmittalTree; }, getTransmittalTree: function () { return state.transmittalTree; },
// derive + reverse // derive + reverse

View file

@ -199,51 +199,153 @@
return rfHit(orig) || rfHit(C().deriveTarget(f).filename || ''); return rfHit(orig) || rfHit(C().deriveTarget(f).filename || '');
} }
// Tracking tree (recursive, filter-aware — a match reveals its whole path). // ── By-tracking: merged-cell table ──────────────────────────────────────
function renderTrackingInto(container, nodes, placedMap) { // The positional hierarchy reads left-to-right as columns (one per configured
container.textContent = ''; // field), ancestor cells span their descendants' rows, and the revision (the
if (!nodes.length) { // leaf) gets its own aligned column. Each placed file is a row.
container.appendChild(el('div', 'target-empty', 'No tracking folders yet — “+ Root folder” to start.'));
return;
}
nodes.forEach(function (n) { var e = trackingNode(n, placedMap, false); if (e) container.appendChild(e); });
if (rfActive() && !container.children.length) {
container.appendChild(el('div', 'target-empty', 'No matches in the tracking tree.'));
}
}
function trackingNode(n, placedMap, ancMatched) {
var matched = ancMatched || rfHit(n.name);
var isLeaf = (n.children || []).length === 0;
var expanded = !collapsed[n.id] || rfActive(); // auto-expand to reveal matches
var childEls = [];
if (expanded || rfActive()) {
(n.children || []).forEach(function (c) { var ce = trackingNode(c, placedMap, matched); if (ce) childEls.push(ce); });
}
var placed = placedMap[n.id] || [];
var shownFiles = (rfActive() && !matched) ? placed.filter(fileRowMatches) : placed;
if (rfActive() && !matched && !childEls.length && !shownFiles.length) return null;
var wrap = el('div', 'tnode' + (isLeaf ? ' tnode--leaf' : '')); // A node is a revision leaf when its name ends in a "(STATUS)" we recognise —
wrap.dataset.id = n.id; // tracking field codes never carry a parenthesised status, so this cleanly
var row = el('div', 'tnode__row'); // separates "0001" (a SEQ field) from "A (IFR)" (a revision).
var toggle = el('button', 'tnode__toggle', isLeaf ? '·' : (expanded ? '▾' : '▸')); function revStatusOf(name) {
if (!isLeaf) toggle.dataset.act = 'toggle'; var m = /\(\s*([A-Za-z0-9-]{1,5})\s*\)\s*$/.exec(name || '');
row.appendChild(toggle); return (m && window.zddc.isValidStatus(m[1])) ? m[1] : null;
row.appendChild(el('span', 'tnode__name', n.name)); }
if (placed.length) row.appendChild(el('span', 'tnode__badge', String(placed.length))); function isRevisionLeaf(node) {
row.appendChild(nodeActions([ return !(node.children || []).length && revStatusOf(node.name) != null;
{ act: 'add', label: '', title: 'Add child folder' }, }
// Flatten the tree into rows: { path:[fieldNodes], rev:revNode|null, file }.
function buildTrackingRows(nodes, placedMap) {
var rows = [];
function emit(path, rev, files) {
var fs = (files && files.length) ? files : [null];
fs.forEach(function (f) { rows.push({ path: path, rev: rev, file: f }); });
}
function walk(node, ancestors) {
var placed = placedMap[node.id] || [];
if (isRevisionLeaf(node)) { emit(ancestors, node, placed); return; }
var myPath = ancestors.concat(node); // node is a tracking field segment
if (placed.length) emit(myPath, null, placed); // files dropped on a partial number
var kids = node.children || [];
if (kids.length) kids.forEach(function (c) { walk(c, myPath); });
else if (!placed.length) emit(myPath, null, []); // empty leaf = drop target
}
nodes.forEach(function (n) { walk(n, []); });
return rows;
}
function rowMatches(row) {
if (!rfActive()) return true;
if (row.file && fileRowMatches(row.file)) return true;
if (row.rev && rfHit(row.rev.name)) return true;
for (var i = 0; i < row.path.length; i++) { if (rfHit(row.path[i].name)) return true; }
return false;
}
function fieldCellContent(node) {
var inner = el('div', 'tcell__inner');
inner.appendChild(el('span', 'tcell__name', node.name));
inner.appendChild(nodeActions([
{ act: 'add', label: '', title: 'Add child segment / revision' },
{ act: 'rename', label: '✎', title: 'Rename' }, { act: 'rename', label: '✎', title: 'Rename' },
{ act: 'del', label: '🗑', title: 'Delete' }, { act: 'del', label: '🗑', title: 'Delete' },
])); ]));
wrap.appendChild(row); return inner;
if (shownFiles.length) wrap.appendChild(fileList(shownFiles));
if (!isLeaf && expanded && childEls.length) {
var kids = el('div', 'tnode__children');
childEls.forEach(function (ce) { kids.appendChild(ce); });
wrap.appendChild(kids);
} }
return wrap; function revCellContent(node, placedMap) {
var inner = el('div', 'tcell__inner trev__inner');
inner.appendChild(el('span', 'tcell__name', node.name));
var n = (placedMap[node.id] || []).length;
if (n) inner.appendChild(el('span', 'tnode__badge', String(n)));
inner.appendChild(nodeActions([
{ act: 'rename', label: '✎', title: 'Rename revision' },
{ act: 'del', label: '🗑', title: 'Delete' },
]));
return inner;
}
// A placed-file cell: editable ZDDC name + validation badge; the original
// filename is on hover, not shown inline. Reuses .tfile/.tfile__name so the
// delegated preview + name-edit handlers apply.
function fileCellContent(f) {
var d = C().deriveTarget(f);
var row = el('div', 'tfile' + (d.errors.length ? ' tfile--err' : ''));
row.dataset.key = d.key;
var orig = f.originalFilename + (f.extension ? '.' + f.extension : '');
var name = el('input', 'tfile__name' + (d.errors.length ? ' tfile__name--err' : ''));
name.type = 'text';
name.value = d.filename || '';
name.placeholder = '(incomplete)';
name.title = (d.errors.length ? d.errors.join('; ') + ' · ' : '') + 'original: ' + orig;
row.appendChild(name);
row.appendChild(el('span', 'tfile__badge' + (d.errors.length ? ' tfile__badge--err' : ' tfile__badge--ok'),
d.errors.length ? '⚠' : '✓'));
return row;
}
function renderTrackingInto(container, nodes, placedMap) {
container.textContent = '';
if (!nodes.length) {
container.appendChild(el('div', 'target-empty', 'No tracking numbers yet — “+ Root folder” to start.'));
return;
}
var rows = buildTrackingRows(nodes, placedMap).filter(rowMatches);
if (!rows.length) {
container.appendChild(el('div', 'target-empty', rfActive() ? 'No matches in the tracking tree.' : 'No tracking numbers yet.'));
return;
}
var fields = C().getTrackingFields();
var maxPath = rows.reduce(function (m, r) { return Math.max(m, r.path.length); }, 0);
var nCols = Math.max(fields.length, maxPath);
function cellId(row, col) {
if (col < nCols) { var n = row.path[col]; return n ? n.id : null; }
return row.rev ? row.rev.id : null; // col === nCols → revision
}
// Rowspan run starting at row i for column col (0 = covered from above).
function spanAt(col, i) {
var id = cellId(rows[i], col);
if (id == null) return 1;
if (i > 0 && cellId(rows[i - 1], col) === id) return 0;
var span = 1;
for (var j = i + 1; j < rows.length; j++) { if (cellId(rows[j], col) === id) span++; else break; }
return span;
}
var table = el('table', 'ttable');
var thead = el('thead'), htr = el('tr');
for (var c = 0; c < nCols; c++) {
htr.appendChild(el('th', 'ttable__fh', fields[c] ? fields[c].name + (fields[c].optional ? ' ?' : '') : '·'));
}
htr.appendChild(el('th', 'ttable__rh', 'REVISION'));
htr.appendChild(el('th', 'ttable__fileh', 'Files'));
thead.appendChild(htr); table.appendChild(thead);
var tbody = el('tbody');
rows.forEach(function (row, i) {
var tr = el('tr');
for (var col = 0; col < nCols; col++) {
var span = spanAt(col, i);
if (span === 0) continue; // merged from the row above
var node = row.path[col] || null;
var td = el('td', 'ttable__cell' + (node ? '' : ' ttable__cell--empty'));
if (span > 1) td.rowSpan = span;
if (node) { td.dataset.id = node.id; td.appendChild(fieldCellContent(node)); }
tr.appendChild(td);
}
var rspan = spanAt(nCols, i);
if (rspan !== 0) {
var rtd = el('td', 'ttable__rev' + (row.rev ? '' : ' ttable__cell--empty'));
if (rspan > 1) rtd.rowSpan = rspan;
if (row.rev) { rtd.dataset.id = row.rev.id; rtd.appendChild(revCellContent(row.rev, placedMap)); }
tr.appendChild(rtd);
}
var ftd = el('td', 'ttable__file');
if (row.file) ftd.appendChild(fileCellContent(row.file));
else ftd.appendChild(el('span', 'ttable__drop', 'drop a file here'));
tr.appendChild(ftd);
tbody.appendChild(tr);
});
table.appendChild(tbody);
container.appendChild(table);
} }
// Transmittal tree // Transmittal tree
@ -334,7 +436,7 @@
// ── events ───────────────────────────────────────────────────────────── // ── events ─────────────────────────────────────────────────────────────
function closestNodeId(target) { function closestNodeId(target) {
var n = target.closest('.tnode'); var n = target.closest('[data-id]');
return n ? n.dataset.id : null; return n ? n.dataset.id : null;
} }
function fileByKey(key) { function fileByKey(key) {
@ -453,10 +555,14 @@
// tracking → any folder node (.tnode) // tracking → any folder node (.tnode)
// transmittal → a transmittal bin only (.tnode--bin) // transmittal → a transmittal bin only (.tnode--bin)
function dropTarget(target, axis) { function dropTarget(target, axis) {
var sel = axis === 'transmittal' ? '.tnode--bin' : '.tnode'; if (axis === 'transmittal') {
var node = target.closest(sel); var bin = target.closest('.tnode--bin');
if (!node || !node.dataset.id) return null; if (!bin || !bin.dataset.id) return null;
return { id: node.dataset.id, row: node.querySelector('.tnode__row') || node }; return { id: bin.dataset.id, row: bin.querySelector('.tnode__row') || bin };
}
var cell = target.closest('.ttable__cell[data-id], .ttable__rev[data-id]');
if (!cell) return null;
return { id: cell.dataset.id, row: cell };
} }
function clearHover(container) { function clearHover(container) {
var hot = container.querySelectorAll('.drop-hover'); var hot = container.querySelectorAll('.drop-hover');
@ -515,7 +621,7 @@
} }
} }
function flashNode(container, id) { function flashNode(container, id) {
var node = container.querySelector('.tnode[data-id="' + id + '"]'); var node = container.querySelector('[data-id="' + id + '"]');
if (!node) return; if (!node) return;
node.scrollIntoView({ block: 'center' }); node.scrollIntoView({ block: 'center' });
var row = node.querySelector('.tnode__row') || node; var row = node.querySelector('.tnode__row') || node;

View file

@ -161,9 +161,9 @@ test('target tree renders structure and tabs switch', async ({ page }) => {
const party = c.addParty('ClientCorp'); const party = c.addParty('ClientCorp');
c.addTransmittalBin(party, 'received', { date: '2026-03-14', type: 'TRN', seq: '0007' }); c.addTransmittalBin(party, 'received', { date: '2026-03-14', type: 'TRN', seq: '0007' });
}); });
// Tracking panel visible by default with the nodes rendered. // Tracking panel visible by default with the table rendered.
await expect(page.locator('#trackingTree .tnode__name', { hasText: 'ACME-PROJ' })).toBeVisible(); await expect(page.locator('#trackingTree .ttable__cell .tcell__name', { hasText: 'ACME-PROJ' })).toBeVisible();
await expect(page.locator('#trackingTree .tnode--leaf .tnode__name', { hasText: 'A (IFR)' })).toBeVisible(); await expect(page.locator('#trackingTree .ttable__rev .tcell__name', { hasText: 'A (IFR)' })).toBeVisible();
// Switch to transmittal tab. // Switch to transmittal tab.
await page.click('#transmittalTab'); await page.click('#transmittalTab');
expect(await page.locator('#transmittalPanel').isHidden()).toBe(false); expect(await page.locator('#transmittalPanel').isHidden()).toBe(false);
@ -174,10 +174,10 @@ test('"+ Root folder" button (prompt) parses a name into nested levels', async (
await page.click('#modeClassifyBtn'); await page.click('#modeClassifyBtn');
page.once('dialog', (d) => d.accept('CPO-0001_0 (IFU)')); page.once('dialog', (d) => d.accept('CPO-0001_0 (IFU)'));
await page.click('#addTrackingRootBtn'); await page.click('#addTrackingRootBtn');
// "CPO-0001_0 (IFU)" → CPO / 0001 / 0 (IFU) (three nested levels). // "CPO-0001_0 (IFU)" → CPO / 0001 columns + "0 (IFU)" revision cell.
await expect(page.locator('#trackingTree .tnode__name', { hasText: 'CPO' })).toBeVisible(); await expect(page.locator('#trackingTree .tcell__name', { hasText: 'CPO' })).toBeVisible();
await expect(page.locator('#trackingTree .tnode__name', { hasText: '0001' })).toBeVisible(); await expect(page.locator('#trackingTree .tcell__name', { hasText: '0001' })).toBeVisible();
await expect(page.locator('#trackingTree .tnode__name', { hasText: '0 (IFU)' })).toBeVisible(); await expect(page.locator('#trackingTree .ttable__rev .tcell__name', { hasText: '0 (IFU)' })).toBeVisible();
}); });
// ── Phase 3: drag-and-drop assignment (drop handler) ─────────────────────── // ── Phase 3: drag-and-drop assignment (drop handler) ───────────────────────
@ -188,7 +188,7 @@ test('dropping a file onto a tracking leaf assigns it', async ({ page }) => {
const c = window.app.modules.classify; const c = window.app.modules.classify;
const leaf = c.addTrackingNode(c.addTrackingNode(null, 'ACME-MECH-0001'), 'A (IFR)'); const leaf = c.addTrackingNode(c.addTrackingNode(null, 'ACME-MECH-0001'), 'A (IFR)');
window.app.modules.targetTree.render(); window.app.modules.targetTree.render();
const row = document.querySelector('#trackingTree .tnode--leaf .tnode__row'); const row = document.querySelector('#trackingTree .ttable__rev[data-id]');
const key = 'Sub/foundation.pdf'; const key = 'Sub/foundation.pdf';
window.app.modules.dnd.setDrag([key]); window.app.modules.dnd.setDrag([key]);
row.dispatchEvent(new Event('drop', { bubbles: true, cancelable: true })); row.dispatchEvent(new Event('drop', { bubbles: true, cancelable: true }));
@ -739,7 +739,7 @@ test('tracking-tree filter reveals matching nodes and hides the rest', async ({
c.addTrackingPath(null, c.parseFolderLevels('XYZ-0009_A (IFR)')); c.addTrackingPath(null, c.parseFolderLevels('XYZ-0009_A (IFR)'));
window.app.modules.targetTree.render(); window.app.modules.targetTree.render();
window.app.modules.targetTree.setNameFilter('CPO'); window.app.modules.targetTree.setNameFilter('CPO');
return Array.from(document.querySelectorAll('#trackingTree .tnode__name')).map((e) => e.textContent); return Array.from(document.querySelectorAll('#trackingTree .tcell__name')).map((e) => e.textContent);
}); });
expect(names).toContain('CPO'); expect(names).toContain('CPO');
expect(names).toContain('0001'); expect(names).toContain('0001');
@ -960,3 +960,27 @@ test('a fully-excluded folder is struck through like its files', async ({ page }
expect(r.before).toBe(false); // not struck through while active expect(r.before).toBe(false); // not struck through while active
expect(r.after).toBe(true); // struck through once the whole subtree is excluded expect(r.after).toBe(true); // struck through once the whole subtree is excluded
}); });
test('By-tracking table merges shared ancestors and aligns revisions', async ({ page }) => {
await page.click('#modeClassifyBtn');
const r = await page.evaluate(() => {
const c = window.app.modules.classify, tt = window.app.modules.targetTree;
c.reset();
c.addTrackingPath(null, c.parseFolderLevels('LKU-123456-PM-SCH-0001_2025-11-17 (IFI)'));
c.addTrackingPath(null, c.parseFolderLevels('LKU-123456-PM-SCH-0001_A (IFR)'));
c.addTrackingPath(null, c.parseFolderLevels('CPO-0001_0 (IFU)'));
tt.render();
const cellByName = (n) => Array.from(document.querySelectorAll('#trackingTree .ttable__cell .tcell__name'))
.filter((e) => e.textContent === n).map((e) => e.closest('td'))[0];
const lku = cellByName('LKU'), cpo = cellByName('CPO');
return {
lkuSpan: lku ? lku.rowSpan : 0,
cpoSpan: cpo ? cpo.rowSpan : 0,
revs: Array.from(document.querySelectorAll('#trackingTree .ttable__rev .tcell__name')).map((e) => e.textContent),
};
});
expect(r.lkuSpan).toBe(2); // the LKU ancestor cell spans its two revisions (merged)
expect(r.cpoSpan).toBe(1);
// The revisions live in one aligned column; the date revision stays intact.
expect(r.revs).toEqual(['2025-11-17 (IFI)', 'A (IFR)', '0 (IFU)']);
});