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>
This commit is contained in:
2026-07-21 07:43:09 +02:00
parent e69361feb7
commit e7f67bada9
4 changed files with 396 additions and 103 deletions

View File

@ -48,6 +48,12 @@ export interface TicketDetailViewModel {
link: (targetRef: string, kind: TicketLinkKind) => Promise<boolean>; link: (targetRef: string, kind: TicketLinkKind) => Promise<boolean>;
unlink: (targetRef: string, kind?: TicketLinkKind) => Promise<boolean>; unlink: (targetRef: string, kind?: TicketLinkKind) => Promise<boolean>;
assign: (agentId: string, assigned: boolean) => 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 * Deletes this ticket (ticket #6). Returns `true` on success. The removal from
* lists and the closing of this surface flow from the resulting `issueDeleted` * lists and the closing of this surface flow from the resulting `issueDeleted`
@ -205,6 +211,12 @@ export function useTicketDetail(
[run, gateway, projectId, ref], [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 () => { const remove: TicketDetailViewModel["remove"] = useCallback(async () => {
setBusy(true); setBusy(true);
setError(null); setError(null);
@ -234,6 +246,7 @@ export function useTicketDetail(
link, link,
unlink, unlink,
assign, assign,
setSprint,
remove, remove,
}; };
} }

View File

@ -7,12 +7,16 @@
* *
* Reuses `useTickets` (sprint CRUD) and `useTicketSearch` (to resolve which * Reuses `useTickets` (sprint CRUD) and `useTicketSearch` (to resolve which
* tickets belong to a sprint client-side, exactly like the desktop * tickets belong to a sprint client-side, exactly like the desktop
* `SprintManager` — `ticket_list` has no server-side `sprintId` filter). * `SprintManager` — `ticket_list` has no server-side `sprintId` filter). The
* per-sprint ticket list also lets the user remove a ticket from the sprint
* (`assignSprint(ref, null)`, which routes to `ticket_unassign_sprint`) — the
* symmetric counterpart of "Ajouter tickets" (QA #86 fix).
*/ */
import { useState } from "react"; import { useState } from "react";
import { useTickets, useTicketSearch } from "@/features/tickets"; import type { Sprint, TicketSummary } from "@/domain";
import { useTickets, useTicketSearch, type TicketsViewModel } from "@/features/tickets";
import { Button, Input, Panel, Spinner } from "@/shared"; import { Button, Input, Panel, Spinner } from "@/shared";
import { WebConfirmDialog } from "./WebConfirmDialog"; import { WebConfirmDialog } from "./WebConfirmDialog";
import { WebTicketPickerSheet } from "./WebTicketPickerSheet"; import { WebTicketPickerSheet } from "./WebTicketPickerSheet";
@ -26,8 +30,8 @@ export interface WebSprintsViewProps {
export function WebSprintsView({ projectId, onViewSprintTickets }: WebSprintsViewProps) { export function WebSprintsView({ projectId, onViewSprintTickets }: WebSprintsViewProps) {
const vm = useTickets(projectId); const vm = useTickets(projectId);
// Independent of the Tickets tab's own query (mirrors desktop SprintManager): // Independent of the Tickets tab's own query (mirrors desktop SprintManager):
// used only to resolve which refs already belong to a sprint, for the // used to resolve which refs already belong to a sprint, both for the
// "ajouter des tickets" picker's exclude set. // compact per-sprint ticket list and the "ajouter des tickets" exclude set.
const search = useTicketSearch(projectId, { refreshOnEvents: true }); const search = useTicketSearch(projectId, { refreshOnEvents: true });
const [showCreate, setShowCreate] = useState(false); const [showCreate, setShowCreate] = useState(false);
@ -100,103 +104,31 @@ export function WebSprintsView({ projectId, onViewSprintTickets }: WebSprintsVie
</div> </div>
) : ( ) : (
<ul className="flex flex-col gap-3"> <ul className="flex flex-col gap-3">
{sprints.map((sprint, index) => { {sprints.map((sprint, index) => (
const renaming = renamingId === sprint.id; <SprintCard
return ( key={sprint.id}
<li key={sprint.id} className="rounded-md border border-border p-3"> sprint={sprint}
<div className="flex items-center justify-between gap-2"> index={index}
<span className="text-xs font-semibold text-muted">#{sprint.order}</span> lastIndex={sprints.length - 1}
{!renaming && ( tickets={search.rows.filter((t) => t.sprintId === sprint.id)}
<span className="min-w-0 flex-1 truncate text-sm font-medium text-content"> vm={vm}
{sprint.name} renaming={renamingId === sprint.id}
</span> renameDraft={renameDraft}
)} onStartRename={() => {
</div> setRenamingId(sprint.id);
setRenameDraft(sprint.name);
{renaming ? ( }}
<div className="mt-2 flex flex-col gap-2"> onRenameDraftChange={setRenameDraft}
<Input onCancelRename={() => setRenamingId(null)}
aria-label={`Renommer le sprint ${sprint.name}`} onSaveRename={async () => {
value={renameDraft} const ok = await vm.renameSprint(sprint.id, renameDraft.trim());
onChange={(e) => setRenameDraft(e.target.value)} if (ok) setRenamingId(null);
disabled={vm.busy} }}
/> onViewTickets={() => onViewSprintTickets(sprint.id)}
<div className="flex justify-end gap-2"> onAddTickets={() => setPickerSprint({ id: sprint.id, name: sprint.name })}
<Button size="sm" variant="ghost" disabled={vm.busy} onClick={() => setRenamingId(null)}> onRequestDelete={() => setConfirmDelete({ id: sprint.id, name: sprint.name })}
Annuler />
</Button> ))}
<Button
size="sm"
disabled={vm.busy || !renameDraft.trim() || renameDraft.trim() === sprint.name}
onClick={async () => {
const ok = await vm.renameSprint(sprint.id, renameDraft.trim());
if (ok) setRenamingId(null);
}}
>
Enregistrer
</Button>
</div>
</div>
) : (
<>
<p className="mt-1 text-xs text-muted">{sprint.ticketCount} ticket{sprint.ticketCount > 1 ? "s" : ""}</p>
<div className="mt-2 flex flex-wrap items-center gap-2">
<Button size="sm" variant="ghost" onClick={() => onViewSprintTickets(sprint.id)}>
Voir tickets
</Button>
<Button
size="sm"
variant="ghost"
onClick={() => setPickerSprint({ id: sprint.id, name: sprint.name })}
>
Ajouter tickets
</Button>
<Button
size="sm"
variant="ghost"
aria-label={`Renommer ${sprint.name}`}
disabled={vm.busy}
onClick={() => {
setRenamingId(sprint.id);
setRenameDraft(sprint.name);
}}
>
Renommer
</Button>
<Button
size="sm"
variant="ghost"
aria-label={`Monter ${sprint.name}`}
disabled={vm.busy || index === 0}
onClick={() => void vm.moveSprint(sprint.id, "up")}
>
Monter
</Button>
<Button
size="sm"
variant="ghost"
aria-label={`Descendre ${sprint.name}`}
disabled={vm.busy || index === sprints.length - 1}
onClick={() => void vm.moveSprint(sprint.id, "down")}
>
Descendre
</Button>
<Button
size="sm"
variant="ghost"
aria-label={`Supprimer ${sprint.name}`}
disabled={vm.busy}
className="text-danger hover:text-danger"
onClick={() => setConfirmDelete({ id: sprint.id, name: sprint.name })}
>
Supprimer
</Button>
</div>
</>
)}
</li>
);
})}
</ul> </ul>
)} )}
@ -231,3 +163,149 @@ export function WebSprintsView({ projectId, onViewSprintTickets }: WebSprintsVie
</Panel> </Panel>
); );
} }
interface SprintCardProps {
sprint: Sprint;
index: number;
lastIndex: number;
tickets: TicketSummary[];
vm: TicketsViewModel;
renaming: boolean;
renameDraft: string;
onStartRename: () => void;
onRenameDraftChange: (value: string) => void;
onCancelRename: () => void;
onSaveRename: () => Promise<void>;
onViewTickets: () => void;
onAddTickets: () => void;
onRequestDelete: () => void;
}
function SprintCard({
sprint,
index,
lastIndex,
tickets,
vm,
renaming,
renameDraft,
onStartRename,
onRenameDraftChange,
onCancelRename,
onSaveRename,
onViewTickets,
onAddTickets,
onRequestDelete,
}: SprintCardProps) {
return (
<li className="rounded-md border border-border p-3">
<div className="flex items-center justify-between gap-2">
<span className="text-xs font-semibold text-muted">#{sprint.order}</span>
{!renaming && (
<span className="min-w-0 flex-1 truncate text-sm font-medium text-content">
{sprint.name}
</span>
)}
</div>
{renaming ? (
<div className="mt-2 flex flex-col gap-2">
<Input
aria-label={`Renommer le sprint ${sprint.name}`}
value={renameDraft}
onChange={(e) => onRenameDraftChange(e.target.value)}
disabled={vm.busy}
/>
<div className="flex justify-end gap-2">
<Button size="sm" variant="ghost" disabled={vm.busy} onClick={onCancelRename}>
Annuler
</Button>
<Button
size="sm"
disabled={vm.busy || !renameDraft.trim() || renameDraft.trim() === sprint.name}
onClick={() => void onSaveRename()}
>
Enregistrer
</Button>
</div>
</div>
) : (
<>
<p className="mt-1 text-xs text-muted">
{sprint.ticketCount} ticket{sprint.ticketCount > 1 ? "s" : ""}
</p>
{/* Compact ticket list + per-row removal (QA #86 fix): symmetric with
"Ajouter tickets" below. */}
{tickets.length > 0 && (
<ul className="mt-2 flex flex-col gap-1">
{tickets.map((t) => (
<li key={t.ref} className="flex items-center gap-2 text-sm">
<code className="shrink-0 rounded bg-raised px-1.5 py-0.5 font-mono text-xs text-content">
{t.ref}
</code>
<span className="min-w-0 flex-1 truncate text-content">{t.title}</span>
<Button
size="sm"
variant="ghost"
aria-label={`Retirer ${t.ref} du sprint ${sprint.name}`}
disabled={vm.busy}
onClick={() => void vm.assignSprint(t.ref, null)}
>
Retirer du sprint
</Button>
</li>
))}
</ul>
)}
<div className="mt-2 flex flex-wrap items-center gap-2">
<Button size="sm" variant="ghost" onClick={onViewTickets}>
Voir tickets
</Button>
<Button size="sm" variant="ghost" onClick={onAddTickets}>
Ajouter tickets
</Button>
<Button
size="sm"
variant="ghost"
aria-label={`Renommer ${sprint.name}`}
disabled={vm.busy}
onClick={onStartRename}
>
Renommer
</Button>
<Button
size="sm"
variant="ghost"
aria-label={`Monter ${sprint.name}`}
disabled={vm.busy || index === 0}
onClick={() => void vm.moveSprint(sprint.id, "up")}
>
Monter
</Button>
<Button
size="sm"
variant="ghost"
aria-label={`Descendre ${sprint.name}`}
disabled={vm.busy || index === lastIndex}
onClick={() => void vm.moveSprint(sprint.id, "down")}
>
Descendre
</Button>
<Button
size="sm"
variant="ghost"
aria-label={`Supprimer ${sprint.name}`}
disabled={vm.busy}
className="text-danger hover:text-danger"
onClick={onRequestDelete}
>
Supprimer
</Button>
</div>
</>
)}
</li>
);
}

View File

@ -242,6 +242,23 @@ export function WebTicketDetail({
))} ))}
</select> </select>
</label> </label>
<label className="flex items-center justify-between gap-2 text-sm">
<span className="text-muted">Sprint</span>
<select
aria-label="Sprint"
className={selectClass}
value={t.sprintId ?? ""}
disabled={vm.busy}
onChange={(e) => void vm.setSprint(e.target.value || null)}
>
<option value="">Sans sprint</option>
{sprints.map((s) => (
<option key={s.id} value={s.id}>
{s.name}
</option>
))}
</select>
</label>
{vm.busy && <Spinner size={12} />} {vm.busy && <Spinner size={12} />}
</Accordion> </Accordion>

