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:
263
frontend/src/features/web/tickets/WebTicketsSprints.test.tsx
Normal file
263
frontend/src/features/web/tickets/WebTicketsSprints.test.tsx
Normal 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");
|
||||
});
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user