fix(classifier): Show toggles preserve tree state; add Reset to raw input

- Toggling Show Unassigned/Assigned/Excluded/Empty no longer force-expands the
  whole tree. Auto-expand is now reserved for the NAME search (where revealing a
  match means expanding to it); the Show toggles only hide/show, leaving your
  expand/collapse state untouched. Also preserve scrollTop across re-render so a
  toggle doesn't jump the view to the top.
- Add a "Reset" button (danger-styled, beside Export/Import) that discards every
  classification — tracking + transmittal trees, assignments, excludes, title
  overrides — and returns to just the raw scanned input. Your files are never
  touched. Destructive + irreversible, so it confirms with an "Export first"
  warning and no-ops (info toast) when there's nothing to reset.

Tests: Show toggle preserves collapse vs. name-search auto-expand (classify 42).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
ZDDC 2026-06-10 13:02:12 -05:00
parent aa34a4b3e7
commit 15ce7098a0
4 changed files with 57 additions and 5 deletions

View file

@ -151,6 +151,7 @@
exportDatasetBtn: document.getElementById('exportDatasetBtn'), exportDatasetBtn: document.getElementById('exportDatasetBtn'),
importDatasetBtn: document.getElementById('importDatasetBtn'), importDatasetBtn: document.getElementById('importDatasetBtn'),
importDatasetInput: document.getElementById('importDatasetInput'), importDatasetInput: document.getElementById('importDatasetInput'),
resetDatasetBtn: document.getElementById('resetDatasetBtn'),
treeFilterInput: document.getElementById('treeFilterInput'), treeFilterInput: document.getElementById('treeFilterInput'),
trackingFilterInput: document.getElementById('trackingFilterInput'), trackingFilterInput: document.getElementById('trackingFilterInput'),
transmittalFilterInput: document.getElementById('transmittalFilterInput'), transmittalFilterInput: document.getElementById('transmittalFilterInput'),
@ -305,6 +306,24 @@
reader.onerror = function () { window.zddc.toast('Import failed — could not read the file.', 'error'); }; reader.onerror = function () { window.zddc.toast('Import failed — could not read the file.', 'error'); };
reader.readAsText(file); reader.readAsText(file);
} }
// Reset to a clean state: keep the scanned source tree (the raw input), but
// discard every classification — trees, assignments, excludes, overrides.
// Destructive and irreversible, so warn and steer the user to Export first.
function resetDataset() {
var c = app.modules.classify;
var n = Object.keys(c.serialize().assignments || {}).length;
if (!n && !c.getTrackingTree().length && !c.getTransmittalTree().length) {
window.zddc.toast('Nothing to reset — already at the raw input.', 'info');
return;
}
if (!confirm('Reset to a clean state?\n\nThis discards ALL classifications ('
+ n + ' assigned file' + (n === 1 ? '' : 's') + ', plus the tracking and '
+ 'transmittal trees) and returns to just the raw scanned input. Your actual '
+ 'files are not touched.\n\nThis cannot be undone — Export first if you might '
+ 'need this data.')) return;
c.reset();
window.zddc.toast('Reset to the raw scanned input.', 'success');
}
/** /**
* Set up event listeners * Set up event listeners
@ -369,6 +388,7 @@
// Dataset export / import (round-trip the classification through a JSON file). // Dataset export / import (round-trip the classification through a JSON file).
if (app.dom.exportDatasetBtn) app.dom.exportDatasetBtn.addEventListener('click', exportDataset); if (app.dom.exportDatasetBtn) app.dom.exportDatasetBtn.addEventListener('click', exportDataset);
if (app.dom.importDatasetBtn) app.dom.importDatasetBtn.addEventListener('click', function () { app.dom.importDatasetInput.click(); }); if (app.dom.importDatasetBtn) app.dom.importDatasetBtn.addEventListener('click', function () { app.dom.importDatasetInput.click(); });
if (app.dom.resetDatasetBtn) app.dom.resetDatasetBtn.addEventListener('click', resetDataset);
if (app.dom.importDatasetInput) app.dom.importDatasetInput.addEventListener('change', function () { if (app.dom.importDatasetInput) app.dom.importDatasetInput.addEventListener('change', function () {
if (this.files && this.files[0]) importDataset(this.files[0]); if (this.files && this.files[0]) importDataset(this.files[0]);
this.value = ''; // allow re-importing the same file this.value = ''; // allow re-importing the same file

View file

@ -143,6 +143,9 @@
*/ */
function render() { function render() {
const container = window.app.dom.folderTree; const container = window.app.dom.folderTree;
// Preserve scroll across re-render — toggling a Show filter shouldn't
// jump the view back to the top.
const prevScroll = container.scrollTop;
wireClassifyInteractions(); wireClassifyInteractions();
container.innerHTML = ''; container.innerHTML = '';
updateFilterCounts(); updateFilterCounts();
@ -164,6 +167,7 @@
} }
updateSelectedCount(); updateSelectedCount();
container.scrollTop = prevScroll;
} }
/** /**
@ -268,7 +272,7 @@
// expandable so its files can be revealed and dragged. // expandable so its files can be revealed and dragged.
|| (classifyOn() && folder.files && folder.files.length > 0); || (classifyOn() && folder.files && folder.files.length > 0);
if (mightHaveChildren) { if (mightHaveChildren) {
toggle.textContent = (folder.expanded || visible) ? '▼' : '▶'; toggle.textContent = (folder.expanded || filterActive()) ? '▼' : '▶';
toggle.addEventListener('click', (e) => { toggle.addEventListener('click', (e) => {
e.stopPropagation(); e.stopPropagation();
const recursive = e.ctrlKey || e.metaKey; const recursive = e.ctrlKey || e.metaKey;
@ -346,8 +350,9 @@
div.appendChild(item); div.appendChild(item);
// Children — when expanded, or auto-expanded to reveal a filter match. // Children — when expanded, or auto-expanded to reveal a NAME-search
if ((folder.expanded || visible) && folder.children && folder.children.length > 0) { // match. The Show toggles only hide/show; they never force-expand.
if ((folder.expanded || filterActive()) && folder.children && folder.children.length > 0) {
const childrenDiv = document.createElement('div'); const childrenDiv = document.createElement('div');
childrenDiv.className = 'folder-children'; childrenDiv.className = 'folder-children';
folder.children.forEach(child => { folder.children.forEach(child => {
@ -359,8 +364,8 @@
} }
// Classify mode: list this folder's own files (draggable leaves) when // Classify mode: list this folder's own files (draggable leaves) when
// expanded (or auto-expanded by a filter), so they can be dropped. // expanded (or auto-expanded by a name search), so they can be dropped.
if (classifyOn() && (folder.expanded || visible) && folder.files && folder.files.length > 0) { if (classifyOn() && (folder.expanded || filterActive()) && folder.files && folder.files.length > 0) {
const filesDiv = document.createElement('div'); const filesDiv = document.createElement('div');
filesDiv.className = 'folder-children folder-files'; filesDiv.className = 'folder-children folder-files';
folder.files.forEach(function (file) { folder.files.forEach(function (file) {

View file

@ -165,6 +165,7 @@
<button id="exportDatasetBtn" class="btn btn-secondary btn-sm" title="Export the classification dataset (trees + assignments) as JSON">Export</button> <button id="exportDatasetBtn" class="btn btn-secondary btn-sm" title="Export the classification dataset (trees + assignments) as JSON">Export</button>
<button id="importDatasetBtn" class="btn btn-secondary btn-sm" title="Import a classification dataset JSON (replaces the current one)">Import</button> <button id="importDatasetBtn" class="btn btn-secondary btn-sm" title="Import a classification dataset JSON (replaces the current one)">Import</button>
<input type="file" id="importDatasetInput" accept="application/json,.json" hidden> <input type="file" id="importDatasetInput" accept="application/json,.json" hidden>
<button id="resetDatasetBtn" class="btn btn-sm btn-danger" title="Discard all classifications and start over from the raw scanned input (does not touch your files)">Reset</button>
<button id="copyOutputBtn" class="btn btn-primary btn-sm" disabled title="Copy mapped files to an output directory (source untouched)">Copy…</button> <button id="copyOutputBtn" class="btn btn-primary btn-sm" disabled title="Copy mapped files to an output directory (source untouched)">Copy…</button>
</div> </div>
</div> </div>

View file

@ -760,3 +760,29 @@ test('Show Empty off hides folders that contain no files', async ({ page }) => {
expect(r.before).toEqual(['Docs', 'EmptyDir']); // both shown by default expect(r.before).toEqual(['Docs', 'EmptyDir']); // both shown by default
expect(r.after).toEqual(['Docs']); // empty folder hidden when Show Empty off expect(r.after).toEqual(['Docs']); // empty folder hidden when Show Empty off
}); });
test('toggling a Show filter preserves collapse state (no force-expand)', async ({ page }) => {
await page.click('#modeClassifyBtn');
const r = await page.evaluate(() => {
window.app.folderTree = [{
name: 'Project', path: 'Project', expanded: false, scanState: 'done', files: [], children: [
{ name: 'Sub', path: 'Project/Sub', expanded: false, scanState: 'done', children: [],
files: [{ originalFilename: 'a', extension: 'pdf', folderPath: 'Project/Sub' }] },
],
}];
const tree = window.app.modules.tree;
tree.render();
const collapsed = Array.from(document.querySelectorAll('#folderTree .folder-item')).map((e) => e.dataset.path);
// A Show toggle must not expand the collapsed parent…
tree.setShowFilters({ unassigned: true, assigned: true, excluded: true, empty: false });
const afterToggle = Array.from(document.querySelectorAll('#folderTree .folder-item')).map((e) => e.dataset.path);
// …whereas a name search still reveals the match by auto-expanding.
tree.setShowFilters({ unassigned: true, assigned: true, excluded: true, empty: true });
tree.setNameFilter('a.pdf');
const afterSearch = Array.from(document.querySelectorAll('#folderTree .folder-item')).map((e) => e.dataset.path);
return { collapsed, afterToggle, afterSearch };
});
expect(r.collapsed).toEqual(['Project']); // child hidden — parent collapsed
expect(r.afterToggle).toEqual(['Project']); // Show toggle leaves it collapsed
expect(r.afterSearch).toEqual(['Project', 'Project/Sub']); // name search auto-expands to the match
});