Merge feature/ticket38-assign-sprint-on-create into develop
Attribution d'un sprint à la création de ticket via popup SprintPicker (#38). QA vert (tsc + 634 tests). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
164
frontend/src/features/tickets/SprintPicker.test.tsx
Normal file
164
frontend/src/features/tickets/SprintPicker.test.tsx
Normal file
@ -0,0 +1,164 @@
|
||||
import { describe, it, expect, beforeEach, vi } from "vitest";
|
||||
import { fireEvent, render, screen } from "@testing-library/react";
|
||||
|
||||
import { DIProvider } from "@/app/di";
|
||||
import { MockSystemGateway, MockTicketGateway } from "@/adapters/mock";
|
||||
import type { Gateways } from "@/ports";
|
||||
import { SprintPicker } from "./SprintPicker";
|
||||
|
||||
const PROJECT_ID = "project-sprint-picker";
|
||||
|
||||
function renderPicker(
|
||||
ticket: MockTicketGateway,
|
||||
props: Partial<React.ComponentProps<typeof SprintPicker>> = {},
|
||||
) {
|
||||
const onSelect = vi.fn();
|
||||
const onClose = vi.fn();
|
||||
const gateways = { ticket } as unknown as Gateways;
|
||||
const utils = render(
|
||||
<DIProvider gateways={gateways}>
|
||||
<SprintPicker
|
||||
open
|
||||
projectId={PROJECT_ID}
|
||||
onSelect={onSelect}
|
||||
onClose={onClose}
|
||||
{...props}
|
||||
/>
|
||||
</DIProvider>,
|
||||
);
|
||||
return { ...utils, onSelect, onClose };
|
||||
}
|
||||
|
||||
/** Seeds two ordered sprints. */
|
||||
function seedSprints(ticket: MockTicketGateway) {
|
||||
const beta = ticket._seedSprint(PROJECT_ID, {
|
||||
id: "s2",
|
||||
order: 2,
|
||||
name: "Beta",
|
||||
ticketCount: 3,
|
||||
});
|
||||
const alpha = ticket._seedSprint(PROJECT_ID, {
|
||||
id: "s1",
|
||||
order: 1,
|
||||
name: "Alpha",
|
||||
});
|
||||
return { alpha, beta };
|
||||
}
|
||||
|
||||
describe("SprintPicker (#38)", () => {
|
||||
let system: MockSystemGateway;
|
||||
let ticket: MockTicketGateway;
|
||||
|
||||
beforeEach(() => {
|
||||
system = new MockSystemGateway();
|
||||
ticket = new MockTicketGateway(system);
|
||||
});
|
||||
|
||||
it("renders nothing when closed", () => {
|
||||
const gateways = { ticket } as unknown as Gateways;
|
||||
const { container } = render(
|
||||
<DIProvider gateways={gateways}>
|
||||
<SprintPicker
|
||||
open={false}
|
||||
projectId={PROJECT_ID}
|
||||
onSelect={vi.fn()}
|
||||
onClose={vi.fn()}
|
||||
/>
|
||||
</DIProvider>,
|
||||
);
|
||||
expect(container.querySelector('[role="dialog"]')).toBeNull();
|
||||
});
|
||||
|
||||
it("lists the project's sprints (ordered) plus a « Sans sprint » entry", async () => {
|
||||
seedSprints(ticket);
|
||||
renderPicker(ticket);
|
||||
|
||||
// "Sans sprint" is always present.
|
||||
expect(screen.getByLabelText("select no sprint")).toBeTruthy();
|
||||
|
||||
// Both sprints appear once loaded, ordered by `order` (Alpha before Beta).
|
||||
await screen.findByLabelText("select sprint Alpha");
|
||||
const options = screen
|
||||
.getAllByRole("option")
|
||||
.map((n) => n.getAttribute("aria-label"));
|
||||
expect(options).toEqual([
|
||||
"select no sprint",
|
||||
"select sprint Alpha",
|
||||
"select sprint Beta",
|
||||
]);
|
||||
});
|
||||
|
||||
it("selecting a sprint hands back the sprint and closes", async () => {
|
||||
const { alpha } = seedSprints(ticket);
|
||||
const { onSelect, onClose } = renderPicker(ticket);
|
||||
|
||||
fireEvent.click(await screen.findByLabelText("select sprint Alpha"));
|
||||
|
||||
expect(onSelect).toHaveBeenCalledTimes(1);
|
||||
expect(onSelect.mock.calls[0][0]).toMatchObject({
|
||||
id: alpha.id,
|
||||
name: "Alpha",
|
||||
});
|
||||
expect(onClose).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("selecting « Sans sprint » hands back null and closes", async () => {
|
||||
seedSprints(ticket);
|
||||
const { onSelect, onClose } = renderPicker(ticket, {
|
||||
selectedSprintId: "s1",
|
||||
});
|
||||
|
||||
await screen.findByLabelText("select sprint Alpha");
|
||||
fireEvent.click(screen.getByLabelText("select no sprint"));
|
||||
|
||||
expect(onSelect).toHaveBeenCalledTimes(1);
|
||||
expect(onSelect.mock.calls[0][0]).toBeNull();
|
||||
expect(onClose).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("marks the currently-selected sprint as aria-selected", async () => {
|
||||
seedSprints(ticket);
|
||||
renderPicker(ticket, { selectedSprintId: "s2" });
|
||||
|
||||
const beta = await screen.findByLabelText("select sprint Beta");
|
||||
expect(beta.getAttribute("aria-selected")).toBe("true");
|
||||
expect(
|
||||
screen.getByLabelText("select sprint Alpha").getAttribute("aria-selected"),
|
||||
).toBe("false");
|
||||
// "Sans sprint" is not selected when a sprint id is active.
|
||||
expect(
|
||||
screen.getByLabelText("select no sprint").getAttribute("aria-selected"),
|
||||
).toBe("false");
|
||||
});
|
||||
|
||||
it("shows an empty state when the project has no sprints", async () => {
|
||||
renderPicker(ticket);
|
||||
expect(await screen.findByText("No sprints yet.")).toBeTruthy();
|
||||
// "Sans sprint" is still offered so the user can clear an assignment.
|
||||
expect(screen.getByLabelText("select no sprint")).toBeTruthy();
|
||||
});
|
||||
|
||||
it("closes on Escape and on backdrop click", async () => {
|
||||
seedSprints(ticket);
|
||||
const { onClose } = renderPicker(ticket);
|
||||
await screen.findByLabelText("select sprint Alpha");
|
||||
|
||||
fireEvent.keyDown(document, { key: "Escape" });
|
||||
expect(onClose).toHaveBeenCalledTimes(1);
|
||||
|
||||
const dialog = screen.getByRole("dialog");
|
||||
const backdrop = dialog.parentElement as HTMLElement;
|
||||
fireEvent.mouseDown(backdrop);
|
||||
expect(onClose).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it("surfaces a listSprints failure", async () => {
|
||||
vi.spyOn(ticket, "listSprints").mockRejectedValueOnce({
|
||||
code: "INTERNAL",
|
||||
message: "boom",
|
||||
});
|
||||
renderPicker(ticket);
|
||||
const alert = await screen.findByRole("alert");
|
||||
expect(alert.textContent).toContain("boom");
|
||||
});
|
||||
});
|
||||
280
frontend/src/features/tickets/SprintPicker.tsx
Normal file
280
frontend/src/features/tickets/SprintPicker.tsx
Normal file
@ -0,0 +1,280 @@
|
||||
/**
|
||||
* Sprint-selection popup (ticket #38) — the sprint analogue of
|
||||
* {@link TicketPicker}. A modal that lists the project's sprints (via
|
||||
* {@link TicketGateway.listSprints}) and lets the caller pick one, or the
|
||||
* explicit **« Sans sprint »** entry to clear the assignment.
|
||||
*
|
||||
* Used by the ticket-creation form to attach a sprint at creation time, but it
|
||||
* is a generic read-only picker: it never mutates: it just hands the chosen
|
||||
* `Sprint` (or `null` for "no sprint") back through `onSelect`.
|
||||
*
|
||||
* Integration rules (mirrors {@link TicketPicker}, note
|
||||
* `ui-rework-sprint-scoping-contracts`):
|
||||
* - G5: mount at the **chrome/panel level**, never inside LayoutGrid/LeafView. A
|
||||
* focus-trap is mandatory because xterm captures keyboard focus.
|
||||
* - z-index `floatingWindowNested` (60): it opens from within other floating
|
||||
* surfaces (the create form), so it must sit above them.
|
||||
*/
|
||||
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
|
||||
import type { GatewayError, Sprint } from "@/domain";
|
||||
import { Button, Spinner, cn, zIndex } from "@/shared";
|
||||
import { useGateways } from "@/app/di";
|
||||
|
||||
export interface SprintPickerProps {
|
||||
/** Whether the popup is shown. Rendered as `null` when closed. */
|
||||
open: boolean;
|
||||
projectId: string;
|
||||
/** The currently-selected sprint id (highlighted); `null`/absent ⇒ none. */
|
||||
selectedSprintId?: string | null;
|
||||
/** Dialog heading (default "Sélectionner un sprint"). */
|
||||
title?: string;
|
||||
/** Selection callback — a `Sprint`, or `null` for the "Sans sprint" entry. */
|
||||
onSelect: (sprint: Sprint | null) => void;
|
||||
/** Called on Escape, backdrop click, or the Close button. */
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
/** Selector for tabbable elements, used by the focus-trap. */
|
||||
const FOCUSABLE =
|
||||
'a[href],button:not([disabled]),input:not([disabled]),select:not([disabled]),textarea:not([disabled]),[tabindex]:not([tabindex="-1"])';
|
||||
|
||||
export function SprintPicker({
|
||||
open,
|
||||
projectId,
|
||||
selectedSprintId,
|
||||
title = "Sélectionner un sprint",
|
||||
onSelect,
|
||||
onClose,
|
||||
}: SprintPickerProps) {
|
||||
if (!open) return null;
|
||||
return (
|
||||
<SprintPickerBody
|
||||
projectId={projectId}
|
||||
selectedSprintId={selectedSprintId ?? null}
|
||||
title={title}
|
||||
onSelect={onSelect}
|
||||
onClose={onClose}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function describe(e: unknown): string {
|
||||
if (e && typeof e === "object" && "message" in e) {
|
||||
return String((e as GatewayError).message);
|
||||
}
|
||||
return String(e);
|
||||
}
|
||||
|
||||
/**
|
||||
* The mounted picker. Split from {@link SprintPicker} so the sprint fetch only
|
||||
* runs while the popup is actually open — mounting/unmounting on `open` resets
|
||||
* state cleanly between openings.
|
||||
*/
|
||||
function SprintPickerBody({
|
||||
projectId,
|
||||
selectedSprintId,
|
||||
title,
|
||||
onSelect,
|
||||
onClose,
|
||||
}: Required<Omit<SprintPickerProps, "open">>) {
|
||||
const { ticket } = useGateways();
|
||||
const dialogRef = useRef<HTMLDivElement>(null);
|
||||
const [sprints, setSprints] = useState<Sprint[] | null>(null);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
// Load the project's sprints once on mount.
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
setError(null);
|
||||
void ticket
|
||||
.listSprints(projectId)
|
||||
.then((list) => {
|
||||
if (!cancelled) setSprints(list);
|
||||
})
|
||||
.catch((e) => {
|
||||
if (!cancelled) {
|
||||
setError(describe(e));
|
||||
setSprints([]);
|
||||
}
|
||||
});
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [ticket, projectId]);
|
||||
|
||||
// Focus-trap (G5): mirror TicketPicker — move focus in on mount, keep Tab
|
||||
// cycling inside the dialog, restore focus on close, and close on Escape.
|
||||
useEffect(() => {
|
||||
const previouslyFocused = document.activeElement as HTMLElement | null;
|
||||
const node = dialogRef.current;
|
||||
const first = node?.querySelector<HTMLElement>(FOCUSABLE);
|
||||
first?.focus();
|
||||
|
||||
function onKeyDown(e: KeyboardEvent) {
|
||||
if (e.key === "Escape") {
|
||||
e.preventDefault();
|
||||
onClose();
|
||||
return;
|
||||
}
|
||||
if (e.key !== "Tab" || !node) return;
|
||||
const focusables = Array.from(
|
||||
node.querySelectorAll<HTMLElement>(FOCUSABLE),
|
||||
).filter((el) => el.offsetParent !== null || el === document.activeElement);
|
||||
if (focusables.length === 0) return;
|
||||
const firstEl = focusables[0];
|
||||
const lastEl = focusables[focusables.length - 1];
|
||||
const active = document.activeElement;
|
||||
if (e.shiftKey && active === firstEl) {
|
||||
e.preventDefault();
|
||||
lastEl.focus();
|
||||
} else if (!e.shiftKey && active === lastEl) {
|
||||
e.preventDefault();
|
||||
firstEl.focus();
|
||||
}
|
||||
}
|
||||
|
||||
document.addEventListener("keydown", onKeyDown);
|
||||
return () => {
|
||||
document.removeEventListener("keydown", onKeyDown);
|
||||
previouslyFocused?.focus?.();
|
||||
};
|
||||
}, [onClose]);
|
||||
|
||||
const ordered = sprints
|
||||
? [...sprints].sort((a, b) => a.order - b.order)
|
||||
: [];
|
||||
|
||||
function pick(sprint: Sprint | null) {
|
||||
onSelect(sprint);
|
||||
onClose();
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
className="fixed inset-0 flex items-center justify-center bg-black/55 p-4"
|
||||
style={{ zIndex: zIndex.floatingWindowNested }}
|
||||
onMouseDown={(e) => {
|
||||
// Backdrop click closes; clicks inside the panel don't bubble here.
|
||||
if (e.target === e.currentTarget) onClose();
|
||||
}}
|
||||
>
|
||||
<div
|
||||
ref={dialogRef}
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-label={title}
|
||||
className="flex max-h-[80vh] w-full max-w-sm flex-col overflow-hidden rounded-lg border border-border bg-surface shadow-xl"
|
||||
>
|
||||
{/* ── Header ── */}
|
||||
<header className="flex shrink-0 items-center justify-between gap-3 border-b border-border px-4 py-3">
|
||||
<span className="text-sm font-medium text-content">{title}</span>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
aria-label="close sprint picker"
|
||||
onClick={onClose}
|
||||
>
|
||||
Close
|
||||
</Button>
|
||||
</header>
|
||||
|
||||
{error && (
|
||||
<p
|
||||
role="alert"
|
||||
className="shrink-0 border-b border-danger/40 bg-danger/10 px-4 py-2 text-sm text-danger"
|
||||
>
|
||||
{error}
|
||||
</p>
|
||||
)}
|
||||
|
||||
{/* ── Options ── */}
|
||||
<div className="min-h-0 flex-1 overflow-auto px-4 py-3">
|
||||
<ul className="flex flex-col divide-y divide-border">
|
||||
{/* "Sans sprint" always available (clears the assignment). */}
|
||||
<li className="py-1.5 first:pt-0">
|
||||
<SprintOption
|
||||
label="Sans sprint"
|
||||
ariaLabel="select no sprint"
|
||||
selected={selectedSprintId === null}
|
||||
onClick={() => pick(null)}
|
||||
/>
|
||||
</li>
|
||||
|
||||
{sprints === null ? (
|
||||
<li className="flex items-center gap-2 py-2 text-sm text-muted">
|
||||
<Spinner size={14} />
|
||||
<span>Loading sprints…</span>
|
||||
</li>
|
||||
) : (
|
||||
ordered.map((s) => (
|
||||
<li key={s.id} className="py-1.5 last:pb-0">
|
||||
<SprintOption
|
||||
label={s.name}
|
||||
ariaLabel={`select sprint ${s.name}`}
|
||||
count={s.ticketCount}
|
||||
selected={selectedSprintId === s.id}
|
||||
onClick={() => pick(s)}
|
||||
/>
|
||||
</li>
|
||||
))
|
||||
)}
|
||||
</ul>
|
||||
|
||||
{sprints !== null && ordered.length === 0 && !error && (
|
||||
<p className="mt-2 text-sm text-muted">No sprints yet.</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** One selectable row (checkmark on the active entry), shared shape/styling. */
|
||||
function SprintOption({
|
||||
label,
|
||||
ariaLabel,
|
||||
count,
|
||||
selected,
|
||||
onClick,
|
||||
}: {
|
||||
label: string;
|
||||
ariaLabel: string;
|
||||
count?: number;
|
||||
selected: boolean;
|
||||
onClick: () => void;
|
||||
}) {
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
role="option"
|
||||
aria-selected={selected}
|
||||
aria-label={ariaLabel}
|
||||
onClick={onClick}
|
||||
className={cn(
|
||||
"flex w-full items-center gap-2 rounded-md px-2 py-1.5 text-left transition-colors",
|
||||
"hover:bg-raised focus:bg-raised focus:outline-none",
|
||||
selected && "bg-raised",
|
||||
)}
|
||||
>
|
||||
<span
|
||||
aria-hidden="true"
|
||||
className={cn(
|
||||
"flex h-4 w-4 shrink-0 items-center justify-center text-[10px] font-bold",
|
||||
selected ? "text-primary" : "text-transparent",
|
||||
)}
|
||||
>
|
||||
✓
|
||||
</span>
|
||||
<span className="min-w-0 flex-1 truncate text-sm font-medium text-content">
|
||||
{label}
|
||||
</span>
|
||||
{count !== undefined && (
|
||||
<span className="shrink-0 rounded-full bg-raised px-1.5 text-[10px] font-medium text-muted">
|
||||
{count}
|
||||
</span>
|
||||
)}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
@ -14,6 +14,7 @@ import { Button, Input, Panel, Spinner, cn } from "@/shared";
|
||||
import { useTickets } from "./useTickets";
|
||||
import { useProjectAgents } from "./useProjectAgents";
|
||||
import { SprintManager } from "./SprintManager";
|
||||
import { SprintPicker } from "./SprintPicker";
|
||||
import { TicketFacetsBar } from "./TicketFacetsBar";
|
||||
import {
|
||||
PriorityBadge,
|
||||
@ -42,6 +43,10 @@ export function TicketsPanel({ projectId, onOpen }: TicketsPanelProps) {
|
||||
const [showSprints, setShowSprints] = useState(false);
|
||||
const [newTitle, setNewTitle] = useState("");
|
||||
const [newPriority, setNewPriority] = useState<TicketPriority>("medium");
|
||||
// Sprint chosen for the new ticket (`null` ⇒ "Sans sprint"), and whether its
|
||||
// picker popup is open (#38).
|
||||
const [newSprint, setNewSprint] = useState<Sprint | null>(null);
|
||||
const [showSprintPicker, setShowSprintPicker] = useState(false);
|
||||
|
||||
const items = vm.list?.items ?? [];
|
||||
|
||||
@ -66,12 +71,18 @@ export function TicketsPanel({ projectId, onOpen }: TicketsPanelProps) {
|
||||
title: newTitle.trim(),
|
||||
priority: newPriority,
|
||||
});
|
||||
if (created) {
|
||||
setNewTitle("");
|
||||
setNewPriority("medium");
|
||||
setShowCreate(false);
|
||||
onOpen(created.ref);
|
||||
if (!created) return; // create failed; `vm.error` already carries the reason.
|
||||
// Two-step assignment (#38): the ticket exists either way. If the sprint
|
||||
// assignment fails, the ticket is kept and `vm.assignSprint` surfaces the
|
||||
// failure via `vm.error` — we never lose the created ticket.
|
||||
if (newSprint) {
|
||||
await vm.assignSprint(created.ref, newSprint.id);
|
||||
}
|
||||
setNewTitle("");
|
||||
setNewPriority("medium");
|
||||
setNewSprint(null);
|
||||
setShowCreate(false);
|
||||
onOpen(created.ref);
|
||||
}
|
||||
|
||||
return (
|
||||
@ -146,6 +157,22 @@ export function TicketsPanel({ projectId, onOpen }: TicketsPanelProps) {
|
||||
Create
|
||||
</Button>
|
||||
</div>
|
||||
{/* Sprint field (#38): opens the SprintPicker popup; shows the choice
|
||||
(or "Sans sprint"). */}
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-xs text-muted">Sprint</span>
|
||||
<Button
|
||||
type="button"
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
aria-label="choose sprint for new ticket"
|
||||
disabled={vm.busy}
|
||||
onClick={() => setShowSprintPicker(true)}
|
||||
className="border border-border"
|
||||
>
|
||||
{newSprint ? newSprint.name : "Sans sprint"}
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
)}
|
||||
|
||||
@ -252,6 +279,17 @@ export function TicketsPanel({ projectId, onOpen }: TicketsPanelProps) {
|
||||
onClose={() => setShowSprints(false)}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Sprint picker for the create form (#38) — chrome-level, nested z-index,
|
||||
focus-trapped. Selecting an entry sets the pending sprint and closes. */}
|
||||
<SprintPicker
|
||||
open={showSprintPicker}
|
||||
projectId={projectId}
|
||||
selectedSprintId={newSprint?.id ?? null}
|
||||
title="Sprint du nouveau ticket"
|
||||
onSelect={(sprint) => setNewSprint(sprint)}
|
||||
onClose={() => setShowSprintPicker(false)}
|
||||
/>
|
||||
</Panel>
|
||||
);
|
||||
}
|
||||
|
||||
@ -15,6 +15,8 @@ export type {
|
||||
TicketPickerResult,
|
||||
TicketPickerSelectionMode,
|
||||
} from "./TicketPicker";
|
||||
export { SprintPicker } from "./SprintPicker";
|
||||
export type { SprintPickerProps } from "./SprintPicker";
|
||||
export { TicketFacetsBar } from "./TicketFacetsBar";
|
||||
export type { TicketFacetsBarProps } from "./TicketFacetsBar";
|
||||
export { useTicketSearch } from "./useTicketSearch";
|
||||
|
||||
@ -214,6 +214,80 @@ describe("TicketsView", () => {
|
||||
expect(await screen.findByText("Live created")).toBeTruthy();
|
||||
});
|
||||
|
||||
it("creates a ticket and assigns the sprint chosen via the picker (#38)", async () => {
|
||||
ticket._seedSprint(PROJECT_ID, { id: "s1", order: 1, name: "Alpha" });
|
||||
const setSpy = vi.spyOn(ticket, "setTicketSprint");
|
||||
|
||||
renderView(ticket, system, agent);
|
||||
fireEvent.click(await screen.findByRole("button", { name: "New" }));
|
||||
fireEvent.change(screen.getByLabelText("new ticket title"), {
|
||||
target: { value: "With sprint" },
|
||||
});
|
||||
|
||||
// Field defaults to "Sans sprint"; open the picker and pick Alpha.
|
||||
const sprintField = screen.getByLabelText("choose sprint for new ticket");
|
||||
expect(sprintField.textContent).toContain("Sans sprint");
|
||||
fireEvent.click(sprintField);
|
||||
fireEvent.click(await screen.findByLabelText("select sprint Alpha"));
|
||||
expect(
|
||||
screen.getByLabelText("choose sprint for new ticket").textContent,
|
||||
).toContain("Alpha");
|
||||
|
||||
fireEvent.click(screen.getByRole("button", { name: "Create" }));
|
||||
|
||||
// create → setTicketSprint(projectId, ref, "s1", version).
|
||||
await waitFor(() => expect(setSpy).toHaveBeenCalledTimes(1));
|
||||
const call = setSpy.mock.calls[0];
|
||||
expect(call[0]).toBe(PROJECT_ID);
|
||||
expect(call[2]).toBe("s1");
|
||||
await waitFor(async () => {
|
||||
const fresh = await ticket.read(PROJECT_ID, call[1]);
|
||||
expect(fresh.sprintId).toBe("s1");
|
||||
});
|
||||
});
|
||||
|
||||
it("creates a ticket without a sprint — setTicketSprint is not called (#38)", async () => {
|
||||
const setSpy = vi.spyOn(ticket, "setTicketSprint");
|
||||
|
||||
renderView(ticket, system, agent);
|
||||
fireEvent.click(await screen.findByRole("button", { name: "New" }));
|
||||
fireEvent.change(screen.getByLabelText("new ticket title"), {
|
||||
target: { value: "No sprint" },
|
||||
});
|
||||
fireEvent.click(screen.getByRole("button", { name: "Create" }));
|
||||
|
||||
await waitFor(async () => {
|
||||
const list = await ticket.list(PROJECT_ID);
|
||||
expect(list.items.map((i) => i.title)).toContain("No sprint");
|
||||
});
|
||||
expect(setSpy).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("keeps the created ticket and surfaces the error when sprint assignment fails (#38)", async () => {
|
||||
ticket._seedSprint(PROJECT_ID, { id: "s1", order: 1, name: "Alpha" });
|
||||
vi.spyOn(ticket, "setTicketSprint").mockRejectedValueOnce({
|
||||
code: "INTERNAL",
|
||||
message: "assign boom",
|
||||
});
|
||||
|
||||
renderView(ticket, system, agent);
|
||||
fireEvent.click(await screen.findByRole("button", { name: "New" }));
|
||||
fireEvent.change(screen.getByLabelText("new ticket title"), {
|
||||
target: { value: "Kept anyway" },
|
||||
});
|
||||
fireEvent.click(screen.getByLabelText("choose sprint for new ticket"));
|
||||
fireEvent.click(await screen.findByLabelText("select sprint Alpha"));
|
||||
fireEvent.click(screen.getByRole("button", { name: "Create" }));
|
||||
|
||||
// The ticket was created despite the failed assignment (no data lost)…
|
||||
await waitFor(async () => {
|
||||
const list = await ticket.list(PROJECT_ID);
|
||||
expect(list.items.map((i) => i.title)).toContain("Kept anyway");
|
||||
});
|
||||
// …and the assignment failure is surfaced.
|
||||
expect(await screen.findByText(/assign boom/)).toBeTruthy();
|
||||
});
|
||||
|
||||
it("multi-selects status/priority facets (OR intra, AND inter) and clears (#12)", async () => {
|
||||
const listSpy = vi.spyOn(ticket, "list");
|
||||
await ticket.create(PROJECT_ID, {
|
||||
|
||||
Reference in New Issue
Block a user