Passe les filtres de la liste des tickets en sélection multiple (#12). Backend Rust : - IssueListFilter : statuses/priorities en Vec, filter_matches en OR intra-champ et AND inter-champs - TicketListRequestDto en tableaux + from_request (parse/déduplication) - MCP idea_ticket_list aligné sur le nouveau contrat Frontend : - TicketListQuery.statuses/priorities - UI de cases à cocher, toggle/clear des filtres Tests verts : issue_store (7), app-tauri --lib (59), mcp_server (24), issue_usecases (6), sprint_usecases (6), frontend vitest (505), npm run build (exit 0). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
298 lines
9.5 KiB
TypeScript
298 lines
9.5 KiB
TypeScript
/**
|
|
* View-model hook for the project ticket list (F2).
|
|
*
|
|
* Consumes the {@link TicketGateway} for reads/creates and subscribes to the
|
|
* `Issue*` domain events (via {@link SystemGateway}) to refresh the list on any
|
|
* ticket mutation — including those made by agents through MCP. Filters/search
|
|
* are part of the query and drive a re-fetch when they change.
|
|
*
|
|
* Sprints (ticket #10): the hook also loads the project's sprints so the panel
|
|
* can group tickets by sprint, and exposes `assignSprint` to move a ticket into
|
|
* or out of a sprint. Sprint lifecycle events refresh the sprint list.
|
|
*/
|
|
|
|
import { useCallback, useEffect, useMemo, useState } from "react";
|
|
|
|
import {
|
|
isSprintEvent,
|
|
isTicketEvent,
|
|
type GatewayError,
|
|
type Sprint,
|
|
type Ticket,
|
|
type TicketList,
|
|
type TicketPriority,
|
|
type TicketStatus,
|
|
} from "@/domain";
|
|
import type { CreateTicketInput, TicketListQuery } from "@/ports";
|
|
import { useGateways } from "@/app/di";
|
|
|
|
export interface TicketsViewModel {
|
|
list: TicketList | null;
|
|
/** The project's sprints, ordered by `order` (ticket #10). */
|
|
sprints: Sprint[];
|
|
error: string | null;
|
|
busy: boolean;
|
|
query: TicketListQuery;
|
|
setQuery: (next: TicketListQuery) => void;
|
|
/**
|
|
* Toggles a status in the multi-select filter (ticket #12): adds it when
|
|
* absent, removes it when present. An empty set ⇒ no status constraint (all).
|
|
* The list re-fetches automatically as the query changes.
|
|
*/
|
|
toggleStatus: (status: TicketStatus) => void;
|
|
/** Toggles a priority in the multi-select filter (ticket #12). */
|
|
togglePriority: (priority: TicketPriority) => void;
|
|
/** Clears both facet filters (status + priority), leaving other criteria. */
|
|
clearFacets: () => void;
|
|
refresh: () => Promise<void>;
|
|
create: (input: CreateTicketInput) => Promise<Ticket | null>;
|
|
/**
|
|
* Assigns a ticket to a sprint (or clears it with `sprintId === null`). Reads
|
|
* the ticket's current version first so the summary-based list needs no
|
|
* version column. Refreshes on the resulting `issueSprintChanged` event.
|
|
*/
|
|
assignSprint: (ref: string, sprintId: string | null) => Promise<boolean>;
|
|
/** Creates a sprint (#11). Returns the created sprint, or `null` on error. */
|
|
createSprint: (name: string) => Promise<Sprint | null>;
|
|
/** Renames a sprint using its current version (#11). */
|
|
renameSprint: (sprintId: string, name: string) => Promise<boolean>;
|
|
/**
|
|
* Moves a sprint one slot up/down in execution order via `reorderSprints`
|
|
* (#11). A no-op at the ends. Accessible (button-driven), non-DnD.
|
|
*/
|
|
moveSprint: (sprintId: string, direction: "up" | "down") => Promise<boolean>;
|
|
/**
|
|
* Deletes a sprint (#11). The backend unassigns its tickets (does NOT delete
|
|
* them). Refreshes both the sprint list and the tickets.
|
|
*/
|
|
deleteSprint: (sprintId: string) => Promise<boolean>;
|
|
}
|
|
|
|
function describe(e: unknown): string {
|
|
if (e && typeof e === "object" && "message" in e) {
|
|
return String((e as GatewayError).message);
|
|
}
|
|
return String(e);
|
|
}
|
|
|
|
export function useTickets(projectId: string): TicketsViewModel {
|
|
const { ticket, system } = useGateways();
|
|
const [list, setList] = useState<TicketList | null>(null);
|
|
const [sprints, setSprints] = useState<Sprint[]>([]);
|
|
const [error, setError] = useState<string | null>(null);
|
|
const [busy, setBusy] = useState(false);
|
|
const [query, setQuery] = useState<TicketListQuery>({});
|
|
|
|
// Stable dependency key so `refresh` only changes when a filter actually does.
|
|
const queryKey = useMemo(() => JSON.stringify(query), [query]);
|
|
|
|
const refresh = useCallback(async () => {
|
|
setBusy(true);
|
|
setError(null);
|
|
try {
|
|
setList(await ticket.list(projectId, query));
|
|
} catch (e) {
|
|
setError(describe(e));
|
|
} finally {
|
|
setBusy(false);
|
|
}
|
|
// `query` is captured via `queryKey`; listing it directly would rebuild the
|
|
// callback on every render (a new object identity each time).
|
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
}, [projectId, ticket, queryKey]);
|
|
|
|
// Toggle a value in one facet set, dropping the key entirely when it empties
|
|
// so the query stays clean ("no constraint"). Setting a fresh object identity
|
|
// re-fetches via the `queryKey` effect (ticket #12).
|
|
const toggleStatus = useCallback((status: TicketStatus) => {
|
|
setQuery((prev) => {
|
|
const current = prev.statuses ?? [];
|
|
const next = current.includes(status)
|
|
? current.filter((s) => s !== status)
|
|
: [...current, status];
|
|
const { statuses: _drop, ...rest } = prev;
|
|
return next.length > 0 ? { ...rest, statuses: next } : rest;
|
|
});
|
|
}, []);
|
|
|
|
const togglePriority = useCallback((priority: TicketPriority) => {
|
|
setQuery((prev) => {
|
|
const current = prev.priorities ?? [];
|
|
const next = current.includes(priority)
|
|
? current.filter((p) => p !== priority)
|
|
: [...current, priority];
|
|
const { priorities: _drop, ...rest } = prev;
|
|
return next.length > 0 ? { ...rest, priorities: next } : rest;
|
|
});
|
|
}, []);
|
|
|
|
const clearFacets = useCallback(() => {
|
|
setQuery(({ statuses: _s, priorities: _p, ...rest }) => rest);
|
|
}, []);
|
|
|
|
const refreshSprints = useCallback(async () => {
|
|
try {
|
|
setSprints(await ticket.listSprints(projectId));
|
|
} catch (e) {
|
|
setError(describe(e));
|
|
}
|
|
}, [projectId, ticket]);
|
|
|
|
const create = useCallback(
|
|
async (input: CreateTicketInput): Promise<Ticket | null> => {
|
|
setError(null);
|
|
try {
|
|
const created = await ticket.create(projectId, input);
|
|
await refresh();
|
|
return created;
|
|
} catch (e) {
|
|
setError(describe(e));
|
|
return null;
|
|
}
|
|
},
|
|
[projectId, ticket, refresh],
|
|
);
|
|
|
|
const assignSprint = useCallback(
|
|
async (ref: string, sprintId: string | null): Promise<boolean> => {
|
|
setError(null);
|
|
try {
|
|
const current = await ticket.read(projectId, ref);
|
|
await ticket.setTicketSprint(projectId, ref, sprintId, current.version);
|
|
// The `issueSprintChanged` event refreshes the list; refresh the sprint
|
|
// counts too (and update immediately in case events are not wired).
|
|
await Promise.all([refresh(), refreshSprints()]);
|
|
return true;
|
|
} catch (e) {
|
|
setError(describe(e));
|
|
return false;
|
|
}
|
|
},
|
|
[projectId, ticket, refresh, refreshSprints],
|
|
);
|
|
|
|
const createSprint = useCallback(
|
|
async (name: string): Promise<Sprint | null> => {
|
|
setError(null);
|
|
try {
|
|
const created = await ticket.createSprint(projectId, name);
|
|
await refreshSprints();
|
|
return created;
|
|
} catch (e) {
|
|
setError(describe(e));
|
|
return null;
|
|
}
|
|
},
|
|
[projectId, ticket, refreshSprints],
|
|
);
|
|
|
|
const renameSprint = useCallback(
|
|
async (sprintId: string, name: string): Promise<boolean> => {
|
|
setError(null);
|
|
try {
|
|
const current = sprints.find((s) => s.id === sprintId);
|
|
if (!current) return false;
|
|
await ticket.renameSprint(projectId, sprintId, name, current.version);
|
|
await refreshSprints();
|
|
return true;
|
|
} catch (e) {
|
|
setError(describe(e));
|
|
return false;
|
|
}
|
|
},
|
|
[projectId, ticket, sprints, refreshSprints],
|
|
);
|
|
|
|
const moveSprint = useCallback(
|
|
async (sprintId: string, direction: "up" | "down"): Promise<boolean> => {
|
|
setError(null);
|
|
const ordered = [...sprints].sort((a, b) => a.order - b.order);
|
|
const index = ordered.findIndex((s) => s.id === sprintId);
|
|
const swapWith = direction === "up" ? index - 1 : index + 1;
|
|
if (index === -1 || swapWith < 0 || swapWith >= ordered.length) {
|
|
return false;
|
|
}
|
|
const next = [...ordered];
|
|
[next[index], next[swapWith]] = [next[swapWith], next[index]];
|
|
try {
|
|
await ticket.reorderSprints(
|
|
projectId,
|
|
next.map((s) => s.id),
|
|
);
|
|
await refreshSprints();
|
|
return true;
|
|
} catch (e) {
|
|
setError(describe(e));
|
|
return false;
|
|
}
|
|
},
|
|
[projectId, ticket, sprints, refreshSprints],
|
|
);
|
|
|
|
const deleteSprint = useCallback(
|
|
async (sprintId: string): Promise<boolean> => {
|
|
setError(null);
|
|
try {
|
|
await ticket.deleteSprint(projectId, sprintId);
|
|
// Delete unassigns tickets → refresh both projections.
|
|
await Promise.all([refresh(), refreshSprints()]);
|
|
return true;
|
|
} catch (e) {
|
|
setError(describe(e));
|
|
return false;
|
|
}
|
|
},
|
|
[projectId, ticket, refresh, refreshSprints],
|
|
);
|
|
|
|
useEffect(() => {
|
|
void refresh();
|
|
}, [refresh]);
|
|
|
|
useEffect(() => {
|
|
void refreshSprints();
|
|
}, [refreshSprints]);
|
|
|
|
useEffect(() => {
|
|
if (!system) return;
|
|
let unsubscribe: (() => void) | undefined;
|
|
let cancelled = false;
|
|
void system
|
|
.onDomainEvent((event) => {
|
|
if (isTicketEvent(event)) void refresh();
|
|
// Sprint membership (`issueSprintChanged`) and sprint lifecycle events
|
|
// both change the grouping → refresh the sprint list.
|
|
if (isSprintEvent(event) || event.type === "issueSprintChanged") {
|
|
void refreshSprints();
|
|
}
|
|
})
|
|
.then((u) => {
|
|
if (cancelled) u();
|
|
else unsubscribe = u;
|
|
});
|
|
return () => {
|
|
cancelled = true;
|
|
unsubscribe?.();
|
|
};
|
|
}, [refresh, refreshSprints, system]);
|
|
|
|
return {
|
|
list,
|
|
sprints,
|
|
error,
|
|
busy,
|
|
query,
|
|
setQuery,
|
|
toggleStatus,
|
|
togglePriority,
|
|
clearFacets,
|
|
refresh,
|
|
create,
|
|
assignSprint,
|
|
createSprint,
|
|
renameSprint,
|
|
moveSprint,
|
|
deleteSprint,
|
|
};
|
|
}
|