feat(frontend): tickets/sprints surface for the web workspace (#86 lot 2)

Adds a Live/Tickets/Sprints tab navigation to WebWorkspace after a project
is opened — a mobile-first adaptation, not a port of the desktop docks/
floating windows, per carnet #86. The web-server transport (17 ticket_*/
sprint_* commands) and the HttpTicketGateway were already wired (lot 1 +
pre-existing gateway code); this lot is UI only.

- Tickets tab: search/filters, list grouped by sprint, create screen,
  detail view with stacked accordion sections (Résumé/Statut et priorité/
  Carnet open by default; Agents assignés/Liens/Zone dangereuse collapsed),
  delete confirmation, optimistic-concurrency conflict banner.
- Sprints tab: create/rename/reorder (Monter/Descendre)/delete with
  confirmation, add tickets via a mobile full-screen ticket picker (never
  a small desktop modal), "Voir tickets" filters the Tickets tab to one
  sprint.
- Reuses the transport-neutral hooks as-is (useTickets, useTicketDetail,
  useTicketSearch) — only presentation and copy are web-specific, in
  French per decision #78 (new local label module, not a reuse of the
  English desktop ticketMeta labels).
- Workaround: `list_agents` isn't in the web-server allowlist, so agent
  names/assignment options are derived from `get_project_work_state`
  (already used by the Live tab) instead of the desktop's useProjectAgents.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
2026-07-21 07:35:20 +02:00
parent 45def4844a
commit e69361feb7
11 changed files with 1942 additions and 1 deletions

View File