View File

@ -11,9 +11,9 @@ import { describe, it, expect } from "vitest";
import { fireEvent, render, screen, waitFor, within } from "@testing-library/react"; import { fireEvent, render, screen, waitFor, within } from "@testing-library/react";
import { DIProvider } from "@/app/di"; import { DIProvider } from "@/app/di";
import { createMockGateways, MockTicketGateway } from "@/adapters/mock"; import { createMockGateways, MockTicketGateway, MockWorkStateGateway } from "@/adapters/mock";
import type { Gateways } from "@/ports"; import type { Gateways } from "@/ports";
import type { Ticket } from "@/domain"; import type { ProjectWorkState, Ticket } from "@/domain";
import { WebWorkspace } from "../WebWorkspace"; import { WebWorkspace } from "../WebWorkspace";
function ticket(over: Partial<Ticket> = {}): Ticket { function ticket(over: Partial<Ticket> = {}): Ticket {
@ -43,6 +43,29 @@ async function setup() {
return { gateways, projectId: project.id }; return { gateways, projectId: project.id };
} }
/**
* Seeds a project's work-state with one idle agent. `WebTicketDetail`/
* `WebTicketsView` resolve the assignable agent roster from
* `get_project_work_state` (not `list_agents` — absent from the web-server
* command allowlist, see `useWebProjectAgents`), so tests that need an
* assignable agent must seed it here rather than via `MockAgentGateway`.
*/
function seedAgent(gateways: Gateways, projectId: string, agentId: string, name: string): void {
const state: ProjectWorkState = {
agents: [
{
agentId,
name,
profileId: "p1",
busy: { state: "idle" },
tickets: [],
},
],
conversations: [],
};
(gateways.workState as MockWorkStateGateway)._setProjectWorkState(projectId, state);
}
function renderWorkspace(gateways: Gateways) { function renderWorkspace(gateways: Gateways) {
return render( return render(
<DIProvider gateways={gateways}> <DIProvider gateways={gateways}>
@ -145,6 +168,148 @@ describe("WebWorkspace — project tabs (ticket #86)", () => {
}); });
}); });
it("changes a ticket's sprint from the detail view, including clearing it back to Sans sprint", async () => {
const { gateways, projectId } = await setup();
const tk = gateways.ticket as MockTicketGateway;
tk._seedSprint(projectId, { id: "s1", order: 1, name: "Sprint courant" });
tk._seedSprint(projectId, { id: "s2", order: 2, name: "Sprint suivant" });
tk._seedTicket(projectId, ticket());
await openProjectAndTab(gateways, "Tickets");
fireEvent.click(
await screen.findByRole("button", {
name: "#1, Ajouter tickets/sprints au web, statut Ouvert, priorité Moyenne",
}),
);
const sprintSelect = await screen.findByLabelText("Sprint");
fireEvent.change(sprintSelect, { target: { value: "s2" } });
await waitFor(async () => {
const updated = await tk.read(projectId, "#1");
expect(updated.sprintId).toBe("s2");
});
// Clear it back to "Sans sprint" (ticket_unassign_sprint via setSprint(null)).
fireEvent.change(await screen.findByLabelText("Sprint"), { target: { value: "" } });
await waitFor(async () => {
const updated = await tk.read(projectId, "#1");
expect(updated.sprintId).toBeNull();
});
});
it("assigns and unassigns an agent from the detail view", async () => {
const { gateways, projectId } = await setup();
const tk = gateways.ticket as MockTicketGateway;
tk._seedTicket(projectId, ticket());
seedAgent(gateways, projectId, "agent-1", "DevFrontend");
await openProjectAndTab(gateways, "Tickets");
fireEvent.click(
await screen.findByRole("button", {
name: "#1, Ajouter tickets/sprints au web, statut Ouvert, priorité Moyenne",
}),
);
fireEvent.click(await screen.findByRole("button", { name: "Agents assignés" }));
fireEvent.change(screen.getByLabelText("Assigner un agent"), {
target: { value: "agent-1" },
});
fireEvent.click(screen.getByRole("button", { name: "Assigner" }));
await waitFor(async () => {
const updated = await tk.read(projectId, "#1");
expect(updated.assignedAgentIds).toContain("agent-1");
});
await screen.findByText("DevFrontend");
fireEvent.click(screen.getByRole("button", { name: "Désassigner DevFrontend" }));
await waitFor(async () => {
const updated = await tk.read(projectId, "#1");
expect(updated.assignedAgentIds).not.toContain("agent-1");
});
});
it("links and unlinks a ticket from the detail view", async () => {
const { gateways, projectId } = await setup();
const tk = gateways.ticket as MockTicketGateway;
tk._seedTicket(projectId, ticket());
tk._seedTicket(projectId, ticket({ ref: "#2", number: 2, title: "Autre ticket" }));
await openProjectAndTab(gateways, "Tickets");
fireEvent.click(
await screen.findByRole("button", {
name: "#1, Ajouter tickets/sprints au web, statut Ouvert, priorité Moyenne",
}),
);
fireEvent.click(await screen.findByRole("button", { name: "Liens" }));
fireEvent.click(screen.getByRole("button", { name: "+ Lier" }));
const sheet = await screen.findByRole("dialog", { name: "Lier un ticket" });
fireEvent.click(within(sheet).getByRole("button", { name: "#2, Autre ticket" }));
await waitFor(async () => {
const updated = await tk.read(projectId, "#1");
expect(updated.links).toEqual([{ targetRef: "#2", kind: "relatesTo" }]);
});
await screen.findByText("#2");
fireEvent.click(screen.getByRole("button", { name: "Retirer" }));
await waitFor(async () => {
const updated = await tk.read(projectId, "#1");
expect(updated.links).toEqual([]);
});
});
it("saves the carnet explicitly from the detail view", async () => {
const { gateways, projectId } = await setup();
const tk = gateways.ticket as MockTicketGateway;
tk._seedTicket(projectId, ticket());
await openProjectAndTab(gateways, "Tickets");
fireEvent.click(
await screen.findByRole("button", {
name: "#1, Ajouter tickets/sprints au web, statut Ouvert, priorité Moyenne",
}),
);
const carnetField = await screen.findByLabelText("Carnet");
const saveButton = screen.getByRole("button", { name: "Enregistrer le carnet" });
expect(saveButton).toHaveProperty("disabled", true);
fireEvent.change(carnetField, { target: { value: "Notes de travail" } });
expect(saveButton).toHaveProperty("disabled", false);
fireEvent.click(saveButton);
await waitFor(async () => {
const updated = await tk.readCarnet(projectId, "#1");
expect(updated.carnet).toBe("Notes de travail");
});
});
it("filters the ticket list by free-text search", async () => {
const { gateways, projectId } = await setup();
const tk = gateways.ticket as MockTicketGateway;
tk._seedTicket(projectId, ticket({ ref: "#1", number: 1, title: "Ajouter tickets/sprints au web" }));
tk._seedTicket(projectId, ticket({ ref: "#2", number: 2, title: "Langue uniforme Settings" }));
await openProjectAndTab(gateways, "Tickets");
await screen.findByText("Langue uniforme Settings");
fireEvent.change(screen.getByLabelText("Rechercher des tickets"), {
target: { value: "Langue" },
});
await waitFor(() => {
expect(screen.queryByText("Ajouter tickets/sprints au web")).toBeNull();
});
expect(screen.getByText("Langue uniforme Settings")).toBeTruthy();
});
it("deletes a ticket after confirmation and returns to the list", async () => { it("deletes a ticket after confirmation and returns to the list", async () => {
const { gateways, projectId } = await setup(); const { gateways, projectId } = await setup();
const tk = gateways.ticket as MockTicketGateway; const tk = gateways.ticket as MockTicketGateway;
@ -260,4 +425,24 @@ describe("WebWorkspace — Sprints tab (ticket #86)", () => {
expect(t.sprintId).toBe("s1"); expect(t.sprintId).toBe("s1");
}); });
}); });
it("shows the sprint's tickets and removes one from the sprint", async () => {
const { gateways, projectId } = await setup();
const tk = gateways.ticket as MockTicketGateway;
tk._seedSprint(projectId, { id: "s1", order: 1, name: "Sprint A" });
tk._seedTicket(projectId, ticket({ ref: "#1", number: 1, title: "Ticket un", sprintId: "s1" }));
await openProjectAndTab(gateways, "Sprints");
await screen.findByText("Sprint A");
expect(screen.getByText("Ticket un")).toBeTruthy();
fireEvent.click(screen.getByRole("button", { name: "Retirer #1 du sprint Sprint A" }));
await waitFor(async () => {
const t = await tk.read(projectId, "#1");
expect(t.sprintId).toBeNull();
});
await waitFor(() => expect(screen.queryByText("Ticket un")).toBeNull());
});
}); });