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/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" \
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
|
|
|
||||||
|
|
@ -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
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.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,
|
||||||
|
|
|
||||||
|
|
@ -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 = '📄'; // 📄
|
||||||
|
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
|
||||||
*/
|
*/
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue