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:
@ -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,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|||||||
@ -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>
|
||||||
|
|
||||||
|
|||||||
@ -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());
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user