QA-flagged gap: the web surface could only ADD a ticket to a sprint, never change or clear it, despite the backend already exposing both ticket_assign_sprint and ticket_unassign_sprint. - useTicketDetail: new setSprint(sprintId | null) method (additive, also available to the desktop TicketDetail — unused there today, no behaviour change), routing through the existing TicketGateway.setTicketSprint. - WebTicketDetail: Sprint selector in "Statut et priorité", immediate save like status/priority; empty value clears back to "Sans sprint". - WebSprintsView: each sprint card now shows its tickets (compact list, resolved client-side like the desktop SprintManager) with a "Retirer du sprint" action per ticket, calling assignSprint(ref, null) — symmetric with "Ajouter tickets". Extracted the card into a SprintCard subcomponent to keep the growing card readable. - Tests: WebTicketsSprints.test.tsx now has 16 tests (was 10) — added sprint change/clear, sprint removal from the Sprints tab, agent assign/unassign, ticket link/unlink, carnet save, and free-text search filtering. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
253 lines
8.5 KiB
TypeScript
253 lines
8.5 KiB
TypeScript
/**
|
|
* 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;
|
|
/**
|
|
* Set once the open ticket has been deleted — by this view or any other actor
|
|
* (ticket #6). Driven by the `issueDeleted` domain event (single source of
|
|
* truth); the surface reacts by closing itself.
|
|
*/
|
|
deleted: boolean;
|
|
busy: boolean;
|
|
/**
|
|
* Monotonic counter bumped **only** when the ticket is (re)loaded from the
|
|
* gateway — initial load, an external-event refresh, or a conflict reload. It
|
|
* is deliberately **not** bumped by an ordinary optimistic mutation (e.g. a
|
|
* priority change), so a consumer can re-seed its local edit draft on genuine
|
|
* reloads without clobbering an in-progress edit on every version bump (#9).
|
|
*/
|
|
reloadCount: number;
|
|
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>;
|
|
/**
|
|
* Changes this ticket's sprint membership, or clears it with
|
|
* `sprintId === null` (ticket #86 — web sprint control in the detail view).
|
|
* Routes to `ticket_assign_sprint`/`ticket_unassign_sprint` via the gateway.
|
|
*/
|
|
setSprint: (sprintId: string | null) => Promise<boolean>;
|
|
/**
|
|
* Deletes this ticket (ticket #6). Returns `true` on success. The removal from
|
|
* lists and the closing of this surface flow from the resulting `issueDeleted`
|
|
* event (see {@link TicketDetailViewModel.deleted}), not from local mutation.
|
|
*/
|
|
remove: () => 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 [deleted, setDeleted] = useState(false);
|
|
const [busy, setBusy] = useState(false);
|
|
const [reloadCount, setReloadCount] = useState(0);
|
|
|
|
const refresh = useCallback(async () => {
|
|
setBusy(true);
|
|
setError(null);
|
|
try {
|
|
setTicket(await gateway.read(projectId, ref, true));
|
|
setConflict(false);
|
|
// Signal a benign (re)load so consumers can refresh their edit draft; an
|
|
// optimistic mutation below never bumps this (#9). A benign reload leaves
|
|
// `conflict` false, so the draft is preserved field-by-field; a conflict
|
|
// reload (see `run`) keeps `conflict` true to force a clean re-apply.
|
|
setReloadCount((c) => c + 1);
|
|
} 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 (event.type === "issueDeleted" && event.issueRef === ref) {
|
|
// The open ticket was deleted (here or elsewhere): don't refresh (it
|
|
// would 404) — flag it so the surface closes (#6).
|
|
setDeleted(true);
|
|
return;
|
|
}
|
|
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)) {
|
|
// Conflict reload: reload the fresh ticket but keep `conflict` true so
|
|
// the consumer force-re-seeds the draft (re-apply cleanly) rather than
|
|
// preserving the now-stale edit. Bump `reloadCount` to trigger it.
|
|
setError(
|
|
"This ticket was modified elsewhere. It has been reloaded — re-apply your change.",
|
|
);
|
|
try {
|
|
setTicket(await gateway.read(projectId, ref, true));
|
|
} catch (reloadErr) {
|
|
setError(describe(reloadErr));
|
|
}
|
|
setConflict(true);
|
|
setReloadCount((c) => c + 1);
|
|
} else {
|
|
setError(describe(e));
|
|
}
|
|
return false;
|
|
} finally {
|
|
setBusy(false);
|
|
}
|
|
},
|
|
[ticket, gateway, projectId, ref],
|
|
);
|
|
|
|
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],
|
|
);
|
|
|
|
const setSprint: TicketDetailViewModel["setSprint"] = useCallback(
|
|
(sprintId) =>
|
|
run((version) => gateway.setTicketSprint(projectId, ref, sprintId, version)),
|
|
[run, gateway, projectId, ref],
|
|
);
|
|
|
|
const remove: TicketDetailViewModel["remove"] = useCallback(async () => {
|
|
setBusy(true);
|
|
setError(null);
|
|
try {
|
|
await gateway.delete(projectId, ref);
|
|
// Closing/removal flows from the `issueDeleted` event (single source of
|
|
// truth) — see the `deleted` flag set in the subscription above.
|
|
return true;
|
|
} catch (e) {
|
|
setError(describe(e));
|
|
return false;
|
|
} finally {
|
|
setBusy(false);
|
|
}
|
|
}, [gateway, projectId, ref]);
|
|
|
|
return {
|
|
ticket,
|
|
error,
|
|
conflict,
|
|
deleted,
|
|
busy,
|
|
reloadCount,
|
|
refresh,
|
|
updateFields,
|
|
saveCarnet,
|
|
link,
|
|
unlink,
|
|
assign,
|
|
setSprint,
|
|
remove,
|
|
};
|
|
}
|