Files
IdeA/frontend/src/features/tickets/useTicketSearch.ts
Blomios bd73fd79bd feat(tickets): contrôle « Trier par… » dans la barre de facettes (#21)
Ajoute le sélecteur de tri dans TicketFacetsBar et propage le critère
via useTicketSearch jusqu'à TicketsPanel et TicketPicker, sur le contrat
de tri backend (e523f44). Couvre le tri par les tests tickets et
TicketPicker.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-07 11:54:28 +02:00

283 lines
9.2 KiB
TypeScript

/**
* Lightweight ticket search view-model for the {@link TicketPicker} popup (#18).
*
* A deliberately slim alternative to {@link useTickets}: it wraps only
* `TicketGateway.list` with the status/priority facets and a debounced free-text
* search, plus client-side `excludeRefs` filtering and opaque-cursor pagination.
* It does NOT group by sprint, subscribe to domain events, or expose
* create/assign — the picker only ever reads and selects.
*
* ── Contract guards ──
* - G3-bis: `cursor` is an **opaque token**. We never build or increment it; we
* only relay the `nextCursor` the previous page returned. A backend migration
* to opaque cursors is therefore zero-change here.
* - `excludeRefs` is filtered **client-side** after each page arrives (the
* backend has no "exclude" criterion).
* - `TicketSummary` from `ticket_list` carries no `id`/`number`, so {@link
* TicketSearchViewModel.resolve} reads the ticket on selection to build the
* full {@link TicketPickerResult}.
*/
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import type {
GatewayError,
TicketPriority,
TicketRef,
TicketStatus,
TicketSummary,
} from "@/domain";
import type { TicketListQuery, TicketListSort } from "@/ports";
import { useGateways } from "@/app/di";
/**
* The selection payload the picker hands back (frozen contract, #18). `number`
* is the numeric form of `ref` and `id` is the stable ticket id — both resolved
* via a single `ticket_read` on selection since `ticket_list` omits them.
*/
export interface TicketPickerResult {
ref: TicketRef;
id: string;
number: number;
title: string;
status: TicketStatus;
priority: TicketPriority;
}
export interface UseTicketSearchOptions {
/**
* Seed query. `statuses`/`priorities`/`assignedAgentId`/`text`/`limit` are
* honored; `cursor` is ignored (pagination always starts at the first page).
*/
initialQuery?: TicketListQuery;
/** Refs to hide from the results, filtered client-side after each page. */
excludeRefs?: TicketRef[];
/** Debounce applied to the free-text search, in ms (default 200). */
debounceMs?: number;
}
export interface TicketSearchViewModel {
/** Result rows for the current query, accumulated across loaded pages, with
* `excludeRefs` removed. */
rows: TicketSummary[];
busy: boolean;
error: string | null;
/** Raw (un-debounced) search text — bind straight to the input. */
text: string;
setText: (text: string) => void;
statuses: TicketStatus[];
priorities: TicketPriority[];
toggleStatus: (status: TicketStatus) => void;
togglePriority: (priority: TicketPriority) => void;
clearFacets: () => void;
/** Current sort (#21); `undefined` ⇒ the backend's historical order. */
sort: TicketListSort | undefined;
/** Sets the sort (or clears it); resets pagination to the first page. */
setSort: (sort: TicketListSort | undefined) => void;
/** True when the backend reported a further page (opaque cursor present). */
hasMore: boolean;
/** Loads and appends the next page (no-op when exhausted or busy). */
loadMore: () => void;
/** Resolves a row's `#ref` to the full picker result (reads id + number). */
resolve: (ref: TicketRef) => Promise<TicketPickerResult>;
}
function describe(e: unknown): string {
if (e && typeof e === "object" && "message" in e) {
return String((e as GatewayError).message);
}
return String(e);
}
/** Facet subset of the query we own locally (text is handled via debounce). */
interface Facets {
statuses?: TicketStatus[];
priorities?: TicketPriority[];
assignedAgentId?: string;
}
export function useTicketSearch(
projectId: string,
options: UseTicketSearchOptions = {},
): TicketSearchViewModel {
const { initialQuery, excludeRefs, debounceMs = 200 } = options;
const { ticket } = useGateways();
const [facets, setFacets] = useState<Facets>({
statuses: initialQuery?.statuses,
priorities: initialQuery?.priorities,
assignedAgentId: initialQuery?.assignedAgentId,
});
const [text, setText] = useState(initialQuery?.text ?? "");
const [debouncedText, setDebouncedText] = useState(text);
const [sort, setSort] = useState<TicketListSort | undefined>(
initialQuery?.sort,
);
const [items, setItems] = useState<TicketSummary[]>([]);
const [cursor, setCursor] = useState<string | undefined>(undefined);
const [busy, setBusy] = useState(false);
const [error, setError] = useState<string | null>(null);
const limit = initialQuery?.limit;
// Stable set so identity only changes when the caller passes new refs.
const excludeKey = (excludeRefs ?? []).join(",");
const excluded = useMemo(
() => new Set(excludeRefs ?? []),
// eslint-disable-next-line react-hooks/exhaustive-deps
[excludeKey],
);
// Debounce the free-text search so typing doesn't refetch on every keystroke.
useEffect(() => {
const id = setTimeout(() => setDebouncedText(text), debounceMs);
return () => clearTimeout(id);
}, [text, debounceMs]);
// 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(
() => JSON.stringify({ facets, text: debouncedText.trim(), sort, limit }),
[facets, debouncedText, sort, limit],
);
// Guards against races: only the latest first-page/loadMore fetch may commit.
const fetchToken = useRef(0);
const buildQuery = useCallback(
(nextCursor?: string): TicketListQuery => {
const trimmed = debouncedText.trim();
return {
...(facets.statuses && facets.statuses.length > 0
? { statuses: facets.statuses }
: {}),
...(facets.priorities && facets.priorities.length > 0
? { priorities: facets.priorities }
: {}),
...(facets.assignedAgentId
? { assignedAgentId: facets.assignedAgentId }
: {}),
...(trimmed ? { text: trimmed } : {}),
...(sort ? { sort } : {}),
...(limit ? { limit } : {}),
// G3-bis: opaque token, relayed verbatim from the previous page only.
...(nextCursor ? { cursor: nextCursor } : {}),
};
},
[facets, debouncedText, sort, limit],
);
// First page: (re)fetch whenever the query key changes; replaces the list.
useEffect(() => {
const token = ++fetchToken.current;
setBusy(true);
setError(null);
void ticket
.list(projectId, buildQuery())
.then((page) => {
if (token !== fetchToken.current) return;
setItems(page.items);
setCursor(page.nextCursor);
})
.catch((e) => {
if (token !== fetchToken.current) return;
setError(describe(e));
setItems([]);
setCursor(undefined);
})
.finally(() => {
if (token === fetchToken.current) setBusy(false);
});
// `buildQuery`/`projectId`/`ticket` are captured via `queryKey`; listing
// them would refetch on every render (fresh object identities).
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [queryKey, projectId, ticket]);
const loadMore = useCallback(() => {
if (busy || !cursor) return;
const token = ++fetchToken.current;
setBusy(true);
setError(null);
void ticket
.list(projectId, buildQuery(cursor))
.then((page) => {
if (token !== fetchToken.current) return;
setItems((prev) => [...prev, ...page.items]);
setCursor(page.nextCursor);
})
.catch((e) => {
if (token !== fetchToken.current) return;
setError(describe(e));
})
.finally(() => {
if (token === fetchToken.current) setBusy(false);
});
}, [busy, cursor, ticket, projectId, buildQuery]);
const toggleStatus = useCallback((status: TicketStatus) => {
setFacets((prev) => {
const current = prev.statuses ?? [];
const next = current.includes(status)
? current.filter((s) => s !== status)
: [...current, status];
return { ...prev, statuses: next.length > 0 ? next : undefined };
});
}, []);
const togglePriority = useCallback((priority: TicketPriority) => {
setFacets((prev) => {
const current = prev.priorities ?? [];
const next = current.includes(priority)
? current.filter((p) => p !== priority)
: [...current, priority];
return { ...prev, priorities: next.length > 0 ? next : undefined };
});
}, []);
const clearFacets = useCallback(() => {
setFacets((prev) => ({
...prev,
statuses: undefined,
priorities: undefined,
}));
}, []);
const resolve = useCallback(
async (ref: TicketRef): Promise<TicketPickerResult> => {
const t = await ticket.read(projectId, ref);
return {
ref: t.ref,
id: t.id,
number: t.number,
title: t.title,
status: t.status,
priority: t.priority,
};
},
[ticket, projectId],
);
const rows = useMemo(
() => (excluded.size === 0 ? items : items.filter((t) => !excluded.has(t.ref))),
[items, excluded],
);
return {
rows,
busy,
error,
text,
setText,
statuses: facets.statuses ?? [],
priorities: facets.priorities ?? [],
toggleStatus,
togglePriority,
clearFacets,
sort,
setSort,
hasMore: cursor !== undefined,
loadMore,
resolve,
};
}