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:
@ -30,6 +30,7 @@ import { TauriWorkStateGateway } from "./workState";
|
|||||||
import { TauriConversationGateway } from "./conversation";
|
import { TauriConversationGateway } from "./conversation";
|
||||||
import { TauriTicketGateway } from "./ticket";
|
import { TauriTicketGateway } from "./ticket";
|
||||||
import { TauriWindowGateway } from "./window";
|
import { TauriWindowGateway } from "./window";
|
||||||
|
import { LocalStorageUiPreferencesGateway } from "./uiPreferences";
|
||||||
|
|
||||||
function notImplemented(what: string): never {
|
function notImplemented(what: string): never {
|
||||||
const err: GatewayError = {
|
const err: GatewayError = {
|
||||||
@ -67,6 +68,7 @@ export function createTauriGateways(): Gateways {
|
|||||||
conversation: new TauriConversationGateway(),
|
conversation: new TauriConversationGateway(),
|
||||||
ticket: new TauriTicketGateway(),
|
ticket: new TauriTicketGateway(),
|
||||||
window: new TauriWindowGateway(),
|
window: new TauriWindowGateway(),
|
||||||
|
uiPreferences: new LocalStorageUiPreferencesGateway(),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -89,4 +91,5 @@ export {
|
|||||||
TauriConversationGateway,
|
TauriConversationGateway,
|
||||||
TauriTicketGateway,
|
TauriTicketGateway,
|
||||||
TauriWindowGateway,
|
TauriWindowGateway,
|
||||||
|
LocalStorageUiPreferencesGateway,
|
||||||
};
|
};
|
||||||
|
|||||||
@ -88,6 +88,7 @@ import type {
|
|||||||
TicketListQuery,
|
TicketListQuery,
|
||||||
CreateTicketInput,
|
CreateTicketInput,
|
||||||
UpdateTicketInput,
|
UpdateTicketInput,
|
||||||
|
UiPreferencesGateway,
|
||||||
ViewWindowClosed,
|
ViewWindowClosed,
|
||||||
WindowGateway,
|
WindowGateway,
|
||||||
WorkStateGateway,
|
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<string, string>();
|
||||||
|
|
||||||
|
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(
|
function mostRestrictive(
|
||||||
project?: PermissionSet["fallback"],
|
project?: PermissionSet["fallback"],
|
||||||
agent?: PermissionSet["fallback"],
|
agent?: PermissionSet["fallback"],
|
||||||
@ -2563,6 +2596,7 @@ export function createMockGateways(): Gateways {
|
|||||||
conversation: new MockConversationGateway(),
|
conversation: new MockConversationGateway(),
|
||||||
ticket: new MockTicketGateway(systemGateway),
|
ticket: new MockTicketGateway(systemGateway),
|
||||||
window: new MockWindowGateway(),
|
window: new MockWindowGateway(),
|
||||||
|
uiPreferences: new MockUiPreferencesGateway(),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -31,6 +31,7 @@ describe("createMockGateways", () => {
|
|||||||
"template",
|
"template",
|
||||||
"terminal",
|
"terminal",
|
||||||
"ticket",
|
"ticket",
|
||||||
|
"uiPreferences",
|
||||||
"window",
|
"window",
|
||||||
"workState",
|
"workState",
|
||||||
]);
|
]);
|
||||||
|
|||||||
65
frontend/src/adapters/uiPreferences.ts
Normal file
65
frontend/src/adapters/uiPreferences.ts
Normal file
@ -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 */
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -31,6 +31,7 @@ import type { TicketRef } from "@/domain";
|
|||||||
import type { TicketListQuery } from "@/ports";
|
import type { TicketListQuery } from "@/ports";
|
||||||
import { Button, Spinner, cn, zIndex } from "@/shared";
|
import { Button, Spinner, cn, zIndex } from "@/shared";
|
||||||
import { TicketFacetsBar } from "./TicketFacetsBar";
|
import { TicketFacetsBar } from "./TicketFacetsBar";
|
||||||
|
import { pickerFiltersKey } from "./ticketFilterPersistence";
|
||||||
import { PriorityBadge, StatusBadge, TicketRef as TicketRefBadge } from "./ticketMeta";
|
import { PriorityBadge, StatusBadge, TicketRef as TicketRefBadge } from "./ticketMeta";
|
||||||
import {
|
import {
|
||||||
useTicketSearch,
|
useTicketSearch,
|
||||||
@ -55,6 +56,13 @@ export interface TicketPickerProps {
|
|||||||
excludeRefs?: TicketRef[];
|
excludeRefs?: TicketRef[];
|
||||||
/** Seed filter/search query. */
|
/** Seed filter/search query. */
|
||||||
initialQuery?: TicketListQuery;
|
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
|
* Selection callback. In `"single"` mode it receives one
|
||||||
* {@link TicketPickerResult}; in `"multi"` it receives the array.
|
* {@link TicketPickerResult}; in `"multi"` it receives the array.
|
||||||
@ -76,6 +84,7 @@ export function TicketPicker({
|
|||||||
selectionMode = "single",
|
selectionMode = "single",
|
||||||
excludeRefs,
|
excludeRefs,
|
||||||
initialQuery,
|
initialQuery,
|
||||||
|
persistFilters = true,
|
||||||
onSelect,
|
onSelect,
|
||||||
onClose,
|
onClose,
|
||||||
}: TicketPickerProps) {
|
}: TicketPickerProps) {
|
||||||
@ -88,6 +97,7 @@ export function TicketPicker({
|
|||||||
selectionMode={selectionMode}
|
selectionMode={selectionMode}
|
||||||
excludeRefs={excludeRefs}
|
excludeRefs={excludeRefs}
|
||||||
initialQuery={initialQuery}
|
initialQuery={initialQuery}
|
||||||
|
persistFilters={persistFilters}
|
||||||
onSelect={onSelect}
|
onSelect={onSelect}
|
||||||
onClose={onClose}
|
onClose={onClose}
|
||||||
/>
|
/>
|
||||||
@ -106,16 +116,27 @@ function TicketPickerBody({
|
|||||||
selectionMode,
|
selectionMode,
|
||||||
excludeRefs,
|
excludeRefs,
|
||||||
initialQuery,
|
initialQuery,
|
||||||
|
persistFilters,
|
||||||
onSelect,
|
onSelect,
|
||||||
onClose,
|
onClose,
|
||||||
}: Required<
|
}: Required<
|
||||||
Pick<
|
Pick<
|
||||||
TicketPickerProps,
|
TicketPickerProps,
|
||||||
"projectId" | "title" | "confirmLabel" | "selectionMode" | "onSelect" | "onClose"
|
| "projectId"
|
||||||
|
| "title"
|
||||||
|
| "confirmLabel"
|
||||||
|
| "selectionMode"
|
||||||
|
| "persistFilters"
|
||||||
|
| "onSelect"
|
||||||
|
| "onClose"
|
||||||
>
|
>
|
||||||
> &
|
> &
|
||||||
Pick<TicketPickerProps, "excludeRefs" | "initialQuery">) {
|
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 dialogRef = useRef<HTMLDivElement>(null);
|
||||||
const [selecting, setSelecting] = useState(false);
|
const [selecting, setSelecting] = useState(false);
|
||||||
const [resolveError, setResolveError] = useState<string | null>(null);
|
const [resolveError, setResolveError] = useState<string | null>(null);
|
||||||
|
|||||||
@ -7,7 +7,7 @@
|
|||||||
* through {@link useTickets}.
|
* through {@link useTickets}.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { useState } from "react";
|
import { useEffect, useState } from "react";
|
||||||
|
|
||||||
import type { Sprint, TicketPriority, TicketSummary } from "@/domain";
|
import type { Sprint, TicketPriority, TicketSummary } from "@/domain";
|
||||||
import { Button, Input, Panel, Spinner, cn } from "@/shared";
|
import { Button, Input, Panel, Spinner, cn } from "@/shared";
|
||||||
@ -38,7 +38,21 @@ export interface TicketsPanelProps {
|
|||||||
|
|
||||||
export function TicketsPanel({ projectId, onOpen }: TicketsPanelProps) {
|
export function TicketsPanel({ projectId, onOpen }: TicketsPanelProps) {
|
||||||
const vm = useTickets(projectId);
|
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 [showCreate, setShowCreate] = useState(false);
|
||||||
const [showSprints, setShowSprints] = useState(false);
|
const [showSprints, setShowSprints] = useState(false);
|
||||||
const [newTitle, setNewTitle] = useState("");
|
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[];
|
agents: Agent[];
|
||||||
/** Maps an agent id to a display name, falling back to a short id. */
|
/** Maps an agent id to a display name, falling back to a short id. */
|
||||||
nameOf: (agentId: string) => string;
|
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 {
|
export function useProjectAgents(projectId: string): ProjectAgents {
|
||||||
const { agent } = useGateways();
|
const { agent } = useGateways();
|
||||||
const [agents, setAgents] = useState<Agent[]>([]);
|
const [agents, setAgents] = useState<Agent[]>([]);
|
||||||
|
const [loaded, setLoaded] = useState(false);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
let cancelled = false;
|
let cancelled = false;
|
||||||
|
setLoaded(false);
|
||||||
void agent
|
void agent
|
||||||
.listAgents(projectId)
|
.listAgents(projectId)
|
||||||
.then((list) => {
|
.then((list) => {
|
||||||
if (!cancelled) setAgents(list);
|
if (!cancelled) {
|
||||||
|
setAgents(list);
|
||||||
|
setLoaded(true);
|
||||||
|
}
|
||||||
})
|
})
|
||||||
.catch(() => {
|
.catch(() => {
|
||||||
if (!cancelled) setAgents([]);
|
if (!cancelled) {
|
||||||
|
setAgents([]);
|
||||||
|
setLoaded(true);
|
||||||
|
}
|
||||||
});
|
});
|
||||||
return () => {
|
return () => {
|
||||||
cancelled = true;
|
cancelled = true;
|
||||||
@ -43,5 +57,5 @@ export function useProjectAgents(projectId: string): ProjectAgents {
|
|||||||
const nameOf = (agentId: string): string =>
|
const nameOf = (agentId: string): string =>
|
||||||
byId.get(agentId) ?? `${agentId.slice(0, 8)}…`;
|
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 { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||||
|
|
||||||
|
import {
|
||||||
|
parseTicketFilters,
|
||||||
|
sanitizePickerFilters,
|
||||||
|
} from "./ticketFilterPersistence";
|
||||||
|
|
||||||
import type {
|
import type {
|
||||||
GatewayError,
|
GatewayError,
|
||||||
TicketPriority,
|
TicketPriority,
|
||||||
@ -62,6 +67,16 @@ export interface UseTicketSearchOptions {
|
|||||||
* (#37) enables it so its independent query stays in sync with add/remove.
|
* (#37) enables it so its independent query stays in sync with add/remove.
|
||||||
*/
|
*/
|
||||||
refreshOnEvents?: boolean;
|
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 {
|
export interface TicketSearchViewModel {
|
||||||
@ -110,20 +125,48 @@ export function useTicketSearch(
|
|||||||
projectId: string,
|
projectId: string,
|
||||||
options: UseTicketSearchOptions = {},
|
options: UseTicketSearchOptions = {},
|
||||||
): TicketSearchViewModel {
|
): TicketSearchViewModel {
|
||||||
const { initialQuery, excludeRefs, debounceMs = 200, refreshOnEvents } =
|
const {
|
||||||
options;
|
initialQuery,
|
||||||
const { ticket, system } = useGateways();
|
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>({
|
const [facets, setFacets] = useState<Facets>({
|
||||||
statuses: initialQuery?.statuses,
|
statuses: seed.statuses,
|
||||||
priorities: initialQuery?.priorities,
|
priorities: seed.priorities,
|
||||||
assignedAgentId: initialQuery?.assignedAgentId,
|
assignedAgentId: seed.assignedAgentId,
|
||||||
});
|
});
|
||||||
const [text, setText] = useState(initialQuery?.text ?? "");
|
const [text, setText] = useState(seed.text ?? "");
|
||||||
const [debouncedText, setDebouncedText] = useState(text);
|
const [debouncedText, setDebouncedText] = useState(text);
|
||||||
const [sort, setSort] = useState<TicketListSort | undefined>(
|
const [sort, setSort] = useState<TicketListSort | undefined>(seed.sort);
|
||||||
initialQuery?.sort,
|
|
||||||
);
|
|
||||||
|
|
||||||
const [items, setItems] = useState<TicketSummary[]>([]);
|
const [items, setItems] = useState<TicketSummary[]>([]);
|
||||||
const [cursor, setCursor] = useState<string | undefined>(undefined);
|
const [cursor, setCursor] = useState<string | undefined>(undefined);
|
||||||
@ -149,6 +192,26 @@ export function useTicketSearch(
|
|||||||
return () => clearTimeout(id);
|
return () => clearTimeout(id);
|
||||||
}, [text, debounceMs]);
|
}, [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
|
// A key that changes whenever the effective query (minus cursor) changes, so
|
||||||
// the first-page effect refetches exactly when a criterion moves.
|
// the first-page effect refetches exactly when a criterion moves.
|
||||||
const queryKey = useMemo(
|
const queryKey = useMemo(
|
||||||
|
|||||||
@ -11,7 +11,7 @@
|
|||||||
* or out of a sprint. Sprint lifecycle events refresh the sprint list.
|
* 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 {
|
import {
|
||||||
isSprintEvent,
|
isSprintEvent,
|
||||||
@ -25,6 +25,10 @@ import {
|
|||||||
} from "@/domain";
|
} from "@/domain";
|
||||||
import type { CreateTicketInput, TicketListQuery } from "@/ports";
|
import type { CreateTicketInput, TicketListQuery } from "@/ports";
|
||||||
import { useGateways } from "@/app/di";
|
import { useGateways } from "@/app/di";
|
||||||
|
import {
|
||||||
|
hydrateListFilters,
|
||||||
|
persistListFilters,
|
||||||
|
} from "./ticketFilterPersistence";
|
||||||
|
|
||||||
export interface TicketsViewModel {
|
export interface TicketsViewModel {
|
||||||
list: TicketList | null;
|
list: TicketList | null;
|
||||||
@ -76,12 +80,33 @@ function describe(e: unknown): string {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function useTickets(projectId: string): TicketsViewModel {
|
export function useTickets(projectId: string): TicketsViewModel {
|
||||||
const { ticket, system } = useGateways();
|
const { ticket, system, uiPreferences } = useGateways();
|
||||||
const [list, setList] = useState<TicketList | null>(null);
|
const [list, setList] = useState<TicketList | null>(null);
|
||||||
const [sprints, setSprints] = useState<Sprint[]>([]);
|
const [sprints, setSprints] = useState<Sprint[]>([]);
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
const [busy, setBusy] = useState(false);
|
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.
|
// Stable dependency key so `refresh` only changes when a filter actually does.
|
||||||
const queryKey = useMemo(() => JSON.stringify(query), [query]);
|
const queryKey = useMemo(() => JSON.stringify(query), [query]);
|
||||||
|
|||||||
@ -971,6 +971,29 @@ export interface WindowGateway {
|
|||||||
): Promise<Unsubscribe>;
|
): Promise<Unsubscribe>;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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 full set of gateways the app depends on, injected via the DI provider.
|
||||||
* The composition (real vs mock) is chosen in `app/`.
|
* The composition (real vs mock) is chosen in `app/`.
|
||||||
@ -995,4 +1018,5 @@ export interface Gateways {
|
|||||||
conversation: ConversationGateway;
|
conversation: ConversationGateway;
|
||||||
ticket: TicketGateway;
|
ticket: TicketGateway;
|
||||||
window: WindowGateway;
|
window: WindowGateway;
|
||||||
|
uiPreferences: UiPreferencesGateway;
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user