From d4d388462d6a4595e636d986873ddfc8134265d3 Mon Sep 17 00:00:00 2001 From: Bakhomious Basta Date: Fri, 30 Oct 2026 03:14:40 +0100 Subject: [PATCH 1/2] feat(notes): save a note from its editor Save, or Cmd/Ctrl+S, sends the editor's text with PUT and If-Match naming the version the page loaded, the note's ETag. A 204 shows the saved note at once and says so; a 412, from a note changed since it loaded, keeps the editor and the text and says so; any other failure keeps them and says the save failed. While the save runs, the editor takes no typing and Save and Cancel wait. Decision 402 now also records where Save sits, the status line, and why the note renders before the PUT. --- apps/web/e2e/notes-edit.spec.ts | 65 +++++++- apps/web/package.json | 1 + apps/web/pnpm-lock.yaml | 3 + apps/web/src/notes/NoteEditor.svelte | 10 +- apps/web/src/notes/NotePage.save.test.ts | 88 +++++++++++ apps/web/src/notes/NotePage.svelte | 24 ++- apps/web/src/notes/api.test.ts | 57 ++++++- apps/web/src/notes/api.ts | 35 ++++- apps/web/src/notes/editor.test.ts | 51 +++++- apps/web/src/notes/editor.ts | 36 ++++- apps/web/src/notes/labels.test.ts | 13 +- apps/web/src/notes/labels.ts | 17 ++ apps/web/src/notes/shown.svelte.ts | 90 +++++++++-- apps/web/src/notes/shown.test.ts | 190 +++++++++++++++++++++++ docs/plan.md | 2 +- 15 files changed, 645 insertions(+), 37 deletions(-) create mode 100644 apps/web/src/notes/NotePage.save.test.ts create mode 100644 apps/web/src/notes/shown.test.ts diff --git a/apps/web/e2e/notes-edit.spec.ts b/apps/web/e2e/notes-edit.spec.ts index 104209b..db97332 100644 --- a/apps/web/e2e/notes-edit.spec.ts +++ b/apps/web/e2e/notes-edit.spec.ts @@ -1,3 +1,5 @@ +import type { Page, Request } from '@playwright/test'; + import { home } from './demo'; import { expect, test } from './fixtures'; @@ -7,7 +9,7 @@ const riff = '---\ntags: [guitar]\n---\n# Riff\n\nPlay it **slow**.\n'; test.beforeEach(async ({ page }) => { await page.route('/api/home', (route) => route.fulfill({ json: home })); await page.route('/api/notes/files/Songs/Riff.md', (route) => - route.fulfill({ body: riff, contentType: 'text/markdown; charset=utf-8' }) + route.fulfill({ body: riff, contentType: 'text/markdown; charset=utf-8', headers: { ETag: '"1"' } }) ); }); @@ -45,3 +47,64 @@ test('a note offers no Edit while the notes service saves no note', async ({ pag await expect(page.getByRole('article')).toContainText('Play it slow.'); await expect(page.getByRole('button', { name: 'Edit' })).toHaveCount(0); }); + +// Answers the note's saves with status, and keeps their requests. +async function saves(page: Page, status: number): Promise { + const requests: Request[] = []; + await page.route('/api/notes/files/Songs/Riff.md', (route) => { + if (route.request().method() !== 'PUT') { + return route.fallback(); + } + requests.push(route.request()); + return route.fulfill({ status, headers: status === 204 ? { ETag: '"2"' } : {} }); + }); + return requests; +} + +// The note's page from a notes service that saves notes, with the note in the editor and " Then fast." typed in it. +async function typed(page: Page) { + await page.route('/api/notes', (route) => route.fulfill({ json: { files, editable: true } })); + await page.goto('/notes/Songs/Riff'); + await page.getByRole('button', { name: 'Edit' }).click(); + const line = page.getByRole('textbox', { name: 'Markdown' }).locator('.cm-line').nth(5); + await line.click(); + await page.keyboard.press('End'); + await page.keyboard.type(' Then fast.'); + return line; +} + +test('Save sends the note with the version it loaded, then shows the note and says it saved', async ({ page }) => { + const requests = await saves(page, 204); + await typed(page); + + await page.getByRole('button', { name: 'Save' }).click(); + + await expect(page.getByRole('article')).toContainText('Play it slow. Then fast.'); + await expect(page.getByText('Saved', { exact: true })).toBeVisible(); + await expect(page.getByRole('button', { name: 'Edit' })).toBeFocused(); + expect(requests).toHaveLength(1); + expect(requests[0]?.headers()).toMatchObject({ 'if-match': '"1"', 'content-type': 'text/markdown; charset=utf-8' }); + expect(requests[0]?.postData()).toBe(riff.replace('**slow**.', '**slow**. Then fast.')); +}); + +test('a note changed since it loaded keeps the editor and the text, and says so', async ({ page }) => { + await saves(page, 412); + const line = await typed(page); + + await page.getByRole('button', { name: 'Save' }).click(); + + await expect(page.getByText('Not saved: the note has changed since it loaded')).toBeVisible(); + await expect(line).toHaveText('Play it **slow**. Then fast.'); + await expect(page.getByRole('article')).toHaveCount(0); +}); + +test('⌘S, or Ctrl+S, in the editor saves', async ({ page }, testInfo) => { + test.skip(testInfo.project.name === 'phone', 'a phone has no ⌘ or Ctrl key without a keyboard'); + const requests = await saves(page, 204); + await typed(page); + + await page.keyboard.press('ControlOrMeta+s'); + + await expect(page.getByText('Saved', { exact: true })).toBeVisible(); + expect(requests).toHaveLength(1); +}); diff --git a/apps/web/package.json b/apps/web/package.json index 79c4303..dbae8f8 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -17,6 +17,7 @@ "@codemirror/lang-markdown": "6.5.2", "@codemirror/lang-yaml": "6.1.3", "@codemirror/language": "6.13.1", + "@codemirror/state": "6.7.6", "@codemirror/view": "6.43.15", "@fontsource-variable/jetbrains-mono": "5.3.0", "@fontsource-variable/space-grotesk": "5.3.0", diff --git a/apps/web/pnpm-lock.yaml b/apps/web/pnpm-lock.yaml index 2994525..aef4aa3 100644 --- a/apps/web/pnpm-lock.yaml +++ b/apps/web/pnpm-lock.yaml @@ -24,6 +24,9 @@ importers: '@codemirror/language': specifier: 6.13.1 version: 6.13.1 + '@codemirror/state': + specifier: 6.7.6 + version: 6.7.6 '@codemirror/view': specifier: 6.43.15 version: 6.43.15 diff --git a/apps/web/src/notes/NoteEditor.svelte b/apps/web/src/notes/NoteEditor.svelte index 5130e48..ffaf890 100644 --- a/apps/web/src/notes/NoteEditor.svelte +++ b/apps/web/src/notes/NoteEditor.svelte @@ -1,12 +1,12 @@ -
+