diff --git a/web/src/modules/jira/agile/SprintPage.test.tsx b/web/src/modules/jira/agile/SprintPage.test.tsx index 55cef31..cac26ee 100644 --- a/web/src/modules/jira/agile/SprintPage.test.tsx +++ b/web/src/modules/jira/agile/SprintPage.test.tsx @@ -1,4 +1,4 @@ -import { render, screen, waitFor, within } from "@testing-library/react"; +import { cleanup, render, screen, waitFor, within } from "@testing-library/react"; import userEvent from "@testing-library/user-event"; import { createMemoryRouter } from "react-router"; import { beforeEach, describe, expect, it } from "vitest"; @@ -96,42 +96,138 @@ describe("«Спринт»", () => { expect(await within(await page()).findByText("срок прошёл")).toBeInTheDocument(); }); - it("два активных: переключатель «Спринты», выбор — ?sprint= и его задачи", async () => { - hub(agile([s102(), sprint({ id: 104, name: "AWPCA_Sprint_5", issues: [iss("AURORA-77")], count: 1 })])); - const router = open("/app/jira/sprint?project=AWPCA"); - const p = within(await page()); - await p.findByText("Тема AURORA-1"); - const seg = p.getByRole("navigation", { name: "Спринты" }); - await userEvent.click(within(seg).getByRole("link", { name: "AWPCA_Sprint_5" })); - expect(await p.findByText("Тема AURORA-77")).toBeInTheDocument(); - expect(p.queryByText("Тема AURORA-1")).toBeNull(); - expect(router.state.location.search).toContain("sprint=104"); - expect(router.state.location.search).toContain("project=AWPCA"); + const s104 = () => sprint({ id: 104, name: "AWPCA_Sprint_5", issues: [iss("AURORA-77")], count: 1 }); + const s103 = () => sprint({ id: 103, name: "AWPCA_Sprint_4", state: "future", start_date: iso(5), end_date: iso(15), issues: [iss("AURORA-88")], count: 1 }); + const picker = (p: HTMLElement) => p.querySelector("button[aria-haspopup]")!; + const sprintGroup = () => screen.getByRole("group", { name: "Спринты" }); + + it("по умолчанию выбраны все активные: два активных — два заголовка и задачи обоих", async () => { + hub(agile([s102(), s104()])); + open("/app/jira/sprint?project=AWPCA"); + const p = await page(); + await within(p).findByText("Тема AURORA-1"); + expect(within(p).getByText("Тема AURORA-77")).toBeInTheDocument(); + expect(within(p).getAllByRole("heading", { level: 2 }).map((h) => h.textContent)).toEqual( + expect.arrayContaining(["AWPCA_Sprint_3", "AWPCA_Sprint_5"])); + expect(picker(p)).toHaveTextContent("Спринты: 2"); }); - it("один активный: переключателя нет", async () => { + it("один активный: на кнопке «Спринты» его имя", async () => { hub(agile()); open("/app/jira/sprint?project=AWPCA"); - const p = within(await page()); - await p.findByText("Тема AURORA-1"); - expect(p.queryByRole("navigation", { name: "Спринты" })).toBeNull(); + const p = await page(); + await within(p).findByText("Тема AURORA-1"); + expect(picker(p)).toHaveTextContent("AWPCA_Sprint_3"); }); - it("устаревший ?sprint= — первый активный", async () => { + it("«Будущий спринт на доске»: отметить будущий — ?sprint=101,102 через replace, «осталось» только у активного", async () => { + hub(agile([sprint({ id: 101, name: "Активный", issues: [iss("AURORA-5")], count: 1 }), sprint({ id: 102, name: "Будущий", state: "future", start_date: iso(5), end_date: iso(15), issues: [iss("AURORA-6")], count: 1 })])); + const router = open("/app/jira/sprint?project=AWPCA"); + const p = await page(); + await within(p).findByText("Тема AURORA-5"); + expect(within(p).queryByText("Тема AURORA-6")).toBeNull(); + await userEvent.click(picker(p)); + await userEvent.click(within(sprintGroup()).getByRole("checkbox", { name: /Будущий/ })); + expect(await within(p).findByText("Тема AURORA-6")).toBeInTheDocument(); + expect(router.state.location.search).toContain("sprint=101%2C102"); + expect(router.state.location.search).toContain("project=AWPCA"); + expect(router.state.historyAction).toBe("REPLACE"); + expect(within(p).getAllByRole("heading", { level: 2, name: /^(Активный|Будущий)$/ })).toHaveLength(2); + expect(within(p).getAllByText(/осталось \d+ дн\./)).toHaveLength(1); + }); + + it("в списке спринтов подписи «активный»/«будущий»; последнюю отметку снять нельзя", async () => { + hub(agile([s102(), s103()])); + open("/app/jira/sprint?project=AWPCA"); + const p = await page(); + await within(p).findByText("Тема AURORA-1"); + await userEvent.click(picker(p)); + const g = within(sprintGroup()); + expect(g.getByRole("checkbox", { name: /AWPCA_Sprint_3.*активный/ })).toBeChecked(); + expect(g.getByRole("checkbox", { name: /AWPCA_Sprint_4.*будущий/ })).not.toBeChecked(); + expect(g.getByRole("checkbox", { name: /AWPCA_Sprint_3/ })).toBeDisabled(); + await userEvent.click(g.getByRole("checkbox", { name: /AWPCA_Sprint_4/ })); + expect(g.getByRole("checkbox", { name: /AWPCA_Sprint_3/ })).toBeEnabled(); + }); + + it("задача в обоих выбранных спринтах — одна карточка", async () => { + hub(agile([s102(), sprint({ id: 104, name: "AWPCA_Sprint_5", issues: [iss("AURORA-1", { story_points: 5 })], count: 1 })])); + open("/app/jira/sprint?project=AWPCA"); + const p = await page(); + await within(p).findByText("Тема AURORA-1"); + expect(within(p).getAllByText("Тема AURORA-1")).toHaveLength(1); + }); + + it("?sprint=999 и ?sprint=abc — все активные", async () => { + hub(agile([s102(), s103()])); + open("/app/jira/sprint?project=AWPCA&sprint=999"); + expect(await within(await page()).findByText("Тема AURORA-1")).toBeInTheDocument(); + expect(screen.queryByText("Тема AURORA-88")).toBeNull(); + }); + + it("мусорный ?sprint=abc — все активные", async () => { + hub(agile()); + open("/app/jira/sprint?project=AWPCA&sprint=abc"); + expect(await within(await page()).findByText("Тема AURORA-1")).toBeInTheDocument(); + }); + + it("?sprint=103 (будущий) — только он", async () => { + hub(agile([s102(), s103()])); + open("/app/jira/sprint?project=AWPCA&sprint=103"); + const p = await page(); + expect(await within(p).findByText("Тема AURORA-88")).toBeInTheDocument(); + expect(within(p).queryByText("Тема AURORA-1")).toBeNull(); + }); + + it("устаревший ?sprint= (закрытый) — все активные", async () => { hub(agile([s102(), sprint({ id: 101, name: "Закрытый", state: "closed", issues: [iss("AURORA-99")] })])); open("/app/jira/sprint?project=AWPCA&sprint=101"); expect(await within(await page()).findByText("Тема AURORA-1")).toBeInTheDocument(); expect(screen.queryByText("Тема AURORA-99")).toBeNull(); }); - it("мусорный ?sprint=abc — первый активный", async () => { + it("нет активного, есть будущий — «Нет активного спринта», «Бэклог», выбор доступен; выбор будущего показывает доску", async () => { + hub(agile([s103()])); + open("/app/jira/sprint?project=AWPCA"); + const p = within(await page()); + expect(await p.findByText("Нет активного спринта")).toBeInTheDocument(); + expect(p.getByRole("link", { name: "Бэклог" })).toHaveAttribute("href", "/app/jira/backlog?project=AWPCA"); + await userEvent.click(picker(await page())); + await userEvent.click(within(sprintGroup()).getByRole("checkbox", { name: /AWPCA_Sprint_4/ })); + expect(await p.findByText("Тема AURORA-88")).toBeInTheDocument(); + expect(p.queryByText("Нет активного спринта")).toBeNull(); + }); + + it("«Исполнитель на доске спринта»: только его карточки, числа колонок по видимым; на «Бэклоге» «все»", async () => { + const sp = s102({ issues: [iss("AURORA-1", { assignee: "ivanov" }), iss("AURORA-2", { assignee: "petrov" }), iss("AURORA-3", { assignee: "ivanov", status: "Готово", done: true })] }); + hub(agile([sp])); + open("/app/jira/sprint?project=AWPCA"); + const p = await page(); + await within(p).findByText("Тема AURORA-2"); + await userEvent.selectOptions(within(p).getByLabelText("Исполнитель"), "ivanov"); + await waitFor(() => expect(within(p).queryByText("Тема AURORA-2")).toBeNull()); + expect(within(p).getByText("Тема AURORA-1")).toBeInTheDocument(); + const cols = Array.from(p.querySelectorAll(".agile-col")); + expect(cols.map((c) => c.querySelector(".agile-col__count")!.textContent)).toEqual(["1", "1"]); + cleanup(); + hub(agile([sp])); + open("/app/jira/backlog?project=AWPCA"); + const b = await screen.findByTestId("page-backlog"); + expect(await within(b).findByLabelText("Исполнитель")).toHaveValue(""); + }); + + it("«Скрыть выполненные» убирает выполненные карточки", async () => { hub(agile()); - open("/app/jira/sprint?project=AWPCA&sprint=abc"); - expect(await within(await page()).findByText("Тема AURORA-1")).toBeInTheDocument(); + open("/app/jira/sprint?project=AWPCA"); + const p = await page(); + await within(p).findByText("Тема AURORA-2"); + await userEvent.click(within(p).getByRole("checkbox", { name: "Скрыть выполненные" })); + expect(within(p).queryByText("Тема AURORA-2")).toBeNull(); + expect(within(p).getByText("Тема AURORA-1")).toBeInTheDocument(); }); it("нет активного — «Нет активного спринта» и ссылка «Бэклог»", async () => { - hub(agile([sprint({ id: 103, name: "F", state: "future" })])); + hub(agile([sprint({ id: 105, name: "C", state: "closed" })])); open("/app/jira/sprint?project=AWPCA"); const p = within(await page()); expect(await p.findByText("Нет активного спринта")).toBeInTheDocument(); diff --git a/web/src/modules/jira/agile/SprintPage.tsx b/web/src/modules/jira/agile/SprintPage.tsx index 35508f4..6858646 100644 --- a/web/src/modules/jira/agile/SprintPage.tsx +++ b/web/src/modules/jira/agile/SprintPage.tsx @@ -1,16 +1,20 @@ import { Link, useSearchParams } from "react-router"; import type { Project } from "../../../api/types.ts"; -import { PageHeader, Panel, Segmented } from "../../../ui/index.ts"; +import { PageHeader, Panel } from "../../../ui/index.ts"; import type { AgileSprint } from "../api/types.ts"; import { RefreshButton } from "../RefreshButton.tsx"; import { AgileGate } from "./AgileGate.tsx"; import { BoardColumns } from "./BoardColumns.tsx"; +import { AgileToolbar } from "./AgileToolbar.tsx"; import { columnsOf } from "./columns.ts"; -import { daysLeft, fmtRange, fmtSp, tasksWord } from "./model.ts"; +import { assigneeOptions, matches, parseSprintIds, selectableSprints, sprintIssues } from "./filters.ts"; +import { SprintPicker } from "./SprintPicker.tsx"; +import { useAgileView } from "./useAgileView.ts"; +import { daysLeft, fmtRange, fmtSp, isEpic, tasksWord } from "./model.ts"; import "./agile.css"; function SprintHead({ s }: { s: AgileSprint }) { - const left = daysLeft(s.end_date, new Date()); + const left = s.state === "active" ? daysLeft(s.end_date, new Date()) : null; return (

{s.name}

@@ -23,35 +27,38 @@ function SprintHead({ s }: { s: AgileSprint }) { ); } -// «Спринт» (spec web-app): колонки доски активного спринта; только просмотр +// «Спринт» (spec web-app): колонки доски выбранных спринтов (по умолчанию все активные); только просмотр export function SprintPage({ project }: { project: Project }) { - const [params] = useSearchParams(); - const wanted = params.get("sprint"); + const [params, setParams] = useSearchParams(); + const { view, set, setQ, filter, reset } = useAgileView(project.label, "sprint"); return (
} /> {(d) => { - const active = d.sprints.filter((s) => s.state === "active"); - // устаревший или мусорный ?sprint= — первый активный (Review Focus 2) - const sprint = active.find((s) => String(s.id) === wanted) ?? active[0]; - if (!sprint) { - return ( - -

Нет активного спринта

- Бэклог -
- ); - } - const proj = encodeURIComponent(project.label); + const selectable = selectableSprints(d.sprints); + const ids = parseSprintIds(params.get("sprint"), d.sprints); + const chosen = selectable.filter((s) => ids.includes(s.id)); + const issues = sprintIssues(chosen).filter((i) => !isEpic(i)); + const shown = issues.filter((i) => matches(i, filter)); + // выбор спринтов — в адресе, без новой записи истории + const pick = (next: number[]) => setParams((p) => { const n = new URLSearchParams(p); n.set("sprint", next.join(",")); return n; }, { replace: true }); return ( <> - {active.length > 1 && ( - ({ value: String(s.id), label: s.name, href: `/jira/sprint?project=${proj}&sprint=${s.id}` }))} /> + + {selectable.length > 0 && } + + {chosen.length === 0 ? ( + +

Нет активного спринта

+ Бэклог +
+ ) : ( + <> + {chosen.map((s) => )} + + )} - - ); }} diff --git a/web/src/modules/jira/agile/SprintPicker.tsx b/web/src/modules/jira/agile/SprintPicker.tsx new file mode 100644 index 0000000..87e60b3 --- /dev/null +++ b/web/src/modules/jira/agile/SprintPicker.tsx @@ -0,0 +1,31 @@ +// выбор спринтов доски: активные и будущие; последнюю отметку снять нельзя +import { Badge, Checkbox, Dropdown, IconChevronDown } from "../../../ui/index.ts"; +import type { AgileSprint } from "../api/types.ts"; + +export function SprintPicker({ sprints, selected, onChange }: { + sprints: AgileSprint[]; selected: number[]; onChange(ids: number[]): void; +}) { + const chosen = sprints.filter((s) => selected.includes(s.id)); + const label = chosen.length === 1 ? chosen[0].name : `Спринты: ${chosen.length}`; + const flip = (id: number, on: boolean) => onChange(sprints.map((s) => s.id).filter((x) => (x === id ? on : selected.includes(x)))); + return ( + ( + + )}> + {() => ( +
+ {sprints.map((s) => { + const on = selected.includes(s.id); + return ( + flip(s.id, e.target.checked)} + label={<>{s.name} {s.state === "active" ? "активный" : "будущий"}} /> + ); + })} +
+ )} +
+ ); +}