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

@ -9,7 +9,7 @@
* context" prefill lets the user hand an agent "work on #N" (F7).
*/
import { useEffect, useState } from "react";
import { useEffect, useRef, useState } from "react";
import type { TicketLinkKind } from "@/domain";
import { Button, Input, Spinner, cn } from "@/shared";
@ -88,8 +88,12 @@ export function TicketDetail({
const { agents, nameOf } = useProjectAgents(projectId);
const t = vm.ticket;
// Local draft state, re-seeded whenever the loaded ticket version changes so a
// conflict-reload or an external edit refreshes the fields.
// Local draft state. Re-seeded only on a genuine (re)load of the ticket —
// initial load, an external-event refresh, or a conflict reload — tracked via
// the hook's `reloadCount`. It is deliberately NOT re-seeded on an ordinary
// optimistic mutation (e.g. changing the priority), which bumps the ticket's
// version but must not clobber an in-progress, unsaved title/description edit
// (#9).
const [title, setTitle] = useState("");
const [description, setDescription] = useState("");
const [carnet, setCarnet] = useState("");
@ -97,20 +101,71 @@ export function TicketDetail({
const [linkKind, setLinkKind] = useState<TicketLinkKind>("relatesTo");
const [assignPick, setAssignPick] = useState("");
const [copied, setCopied] = useState(false);
// Confirmation shown when the user tries to close with unsaved changes.
const [confirmClose, setConfirmClose] = useState(false);
const version = t?.version;
const reloadCount = vm.reloadCount;
const seededReload = useRef(-1);
// The persisted values the draft was last seeded from. Lets a benign reload
// (echo of one's own priority change, or an external edit to an untouched
// field) adopt fresh values field-by-field WITHOUT clobbering a field the user
// is currently editing (#9).
const baseline = useRef<{
title: string;
description: string;
carnet: string;
} | null>(null);
useEffect(() => {
if (!t) return;
setTitle(t.title);
setDescription(t.description);
setCarnet(t.carnet ?? "");
}, [t, version]);
// Re-seed once per (re)load. An optimistic mutation (e.g. a priority change)
// changes `t` without bumping `reloadCount`, so this guard short-circuits and
// preserves the draft (#9).
if (seededReload.current === reloadCount) return;
seededReload.current = reloadCount;
const nextCarnet = t.carnet ?? "";
const base = baseline.current;
// Force a full reset on the initial load and on a conflict reload (the local
// edit is stale — the user must re-apply). A benign reload preserves any
// field the user has edited since the last seed.
const forced = base === null || vm.conflict;
if (forced) {
setTitle(t.title);
setDescription(t.description);
setCarnet(nextCarnet);
} else {
setTitle((cur) => (cur === base.title ? t.title : cur));
setDescription((cur) => (cur === base.description ? t.description : cur));
setCarnet((cur) => (cur === base.carnet ? nextCarnet : cur));
}
baseline.current = {
title: t.title,
description: t.description,
carnet: nextCarnet,
};
}, [t, reloadCount, vm.conflict]);
const dirtyFields =
!!t && (title !== t.title || description !== t.description);
const dirtyCarnet = !!t && carnet !== (t.carnet ?? "");
const dirty = dirtyFields || dirtyCarnet;
const assignable = agents.filter((a) => !t?.assignedAgentIds.includes(a.id));
/** Close guard: prompt before discarding unsaved edits, else close directly. */
function requestClose() {
if (dirty) setConfirmClose(true);
else onClose();
}
/** Save whatever is dirty, then close only if every save succeeded. */
async function saveAndClose() {
let ok = true;
if (dirtyFields) ok = (await vm.updateFields({ title, description })) && ok;
if (ok && dirtyCarnet) ok = (await vm.saveCarnet(carnet)) && ok;
setConfirmClose(false);
// On a failed save (e.g. a version conflict) stay open so the banner shows.
if (ok) onClose();
}
async function copyDelegation() {
if (!t) return;
const ok = await copyToClipboard(
@ -149,7 +204,7 @@ export function TicketDetail({
>
{copied ? "Copied" : "Delegation context"}
</Button>
<Button size="sm" variant="ghost" onClick={onClose}>
<Button size="sm" variant="ghost" onClick={requestClose}>
Close
</Button>
</div>
@ -445,6 +500,56 @@ export function TicketDetail({
</Section>
</div>
)}
{/* ── Unsaved-changes confirmation on close (#9) ── */}
{confirmClose && (
<div
role="dialog"
aria-modal="true"
aria-label="unsaved changes"
className="fixed inset-0 z-[60] flex items-center justify-center bg-black/55 p-4"
>
<div className="w-full max-w-sm rounded-lg border border-border bg-surface p-5 shadow-xl">
<h4 className="text-sm font-semibold text-content">
Modifications non sauvegardées
</h4>
<p className="mt-2 text-sm text-muted">
Vous avez des modifications non sauvegardées. Voulez-vous les
sauvegarder avant de fermer&nbsp;?
</p>
<div className="mt-4 flex flex-wrap items-center justify-end gap-2">
<Button
size="sm"
variant="ghost"
aria-label="cancel close"
disabled={vm.busy}
onClick={() => setConfirmClose(false)}
>
Annuler
</Button>
<Button
size="sm"
variant="ghost"
aria-label="close without saving"
disabled={vm.busy}
onClick={onClose}
className="text-danger hover:text-danger"
>
Fermer sans sauvegarder
</Button>
<Button
size="sm"
aria-label="save and close"
loading={vm.busy}
disabled={vm.busy}
onClick={() => void saveAndClose()}
>
Sauvegarder
</Button>
</div>
</div>
</div>
)}
</div>
);
}