Files
IdeA/frontend/src/features/tickets/useTicketSearch.ts
Blomios 1c28754b58 feat(tickets): conservation des tickets en sprint, affichage du statut & filtres (#37)
Les tickets restent rattachés à leur sprint ; SprintManager affiche le
statut et propose des filtres via useTicketSearch. Frontend / read-query.

QA vert : tsc --noEmit exit 0, vitest 62 fichiers / 623 tests passés.

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

322 lines
11 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 { isSprintEvent, isTicketEvent } 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;
/**
* When true, subscribe to `Issue*`/`sprint*` domain events and re-fetch the
* first page on any ticket/sprint mutation. Off by default because the
* {@link TicketPicker} popup is short-lived and only reads; the sprint view
* (#37) enables it so its independent query stays in sync with add/remove.
*/
refreshOnEvents?: boolean;
}
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;
/** Forces a first-page re-fetch (e.g. after an external mutation). */
refresh: () => 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, refreshOnEvents } =
options;
const { ticket, system } = 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);
// Bumped to force a first-page re-fetch without changing the query (used by
// `refresh` and the domain-event subscription).
const [reloadNonce, setReloadNonce] = useState(0);
const refresh = useCallback(() => setReloadNonce((n) => n + 1), []);
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).
// `reloadNonce` is an explicit refetch trigger (refresh / domain events).
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [queryKey, projectId, ticket, reloadNonce]);
// Optional live refresh: on any ticket/sprint mutation, re-fetch the first
// page so an externally-driven change (e.g. sprint add/remove) reflects here.
useEffect(() => {
if (!refreshOnEvents || !system) return;
let unsubscribe: (() => void) | undefined;
let cancelled = false;
void system
.onDomainEvent((event) => {
if (isTicketEvent(event) || isSprintEvent(event)) {
setReloadNonce((n) => n + 1);
}
})
.then((u) => {
if (cancelled) u();
else unsubscribe = u;
});
return () => {
cancelled = true;
unsubscribe?.();
};
}, [refreshOnEvents, system]);
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,
refresh,
resolve,
};
}