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:
parent
a8403d1f73
commit
47cf58b0e9
7 changed files with 241 additions and 1 deletions
|
|
@ -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" \
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
|
|||
28
classifier/js/dnd.js
Normal file
28
classifier/js/dnd.js
Normal 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,
|
||||
};
|
||||
})();
|
||||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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
|
||||
*/
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
Loading…
Reference in a new issue