feat(tickets): popup réutilisable de sélection de ticket (#18)
Ajoute TicketPicker, popup modale réutilisable de sélection de ticket, socle du chemin critique du sprint UI rework (#17 et #19 en dépendent). - TicketPicker.tsx : popup de recherche/sélection réutilisable - useTicketSearch.ts : hook de recherche/filtrage extrait et partagé - TicketFacetsBar.tsx : barre de facettes extraite de TicketsPanel pour réutilisation (listing principal + picker) - shared/ui/zIndex.ts : échelle z-index centralisée - TicketsPanel : consomme la barre de facettes extraite Tests : TicketPicker.test.tsx 8/8, suite tickets 37/37, tsc --noEmit clean. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
272
frontend/src/features/tickets/useTicketSearch.ts
Normal file
272
frontend/src/features/tickets/useTicketSearch.ts
Normal file
@ -0,0 +1,272 @@
|
||||
/**
|
||||
* 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 } 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;
|
||||
/** 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 [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(), limit }),
|
||||
[facets, debouncedText, 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 } : {}),
|
||||
...(limit ? { limit } : {}),
|
||||
// G3-bis: opaque token, relayed verbatim from the previous page only.
|
||||
...(nextCursor ? { cursor: nextCursor } : {}),
|
||||
};
|
||||
},
|
||||
[facets, debouncedText, 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,
|
||||
hasMore: cursor !== undefined,
|
||||
loadMore,
|
||||
resolve,
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user