diff --git a/classifier/css/layout.css b/classifier/css/layout.css index 8547a1d..7845c7a 100644 --- a/classifier/css/layout.css +++ b/classifier/css/layout.css @@ -150,6 +150,15 @@ .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; } +/* Live filter box above a file tree. */ +.tree-filter { + width: 100%; box-sizing: border-box; margin: 0.25rem 0; + padding: 0.25rem 0.5rem; font: inherit; font-size: 0.85rem; + border: 1px solid var(--border); border-radius: var(--radius); + background: var(--bg); color: var(--text); +} +.tree-filter:focus { outline: none; border-color: var(--primary); } + .folder-stats, .file-stats { display: flex; diff --git a/classifier/js/app.js b/classifier/js/app.js index c619896..3979c78 100644 --- a/classifier/js/app.js +++ b/classifier/js/app.js @@ -150,6 +150,9 @@ exportDatasetBtn: document.getElementById('exportDatasetBtn'), importDatasetBtn: document.getElementById('importDatasetBtn'), importDatasetInput: document.getElementById('importDatasetInput'), + treeFilterInput: document.getElementById('treeFilterInput'), + trackingFilterInput: document.getElementById('trackingFilterInput'), + transmittalFilterInput: document.getElementById('transmittalFilterInput'), // Folder tree folderTree: document.getElementById('folderTree'), @@ -347,6 +350,20 @@ if (app.dom.modeClassifyBtn) app.dom.modeClassifyBtn.addEventListener('click', function () { setMode('classify'); }); if (app.dom.copyOutputBtn) app.dom.copyOutputBtn.addEventListener('click', function () { app.modules.copy.run(); }); + // Live source-tree filter (matches file path + name; reveals the hierarchy). + if (app.dom.treeFilterInput) app.dom.treeFilterInput.addEventListener('input', function () { + if (app.modules.tree && app.modules.tree.setNameFilter) app.modules.tree.setNameFilter(this.value); + }); + // Target-tree filter — both tabs share one query (mirrored across inputs). + function targetFilter(val) { + if (app.dom.trackingFilterInput) app.dom.trackingFilterInput.value = val; + if (app.dom.transmittalFilterInput) app.dom.transmittalFilterInput.value = val; + if (app.modules.targetTree && app.modules.targetTree.setNameFilter) app.modules.targetTree.setNameFilter(val); + } + [app.dom.trackingFilterInput, app.dom.transmittalFilterInput].forEach(function (inp) { + if (inp) inp.addEventListener('input', function () { targetFilter(this.value); }); + }); + // Dataset export / import (round-trip the classification through a JSON file). if (app.dom.exportDatasetBtn) app.dom.exportDatasetBtn.addEventListener('click', exportDataset); if (app.dom.importDatasetBtn) app.dom.importDatasetBtn.addEventListener('click', function () { app.dom.importDatasetInput.click(); }); diff --git a/classifier/js/target-tree.js b/classifier/js/target-tree.js index 288747d..92c8969 100644 --- a/classifier/js/target-tree.js +++ b/classifier/js/target-tree.js @@ -180,40 +180,67 @@ return box; } - // Tracking tree (recursive) + // ── name filter (the autofilter box above the target trees) ──────────── + var rfTerms = []; + function setNameFilter(q) { + rfTerms = String(q || '').trim().toLowerCase().split(/\s+/).filter(Boolean); + render(); + } + function rfActive() { return rfTerms.length > 0; } + function rfHit(text) { + if (!rfTerms.length) return true; + var t = String(text || '').toLowerCase(); + for (var i = 0; i < rfTerms.length; i++) { if (t.indexOf(rfTerms[i]) === -1) return false; } + return true; + } + // A placed-file row matches on its original name or its derived ZDDC name. + function fileRowMatches(f) { + var orig = f.originalFilename + (f.extension ? '.' + f.extension : ''); + return rfHit(orig) || rfHit(C().deriveTarget(f).filename || ''); + } + + // Tracking tree (recursive, filter-aware — a match reveals its whole path). function renderTrackingInto(container, nodes, placedMap) { container.textContent = ''; if (!nodes.length) { container.appendChild(el('div', 'target-empty', 'No tracking folders yet — “+ Root folder” to start.')); return; } - nodes.forEach(function (n) { container.appendChild(trackingNode(n, placedMap)); }); + 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) { + 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' : '')); wrap.dataset.id = n.id; var row = el('div', 'tnode__row'); - - var toggle = el('button', 'tnode__toggle', isLeaf ? '·' : (collapsed[n.id] ? '▸' : '▾')); + var toggle = el('button', 'tnode__toggle', isLeaf ? '·' : (expanded ? '▾' : '▸')); if (!isLeaf) toggle.dataset.act = 'toggle'; row.appendChild(toggle); row.appendChild(el('span', 'tnode__name', n.name)); - - var placed = placedMap[n.id] || []; if (placed.length) row.appendChild(el('span', 'tnode__badge', String(placed.length))); - row.appendChild(nodeActions([ { act: 'add', label: '+', title: 'Add child folder' }, { act: 'rename', label: '✎', title: 'Rename' }, { act: 'del', label: '🗑', title: 'Delete' }, ])); wrap.appendChild(row); - - if (placed.length) wrap.appendChild(fileList(placed)); - if (!isLeaf && !collapsed[n.id]) { + if (shownFiles.length) wrap.appendChild(fileList(shownFiles)); + if (!isLeaf && expanded && childEls.length) { var kids = el('div', 'tnode__children'); - (n.children || []).forEach(function (c) { kids.appendChild(trackingNode(c, placedMap)); }); + childEls.forEach(function (ce) { kids.appendChild(ce); }); wrap.appendChild(kids); } return wrap; @@ -226,20 +253,14 @@ container.appendChild(el('div', 'target-empty', 'No parties yet — “+ Party” to start.')); return; } - parties.forEach(function (p) { container.appendChild(partyNode(p, placedMap)); }); + parties.forEach(function (p) { var e = partyNode(p, placedMap); if (e) container.appendChild(e); }); + if (rfActive() && !container.children.length) { + container.appendChild(el('div', 'target-empty', 'No matches in the transmittal tree.')); + } } function partyNode(party, placedMap) { - var wrap = el('div', 'tnode tnode--party'); - wrap.dataset.id = party.id; - var row = el('div', 'tnode__row'); - row.appendChild(el('span', 'tnode__icon', '🏢')); - row.appendChild(el('span', 'tnode__name', party.name)); - row.appendChild(nodeActions([ - { act: 'rename-party', label: '✎', title: 'Rename party' }, - { act: 'del-party', label: '🗑', title: 'Delete party' }, - ])); - wrap.appendChild(row); - + var partyMatch = rfHit(party.name); + var slotEls = [], anyBin = false; SLOTS.forEach(function (slot) { var slotNode = (party.children || []).filter(function (s) { return s.slot === slot; })[0]; var sw = el('div', 'tslot'); @@ -256,22 +277,39 @@ sw.appendChild(binForm(party.id, slot)); } (slotNode ? slotNode.children : []).forEach(function (bin) { - sw.appendChild(binNode(bin, placedMap)); + var be = binNode(bin, placedMap, partyMatch); + if (be) { sw.appendChild(be); anyBin = true; } }); - wrap.appendChild(sw); + slotEls.push(sw); }); + if (rfActive() && !partyMatch && !anyBin) return null; + + var wrap = el('div', 'tnode tnode--party'); + wrap.dataset.id = party.id; + var row = el('div', 'tnode__row'); + row.appendChild(el('span', 'tnode__icon', '🏢')); + row.appendChild(el('span', 'tnode__name', party.name)); + row.appendChild(nodeActions([ + { act: 'rename-party', label: '✎', title: 'Rename party' }, + { act: 'del-party', label: '🗑', title: 'Delete party' }, + ])); + wrap.appendChild(row); + slotEls.forEach(function (sw) { wrap.appendChild(sw); }); return wrap; } - function binNode(bin, placedMap) { + function binNode(bin, placedMap, ancMatched) { + var matched = ancMatched || rfHit(bin.name || ''); + var placed = placedMap[bin.id] || []; + var shownFiles = (rfActive() && !matched) ? placed.filter(fileRowMatches) : placed; + if (rfActive() && !matched && !shownFiles.length) return null; var wrap = el('div', 'tnode tnode--bin'); wrap.dataset.id = bin.id; var row = el('div', 'tnode__row'); row.appendChild(el('span', 'tnode__name', bin.name || '(invalid — set date/seq)')); - var placed = placedMap[bin.id] || []; if (placed.length) row.appendChild(el('span', 'tnode__badge', String(placed.length))); row.appendChild(nodeActions([{ act: 'del', label: '🗑', title: 'Delete transmittal' }])); wrap.appendChild(row); - if (placed.length) wrap.appendChild(fileList(placed)); + if (shownFiles.length) wrap.appendChild(fileList(shownFiles)); return wrap; } @@ -490,6 +528,7 @@ render: render, showTab: showTab, activeAxis: activeAxis, + setNameFilter: setNameFilter, reveal: reveal, }; })(); diff --git a/classifier/js/tree.js b/classifier/js/tree.js index 2dea00c..dcc6905 100644 --- a/classifier/js/tree.js +++ b/classifier/js/tree.js @@ -63,18 +63,55 @@ var assigned = a && (activeAxis() === 'transmittal' ? a.transmittalNodeId : a.trackingNodeId); return assigned ? 'assigned' : 'unassigned'; } - function fileVisible(file) { return !!showFilters[fileCategory(file)]; } - function subtreeVisibleCount(folder) { - var n = 0; - subtreeFiles(folder).forEach(function (f) { if (fileVisible(f)) n++; }); - return n; + function classifyAllows(file) { return !classifyOn() || !!showFilters[fileCategory(file)]; } + + // ── name filter (the autofilter box above the tree) ──────────────────── + // Live substring search over each file's full path+name (and folder names), + // ANDing space-separated terms. Matches reveal their whole folder hierarchy. + var nameFilter = '', filterTerms = []; + function setNameFilter(q) { + nameFilter = (q || '').trim(); + filterTerms = nameFilter.toLowerCase().split(/\s+/).filter(Boolean); + render(); } - // Hide a folder only when it's fully scanned (so we never hide one that may - // still reveal files) and the active filters leave nothing visible in it. - function folderHidden(folder) { - if (!classifyOn() || allFiltersOn()) return false; - if (folder.scanState && folder.scanState !== 'done') return false; - return subtreeVisibleCount(folder) === 0; + function filterActive() { return filterTerms.length > 0; } + function nameHit(text) { + if (!filterTerms.length) return true; + var t = String(text || '').toLowerCase(); + for (var i = 0; i < filterTerms.length; i++) { if (t.indexOf(filterTerms[i]) === -1) return false; } + return true; + } + // Anything narrowing the tree (a name search, or a show-filter turned off). + function anyFilter() { return filterActive() || (classifyOn() && !allFiltersOn()); } + // One pass → the set of folder paths + file keys to render. A file shows when + // it passes the show-filters AND (no name search, OR an ancestor folder + // matched, OR its own path/name matches). A folder shows when it (or an + // ancestor) matches, or anything inside it shows — so the path to a hit is + // always revealed. + var visible = null; // { folders, files } while filtering, else null + function computeVisible() { + var c = window.app.modules.classify; + var folders = Object.create(null), files = Object.create(null); + var nf = filterActive(); + function walk(folder, ancMatched) { + var matched = ancMatched || (nf && nameHit(folder.path || folder.name)); + var show = false; + (folder.children || []).forEach(function (ch) { if (walk(ch, matched)) show = true; }); + (folder.files || []).forEach(function (f) { + if (!classifyAllows(f)) return; + if (!nf || matched || nameHit(c.srcKeyForFile(f))) { files[c.srcKeyForFile(f)] = true; show = true; } + }); + if (matched) show = true; + if (show) folders[folder.path] = true; + return show; + } + (window.app.folderTree || []).forEach(function (root) { walk(root, false); }); + return { folders: folders, files: files }; + } + function folderShown(folder) { return !visible || !!visible.folders[folder.path]; } + function fileShown(file) { + if (!classifyAllows(file)) return false; + return !visible || !!visible.files[window.app.modules.classify.srcKeyForFile(file)]; } // All scanned files (for the per-bucket counts on the filter checkboxes). function allClassifyFiles() { @@ -100,6 +137,7 @@ wireClassifyInteractions(); container.innerHTML = ''; updateFilterCounts(); + visible = anyFilter() ? computeVisible() : null; if (window.app.folderTree.length === 0) { container.innerHTML = '
No folders found
'; @@ -107,12 +145,13 @@ } window.app.folderTree.forEach(folder => { - if (folderHidden(folder)) return; + if (!folderShown(folder)) return; const element = createFolderElement(folder); container.appendChild(element); }); - if (classifyOn() && !container.children.length) { - container.innerHTML = '
Nothing matches the current filters.
'; + if (!container.children.length) { + container.innerHTML = '
' + + (filterActive() ? 'No files match “' + nameFilter + '”.' : 'Nothing matches the current filters.') + '
'; } updateSelectedCount(); @@ -220,7 +259,7 @@ // expandable so its files can be revealed and dragged. || (classifyOn() && folder.files && folder.files.length > 0); if (mightHaveChildren) { - toggle.textContent = folder.expanded ? '▼' : '▶'; + toggle.textContent = (folder.expanded || visible) ? '▼' : '▶'; toggle.addEventListener('click', (e) => { e.stopPropagation(); const recursive = e.ctrlKey || e.metaKey; @@ -298,12 +337,12 @@ div.appendChild(item); - // Children (if expanded) - if (folder.expanded && folder.children && folder.children.length > 0) { + // Children — when expanded, or auto-expanded to reveal a filter match. + if ((folder.expanded || visible) && folder.children && folder.children.length > 0) { const childrenDiv = document.createElement('div'); childrenDiv.className = 'folder-children'; folder.children.forEach(child => { - if (folderHidden(child)) return; + if (!folderShown(child)) return; const childElement = createFolderElement(child, level + 1); childrenDiv.appendChild(childElement); }); @@ -311,12 +350,12 @@ } // Classify mode: list this folder's own files (draggable leaves) when - // expanded, so they can be dropped onto the target trees. - if (classifyOn() && folder.expanded && folder.files && folder.files.length > 0) { + // expanded (or auto-expanded by a filter), so they can be dropped. + if (classifyOn() && (folder.expanded || visible) && folder.files && folder.files.length > 0) { const filesDiv = document.createElement('div'); filesDiv.className = 'folder-children folder-files'; folder.files.forEach(function (file) { - if (classifyOn() && !fileVisible(file)) return; + if (!fileShown(file)) return; filesDiv.appendChild(createFileElement(file, level + 1)); }); div.appendChild(filesDiv); @@ -894,6 +933,7 @@ expandAll, selectAll, revealFile, - setShowFilters + setShowFilters, + setNameFilter }; })(); diff --git a/classifier/template.html b/classifier/template.html index 8952361..ea3404e 100644 --- a/classifier/template.html +++ b/classifier/template.html @@ -78,6 +78,8 @@ 0 folders selected +
@@ -168,6 +170,8 @@ Folders join with “-” into the tracking number; the leaf folder is the revision — name it like “A (IFR)”. +
diff --git a/tests/classify.spec.js b/tests/classify.spec.js index ac7a9b4..ea168d3 100644 --- a/tests/classify.spec.js +++ b/tests/classify.spec.js @@ -701,3 +701,44 @@ test('dataset (filename-based): import reconstruction rebuilds tracking + shared expect(r.bins).toBe(1); // shared transmittal → single bin (dedup) expect(r.excluded).toBe(true); }); + +test('source-tree filter reveals matches with their folder hierarchy', 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: 'Electrical', path: 'Project/Electrical', expanded: false, scanState: 'done', children: [], files: [ + { originalFilename: 'Master Deliverables List', extension: 'xlsx', folderPath: 'Project/Electrical' }, + { originalFilename: 'Switchgear Spec', extension: 'pdf', folderPath: 'Project/Electrical' }, + ] }, + { name: 'Civil', path: 'Project/Civil', expanded: false, scanState: 'done', children: [], files: [ + { originalFilename: 'Site Plan', extension: 'pdf', folderPath: 'Project/Civil' }, + ] }, + ], + }]; + window.app.modules.tree.render(); + window.app.modules.tree.setNameFilter('master deliverables'); + return { + files: Array.from(document.querySelectorAll('#folderTree .file-item .file-name')).map((e) => e.textContent), + folders: Array.from(document.querySelectorAll('#folderTree .folder-item')).map((e) => e.dataset.path), + }; + }); + expect(r.files).toEqual(['Master Deliverables List.xlsx']); // only the match shown + expect(r.folders).toEqual(['Project', 'Project/Electrical']); // path revealed; Civil hidden +}); + +test('tracking-tree filter reveals matching nodes and hides the rest', async ({ page }) => { + await page.click('#modeClassifyBtn'); + const names = await page.evaluate(() => { + const c = window.app.modules.classify; + 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 .tnode__name')).map((e) => e.textContent); + }); + expect(names).toContain('CPO'); + expect(names).toContain('0001'); + expect(names).not.toContain('XYZ'); +});