patx/wred
Fix notebook draft preservation and pagination validation
Commit b36177c · patx · 2026-10-19T03:18:51-04:00
Comments
No comments yet.
Diff
diff --git a/example_app/notes.py b/example_app/notes.py
index ee77e7c..3bcedf7 100644
--- a/example_app/notes.py
+++ b/example_app/notes.py
@@ -81,11 +81,13 @@ def register_routes(app, database):
'application/json',
400,
)
- if not 1 <= limit <= 50 or offset < 0:
+ # The offset is echoed in JSON, whose serializer supports uint64.
+ if not 1 <= limit <= 50 or not 0 <= offset < 2**64:
return (
{
'error': (
- 'Limit must be 1–50; offset must be zero or greater'
+ 'Limit must be 1–50; offset must be between '
+ '0 and 18446744073709551615'
)
},
'application/json',
diff --git a/example_app/static/app.js b/example_app/static/app.js
index c7a4405..7f19135 100644
--- a/example_app/static/app.js
+++ b/example_app/static/app.js
@@ -158,11 +158,11 @@ function updateDraftState() {
$('save-state').textContent = dirty() ? 'Unsaved changes' : state.selected ? 'Saved' : 'Ready when you are';
}
-function showNote(note) {
+function showNote(note, draft = note) {
state.selected = note ? structuredClone(note) : null;
- $('note-title').value = note?.title || '';
- $('note-body').value = note?.body || '';
- $('note-pinned').checked = note?.pinned || false;
+ $('note-title').value = draft?.title || '';
+ $('note-body').value = draft?.body || '';
+ $('note-pinned').checked = draft?.pinned || false;
$('editor-heading').textContent = note ? 'Edit note' : 'New note';
$('save-note').textContent = note ? 'Save changes' : 'Save note';
$('discard').textContent = note ? 'Discard changes' : 'Clear draft';
@@ -249,14 +249,21 @@ $('note-form').addEventListener('input', updateDraftState);
$('note-form').addEventListener('submit', event => {
event.preventDefault();
const id = state.selected?.id;
+ const sequence = selectionSequence;
const values = { title: $('note-title').value, body: $('note-body').value, pinned: $('note-pinned').checked };
action(async () => {
const { data } = await api(id ? `/api/notes/${encodeURIComponent(id)}` : '/api/notes', { method: id ? 'PATCH' : 'POST', data: values });
- selectionSequence++;
- showNote(data);
+ if (sequence === selectionSequence) {
+ // Keep edits made during the save, but retain the saved ID and baseline.
+ const draft = { title: $('note-title').value, body: $('note-body').value, pinned: $('note-pinned').checked };
+ for (const key of Object.keys(draft)) {
+ if (draft[key] === values[key]) draft[key] = data[key];
+ }
+ showNote(data, draft);
+ notice(dirty() ? 'Note saved. Newer edits are unsaved.' : 'Note saved.');
+ }
state.offset = 0;
await loadNotes();
- notice('Note saved.');
}, $('save-note'));
});
diff --git a/tests/test_notebook.py b/tests/test_notebook.py
index 58b18ef..0110983 100644
--- a/tests/test_notebook.py
+++ b/tests/test_notebook.py
@@ -499,6 +499,8 @@ asyncio.run(main())
'limit=0',
'limit=51',
'offset=-1',
+ 'offset=18446744073709551616',
+ 'offset=' + '9' * 100,
'pinned=maybe',
'&'.join(f'x={i}' for i in range(21)),
]:
diff --git a/tests/test_notebook_ui.cjs b/tests/test_notebook_ui.cjs
new file mode 100644
index 0000000..5c0811c
--- /dev/null
+++ b/tests/test_notebook_ui.cjs
@@ -0,0 +1,90 @@
+const assert = require('node:assert/strict');
+const { readFileSync } = require('node:fs');
+const { test } = require('node:test');
+const vm = require('node:vm');
+
+const source = readFileSync('example_app/static/app.js', 'utf8');
+const tick = () => new Promise(resolve => setImmediate(resolve));
+
+async function notebook() {
+ const elements = new Map();
+ const element = id => {
+ if (!elements.has(id)) elements.set(id, {
+ value: '', checked: false, hidden: false, listeners: {},
+ classList: { toggle() {} }, setAttribute() {}, removeAttribute() {},
+ replaceChildren() {}, append() {}, focus() {},
+ addEventListener(type, callback) { this.listeners[type] = callback; },
+ });
+ return elements.get(id);
+ };
+ const writes = [];
+ const response = data => ({ status: 200, ok: true, headers: new Headers(), json: async () => data });
+ const context = vm.createContext({
+ document: { getElementById: element, createElement: () => element(Symbol()) },
+ window: { confirm: () => true },
+ structuredClone, URLSearchParams, DOMException, setTimeout, clearTimeout,
+ fetch: async (path, options) => {
+ if (path === '/api/refresh') return response({ access_token: 'token', user: { id: 'alice' } });
+ if (path === '/api/me') return response({ id: 'alice' });
+ if (options.method === 'POST' || options.method === 'PATCH') {
+ return new Promise(resolve => writes.push({ path, options, resolve }));
+ }
+ return response({ items: [], total: 0 });
+ },
+ });
+ vm.runInContext(source, context);
+ await tick();
+ return {
+ element, writes,
+ submit() { element('note-form').listeners.submit({ preventDefault() {} }); },
+ async finishSave(index) {
+ const write = writes[index];
+ write.resolve(response({
+ ...JSON.parse(write.options.body), id: 'saved-note',
+ updated_at: '2026-01-01T00:00:00Z',
+ }));
+ await tick();
+ },
+ };
+}
+
+for (const editingExisting of [false, true]) {
+ test(`a delayed ${editingExisting ? 'update' : 'create'} preserves newer edits and the saved identity`, async () => {
+ const ui = await notebook();
+ if (editingExisting) {
+ ui.element('note-title').value = 'Original';
+ ui.submit();
+ await ui.finishSave(0);
+ }
+ const index = ui.writes.length;
+ ui.element('note-title').value = 'Submitted title';
+ ui.submit();
+ ui.element('note-title').value = 'Newer title';
+ ui.element('note-body').value = 'Newer body';
+ ui.element('note-pinned').checked = true;
+ await ui.finishSave(index);
+ assert.equal(ui.element('note-title').value, 'Newer title');
+ assert.equal(ui.element('note-body').value, 'Newer body');
+ assert.equal(ui.element('note-pinned').checked, true);
+ assert.equal(ui.element('save-state').textContent, 'Unsaved changes');
+ ui.submit();
+ assert.equal(ui.writes[index + 1].path, '/api/notes/saved-note');
+ assert.equal(ui.writes[index + 1].options.method, 'PATCH');
+ await ui.finishSave(index + 1);
+ assert.equal(ui.element('save-state').textContent, 'Saved');
+ });
+}
+
+test('a delayed save cannot replace a different draft opened while waiting', async () => {
+ const ui = await notebook();
+ ui.element('note-title').value = 'Submitted title';
+ ui.submit();
+ ui.element('new-note').listeners.click();
+ ui.element('note-title').value = 'Different draft';
+ await ui.finishSave(0);
+ assert.equal(ui.element('note-title').value, 'Different draft');
+ ui.submit();
+ assert.equal(ui.writes[1].path, '/api/notes');
+ assert.equal(ui.writes[1].options.method, 'POST');
+ await ui.finishSave(1);
+});