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>
322 lines
11 KiB
TypeScript
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,
|
|
};
|
|
}
|