Files
IdeaSDK/frontend/src/features/tickets/useTicketDetail.ts
Blomios e7f67bada9 fix(frontend): sprint change/removal on tickets, web workspace (#86 QA fix)
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>
2026-07-21 07:43:09 +02:00

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,
};
}