Fix notebook draft preservation and pagination validation

Commit b36177c · patx · 2026-10-19T03:18:51-04:00

Changeset
b36177cdabb17d5ef782b8b5eb696f1dac05fe2a
Parents
fd2e0c27c1860ad9ed99a51a34dda04bef5ada27

View source at this commit

Comments

No comments yet.

Log in to comment

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);
+});