From 1bbae698d504d87979f0a4c07ed34110413c7956 Mon Sep 17 00:00:00 2001 From: Kristian Wind Date: Wed, 19 Aug 2026 17:03:28 +0200 Subject: [PATCH] feat(notes): en note deles ved at ligge i et projekt MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Ikke en adgangsliste af sin egen. Projekter har allerede medlemmer, roller og invitationer — alt sammen afprøvet, og alt sammen noget folk har lært her i forvejen. En anden måde at give adgang på er et andet sted at gøre det forkert, og de to ville være uenige den dag, den ene blev ændret. To ting, prøven fandt, som begge var rigtige fejl. Projektsiden viste kun noter, der NÆVNTE projektet, ikke dem, der lå i det — så en note, man lige havde delt derind, dukkede ikke op. Nu vises begge slags. Og noteoversigten viste kun dem uden projekt, så en note forsvandt fra listen i det øjeblik, den blev delt. Det fik deling til at se ud som sletning. Listen viser nu alt, man kan se: sine egne løse, og dem i de projekter, man er med i. Co-Authored-By: Claude Opus 5 --- internal/httpapi/notes_handlers.go | 5 ++- internal/store/notes.go | 20 +++++++++++ web/e2e/smoke.spec.js | 35 ++++++++++++++++++ web/src/lib/locales/da.js | 4 +++ web/src/lib/locales/en.js | 4 +++ web/src/routes/noter/+page.svelte | 45 ++++++++++++++++++++++++ web/src/routes/projekt/[id]/+page.svelte | 28 ++++++++++----- 7 files changed, 132 insertions(+), 9 deletions(-) diff --git a/internal/httpapi/notes_handlers.go b/internal/httpapi/notes_handlers.go index c216ef1..98d7565 100644 --- a/internal/httpapi/notes_handlers.go +++ b/internal/httpapi/notes_handlers.go @@ -57,7 +57,10 @@ func (s *Server) handleListNotes(w http.ResponseWriter, r *http.Request) { return } - found, err := s.db.LooseNotes(r.Context(), user.ID) + // Everything they can see, not only the loose ones. Filing a note in a project + // is how it gets shared, and a list that dropped it at that moment made sharing + // look like deleting. + found, err := s.db.AllNotes(r.Context(), user.ID) if err != nil { s.internal(w, r, "list notes", err) return diff --git a/internal/store/notes.go b/internal/store/notes.go index ce049af..af5d792 100644 --- a/internal/store/notes.go +++ b/internal/store/notes.go @@ -184,6 +184,26 @@ func (db *DB) LooseNotes(ctx context.Context, userID string) ([]Note, error) { ORDER BY pinned DESC, updated_at DESC`, userID) } +// AllNotes is everything a person can see: their own loose ones, and the ones in +// projects they are part of. +// +// This is what the Notes page lists. Filing a note in a project is how it gets +// shared, and a list that dropped it at that moment would make sharing look like +// deleting — which is exactly how it read before this existed. +func (db *DB) AllNotes(ctx context.Context, userID string) ([]Note, error) { + return db.notesWhere(ctx, ` + WHERE deleted_at IS NULL + AND ( + (project_id IS NULL AND created_by = ?) + OR project_id IN ( + SELECT id FROM projects WHERE owner_id = ? AND deleted_at IS NULL + UNION + SELECT project_id FROM project_members WHERE user_id = ? + ) + ) + ORDER BY pinned DESC, updated_at DESC`, userID, userID, userID) +} + // NotesLinking answers the backwards question: what points at this thing. // // The reason note_links exists. Without it this would mean reading every note's diff --git a/web/e2e/smoke.spec.js b/web/e2e/smoke.spec.js index ef895f2..756cebb 100644 --- a/web/e2e/smoke.spec.js +++ b/web/e2e/smoke.spec.js @@ -2010,3 +2010,38 @@ test('noteeditoren er rich text, og teksten overlever turen til Markdown og tilb expect(trouble).toEqual([]); }); +test('en note kan deles med et projekts folk, og tages tilbage igen', async ({ page, browser }) => { + 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('Delenoter'); + await sidebar.getByLabel('Projektnavn').press('Enter'); + await expect(sidebar.getByRole('link', { name: 'Delenoter' })).toBeVisible(); + + await sidebar.getByRole('link', { name: 'Noter', exact: true }).click(); + await page.getByRole('button', { name: 'Ny note' }).click(); + await page.getByRole('textbox', { name: 'Notens tekst' }).click(); + await page.keyboard.type('Fælles aftale om leveringen'); + await page.getByRole('textbox', { name: 'Notens tekst' }).blur(); + + // Shared by being filed in a project. Not an access list of its own. + await page.getByLabel('Del i').selectOption({ label: 'Delenoter' }); + await expect(page.getByText('delt med projektets folk')).toBeVisible(); + + // And it is really there, not merely labelled: the project's own page shows it. + await sidebar.getByRole('link', { name: 'Delenoter' }).click(); + await expect(page.locator('.notes').getByRole('link', { name: /Fælles aftale/ })).toBeVisible(); + + // Taken back out, it is the author's alone again. + await sidebar.getByRole('link', { name: 'Noter', exact: true }).click(); + await page.getByRole('button', { name: /Fælles aftale/ }).click(); + await page.getByLabel('Del i').selectOption({ label: 'Kun mig' }); + await expect(page.getByText('din igen')).toBeVisible(); + + await sidebar.getByRole('link', { name: 'Delenoter' }).click(); + await expect(page.locator('.notes').getByRole('link', { name: /Fælles aftale/ })).toBeHidden(); + + expect(trouble).toEqual([]); +}); diff --git a/web/src/lib/locales/da.js b/web/src/lib/locales/da.js index 0e573ed..3659a46 100644 --- a/web/src/lib/locales/da.js +++ b/web/src/lib/locales/da.js @@ -16,6 +16,10 @@ */ export const da = { // --- the shell --------------------------------------------------------------- + 'notes.shareWith': 'Del i', + 'notes.private': 'Kun mig', + 'notes.shared': 'Noten er delt med projektets folk.', + 'notes.unshared': 'Noten er din igen.', 'notes.save': 'Gem', 'notes.format': 'Formatér', 'notes.bold': 'Fed', diff --git a/web/src/lib/locales/en.js b/web/src/lib/locales/en.js index 3b4d163..500530a 100644 --- a/web/src/lib/locales/en.js +++ b/web/src/lib/locales/en.js @@ -12,6 +12,10 @@ */ export const en = { // --- the shell --------------------------------------------------------------- + 'notes.shareWith': 'Share in', + 'notes.private': 'Only me', + 'notes.shared': "The note is shared with the project's people.", + 'notes.unshared': 'The note is yours again.', 'notes.save': 'Save', 'notes.format': 'Format', 'notes.bold': 'Bold', diff --git a/web/src/routes/noter/+page.svelte b/web/src/routes/noter/+page.svelte index 0bd88d7..bd8996a 100644 --- a/web/src/routes/noter/+page.svelte +++ b/web/src/routes/noter/+page.svelte @@ -108,6 +108,24 @@ } } + /** + * Moves the note into a project, which is what sharing it means: everybody who + * can read the project can read the note, and an editor can change it. Choosing + * the empty option takes it back out and makes it the author's alone again. + */ + async function share(projectId) { + if (!selected) return; + const id = selected.id; + try { + const saved = await api.updateNote(id, { project_id: projectId }); + notes = notes.map((n) => (n.id === id ? saved : n)); + if (selected?.id === id) selected = saved; + app.toast(projectId ? t('notes.shared') : t('notes.unshared')); + } catch (e) { + app.toast(humanMessage(e)); + } + } + async function remove(note) { if (!confirm(t('notes.deleteNote', { name: note.title || t('notes.untitled') }))) return; try { @@ -192,6 +210,22 @@ {/each} {/if} + + +
@@ -436,6 +470,17 @@ font-family: var(--mono, ui-monospace, monospace); } + .share { + display: flex; + align-items: center; + gap: var(--s1); + } + + .share select { + font-size: var(--text-xs); + padding: 2px var(--s1); + } + /* Real buttons. They were bare words, which on a Mac reads as a web page rather than as something you can press. */ .actions { diff --git a/web/src/routes/projekt/[id]/+page.svelte b/web/src/routes/projekt/[id]/+page.svelte index 4080308..5ed5a8e 100644 --- a/web/src/routes/projekt/[id]/+page.svelte +++ b/web/src/routes/projekt/[id]/+page.svelte @@ -43,21 +43,33 @@ 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. + // Two ways a note belongs here, and both count. + // + // Filed in the project — which is what sharing a note means — and mentioning it + // by #tag, which is what somebody does while writing without stopping to file + // anything. Looked up by name for the second, 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 id = project?.id; const name = project?.name; - if (!name) { + if (!id || !name) { notes = []; return; } - api - .notesLinking('project', name) - .then((r) => (notes = r.notes ?? [])) - .catch(() => (notes = [])); + Promise.all([ + api.notes({ project: id }).catch(() => ({ notes: [] })), + api.notesLinking('project', name).catch(() => ({ notes: [] })) + ]).then(([filed, mentioned]) => { + const seen = new Set(); + notes = [...(filed.notes ?? []), ...(mentioned.notes ?? [])].filter((n) => { + if (seen.has(n.id)) return false; + seen.add(n.id); + return true; + }); + }); }); async function load(projectId) {