Веб: доработки «Бэклога» и «Спринта» по образцу Jira (#112) #114

Merged
aep merged 23 commits from feat/112-agile-web-polish into main 2026-10-25 20:54:18 +03:00
4 changed files with 217 additions and 12 deletions
Showing only changes of commit f4c3bd50a5 - Show all commits

View File

@ -211,23 +211,23 @@ describe("«Бэклог»", () => {
}) });
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();
const rowTexts = [...document.querySelectorAll(".agile-row")].map((r) => r.textContent);
expect(rowTexts.some((t) => t?.includes("AURORA-900") || t?.includes("AURORA-901"))).toBe(false); // в панели «Эпики» они есть, строками — нет
expect(rowTexts.some((t) => t?.includes("AURORA-50"))).toBe(true);
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: 102, name: "AWPCA_Sprint_3", 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/ });
const sec = p.getByRole("region", { name: /AWPCA_Sprint_3/ });
expect(sec.querySelectorAll(".agile-row")).toHaveLength(6);
expect(sec).toHaveTextContent("10 задач");
expect(sec).toHaveTextContent("показано 6");
@ -267,8 +267,131 @@ describe("«Бэклог»", () => {
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());
expect(names.length).toBeGreaterThan(0);
for (const n of names) {
expect(n === "summary" || /^(AWPCA_Sprint_3|AWPCA_Sprint_4|Бэклог|Обновить из Jira|Сбросить фильтры)/.test(n)).toBe(true);
expect(n === "summary" || /^(AWPCA_Sprint_3|AWPCA_Sprint_4|Бэклог|Обновить из Jira|Сбросить фильтры|Эпики|Все задачи|Без эпика)/.test(n)).toBe(true);
}
});
describe("панель «Эпики»", () => {
const epicRow = (key: string, over: Partial<AgileIssue> = {}) => iss(key, { issue_type: "Фича", summary: `Эпик ${key}`, ...over });
const inEpic = (key: string, epic: string | null) => iss(key, { epic_key: epic });
const epicData = (over: Partial<AgileResponse> = {}) => agile({
sprints: [sprint({ id: 102, name: "AWPCA_Sprint_3", count: 3, issues: [epicRow("AURORA-10"), inEpic("AURORA-1", "AURORA-10"), inEpic("AURORA-2", "AURORA-10"), inEpic("AURORA-3", null)] }),
sprint({ id: 101, name: "Old", state: "closed", complete_date: COMPLETE, count: 2, count_done: 1, issues: [inEpic("OLD-1", "AURORA-10"), inEpic("OLD-2", null)] })],
backlog: [inEpic("AURORA-50", "AURORA-10"), inEpic("AURORA-51", "AURORA-10"), inEpic("AURORA-52", "AURORA-10"),
...[60, 61, 62, 63, 64].map((n) => inEpic(`AURORA-${n}`, null))],
...over,
});
const panel = () => within(screen.getByRole("complementary", { name: "Эпики" }));
const item = (name: RegExp | string) => panel().getByRole("button", { name });
const rows = (region: HTMLElement) => region.querySelectorAll(".agile-row");
it("«Фильтр по эпику»: 2 в спринте и «показано 2», 3 в «Бэклоге», строки эпика нет, у пункта 5", async () => {
hub({ data: epicData() });
open("/app/jira/backlog?project=AWPCA");
const p = within(await loaded());
expect(item(/AURORA-10/)).toHaveTextContent("5");
await userEvent.click(item(/AURORA-10/));
const sprintSec = p.getByRole("region", { name: /AWPCA_Sprint_3/ });
expect(rows(sprintSec)).toHaveLength(2);
expect(sprintSec).toHaveTextContent("показано 2");
expect(rows(p.getByRole("region", { name: /Бэклог/ }))).toHaveLength(3);
expect(p.queryByText("Эпик AURORA-10", { selector: ".agile-row *" })).toBeNull();
expect(item(/AURORA-10/)).toHaveTextContent("5");
expect(item(/AURORA-10/)).toHaveAttribute("aria-pressed", "true");
});
it("повторный щелчок по выбранному пункту — «Все задачи»", async () => {
hub({ data: epicData() });
open("/app/jira/backlog?project=AWPCA");
await loaded();
expect(item("Все задачи")).toHaveAttribute("aria-pressed", "true");
await userEvent.click(item(/AURORA-10/));
expect(item("Все задачи")).toHaveAttribute("aria-pressed", "false");
await userEvent.click(item(/AURORA-10/));
expect(item("Все задачи")).toHaveAttribute("aria-pressed", "true");
expect(item(/AURORA-10/)).toHaveAttribute("aria-pressed", "false");
});
it("«Без эпика» — только задачи без epic_key", async () => {
hub({ data: epicData() });
open("/app/jira/backlog?project=AWPCA");
const p = within(await loaded());
await userEvent.click(item("Без эпика"));
expect(rows(p.getByRole("region", { name: /Бэклог/ }))).toHaveLength(5);
expect(rows(p.getByRole("region", { name: /AWPCA_Sprint_3/ }))).toHaveLength(1);
});
it("эпик без строки — пункт с ключом, фильтр по нему работает", async () => {
hub({ data: agile({ sprints: [], backlog: [inEpic("AURORA-50", "AURORA-77"), inEpic("AURORA-51", null)] }) });
open("/app/jira/backlog?project=AWPCA");
const p = within(await loaded());
await userEvent.click(item(/AURORA-77/));
expect(rows(p.getByRole("region", { name: /Бэклог/ }))).toHaveLength(1);
expect(p.getByText("Тема AURORA-50")).toBeInTheDocument();
});
it("«Скрыть выполненные»: выполненный эпик пропадает из панели, выбранный остаётся", async () => {
hub({ data: agile({ sprints: [], backlog: [epicRow("AURORA-10", { done: true }), epicRow("AURORA-11"), inEpic("AURORA-50", "AURORA-10")] }) });
open("/app/jira/backlog?project=AWPCA");
const p = within(await loaded());
await userEvent.click(p.getByLabelText("Скрыть выполненные"));
expect(panel().queryByRole("button", { name: /AURORA-10/ })).toBeNull();
expect(item(/AURORA-11/)).toBeInTheDocument();
await userEvent.click(p.getByLabelText("Скрыть выполненные"));
await userEvent.click(item(/AURORA-10/));
await userEvent.click(p.getByLabelText("Скрыть выполненные"));
expect(item(/AURORA-10/)).toHaveAttribute("aria-pressed", "true");
});
it("сохранённый эпик, которого нет в данных, остаётся выбранным пунктом; секции — «Нет задач по фильтрам»", async () => {
localStorage.setItem("sdlc-hub-agile", JSON.stringify({ AWPCA: { backlog: { epic: "AURORA-999" } } }));
hub({ data: epicData() });
open("/app/jira/backlog?project=AWPCA");
const p = within(await loaded());
expect(item(/AURORA-999/)).toHaveAttribute("aria-pressed", "true");
expect(p.getByRole("region", { name: /Бэклог/ })).toHaveTextContent("Нет задач по фильтрам");
expect(p.getByRole("region", { name: /AWPCA_Sprint_3/ })).toHaveTextContent("Нет задач по фильтрам");
});
it("кнопка «Эпики» скрывает и показывает панель, состояние переживает повторный монтаж", async () => {
hub({ data: epicData() });
open("/app/jira/backlog?project=AWPCA");
const p = within(await loaded());
const btn = () => p.getByRole("button", { name: "Эпики" });
expect(btn()).toHaveAttribute("aria-pressed", "true");
await userEvent.click(btn());
expect(btn()).toHaveAttribute("aria-pressed", "false");
expect(screen.queryByRole("complementary", { name: "Эпики" })).toBeNull();
cleanup();
open("/app/jira/backlog?project=AWPCA");
const p2 = within(await loaded());
expect(p2.getByRole("button", { name: "Эпики" })).toHaveAttribute("aria-pressed", "false");
expect(screen.queryByRole("complementary", { name: "Эпики" })).toBeNull();
await userEvent.click(p2.getByRole("button", { name: "Эпики" }));
expect(screen.getByRole("complementary", { name: "Эпики" })).toBeInTheDocument();
});
it("выбор «Эпик» в панели фильтров (узкое окно) ставит тот же фильтр", async () => {
hub({ data: epicData() });
open("/app/jira/backlog?project=AWPCA");
const p = within(await loaded());
await userEvent.selectOptions(p.getByLabelText("Эпик"), "AURORA-10");
expect(item(/AURORA-10/)).toHaveAttribute("aria-pressed", "true");
expect(rows(p.getByRole("region", { name: /Бэклог/ }))).toHaveLength(3);
expect([...p.getByLabelText("Эпик").querySelectorAll("option")].map((o) => o.textContent)).toEqual(["Все задачи", "Эпик AURORA-10", "Без эпика"]);
});
it("фильтр эпика применяется и к прошлому спринту", async () => {
hub({ data: epicData() });
open("/app/jira/backlog?project=AWPCA");
const p = within(await loaded());
await userEvent.click(item(/AURORA-10/));
const summary = p.getByText(/^Old · закрыт/);
expect(summary).toHaveTextContent("показано 1");
await userEvent.click(summary);
expect(summary.closest("details")!.querySelectorAll(".agile-row")).toHaveLength(1);
});
});
});