@ -0,0 +1,263 @@
/**
* Ticket #86, lot 2 — the web workspace's Tickets/Sprints tabs.
*
* Pins the carnet #86 UX contract exercised through the real `WebWorkspace`
* (project list → tab navigation), driven entirely by the same
* `MockTicketGateway`/`MockSystemGateway` pair the desktop ticket tests use, so
* behaviour (event-driven refresh, optimistic concurrency, sprint semantics)
* stays identical — only the presentation is web-specific.
*/
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 type { Gateways } from "@/ports";
import type { Ticket } from "@/domain";
import { WebWorkspace } from "../WebWorkspace";
function ticket(over: Partial<Ticket> = {}): Ticket {
return {
id: "t-1",
ref: "#1",
number: 1,
title: "Ajouter tickets/sprints au web",
description: "",
status: "open",
priority: "medium",
sprintId: null,
links: [],
assignedAgentIds: [],
createdBy: { kind: "user" },
updatedBy: { kind: "user" },
createdAt: 1,
updatedAt: 1,
version: 1,
...over,
};
}
async function setup() {
const gateways = createMockGateways();
const project = await gateways.project.createProject("IdeA", "/srv/idea");
return { gateways, projectId: project.id };
}
function renderWorkspace(gateways: Gateways) {
return render(
<DIProvider gateways={gateways}>
<WebWorkspace />
</DIProvider>,
);
}
async function openProjectAndTab(gateways: Gateways, tabName: string) {
renderWorkspace(gateways);
fireEvent.click(await screen.findByText("IdeA"));
await screen.findByRole("tablist", { name: "Navigation du projet" });
fireEvent.click(screen.getByRole("tab", { name: tabName }));
}
describe("WebWorkspace — project tabs (ticket #86)", () => {
it("defaults to the Live tab and lets the user switch to Tickets/Sprints", async () => {
const { gateways } = await setup();
renderWorkspace(gateways);
fireEvent.click(await screen.findByText("IdeA"));
const tablist = await screen.findByRole("tablist", { name: "Navigation du projet" });
const tabs = within(tablist).getAllByRole("tab");
expect(tabs.map((t) => t.textContent)).toEqual(["Live", "Tickets", "Sprints"]);
expect(screen.getByRole("tab", { name: "Live" }).getAttribute("aria-selected")).toBe("true");
fireEvent.click(screen.getByRole("tab", { name: "Tickets" }));
expect(await screen.findByRole("heading", { name: "Tickets" })).toBeTruthy();
fireEvent.click(screen.getByRole("tab", { name: "Sprints" }));
expect(await screen.findByRole("heading", { name: "Sprints" })).toBeTruthy();
});
it("renders the ticket list grouped by sprint, in French", async () => {
const { gateways, projectId } = await setup();
const tk = gateways.ticket as MockTicketGateway;
tk._seedSprint(projectId, { id: "s1", order: 1, name: "Sprint courant" });
tk._seedTicket(projectId, ticket({ ref: "#1", number: 1, title: "Ajouter tickets/sprints au web", sprintId: "s1" }));
tk._seedTicket(projectId, ticket({ ref: "#2", number: 2, title: "Langue uniforme Settings", status: "closed", priority: "low" }));
await openProjectAndTab(gateways, "Tickets");
const list = await screen.findByTestId("web-ticket-list");
expect(within(list).getByText("Sprint courant")).toBeTruthy();
expect(within(list).getByText("Sans sprint")).toBeTruthy();
expect(
screen.getByRole("button", {
name: "#1, Ajouter tickets/sprints au web, statut Ouvert, priorité Moyenne",
}),
).toBeTruthy();
expect(
screen.getByRole("button", {
name: "#2, Langue uniforme Settings, statut Fermé, priorité Faible",
}),
).toBeTruthy();
});
it("shows the empty state with a create shortcut when the project has no tickets", async () => {
const { gateways } = await setup();
await openProjectAndTab(gateways, "Tickets");
expect(await screen.findByText("Aucun ticket dans ce projet.")).toBeTruthy();
expect(screen.getByRole("button", { name: "Créer un ticket" })).toBeTruthy();
});
it("creates a ticket and opens its detail", async () => {
const { gateways } = await setup();
await openProjectAndTab(gateways, "Tickets");
await screen.findByText("Aucun ticket dans ce projet.");
fireEvent.click(screen.getByRole("button", { name: "+ Ticket" }));
expect(await screen.findByRole("heading", { name: "Nouveau ticket" })).toBeTruthy();
fireEvent.change(screen.getByLabelText("Titre"), { target: { value: "Un nouveau ticket" } });
fireEvent.click(screen.getByRole("button", { name: "Créer" }));
await waitFor(() => expect(screen.getByText("Un nouveau ticket")).toBeTruthy());
// Landed on the detail screen (back button visible), not the list.
expect(screen.getByRole("button", { name: "← Tickets" })).toBeTruthy();
});
it("changes a ticket's status immediately 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 statusSelect = await screen.findByLabelText("Statut");
fireEvent.change(statusSelect, { target: { value: "closed" } });
await waitFor(async () => {
const updated = await tk.read(projectId, "#1");
expect(updated.status).toBe("closed");
});
});
it("deletes a ticket after confirmation and returns to the list", 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",
}),
);
fireEvent.click(await screen.findByRole("button", { name: "Zone dangereuse" }));
fireEvent.click(screen.getByRole("button", { name: "Supprimer" }));
const dialog = await screen.findByRole("alertdialog", { name: "Supprimer ce ticket ?" });
expect(within(dialog).getByText(/sera supprimé définitivement/)).toBeTruthy();
fireEvent.click(within(dialog).getByRole("button", { name: "Supprimer" }));
await waitFor(() => expect(screen.getByText("Aucun ticket dans ce projet.")).toBeTruthy());
});
});
describe("WebWorkspace — Sprints tab (ticket #86)", () => {
it("shows the empty state with a create shortcut", async () => {
const { gateways } = await setup();
await openProjectAndTab(gateways, "Sprints");
expect(await screen.findByText("Aucun sprint.")).toBeTruthy();
expect(screen.getByRole("button", { name: "Créer un sprint" })).toBeTruthy();
});
it("creates a sprint (disabled until a name is entered)", async () => {
const { gateways, projectId } = await setup();
const tk = gateways.ticket as MockTicketGateway;
await openProjectAndTab(gateways, "Sprints");
fireEvent.click(screen.getByRole("button", { name: "+ Sprint" }));
const createButton = screen.getByRole("button", { name: "Créer" });
expect(createButton).toHaveProperty("disabled", true);
fireEvent.change(screen.getByLabelText("Nom du sprint"), {
target: { value: "Backlog client web" },
});
expect(createButton).toHaveProperty("disabled", false);
fireEvent.click(createButton);
await waitFor(async () => {
const sprints = await tk.listSprints(projectId);
expect(sprints.map((s) => s.name)).toContain("Backlog client web");
});
expect(await screen.findByText("Backlog client web")).toBeTruthy();
});
it("renames, reorders and deletes a sprint with confirmation", async () => {
const { gateways, projectId } = await setup();
const tk = gateways.ticket as MockTicketGateway;
tk._seedSprint(projectId, { id: "s1", order: 1, name: "Sprint A" });
tk._seedSprint(projectId, { id: "s2", order: 2, name: "Sprint B" });
await openProjectAndTab(gateways, "Sprints");
await screen.findByText("Sprint A");
// Rename.
fireEvent.click(screen.getByRole("button", { name: "Renommer Sprint A" }));
fireEvent.change(screen.getByLabelText("Renommer le sprint Sprint A"), {
target: { value: "Sprint A renommé" },
});
fireEvent.click(screen.getByRole("button", { name: "Enregistrer" }));
await waitFor(() => expect(screen.getByText("Sprint A renommé")).toBeTruthy());
// Reorder: move Sprint B up.
fireEvent.click(screen.getByRole("button", { name: "Monter Sprint B" }));
await waitFor(async () => {
const sprints = await tk.listSprints(projectId);
expect(sprints.find((s) => s.id === "s2")?.order).toBe(1);
});
// Delete with confirmation.
fireEvent.click(screen.getByRole("button", { name: "Supprimer Sprint B" }));
const dialog = await screen.findByRole("alertdialog", {
name: "Supprimer le sprint « Sprint B » ?",
});
expect(within(dialog).getByText(/passeront en « Sans sprint »/)).toBeTruthy();
fireEvent.click(within(dialog).getByRole("button", { name: "Supprimer" }));
await waitFor(async () => {
const sprints = await tk.listSprints(projectId);
expect(sprints.map((s) => s.id)).not.toContain("s2");
});
});
it("adds tickets to a sprint via the mobile picker", 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" }));
tk._seedTicket(projectId, ticket({ ref: "#2", number: 2, title: "Ticket deux" }));
await openProjectAndTab(gateways, "Sprints");
await screen.findByText("Sprint A");
fireEvent.click(screen.getByRole("button", { name: "Ajouter tickets" }));
const sheet = await screen.findByRole("dialog", {
name: "Ajouter des tickets au sprint « Sprint A »",
});
fireEvent.click(within(sheet).getByRole("checkbox", { name: "#1, Ticket un" }));
fireEvent.click(
within(sheet).getByRole("button", { name: /Ajouter au sprint/ }),
);
await waitFor(async () => {
const t = await tk.read(projectId, "#1");
expect(t.sprintId).toBe("s1");
});
});
});