feat(classifier): By Tracking Number is now a flat editable grid (one row per file)
Replace the merged-cell positional table (one column per tracking-number segment,
hierarchy via shared ancestors, built by creating folders) with a plain editable
spreadsheet: one row per file, with the tracking number, the rev (status), and
the title as three separate editable columns. Columns are hideable + resizable.
The storage model is unchanged — a file's tracking identity is still its
placement in the tracking-folder tree. The grid is a flat presentation + inline-
edit layer over it; editing a cell re-materializes the placement via the existing
path (addTrackingPath → place(…,'tracking') → setTitleOverride), generalized to
per-field.
- classify.js: `trackingWorkset` (serialized) so a dropped file is a row before
it has a number; `addToTrackingGrid`/`removeFromTrackingGrid`/`trackingGridKeys`
(union with files that have a tracking placement — incl. ones named via "From a
list"); `setFileIdentity(key, {tracking, rev, title})` re-files + prunes the old
leaf; blank tracking = an unfilled row, blank rev = a PENDING_REV leaf.
- target-tree.js: `renderTrackingGrid` (Status badge · Original name preview ·
Tracking number · Rev (status) · Title · ✕); drag onto the grid adds rows and
auto-fills any file whose own name already parses as ZDDC; a "Columns ▾" chooser
+ drag-resize (resize.js, now parameterized) persisted to localStorage. The
status badge validates the NAME only (the transmittal is a different tab).
Removed the merged-cell machinery + per-node CRUD (+ Root folder, ✎/🗑, brace
expansion) and the now-dead drop-on-node path.
- template/css: tracking toolbar → Columns chooser + hint; flat-grid + chooser CSS.
Tests: replaced the merged-cell/+Root-folder/drop-on-leaf/filename-edit tests with
grid tests (render, drop+auto-fill, per-cell re-file, filter, hide/persist,
preview link). Suite 342 green.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
e58347d476
commit
2b32aced6d
6 changed files with 338 additions and 305 deletions
|
|
@ -731,3 +731,33 @@ input.tfile__name:focus { border-color: var(--primary); background: var(--bg); o
|
|||
.tfile__badge { font-size: 0.78rem; flex: 0 0 auto; }
|
||||
.tfile__badge--ok { color: var(--success, #16a34a); }
|
||||
.tfile__badge--err { color: var(--danger); }
|
||||
|
||||
/* ── By-tracking flat editable grid (one row per file) ──────────────────── */
|
||||
.ttable--grid { width: auto; }
|
||||
.ttable--grid td.tg-td { padding: 0.1rem 0.35rem; vertical-align: middle; }
|
||||
.ttable--grid th.tg-th { white-space: nowrap; } /* .column-resizer (spreadsheet.css) sits in the sticky th */
|
||||
.tg-input {
|
||||
width: 100%; min-width: 4rem; box-sizing: border-box;
|
||||
padding: 0.12rem 0.3rem; border: 1px solid transparent; border-radius: var(--radius);
|
||||
background: transparent; color: var(--text); font: inherit; font-size: 0.8rem;
|
||||
}
|
||||
.tg-input:hover { border-color: var(--border); }
|
||||
.tg-input:focus { border-color: var(--primary); background: var(--bg); outline: none; }
|
||||
.tg-tn .tg-input { font-family: var(--mono, monospace); }
|
||||
.tg-input.is-warn { border-color: var(--warning, #b8860b); }
|
||||
.tg-orig__link { color: var(--text-muted); white-space: nowrap; text-decoration: none; cursor: pointer; }
|
||||
.tg-orig__link:hover { text-decoration: underline; }
|
||||
.tg-status, .tg-x { text-align: center; }
|
||||
.tg-x__btn { opacity: 0.5; }
|
||||
.tg-row:hover .tg-x__btn { opacity: 1; }
|
||||
.tg-row--err .tg-status { color: var(--danger); }
|
||||
.tg-drop-hover { outline: 2px dashed var(--primary); outline-offset: -3px; background: var(--primary-light); }
|
||||
|
||||
/* "Columns ▾" chooser menu */
|
||||
.col-chooser {
|
||||
position: fixed; z-index: 9600; background: var(--bg);
|
||||
border: 1px solid var(--border); border-radius: var(--radius);
|
||||
box-shadow: 0 6px 18px rgba(0,0,0,0.18); padding: 0.3rem; min-width: 11rem;
|
||||
}
|
||||
.col-chooser__item { display: flex; align-items: center; gap: 0.4rem; padding: 0.25rem 0.4rem; font-size: 0.83rem; cursor: pointer; border-radius: var(--radius); }
|
||||
.col-chooser__item:hover { background: var(--bg-hover); }
|
||||
|
|
|
|||
|
|
@ -60,6 +60,7 @@
|
|||
outputName: null, // remembered output directory display name
|
||||
config: defaultConfig(), // tracking-number pattern (fields/statuses/modifiers)
|
||||
worklist: [], // "From a list" scratch rows: [ { id, trackingNumber, title, revisionCell, source, archiveRevisions } ]
|
||||
trackingWorkset: Object.create(null), // srcKeys shown as rows in the By-tracking grid (set: key->true)
|
||||
};
|
||||
|
||||
// id -> { node, kind:'tracking'|'party'|'slot'|'transmittal', parent }
|
||||
|
|
@ -420,6 +421,7 @@
|
|||
worklist: state.worklist.map(function (r) {
|
||||
return { id: r.id, party: r.party, trackingNumber: r.trackingNumber, title: r.title, revisionCell: r.revisionCell, source: r.source, archiveRevisions: r.archiveRevisions };
|
||||
}),
|
||||
trackingWorkset: Object.keys(state.trackingWorkset),
|
||||
};
|
||||
}
|
||||
function load(obj) {
|
||||
|
|
@ -430,6 +432,8 @@
|
|||
state.outputName = obj.outputName || null;
|
||||
state.config = normalizeConfig(obj.config);
|
||||
state.worklist = (Array.isArray(obj.worklist) ? obj.worklist : []).map(normalizeRow);
|
||||
state.trackingWorkset = Object.create(null);
|
||||
(Array.isArray(obj.trackingWorkset) ? obj.trackingWorkset : []).forEach(function (k) { state.trackingWorkset[k] = true; });
|
||||
rebuildIndex();
|
||||
migrateLegacyMdl(obj.worklist); // BEFORE anything can prune; materializes old mdl placements
|
||||
notify();
|
||||
|
|
@ -461,10 +465,57 @@
|
|||
function reset() {
|
||||
state.assignments = {}; state.trackingTree = []; state.transmittalTree = [];
|
||||
state.outputName = null;
|
||||
state.trackingWorkset = Object.create(null);
|
||||
rebuildIndex();
|
||||
notify();
|
||||
}
|
||||
|
||||
// ── By-tracking grid (one editable row per file) ─────────────────────────
|
||||
// The grid is a flat presentation over the tracking-tree placement model.
|
||||
// `trackingWorkset` tracks files put on the grid so a dropped file shows as a
|
||||
// row before it has a tracking number; a file with a real tracking placement
|
||||
// (named here OR via the "From a list" tab) is always a row too.
|
||||
function addToTrackingGrid(keys) {
|
||||
var changed = false;
|
||||
(keys || []).forEach(function (k) { if (!state.trackingWorkset[k]) { state.trackingWorkset[k] = true; changed = true; } });
|
||||
if (changed) notify();
|
||||
}
|
||||
function removeFromTrackingGrid(key) {
|
||||
var a = state.assignments[key], old = a ? a.trackingNodeId : null;
|
||||
delete state.trackingWorkset[key];
|
||||
place([key], null, 'tracking');
|
||||
if (old) pruneEmptyTrackingChain(old);
|
||||
notify();
|
||||
}
|
||||
function trackingGridKeys() {
|
||||
var set = Object.create(null);
|
||||
Object.keys(state.trackingWorkset).forEach(function (k) { set[k] = true; });
|
||||
Object.keys(state.assignments).forEach(function (k) { if (state.assignments[k].trackingNodeId) set[k] = true; });
|
||||
return Object.keys(set);
|
||||
}
|
||||
// Re-materialize a file's tracking placement from a full identity. The caller
|
||||
// passes ALL three fields (current values for the ones it didn't edit), read
|
||||
// from deriveTarget — so this module needs no file objects. A blank revision
|
||||
// lands on the PENDING_REV placeholder leaf (incomplete until set); a blank
|
||||
// tracking number clears the placement (the row stays, unfilled).
|
||||
function setFileIdentity(key, ident) {
|
||||
ident = ident || {};
|
||||
var tracking = (ident.tracking == null ? '' : String(ident.tracking)).trim();
|
||||
var rev = (ident.rev == null ? '' : String(ident.rev)).trim();
|
||||
var a = state.assignments[key], old = a ? a.trackingNodeId : null;
|
||||
if (tracking) {
|
||||
var leaf = addTrackingPath(null, parseFolderLevels(tracking + '_' + (rev || PENDING_REV)));
|
||||
place([key], leaf, 'tracking');
|
||||
if (old && old !== leaf) pruneEmptyTrackingChain(old);
|
||||
} else {
|
||||
place([key], null, 'tracking');
|
||||
if (old) pruneEmptyTrackingChain(old);
|
||||
}
|
||||
setTitleOverride(key, ident.title || '');
|
||||
state.trackingWorkset[key] = true;
|
||||
notify();
|
||||
}
|
||||
|
||||
// ── pattern config ───────────────────────────────────────────────────────
|
||||
function normalizeConfig(c) {
|
||||
var d = defaultConfig();
|
||||
|
|
@ -888,6 +939,9 @@
|
|||
transmittalRecord: transmittalRecord,
|
||||
findOrAddParty: findOrAddParty, findOrAddTransmittalBin: findOrAddTransmittalBin,
|
||||
getConfig: getConfig, setConfig: setConfig, getTrackingFields: getTrackingFields,
|
||||
// By-tracking grid
|
||||
addToTrackingGrid: addToTrackingGrid, removeFromTrackingGrid: removeFromTrackingGrid,
|
||||
trackingGridKeys: trackingGridKeys, setFileIdentity: setFileIdentity,
|
||||
setWorklist: setWorklist, appendWorklist: appendWorklist, clearWorklist: clearWorklist,
|
||||
getWorklist: getWorklist, getWorklistRow: getWorklistRow,
|
||||
assignFromRow: assignFromRow, unassignRowFile: unassignRowFile,
|
||||
|
|
|
|||
|
|
@ -8,32 +8,39 @@
|
|||
let resizingColumn = null;
|
||||
let startX = 0;
|
||||
let startWidth = 0;
|
||||
let activeTable = null;
|
||||
let activeOnResize = null;
|
||||
|
||||
/**
|
||||
* Initialize column resizing
|
||||
* Initialize column resizing on a table. Defaults to the rename-in-place
|
||||
* spreadsheet when no table is passed (back-compatible). onResize(table) is
|
||||
* called after each drag ends, so a caller can persist the new widths.
|
||||
*/
|
||||
function init() {
|
||||
const table = window.app.dom.spreadsheet;
|
||||
function init(table, onResize) {
|
||||
table = table || (window.app.dom && window.app.dom.spreadsheet);
|
||||
if (!table) return;
|
||||
const headers = table.querySelectorAll('thead th');
|
||||
|
||||
|
||||
headers.forEach(th => {
|
||||
// Skip if resize handle already exists
|
||||
if (th.querySelector('.column-resizer')) return;
|
||||
|
||||
|
||||
// Add resize handle
|
||||
const resizer = document.createElement('div');
|
||||
resizer.className = 'column-resizer';
|
||||
th.appendChild(resizer);
|
||||
|
||||
|
||||
// Mouse down on resizer
|
||||
resizer.addEventListener('mousedown', (e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
|
||||
|
||||
resizingColumn = th;
|
||||
startX = e.pageX;
|
||||
startWidth = th.offsetWidth;
|
||||
|
||||
activeTable = table;
|
||||
activeOnResize = onResize || null;
|
||||
|
||||
document.addEventListener('mousemove', handleMouseMove);
|
||||
document.addEventListener('mouseup', handleMouseUp);
|
||||
});
|
||||
|
|
@ -61,6 +68,8 @@
|
|||
resizingColumn = null;
|
||||
document.removeEventListener('mousemove', handleMouseMove);
|
||||
document.removeEventListener('mouseup', handleMouseUp);
|
||||
if (activeOnResize && activeTable) { try { activeOnResize(activeTable); } catch (_) { /* ignore */ } }
|
||||
activeTable = null; activeOnResize = null;
|
||||
}
|
||||
|
||||
// Export module
|
||||
|
|
|
|||
|
|
@ -43,7 +43,7 @@
|
|||
matchNamesBtn: document.getElementById('matchNamesBtn'),
|
||||
clearListBtn: document.getElementById('clearListBtn'),
|
||||
hideAssignedToggle: document.getElementById('hideAssignedToggle'),
|
||||
addTrackingRootBtn: document.getElementById('addTrackingRootBtn'),
|
||||
trackingColsBtn: document.getElementById('trackingColsBtn'),
|
||||
addPartyBtn: document.getElementById('addPartyBtn'),
|
||||
stats: document.getElementById('classifyStats'),
|
||||
};
|
||||
|
|
@ -78,22 +78,16 @@
|
|||
var text = t ? t.getData('text') : '';
|
||||
if (text) { e.preventDefault(); openPasteDialog(text); }
|
||||
});
|
||||
els.addTrackingRootBtn.addEventListener('click', function () {
|
||||
var name = prompt('Root folder name (a tracking-number segment, e.g. "ACME-PROJ").\n'
|
||||
+ 'Brace patterns expand: BMB-{PM,EL}-{0001-0002,0005}_A (IFR)', '');
|
||||
addFoldersFromPattern(null, name);
|
||||
});
|
||||
if (els.trackingColsBtn) els.trackingColsBtn.addEventListener('click', openColumnChooser);
|
||||
els.addPartyBtn.addEventListener('click', function () {
|
||||
var name = prompt('Party name (also the transmittal-number prefix):', '');
|
||||
if (name && name.trim()) C().addParty(name.trim());
|
||||
});
|
||||
|
||||
els.trackingTree.addEventListener('click', onTrackingClick);
|
||||
els.transmittalTree.addEventListener('click', onTransmittalClick);
|
||||
els.trackingTree.addEventListener('change', onFileNameChange);
|
||||
els.transmittalTree.addEventListener('change', onFileNameChange);
|
||||
|
||||
setupDropZone(els.trackingTree, 'tracking');
|
||||
setupGridDrop(els.trackingTree);
|
||||
setupDropZone(els.transmittalTree, 'transmittal');
|
||||
|
||||
C().on(render);
|
||||
|
|
@ -151,28 +145,13 @@
|
|||
function activeAxis() { return currentTab === 'transmittal' ? 'transmittal' : 'tracking'; }
|
||||
function reRenderSource() { if (window.app.modules.tree && window.app.modules.tree.render) window.app.modules.tree.render(); }
|
||||
|
||||
// Expand a brace pattern into folder names and create them (confirming a
|
||||
// multi-create first). parentId null = root folders. See expandFolderPattern.
|
||||
function addFoldersFromPattern(parentId, raw) {
|
||||
if (!raw || !raw.trim()) return;
|
||||
var names = C().expandFolderPattern(raw);
|
||||
if (!names.length) return;
|
||||
if (names.length > 1) {
|
||||
var shown = names.slice(0, 8).join('\n');
|
||||
if (names.length > 8) shown += '\n…and ' + (names.length - 8) + ' more';
|
||||
if (!confirm('Create ' + names.length + ' folders?\n\n' + shown)) return;
|
||||
}
|
||||
// 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 ───────────────────────────────────────────────────────────────
|
||||
function render() {
|
||||
if (!initialized || !C().isEnabled()) return;
|
||||
var files = allFiles();
|
||||
var placed = buildPlaced(files);
|
||||
renderTrackingInto(els.trackingTree, C().getTrackingTree(), placed.tracking);
|
||||
renderTrackingGrid(els.trackingTree);
|
||||
renderTransmittalInto(els.transmittalTree, C().getTransmittalTree(), placed.transmittal);
|
||||
renderWorklist(placed.byTracking);
|
||||
renderStats(files);
|
||||
|
|
@ -258,165 +237,173 @@
|
|||
return rfHit(orig) || rfHit(C().deriveTarget(f).filename || '');
|
||||
}
|
||||
|
||||
// ── By-tracking: merged-cell table ──────────────────────────────────────
|
||||
// The positional hierarchy reads left-to-right as columns (one per configured
|
||||
// field), ancestor cells span their descendants' rows, and the revision (the
|
||||
// leaf) gets its own aligned column. Each placed file is a row.
|
||||
// ── By-tracking: flat editable grid (one row per file) ──────────────────
|
||||
var GRID_COLS = [
|
||||
{ id: 'status', title: '', cls: 'tg-status', fixed: true },
|
||||
{ id: 'orig', title: 'Original name', cls: 'tg-orig' },
|
||||
{ id: 'tn', title: 'Tracking number', cls: 'tg-tn' },
|
||||
{ id: 'rev', title: 'Rev (status)', cls: 'tg-rev' },
|
||||
{ id: 'title', title: 'Title', cls: 'tg-title' },
|
||||
{ id: 'x', title: '', cls: 'tg-x', fixed: true },
|
||||
];
|
||||
var GRID_PREFS_KEY = 'zddc.classifier.trackingCols';
|
||||
function gridPrefs() { try { return JSON.parse(localStorage.getItem(GRID_PREFS_KEY)) || {}; } catch (_) { return {}; } }
|
||||
function saveGridPrefs(p) { try { localStorage.setItem(GRID_PREFS_KEY, JSON.stringify(p)); } catch (_) { /* private mode */ } }
|
||||
|
||||
// A node is a revision leaf when its name ends in a "(STATUS)" we recognise —
|
||||
// tracking field codes never carry a parenthesised status, so this cleanly
|
||||
// separates "0001" (a SEQ field) from "A (IFR)" (a revision).
|
||||
function revStatusOf(name) {
|
||||
var m = /\(\s*([A-Za-z0-9-]{1,5})\s*\)\s*$/.exec(name || '');
|
||||
return (m && window.zddc.isValidStatus(m[1])) ? m[1] : null;
|
||||
}
|
||||
function isRevisionLeaf(node) {
|
||||
return !(node.children || []).length && revStatusOf(node.name) != null;
|
||||
}
|
||||
// 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: 'del', label: '🗑', title: 'Delete' },
|
||||
]));
|
||||
return inner;
|
||||
}
|
||||
function revCellContent(node, placedMap) {
|
||||
var inner = el('div', 'tcell__inner trev__inner');
|
||||
// The revision name doubles as a preview link for its placed file (the
|
||||
// common case is one file per revision). No count bubble.
|
||||
var files = placedMap[node.id] || [];
|
||||
if (files.length) {
|
||||
var link = el('a', 'tcell__name tcell__preview', node.name);
|
||||
link.href = '#';
|
||||
link.dataset.previewKey = C().srcKeyForFile(files[0]);
|
||||
link.title = 'Preview ' + files[0].originalFilename + (files[0].extension ? '.' + files[0].extension : '');
|
||||
inner.appendChild(link);
|
||||
} else {
|
||||
inner.appendChild(el('span', 'tcell__name', node.name));
|
||||
}
|
||||
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) {
|
||||
// A file's current identity, read from the placement model so per-field edits
|
||||
// keep the fields they didn't touch.
|
||||
function currentIdent(f) {
|
||||
var d = C().deriveTarget(f);
|
||||
var conflict = C().hasHashConflict(d.key); // same name, different bytes
|
||||
var bad = d.errors.length || conflict;
|
||||
var row = el('div', 'tfile' + (bad ? ' tfile--err' : ''));
|
||||
row.dataset.key = d.key;
|
||||
var orig = f.originalFilename + (f.extension ? '.' + f.extension : '');
|
||||
var name = el('input', 'tfile__name' + (bad ? ' tfile__name--err' : ''));
|
||||
name.type = 'text';
|
||||
name.value = d.filename || '';
|
||||
name.placeholder = '(incomplete)';
|
||||
name.title = (conflict ? 'Same tracking+revision as another file but DIFFERENT content — fix before copying · ' : '')
|
||||
+ (d.errors.length ? d.errors.join('; ') + ' · ' : '') + 'original: ' + orig;
|
||||
row.appendChild(name);
|
||||
row.appendChild(el('span', 'tfile__badge' + (bad ? ' tfile__badge--err' : ' tfile__badge--ok'),
|
||||
conflict ? '≠' : (d.errors.length ? '⚠' : '✓')));
|
||||
return row;
|
||||
return { tracking: d.tracking || '', rev: (d.revision || '') + (d.status ? ' (' + d.status + ')' : ''), title: d.title || '' };
|
||||
}
|
||||
function gridTnWarn(tn) {
|
||||
tn = (tn || '').trim(); if (!tn) return '';
|
||||
var n = tn.split('-').length, want = C().getTrackingFields().length;
|
||||
return (n < want - 1 || n > want) ? ('Has ' + n + ' segments; the pattern expects ' + want + '.') : '';
|
||||
}
|
||||
function previewKey(key) {
|
||||
var f = fileByKey(key);
|
||||
if (f && window.app.modules.preview && window.app.modules.preview.previewFile) window.app.modules.preview.previewFile(f);
|
||||
}
|
||||
// The By-tracking grid validates the NAME only — the transmittal (path) is a
|
||||
// different tab, so its "not placed in a transmittal" error doesn't count here.
|
||||
function nameErrors(d) { return (d.errors || []).filter(function (e) { return e.indexOf('transmittal') === -1; }); }
|
||||
|
||||
function renderTrackingInto(container, nodes, placedMap) {
|
||||
function renderTrackingGrid(container) {
|
||||
container.textContent = '';
|
||||
if (!nodes.length) {
|
||||
container.appendChild(el('div', 'target-empty', 'No tracking numbers yet — “+ Root folder” to start.'));
|
||||
var c = C();
|
||||
var files = c.trackingGridKeys().map(fileByKey).filter(Boolean)
|
||||
.filter(function (f) { return !rfActive() || fileRowMatches(f); });
|
||||
if (!files.length) {
|
||||
container.appendChild(el('div', 'target-empty', rfActive() ? 'No matches.'
|
||||
: 'No files yet — drag files here from the left, then type each one’s tracking number, revision, and title. A file that’s already ZDDC-named fills in automatically.'));
|
||||
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);
|
||||
var prefs = gridPrefs(), hidden = prefs.hidden || {}, widths = prefs.widths || {};
|
||||
var cols = GRID_COLS.filter(function (col) { return !hidden[col.id]; });
|
||||
|
||||
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 table = el('table', 'ttable ttable--grid');
|
||||
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'));
|
||||
cols.forEach(function (col) {
|
||||
var th = el('th', 'tg-th ' + col.cls, col.title);
|
||||
th.dataset.col = col.id;
|
||||
if (widths[col.id]) { th.style.width = th.style.minWidth = th.style.maxWidth = widths[col.id] + 'px'; }
|
||||
htr.appendChild(th);
|
||||
});
|
||||
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)); }
|
||||
files.forEach(function (f) {
|
||||
var key = c.srcKeyForFile(f), d = c.deriveTarget(f);
|
||||
var bad = nameErrors(d).length || c.hasHashConflict(key);
|
||||
var tr = el('tr', 'tg-row' + (bad ? ' tg-row--err' : ''));
|
||||
tr.dataset.key = key;
|
||||
cols.forEach(function (col) {
|
||||
var td = el('td', 'tg-td ' + col.cls);
|
||||
buildGridCell(col.id, td, f, key);
|
||||
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);
|
||||
if (window.app.modules.resize && window.app.modules.resize.init) window.app.modules.resize.init(table, persistColWidths);
|
||||
}
|
||||
|
||||
function buildGridCell(colId, td, f, key) {
|
||||
var c = C(), d = c.deriveTarget(f), conflict = c.hasHashConflict(key);
|
||||
if (colId === 'status') {
|
||||
var ne = nameErrors(d), ok = !ne.length && !conflict;
|
||||
var badge = el('span', 'tfile__badge ' + (ok ? 'tfile__badge--ok' : 'tfile__badge--err'), conflict ? '≠' : (ne.length ? '⚠' : '✓'));
|
||||
badge.title = (conflict ? 'Same tracking+revision as another file but DIFFERENT content. ' : '') + (ne.length ? ne.join('; ') : 'Complete');
|
||||
td.appendChild(badge); return;
|
||||
}
|
||||
if (colId === 'orig') {
|
||||
var orig = f.originalFilename + (f.extension ? '.' + f.extension : '');
|
||||
var link = el('a', 'tg-orig__link', orig);
|
||||
link.href = '#'; link.title = 'Preview ' + orig;
|
||||
link.addEventListener('click', function (e) { e.preventDefault(); previewKey(key); });
|
||||
td.appendChild(link); return;
|
||||
}
|
||||
if (colId === 'x') {
|
||||
var rm = el('button', 'tnode__act tg-x__btn', '✕');
|
||||
rm.title = 'Remove from the grid';
|
||||
rm.addEventListener('click', function () { c.removeFromTrackingGrid(key); });
|
||||
td.appendChild(rm); return;
|
||||
}
|
||||
// editable: tn / rev / title
|
||||
var ident = currentIdent(f);
|
||||
var value = colId === 'tn' ? ident.tracking : colId === 'rev' ? ident.rev : ident.title;
|
||||
var ph = colId === 'tn' ? 'ACME-…-0001' : colId === 'rev' ? 'A (IFR)' : 'title';
|
||||
var warn = colId === 'tn' ? gridTnWarn(ident.tracking) : '';
|
||||
var inp = el('input', 'tg-input' + (warn ? ' is-warn' : ''));
|
||||
inp.type = 'text'; inp.value = value || ''; inp.placeholder = ph; inp.spellcheck = false;
|
||||
if (warn) inp.title = warn;
|
||||
inp.addEventListener('change', function () {
|
||||
var cur = currentIdent(f); // re-read so a prior edit isn't clobbered
|
||||
if (colId === 'tn') cur.tracking = inp.value.trim();
|
||||
else if (colId === 'rev') cur.rev = inp.value.trim();
|
||||
else cur.title = inp.value;
|
||||
c.setFileIdentity(key, cur);
|
||||
});
|
||||
td.appendChild(inp);
|
||||
}
|
||||
|
||||
function persistColWidths(table) {
|
||||
var p = gridPrefs(); p.widths = p.widths || {};
|
||||
Array.prototype.forEach.call(table.querySelectorAll('thead th[data-col]'), function (th) { p.widths[th.dataset.col] = Math.round(th.offsetWidth); });
|
||||
saveGridPrefs(p);
|
||||
}
|
||||
|
||||
// Drag files onto the grid → add as rows; auto-fill any already ZDDC-named.
|
||||
function setupGridDrop(container) {
|
||||
container.addEventListener('dragover', function (e) {
|
||||
if (!window.app.modules.dnd.active()) return;
|
||||
e.preventDefault(); e.dataTransfer.dropEffect = 'copy'; container.classList.add('tg-drop-hover');
|
||||
});
|
||||
container.addEventListener('dragleave', function (e) { if (e.target === container) container.classList.remove('tg-drop-hover'); });
|
||||
container.addEventListener('drop', function (e) {
|
||||
container.classList.remove('tg-drop-hover');
|
||||
e.preventDefault();
|
||||
var keys = window.app.modules.dnd.getDrag(); window.app.modules.dnd.clearDrag();
|
||||
if (keys.length) onGridDrop(keys);
|
||||
});
|
||||
}
|
||||
function onGridDrop(keys) {
|
||||
var c = C();
|
||||
c.addToTrackingGrid(keys);
|
||||
keys.forEach(function (k) {
|
||||
var f = fileByKey(k); if (!f) return;
|
||||
var p = window.zddc.parseFilename(f.originalFilename + (f.extension ? '.' + f.extension : ''));
|
||||
if (p && p.valid && p.trackingNumber) c.setFileIdentity(k, { tracking: p.trackingNumber, rev: p.revision + (p.status ? ' (' + p.status + ')' : ''), title: p.title || '' });
|
||||
});
|
||||
}
|
||||
|
||||
// "Columns ▾" chooser — hide/show grid columns (status + ✕ always shown).
|
||||
function openColumnChooser() {
|
||||
var open = document.querySelector('.col-chooser');
|
||||
if (open) { open.remove(); return; }
|
||||
var hidden = (gridPrefs().hidden || {});
|
||||
var menu = el('div', 'col-chooser');
|
||||
GRID_COLS.forEach(function (col) {
|
||||
if (col.fixed) return;
|
||||
var lbl = el('label', 'col-chooser__item');
|
||||
var cb = document.createElement('input'); cb.type = 'checkbox'; cb.checked = !hidden[col.id];
|
||||
cb.addEventListener('change', function () {
|
||||
var p = gridPrefs(); p.hidden = p.hidden || {};
|
||||
if (cb.checked) delete p.hidden[col.id]; else p.hidden[col.id] = true;
|
||||
saveGridPrefs(p); render();
|
||||
});
|
||||
lbl.appendChild(cb); lbl.appendChild(document.createTextNode(' ' + col.title));
|
||||
menu.appendChild(lbl);
|
||||
});
|
||||
var r = els.trackingColsBtn.getBoundingClientRect();
|
||||
menu.style.top = (r.bottom + 2) + 'px'; menu.style.left = r.left + 'px';
|
||||
document.body.appendChild(menu);
|
||||
setTimeout(function () {
|
||||
function off(e) { if (!menu.contains(e.target) && e.target !== els.trackingColsBtn) { menu.remove(); document.removeEventListener('mousedown', off); } }
|
||||
document.addEventListener('mousedown', off);
|
||||
}, 0);
|
||||
}
|
||||
|
||||
// Transmittal tree
|
||||
|
|
@ -901,40 +888,6 @@
|
|||
if (parsed.title != null) C().setTitleOverride(tf.dataset.key, parsed.title);
|
||||
// place/setTitleOverride fire classify.notify → re-render.
|
||||
}
|
||||
// Collapse/expand a node and its whole subtree (ctrl/cmd-click a toggle).
|
||||
function setSubtreeCollapsed(nodeId, collapse) {
|
||||
var node = C().getNode(nodeId);
|
||||
if (!node) return;
|
||||
(function walk(n) {
|
||||
if ((n.children || []).length) { if (collapse) collapsed[n.id] = true; else delete collapsed[n.id]; }
|
||||
(n.children || []).forEach(walk);
|
||||
})(node);
|
||||
}
|
||||
function onTrackingClick(e) {
|
||||
if (previewFromTarget(e)) return;
|
||||
var btn = e.target.closest('[data-act]');
|
||||
if (!btn) return;
|
||||
var act = btn.dataset.act;
|
||||
var id = closestNodeId(btn);
|
||||
if (act === 'toggle') {
|
||||
var collapse = !collapsed[id];
|
||||
if (e.ctrlKey || e.metaKey) setSubtreeCollapsed(id, collapse);
|
||||
else if (collapse) collapsed[id] = true; else delete collapsed[id];
|
||||
render();
|
||||
return;
|
||||
}
|
||||
if (act === 'add') {
|
||||
var name = prompt('Child folder name (next tracking segment, or a leaf revision like "A (IFR)").\n'
|
||||
+ 'Brace patterns expand: {PM,EL,EM}-MOM-{0001-0002,0005}_A (IFR)', '');
|
||||
addFoldersFromPattern(id, name);
|
||||
} else if (act === 'rename') {
|
||||
var node = C().getNode(id);
|
||||
var nn = prompt('Rename folder:', node ? node.name : '');
|
||||
if (nn && nn.trim()) C().renameNode(id, nn.trim());
|
||||
} else if (act === 'del') {
|
||||
if (confirm('Delete this folder and everything under it? Files placed here become unassigned.')) C().deleteNode(id);
|
||||
}
|
||||
}
|
||||
function onTransmittalClick(e) {
|
||||
if (previewFromTarget(e)) return;
|
||||
var btn = e.target.closest('[data-act]');
|
||||
|
|
@ -1026,25 +979,10 @@
|
|||
var keys = window.app.modules.dnd.getDrag();
|
||||
window.app.modules.dnd.clearDrag();
|
||||
if (!keys.length) return;
|
||||
if (axis === 'tracking') placeTrackingDrop(keys, t.id);
|
||||
else C().place(keys, t.id, axis);
|
||||
C().place(keys, t.id, axis);
|
||||
});
|
||||
}
|
||||
|
||||
// Tracking drop: if the target is already a complete leaf, assign directly;
|
||||
// otherwise prompt for the remaining levels (parsed + nested under it) so a
|
||||
// file can be dropped on an existing partial tracking number and completed.
|
||||
function placeTrackingDrop(keys, nodeId) {
|
||||
if (C().trackingNodeComplete(nodeId)) { C().place(keys, nodeId, 'tracking'); return; }
|
||||
var label = C().trackingPathLabel(nodeId);
|
||||
var input = prompt('Dropping under "' + label + '".\n'
|
||||
+ 'Add the remaining tracking levels (e.g. "0001_0 (IFU)"), or leave blank to drop here:', '');
|
||||
if (input === null) return; // cancelled
|
||||
var levels = C().parseFolderLevels(input.trim());
|
||||
var target = levels.length ? C().addTrackingPath(nodeId, levels) : nodeId;
|
||||
C().place(keys, target, 'tracking');
|
||||
}
|
||||
|
||||
// Reveal a source key's placement in the target pane (source → target).
|
||||
function reveal(key) {
|
||||
var a = C().getAssignment(key);
|
||||
|
|
|
|||
|
|
@ -184,11 +184,11 @@
|
|||
<div class="target-body">
|
||||
<section id="trackingPanel" class="target-panel">
|
||||
<div class="target-panel__toolbar">
|
||||
<button id="addTrackingRootBtn" class="btn btn-sm btn-secondary">+ Root folder</button>
|
||||
<span class="target-hint">Folders join with “-” into the tracking number; the leaf folder is the revision — name it like “A (IFR)”.</span>
|
||||
<button id="trackingColsBtn" class="btn btn-sm btn-secondary" title="Show or hide columns">Columns ▾</button>
|
||||
<span class="target-hint">Drag files in, then type each one’s tracking number, revision (e.g. “A (IFR)”), and title. A file that’s already ZDDC-named fills in automatically. Columns are hideable + resizable.</span>
|
||||
</div>
|
||||
<input type="search" id="trackingFilterInput" class="tree-filter target-filter" spellcheck="false"
|
||||
placeholder="Filter the tracking tree…" aria-label="Filter tracking tree">
|
||||
placeholder="Filter the grid…" aria-label="Filter the tracking grid">
|
||||
<div id="trackingTree" class="target-tree"></div>
|
||||
</section>
|
||||
<section id="transmittalPanel" class="target-panel" hidden>
|
||||
|
|
|
|||
|
|
@ -152,49 +152,51 @@ test('mode switch swaps the spreadsheet pane for the target pane', async ({ page
|
|||
expect(await page.locator('#spreadsheetPane').isHidden()).toBe(false);
|
||||
});
|
||||
|
||||
test('target tree renders structure and tabs switch', async ({ page }) => {
|
||||
test('target tree renders the By-tracking grid and tabs switch', async ({ page }) => {
|
||||
await page.click('#modeClassifyBtn');
|
||||
await page.evaluate(() => {
|
||||
const c = window.app.modules.classify;
|
||||
const acme = c.addTrackingNode(null, 'ACME-PROJ');
|
||||
c.addTrackingNode(acme, 'A (IFR)');
|
||||
c.reset();
|
||||
const f = { originalFilename: 'scan', extension: 'pdf', folderPath: 'R' };
|
||||
window.app.folderTree = [{ name: 'R', path: 'R', files: [f], children: [] }];
|
||||
c.setFileIdentity(c.srcKeyForFile(f), { tracking: 'ACME-PROJ-EL-DWG-0001', rev: 'A (IFR)', title: 'Spec' });
|
||||
const party = c.addParty('ClientCorp');
|
||||
c.addTransmittalBin(party, 'received', { date: '2026-03-14', type: 'TRN', seq: '0007' });
|
||||
window.app.modules.targetTree.render();
|
||||
});
|
||||
// Tracking panel visible by default with the table rendered.
|
||||
await expect(page.locator('#trackingTree .ttable__cell .tcell__name', { hasText: 'ACME-PROJ' })).toBeVisible();
|
||||
await expect(page.locator('#trackingTree .ttable__rev .tcell__name', { hasText: 'A (IFR)' })).toBeVisible();
|
||||
// The grid shows the file's tracking number in an editable cell.
|
||||
await expect(page.locator('#trackingTree .ttable--grid')).toBeVisible();
|
||||
await expect(page.locator('#trackingTree .tg-tn .tg-input')).toHaveValue('ACME-PROJ-EL-DWG-0001');
|
||||
// Switch to transmittal tab.
|
||||
await page.click('#transmittalTab');
|
||||
expect(await page.locator('#transmittalPanel').isHidden()).toBe(false);
|
||||
await expect(page.locator('#transmittalTree .tnode--bin .tnode__name', { hasText: 'ClientCorp-TRN-0007' })).toBeVisible();
|
||||
});
|
||||
|
||||
test('"+ Root folder" button (prompt) parses a name into nested levels', async ({ page }) => {
|
||||
await page.click('#modeClassifyBtn');
|
||||
page.once('dialog', (d) => d.accept('CPO-0001_0 (IFU)'));
|
||||
await page.click('#addTrackingRootBtn');
|
||||
// "CPO-0001_0 (IFU)" → CPO / 0001 columns + "0 (IFU)" revision cell.
|
||||
await expect(page.locator('#trackingTree .tcell__name', { hasText: 'CPO' })).toBeVisible();
|
||||
await expect(page.locator('#trackingTree .tcell__name', { hasText: '0001' })).toBeVisible();
|
||||
await expect(page.locator('#trackingTree .ttable__rev .tcell__name', { hasText: '0 (IFU)' })).toBeVisible();
|
||||
});
|
||||
|
||||
// ── Phase 3: drag-and-drop assignment (drop handler) ───────────────────────
|
||||
|
||||
test('dropping a file onto a tracking leaf assigns it', async ({ page }) => {
|
||||
test('dropping files onto the By-tracking grid adds rows and auto-fills ZDDC names', async ({ page }) => {
|
||||
await page.click('#modeClassifyBtn');
|
||||
const r = await page.evaluate(() => {
|
||||
const c = window.app.modules.classify;
|
||||
const leaf = c.addTrackingNode(c.addTrackingNode(null, 'ACME-MECH-0001'), 'A (IFR)');
|
||||
window.app.modules.targetTree.render();
|
||||
const row = document.querySelector('#trackingTree .ttable__rev[data-id]');
|
||||
const key = 'Sub/foundation.pdf';
|
||||
window.app.modules.dnd.setDrag([key]);
|
||||
row.dispatchEvent(new Event('drop', { bubbles: true, cancelable: true }));
|
||||
return { assigned: c.assignmentFor(key).trackingNodeId, leaf };
|
||||
const c = window.app.modules.classify; c.reset();
|
||||
const plain = { originalFilename: 'messy scan', extension: 'pdf', folderPath: 'R' };
|
||||
const named = { originalFilename: 'ACME-MECH-0001_A (IFR) - Pump', extension: 'pdf', folderPath: 'R' };
|
||||
window.app.folderTree = [{ name: 'R', path: 'R', files: [plain, named], children: [] }];
|
||||
const tt = window.app.modules.targetTree; tt.render();
|
||||
const grid = document.querySelector('#trackingTree');
|
||||
function drop(key) { window.app.modules.dnd.setDrag([key]); grid.dispatchEvent(new Event('drop', { bubbles: true, cancelable: true })); }
|
||||
drop(c.srcKeyForFile(plain));
|
||||
drop(c.srcKeyForFile(named));
|
||||
return {
|
||||
rows: c.trackingGridKeys().length,
|
||||
namedTn: c.deriveTarget(named).tracking, namedRev: c.deriveTarget(named).revision,
|
||||
plainTn: c.deriveTarget(plain).tracking,
|
||||
};
|
||||
});
|
||||
expect(r.assigned).toBe(r.leaf);
|
||||
expect(r.rows).toBe(2); // both files added as rows
|
||||
expect(r.namedTn).toBe('ACME-MECH-0001'); // the ZDDC-named file auto-filled
|
||||
expect(r.namedRev).toBe('A');
|
||||
expect(r.plainTn).toBe(''); // the plain file is a blank row to fill in
|
||||
});
|
||||
|
||||
test('dropping onto a transmittal bin assigns; dropping on a party row does not', async ({ page }) => {
|
||||
|
|
@ -663,24 +665,26 @@ test('trackingNodeComplete: true only for a leaf with a valid status', async ({
|
|||
expect(r).toEqual({ root: false, num: false, leaf: true, bare: false });
|
||||
});
|
||||
|
||||
test('editing a placed file’s filename re-files it onto the parsed tracking path', async ({ page }) => {
|
||||
test('editing grid cells re-files the file onto the new tracking path', async ({ page }) => {
|
||||
await page.click('#modeClassifyBtn');
|
||||
const r = await page.evaluate(() => {
|
||||
const c = window.app.modules.classify;
|
||||
const c = window.app.modules.classify, tt = window.app.modules.targetTree;
|
||||
c.reset();
|
||||
const leaf = c.addTrackingNode(c.addTrackingNode(null, 'OLD'), 'A (IFR)');
|
||||
const file = { folderPath: 'Root/Sub', originalFilename: 'doc', extension: 'pdf' };
|
||||
const key = c.srcKeyForFile(file);
|
||||
const leaf = c.addTrackingPath(null, c.parseFolderLevels('OLD-0001_A (IFR)'));
|
||||
c.place([key], leaf, 'tracking');
|
||||
window.app.folderTree = [{
|
||||
name: 'Sub', path: 'Sub', expanded: true, scanState: 'done', children: [], files: [file],
|
||||
}];
|
||||
window.app.modules.targetTree.render();
|
||||
const input = document.querySelector('#trackingTree .tfile__name');
|
||||
input.value = 'CPO-0002_0 (IFU) - New Title.pdf';
|
||||
input.dispatchEvent(new Event('change', { bubbles: true }));
|
||||
window.app.folderTree = [{ name: 'Sub', path: 'Root/Sub', expanded: true, scanState: 'done', children: [], files: [file] }];
|
||||
function editCell(cls, val) {
|
||||
tt.render(); // re-render so we edit the live input each time
|
||||
const inp = document.querySelector('#trackingTree .' + cls + ' .tg-input');
|
||||
inp.value = val; inp.dispatchEvent(new Event('change', { bubbles: true }));
|
||||
}
|
||||
editCell('tg-tn', 'CPO-0002');
|
||||
editCell('tg-rev', '0 (IFU)');
|
||||
editCell('tg-title', 'New Title');
|
||||
const d = c.deriveTarget(file);
|
||||
return { tracking: d.tracking, revision: d.revision, status: d.status, title: d.title, complete: d.complete };
|
||||
return { tracking: d.tracking, revision: d.revision, status: d.status, title: d.title };
|
||||
});
|
||||
expect(r.tracking).toBe('CPO-0002');
|
||||
expect(r.revision).toBe('0');
|
||||
|
|
@ -760,20 +764,22 @@ test('source-tree filter hides non-matches in place; never changes expand state'
|
|||
expect(r.civilStillCollapsed).toBe(true); // the filter did NOT expand it
|
||||
});
|
||||
|
||||
test('tracking-tree filter reveals matching nodes and hides the rest', async ({ page }) => {
|
||||
test('the By-tracking grid filter narrows rows by name/tracking', async ({ page }) => {
|
||||
await page.click('#modeClassifyBtn');
|
||||
const names = await page.evaluate(() => {
|
||||
const c = window.app.modules.classify;
|
||||
const r = await page.evaluate(() => {
|
||||
const c = window.app.modules.classify, tt = window.app.modules.targetTree;
|
||||
c.reset();
|
||||
c.addTrackingPath(null, c.parseFolderLevels('CPO-0001_0 (IFU)'));
|
||||
c.addTrackingPath(null, c.parseFolderLevels('XYZ-0009_A (IFR)'));
|
||||
window.app.modules.targetTree.render();
|
||||
window.app.modules.targetTree.setNameFilter('CPO');
|
||||
return Array.from(document.querySelectorAll('#trackingTree .tcell__name')).map((e) => e.textContent);
|
||||
const a = { originalFilename: 'pump', extension: 'pdf', folderPath: 'R' };
|
||||
const b = { originalFilename: 'valve', extension: 'pdf', folderPath: 'R' };
|
||||
window.app.folderTree = [{ name: 'R', path: 'R', files: [a, b], children: [] }];
|
||||
c.setFileIdentity(c.srcKeyForFile(a), { tracking: 'CPO-0001', rev: 'A (IFR)', title: 'Pump' });
|
||||
c.setFileIdentity(c.srcKeyForFile(b), { tracking: 'XYZ-0009', rev: 'A (IFR)', title: 'Valve' });
|
||||
tt.render();
|
||||
tt.setNameFilter('CPO');
|
||||
return Array.from(document.querySelectorAll('#trackingTree .tg-tn .tg-input')).map((e) => e.value);
|
||||
});
|
||||
expect(names).toContain('CPO');
|
||||
expect(names).toContain('0001');
|
||||
expect(names).not.toContain('XYZ');
|
||||
expect(r).toContain('CPO-0001');
|
||||
expect(r).not.toContain('XYZ-0009');
|
||||
});
|
||||
|
||||
test('Show Empty off hides folders that contain no files', async ({ page }) => {
|
||||
|
|
@ -1023,51 +1029,47 @@ test('a fully-excluded folder is struck through like its files', async ({ page }
|
|||
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 }) => {
|
||||
test('grid: hiding a column drops its cells; a status badge reflects completeness', 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)'));
|
||||
try { localStorage.removeItem('zddc.classifier.trackingCols'); } catch (_) {}
|
||||
const f = { originalFilename: 'x', extension: 'pdf', folderPath: 'R' };
|
||||
window.app.folderTree = [{ name: 'R', path: 'R', files: [f], children: [] }];
|
||||
c.setFileIdentity(c.srcKeyForFile(f), { tracking: 'ACME-MECH-0001', rev: 'A (IFR)', title: 'X' });
|
||||
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),
|
||||
};
|
||||
const titleBefore = !!document.querySelector('#trackingTree .tg-title');
|
||||
const badge = document.querySelector('#trackingTree .tg-status .tfile__badge');
|
||||
// Hide the Title column via the persisted prefs, then re-render.
|
||||
localStorage.setItem('zddc.classifier.trackingCols', JSON.stringify({ hidden: { title: true } }));
|
||||
tt.render();
|
||||
const titleAfter = !!document.querySelector('#trackingTree .tg-title');
|
||||
try { localStorage.removeItem('zddc.classifier.trackingCols'); } catch (_) {}
|
||||
return { titleBefore, titleAfter, badge: badge && badge.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)']);
|
||||
expect(r.titleBefore).toBe(true);
|
||||
expect(r.titleAfter).toBe(false); // Title column hidden + persists across re-render
|
||||
expect(r.badge).toBe('✓'); // complete (tracking + rev + status all set)
|
||||
});
|
||||
|
||||
test('revision cell links to preview its file and shows no count bubble', async ({ page }) => {
|
||||
test('grid: the original-name cell is a preview link', async ({ page }) => {
|
||||
await page.click('#modeClassifyBtn');
|
||||
const r = await page.evaluate(() => {
|
||||
const c = window.app.modules.classify, tt = window.app.modules.targetTree;
|
||||
c.reset();
|
||||
const f = { originalFilename: 'foundation', extension: 'pdf', folderPath: 'Root' };
|
||||
window.app.folderTree = [{ name: 'Root', path: 'Root', files: [f], children: [] }];
|
||||
const leaf = c.addTrackingPath(null, c.parseFolderLevels('ACME-MECH-0001_A (IFR)'));
|
||||
c.place([c.srcKeyForFile(f)], leaf, 'tracking');
|
||||
let previewed = null;
|
||||
window.app.modules.preview.previewFile = (file) => { previewed = file.originalFilename; };
|
||||
c.setFileIdentity(c.srcKeyForFile(f), { tracking: 'ACME-MECH-0001', rev: 'A (IFR)', title: 'Foundation' });
|
||||
tt.render();
|
||||
const rev = document.querySelector('#trackingTree .ttable__rev');
|
||||
const link = rev.querySelector('.tcell__preview[data-preview-key]');
|
||||
return {
|
||||
hasPreview: !!link,
|
||||
previewKey: link && link.dataset.previewKey,
|
||||
hasBadge: !!rev.querySelector('.tnode__badge'),
|
||||
};
|
||||
const link = document.querySelector('#trackingTree .tg-orig .tg-orig__link');
|
||||
if (link) link.click();
|
||||
return { text: link && link.textContent, previewed };
|
||||
});
|
||||
expect(r.hasPreview).toBe(true); // revision name is a preview link
|
||||
expect(r.previewKey).toBe('foundation.pdf');
|
||||
expect(r.hasBadge).toBe(false); // no count bubble
|
||||
expect(r.text).toBe('foundation.pdf');
|
||||
expect(r.previewed).toBe('foundation'); // clicking the name previews the file
|
||||
});
|
||||
|
||||
test('Show Partial surfaces files assigned in the other tab only', async ({ page }) => {
|
||||
|
|
|
|||
Loading…
Reference in a new issue