Make reordering work on the page you are actually looking at

Two defects, both found by driving the real editor in Firefox:

1. The editor drew grab handles with a grab cursor on sets it refuses to
   reorder, so the live Food menu looked draggable and did nothing. Handles and
   the move buttons are now rendered only when reordering is possible, and the
   notice says what can still be done.

2. Reordering was saved only from dragend. Browsers suppress mouse events during
   a drag and dragend is not guaranteed to arrive, which left the rows visibly
   reordered while the server kept the old order — the drag looked like it
   worked and then reverted on reload. The order now commits from dragover after
   a pause in the drag, with drop/dragend/mouse events as faster paths, deduped
   against the last confirmed order and reported in the page.

To make (1) coherent the Current set may now be reordered while its Pages stay
fixed: order is presentation, media URLs are content-hashed and pages are
rendered per request, so nothing stale can be served (ADR 0006). Archives stay
frozen. Admin pages are no-store and assets cache for 5 minutes, so a fix like
this one is not masked by a stale admin.js.

scripts/check-dnd.mjs drives the real editor and asserts the drag persists; it
is the only automated check that reaches this path.
This commit is contained in:
tanshu committed 2026-09-22 22:01:52 +05:30
1 parent 1f41398370
commit 7bf67cc299
12 files changed
+415 -35

No files matched your search

