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