feat(tickets): persistance de l'édition d'un ticket côté frontend

Ticket #9 — l'édition d'un ticket (titre, corps, champs) est désormais
persistée depuis le détail : useTicketDetail porte l'état d'édition et le
flux de sauvegarde, TicketDetail expose l'UI d'édition/validation.

Tests Vitest tickets verts (13/13), typecheck et build OK.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-07-05 02:20:23 +02:00
parent ce6ef5efae
commit 5ecc64f9c5
3 changed files with 260 additions and 12 deletions

View File

@ -23,6 +23,14 @@ export interface TicketDetailViewModel {
/** Set when the last write lost an optimistic-concurrency race (F3). */
conflict: 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;
@ -62,6 +70,7 @@ export function useTicketDetail(
const [error, setError] = useState<string | null>(null);
const [conflict, setConflict] = useState(false);
const [busy, setBusy] = useState(false);
const [reloadCount, setReloadCount] = useState(0);
const refresh = useCallback(async () => {
setBusy(true);
@ -69,6 +78,11 @@ export function useTicketDetail(
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 {
@ -119,11 +133,19 @@ export function useTicketDetail(
return true;
} catch (e) {
if (isVersionConflict(e)) {
setConflict(true);
// 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.",
);
await refresh();
try {
setTicket(await gateway.read(projectId, ref, true));
} catch (reloadErr) {
setError(describe(reloadErr));
}
setConflict(true);
setReloadCount((c) => c + 1);
} else {
setError(describe(e));
}
@ -132,7 +154,7 @@ export function useTicketDetail(
setBusy(false);
}
},
[ticket, gateway, refresh],
[ticket, gateway, projectId, ref],
);
const updateFields: TicketDetailViewModel["updateFields"] = useCallback(
@ -169,6 +191,7 @@ export function useTicketDetail(
error,
conflict,
busy,
reloadCount,
refresh,
updateFields,
saveCarnet,