From 47cf58b0e9e7607ecbc7746105c9d645246b46e5 Mon Sep 17 00:00:00 2001 From: ZDDC Date: Tue, 9 Jun 2026 12:23:38 -0500 Subject: [PATCH] feat(classifier): drag-and-drop assignment (phase 3) In Classify & Copy mode the left tree now lists each folder's files as draggable rows (with a classification state dot), and folder rows are draggable for a group-drag of the whole subtree. Target-tree nodes are drop zones: a tracking folder (any node) or a transmittal bin; dropping assigns the dragged source key(s) along that axis via classify.place(). - dnd.js: drag-payload bus (keys held in a module var since dataTransfer can't be read during dragover; carries a marker for the copy cursor). - tree.js: createFileElement + group-drag dragstart; classify-mode file rows. - target-tree.js: setupDropZone with dragover highlight + drop assignment (tracking = any node, transmittal = bins only). - app.js: source tree re-renders on classify state change. - 2 DnD drop-handler tests (14 total green). Co-Authored-By: Claude Opus 4.8 (1M context) --- classifier/build.sh | 1 + classifier/css/layout.css | 33 +++++++++++++- classifier/js/app.js | 9 ++++ classifier/js/dnd.js | 28 ++++++++++++ classifier/js/target-tree.js | 41 ++++++++++++++++++ classifier/js/tree.js | 84 ++++++++++++++++++++++++++++++++++++ tests/classify.spec.js | 46 ++++++++++++++++++++ 7 files changed, 241 insertions(+), 1 deletion(-) create mode 100644 classifier/js/dnd.js diff --git a/classifier/build.sh b/classifier/build.sh index d0bd5d0..0cbdfeb 100755 --- a/classifier/build.sh +++ b/classifier/build.sh @@ -53,6 +53,7 @@ concat_files \ "js/store.js" \ "js/persist.js" \ "js/classify.js" \ + "js/dnd.js" \ "js/validator.js" \ "js/scanner.js" \ "js/tree.js" \ diff --git a/classifier/css/layout.css b/classifier/css/layout.css index 0b4d84d..e54984d 100644 --- a/classifier/css/layout.css +++ b/classifier/css/layout.css @@ -390,9 +390,40 @@ .binform__seq { width: 7rem; } .binform__title { width: 11rem; } -/* drop-target affordance (used in phase 3) */ +/* drop-target affordance */ .tnode__row.drop-hover, .tslot.drop-hover { outline: 2px dashed var(--primary); outline-offset: -2px; background: var(--primary-light); } +/* ── Source-tree file rows (classify mode) ─────────────────────────────── */ +.file-item { + display: flex; + align-items: center; + gap: 0.4rem; + padding: 0.15rem 0.5rem; + cursor: grab; + border-radius: var(--radius); + font-size: 0.85rem; + user-select: none; +} +.file-item:hover { background: var(--bg-hover); } +.file-item:active { cursor: grabbing; } +.file-item.match-highlight { background: var(--primary-light); outline: 1px solid var(--primary); } +.folder-item[draggable="true"] { cursor: grab; } +.file-icon { color: var(--text-muted); } +.file-name { flex: 1; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } + +/* classification state dot */ +.cl-dot { + width: 0.55rem; height: 0.55rem; border-radius: 999px; flex-shrink: 0; + border: 1px solid var(--border); background: transparent; +} +.cl-dot--none { background: transparent; } +.cl-dot--tracking, +.cl-dot--transmittal { background: var(--warning); border-color: var(--warning); } +.cl-dot--partial { background: var(--warning); border-color: var(--warning); } +.cl-dot--done { background: var(--success); border-color: var(--success); } +.cl-dot--excluded { background: var(--text-muted); border-color: var(--text-muted); opacity: 0.6; } +.file-item.excluded .file-name { text-decoration: line-through; color: var(--text-muted); } + /* Spreadsheet Pane */ .spreadsheet-pane { flex: 1; diff --git a/classifier/js/app.js b/classifier/js/app.js index 921b829..23a2e64 100644 --- a/classifier/js/app.js +++ b/classifier/js/app.js @@ -224,6 +224,15 @@ // Resize handle setupResizeHandle(); + + // Re-render the source tree when classify state changes (so file dots + // and placements stay in sync after a drop). Cheap no-op outside + // classify mode. + if (app.modules.classify) { + app.modules.classify.on(function () { + if (app.modules.classify.isEnabled() && app.modules.tree) app.modules.tree.render(); + }); + } } /** diff --git a/classifier/js/dnd.js b/classifier/js/dnd.js new file mode 100644 index 0000000..6a52174 --- /dev/null +++ b/classifier/js/dnd.js @@ -0,0 +1,28 @@ +/** + * ZDDC Classifier — drag payload bus for Classify & Copy. + * + * HTML5 dataTransfer can't be read during `dragover` (only on `drop`), and we + * need the dragged set to drive drop-target highlighting. So the source keys + * live in a module variable for the lifetime of a drag; dataTransfer carries a + * marker so the browser shows a copy cursor and external drops are ignored. + */ +(function () { + 'use strict'; + + var keys = []; + + function setDrag(srcKeys, e) { + keys = (srcKeys || []).slice(); + if (e && e.dataTransfer) { + e.dataTransfer.effectAllowed = 'copy'; + try { e.dataTransfer.setData('application/x-zddc-keys', keys.join('\n')); } catch (_) { /* ok */ } + } + } + function getDrag() { return keys; } + function active() { return keys.length > 0; } + function clearDrag() { keys = []; } + + window.app.modules.dnd = { + setDrag: setDrag, getDrag: getDrag, active: active, clearDrag: clearDrag, + }; +})(); diff --git a/classifier/js/target-tree.js b/classifier/js/target-tree.js index 97245dc..0410644 100644 --- a/classifier/js/target-tree.js +++ b/classifier/js/target-tree.js @@ -49,6 +49,9 @@ els.trackingTree.addEventListener('click', onTrackingClick); els.transmittalTree.addEventListener('click', onTransmittalClick); + setupDropZone(els.trackingTree, 'tracking'); + setupDropZone(els.transmittalTree, 'transmittal'); + C().on(render); if (window.app.modules.store && window.app.modules.store.on) { window.app.modules.store.on('files', render); @@ -293,6 +296,44 @@ } } + // ── drop targets ─────────────────────────────────────────────────────── + // Resolve the drop target under an event: + // tracking → any folder node (.tnode) + // transmittal → a transmittal bin only (.tnode--bin) + function dropTarget(target, axis) { + var sel = axis === 'transmittal' ? '.tnode--bin' : '.tnode'; + var node = target.closest(sel); + if (!node || !node.dataset.id) return null; + return { id: node.dataset.id, row: node.querySelector('.tnode__row') || node }; + } + function clearHover(container) { + var hot = container.querySelectorAll('.drop-hover'); + for (var i = 0; i < hot.length; i++) hot[i].classList.remove('drop-hover'); + } + function setupDropZone(container, axis) { + container.addEventListener('dragover', function (e) { + if (!window.app.modules.dnd.active()) return; + var t = dropTarget(e.target, axis); + clearHover(container); + if (!t) return; + e.preventDefault(); + e.dataTransfer.dropEffect = 'copy'; + t.row.classList.add('drop-hover'); + }); + container.addEventListener('dragleave', function (e) { + if (e.target === container) clearHover(container); + }); + container.addEventListener('drop', function (e) { + var t = dropTarget(e.target, axis); + clearHover(container); + if (!t) return; + e.preventDefault(); + var keys = window.app.modules.dnd.getDrag(); + window.app.modules.dnd.clearDrag(); + if (keys.length) C().place(keys, t.id, axis); + }); + } + window.app.modules.targetTree = { init: init, render: render, diff --git a/classifier/js/tree.js b/classifier/js/tree.js index 9a17725..95951e0 100644 --- a/classifier/js/tree.js +++ b/classifier/js/tree.js @@ -5,6 +5,36 @@ (function() { 'use strict'; + // ── Classify & Copy helpers ──────────────────────────────────────────── + function classifyOn() { + var c = window.app.modules.classify; + return c && c.isEnabled(); + } + // All file objects in a folder's (already-scanned) subtree — group-drag. + function subtreeFiles(folder, out) { + out = out || []; + (folder.files || []).forEach(function (f) { out.push(f); }); + (folder.children || []).forEach(function (c) { subtreeFiles(c, out); }); + return out; + } + function keysFor(files) { + var c = window.app.modules.classify; + return files.map(function (f) { return c.srcKeyForFile(f); }); + } + // A small status dot reflecting a file's classification state. + var STATE_TITLE = { + none: 'unassigned', tracking: 'has tracking number, needs a transmittal', + transmittal: 'in a transmittal, needs a tracking number', + partial: 'placed, but the name is incomplete', done: 'fully classified', + excluded: 'excluded — will not be copied', + }; + function stateDot(state) { + var dot = document.createElement('span'); + dot.className = 'cl-dot cl-dot--' + state; + dot.title = STATE_TITLE[state] || ''; + return dot; + } + /** * Render the folder tree */ @@ -104,6 +134,18 @@ item.classList.add('selected'); } + // Classify mode: the folder row is a drag source for a group-drag of + // every file in its subtree. + if (classifyOn()) { + item.draggable = true; + item.addEventListener('dragstart', function (e) { + e.stopPropagation(); + var files = subtreeFiles(folder); + if (!files.length) { e.preventDefault(); return; } + window.app.modules.dnd.setDrag(keysFor(files), e); + }); + } + // Toggle button: shown when the folder has children OR hasn't been // scanned yet (it might have children — expanding triggers its scan). const toggle = document.createElement('span'); @@ -195,9 +237,51 @@ div.appendChild(childrenDiv); } + // 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) { + const filesDiv = document.createElement('div'); + filesDiv.className = 'folder-children folder-files'; + folder.files.forEach(function (file) { + filesDiv.appendChild(createFileElement(file, level + 1)); + }); + div.appendChild(filesDiv); + } + return div; } + /** + * Create a draggable source-file row (classify mode only). + */ + function createFileElement(file, level) { + const c = window.app.modules.classify; + const item = document.createElement('div'); + item.className = 'file-item'; + item.style.paddingLeft = `${level * 1.5}rem`; + item.draggable = true; + const key = c.srcKeyForFile(file); + item.dataset.key = key; + + item.appendChild(stateDot(c.fileState(file))); + + const icon = document.createElement('span'); + icon.className = 'file-icon'; + icon.innerHTML = '📄'; // 📄 + item.appendChild(icon); + + const name = document.createElement('span'); + name.className = 'file-name'; + name.textContent = zddc.joinExtension(file.originalFilename, file.extension); + item.appendChild(name); + + item.addEventListener('dragstart', function (e) { + e.stopPropagation(); + window.app.modules.dnd.setDrag([key], e); + }); + return item; + } + /** * Handle folder click with multi-select support */ diff --git a/tests/classify.spec.js b/tests/classify.spec.js index 1732896..58f73b7 100644 --- a/tests/classify.spec.js +++ b/tests/classify.spec.js @@ -177,6 +177,52 @@ test('"+ Root folder" button (prompt) adds a tracking node', async ({ page }) => await expect(page.locator('#trackingTree .tnode__name', { hasText: 'ACME-PROJ' })).toBeVisible(); }); +// ── Phase 3: drag-and-drop assignment (drop handler) ─────────────────────── + +test('dropping a file onto a tracking leaf assigns it', 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 .tnode--leaf .tnode__row'); + 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 }; + }); + expect(r.assigned).toBe(r.leaf); +}); + +test('dropping onto a transmittal bin assigns; dropping on a party row does not', async ({ page }) => { + await page.click('#modeClassifyBtn'); + await page.click('#transmittalTab'); + const r = await page.evaluate(() => { + const c = window.app.modules.classify; + const party = c.addParty('ClientCorp'); + const bin = c.addTransmittalBin(party, 'received', { date: '2026-03-14', type: 'TRN', seq: '0007' }); + window.app.modules.targetTree.render(); + const key = 'Sub/foundation.pdf'; + + // Drop on the bin → assigned. + const binRow = document.querySelector('#transmittalTree .tnode--bin .tnode__row'); + window.app.modules.dnd.setDrag([key]); + binRow.dispatchEvent(new Event('drop', { bubbles: true, cancelable: true })); + const afterBin = c.assignmentFor(key).transmittalNodeId; + + // Reset, then drop on the party row → ignored (only bins are targets). + c.place([key], null, 'transmittal'); + const partyRow = document.querySelector('#transmittalTree .tnode--party > .tnode__row'); + window.app.modules.dnd.setDrag([key]); + partyRow.dispatchEvent(new Event('drop', { bubbles: true, cancelable: true })); + const afterParty = c.assignmentFor(key).transmittalNodeId; + + return { afterBin, bin, afterParty }; + }); + expect(r.afterBin).toBe(r.bin); + expect(r.afterParty).toBe(null); +}); + test('deleting a tracking node clears the files placed in it', async ({ page }) => { const after = await page.evaluate((file) => { const c = window.app.modules.classify;