diff --git a/web/e2e/smoke.spec.js b/web/e2e/smoke.spec.js index 25448bd..93412b6 100644 --- a/web/e2e/smoke.spec.js +++ b/web/e2e/smoke.spec.js @@ -4635,3 +4635,57 @@ test('et projekt kan deles med en, der allerede har en konto', async ({ browser, expect(trouble).toEqual([]); }); + +/** + * En søgning i ⌘K efterlader resultaterne, den fandt. + * + * Paletten viste de noter, der passede, i sin egen liste — og når man valgte en, + * åbnede den noten på en side, hvis liste stod helt uændret. De andre træffere var + * væk, og det eneste sted, de nogensinde havde været, var en popup, der lige var + * lukket. Rapporteret sådan her: "jeg får kun præsenteret en popup liste med + * resultater, men hvis jeg trykker retur, kommer jeg tilbage til den normale liste + * — ikke resultater." + * + * Prøven laver sine egne noter med et ord, ingen anden note i basen har. Et ord + * lånt fra en tidligere prøve ville gøre den her afhængig af rækkefølgen, og den + * kobling har allerede narret mig én gang i den her fil. + */ +test('en søgning i ⌘K efterlader de træffere, den fandt', async ({ page }) => { + const trouble = watchForTrouble(page); + await page.goto('/noter'); + + // Gemt ved at forlade feltet, som resten af fladen gør det — der er ingen + // Gem-knap at huske. + for (const title of ['Fyrreskov nord', 'Fyrreskov syd', 'Egeskov']) { + await page.getByRole('button', { name: 'Ny note' }).click(); + const body = page.getByLabel('Notens tekst'); + await body.fill(`${title}\n\nnoget om den`); + await body.blur(); + await expect(page.locator('.list').getByText(title, { exact: true })).toBeVisible(); + } + + await page.goto('/'); + await page.getByRole('button', { name: /Søg/ }).click(); + const palette = page.getByRole('dialog'); + await palette.getByRole('textbox').fill('Fyrreskov'); + await expect(palette.locator('li button', { hasText: 'Fyrreskov nord' })).toBeVisible(); + await palette.locator('li button', { hasText: 'Fyrreskov nord' }).click(); + + // Noten er åben … + await expect(page).toHaveURL(/\/noter\?note=[^&]+&q=Fyrreskov/); + await expect(page.getByLabel('Notens tekst')).toContainText('Fyrreskov nord'); + + // … og listen ved siden af den er de andre træffere, ikke den normale liste. + await expect(page.getByPlaceholder('Søg i noter')).toHaveValue('Fyrreskov'); + const list = page.locator(".list"); + await expect(list.getByText('Fyrreskov syd')).toBeVisible(); + await expect(list.getByText('Egeskov')).toHaveCount(0); + + // Og adressen lyver ikke, når feltet tømmes: uden det ville et genbesøg hente + // en søgning tilbage, man selv havde ryddet væk. + await page.getByPlaceholder('Søg i noter').fill(''); + await expect(page).not.toHaveURL(/[?&]q=/); + await expect(list.getByText('Egeskov')).toBeVisible(); + + expect(trouble).toEqual([]); +}); diff --git a/web/src/lib/components/CommandPalette.svelte b/web/src/lib/components/CommandPalette.svelte index 7f80611..0d62180 100644 --- a/web/src/lib/components/CommandPalette.svelte +++ b/web/src/lib/components/CommandPalette.svelte @@ -125,7 +125,13 @@ // En indstilling bærer sin egen adresse i sit id — der er ingen række at slå // op, den ER stedet. if (item.kind === 'setting') return goto(item.id); - if (item.kind === 'note') return goto(`/noter?note=${item.id}`); + // Søgningen følger med noten. Uden den åbner man én træffer på en side, hvis + // liste står uændret — og de andre træffere var kun at se i den popup, man + // lige lukkede. + if (item.kind === 'note') { + const q = query.trim(); + return goto(`/noter?note=${item.id}` + (q ? `&q=${encodeURIComponent(q)}` : '')); + } goto(item.kind === 'project' ? `/projekt/${item.id}` : `/projekt/${item.project}`); } diff --git a/web/src/routes/noter/+page.svelte b/web/src/routes/noter/+page.svelte index 8a81606..15ea731 100644 --- a/web/src/routes/noter/+page.svelte +++ b/web/src/routes/noter/+page.svelte @@ -14,7 +14,8 @@ import { api, humanMessage } from '$lib/api.js'; import { app } from '$lib/stores.svelte.js'; import { page } from '$app/stores'; - import { goto } from '$app/navigation'; + import { untrack } from 'svelte'; + import { goto, replaceState } from '$app/navigation'; import { t, tag } from '$lib/i18n.svelte.js'; import { NOTE, startDrag } from '$lib/dnd.js'; import { colorVar } from '$lib/colors.js'; @@ -483,6 +484,49 @@ load(query); }); + /** + * En søgning, man kommer med, og en søgning, man kan gå videre med. + * + * ⌘K viste noter, der passede, i sin egen liste — og når man valgte en, åbnede + * den noten på en side, hvis liste stod uændret. Så var de andre træffere væk, + * og det eneste sted, de havde været, var en popup, der lige var lukket. "Jeg + * får kun præsenteret en popup med resultater, og trykker jeg retur, kommer jeg + * tilbage til den normale liste." + * + * Nu bærer adressen søgningen, så listen ved siden af den åbne note er de andre + * træffere. Og den skrives tilbage, når feltet ændres, så adressen kan gemmes og + * deles — og frem for alt ikke lyver, når man har tømt feltet. + * + * `settled` er det, de to retninger er blevet enige om — og den læses med + * `untrack`, hvilket er hele forskellen på at virke og ikke. + * + * Uden den: den nederste effekt sætter `settled`, det vækker den øverste, og + * `replaceState` har endnu ikke opdateret `$page.url`. Den øverste læser derfor + * den *gamle* adresse, finder en søgning, der ikke er enig med `settled`, og + * skriver den tilbage i feltet. Man tømmer søgefeltet, og ordet kommer igen. + * Hver effekt skal reagere på sin egen kilde — adressen og feltet — og aldrig + * på den fælles markør. + */ + let settled = $state(null); + + $effect(() => { + const inURL = $page.url.searchParams.get('q') ?? ''; + if (inURL === untrack(() => settled)) return; + settled = inURL; + draftQuery = inURL; + query = inURL; + }); + + $effect(() => { + const now = query; + if (now === untrack(() => settled)) return; + settled = now; + const url = new URL(untrack(() => $page.url)); + if (now) url.searchParams.set('q', now); + else url.searchParams.delete('q'); + replaceState(url, {}); + }); + // Arriving from somewhere that names a note — a task's panel, a project's page — // opens it rather than dropping the person on a list to find it again. let asked = $derived($page.url.searchParams.get('note'));