feat(classifier): three-state filters, expand/collapse-all, drop-prompt, preview + editable filenames

Classify & Copy interaction pass (replaces the single "Hide Assigned" toggle):

- Source-tree filters: three "Show Unassigned / Show Assigned / Show Excluded"
  checkboxes (classify mode only) with live per-tab counts; "Hide Compliant" is
  now rename-mode only. Folders with nothing visible collapse out.
- Target tree: ctrl/cmd-click a toggle to expand/collapse the whole subtree.
- Tracking drop-to-any-level: dropping on a node that isn't already a complete
  leaf prompts for the remaining levels (e.g. "0001_0 (IFU)"), which are parsed
  and nested under the drop target. Dropping on a finished leaf assigns directly.
- Placed-file rows: click to preview; the derived filename is now an inline
  input — edit it (full "TRACKING_REV (STATUS) - Title.ext") and the item is
  re-filed onto the parsed tracking path (created if needed) + title override.

New classify helpers: trackingNodeComplete, trackingPathLabel. tree.setShowFilters
replaces setHideAssigned. Tests updated/added (classify.spec.js -> 33 passed).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
ZDDC 2026-06-10 11:08:30 -05:00
parent 59ffd861f9
commit 139171481e
7 changed files with 233 additions and 48 deletions

View file

