feat(sacred-valley): drag-to-reorder with server-persisted layout
Adds HTML5 drag-to-reorder for .sv-card elements in Sacred Valley. The pure moveId helper is unit-tested. Drop calls PUT /api/dashboard/layout to persist the new card_order; DOM reflects the new order immediately. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -63,6 +63,7 @@ function promptForToken() {
|
|||||||
export const api = {
|
export const api = {
|
||||||
get: (p) => call('GET', p),
|
get: (p) => call('GET', p),
|
||||||
post: (p, body) => call('POST', p, body ?? {}),
|
post: (p, body) => call('POST', p, body ?? {}),
|
||||||
|
put: (p, body) => call('PUT', p, body ?? {}),
|
||||||
patch: (p, body) => call('PATCH', p, body ?? {}),
|
patch: (p, body) => call('PATCH', p, body ?? {}),
|
||||||
del: (p) => call('DELETE', p),
|
del: (p) => call('DELETE', p),
|
||||||
setToken: (v) => localStorage.setItem(TOKEN_KEY, v),
|
setToken: (v) => localStorage.setItem(TOKEN_KEY, v),
|
||||||
|
|||||||
28
public/components/sv_reorder.js
Normal file
28
public/components/sv_reorder.js
Normal file
@@ -0,0 +1,28 @@
|
|||||||
|
export function moveId(order, dragId, beforeId) {
|
||||||
|
if (dragId === beforeId) return [...order];
|
||||||
|
const out = order.filter(id => id !== dragId);
|
||||||
|
if (beforeId == null) { out.push(dragId); return out; }
|
||||||
|
const i = out.indexOf(beforeId);
|
||||||
|
if (i < 0) { out.push(dragId); return out; }
|
||||||
|
out.splice(i, 0, dragId);
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Wires HTML5 drag on .sv-card elements in `grid`. onReorder(newOrderIds) fires
|
||||||
|
// after a drop. Drag handle = the whole card (cursor:grab on the title via CSS).
|
||||||
|
export function attachReorder(grid, onReorder) {
|
||||||
|
let dragId = null;
|
||||||
|
grid.querySelectorAll('.sv-card').forEach(card => {
|
||||||
|
card.draggable = true;
|
||||||
|
card.addEventListener('dragstart', () => { dragId = card.dataset.cardId; card.classList.add('dragging'); });
|
||||||
|
card.addEventListener('dragend', () => { card.classList.remove('dragging'); dragId = null; });
|
||||||
|
card.addEventListener('dragover', e => { e.preventDefault(); card.classList.add('drag-over'); });
|
||||||
|
card.addEventListener('dragleave', () => card.classList.remove('drag-over'));
|
||||||
|
card.addEventListener('drop', e => {
|
||||||
|
e.preventDefault(); card.classList.remove('drag-over');
|
||||||
|
if (!dragId || dragId === card.dataset.cardId) return;
|
||||||
|
const ids = [...grid.querySelectorAll('.sv-card')].map(c => c.dataset.cardId);
|
||||||
|
onReorder(moveId(ids, dragId, card.dataset.cardId));
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -1,6 +1,7 @@
|
|||||||
import { el, mount } from '../dom.js';
|
import { el, mount } from '../dom.js';
|
||||||
import { api } from '../api.js';
|
import { api } from '../api.js';
|
||||||
import { svCard } from '../components/sv_card.js';
|
import { svCard } from '../components/sv_card.js';
|
||||||
|
import { attachReorder } from '../components/sv_reorder.js';
|
||||||
import { orderCards } from './cards/registry.js';
|
import { orderCards } from './cards/registry.js';
|
||||||
import clock from './cards/clock.js';
|
import clock from './cards/clock.js';
|
||||||
import weather from './cards/weather.js';
|
import weather from './cards/weather.js';
|
||||||
@@ -30,5 +31,13 @@ export async function render(main) {
|
|||||||
try { def.mount(body); def.start && def.start(); active.push(def); }
|
try { def.mount(body); def.start && def.start(); active.push(def); }
|
||||||
catch (e) { body.appendChild(el('span', { class: 'muted' }, 'card failed')); console.error(def.id, e); }
|
catch (e) { body.appendChild(el('span', { class: 'muted' }, 'card failed')); console.error(def.id, e); }
|
||||||
}
|
}
|
||||||
// health band + drag wiring arrive in Tasks 22 and 10.
|
attachReorder(grid, async (newOrder) => {
|
||||||
|
// reflect immediately
|
||||||
|
const frag = document.createDocumentFragment();
|
||||||
|
newOrder.forEach(id => { const n = grid.querySelector(`.sv-card[data-card-id="${id}"]`); if (n) frag.appendChild(n); });
|
||||||
|
grid.appendChild(frag);
|
||||||
|
try { await api.put('/api/dashboard/layout', { ...layout, card_order: newOrder }); layout.card_order = newOrder; }
|
||||||
|
catch (e) { console.error('save layout', e); }
|
||||||
|
});
|
||||||
|
// health band wiring arrives in Task 22.
|
||||||
}
|
}
|
||||||
|
|||||||
14
tests/frontend/reorder.test.js
Normal file
14
tests/frontend/reorder.test.js
Normal file
@@ -0,0 +1,14 @@
|
|||||||
|
import { describe, it, expect } from 'vitest';
|
||||||
|
import { moveId } from '../../public/components/sv_reorder.js';
|
||||||
|
|
||||||
|
describe('moveId', () => {
|
||||||
|
it('moves an id before a target', () => {
|
||||||
|
expect(moveId(['a', 'b', 'c'], 'c', 'a')).toEqual(['c', 'a', 'b']);
|
||||||
|
});
|
||||||
|
it('moving onto itself is a no-op', () => {
|
||||||
|
expect(moveId(['a', 'b', 'c'], 'b', 'b')).toEqual(['a', 'b', 'c']);
|
||||||
|
});
|
||||||
|
it('moving to end when target is null appends', () => {
|
||||||
|
expect(moveId(['a', 'b', 'c'], 'a', null)).toEqual(['b', 'c', 'a']);
|
||||||
|
});
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user