From e7f67bada9f63c30cd55b8cdcc31744f7aecda55 Mon Sep 17 00:00:00 2001 From: Blomios Date: Tue, 21 Jul 2026 07:43:09 +0200 Subject: [PATCH] fix(frontend): sprint change/removal on tickets, web workspace (#86 QA fix) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- .../src/features/tickets/useTicketDetail.ts | 13 + .../features/web/tickets/WebSprintsView.tsx | 280 +++++++++++------- .../features/web/tickets/WebTicketDetail.tsx | 17 ++ .../web/tickets/WebTicketsSprints.test.tsx | 189 +++++++++++- 4 files changed, 396 insertions(+), 103 deletions(-) diff --git a/frontend/src/features/tickets/useTicketDetail.ts b/frontend/src/features/tickets/useTicketDetail.ts index e867e9b..e9d68f6 100644 --- a/frontend/src/features/tickets/useTicketDetail.ts +++ b/frontend/src/features/tickets/useTicketDetail.ts @@ -48,6 +48,12 @@ export interface TicketDetailViewModel { link: (targetRef: string, kind: TicketLinkKind) => Promise; unlink: (targetRef: string, kind?: TicketLinkKind) => Promise; assign: (agentId: string, assigned: boolean) => Promise; + /** + * 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; /** * Deletes this ticket (ticket #6). Returns `true` on success. The removal from * lists and the closing of this surface flow from the resulting `issueDeleted` @@ -205,6 +211,12 @@ export function useTicketDetail( [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); @@ -234,6 +246,7 @@ export function useTicketDetail( link, unlink, assign, + setSprint, remove, }; } diff --git a/frontend/src/features/web/tickets/WebSprintsView.tsx b/frontend/src/features/web/tickets/WebSprintsView.tsx index 948df2d..e70bb89 100644 --- a/frontend/src/features/web/tickets/WebSprintsView.tsx +++ b/frontend/src/features/web/tickets/WebSprintsView.tsx @@ -7,12 +7,16 @@ * * Reuses `useTickets` (sprint CRUD) and `useTicketSearch` (to resolve which * 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 { 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 { WebConfirmDialog } from "./WebConfirmDialog"; import { WebTicketPickerSheet } from "./WebTicketPickerSheet"; @@ -26,8 +30,8 @@ export interface WebSprintsViewProps { export function WebSprintsView({ projectId, onViewSprintTickets }: WebSprintsViewProps) { const vm = useTickets(projectId); // Independent of the Tickets tab's own query (mirrors desktop SprintManager): - // used only to resolve which refs already belong to a sprint, for the - // "ajouter des tickets" picker's exclude set. + // used to resolve which refs already belong to a sprint, both for the + // compact per-sprint ticket list and the "ajouter des tickets" exclude set. const search = useTicketSearch(projectId, { refreshOnEvents: true }); const [showCreate, setShowCreate] = useState(false); @@ -100,103 +104,31 @@ export function WebSprintsView({ projectId, onViewSprintTickets }: WebSprintsVie ) : (
    - {sprints.map((sprint, index) => { - const renaming = renamingId === sprint.id; - return ( -
  • -
    - #{sprint.order} - {!renaming && ( - - {sprint.name} - - )} -
    - - {renaming ? ( -
    - setRenameDraft(e.target.value)} - disabled={vm.busy} - /> -
    - - -
    -
    - ) : ( - <> -

    {sprint.ticketCount} ticket{sprint.ticketCount > 1 ? "s" : ""}

    -
    - - - - - - -
    - - )} -
  • - ); - })} + {sprints.map((sprint, index) => ( + t.sprintId === sprint.id)} + vm={vm} + renaming={renamingId === sprint.id} + renameDraft={renameDraft} + onStartRename={() => { + setRenamingId(sprint.id); + setRenameDraft(sprint.name); + }} + onRenameDraftChange={setRenameDraft} + onCancelRename={() => setRenamingId(null)} + onSaveRename={async () => { + const ok = await vm.renameSprint(sprint.id, renameDraft.trim()); + if (ok) setRenamingId(null); + }} + onViewTickets={() => onViewSprintTickets(sprint.id)} + onAddTickets={() => setPickerSprint({ id: sprint.id, name: sprint.name })} + onRequestDelete={() => setConfirmDelete({ id: sprint.id, name: sprint.name })} + /> + ))}
)} @@ -231,3 +163,149 @@ export function WebSprintsView({ projectId, onViewSprintTickets }: WebSprintsVie ); } + +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; + onViewTickets: () => void; + onAddTickets: () => void; + onRequestDelete: () => void; +} + +function SprintCard({ + sprint, + index, + lastIndex, + tickets, + vm, + renaming, + renameDraft, + onStartRename, + onRenameDraftChange, + onCancelRename, + onSaveRename, + onViewTickets, + onAddTickets, + onRequestDelete, +}: SprintCardProps) { + return ( +
  • +
    + #{sprint.order} + {!renaming && ( + + {sprint.name} + + )} +
    + + {renaming ? ( +
    + onRenameDraftChange(e.target.value)} + disabled={vm.busy} + /> +
    + + +
    +
    + ) : ( + <> +

    + {sprint.ticketCount} ticket{sprint.ticketCount > 1 ? "s" : ""} +

    + + {/* Compact ticket list + per-row removal (QA #86 fix): symmetric with + "Ajouter tickets" below. */} + {tickets.length > 0 && ( +
      + {tickets.map((t) => ( +
    • + + {t.ref} + + {t.title} + +
    • + ))} +
    + )} + +
    + + + + + + +
    + + )} +
  • + ); +} diff --git a/frontend/src/features/web/tickets/WebTicketDetail.tsx b/frontend/src/features/web/tickets/WebTicketDetail.tsx index 358bd91..c98ed6c 100644 --- a/frontend/src/features/web/tickets/WebTicketDetail.tsx +++ b/frontend/src/features/web/tickets/WebTicketDetail.tsx @@ -242,6 +242,23 @@ export function WebTicketDetail({ ))} + {vm.busy && } diff --git a/frontend/src/features/web/tickets/WebTicketsSprints.test.tsx b/frontend/src/features/web/tickets/WebTicketsSprints.test.tsx index 8e4bf05..dd31a18 100644 --- a/frontend/src/features/web/tickets/WebTicketsSprints.test.tsx +++ b/frontend/src/features/web/tickets/WebTicketsSprints.test.tsx @@ -11,9 +11,9 @@ import { describe, it, expect } from "vitest"; import { fireEvent, render, screen, waitFor, within } from "@testing-library/react"; 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 { Ticket } from "@/domain"; +import type { ProjectWorkState, Ticket } from "@/domain"; import { WebWorkspace } from "../WebWorkspace"; function ticket(over: Partial = {}): Ticket { @@ -43,6 +43,29 @@ async function setup() { 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) { return render( @@ -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 () => { const { gateways, projectId } = await setup(); const tk = gateways.ticket as MockTicketGateway; @@ -260,4 +425,24 @@ describe("WebWorkspace — Sprints tab (ticket #86)", () => { 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()); + }); });