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:
2026-07-13 07:45:50 +02:00
parent 9ee7290cde
commit 79f06c26d9
13 changed files with 793 additions and 20 deletions

View File

@ -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(