From 79f06c26d9acd68ff8b6a03c1f10dd6381e51804 Mon Sep 17 00:00:00 2001 From: Blomios Date: Mon, 13 Jul 2026 07:45:50 +0200 Subject: [PATCH] =?UTF-8?q?feat(tickets):=20persistance=20des=20filtres=20?= =?UTF-8?q?tickets=20entre=20red=C3=A9marrages=20(#29)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Introduit le port UiPreferencesGateway et son adapter uiPreferences, avec le module ticketFilterPersistence qui sauvegarde/restaure les filtres (recherche, statut, sprint, agents) via useTickets, useTicketSearch et useProjectAgents. Couvert par tests unitaires et un test d'intégration. Co-Authored-By: Claude Opus 4.8 --- frontend/src/adapters/index.ts | 3 + frontend/src/adapters/mock/index.ts | 34 +++ frontend/src/adapters/mock/mock.test.ts | 1 + frontend/src/adapters/uiPreferences.ts | 65 +++++ .../src/features/tickets/TicketPicker.tsx | 25 +- .../src/features/tickets/TicketsPanel.tsx | 18 +- .../tickets/ticketFilterPersistence.test.ts | 116 +++++++++ .../tickets/ticketFilterPersistence.ts | 231 ++++++++++++++++++ ...ketFiltersPersistence.integration.test.tsx | 162 ++++++++++++ .../src/features/tickets/useProjectAgents.ts | 20 +- .../src/features/tickets/useTicketSearch.ts | 83 ++++++- frontend/src/features/tickets/useTickets.ts | 31 ++- frontend/src/ports/index.ts | 24 ++ 13 files changed, 793 insertions(+), 20 deletions(-) create mode 100644 frontend/src/adapters/uiPreferences.ts create mode 100644 frontend/src/features/tickets/ticketFilterPersistence.test.ts create mode 100644 frontend/src/features/tickets/ticketFilterPersistence.ts create mode 100644 frontend/src/features/tickets/ticketFiltersPersistence.integration.test.tsx diff --git a/frontend/src/adapters/index.ts b/frontend/src/adapters/index.ts index 240e05a..421727d 100644 --- a/frontend/src/adapters/index.ts +++ b/frontend/src/adapters/index.ts @@ -30,6 +30,7 @@ import { TauriWorkStateGateway } from "./workState"; import { TauriConversationGateway } from "./conversation"; import { TauriTicketGateway } from "./ticket"; import { TauriWindowGateway } from "./window"; +import { LocalStorageUiPreferencesGateway } from "./uiPreferences"; function notImplemented(what: string): never { const err: GatewayError = { @@ -67,6 +68,7 @@ export function createTauriGateways(): Gateways { conversation: new TauriConversationGateway(), ticket: new TauriTicketGateway(), window: new TauriWindowGateway(), + uiPreferences: new LocalStorageUiPreferencesGateway(), }; } @@ -89,4 +91,5 @@ export { TauriConversationGateway, TauriTicketGateway, TauriWindowGateway, + LocalStorageUiPreferencesGateway, }; diff --git a/frontend/src/adapters/mock/index.ts b/frontend/src/adapters/mock/index.ts index 8908d45..e6b55c0 100644 --- a/frontend/src/adapters/mock/index.ts +++ b/frontend/src/adapters/mock/index.ts @@ -88,6 +88,7 @@ import type { TicketListQuery, CreateTicketInput, UpdateTicketInput, + UiPreferencesGateway, ViewWindowClosed, WindowGateway, WorkStateGateway, @@ -2529,6 +2530,38 @@ export class MockTicketGateway implements TicketGateway { } } +/** + * In-memory {@link UiPreferencesGateway} for tests/dev (ticket #29). Mirrors the + * localStorage adapter's best-effort JSON round-trip without touching the DOM, + * so persisted-filter behaviour can be exercised offline. + */ +export class MockUiPreferencesGateway implements UiPreferencesGateway { + private readonly store = new Map(); + + read(key: string): unknown { + const raw = this.store.get(key); + if (raw === undefined) return null; + try { + return JSON.parse(raw); + } catch { + this.store.delete(key); + return null; + } + } + + write(key: string, value: unknown): void { + try { + this.store.set(key, JSON.stringify(value)); + } catch { + /* best-effort */ + } + } + + remove(key: string): void { + this.store.delete(key); + } +} + function mostRestrictive( project?: PermissionSet["fallback"], agent?: PermissionSet["fallback"], @@ -2563,6 +2596,7 @@ export function createMockGateways(): Gateways { conversation: new MockConversationGateway(), ticket: new MockTicketGateway(systemGateway), window: new MockWindowGateway(), + uiPreferences: new MockUiPreferencesGateway(), }; } diff --git a/frontend/src/adapters/mock/mock.test.ts b/frontend/src/adapters/mock/mock.test.ts index 162ec31..348d7ba 100644 --- a/frontend/src/adapters/mock/mock.test.ts +++ b/frontend/src/adapters/mock/mock.test.ts @@ -31,6 +31,7 @@ describe("createMockGateways", () => { "template", "terminal", "ticket", + "uiPreferences", "window", "workState", ]); diff --git a/frontend/src/adapters/uiPreferences.ts b/frontend/src/adapters/uiPreferences.ts new file mode 100644 index 0000000..0e43eee --- /dev/null +++ b/frontend/src/adapters/uiPreferences.ts @@ -0,0 +1,65 @@ +/** + * `localStorage`-backed {@link UiPreferencesGateway} (ticket #29). + * + * The only place that touches `window.localStorage`; features go through the + * port via DI. Everything is best-effort: a missing / private-mode / quota-full + * storage, or a corrupt JSON entry, degrades silently to "no preference" rather + * than throwing into the UI (persisted UI filters must never break the app). + */ + +import type { UiPreferencesGateway } from "@/ports"; + +/** Returns the `Storage` if usable in this environment, else `null`. */ +function storage(): Storage | null { + try { + return typeof window !== "undefined" ? window.localStorage : null; + } catch { + // Accessing `localStorage` can throw (sandboxed / disabled cookies). + return null; + } +} + +export class LocalStorageUiPreferencesGateway implements UiPreferencesGateway { + read(key: string): unknown { + const store = storage(); + if (!store) return null; + let raw: string | null; + try { + raw = store.getItem(key); + } catch { + return null; + } + if (raw === null) return null; + try { + return JSON.parse(raw); + } catch { + // Corrupt entry — drop it so we stop tripping over it, return default. + try { + store.removeItem(key); + } catch { + /* ignore */ + } + return null; + } + } + + write(key: string, value: unknown): void { + const store = storage(); + if (!store) return; + try { + store.setItem(key, JSON.stringify(value)); + } catch { + /* quota / serialisation failure — persistence is best-effort */ + } + } + + remove(key: string): void { + const store = storage(); + if (!store) return; + try { + store.removeItem(key); + } catch { + /* ignore */ + } + } +} diff --git a/frontend/src/features/tickets/TicketPicker.tsx b/frontend/src/features/tickets/TicketPicker.tsx index adb0892..95d6b2f 100644 --- a/frontend/src/features/tickets/TicketPicker.tsx +++ b/frontend/src/features/tickets/TicketPicker.tsx @@ -31,6 +31,7 @@ import type { TicketRef } from "@/domain"; import type { TicketListQuery } from "@/ports"; import { Button, Spinner, cn, zIndex } from "@/shared"; import { TicketFacetsBar } from "./TicketFacetsBar"; +import { pickerFiltersKey } from "./ticketFilterPersistence"; import { PriorityBadge, StatusBadge, TicketRef as TicketRefBadge } from "./ticketMeta"; import { useTicketSearch, @@ -55,6 +56,13 @@ export interface TicketPickerProps { excludeRefs?: TicketRef[]; /** Seed filter/search query. */ initialQuery?: TicketListQuery; + /** + * Whether the picker persists its filters across openings (ticket #29, + * default `true`). `initialQuery` still wins per field, so a caller imposing a + * business filter keeps priority; a caller that must never inherit stored + * preferences can opt out entirely with `false`. + */ + persistFilters?: boolean; /** * Selection callback. In `"single"` mode it receives one * {@link TicketPickerResult}; in `"multi"` it receives the array. @@ -76,6 +84,7 @@ export function TicketPicker({ selectionMode = "single", excludeRefs, initialQuery, + persistFilters = true, onSelect, onClose, }: TicketPickerProps) { @@ -88,6 +97,7 @@ export function TicketPicker({ selectionMode={selectionMode} excludeRefs={excludeRefs} initialQuery={initialQuery} + persistFilters={persistFilters} onSelect={onSelect} onClose={onClose} /> @@ -106,16 +116,27 @@ function TicketPickerBody({ selectionMode, excludeRefs, initialQuery, + persistFilters, onSelect, onClose, }: Required< Pick< TicketPickerProps, - "projectId" | "title" | "confirmLabel" | "selectionMode" | "onSelect" | "onClose" + | "projectId" + | "title" + | "confirmLabel" + | "selectionMode" + | "persistFilters" + | "onSelect" + | "onClose" > > & Pick) { - const vm = useTicketSearch(projectId, { initialQuery, excludeRefs }); + const vm = useTicketSearch(projectId, { + initialQuery, + excludeRefs, + persistenceKey: persistFilters ? pickerFiltersKey(projectId) : undefined, + }); const dialogRef = useRef(null); const [selecting, setSelecting] = useState(false); const [resolveError, setResolveError] = useState(null); diff --git a/frontend/src/features/tickets/TicketsPanel.tsx b/frontend/src/features/tickets/TicketsPanel.tsx index b4a5a03..b733b0d 100644 --- a/frontend/src/features/tickets/TicketsPanel.tsx +++ b/frontend/src/features/tickets/TicketsPanel.tsx @@ -7,7 +7,7 @@ * through {@link useTickets}. */ -import { useState } from "react"; +import { useEffect, useState } from "react"; import type { Sprint, TicketPriority, TicketSummary } from "@/domain"; import { Button, Input, Panel, Spinner, cn } from "@/shared"; @@ -38,7 +38,21 @@ export interface TicketsPanelProps { export function TicketsPanel({ projectId, onOpen }: TicketsPanelProps) { const vm = useTickets(projectId); - const { agents, nameOf } = useProjectAgents(projectId); + const { agents, nameOf, loaded: agentsLoaded } = useProjectAgents(projectId); + + // Reconcile a *restored* assignee filter (ticket #29) against the live roster: + // once the agents are known, an assignee that no longer exists is dropped + // (which also clears it from persisted storage via `useTickets`). Guarded on + // `agentsLoaded` so a still-valid assignee is never cleared during the load + // window when the roster is momentarily empty. + useEffect(() => { + if (!agentsLoaded) return; + const id = vm.query.assignedAgentId; + if (id && !agents.some((a) => a.id === id)) { + const { assignedAgentId: _drop, ...rest } = vm.query; + vm.setQuery(rest); + } + }, [agentsLoaded, agents, vm.query, vm.setQuery]); const [showCreate, setShowCreate] = useState(false); const [showSprints, setShowSprints] = useState(false); const [newTitle, setNewTitle] = useState(""); diff --git a/frontend/src/features/tickets/ticketFilterPersistence.test.ts b/frontend/src/features/tickets/ticketFilterPersistence.test.ts new file mode 100644 index 0000000..ca8bb64 --- /dev/null +++ b/frontend/src/features/tickets/ticketFilterPersistence.test.ts @@ -0,0 +1,116 @@ +import { describe, it, expect } from "vitest"; + +import type { TicketListQuery } from "@/ports"; +import { + listFiltersKey, + parseTicketFilters, + pickerFiltersKey, + sanitizeListFilters, + sanitizePickerFilters, +} from "./ticketFilterPersistence"; + +describe("ticketFilterPersistence keys", () => { + it("scopes keys by project and surface (list vs picker disjoint)", () => { + expect(listFiltersKey("p1")).toBe("tickets:listFilters:p1"); + expect(pickerFiltersKey("p1")).toBe("tickets:pickerFilters:p1"); + expect(listFiltersKey("p1")).not.toBe(pickerFiltersKey("p1")); + }); +}); + +describe("parseTicketFilters (validation)", () => { + it("returns an empty query for non-objects / corrupt input", () => { + expect(parseTicketFilters(null)).toEqual({}); + expect(parseTicketFilters(undefined)).toEqual({}); + expect(parseTicketFilters("nope")).toEqual({}); + expect(parseTicketFilters(42)).toEqual({}); + expect(parseTicketFilters([1, 2, 3])).toEqual({}); + }); + + it("keeps only known statuses/priorities and drops unknown values", () => { + const parsed = parseTicketFilters({ + statuses: ["open", "bogus", "QA", "open"], + priorities: ["high", "nope"], + }); + expect(parsed.statuses).toEqual(["open", "QA"]); // deduped, unknown dropped + expect(parsed.priorities).toEqual(["high"]); + }); + + it("keeps valid text/sort/limit and rejects malformed ones", () => { + expect(parseTicketFilters({ text: "boom" }).text).toBe("boom"); + expect(parseTicketFilters({ text: "" }).text).toBeUndefined(); + expect( + parseTicketFilters({ sort: { field: "number", direction: "desc" } }).sort, + ).toEqual({ field: "number", direction: "desc" }); + expect( + parseTicketFilters({ sort: { field: "bogus", direction: "desc" } }).sort, + ).toBeUndefined(); + expect(parseTicketFilters({ limit: 50 }).limit).toBe(50); + expect(parseTicketFilters({ limit: -1 }).limit).toBeUndefined(); + expect(parseTicketFilters({ limit: 1.5 }).limit).toBeUndefined(); + }); + + it("never restores the opaque cursor", () => { + const parsed = parseTicketFilters({ text: "x", cursor: "opaque-token" }); + expect(parsed.text).toBe("x"); + expect("cursor" in parsed).toBe(false); + }); + + it("drops an obsolete assignee when it is not among the known agents", () => { + const raw = { assignedAgentId: "gone" }; + // No roster provided → shape-only (assignee kept). + expect(parseTicketFilters(raw).assignedAgentId).toBe("gone"); + // Roster provided and missing the id → assignee dropped. + expect( + parseTicketFilters(raw, { validAgentIds: new Set(["a1"]) }).assignedAgentId, + ).toBeUndefined(); + // Roster provided and containing the id → kept. + expect( + parseTicketFilters(raw, { validAgentIds: new Set(["gone"]) }) + .assignedAgentId, + ).toBe("gone"); + }); +}); + +describe("sanitize (persistable subsets)", () => { + const full: TicketListQuery = { + text: "hi", + statuses: ["open"], + priorities: ["high"], + assignedAgentId: "a1", + sort: { field: "title", direction: "asc" }, + limit: 100, + cursor: "opaque", + }; + + it("list keeps assignee + limit but never cursor", () => { + const out = sanitizeListFilters(full)!; + expect(out).toEqual({ + text: "hi", + statuses: ["open"], + priorities: ["high"], + assignedAgentId: "a1", + sort: { field: "title", direction: "asc" }, + limit: 100, + }); + expect("cursor" in out).toBe(false); + }); + + it("picker drops assignee, limit AND cursor", () => { + const out = sanitizePickerFilters(full)!; + expect(out).toEqual({ + text: "hi", + statuses: ["open"], + priorities: ["high"], + sort: { field: "title", direction: "asc" }, + }); + expect("assignedAgentId" in out).toBe(false); + expect("limit" in out).toBe(false); + expect("cursor" in out).toBe(false); + }); + + it("returns null when nothing is worth persisting", () => { + expect(sanitizeListFilters({})).toBeNull(); + expect(sanitizeListFilters({ cursor: "only-a-cursor" })).toBeNull(); + expect(sanitizePickerFilters({ assignedAgentId: "a1" })).toBeNull(); + }); +}); diff --git a/frontend/src/features/tickets/ticketFilterPersistence.ts b/frontend/src/features/tickets/ticketFilterPersistence.ts new file mode 100644 index 0000000..0718638 --- /dev/null +++ b/frontend/src/features/tickets/ticketFilterPersistence.ts @@ -0,0 +1,231 @@ +/** + * Persistence of ticket filters across restarts / reopens (ticket #29). + * + * Frontend-only: filters are UI preferences, so they live behind the thin + * {@link UiPreferencesGateway} (localStorage adapter) — the backend stays the + * source of tickets, never of these preferences. Persistence is scoped **per + * project and per surface** through two disjoint keys so the main list and the + * {@link TicketPicker} popup never pollute each other: + * + * - `tickets:listFilters:` — the main tickets window. + * - `tickets:pickerFilters:` — the reusable ticket picker. + * + * We only ever store the *stable* criteria (`text`, `statuses`, `priorities`, + * `sort`, `limit`, and — main list only — `assignedAgentId`). The opaque + * `cursor` is **never** persisted: it is a transient pagination token whose reuse + * across sessions is meaningless (G3-bis). Reads are defensive — unknown values + * are dropped and a corrupt blob yields the default empty query. + */ + +import type { TicketPriority, TicketStatus } from "@/domain"; +import type { + TicketListQuery, + TicketListSort, + TicketListSortDirection, + TicketListSortField, + UiPreferencesGateway, +} from "@/ports"; + +const VALID_STATUSES: readonly TicketStatus[] = [ + "open", + "inProgress", + "QA", + "closed", +]; +const VALID_PRIORITIES: readonly TicketPriority[] = [ + "low", + "medium", + "high", + "critical", +]; +const VALID_SORT_FIELDS: readonly TicketListSortField[] = [ + "number", + "priority", + "status", + "title", +]; +const VALID_SORT_DIRECTIONS: readonly TicketListSortDirection[] = ["asc", "desc"]; + +/** Storage key for the main tickets window, scoped to `projectId`. */ +export function listFiltersKey(projectId: string): string { + return `tickets:listFilters:${projectId}`; +} + +/** Storage key for the {@link TicketPicker} popup, scoped to `projectId`. */ +export function pickerFiltersKey(projectId: string): string { + return `tickets:pickerFilters:${projectId}`; +} + +function isObject(v: unknown): v is Record { + return typeof v === "object" && v !== null && !Array.isArray(v); +} + +/** Keeps only the members of `allowed`, de-duplicated, order preserved. */ +function filterEnumArray( + raw: unknown, + allowed: readonly T[], +): T[] | undefined { + if (!Array.isArray(raw)) return undefined; + const set = new Set(allowed); + const out: T[] = []; + for (const v of raw) { + if (typeof v === "string" && set.has(v as T) && !out.includes(v as T)) { + out.push(v as T); + } + } + return out.length > 0 ? out : undefined; +} + +function parseSort(raw: unknown): TicketListSort | undefined { + if (!isObject(raw)) return undefined; + const { field, direction } = raw; + if ( + typeof field === "string" && + (VALID_SORT_FIELDS as readonly string[]).includes(field) && + typeof direction === "string" && + (VALID_SORT_DIRECTIONS as readonly string[]).includes(direction) + ) { + return { + field: field as TicketListSortField, + direction: direction as TicketListSortDirection, + }; + } + return undefined; +} + +/** Options for {@link parseTicketFilters}. */ +export interface ParseTicketFiltersOptions { + /** + * When provided, a persisted `assignedAgentId` is kept only if it belongs to + * this set — an obsolete assignee (agent since deleted) is dropped. When + * `undefined`, the assignee is validated for *shape* only (the caller does not + * know the agent roster yet and reconciles later). + */ + validAgentIds?: ReadonlySet; +} + +/** + * Validates an untrusted persisted blob into a clean {@link TicketListQuery}. + * Unknown fields/values are ignored; a non-object (corrupt JSON already yielded + * `null` upstream) yields the default empty query. `cursor` is never read back. + */ +export function parseTicketFilters( + raw: unknown, + options: ParseTicketFiltersOptions = {}, +): TicketListQuery { + if (!isObject(raw)) return {}; + const query: TicketListQuery = {}; + + if (typeof raw.text === "string" && raw.text.length > 0) { + query.text = raw.text; + } + + const statuses = filterEnumArray(raw.statuses, VALID_STATUSES); + if (statuses) query.statuses = statuses; + + const priorities = filterEnumArray(raw.priorities, VALID_PRIORITIES); + if (priorities) query.priorities = priorities; + + if (typeof raw.assignedAgentId === "string" && raw.assignedAgentId) { + const { validAgentIds } = options; + if (!validAgentIds || validAgentIds.has(raw.assignedAgentId)) { + query.assignedAgentId = raw.assignedAgentId; + } + } + + const sort = parseSort(raw.sort); + if (sort) query.sort = sort; + + if ( + typeof raw.limit === "number" && + Number.isInteger(raw.limit) && + raw.limit > 0 + ) { + query.limit = raw.limit; + } + + // `cursor` is intentionally never restored. + return query; +} + +/** + * Reduces a live query to the persistable subset for the **main list**: keeps + * `text/statuses/priorities/assignedAgentId/sort/limit`, drops `cursor`. Returns + * `null` when nothing is worth storing (so the caller can remove the key). + */ +export function sanitizeListFilters(query: TicketListQuery): TicketListQuery | null { + const out: TicketListQuery = {}; + if (query.text) out.text = query.text; + if (query.statuses && query.statuses.length > 0) out.statuses = query.statuses; + if (query.priorities && query.priorities.length > 0) { + out.priorities = query.priorities; + } + if (query.assignedAgentId) out.assignedAgentId = query.assignedAgentId; + if (query.sort) out.sort = query.sort; + if (typeof query.limit === "number") out.limit = query.limit; + return Object.keys(out).length > 0 ? out : null; +} + +/** + * Reduces a live query to the persistable subset for the **picker**: like the + * list but *without* `assignedAgentId` (assignee is a main-list-only preference) + * and without `limit` (the picker's limit is a caller/business concern, not a + * user preference). `cursor` is likewise dropped. + */ +export function sanitizePickerFilters( + query: TicketListQuery, +): TicketListQuery | null { + const out: TicketListQuery = {}; + if (query.text) out.text = query.text; + if (query.statuses && query.statuses.length > 0) out.statuses = query.statuses; + if (query.priorities && query.priorities.length > 0) { + out.priorities = query.priorities; + } + if (query.sort) out.sort = query.sort; + return Object.keys(out).length > 0 ? out : null; +} + +/** Reads + validates the persisted main-list filters (empty query on miss). */ +export function hydrateListFilters( + prefs: UiPreferencesGateway | undefined, + projectId: string, + options?: ParseTicketFiltersOptions, +): TicketListQuery { + if (!prefs) return {}; + return parseTicketFilters(prefs.read(listFiltersKey(projectId)), options); +} + +/** Persists (or clears) the main-list filters for a project. */ +export function persistListFilters( + prefs: UiPreferencesGateway | undefined, + projectId: string, + query: TicketListQuery, +): void { + if (!prefs) return; + const sanitized = sanitizeListFilters(query); + const key = listFiltersKey(projectId); + if (sanitized) prefs.write(key, sanitized); + else prefs.remove(key); +} + +/** Reads + validates the persisted picker filters (empty query on miss). */ +export function hydratePickerFilters( + prefs: UiPreferencesGateway | undefined, + projectId: string, +): TicketListQuery { + if (!prefs) return {}; + return parseTicketFilters(prefs.read(pickerFiltersKey(projectId))); +} + +/** Persists (or clears) the picker filters for a project. */ +export function persistPickerFilters( + prefs: UiPreferencesGateway | undefined, + projectId: string, + query: TicketListQuery, +): void { + if (!prefs) return; + const sanitized = sanitizePickerFilters(query); + const key = pickerFiltersKey(projectId); + if (sanitized) prefs.write(key, sanitized); + else prefs.remove(key); +} diff --git a/frontend/src/features/tickets/ticketFiltersPersistence.integration.test.tsx b/frontend/src/features/tickets/ticketFiltersPersistence.integration.test.tsx new file mode 100644 index 0000000..3a9d6ec --- /dev/null +++ b/frontend/src/features/tickets/ticketFiltersPersistence.integration.test.tsx @@ -0,0 +1,162 @@ +import { describe, it, expect, beforeEach, vi } from "vitest"; +import { fireEvent, render, screen, waitFor } from "@testing-library/react"; + +import { DIProvider } from "@/app/di"; +import { + MockAgentGateway, + MockSystemGateway, + MockTicketGateway, + MockUiPreferencesGateway, +} from "@/adapters/mock"; +import type { Gateways } from "@/ports"; +import { TicketsPanel } from "./TicketsPanel"; +import { TicketPicker } from "./TicketPicker"; +import { listFiltersKey, pickerFiltersKey } from "./ticketFilterPersistence"; + +const PROJECT_ID = "project-persist-test"; + +describe("ticket #29 — persisted list filters (useTickets)", () => { + let system: MockSystemGateway; + let ticket: MockTicketGateway; + let agent: MockAgentGateway; + let prefs: MockUiPreferencesGateway; + + beforeEach(() => { + system = new MockSystemGateway(); + ticket = new MockTicketGateway(system); + agent = new MockAgentGateway(); + prefs = new MockUiPreferencesGateway(); + }); + + function renderPanel() { + const gateways = { system, ticket, agent, uiPreferences: prefs } as unknown as Gateways; + return render( + + + , + ); + } + + it("restores persisted list filters at mount", async () => { + prefs.write(listFiltersKey(PROJECT_ID), { + text: "restored-text", + statuses: ["open"], + }); + renderPanel(); + const search = (await screen.findByLabelText( + "search tickets", + )) as HTMLInputElement; + expect(search.value).toBe("restored-text"); + }); + + it("persists a filter change (scoped to the list key)", async () => { + renderPanel(); + const search = (await screen.findByLabelText( + "search tickets", + )) as HTMLInputElement; + fireEvent.change(search, { target: { value: "boom" } }); + await waitFor(() => { + expect(prefs.read(listFiltersKey(PROJECT_ID))).toMatchObject({ + text: "boom", + }); + }); + // The picker key stays untouched (surfaces do not pollute each other). + expect(prefs.read(pickerFiltersKey(PROJECT_ID))).toBeNull(); + }); + + it("drops an obsolete persisted assignee once the roster is known", async () => { + prefs.write(listFiltersKey(PROJECT_ID), { + text: "keep-me", + assignedAgentId: "ghost-agent", + }); + renderPanel(); + // The roster (empty here) has no such agent → the assignee is reconciled out + // and re-persisted without it, while the rest of the filters survive. + await waitFor(() => { + const stored = prefs.read(listFiltersKey(PROJECT_ID)) as + | Record + | null; + expect(stored).not.toBeNull(); + expect(stored).toMatchObject({ text: "keep-me" }); + expect("assignedAgentId" in (stored ?? {})).toBe(false); + }); + }); +}); + +describe("ticket #29 — persisted picker filters (TicketPicker)", () => { + let system: MockSystemGateway; + let ticket: MockTicketGateway; + let prefs: MockUiPreferencesGateway; + + beforeEach(() => { + system = new MockSystemGateway(); + ticket = new MockTicketGateway(system); + prefs = new MockUiPreferencesGateway(); + }); + + function renderPicker( + props: Partial> = {}, + ) { + const gateways = { system, ticket, uiPreferences: prefs } as unknown as Gateways; + return render( + + + , + ); + } + + it("restores persisted picker filters on reopen", async () => { + prefs.write(pickerFiltersKey(PROJECT_ID), { text: "picker-pref" }); + renderPicker(); + const search = (await screen.findByLabelText( + "search tickets", + )) as HTMLInputElement; + expect(search.value).toBe("picker-pref"); + }); + + it("lets initialQuery win over the persisted preference for imposed fields", async () => { + await ticket.create(PROJECT_ID, { title: "Open item", status: "open" }); + const closed = await ticket.create(PROJECT_ID, { title: "Closed item" }); + await ticket.update(PROJECT_ID, closed.ref, { + status: "closed", + expectedVersion: closed.version, + }); + // The stored preference asks for "closed", but the caller imposes "open". + prefs.write(pickerFiltersKey(PROJECT_ID), { statuses: ["closed"] }); + renderPicker({ initialQuery: { statuses: ["open"] } }); + // initialQuery wins → the open item shows, the closed one is filtered out. + expect(await screen.findByText("Open item")).toBeTruthy(); + expect(screen.queryByText("Closed item")).toBeNull(); + }); + + it("never persists the opaque cursor, even after paginating", async () => { + for (let i = 0; i < 3; i += 1) { + await ticket.create(PROJECT_ID, { title: `Alpha ${i}` }); + } + // Seed text so something is persisted; small limit forces a nextCursor. + renderPicker({ initialQuery: { text: "Alpha", limit: 2 } }); + // Wait for the first page and its "Load more" (nextCursor present). + const loadMore = await screen.findByRole("button", { name: /load more/i }); + fireEvent.click(loadMore); + await screen.findByText("Alpha 0"); + + await waitFor(() => { + const stored = prefs.read(pickerFiltersKey(PROJECT_ID)) as + | Record + | null; + expect(stored).toMatchObject({ text: "Alpha" }); + }); + const stored = prefs.read(pickerFiltersKey(PROJECT_ID)) as Record< + string, + unknown + >; + expect("cursor" in stored).toBe(false); + expect("limit" in stored).toBe(false); + }); +}); diff --git a/frontend/src/features/tickets/useProjectAgents.ts b/frontend/src/features/tickets/useProjectAgents.ts index 3393b04..0fcbe93 100644 --- a/frontend/src/features/tickets/useProjectAgents.ts +++ b/frontend/src/features/tickets/useProjectAgents.ts @@ -14,21 +14,35 @@ export interface ProjectAgents { agents: Agent[]; /** Maps an agent id to a display name, falling back to a short id. */ nameOf: (agentId: string) => string; + /** + * `true` once the agent roster has been fetched for the current project. + * Callers that reconcile persisted state against known agents (ticket #29) + * must wait for this so a valid assignee is not dropped during the load window. + */ + loaded: boolean; } export function useProjectAgents(projectId: string): ProjectAgents { const { agent } = useGateways(); const [agents, setAgents] = useState([]); + const [loaded, setLoaded] = useState(false); useEffect(() => { let cancelled = false; + setLoaded(false); void agent .listAgents(projectId) .then((list) => { - if (!cancelled) setAgents(list); + if (!cancelled) { + setAgents(list); + setLoaded(true); + } }) .catch(() => { - if (!cancelled) setAgents([]); + if (!cancelled) { + setAgents([]); + setLoaded(true); + } }); return () => { cancelled = true; @@ -43,5 +57,5 @@ export function useProjectAgents(projectId: string): ProjectAgents { const nameOf = (agentId: string): string => byId.get(agentId) ?? `${agentId.slice(0, 8)}…`; - return { agents, nameOf }; + return { agents, nameOf, loaded }; } diff --git a/frontend/src/features/tickets/useTicketSearch.ts b/frontend/src/features/tickets/useTicketSearch.ts index 55f1651..4ac668a 100644 --- a/frontend/src/features/tickets/useTicketSearch.ts +++ b/frontend/src/features/tickets/useTicketSearch.ts @@ -20,6 +20,11 @@ import { useCallback, useEffect, useMemo, useRef, useState } from "react"; +import { + parseTicketFilters, + sanitizePickerFilters, +} from "./ticketFilterPersistence"; + import type { GatewayError, TicketPriority, @@ -62,6 +67,16 @@ export interface UseTicketSearchOptions { * (#37) enables it so its independent query stays in sync with add/remove. */ refreshOnEvents?: boolean; + /** + * Storage key under which this surface persists its filters (ticket #29). When + * set, the facets/text/sort are hydrated from it at mount and re-persisted on + * change — but **`initialQuery` wins per field**: any criterion the caller + * imposes (a business filter such as a sprint context) overrides the persisted + * preference for that field. `assignedAgentId`, `limit` and the opaque `cursor` + * are never persisted here (picker surface). When omitted, no persistence + * happens (default) so unrelated `useTicketSearch` call-sites are unaffected. + */ + persistenceKey?: string; } export interface TicketSearchViewModel { @@ -110,20 +125,48 @@ export function useTicketSearch( projectId: string, options: UseTicketSearchOptions = {}, ): TicketSearchViewModel { - const { initialQuery, excludeRefs, debounceMs = 200, refreshOnEvents } = - options; - const { ticket, system } = useGateways(); + const { + initialQuery, + excludeRefs, + debounceMs = 200, + refreshOnEvents, + persistenceKey, + } = options; + const { ticket, system, uiPreferences } = useGateways(); + + // Seed the initial facets/text/sort once (ticket #29): the persisted + // preference is the base, but `initialQuery` overrides it per field so a + // caller-imposed business filter always wins over a stored one. + const seedRef = useRef<{ + statuses?: TicketStatus[]; + priorities?: TicketPriority[]; + assignedAgentId?: string; + text?: string; + sort?: TicketListSort; + } | null>(null); + if (seedRef.current === null) { + const persisted = + persistenceKey && uiPreferences + ? parseTicketFilters(uiPreferences.read(persistenceKey)) + : {}; + seedRef.current = { + statuses: initialQuery?.statuses ?? persisted.statuses, + priorities: initialQuery?.priorities ?? persisted.priorities, + assignedAgentId: initialQuery?.assignedAgentId ?? persisted.assignedAgentId, + text: initialQuery?.text ?? persisted.text, + sort: initialQuery?.sort ?? persisted.sort, + }; + } + const seed = seedRef.current; const [facets, setFacets] = useState({ - statuses: initialQuery?.statuses, - priorities: initialQuery?.priorities, - assignedAgentId: initialQuery?.assignedAgentId, + statuses: seed.statuses, + priorities: seed.priorities, + assignedAgentId: seed.assignedAgentId, }); - const [text, setText] = useState(initialQuery?.text ?? ""); + const [text, setText] = useState(seed.text ?? ""); const [debouncedText, setDebouncedText] = useState(text); - const [sort, setSort] = useState( - initialQuery?.sort, - ); + const [sort, setSort] = useState(seed.sort); const [items, setItems] = useState([]); const [cursor, setCursor] = useState(undefined); @@ -149,6 +192,26 @@ export function useTicketSearch( return () => clearTimeout(id); }, [text, debounceMs]); + // Persist the picker's filters (ticket #29): text/statuses/priorities/sort + // only — never `assignedAgentId`, `limit` or the opaque `cursor`. Keyed by the + // caller-provided `persistenceKey`; a no-op when persistence is disabled. + useEffect(() => { + if (!persistenceKey || !uiPreferences) return; + const trimmed = debouncedText.trim(); + const sanitized = sanitizePickerFilters({ + ...(facets.statuses && facets.statuses.length > 0 + ? { statuses: facets.statuses } + : {}), + ...(facets.priorities && facets.priorities.length > 0 + ? { priorities: facets.priorities } + : {}), + ...(trimmed ? { text: trimmed } : {}), + ...(sort ? { sort } : {}), + }); + if (sanitized) uiPreferences.write(persistenceKey, sanitized); + else uiPreferences.remove(persistenceKey); + }, [persistenceKey, uiPreferences, facets, debouncedText, sort]); + // A key that changes whenever the effective query (minus cursor) changes, so // the first-page effect refetches exactly when a criterion moves. const queryKey = useMemo( diff --git a/frontend/src/features/tickets/useTickets.ts b/frontend/src/features/tickets/useTickets.ts index 624c1d6..118c7a1 100644 --- a/frontend/src/features/tickets/useTickets.ts +++ b/frontend/src/features/tickets/useTickets.ts @@ -11,7 +11,7 @@ * or out of a sprint. Sprint lifecycle events refresh the sprint list. */ -import { useCallback, useEffect, useMemo, useState } from "react"; +import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { isSprintEvent, @@ -25,6 +25,10 @@ import { } from "@/domain"; import type { CreateTicketInput, TicketListQuery } from "@/ports"; import { useGateways } from "@/app/di"; +import { + hydrateListFilters, + persistListFilters, +} from "./ticketFilterPersistence"; export interface TicketsViewModel { list: TicketList | null; @@ -76,12 +80,33 @@ function describe(e: unknown): string { } export function useTickets(projectId: string): TicketsViewModel { - const { ticket, system } = useGateways(); + const { ticket, system, uiPreferences } = useGateways(); const [list, setList] = useState(null); const [sprints, setSprints] = useState([]); const [error, setError] = useState(null); const [busy, setBusy] = useState(false); - const [query, setQuery] = useState({}); + // Hydrate the filters persisted for this project (ticket #29) on first render + // so reopening the window restores them without a flash of defaults. The + // assignee, if any, is validated for shape here and reconciled against the + // live agent roster by the panel (an obsolete assignee is then cleared). + const [query, setQuery] = useState(() => + hydrateListFilters(uiPreferences, projectId), + ); + + // Re-hydrate when the hosting project changes (the mounted hook is normally + // pinned to one project, but stay correct if `projectId` is swapped in place). + const hydratedProject = useRef(projectId); + useEffect(() => { + if (hydratedProject.current === projectId) return; + hydratedProject.current = projectId; + setQuery(hydrateListFilters(uiPreferences, projectId)); + }, [projectId, uiPreferences]); + + // Persist the filters (minus the opaque cursor) whenever they change, scoped + // to this project's list key. + useEffect(() => { + persistListFilters(uiPreferences, projectId, query); + }, [uiPreferences, projectId, query]); // Stable dependency key so `refresh` only changes when a filter actually does. const queryKey = useMemo(() => JSON.stringify(query), [query]); diff --git a/frontend/src/ports/index.ts b/frontend/src/ports/index.ts index 954cd60..e2fa381 100644 --- a/frontend/src/ports/index.ts +++ b/frontend/src/ports/index.ts @@ -971,6 +971,29 @@ export interface WindowGateway { ): Promise; } +/** + * Thin UI-preferences port (ticket #29). Persists small, per-surface pieces of + * UI state (like the ticket filters) so they survive a restart / reopen. This is + * **frontend-owned** state: the backend stays the source of tickets, never of UI + * preferences. Values are opaque JSON blobs addressed by an explicit string key; + * the adapter serialises/parses them. Reads are best-effort — an absent or + * corrupt entry yields `null` so callers fall back to their default state, and + * writes never throw (a full/blocked store degrades to "not persisted"). + * + * Deliberately synchronous: hydration happens during a component's first render, + * so the persisted state is applied without a flash of default filters. The + * localStorage adapter is synchronous; a future async backend (Tauri store) + * would front this with an in-memory cache to keep the contract. + */ +export interface UiPreferencesGateway { + /** Reads and JSON-parses the blob at `key`; `null` when absent or invalid. */ + read(key: string): unknown; + /** JSON-serialises and persists `value` at `key`. Best-effort (never throws). */ + write(key: string, value: unknown): void; + /** Removes any persisted blob at `key`. Best-effort (never throws). */ + remove(key: string): void; +} + /** * The full set of gateways the app depends on, injected via the DI provider. * The composition (real vs mock) is chosen in `app/`. @@ -995,4 +1018,5 @@ export interface Gateways { conversation: ConversationGateway; ticket: TicketGateway; window: WindowGateway; + uiPreferences: UiPreferencesGateway; }