feat(tickets): surface frontend V1 du système de tickets
Surface UI complète des tickets (domaine Issue exposé « ticket »), validée QA de bout en bout : cargo build + tests backend verts, npm run build + npm test (459) verts. - Domaine : DTO Ticket, 9 events Issue* + guard isTicketEvent. - Ports : TicketGateway (+ types query/input) ajouté à Gateways. - Adapters : TauriTicketGateway réel (+ isTicketVersionConflict), wiring, et MockTicketGateway pour les tests. - Feature tickets : hooks (useTickets, useTicketDetail, useProjectAgents), TicketsPanel, TicketDetail, TicketsView, ticketMeta + tests. - ProjectsView : onglet sidebar « Tickets ». Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
179
frontend/src/features/tickets/useTicketDetail.ts
Normal file
179
frontend/src/features/tickets/useTicketDetail.ts
Normal file
@ -0,0 +1,179 @@
|
||||
/**
|
||||
* View-model hook for a single ticket's detail/edit surface (F3/F4/F5).
|
||||
*
|
||||
* Owns optimistic-concurrency handling: every mutation sends the ticket's
|
||||
* current `version` as `expectedVersion`. When the backend reports a version
|
||||
* conflict (the ticket moved underneath — e.g. an agent edited it), the hook
|
||||
* flips `conflict`, reloads the fresh ticket and surfaces a clear message so the
|
||||
* user can re-apply their change against the new version.
|
||||
*
|
||||
* The loaded ticket always includes its carnet body (F4); mutations that return
|
||||
* a carnet-less ticket preserve the last known carnet so the editor never blanks
|
||||
* out unexpectedly.
|
||||
*/
|
||||
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
|
||||
import { isTicketEvent, type GatewayError, type Ticket, type TicketLinkKind } from "@/domain";
|
||||
import { useGateways } from "@/app/di";
|
||||
|
||||
export interface TicketDetailViewModel {
|
||||
ticket: Ticket | null;
|
||||
error: string | null;
|
||||
/** Set when the last write lost an optimistic-concurrency race (F3). */
|
||||
conflict: boolean;
|
||||
busy: boolean;
|
||||
refresh: () => Promise<void>;
|
||||
updateFields: (input: {
|
||||
title?: string;
|
||||
description?: string;
|
||||
status?: Ticket["status"];
|
||||
priority?: Ticket["priority"];
|
||||
}) => Promise<boolean>;
|
||||
saveCarnet: (carnet: string) => Promise<boolean>;
|
||||
link: (targetRef: string, kind: TicketLinkKind) => Promise<boolean>;
|
||||
unlink: (targetRef: string, kind?: TicketLinkKind) => Promise<boolean>;
|
||||
assign: (agentId: string, assigned: boolean) => Promise<boolean>;
|
||||
}
|
||||
|
||||
function describe(e: unknown): string {
|
||||
if (e && typeof e === "object" && "message" in e) {
|
||||
return String((e as GatewayError).message);
|
||||
}
|
||||
return String(e);
|
||||
}
|
||||
|
||||
/** A generic `INVALID` from the backend whose message flags a version conflict. */
|
||||
function isVersionConflict(e: unknown): boolean {
|
||||
return (
|
||||
!!e &&
|
||||
typeof e === "object" &&
|
||||
typeof (e as GatewayError).message === "string" &&
|
||||
(e as GatewayError).message.includes("version conflict")
|
||||
);
|
||||
}
|
||||
|
||||
export function useTicketDetail(
|
||||
projectId: string,
|
||||
ref: string,
|
||||
): TicketDetailViewModel {
|
||||
const { ticket: gateway, system } = useGateways();
|
||||
const [ticket, setTicket] = useState<Ticket | null>(null);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [conflict, setConflict] = useState(false);
|
||||
const [busy, setBusy] = useState(false);
|
||||
|
||||
const refresh = useCallback(async () => {
|
||||
setBusy(true);
|
||||
setError(null);
|
||||
try {
|
||||
setTicket(await gateway.read(projectId, ref, true));
|
||||
setConflict(false);
|
||||
} catch (e) {
|
||||
setError(describe(e));
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
}, [projectId, ref, gateway]);
|
||||
|
||||
useEffect(() => {
|
||||
void refresh();
|
||||
}, [refresh]);
|
||||
|
||||
// Refresh when *another* actor mutates this same ticket while it is open.
|
||||
useEffect(() => {
|
||||
if (!system) return;
|
||||
let unsubscribe: (() => void) | undefined;
|
||||
let cancelled = false;
|
||||
void system
|
||||
.onDomainEvent((event) => {
|
||||
if (isTicketEvent(event) && event.issueRef === ref) void refresh();
|
||||
})
|
||||
.then((u) => {
|
||||
if (cancelled) u();
|
||||
else unsubscribe = u;
|
||||
});
|
||||
return () => {
|
||||
cancelled = true;
|
||||
unsubscribe?.();
|
||||
};
|
||||
}, [refresh, system, ref]);
|
||||
|
||||
/**
|
||||
* Runs one mutation, preserving the loaded carnet (mutations return a
|
||||
* carnet-less ticket) and translating a version conflict into `conflict` +
|
||||
* a reload so the UI can prompt a clean retry.
|
||||
*/
|
||||
const run = useCallback(
|
||||
async (op: (version: number) => Promise<Ticket>, carnet?: string): Promise<boolean> => {
|
||||
if (!ticket) return false;
|
||||
setBusy(true);
|
||||
setError(null);
|
||||
try {
|
||||
const updated = await op(ticket.version);
|
||||
setTicket({
|
||||
...updated,
|
||||
carnet: carnet !== undefined ? carnet : ticket.carnet,
|
||||
});
|
||||
setConflict(false);
|
||||
return true;
|
||||
} catch (e) {
|
||||
if (isVersionConflict(e)) {
|
||||
setConflict(true);
|
||||
setError(
|
||||
"This ticket was modified elsewhere. It has been reloaded — re-apply your change.",
|
||||
);
|
||||
await refresh();
|
||||
} else {
|
||||
setError(describe(e));
|
||||
}
|
||||
return false;
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
},
|
||||
[ticket, gateway, refresh],
|
||||
);
|
||||
|
||||
const updateFields: TicketDetailViewModel["updateFields"] = useCallback(
|
||||
(input) => run((version) => gateway.update(projectId, ref, { ...input, expectedVersion: version })),
|
||||
[run, gateway, projectId, ref],
|
||||
);
|
||||
|
||||
const saveCarnet: TicketDetailViewModel["saveCarnet"] = useCallback(
|
||||
(carnet) =>
|
||||
run((version) => gateway.updateCarnet(projectId, ref, carnet, version), carnet),
|
||||
[run, gateway, projectId, ref],
|
||||
);
|
||||
|
||||
const link: TicketDetailViewModel["link"] = useCallback(
|
||||
(targetRef, kind) =>
|
||||
run((version) => gateway.link(projectId, ref, targetRef, kind, version)),
|
||||
[run, gateway, projectId, ref],
|
||||
);
|
||||
|
||||
const unlink: TicketDetailViewModel["unlink"] = useCallback(
|
||||
(targetRef, kind) =>
|
||||
run((version) => gateway.unlink(projectId, ref, targetRef, version, kind)),
|
||||
[run, gateway, projectId, ref],
|
||||
);
|
||||
|
||||
const assign: TicketDetailViewModel["assign"] = useCallback(
|
||||
(agentId, assigned) =>
|
||||
run((version) => gateway.assign(projectId, ref, agentId, assigned, version)),
|
||||
[run, gateway, projectId, ref],
|
||||
);
|
||||
|
||||
return {
|
||||
ticket,
|
||||
error,
|
||||
conflict,
|
||||
busy,
|
||||
refresh,
|
||||
updateFields,
|
||||
saveCarnet,
|
||||
link,
|
||||
unlink,
|
||||
assign,
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user