feat(tickets): persistance des filtres tickets entre redémarrages (#29)
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 <noreply@anthropic.com>
This commit is contained in:
@ -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<TicketPickerProps, "excludeRefs" | "initialQuery">) {
|
||||
const vm = useTicketSearch(projectId, { initialQuery, excludeRefs });
|
||||
const vm = useTicketSearch(projectId, {
|
||||
initialQuery,
|
||||
excludeRefs,
|
||||
persistenceKey: persistFilters ? pickerFiltersKey(projectId) : undefined,
|
||||
});
|
||||
const dialogRef = useRef<HTMLDivElement>(null);
|
||||
const [selecting, setSelecting] = useState(false);
|
||||
const [resolveError, setResolveError] = useState<string | null>(null);
|
||||
|
||||
@ -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("");
|
||||
|
||||
116
frontend/src/features/tickets/ticketFilterPersistence.test.ts
Normal file
116
frontend/src/features/tickets/ticketFilterPersistence.test.ts
Normal file
@ -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();
|
||||
});
|
||||
});
|
||||
231
frontend/src/features/tickets/ticketFilterPersistence.ts
Normal file
231
frontend/src/features/tickets/ticketFilterPersistence.ts
Normal file
@ -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:<projectId>` — the main tickets window.
|
||||
* - `tickets:pickerFilters:<projectId>` — 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<string, unknown> {
|
||||
return typeof v === "object" && v !== null && !Array.isArray(v);
|
||||
}
|
||||
|
||||
/** Keeps only the members of `allowed`, de-duplicated, order preserved. */
|
||||
function filterEnumArray<T extends string>(
|
||||
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<string>;
|
||||
}
|
||||
|
||||
/**
|
||||
* 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);
|
||||
}
|
||||
@ -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(
|
||||
<DIProvider gateways={gateways}>
|
||||
<TicketsPanel projectId={PROJECT_ID} onOpen={vi.fn()} />
|
||||
</DIProvider>,
|
||||
);
|
||||
}
|
||||
|
||||
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<string, unknown>
|
||||
| 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<React.ComponentProps<typeof TicketPicker>> = {},
|
||||
) {
|
||||
const gateways = { system, ticket, uiPreferences: prefs } as unknown as Gateways;
|
||||
return render(
|
||||
<DIProvider gateways={gateways}>
|
||||
<TicketPicker
|
||||
open
|
||||
projectId={PROJECT_ID}
|
||||
onSelect={vi.fn()}
|
||||
onClose={vi.fn()}
|
||||
{...props}
|
||||
/>
|
||||
</DIProvider>,
|
||||
);
|
||||
}
|
||||
|
||||
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<string, unknown>
|
||||
| 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);
|
||||
});
|
||||
});
|
||||
@ -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<Agent[]>([]);
|
||||
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 };
|
||||
}
|
||||
|
||||
@ -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<Facets>({
|
||||
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<TicketListSort | undefined>(
|
||||
initialQuery?.sort,
|
||||
);
|
||||
const [sort, setSort] = useState<TicketListSort | undefined>(seed.sort);
|
||||
|
||||
const [items, setItems] = useState<TicketSummary[]>([]);
|
||||
const [cursor, setCursor] = useState<string | undefined>(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(
|
||||
|
||||
@ -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<TicketList | null>(null);
|
||||
const [sprints, setSprints] = useState<Sprint[]>([]);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [query, setQuery] = useState<TicketListQuery>({});
|
||||
// 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<TicketListQuery>(() =>
|
||||
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]);
|
||||
|
||||
Reference in New Issue
Block a user