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:
@ -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(
|
||||
|
||||
Reference in New Issue
Block a user