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 userEvent from "@testing-library/user-event";
import { createMemoryRouter } from "react-router"; import { createMemoryRouter } from "react-router";
import { beforeEach, describe, expect, it } from "vitest"; import { beforeEach, describe, expect, it } from "vitest";
@ -96,42 +96,138 @@ describe("«Спринт»", () => {
expect(await within(await page()).findByText("срок прошёл")).toBeInTheDocument(); expect(await within(await page()).findByText("срок прошёл")).toBeInTheDocument();
}); });
it("два активных: переключатель «Спринты», выбор — ?sprint=<id> и его задачи", async () => { const s104 = () => sprint({ id: 104, name: "AWPCA_Sprint_5", issues: [iss("AURORA-77")], count: 1 });
hub(agile([s102(), 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 router = open("/app/jira/sprint?project=AWPCA"); const picker = (p: HTMLElement) => p.querySelector<HTMLButtonElement>("button[aria-haspopup]")!;
const p = within(await page()); const sprintGroup = () => screen.getByRole("group", { name: "Спринты" });
await p.findByText("Тема AURORA-1");
const seg = p.getByRole("navigation", { name: "Спринты" }); it("по умолчанию выбраны все активные: два активных — два заголовка и задачи обоих", async () => {
await userEvent.click(within(seg).getByRole("link", { name: "AWPCA_Sprint_5" })); hub(agile([s102(), s104()]));
expect(await p.findByText("Тема AURORA-77")).toBeInTheDocument(); open("/app/jira/sprint?project=AWPCA");
expect(p.queryByText("Тема AURORA-1")).toBeNull(); const p = await page();
expect(router.state.location.search).toContain("sprint=104"); await within(p).findByText("Тема AURORA-1");
expect(router.state.location.search).toContain("project=AWPCA"); 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()); hub(agile());
open("/app/jira/sprint?project=AWPCA"); open("/app/jira/sprint?project=AWPCA");
const p = within(await page()); const p = await page();
await p.findByText("Тема AURORA-1"); await within(p).findByText("Тема AURORA-1");
expect(p.queryByRole("navigation", { name: "Спринты" })).toBeNull(); 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")] })])); hub(agile([s102(), sprint({ id: 101, name: "Закрытый", state: "closed", issues: [iss("AURORA-99")] })]));
open("/app/jira/sprint?project=AWPCA&sprint=101"); open("/app/jira/sprint?project=AWPCA&sprint=101");
expect(await within(await page()).findByText("Тема AURORA-1")).toBeInTheDocument(); expect(await within(await page()).findByText("Тема AURORA-1")).toBeInTheDocument();
expect(screen.queryByText("Тема AURORA-99")).toBeNull(); 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()); hub(agile());
open("/app/jira/sprint?project=AWPCA&sprint=abc"); open("/app/jira/sprint?project=AWPCA");
expect(await within(await page()).findByText("Тема AURORA-1")).toBeInTheDocument(); 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 () => { 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"); open("/app/jira/sprint?project=AWPCA");
const p = within(await page()); const p = within(await page());
expect(await p.findByText("Нет активного спринта")).toBeInTheDocument(); expect(await p.findByText("Нет активного спринта")).toBeInTheDocument();

View File

@ -1,16 +1,20 @@
import { Link, useSearchParams } from "react-router"; import { Link, useSearchParams } from "react-router";
import type { Project } from "../../../api/types.ts"; 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 type { AgileSprint } from "../api/types.ts";
import { RefreshButton } from "../RefreshButton.tsx"; import { RefreshButton } from "../RefreshButton.tsx";
import { AgileGate } from "./AgileGate.tsx"; import { AgileGate } from "./AgileGate.tsx";
import { BoardColumns } from "./BoardColumns.tsx"; import { BoardColumns } from "./BoardColumns.tsx";
import { AgileToolbar } from "./AgileToolbar.tsx";
import { columnsOf } from "./columns.ts"; 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"; import "./agile.css";
function SprintHead({ s }: { s: AgileSprint }) { 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 ( return (
<header className="agile-section__head"> <header className="agile-section__head">
<h2 className="agile-section__title">{s.name}</h2> <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 }) { export function SprintPage({ project }: { project: Project }) {
const [params] = useSearchParams(); const [params, setParams] = useSearchParams();
const wanted = params.get("sprint"); const { view, set, setQ, filter, reset } = useAgileView(project.label, "sprint");
return ( return (
<div data-testid="page-sprint" className="agile agile--wide"> <div data-testid="page-sprint" className="agile agile--wide">
<PageHeader title="Спринт" actions={<RefreshButton />} /> <PageHeader title="Спринт" actions={<RefreshButton />} />
<AgileGate project={project} kind="scrum"> <AgileGate project={project} kind="scrum">
{(d) => { {(d) => {
const active = d.sprints.filter((s) => s.state === "active"); const selectable = selectableSprints(d.sprints);
// устаревший или мусорный ?sprint= — первый активный (Review Focus 2) const ids = parseSprintIds(params.get("sprint"), d.sprints);
const sprint = active.find((s) => String(s.id) === wanted) ?? active[0]; const chosen = selectable.filter((s) => ids.includes(s.id));
if (!sprint) { const issues = sprintIssues(chosen).filter((i) => !isEpic(i));
return ( const shown = issues.filter((i) => matches(i, filter));
<Panel className="agile-section"> // выбор спринтов — в адресе, без новой записи истории
<p className="agile__empty">Нет активного спринта</p> const pick = (next: number[]) => setParams((p) => { const n = new URLSearchParams(p); n.set("sprint", next.join(",")); return n; }, { replace: true });
<Link className="agile__link" to={`/jira/backlog?project=${encodeURIComponent(project.label)}`}>Бэклог</Link>
</Panel>
);
}
const proj = encodeURIComponent(project.label);
return ( return (
<> <>
{active.length > 1 && ( <AgileToolbar filter={filter} setQ={setQ} set={set} onReset={reset} assignees={assigneeOptions(issues, view.assignee)} showHideDone>
<Segmented label="Спринты" value={String(sprint.id)} {selectable.length > 0 && <SprintPicker sprints={selectable} selected={ids} onChange={pick} />}
options={active.map((s) => ({ value: String(s.id), label: s.name, href: `/jira/sprint?project=${proj}&sprint=${s.id}` }))} /> </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>
);
}