@ -146,6 +146,10 @@
align-items: flex-end;
}
/* Classify-mode source-tree filters (Show Unassigned/Assigned/Excluded). */
.classify-filters { display: inline-flex; flex-wrap: wrap; gap: 0.3rem 0.75rem; justify-content: flex-end; }
.classify-filters .filter-count { color: var(--text-muted); font-size: 0.85em; }
.folder-stats,
.file-stats {
display: flex;
@ -478,7 +482,13 @@
/* placed-file row in the target pane is clickable (reveal in source) */
.tfile { cursor: pointer; }
.tfile:hover .tfile__name { text-decoration: underline; }
.tfile:hover .tfile__orig { text-decoration: underline; } /* click row (not the name input) → preview */
input.tfile__name {
flex: 1 1 auto; min-width: 10rem; font: inherit; color: var(--text);
border: 1px solid transparent; background: transparent; border-radius: 3px; padding: 0 0.2rem;
}
input.tfile__name:hover { border-color: var(--border); }
input.tfile__name:focus { border-color: var(--primary); background: var(--bg); outline: none; }
/* cross-tree reveal flash */
.reveal-flash, .match-highlight { animation: cl-flash 1.5s ease-out; }

View file

@ -143,8 +143,10 @@
sha256Checkbox: document.getElementById('sha256Checkbox'),
hideCompliantCheckbox: document.getElementById('hideCompliantCheckbox'),
hideCompliantLabel: document.getElementById('hideCompliantLabel'),
hideAssignedCheckbox: document.getElementById('hideAssignedCheckbox'),
hideAssignedLabel: document.getElementById('hideAssignedLabel'),
classifyFilters: document.getElementById('classifyFilters'),
showUnassignedCheckbox: document.getElementById('showUnassignedCheckbox'),
showAssignedCheckbox: document.getElementById('showAssignedCheckbox'),
showExcludedCheckbox: document.getElementById('showExcludedCheckbox'),
// Folder tree
folderTree: document.getElementById('folderTree'),
@ -189,7 +191,7 @@
// Mode-specific source-tree filters: "Hide Compliant" is for the rename
// grid; "Hide Assigned" is for the classify workflow.
if (app.dom.hideCompliantLabel) app.dom.hideCompliantLabel.hidden = classify;
if (app.dom.hideAssignedLabel) app.dom.hideAssignedLabel.hidden = !classify;
if (app.dom.classifyFilters) app.dom.classifyFilters.hidden = !classify;
app.modules.classify.setEnabled(classify);
if (classify && app.modules.targetTree) {
app.modules.targetTree.init();
@ -223,14 +225,18 @@
// Hide compliant toggle
app.dom.hideCompliantCheckbox.addEventListener('change', handleHideCompliantToggle);
// Hide assigned toggle (classify mode — filters the source tree)
if (app.dom.hideAssignedCheckbox) {
app.dom.hideAssignedCheckbox.addEventListener('change', function () {
if (app.modules.tree && app.modules.tree.setHideAssigned) {
app.modules.tree.setHideAssigned(this.checked);
}
// Classify-mode source-tree filters: show/hide unassigned, assigned, excluded.
function pushClassifyFilters() {
if (app.modules.tree && app.modules.tree.setShowFilters) {
app.modules.tree.setShowFilters({
unassigned: app.dom.showUnassignedCheckbox.checked,
assigned: app.dom.showAssignedCheckbox.checked,
excluded: app.dom.showExcludedCheckbox.checked,
});
}
}
[app.dom.showUnassignedCheckbox, app.dom.showAssignedCheckbox, app.dom.showExcludedCheckbox]
.forEach(function (cb) { if (cb) cb.addEventListener('change', pushClassifyFilters); });
// Collapse tree button
app.dom.collapseTreeBtn.addEventListener('click', handleCollapseTree);

View file

@ -486,6 +486,23 @@
return cur;
}
// A tracking node is a "complete" drop target when it's a leaf whose name
// carries a valid "(STATUS)" — i.e. a file dropped there yields a full name
// with no more levels needed. Used to decide whether a drop should prompt.
function trackingNodeComplete(nodeId) {
var info = infoFor(nodeId);
if (!info || info.kind !== 'tracking') return false;
if ((info.node.children || []).length) return false;
var leaf = parseLeafLabel(info.node.name);
return !!(leaf.status && zddc.isValidStatus(leaf.status));
}
// Human-readable "root / … / node" path for a tracking node (prompt context).
function trackingPathLabel(nodeId) {
var info = infoFor(nodeId);
if (!info || info.kind !== 'tracking') return '';
return trackingChain(info).join(' / ');
}
// ── mode ─────────────────────────────────────────────────────────────────
function setEnabled(on) { state.enabled = !!on; notify(); }
function isEnabled() { return state.enabled; }
@ -506,6 +523,7 @@
addTransmittalBin: addTransmittalBin, renameNode: renameNode, deleteNode: deleteNode,
expandFolderPattern: expandFolderPattern,
parseFolderLevels: parseFolderLevels, addTrackingPath: addTrackingPath,
trackingNodeComplete: trackingNodeComplete, trackingPathLabel: trackingPathLabel,
getNode: getNode, getTrackingTree: function () { return state.trackingTree; },
getTransmittalTree: function () { return state.transmittalTree; },
// derive + reverse

View file

@ -50,6 +50,8 @@
els.trackingTree.addEventListener('click', onTrackingClick);
els.transmittalTree.addEventListener('click', onTransmittalClick);
els.trackingTree.addEventListener('change', onFileNameChange);
els.transmittalTree.addEventListener('change', onFileNameChange);
setupDropZone(els.trackingTree, 'tracking');
setupDropZone(els.transmittalTree, 'transmittal');
@ -161,11 +163,18 @@
files.forEach(function (f) {
var d = C().deriveTarget(f);
var row = el('div', 'tfile' + (d.errors.length ? ' tfile--err' : ''));
row.title = d.errors.length ? d.errors.join('; ') : 'Click to find this file in the source tree';
row.dataset.key = d.key; // for cross-tree reveal
row.appendChild(el('span', 'tfile__orig', f.originalFilename + (f.extension ? '.' + f.extension : '')));
row.dataset.key = d.key;
var orig = el('span', 'tfile__orig', f.originalFilename + (f.extension ? '.' + f.extension : ''));
orig.title = 'Click to preview';
row.appendChild(orig);
row.appendChild(el('span', 'tfile__arrow', '→'));
row.appendChild(el('span', 'tfile__name', d.filename || '(incomplete)'));
// Editable derived filename — edit it to re-file the item.
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('; ') : 'Edit this ZDDC filename to re-file the item';
row.appendChild(name);
box.appendChild(row);
});
return box;
@ -290,21 +299,65 @@
var n = target.closest('.tnode');
return n ? n.dataset.id : null;
}
function revealInSource(e) {
function fileByKey(key) {
var files = allFiles();
for (var i = 0; i < files.length; i++) { if (C().srcKeyForFile(files[i]) === key) return files[i]; }
return null;
}
// Click a placed-file row (anywhere but its editable name) → preview it.
function previewFromTarget(e) {
if (e.target.closest('.tfile__name')) return false;
var tf = e.target.closest('.tfile');
if (tf && tf.dataset.key && window.app.modules.tree.revealFile) {
window.app.modules.tree.revealFile(tf.dataset.key);
if (!tf || !tf.dataset.key) return false;
var f = fileByKey(tf.dataset.key);
if (f && window.app.modules.preview && window.app.modules.preview.previewFile) {
window.app.modules.preview.previewFile(f);
}
return true;
}
return false;
// Edited a placed-file's ZDDC filename → re-derive its tracking placement
// (creating the folder path if needed) + its title override.
function onFileNameChange(e) {
var input = e.target.closest('.tfile__name');
if (input) commitFilenameEdit(input);
}
function commitFilenameEdit(input) {
var tf = input.closest('.tfile');
if (!tf || !tf.dataset.key) return;
var parsed = window.zddc.parseFilename((input.value || '').trim());
if (!parsed || !parsed.valid) {
window.zddc.toast('Not a valid ZDDC filename — expected "TRACKING_REV (STATUS) - Title.ext".', 'warning');
render(); // restore the derived value
return;
}
var stem = parsed.trackingNumber + '_' + parsed.revision + ' (' + parsed.status + ')';
var leaf = C().addTrackingPath(null, C().parseFolderLevels(stem));
C().place([tf.dataset.key], leaf, 'tracking');
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 (revealInSource(e)) return;
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') { collapsed[id] = !collapsed[id]; render(); return; }
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)', '');
@ -318,7 +371,7 @@
}
}
function onTransmittalClick(e) {
if (revealInSource(e)) return;
if (previewFromTarget(e)) return;
var btn = e.target.closest('[data-act]');
if (!btn) return;
var act = btn.dataset.act;
@ -391,10 +444,26 @@
e.preventDefault();
var keys = window.app.modules.dnd.getDrag();
window.app.modules.dnd.clearDrag();
if (keys.length) C().place(keys, t.id, axis);
if (!keys.length) return;
if (axis === 'tracking') placeTrackingDrop(keys, t.id);
else 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);

View file

@ -35,35 +35,61 @@
return dot;
}
// ── "Hide Assigned" filter (classify mode) ─────────────────────────────
// The goal in either target tab is to assign-or-exclude every file, so this
// collapses the left tree down to only what's left to deal with on the
// ACTIVE axis: hide files already assigned in the current tab (or excluded),
// and any folder whose whole (scanned) subtree is thereby empty.
var hideAssigned = false;
function setHideAssigned(on) { hideAssigned = !!on; render(); }
// ── Classify-mode source-tree filters ──────────────────────────────────
// The goal in either target tab is to assign-or-exclude every file. Each
// file falls in one bucket FOR THE ACTIVE AXIS — unassigned / assigned /
// excluded — and three "Show …" toggles control which buckets are visible
// (so unchecking Assigned+Excluded leaves only what's left to do). A folder
// whose whole scanned subtree is filtered away is itself hidden.
var showFilters = { unassigned: true, assigned: true, excluded: true };
function setShowFilters(f) {
showFilters = {
unassigned: f.unassigned !== false,
assigned: f.assigned !== false,
excluded: f.excluded !== false,
};
render();
}
function allFiltersOn() { return showFilters.unassigned && showFilters.assigned && showFilters.excluded; }
function activeAxis() {
var tt = window.app.modules.targetTree;
return (tt && tt.activeAxis) ? tt.activeAxis() : 'tracking';
}
function fileDealtWith(file) {
// Bucket a file relative to the active axis: 'excluded' | 'assigned' | 'unassigned'.
function fileCategory(file) {
var c = window.app.modules.classify;
var a = c.getAssignment(c.srcKeyForFile(file));
if (!a) return false;
if (a.excluded) return true;
return activeAxis() === 'transmittal' ? !!a.transmittalNodeId : !!a.trackingNodeId;
if (a && a.excluded) return 'excluded';
var assigned = a && (activeAxis() === 'transmittal' ? a.transmittalNodeId : a.trackingNodeId);
return assigned ? 'assigned' : 'unassigned';
}
function subtreeRemaining(folder) {
function fileVisible(file) { return !!showFilters[fileCategory(file)]; }
function subtreeVisibleCount(folder) {
var n = 0;
subtreeFiles(folder).forEach(function (f) { if (!fileDealtWith(f)) n++; });
subtreeFiles(folder).forEach(function (f) { if (fileVisible(f)) n++; });
return n;
}
// Hide a folder only when it's fully scanned (so we never hide one that may
// still reveal files) and nothing in its subtree remains to be dealt with.
// still reveal files) and the active filters leave nothing visible in it.
function folderHidden(folder) {
if (!classifyOn() || !hideAssigned) return false;
if (!classifyOn() || allFiltersOn()) return false;
if (folder.scanState && folder.scanState !== 'done') return false;
return subtreeRemaining(folder) === 0;
return subtreeVisibleCount(folder) === 0;
}
// All scanned files (for the per-bucket counts on the filter checkboxes).
function allClassifyFiles() {
var out = [];
(window.app.folderTree || []).forEach(function (f) { subtreeFiles(f, out); });
return out;
}
function updateFilterCounts() {
if (!classifyOn()) return;
var n = { unassigned: 0, assigned: 0, excluded: 0 };
allClassifyFiles().forEach(function (f) { n[fileCategory(f)]++; });
['unassigned', 'assigned', 'excluded'].forEach(function (k) {
var el = document.getElementById('show' + k.charAt(0).toUpperCase() + k.slice(1) + 'Count');
if (el) el.textContent = '(' + n[k] + ')';
});
}
/**
@ -73,6 +99,7 @@
const container = window.app.dom.folderTree;
wireClassifyInteractions();
container.innerHTML = '';
updateFilterCounts();
if (window.app.folderTree.length === 0) {
container.innerHTML = '<div class="tree-empty">No folders found</div>';
@ -84,6 +111,9 @@
const element = createFolderElement(folder);
container.appendChild(element);
});
if (classifyOn() && !container.children.length) {
container.innerHTML = '<div class="tree-empty">Nothing matches the current filters.</div>';
}
updateSelectedCount();
}
@ -286,7 +316,7 @@
const filesDiv = document.createElement('div');
filesDiv.className = 'folder-children folder-files';
folder.files.forEach(function (file) {
if (hideAssigned && fileDealtWith(file)) return;
if (classifyOn() && !fileVisible(file)) return;
filesDiv.appendChild(createFileElement(file, level + 1));
});
div.appendChild(filesDiv);
@ -864,6 +894,6 @@
expandAll,
selectAll,
revealFile,
setHideAssigned
setShowFilters
};
})();

View file

@ -61,11 +61,20 @@
<input type="checkbox" id="hideCompliantCheckbox">
Hide Compliant
</label>
<label class="checkbox-label" id="hideAssignedLabel" hidden
title="Hide files already assigned in the active tab (or excluded), and folders left empty">
<input type="checkbox" id="hideAssignedCheckbox">
Hide Assigned
<span id="classifyFilters" class="classify-filters" hidden>
<label class="checkbox-label" title="Show files not yet assigned in the active tab">
<input type="checkbox" id="showUnassignedCheckbox" checked>
Show Unassigned <span class="filter-count" id="showUnassignedCount"></span>
</label>
<label class="checkbox-label" title="Show files already assigned in the active tab">
<input type="checkbox" id="showAssignedCheckbox" checked>
Show Assigned <span class="filter-count" id="showAssignedCount"></span>
</label>
<label class="checkbox-label" title="Show excluded files">
<input type="checkbox" id="showExcludedCheckbox" checked>
Show Excluded <span class="filter-count" id="showExcludedCount"></span>
</label>
</span>
<span id="selectedFoldersCount" class="folder-count">0 folders selected</span>
</div>
</div>

View file

@ -575,7 +575,7 @@ test('Hide Assigned: hides files dealt-with on the active axis and folders left
expect(before).toBe(2); // nothing hidden yet
const after = await page.evaluate(() => {
window.app.modules.tree.setHideAssigned(true);
window.app.modules.tree.setShowFilters({ unassigned: true, assigned: false, excluded: false });
return {
files: Array.from(document.querySelectorAll('#folderTree .file-item .file-name')).map(e => e.textContent),
folderA: !!document.querySelector('#folderTree .folder-item[data-path="A"]'),
@ -617,3 +617,46 @@ test('add-folder builds a nested chain sharing common ancestors', async ({ page
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
});
test('trackingNodeComplete: true only for a leaf with a valid status', async ({ page }) => {
const r = await page.evaluate(() => {
const c = window.app.modules.classify;
c.reset();
const parent = c.addTrackingNode(null, 'CPO');
const num = c.addTrackingNode(parent, '0001');
const leaf = c.addTrackingNode(num, '0 (IFU)');
const bare = c.addTrackingNode(c.addTrackingNode(null, 'X'), '0001'); // leaf, no status
return {
root: c.trackingNodeComplete(parent), // has children
num: c.trackingNodeComplete(num), // has a child leaf
leaf: c.trackingNodeComplete(leaf), // leaf + valid status
bare: c.trackingNodeComplete(bare), // leaf, no "(STATUS)"
};
});
expect(r).toEqual({ root: false, num: false, leaf: true, bare: false });
});
test('editing a placed files filename re-files it onto the parsed tracking path', async ({ page }) => {
await page.click('#modeClassifyBtn');
const r = await page.evaluate(() => {
const c = window.app.modules.classify;
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);
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 }));
const d = c.deriveTarget(file);
return { tracking: d.tracking, revision: d.revision, status: d.status, title: d.title, complete: d.complete };
});
expect(r.tracking).toBe('CPO-0002');
expect(r.revision).toBe('0');
expect(r.status).toBe('IFU');
expect(r.title).toBe('New Title');
});