feat(web): «Бэклог» — фильтры, свёртка секций, эпики не строками (#112)
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
parent
e79b073180
commit
eae6ec317f
@ -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);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
@ -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>
|
||||
)}
|
||||
</>
|
||||
|
||||
@ -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}
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user