feat(web): «Бэклог» — фильтры, свёртка секций, эпики не строками (#112)

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
Andrey Epifancev 2026-10-25 20:30:51 +04:00
parent e79b073180
commit eae6ec317f
3 changed files with 154 additions and 25 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";
@ -123,16 +123,6 @@ describe("«Бэклог»", () => {
expect(within(det).getAllByText("не выполнено")).toHaveLength(12);
});
it("только смотрит: нет draggable, кнопки — только summary и «Обновить из Jira»", async () => {
hub();
open("/app/jira/backlog?project=AWPCA");
const p = await loaded();
expect(document.querySelectorAll('[draggable="true"]').length).toBe(0);
const buttons = within(p).queryAllByRole("button");
expect(buttons.length).toBeGreaterThan(0);
expect(buttons.every((b) => b.tagName === "SUMMARY" || /Обновить из Jira/.test(b.textContent ?? ""))).toBe(true);
});
it("щелчок по строке открывает карточку поверх «Бэклога»", async () => {
hub();
const router = open("/app/jira/backlog?project=AWPCA");
@ -187,4 +177,98 @@ describe("«Бэклог»", () => {
expect(await screen.findByText("У проекта нет scrum-доски")).toBeInTheDocument();
expect(router.state.location.pathname).toBe("/app/jira/backlog");
});
it("свёртка: заголовок — кнопка aria-expanded, итоги видны, состояние помнится; сворачиваются и будущий, и «Бэклог»", async () => {
hub();
open("/app/jira/backlog?project=AWPCA");
let p = within(await loaded());
const toggle = (name: string) => p.getByRole("button", { name: new RegExp(`^${name}`) });
expect(toggle("AWPCA_Sprint_3")).toHaveAttribute("aria-expanded", "true");
await userEvent.click(toggle("AWPCA_Sprint_3"));
expect(toggle("AWPCA_Sprint_3")).toHaveAttribute("aria-expanded", "false");
const active = p.getByRole("region", { name: /AWPCA_Sprint_3/ });
expect(active).not.toHaveTextContent("Тема AURORA-1");
expect(active).toHaveTextContent("12 задач");
expect(active).toHaveTextContent("25 SP (выполнено 8)");
await userEvent.click(toggle("AWPCA_Sprint_4"));
await userEvent.click(toggle("Бэклог"));
expect(p.getByRole("region", { name: /Бэклог/ })).not.toHaveTextContent("AURORA-50");
expect(p.getByRole("region", { name: /Бэклог/ })).toHaveTextContent("2 задачи");
cleanup();
open("/app/jira/backlog?project=AWPCA");
p = within(await loaded());
expect(toggle("AWPCA_Sprint_3")).toHaveAttribute("aria-expanded", "false");
expect(toggle("Бэклог")).toHaveAttribute("aria-expanded", "false");
await userEvent.click(toggle("AWPCA_Sprint_3"));
expect(toggle("AWPCA_Sprint_3")).toHaveAttribute("aria-expanded", "true");
expect(p.getByRole("region", { name: /AWPCA_Sprint_3/ })).toHaveTextContent("Тема AURORA-1");
});
it("эпики строками не показаны и не входят в «N задач» бэклога", async () => {
hub({ data: agile({
sprints: [sprint({ id: 102, issues: [iss("AURORA-1"), iss("AURORA-900", { issue_type: "Фича" })] })],
backlog: [iss("AURORA-50"), iss("AURORA-901", { issue_type: "Фича" })],
}) });
open("/app/jira/backlog?project=AWPCA");
const p = within(await loaded());
expect(p.queryByText("Тема AURORA-900")).toBeNull();
expect(p.queryByText("Тема AURORA-901")).toBeNull();
expect(p.getByText("Тема AURORA-50")).toBeInTheDocument();
expect(p.getByRole("region", { name: /Бэклог/ })).toHaveTextContent("1 задача");
});
it("«Скрыть выполненные»: 6 из 10, «показано 6»; прошлый спринт раскрыт целиком", async () => {
hub({ data: agile({
sprints: [
sprint({ id: 102, count: 10, count_done: 4, issues: many(10, 4) }),
sprint({ id: 101, name: "Old", state: "closed", complete_date: COMPLETE, count: 10, count_done: 4, issues: many(10, 4).map((i) => ({ ...i, key: `OLD-${i.key}` })) }),
],
}) });
open("/app/jira/backlog?project=AWPCA");
const p = within(await loaded());
await userEvent.click(p.getByLabelText("Скрыть выполненные"));
const sec = p.getByRole("region", { name: /AWPCA_Sprint_3|S/ });
expect(sec.querySelectorAll(".agile-row")).toHaveLength(6);
expect(sec).toHaveTextContent("10 задач");
expect(sec).toHaveTextContent("показано 6");
await userEvent.click(p.getByText(/^Old · закрыт/));
expect(document.querySelectorAll("details .agile-row")).toHaveLength(10);
});
it("поиск «AURORA-50»: в «Бэклоге» одна строка, в спринте «Нет задач по фильтрам»", async () => {
hub();
open("/app/jira/backlog?project=AWPCA");
const p = within(await loaded());
await userEvent.type(p.getByLabelText("Поиск по ключу или теме"), "AURORA-50");
expect(p.getByRole("region", { name: /Бэклог/ }).querySelectorAll(".agile-row")).toHaveLength(1);
expect(p.getByRole("region", { name: /AWPCA_Sprint_3/ })).toHaveTextContent("Нет задач по фильтрам");
});
it("смена проекта: исполнитель другого проекта не протекает", async () => {
const B = { ...A, label: "SECOND", name: "Второй", board_id: 5 };
mockFetch((method, url) => {
const k = `${method} ${url.pathname}`;
if (k === "GET /agile") return json(200, agile());
if (k === "GET /projects") return json(200, { projects: [A, B] });
if (k === "GET /settings") return json(200, settings);
return json(200, { issues: [], batches: [], agents: [] });
});
open("/app/jira/backlog?project=AWPCA");
await loaded();
await userEvent.selectOptions(screen.getByLabelText("Исполнитель"), "Иванов Иван");
expect(screen.getByLabelText("Исполнитель")).toHaveValue("Иванов Иван");
await userEvent.selectOptions(screen.getByLabelText("Проект"), "SECOND");
await waitFor(() => expect(screen.getByLabelText("Исполнитель")).toHaveValue(""));
});
it("бэклог только смотрит: нет draggable, кнопки — свёртка, панель фильтров, раскрытие", async () => {
hub();
open("/app/jira/backlog?project=AWPCA");
const p = await loaded();
expect(document.querySelectorAll("[draggable]").length).toBe(0);
const names = within(p).getAllByRole("button").map((b) => b.tagName === "SUMMARY" ? "summary" : (b.textContent ?? "").trim());
for (const n of names) {
expect(n === "summary" || /^(AWPCA_Sprint_3|AWPCA_Sprint_4|Бэклог|Обновить из Jira|Сбросить фильтры)/.test(n)).toBe(true);
}
});
});

View File

@ -1,41 +1,71 @@
import { useId } from "react";
import type { Project } from "../../../api/types.ts";
import { Badge, PageHeader, Panel } from "../../../ui/index.ts";
import { IconChevronDown, IconChevronUp } from "../../../ui/icons.tsx";
import type { AgileIssue, AgileSprint } from "../api/types.ts";
import { RefreshButton } from "../RefreshButton.tsx";
import { AgileGate } from "./AgileGate.tsx";
import { AgileToolbar } from "./AgileToolbar.tsx";
import { assigneeOptions, matches, type IssueFilter } from "./filters.ts";
import { useAgileView } from "./useAgileView.ts";
import { AgileIssueRow } from "./IssueRow.tsx";
import { fmtDay, fmtRange, fmtSp, tasksWord } from "./model.ts";
import { fmtDay, fmtRange, fmtSp, isEpic, tasksWord } from "./model.ts";
import "./agile.css";
function Issues({ issues, markUndone }: { issues: AgileIssue[]; markUndone?: boolean }) {
if (!issues.length) return <p className="agile__empty">Нет задач</p>;
function Issues({ issues, total, markUndone }: { issues: AgileIssue[]; total: number; markUndone?: boolean }) {
if (!issues.length) return <p className="agile__empty">{total > 0 ? "Нет задач по фильтрам" : "Нет задач"}</p>;
return <ul className="agile-list">{issues.map((i) => <AgileIssueRow key={i.key} issue={i} mark={markUndone && !i.done ? "не выполнено" : undefined} />)}</ul>;
}
function SprintSection({ s }: { s: AgileSprint }) {
// заголовок секции — кнопка свёртки; id — String(sprint.id) или "backlog"
function Toggle({ titleId, bodyId, collapsed, onToggle, children }: {
titleId: string; bodyId: string; collapsed: boolean; onToggle(): void; children: string;
}) {
return (
<h2 id={titleId} className="agile-section__title">
<button type="button" className="agile-section__toggle" aria-expanded={!collapsed} aria-controls={bodyId} onClick={onToggle}>
{children}
{collapsed ? <IconChevronUp /> : <IconChevronDown />}
</button>
</h2>
);
}
interface SectionProps { collapsed: boolean; onToggle(): void; shown: AgileIssue[] }
function Shown({ shown, total }: { shown: AgileIssue[]; total: number }) {
return shown.length < total ? <span className="agile-section__meta">{`показано ${shown.length}`}</span> : null;
}
function SprintSection({ s, collapsed, onToggle, shown }: { s: AgileSprint } & SectionProps) {
const id = useId();
const bodyId = `${id}-body`;
const total = s.issues.filter((i) => !isEpic(i)).length;
return (
<Panel as="section" className="agile-section" aria-labelledby={id}>
<header className="agile-section__head">
<h2 id={id} className="agile-section__title">{s.name}</h2>
<Toggle titleId={id} bodyId={bodyId} collapsed={collapsed} onToggle={onToggle}>{s.name}</Toggle>
<Badge>{s.state === "active" ? "активный" : "будущий"}</Badge>
<span className="agile-section__meta">{fmtRange(s.start_date, s.end_date)}</span>
{s.goal && <span className="agile-section__goal">{s.goal}</span>}
<span className="agile-section__meta">{`${s.count} ${tasksWord(s.count)}`}</span>
<Shown shown={shown} total={total} />
<span className="agile-section__meta">{`${fmtSp(s.points)} SP (выполнено ${fmtSp(s.points_done)})`}</span>
{s.unestimated > 0 && <span className="agile-section__meta">{`без оценки: ${s.unestimated}`}</span>}
</header>
<Issues issues={s.issues} />
<div id={bodyId} hidden={collapsed}>{!collapsed && <Issues issues={shown} total={total} />}</div>
</Panel>
);
}
function PastSprint({ s }: { s: AgileSprint }) {
function PastSprint({ s, filter }: { s: AgileSprint; filter: IssueFilter }) {
const all = s.issues.filter((i) => !isEpic(i));
const shown = all.filter((i) => matches(i, { ...filter, hideDone: false }));
const more = shown.length < all.length ? ` · показано ${shown.length}` : "";
return (
<details className="agile-past__sprint">
<summary>{`${s.name} · закрыт ${fmtDay(s.complete_date)} · выполнено ${s.count_done} из ${s.count} · ${fmtSp(s.points)} SP`}</summary>
<Issues issues={s.issues} markUndone />
<summary>{`${s.name} · закрыт ${fmtDay(s.complete_date)} · выполнено ${s.count_done} из ${s.count} · ${fmtSp(s.points)} SP${more}`}</summary>
<Issues issues={shown} total={all.length} markUndone />
</details>
);
}
@ -43,6 +73,9 @@ function PastSprint({ s }: { s: AgileSprint }) {
// «Бэклог» (spec web-app): активные и будущие спринты, бэклог, прошлые спринты — только просмотр (R-20)
export function BacklogPage({ project }: { project: Project }) {
const backlogId = useId();
const { view, set, setQ, filter, reset } = useAgileView(project.label, "backlog");
const isCollapsed = (id: string) => view.collapsed.includes(id);
const toggle = (id: string) => set({ collapsed: isCollapsed(id) ? view.collapsed.filter((x) => x !== id) : [...view.collapsed, id] });
return (
<div data-testid="page-backlog" className="agile">
<PageHeader title="Бэклог" actions={<RefreshButton />} />
@ -50,20 +83,30 @@ export function BacklogPage({ project }: { project: Project }) {
{(d) => {
const live = [...d.sprints.filter((s) => s.state === "active"), ...d.sprints.filter((s) => s.state === "future")];
const past = d.sprints.filter((s) => s.state === "closed");
const backlog = (d.backlog ?? []).filter((i) => !isEpic(i));
const shownBacklog = backlog.filter((i) => matches(i, filter));
const allIssues = [...d.sprints.flatMap((s) => s.issues), ...(d.backlog ?? [])].filter((i) => !isEpic(i));
return (
<>
{live.map((s) => <SprintSection key={s.id} s={s} />)}
<AgileToolbar filter={filter} setQ={setQ} set={set} onReset={reset} assignees={assigneeOptions(allIssues, view.assignee)} showHideDone />
{live.map((s) => (
<SprintSection key={s.id} s={s} collapsed={isCollapsed(String(s.id))} onToggle={() => toggle(String(s.id))}
shown={s.issues.filter((i) => !isEpic(i) && matches(i, filter))} />
))}
<Panel as="section" className="agile-section" aria-labelledby={backlogId}>
<header className="agile-section__head">
<h2 id={backlogId} className="agile-section__title">Бэклог</h2>
<span className="agile-section__meta">{`${d.backlog?.length ?? 0} ${tasksWord(d.backlog?.length ?? 0)}`}</span>
<Toggle titleId={backlogId} bodyId={`${backlogId}-body`} collapsed={isCollapsed("backlog")} onToggle={() => toggle("backlog")}>Бэклог</Toggle>
<span className="agile-section__meta">{`${backlog.length} ${tasksWord(backlog.length)}`}</span>
<Shown shown={shownBacklog} total={backlog.length} />
</header>
<Issues issues={d.backlog ?? []} />
<div id={`${backlogId}-body`} hidden={isCollapsed("backlog")}>
{!isCollapsed("backlog") && <Issues issues={shownBacklog} total={backlog.length} />}
</div>
</Panel>
{past.length > 0 && (
<details className="agile-past">
<summary>{`Прошлые спринты (${past.length})`}</summary>
{past.map((s) => <PastSprint key={s.id} s={s} />)}
{past.map((s) => <PastSprint key={s.id} s={s} filter={filter} />)}
</details>
)}
</>

View File

@ -60,3 +60,5 @@
/* панель фильтров */
.agile-toolbar{display:flex;flex-wrap:wrap;align-items:center;gap:var(--space-3)}
.agile-toolbar__search{flex:1 1 220px;min-width:0}
.agile-section__toggle{display:inline-flex;align-items:center;gap:var(--space-2);margin:0;padding:0;border:0;background:none;color:inherit;font:inherit;cursor:pointer}
.agile-section__toggle:focus-visible{outline:2px solid var(--color-focus-ring);outline-offset:2px}