View File

@ -1,17 +1,21 @@
import { useId } from "react";
import type { Project } from "../../../api/types.ts";
import { Badge, PageHeader, Panel } from "../../../ui/index.ts";
import { Badge, Button, 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 { EpicSelect, EpicsPanel } from "./EpicsPanel.tsx";
import { assigneeOptions, epicEntries, matches, type IssueFilter } from "./filters.ts";
import { useAgileView } from "./useAgileView.ts";
import { AgileIssueRow } from "./IssueRow.tsx";
import { fmtDay, fmtRange, fmtSp, isEpic, tasksWord } from "./model.ts";
import "./agile.css";
// задачи страницы: без строк-эпиков, подходящие под фильтр
const visible = (issues: AgileIssue[], filter: IssueFilter) => issues.filter((i) => !isEpic(i) && matches(i, filter));
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>;
@ -60,7 +64,7 @@ function SprintSection({ s, collapsed, onToggle, shown }: { s: AgileSprint } & S
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 shown = visible(all, { ...filter, hideDone: false });
const more = shown.length < all.length ? ` · показано ${shown.length}` : "";
return (
<details className="agile-past__sprint">
@ -84,14 +88,21 @@ export function BacklogPage({ project }: { project: Project }) {
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 shownBacklog = visible(backlog, filter);
const epicProps = { entries: epicEntries(d), value: view.epic, onChange: (epic: string) => set({ epic }), hideDone: view.hideDone };
const allIssues = [...d.sprints.flatMap((s) => s.issues), ...(d.backlog ?? [])].filter((i) => !isEpic(i));
return (
<>
<AgileToolbar filter={filter} setQ={setQ} set={set} onReset={reset} assignees={assigneeOptions(allIssues, view.assignee)} showHideDone />
<AgileToolbar filter={filter} setQ={setQ} set={set} onReset={reset} assignees={assigneeOptions(allIssues, view.assignee)} showHideDone>
<EpicSelect {...epicProps} />
<Button className="agile-epics-toggle" aria-pressed={view.epicsPanel} onClick={() => set({ epicsPanel: !view.epicsPanel })}>Эпики</Button>
</AgileToolbar>
<div className={`agile-backlog${view.epicsPanel ? " has-epics" : ""}`}>
{view.epicsPanel && <EpicsPanel {...epicProps} />}
<div className="agile-backlog__main">
{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))} />
shown={visible(s.issues, filter)} />
))}
<Panel as="section" className="agile-section" aria-labelledby={backlogId}>
<header className="agile-section__head">
@ -109,6 +120,8 @@ export function BacklogPage({ project }: { project: Project }) {
{past.map((s) => <PastSprint key={s.id} s={s} filter={filter} />)}
</details>
)}
</div>
</div>
</>
);
}}

