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) <noreply@anthropic.com>
This commit is contained in:
ZDDC 2026-06-09 12:23:38 -05:00
parent a8403d1f73
commit 47cf58b0e9
7 changed files with 241 additions and 1 deletions

View file

@ -53,6 +53,7 @@ concat_files \
"js/store.js" \ "js/store.js" \
"js/persist.js" \ "js/persist.js" \
"js/classify.js" \ "js/classify.js" \
"js/dnd.js" \
"js/validator.js" \ "js/validator.js" \
"js/scanner.js" \ "js/scanner.js" \
"js/tree.js" \ "js/tree.js" \

View file

@ -390,9 +390,40 @@
.binform__seq { width: 7rem; } .binform__seq { width: 7rem; }
.binform__title { width: 11rem; } .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); } .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 */
.spreadsheet-pane { .spreadsheet-pane {
flex: 1; flex: 1;

View file

@ -224,6 +224,15 @@
// Resize handle // Resize handle
setupResizeHandle(); 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();
});
}
} }
/** /**

28
classifier/js/dnd.js Normal file
View file

@ -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,
};
})();

View file

@ -49,6 +49,9 @@
els.trackingTree.addEventListener('click', onTrackingClick); els.trackingTree.addEventListener('click', onTrackingClick);
els.transmittalTree.addEventListener('click', onTransmittalClick); els.transmittalTree.addEventListener('click', onTransmittalClick);
setupDropZone(els.trackingTree, 'tracking');
setupDropZone(els.transmittalTree, 'transmittal');
C().on(render); C().on(render);
if (window.app.modules.store && window.app.modules.store.on) { if (window.app.modules.store && window.app.modules.store.on) {
window.app.modules.store.on('files', render); 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 = { window.app.modules.targetTree = {
init: init, init: init,
render: render, render: render,

View file

@ -5,6 +5,36 @@
(function() { (function() {
'use strict'; '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 * Render the folder tree
*/ */
@ -104,6 +134,18 @@
item.classList.add('selected'); 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 // Toggle button: shown when the folder has children OR hasn't been
// scanned yet (it might have children — expanding triggers its scan). // scanned yet (it might have children — expanding triggers its scan).
const toggle = document.createElement('span'); const toggle = document.createElement('span');
@ -195,9 +237,51 @@
div.appendChild(childrenDiv); 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; 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 = '&#128196;'; // 📄
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 * Handle folder click with multi-select support
*/ */

View file

@ -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(); 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 }) => { test('deleting a tracking node clears the files placed in it', async ({ page }) => {
const after = await page.evaluate((file) => { const after = await page.evaluate((file) => {
const c = window.app.modules.classify; const c = window.app.modules.classify;