+9
View File
@@ -83,6 +83,15 @@ npm run verify # integrity of .dev/data
npm run check-site -- http://127.0.0.1:3000 # checks a running site from outside npm run check-site -- http://127.0.0.1:3000 # checks a running site from outside
``` ```
Dragging Pages is the one interaction that only a browser can check, so there is
a harness for it. It needs `geckodriver` on PATH and a running dev server:
```sh
geckodriver --port 4444 &
npm run check-dnd -- <set-id> native # drags and asserts it persisted
npm run check-dnd -- <set-id> none # just reports affordances
```
### In VS Code ### In VS Code
`.vscode/` holds launch configurations and tasks. `.vscode/` holds launch configurations and tasks.
@@ -0,0 +1,5 @@
# The Current set can be reordered, though its Pages are fixed
A published Menu Set's Pages are still fixed, but the order of the Current set can be changed, because order is presentation rather than content: Viewing image URLs are content-hashed, pages are rendered per request, and nothing downstream caches a rendered page, so moving a Page cannot serve anything stale. A restaurant that finds its live menu in the wrong order fixes it in seconds instead of re-uploading twenty photos; Archives stay frozen as historical records.
**Considered Options**: duplicating the Current set into a Draft to reorder it. That keeps one simple rule but makes the commonest correction the most expensive, and it moves the menu's URL for what is only a change of order.
+2 -1
View File
@@ -21,7 +21,7 @@ Replaces the `scp`-a-JPG workflow for `menu.hopsngrains.com` with a small self-h
| Metadata | JSON manifests, state encoded in the directory layout (ADR 0002) | | Metadata | JSON manifests, state encoded in the directory layout (ADR 0002) |
| Scope | One app instance per Site (ADR 0004); other two Sites migrate later | | Scope | One app instance per Site (ADR 0004); other two Sites migrate later |
| Sections | Per-Site config in git; not editable in the admin (ADR 0005) | | Sections | Per-Site config in git; not editable in the admin (ADR 0005) |
| Lifecycle | Draft → Current → Archive; publishing auto-archives the previous set; archives unlisted but reachable | | Lifecycle | Draft → Current → Archive; publishing auto-archives the previous set; archives unlisted but reachable. A published set's Pages are fixed, but the Current set's order can be corrected (ADR 0006) |
| Auth | Single shared password per Site; app login; hash + session secret in an untracked `.env` on the server | | Auth | Single shared password per Site; app login; hash + session secret in an untracked `.env` on the server |
| Images | 1600px viewing WebP + JPEG fallback, 480px admin-only thumbnails, Originals kept and never served | | Images | 1600px viewing WebP + JPEG fallback, 480px admin-only thumbnails, Originals kept and never served |
| Logging | JSON to stdout only; no Prometheus, no Promtail | | Logging | JSON to stdout only; no Prometheus, no Promtail |
@@ -55,6 +55,7 @@ caddy (80/443, TLS) ──reverse_proxy──▶ menu-app.hopsngrains.com:3000
- `<slug>` is the valid-until date when set, else the publication date (`YYYY-MM-DD`), suffixed `-2` on collision. - `<slug>` is the valid-until date when set, else the publication date (`YYYY-MM-DD`), suffixed `-2` on collision.
- Directory encodes state: `drafts/` is invisible to customers, `published/` is reachable. - Directory encodes state: `drafts/` is invisible to customers, `published/` is reachable.
- Editing rules: a Draft accepts anything; the Current set accepts reordering only (order is presentation, ADR 0006); an Archive is frozen.
- `manifest.json` holds `id`, `section`, `label`, `validUntil`, `createdAt`, `publishedAt` and the ordered Page list; each Page carries its own `id`, content hash, source extension, the Viewing image's dimensions and the uploaded filename. - `manifest.json` holds `id`, `section`, `label`, `validUntil`, `createdAt`, `publishedAt` and the ordered Page list; each Page carries its own `id`, content hash, source extension, the Viewing image's dimensions and the uploaded filename.
- **Current** is the published set for a Section with the newest `publishedAt`; everything else published is an Archive. No separate current pointer exists to drift. - **Current** is the published set for a Section with the newest `publishedAt`; everything else published is an Archive. No separate current pointer exists to drift.
- Writes are temp file + `fsync` + rename, and publishing is a directory rename, so a crash leaves either a Draft or a published set. `publish`/`archive` serialize behind one in-process lock. Boot validation checks that every manifest matches its directory and that Page files exist. - Writes are temp file + `fsync` + rename, and publishing is a directory rename, so a crash leaves either a Draft or a published set. `publish`/`archive` serialize behind one in-process lock. Boot validation checks that every manifest matches its directory and that Page files exist.
+2 -1
View File
@@ -16,7 +16,8 @@
"verify": "node src/cli/verify.ts", "verify": "node src/cli/verify.ts",
"repair": "node src/cli/repair.ts", "repair": "node src/cli/repair.ts",
"typecheck": "tsc --noEmit", "typecheck": "tsc --noEmit",
"test": "node --test test/*.test.ts" "test": "node --test test/*.test.ts",
"check-dnd": "node scripts/check-dnd.mjs"
}, },
"dependencies": { "dependencies": {
"@fastify/cookie": "^11.1.2", "@fastify/cookie": "^11.1.2",
+55 -13
View File
@@ -154,6 +154,12 @@
return Array.prototype.slice.call(list.querySelectorAll('[data-page-id]')); return Array.prototype.slice.call(list.querySelectorAll('[data-page-id]'));
} }
function order() {
return items().map(function (item) {
return item.getAttribute('data-page-id');
});
}
function renumber() { function renumber() {
items().forEach(function (item, index) { items().forEach(function (item, index) {
var number = item.querySelector('.page-admin__number'); var number = item.querySelector('.page-admin__number');
@@ -161,11 +167,21 @@
}); });
} }
function saveOrder() { // The order the server last confirmed. Everything below goes through
var ids = items().map(function (item) { // saveOrderIfChanged, so a reorder is sent once however the drag ends.
return item.getAttribute('data-page-id'); var savedOrder = list ? order().join(',') : '';
}); var orderStatus = document.querySelector('[data-order-status]');
say('Saving order…');
function sayOrder(message) {
if (orderStatus) orderStatus.textContent = message;
}
function saveOrderIfChanged() {
if (!list) return;
var ids = order();
var key = ids.join(',');
if (key === savedOrder) return;
sayOrder('Saving order…');
return fetch('/admin/sets/' + setId + '/reorder', { return fetch('/admin/sets/' + setId + '/reorder', {
method: 'POST', method: 'POST',
headers: { 'content-type': 'application/json', 'x-csrf': csrf, accept: 'application/json' }, headers: { 'content-type': 'application/json', 'x-csrf': csrf, accept: 'application/json' },
@@ -173,15 +189,40 @@
}) })
.then(function (response) { .then(function (response) {
if (!response.ok) throw new Error('could not save the order'); if (!response.ok) throw new Error('could not save the order');
say('Order saved.'); savedOrder = key;
sayOrder('Order saved.');
}) })
.catch(function (error) { .catch(function (error) {
say(error.message); // Leave savedOrder alone so the next interaction tries again, and say so
// rather than leaving a reordered list that is not what customers see.
sayOrder(error.message + ' — not saved yet, try again');
}); });
} }
if (list) { if (list) {
var dragged = null; var dragged = null;
var pendingSave = null;
// Browsers suppress mouse events while a drag is in flight, and dragend is
// not guaranteed to arrive — so the save cannot wait for the drag to end.
// The rows have already moved by then, and what is on screen is what should
// be saved, so a pause in the drag commits it.
function scheduleSave() {
if (pendingSave) clearTimeout(pendingSave);
pendingSave = setTimeout(function () {
pendingSave = null;
saveOrderIfChanged();
}, 600);
}
function commitSave() {
if (pendingSave) {
clearTimeout(pendingSave);
pendingSave = null;
}
renumber();
saveOrderIfChanged();
}
items().forEach(function (item) { items().forEach(function (item) {
item.addEventListener('dragstart', function (event) { item.addEventListener('dragstart', function (event) {
@@ -189,19 +230,20 @@
item.classList.add('is-dragging'); item.classList.add('is-dragging');
if (event.dataTransfer) event.dataTransfer.effectAllowed = 'move'; if (event.dataTransfer) event.dataTransfer.effectAllowed = 'move';
}); });
item.addEventListener('dragend', function () { item.addEventListener('dragend', commitSave);
item.classList.remove('is-dragging');
dragged = null;
renumber();
saveOrder();
});
item.addEventListener('dragover', function (event) { item.addEventListener('dragover', function (event) {
if (!dragged || dragged === item) return; if (!dragged || dragged === item) return;
event.preventDefault(); event.preventDefault();
var rect = item.getBoundingClientRect(); var rect = item.getBoundingClientRect();
var after = event.clientY > rect.top + rect.height / 2; var after = event.clientY > rect.top + rect.height / 2;
list.insertBefore(dragged, after ? item.nextSibling : item); list.insertBefore(dragged, after ? item.nextSibling : item);
renumber();
scheduleSave();
}); });
}); });
['drop', 'pointerup', 'mouseup', 'pointercancel'].forEach(function (type) {
list.addEventListener(type, commitSave);
});
} }
})(); })();
+172
View File
@@ -0,0 +1,172 @@
/* Drives the real admin editor in Firefox and reports whether dragging a Page
actually reorders it and reaches the server. This is the only automated check
that reaches the drag path: the order is committed from dragover, because
browsers suppress mouse events during a drag and dragend is not guaranteed.
Needs a running dev server and geckodriver on PATH:
npm run dev # in one terminal
geckodriver --port 4444 & # in another
npm run check-dnd -- <setId> [native|synthetic|none]
Use "none" to inspect a set's affordances without dragging: an Archive should
report no draggable rows and no grab handles.
*/
const GECKO = 'http://127.0.0.1:4444';
const SITE = process.env.SITE ?? 'http://127.0.0.1:3000';
const PASSWORD = process.env.DEV_PASSWORD ?? 'menu-dev-password';
const setId = process.argv[2];
const gesture = process.argv[3] ?? 'native';
if (!setId) throw new Error('usage: node .tmp/dnd.mjs <setId> [native|synthetic|none]');
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
const KEY = 'element-6066-11e4-a52e-4f735466cecf';
async function cmd(method, path, body) {
const response = await fetch(GECKO + path, {
method,
headers: body ? { 'content-type': 'application/json' } : {},
body: body ? JSON.stringify(body) : undefined,
});
const json = JSON.parse(await response.text());
if (json.value && json.value.error) throw new Error(`${json.value.error}: ${json.value.message}`);
return json.value;
}
const find = async (sid, selector) =>
(await cmd('POST', `/session/${sid}/element`, { using: 'css selector', value: selector }))[KEY];
const findAll = async (sid, selector) =>
(await cmd('POST', `/session/${sid}/elements`, { using: 'css selector', value: selector })).map(
(e) => e[KEY],
);
const click = (sid, element) => cmd('POST', `/session/${sid}/element/${element}/click`, {});
const type = (sid, element, value) =>
cmd('POST', `/session/${sid}/element/${element}/value`, { text: value });
const script = (sid, body, args = []) =>
cmd('POST', `/session/${sid}/execute/sync`, { script: body, args });
async function pageState(sid) {
return script(
sid,
`return {
rows: Array.from(document.querySelectorAll('[data-page-id]')).map(function (li) {
var handle = li.querySelector('.page-admin__handle');
return {
id: li.getAttribute('data-page-id'),
draggable: li.draggable,
hasHandle: !!handle,
cursor: handle ? getComputedStyle(handle).cursor : null,
formCount: li.querySelectorAll('form').length
};
}),
hasList: !!document.querySelector('[data-pages-admin]'),
scriptLoaded: !!document.querySelector('script[src="/assets/admin.js"]'),
dnd: window.__dnd || null
};`,
);
}
const session = await cmd('POST', '/session', {
capabilities: { alwaysMatch: { 'moz:firefoxOptions': { args: ['-headless'] } } },
});
const sid = session.sessionId;
try {
await cmd('POST', `/session/${sid}/window/rect`, { width: 430, height: 1000 });
await cmd('POST', `/session/${sid}/url`, { url: `${SITE}/admin/login` });
await sleep(500);
await type(sid, await find(sid, 'input[name=password]'), PASSWORD);
await click(sid, await find(sid, 'button[type=submit]'));
await sleep(800);
await cmd('POST', `/session/${sid}/url`, { url: `${SITE}/admin/sets/${setId}` });
await sleep(1000);
await script(
sid,
`window.__dnd = { dragstart: 0, dragover: 0, drop: 0, dragend: 0 };
['dragstart','dragover','drop','dragend'].forEach(function (name) {
document.addEventListener(name, function () { window.__dnd[name] += 1; }, true);
});
return true;`,
);
const before = await pageState(sid);
console.log(`rows: ${before.rows.length}`);
console.log(`draggable rows: ${before.rows.filter((r) => r.draggable).length}`);
console.log(`rows with a grab handle: ${before.rows.filter((r) => r.hasHandle).length}`);
console.log(`handle cursor: ${before.rows[0]?.cursor}`);
console.log(`per-row up/down/delete forms: ${before.rows[0]?.formCount}`);
console.log(`[data-pages-admin] list present: ${before.hasList}`);
console.log(`admin.js included: ${before.scriptLoaded}`);
const beforeIds = before.rows.map((r) => r.id);
if (gesture === 'native' && before.rows.length >= 3) {
const elements = await findAll(sid, '[data-page-id]');
const origin = (element) => ({ [KEY]: element });
const from = elements[0];
const to = elements[2];
await cmd('POST', `/session/${sid}/actions`, {
actions: [
{
type: 'pointer',
id: 'mouse',
parameters: { pointerType: 'mouse' },
actions: [
{ type: 'pointerMove', duration: 0, origin: origin(from), x: 5, y: 5 },
{ type: 'pointerDown', button: 0 },
{ type: 'pause', duration: 200 },
{ type: 'pointerMove', duration: 300, origin: origin(to), x: 5, y: 40 },
{ type: 'pause', duration: 300 },
{ type: 'pointerUp', button: 0 },
],
},
],
});
await sleep(1200);
}
if (gesture === 'synthetic' && before.rows.length >= 3) {
// Exercises our handlers without the browser's own drag machinery.
await script(
sid,
`var rows = document.querySelectorAll('[data-page-id]');
var from = rows[0], to = rows[2];
var rect = to.getBoundingClientRect();
function fire(target, type, extra) {
var event = new DragEvent(type, Object.assign({ bubbles: true, cancelable: true, dataTransfer: new DataTransfer() }, extra || {}));
target.dispatchEvent(event);
}
fire(from, 'dragstart');
fire(to, 'dragover', { clientY: rect.bottom - 2 });
fire(to, 'drop', { clientY: rect.bottom - 2 });
fire(from, 'dragend');
return true;`,
);
await sleep(1200);
}
const after = await pageState(sid);
const afterIds = after.rows.map((r) => r.id);
console.log(`\nevents seen: ${JSON.stringify(after.dnd)}`);
const domChanged = JSON.stringify(beforeIds) !== JSON.stringify(afterIds);
console.log(`DOM order changed by the drag: ${domChanged}`);
if (domChanged) {
console.log(` was: ${beforeIds.slice(0, 3).join(', ')} ...`);
console.log(` now: ${afterIds.slice(0, 3).join(', ')} ...`);
}
// What the server holds now, read back through the editor.
await cmd('POST', `/session/${sid}/url`, { url: `${SITE}/admin/sets/${setId}` });
await sleep(800);
const reloaded = await pageState(sid);
const serverIds = reloaded.rows.map((r) => r.id);
const persisted = JSON.stringify(serverIds) !== JSON.stringify(beforeIds);
console.log(`server order changed (after reload): ${persisted}`);
if (persisted) console.log(` server now: ${serverIds.slice(0, 3).join(', ')} ...`);
console.log(`\nVERDICT: ${domChanged && persisted ? 'PASS' : 'FAIL'} (drag reorders and persists)`);
} finally {
await cmd('DELETE', `/session/${sid}`);
}
+32 -15
View File
@@ -157,6 +157,8 @@ export interface EditorInput {
section: SectionConfig; section: SectionConfig;
set: StoredSet; set: StoredSet;
csrf: string; csrf: string;
/** True when this set is the one customers are being served right now. */
current: boolean;
notice: string | null; notice: string | null;
error: string | null; error: string | null;
} }
@@ -164,18 +166,21 @@ export interface EditorInput {
export function renderEditor(input: EditorInput): Html { export function renderEditor(input: EditorInput): Html {
const { config, section, set } = input; const { config, section, set } = input;
const draft = set.state === 'draft'; const draft = set.state === 'draft';
// A published set's Pages are fixed, but its order can still be corrected
// while it is the Current one. Archives are read-only.
const reorderable = draft || input.current;
const pageItems = set.manifest.pages.map((page, index) => { const pageItems = set.manifest.pages.map((page, index) => {
const names = variantFileNames(page); const names = variantFileNames(page);
return html`<li class="page-admin" draggable="${draft ? 'true' : 'false'}" data-page-id="${page.id}"> return html`<li class="page-admin" draggable="${reorderable ? 'true' : 'false'}" data-page-id="${page.id}">
<span class="page-admin__handle" aria-hidden="true">⠿</span> ${reorderable ? html`<span class="page-admin__handle" aria-hidden="true">⠿</span>` : ''}
<img src="/media/${names.thumb}" alt="" width="64" /> <img src="/media/${names.thumb}" alt="" width="64" />
<span class="page-admin__meta"> <span class="page-admin__meta">
<span class="page-admin__number">${index + 1}</span> <span class="page-admin__number">${index + 1}</span>
<span class="page-admin__name">${page.originalName}</span> <span class="page-admin__name">${page.originalName}</span>
<span class="page-admin__dims">${page.width} × ${page.height}</span> <span class="page-admin__dims">${page.width} × ${page.height}</span>
</span> </span>
${draft ${reorderable
? html`<span class="page-admin__actions"> ? html`<span class="page-admin__actions">
<form method="post" action="/admin/sets/${set.id}/move"> <form method="post" action="/admin/sets/${set.id}/move">
<input type="hidden" name="csrf" value="${input.csrf}" /> <input type="hidden" name="csrf" value="${input.csrf}" />
@@ -189,10 +194,12 @@ export function renderEditor(input: EditorInput): Html {
<input type="hidden" name="direction" value="down" /> <input type="hidden" name="direction" value="down" />
<button class="button button--icon" type="submit" title="Move down">↓</button> <button class="button button--icon" type="submit" title="Move down">↓</button>
</form> </form>
<form method="post" action="/admin/sets/${set.id}/pages/${page.id}/delete"> ${draft
<input type="hidden" name="csrf" value="${input.csrf}" /> ? html`<form method="post" action="/admin/sets/${set.id}/pages/${page.id}/delete">
<button class="button button--icon button--danger" type="submit" title="Remove page">✕</button> <input type="hidden" name="csrf" value="${input.csrf}" />
</form> <button class="button button--icon button--danger" type="submit" title="Remove page">✕</button>
</form>`
: ''}
</span>` </span>`
: ''} : ''}
</li>`; </li>`;
@@ -212,12 +219,20 @@ export function renderEditor(input: EditorInput): Html {
${input.notice === null ? '' : html`<p class="notice">${input.notice}</p>`} ${input.notice === null ? '' : html`<p class="notice">${input.notice}</p>`}
${input.error === null ? '' : html`<p class="notice notice--error">${input.error}</p>`} ${input.error === null ? '' : html`<p class="notice notice--error">${input.error}</p>`}
${set.state === 'published' ${set.state === 'draft'
? html`<p class="notice"> ? ''
This menu is published at <a href="/${section.slug}/${set.slug}/">/${section.slug}/${set.slug}/</a>. : input.current
A published menu never changes; to change it, create a new Menu Set. ? html`<p class="notice">
</p>` This is the menu customers are seeing at
: ''} <a href="/${section.slug}/">/${section.slug}/</a>. Its Pages are fixed, but you can drag them
into a different order and it takes effect straight away. To add, remove or replace a Page,
create a new Menu Set.
</p>`
: html`<p class="notice">
This is an older menu, kept as it was at
<a href="/${section.slug}/${set.slug}/">/${section.slug}/${set.slug}/</a>. Archived menus cannot
be changed; press "Make current" to put it back in front of customers.
</p>`}
<section class="block"> <section class="block">
<h2 class="block__title">Details</h2> <h2 class="block__title">Details</h2>
@@ -253,10 +268,12 @@ export function renderEditor(input: EditorInput): Html {
<ul class="pages-admin" data-pages-admin> <ul class="pages-admin" data-pages-admin>
${pageItems.length === 0 ? html`<li class="setlist__none">No pages yet.</li>` : join(pageItems)} ${pageItems.length === 0 ? html`<li class="setlist__none">No pages yet.</li>` : join(pageItems)}
</ul> </ul>
<p class="drop__status" data-order-status role="status"></p>
</section>` </section>`
: html`<section class="block"> : html`<section class="block">
<h2 class="block__title">Pages</h2> <h2 class="block__title">Pages</h2>
<ul class="pages-admin">${join(pageItems)}</ul> <ul class="pages-admin" ${reorderable ? html`data-pages-admin` : ''}>${join(pageItems)}</ul>
<p class="drop__status" data-order-status role="status"></p>
</section>`} </section>`}
<section class="block"> <section class="block">
@@ -271,7 +288,7 @@ export function renderEditor(input: EditorInput): Html {
</form> </form>
<a class="button" href="/admin/sets/${set.id}/preview">Preview</a>` <a class="button" href="/admin/sets/${set.id}/preview">Preview</a>`
: html`<a class="button" href="/${section.slug}/${set.slug}/">View live</a>`} : html`<a class="button" href="/${section.slug}/${set.slug}/">View live</a>`}
${set.state === 'published' ${set.state === 'published' && !input.current
? html`<form method="post" action="/admin/sets/${set.id}/restore"> ? html`<form method="post" action="/admin/sets/${set.id}/restore">
<input type="hidden" name="csrf" value="${input.csrf}" /> <input type="hidden" name="csrf" value="${input.csrf}" />
<button class="button" type="submit">Make current</button> <button class="button" type="submit">Make current</button>
+5 -1
View File
@@ -44,7 +44,9 @@ export interface AdminRouteDeps {
} }
function sendHtml(reply: FastifyReply, body: string, status = 200): FastifyReply { function sendHtml(reply: FastifyReply, body: string, status = 200): FastifyReply {
return reply.code(status).type(HTML).send(body); // Admin pages carry a CSRF token and one-off notices, so never let a browser
// keep them.
return reply.code(status).header('cache-control', 'no-store').type(HTML).send(body);
} }
/** Same-origin check: the second belt beside the CSRF token. */ /** Same-origin check: the second belt beside the CSRF token. */
@@ -212,6 +214,7 @@ export async function registerAdminRoutes(app: FastifyInstance, deps: AdminRoute
const section = sectionBySlug(config, set.section); const section = sectionBySlug(config, set.section);
if (section === undefined) return reply.code(404).send('Unknown Section.'); if (section === undefined) return reply.code(404).send('Unknown Section.');
const query = request.query as { notice?: string; error?: string }; const query = request.query as { notice?: string; error?: string };
const current = (await store.currentBySection()).get(set.section);
return sendHtml( return sendHtml(
reply, reply,
renderEditor({ renderEditor({
@@ -219,6 +222,7 @@ export async function registerAdminRoutes(app: FastifyInstance, deps: AdminRoute
section, section,
set, set,
csrf: csrfToken(sessionToken(request), deps.sessionSecret), csrf: csrfToken(sessionToken(request), deps.sessionSecret),
current: current?.manifest.id === set.id,
notice: query.notice ?? null, notice: query.notice ?? null,
error: query.error ?? null, error: query.error ?? null,
}).value, }).value,
+3 -1
View File
@@ -75,7 +75,9 @@ export async function buildApp(options: AppOptions): Promise<FastifyInstance> {
prefix: '/assets/', prefix: '/assets/',
index: false, index: false,
cacheControl: true, cacheControl: true,
maxAge: '1h', // Short, because a deploy that fixes admin.js must not wait an hour to be
// picked up. Media keeps its own immutable caching in the media route.
maxAge: '5m',
}); });
const store = new SetStore({ const store = new SetStore({
+15 -1
View File
@@ -276,9 +276,23 @@ export class SetStore {
}); });
} }
/**
* Reordering is allowed on a Draft and on the Current Menu Set, because order
* is presentation rather than content: media URLs are content-hashed and
* pages are rendered per request, so nothing stale can be served by moving a
* Page. Archived sets stay frozen.
*/
private async requireReorderable(setId: string): Promise<StoredSet> {
const set = await this.get(setId);
if (set.state === 'draft') return set;
const current = (await this.currentBySection()).get(set.section);
if (current?.manifest.id === set.id) return set;
throw new StoreError('only a Draft or the Current Menu Set can be reordered', 409);
}
async reorder(setId: string, pageIds: readonly string[]): Promise<void> { async reorder(setId: string, pageIds: readonly string[]): Promise<void> {
await this.exclusive(async () => { await this.exclusive(async () => {
const set = await this.requireDraft(setId); const set = await this.requireReorderable(setId);
const before = set.manifest.pages.map((page) => page.id); const before = set.manifest.pages.map((page) => page.id);
const after = [...pageIds]; const after = [...pageIds];
if (before.length !== after.length) { if (before.length !== after.length) {
+78
View File
@@ -673,6 +673,84 @@ describe('forms as a browser sends them', () => {
}); });
}); });
describe('reordering', () => {
async function publishBar(label = 'Bar Menu', validUntil: string | null = null) {
const { id } = await newSet('bar', label, validUntil);
await upload(id, 'one.jpg');
await upload(id, 'two.jpg');
const response = await app.inject({
method: 'POST',
url: `/admin/sets/${id}/publish`,
cookies: cookies(),
headers: { 'x-csrf': csrf },
});
assert.equal(response.statusCode, 303);
return id;
}
it('offers dragging on the Current set and the new order reaches customers', async () => {
const id = await publishBar('Bar Menu', '2026-06-26');
const editor = await app.inject({ method: 'GET', url: `/admin/sets/${id}`, cookies: cookies() });
assert.equal(editor.statusCode, 200);
assert.match(editor.body, /data-pages-admin/);
assert.match(editor.body, /page-admin__handle/);
assert.equal((editor.body.match(/draggable="true"/g) ?? []).length, 2);
assert.match(editor.body, /the menu customers are seeing/);
assert.doesNotMatch(editor.body, /Make current/);
const before = (await app.menu.store.get(id)).manifest.pages.map((page) => page.id);
const reorder = await app.inject({
method: 'POST',
url: `/admin/sets/${id}/reorder`,
cookies: cookies(),
headers: { 'x-csrf': csrf, accept: 'application/json' },
payload: { pageIds: [before[1], before[0]] },
});
assert.equal(reorder.statusCode, 200);
const live = await app.inject({ method: 'GET', url: '/bar/' });
const served = [...live.body.matchAll(/\/media\/([0-9a-f-]{36})\./g)].map((match) => match[1]);
assert.deepEqual([...new Set(served)], [before[1], before[0]]);
});
it('keeps an Archive read-only, with no handle to pull', async () => {
const archived = await publishBar('Bar Menu', '2026-06-26');
CLOCK.advance(60_000);
const next = await newSet('bar', 'Bar Menu, next');
await upload(next.id, 'two.jpg');
await app.inject({
method: 'POST',
url: `/admin/sets/${next.id}/publish`,
cookies: cookies(),
headers: { 'x-csrf': csrf },
});
const editor = await app.inject({
method: 'GET',
url: `/admin/sets/${archived}`,
cookies: cookies(),
});
assert.equal(editor.statusCode, 200);
assert.match(editor.body, /This is an older menu/);
assert.doesNotMatch(editor.body, /page-admin__handle/);
assert.doesNotMatch(editor.body, /data-pages-admin/);
assert.equal((editor.body.match(/draggable="false"/g) ?? []).length, 2);
assert.match(editor.body, /Make current/);
const pages = (await app.menu.store.get(archived)).manifest.pages.map((page) => page.id);
const reorder = await app.inject({
method: 'POST',
url: `/admin/sets/${archived}/reorder`,
cookies: cookies(),
headers: { 'x-csrf': csrf, accept: 'application/json' },
payload: { pageIds: [pages[1], pages[0]] },
});
assert.equal(reorder.statusCode, 409);
});
});
describe('previewing a Draft', () => { describe('previewing a Draft', () => {
it('renders the customer page with a banner, and serves its media to the session', async () => { it('renders the customer page with a banner, and serves its media to the session', async () => {
const { id } = await newSet('bar', 'Bar Menu', '2026-06-26'); const { id } = await newSet('bar', 'Bar Menu', '2026-06-26');
+37 -2
View File
@@ -194,18 +194,53 @@ describe('publishing', () => {
await assert.rejects(() => store.publish('missing'), StoreError); await assert.rejects(() => store.publish('missing'), StoreError);
}); });
it('freezes a published set against editing', async () => { it('freezes a published set against adding, removing and relabelling', async () => {
const { store } = makeStore(dataDir); const { store } = makeStore(dataDir);
const set = await store.create('bar', { label: 'Bar Menu' }); const set = await store.create('bar', { label: 'Bar Menu' });
const page = await store.addPage(set.id, { data: pngBytes('one'), originalName: 'a.jpg' }); const page = await store.addPage(set.id, { data: pngBytes('one'), originalName: 'a.jpg' });
await store.publish(set.id); await store.publish(set.id);
await assert.rejects(() => store.addPage(set.id, { data: pngBytes('two'), originalName: 'b.jpg' }), StoreError); await assert.rejects(() => store.addPage(set.id, { data: pngBytes('two'), originalName: 'b.jpg' }), StoreError);
await assert.rejects(() => store.reorder(set.id, [page.id]), StoreError);
await assert.rejects(() => store.update(set.id, { label: 'New' }), StoreError); await assert.rejects(() => store.update(set.id, { label: 'New' }), StoreError);
await assert.rejects(() => store.removePage(set.id, page.id), StoreError); await assert.rejects(() => store.removePage(set.id, page.id), StoreError);
}); });
it('lets the Current set be reordered, because order is presentation not content', async () => {
const { store } = makeStore(dataDir);
const set = await store.create('bar', { label: 'Bar Menu' });
const first = await store.addPage(set.id, { data: pngBytes('one'), originalName: 'a.jpg' });
const second = await store.addPage(set.id, { data: pngBytes('two'), originalName: 'b.jpg' });
const published = await store.publish(set.id);
await store.reorder(published.id, [second.id, first.id]);
const current = (await store.currentBySection()).get('bar');
assert.equal(current?.manifest.id, published.id);
assert.deepEqual(
current?.manifest.pages.map((page) => page.id),
[second.id, first.id],
);
assert.equal(current?.slug, '2026-01-01');
});
it('refuses to reorder an Archive', async () => {
const clock = testClock('2026-01-05T04:00:00.000Z');
const { store } = makeStore(dataDir, { clock });
const first = await store.create('bar', { label: 'Bar Menu' });
const page = await store.addPage(first.id, { data: pngBytes('one'), originalName: 'a.jpg' });
await store.publish(first.id);
clock.advance(60_000);
const second = await store.create('bar', { label: 'Bar Menu, next' });
await store.addPage(second.id, { data: pngBytes('two'), originalName: 'b.jpg' });
await store.publish(second.id);
await assert.rejects(
() => store.reorder(first.id, [page.id]),
(error: unknown) => error instanceof StoreError && error.status === 409,
);
});
it('restores an archived set to Current without changing its URL', async () => { it('restores an archived set to Current without changing its URL', async () => {
const clock = testClock('2026-01-05T04:00:00.000Z'); const clock = testClock('2026-01-05T04:00:00.000Z');
const { store } = makeStore(dataDir, { clock }); const { store } = makeStore(dataDir, { clock });