View File

@ -0,0 +1,48 @@
// панель «Эпики» бэклога и её узкий вариант — выбор «Эпик» (spec web-app); оба только меняют фильтр, Jira не трогают
import { Select } from "../../../ui/index.ts";
import { NONE, type EpicEntry } from "./filters.ts";
interface Props { entries: EpicEntry[]; value: string; onChange(v: string): void; hideDone: boolean }
// выполненные скрываются при «Скрыть выполненные», кроме выбранного; выбранный, которого нет в данных, остаётся пунктом по ключу
function listed({ entries, value, hideDone }: Pick<Props, "entries" | "value" | "hideDone">): EpicEntry[] {
const list = entries.filter((e) => !(hideDone && e.done && e.key !== value));
if (value && value !== NONE && !entries.some((e) => e.key === value)) list.push({ key: value, title: value, count: 0, done: false });
return list;
}
export function EpicsPanel(props: Props) {
const { value, onChange } = props;
const item = (v: string, title: string, key?: string, count?: number) => (
<li key={v}>
<button type="button" className="agile-epics__item" aria-pressed={value === v} onClick={() => onChange(value === v ? "" : v)}>
<span className="agile-epics__title">{title}</span>
{key && key !== title && <span className="agile-epics__key">{key}</span>}
{count !== undefined && <span className="agile-epics__count">{count}</span>}
</button>
</li>
);
return (
<aside className="agile-epics" aria-label="Эпики">
<ul className="agile-epics__list">
<li>
<button type="button" className="agile-epics__item" aria-pressed={value === ""} onClick={() => onChange("")}>
<span className="agile-epics__title">Все задачи</span>
</button>
</li>
{listed(props).map((e) => item(e.key, e.title, e.key, e.count))}
{item(NONE, "Без эпика")}
</ul>
</aside>
);
}
export function EpicSelect(props: Props) {
return (
<Select aria-label="Эпик" className="agile-epic-select" value={props.value} onChange={(e) => props.onChange(e.target.value)}>
<option value="">Все задачи</option>
{listed(props).map((e) => <option key={e.key} value={e.key}>{e.title}</option>)}
<option value={NONE}>Без эпика</option>
</Select>
);
}

