feat(displays): drag-to-reorder display tiles within a section (#106)

Option A: tile-on-tile (same section) reorders; tile-on-section / cross-
section stays group-assign (existing behavior untouched). Ordering is
cosmetic (dashboard only — nothing the device/player reads).

Backend:
- Migration: devices.sort_order column (idempotent ALTER; default 0).
- GET /api/devices ordering: sort_order ASC, created_at ASC (was created_at).
- POST /api/devices/reorder — ordered id array -> transactional
  UPDATE sort_order=index, scoped WHERE workspace_id = caller's workspace
  (forged cross-workspace ids are no-ops). Write-gated (viewer read-only).
  Mirrors the playlist items reorder.

Frontend (the collision):
- Card-level dragover/drop: reorder ONLY when target is another card in the
  SAME section; otherwise no-op so the event bubbles to the section's
  group-assign handler. stopPropagation on the same-section drop prevents
  the section handler also firing. Drop indicator (inset box-shadow).
  Native HTML5 DnD; no library.

Validated (headless Chrome, synthetic DnD + a section-level drop spy):
- SAME-section reorder: section drop suppressed (sectionDrops=0), POST
  /devices/reorder fires, NO group call, sort_order persists in DB.
- CROSS-section: section drop fires (sectionDrops=1), POST /groups/:id/
  devices fires and membership actually changes — group-assign unbroken.
- The 0-vs-1 contrast proves stopPropagation disambiguates the shared gesture.
- 149 server tests green; migration applies clean on the prod-copy DB.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
ScreenTinker 2026-06-15 15:15:21 -05:00
parent d64244b5ac
commit 5d24c30ea1
4 changed files with 80 additions and 1 deletions

View file

@ -28,6 +28,7 @@ async function request(url, options = {}) {
export const api = {
// Devices
getDevices: () => request('/devices'),
reorderDevices: (order) => request('/devices/reorder', { method: 'POST', body: JSON.stringify({ order }) }),
getDevice: (id) => request(`/devices/${id}`),
updateDevice: (id, data) => request(`/devices/${id}`, { method: 'PUT', body: JSON.stringify(data) }),
deleteDevice: (id) => request(`/devices/${id}`, { method: 'DELETE' }),

View file

@ -622,11 +622,64 @@ function attachGroupHandlers(groupsWithDevices, allDevices) {
});
}
// #106: within-section drag-to-reorder. Tracks the in-flight drag (which device,
// and which section it started in) so a CARD-level drop can tell reorder (same
// section) from group-assign (different section / section background).
let dragDeviceId = null;
let dragSectionKey = null;
const sectionKeyOf = (el) => {
const g = el.closest('.group-section');
if (g && g.dataset.groupId) return 'g:' + g.dataset.groupId;
if (el.closest('[data-ungrouped="1"]')) return 'ungrouped';
return null;
};
const clearDropIndicators = () => document.querySelectorAll('.device-card').forEach(c => { c.style.boxShadow = ''; });
document.querySelectorAll('.device-card').forEach(card => {
card.addEventListener('dragstart', (e) => {
e.dataTransfer.setData('text/device-id', card.dataset.deviceId);
e.dataTransfer.setData('text/device-name', card.dataset.deviceName || '');
e.dataTransfer.effectAllowed = 'move';
dragDeviceId = card.dataset.deviceId; // #106
dragSectionKey = sectionKeyOf(card); // #106
});
card.addEventListener('dragend', () => { dragDeviceId = null; dragSectionKey = null; clearDropIndicators(); });
// #106 within-section reorder. Engages ONLY when the target is another card in the
// SAME section; otherwise it no-ops and the event bubbles to the section handler
// (group-assign), leaving the existing behavior untouched.
card.addEventListener('dragover', (e) => {
if (!dragDeviceId || dragDeviceId === card.dataset.deviceId) return;
if (sectionKeyOf(card) !== dragSectionKey) return; // cross-section -> section handles (assign)
e.preventDefault();
e.stopPropagation(); // suppress the section's group-assign dragover/highlight
e.dataTransfer.dropEffect = 'move';
const r = card.getBoundingClientRect();
const before = e.clientX < r.left + r.width / 2;
card.style.boxShadow = before ? 'inset 3px 0 0 var(--primary)' : 'inset -3px 0 0 var(--primary)';
});
card.addEventListener('dragleave', () => { card.style.boxShadow = ''; });
card.addEventListener('drop', async (e) => {
if (!dragDeviceId || dragDeviceId === card.dataset.deviceId) return;
if (sectionKeyOf(card) !== dragSectionKey) return; // cross-section -> bubble to section (assign)
e.preventDefault();
e.stopPropagation(); // CRITICAL: stop the section's group-assign drop also firing
const r = card.getBoundingClientRect();
const before = e.clientX < r.left + r.width / 2;
clearDropIndicators();
const grid = card.closest('.device-grid');
if (!grid) return;
const ids = Array.from(grid.querySelectorAll('.device-card')).map(c => c.dataset.deviceId).filter(Boolean);
const from = ids.indexOf(dragDeviceId);
if (from === -1) return;
ids.splice(from, 1);
let to = ids.indexOf(card.dataset.deviceId);
if (!before) to += 1;
ids.splice(to, 0, dragDeviceId);
try {
await api.reorderDevices(ids);
loadDashboard();
} catch (err) { showToast(err.message, 'error'); }
});
});

View file

@ -203,6 +203,9 @@ const migrations = [
// #73: zone-binding was reverted (placement belongs to the device, not the playlist - see
// the agency-tokens history). Drop the table on DBs where the short-lived migration ran.
"DROP TABLE IF EXISTS api_token_target_zones",
// #106: cosmetic per-workspace display ordering for the Displays view (drag-to-
// reorder). Default 0 -> existing devices fall back to the created_at tiebreak.
"ALTER TABLE devices ADD COLUMN sort_order INTEGER NOT NULL DEFAULT 0",
];
// Apply each ALTER idempotently. A "duplicate column name" / "already exists"
// error means the column is already present (expected on a migrated DB) - benign.

View file

@ -37,12 +37,34 @@ router.get('/', (req, res) => {
ON sc.device_id = latest.device_id AND sc.captured_at = latest.max_at
) s ON d.id = s.device_id
WHERE d.workspace_id = ?
ORDER BY d.created_at ASC
ORDER BY d.sort_order ASC, d.created_at ASC
LIMIT ? OFFSET ?
`).all(req.workspaceId, limit, offset);
res.json(devices.map(stripDeviceSecrets));
});
// #106: reorder display tiles (cosmetic, within-section). Writes devices.sort_order
// = position in the given id array. Workspace-scoped: the UPDATE matches WHERE
// workspace_id = the caller's current workspace, so a forged id from another
// workspace is silently a no-op (can't reorder or probe devices you can't see).
// Write-gated: workspace_viewer (non-acting) is read-only. Ordering affects ONLY the
// dashboard listing — nothing the device/player reads (grouping/pairing/playback
// are independent). Mirrors the playlist items reorder.
router.post('/reorder', (req, res) => {
if (!req.workspaceId) return res.status(403).json({ error: 'No workspace' });
if (!req.actingAs && req.workspaceRole === 'workspace_viewer') {
return res.status(403).json({ error: 'Read-only access' });
}
const { order } = req.body;
if (!Array.isArray(order)) return res.status(400).json({ error: 'order must be an array of device IDs' });
const stmt = db.prepare("UPDATE devices SET sort_order = ?, updated_at = strftime('%s','now') WHERE id = ? AND workspace_id = ?");
const tx = db.transaction(() => {
order.forEach((id, index) => stmt.run(index, id, req.workspaceId));
});
tx();
res.json({ success: true });
});
// List unclaimed provisioning devices (admin only).
// #13: read-only, so platform_operator may view the pool too (cross-org staff
// troubleshooting). Claiming a device is a separate workspace-scoped mutation.