feat(web): «Спринт» — выбор активных и будущих спринтов, фильтры (#112)

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
Andrey Epifancev 2026-10-25 20:41:21 +04:00
parent f4c3bd50a5
commit dea051e7f9
3 changed files with 178 additions and 44 deletions

View File

@ -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=<id> и его задачи", 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<HTMLButtonElement>("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();

View File

@ -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 (
<header className="agile-section__head">
<h2 className="agile-section__title">{s.name}</h2>
@ -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 (
<div data-testid="page-sprint" className="agile agile--wide">
<PageHeader title="Спринт" actions={<RefreshButton />} />
<AgileGate project={project} kind="scrum">
{(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 (
<Panel className="agile-section">
<p className="agile__empty">Нет активного спринта</p>
<Link className="agile__link" to={`/jira/backlog?project=${encodeURIComponent(project.label)}`}>Бэклог</Link>
</Panel>
);
}
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 && (
<Segmented label="Спринты" value={String(sprint.id)}
options={active.map((s) => ({ value: String(s.id), label: s.name, href: `/jira/sprint?project=${proj}&sprint=${s.id}` }))} />
<AgileToolbar filter={filter} setQ={setQ} set={set} onReset={reset} assignees={assigneeOptions(issues, view.assignee)} showHideDone>
{selectable.length > 0 && <SprintPicker sprints={selectable} selected={ids} onChange={pick} />}
</AgileToolbar>
{chosen.length === 0 ? (
<Panel className="agile-section">
<p className="agile__empty">Нет активного спринта</p>
<Link className="agile__link" to={`/jira/backlog?project=${encodeURIComponent(project.label)}`}>Бэклог</Link>
</Panel>
) : (
<>
{chosen.map((s) => <SprintHead key={s.id} s={s} />)}
<BoardColumns columns={columnsOf(d.board?.columns ?? null, shown)} />
</>
)}
<SprintHead s={sprint} />
<BoardColumns columns={columnsOf(d.board?.columns ?? null, sprint.issues)} />
</>
);
}}

View File

@ -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 (
<Dropdown className="agile-sprint-picker" button={({ open, toggle }) => (
<button type="button" className="ui-input multi-btn" aria-haspopup="true" aria-expanded={open} onClick={toggle}>
<span className="lbl">{label}</span><IconChevronDown size={16} />
</button>
)}>
{() => (
<div className="dd-list" role="group" aria-label="Спринты">
{sprints.map((s) => {
const on = selected.includes(s.id);
return (
<Checkbox key={s.id} className="dd-item" checked={on} disabled={on && selected.length === 1}
onChange={(e) => flip(s.id, e.target.checked)}
label={<>{s.name} <Badge>{s.state === "active" ? "активный" : "будущий"}</Badge></>} />
);
})}
</div>
)}
</Dropdown>
);
}