diff --git a/web/e2e/smoke.spec.js b/web/e2e/smoke.spec.js index 00450e3..6dba626 100644 --- a/web/e2e/smoke.spec.js +++ b/web/e2e/smoke.spec.js @@ -1900,3 +1900,54 @@ test('tallet ved et projekt er opgaver, og sidebjælken kan foldes med tastature expect(trouble).toEqual([]); }); + +test('en note kan skrives, findes igen, og peger på det den nævner', async ({ page }) => { + const trouble = watchForTrouble(page); + await page.goto('/'); + + const sidebar = page.getByRole('navigation', { name: 'Hovedmenu' }); + await sidebar.getByRole('button', { name: 'Nyt projekt' }).click(); + await sidebar.getByLabel('Projektnavn').fill('Notetesteriet'); + await sidebar.getByLabel('Projektnavn').press('Enter'); + await expect(sidebar.getByRole('link', { name: 'Notetesteriet' })).toBeVisible(); + + await sidebar.getByRole('link', { name: 'Noter' }).click(); + await expect(page.getByRole('heading', { name: 'Noter' })).toBeVisible(); + + await page.getByRole('button', { name: 'Ny note' }).click(); + const body = page.getByLabel('Notens tekst'); + await body.fill('Møde om #Notetesteriet\n\nHan vil gerne have kaffe hver uge.'); + // Saved on the way out, so there is no Gem-knap to forget. + await body.blur(); + + // The title comes from the first line, the way Apple Notes does it. Without it + // a list of notes is a list of "Uden titel". + await expect(page.getByRole('button', { name: /Møde om/ })).toBeVisible(); + + // And the #tag was read out of the text, unasked. This is the whole point: it + // is the same tag a task carries, so the note can be found from the project. + // Scoped to the panel under the editor: the same text is also in the textarea + // and in the list preview, which is three of the same thing and none of them + // the one being asserted. + await expect(page.locator('.link', { hasText: '#Notetesteriet' })).toBeVisible(); + + // Findable by a word from the body, and in the other Danish spelling too. + await page.getByLabel('Søg i noter').fill('møde'); + await expect(page.getByRole('button', { name: /Møde om/ })).toBeVisible(); + await page.getByLabel('Søg i noter').fill('mode'); + await expect(page.getByRole('button', { name: /Møde om/ })).toBeVisible(); + + // It survives a reload, which is the only proof that it was ever written down. + await page.reload(); + await expect(page.getByRole('button', { name: /Møde om/ })).toBeVisible(); + + // And the payoff: the note turns up in the project it named, without anybody + // filing it there. This is what a #tag being the same tag actually buys. + await sidebar.getByRole('link', { name: 'Notetesteriet' }).click(); + await expect(page.getByRole('heading', { name: 'Notetesteriet' })).toBeVisible(); + await expect( + page.locator('.notes').getByRole('link', { name: /Møde om/ }) + ).toBeVisible(); + + expect(trouble).toEqual([]); +}); diff --git a/web/src/lib/api.js b/web/src/lib/api.js index ab41c91..8846b96 100644 --- a/web/src/lib/api.js +++ b/web/src/lib/api.js @@ -360,6 +360,18 @@ export const api = { authorizeGmail: () => post('/gmail/authorize'), syncGmail: () => post('/gmail/sync'), + // Notes. Listing and searching are one endpoint because they answer the same + // question with different words. + notes: (params = {}) => { + const q = new URLSearchParams(Object.entries(params).filter(([, v]) => v)).toString(); + return get('/notes' + (q ? `?${q}` : '')); + }, + note: (id) => get(`/notes/${id}`), + createNote: (body) => post('/notes', body), + updateNote: (id, body) => patch(`/notes/${id}`, body), + deleteNote: (id) => del(`/notes/${id}`), + notesLinking: (kind, targetId) => get(`/notes/linking/${kind}/${encodeURIComponent(targetId)}`), + // Mailboxes read over IMAP. Each belongs to the person who connected it, so // there is no instance-wide registration behind these the way Gmail has one. mailboxes: () => get('/mailboxes'), diff --git a/web/src/lib/components/Sidebar.svelte b/web/src/lib/components/Sidebar.svelte index 0b576e3..fb3c99f 100644 --- a/web/src/lib/components/Sidebar.svelte +++ b/web/src/lib/components/Sidebar.svelte @@ -379,6 +379,16 @@ {#if app.inbox} {@render projectRow(app.inbox, false)} + + + + + + {t('notes.title')} + + {/if} diff --git a/web/src/lib/locales/da.js b/web/src/lib/locales/da.js index 47b9cce..793a090 100644 --- a/web/src/lib/locales/da.js +++ b/web/src/lib/locales/da.js @@ -16,6 +16,19 @@ */ export const da = { // --- the shell --------------------------------------------------------------- + 'notes.title': 'Noter', + 'notes.new': 'Ny note', + 'notes.search': 'Søg i noter', + 'notes.body': 'Notens tekst', + 'notes.placeholder': 'Skriv. #projekt og [[en anden note]] bliver til henvisninger.', + 'notes.untitled': 'Uden titel', + 'notes.noneYet': 'Ingen noter endnu.', + 'notes.noneFound': 'Ingen noter matcher.', + 'notes.pickOne': 'Vælg en note, eller lav en ny.', + 'notes.saving': 'Gemmer …', + 'notes.saved': 'Gemt', + 'notes.delete': 'Slet', + 'notes.deleteNote': 'Slet "{name}"? Den kan hentes tilbage fra papirkurven.', 'nav.today': 'I dag', 'nav.upcoming': 'Kommende', 'nav.delegated': 'Venter på andre', diff --git a/web/src/lib/locales/en.js b/web/src/lib/locales/en.js index 69dc5d2..7fb9526 100644 --- a/web/src/lib/locales/en.js +++ b/web/src/lib/locales/en.js @@ -12,6 +12,19 @@ */ export const en = { // --- the shell --------------------------------------------------------------- + 'notes.title': 'Notes', + 'notes.new': 'New note', + 'notes.search': 'Search notes', + 'notes.body': "The note's text", + 'notes.placeholder': 'Write. #project and [[another note]] become references.', + 'notes.untitled': 'Untitled', + 'notes.noneYet': 'No notes yet.', + 'notes.noneFound': 'No notes match.', + 'notes.pickOne': 'Pick a note, or write a new one.', + 'notes.saving': 'Saving …', + 'notes.saved': 'Saved', + 'notes.delete': 'Delete', + 'notes.deleteNote': 'Delete "{name}"? It can be brought back from the trash.', 'nav.today': 'Today', 'nav.upcoming': 'Upcoming', 'nav.delegated': 'Waiting on others', diff --git a/web/src/routes/noter/+page.svelte b/web/src/routes/noter/+page.svelte new file mode 100644 index 0000000..c389122 --- /dev/null +++ b/web/src/routes/noter/+page.svelte @@ -0,0 +1,326 @@ + + +{t('notes.title')} · verdande + +
+ + +
+ {#if selected} + + +
+ {saving ? t('notes.saving') : t('notes.saved')} + {#if links.length} + + {#each links as link} + {link.kind === 'project' ? '#' : ''}{link.target_id} + {/each} + + {/if} + +
+ {:else} +

{t('notes.pickOne')}

+ {/if} +
+
+ + diff --git a/web/src/routes/projekt/[id]/+page.svelte b/web/src/routes/projekt/[id]/+page.svelte index c838ae8..4080308 100644 --- a/web/src/routes/projekt/[id]/+page.svelte +++ b/web/src/routes/projekt/[id]/+page.svelte @@ -43,6 +43,23 @@ load(id); }); + // Notes that mention this project. Looked up by name rather than by id, because + // a #tag is written by hand and a person types the name — the same string the + // quick-add parser reads out of a task. + let notes = $state([]); + + $effect(() => { + const name = project?.name; + if (!name) { + notes = []; + return; + } + api + .notesLinking('project', name) + .then((r) => (notes = r.notes ?? [])) + .catch(() => (notes = [])); + }); + async function load(projectId) { if (!projectId) return; status = 'loading'; @@ -793,6 +810,24 @@ {#if project.role === 'viewer'}

{t('project.readOnly')}

{/if} + + {#if notes.length} +
+

{t('notes.title')}

+ +
+ {/if} {:else if status === 'loading'} @@ -809,6 +844,53 @@