View File

@ -62,3 +62,24 @@
.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}
/* «Бэклог»: панель «Эпики» слева; на узком окне — выбор «Эпик» в панели фильтров */
.agile-backlog{display:grid;grid-template-columns:minmax(0,1fr);gap:var(--space-4);align-items:start}
.agile-backlog.has-epics{grid-template-columns:240px minmax(0,1fr)}
.agile-backlog__main{display:flex;flex-direction:column;gap:var(--space-4);min-width:0}
.agile-epics{min-width:0;background:var(--color-bg-surface);border:1px solid var(--color-border-default);border-radius:var(--radius-lg);padding:var(--space-2)}
.agile-epics__list{list-style:none;margin:0;padding:0;display:flex;flex-direction:column;gap:var(--space-1)}
.agile-epics__item{display:flex;align-items:baseline;flex-wrap:wrap;gap:0 var(--space-2);width:100%;margin:0;padding:var(--space-2);text-align:left;
border:0;border-radius:var(--radius-md);background:none;color:inherit;font:inherit;font-size:var(--fs-body);cursor:pointer}
.agile-epics__item:hover{background:var(--color-bg-hover)}
.agile-epics__item[aria-pressed="true"]{background:var(--color-bg-subtle);font-weight:600}
.agile-epics__item:focus-visible{outline:2px solid var(--color-focus-ring);outline-offset:2px}
.agile-epics__title{flex:1 1 100%;min-width:0;overflow-wrap:anywhere}
.agile-epics__key{font-family:var(--font-mono);font-size:var(--fs-caption);color:var(--color-text-secondary)}
.agile-epics__count{margin-left:auto;font-size:var(--fs-caption);color:var(--color-text-secondary)}
.agile-epic-select{display:none}
@container page (max-width: 899px){
.agile-backlog.has-epics{grid-template-columns:minmax(0,1fr)}
.agile-epics{display:none}
.agile-epic-select{display:inline-block}
.agile-epics-toggle{display:none}
}