diff --git a/web/src/modules/jira/agile/BacklogPage.test.tsx b/web/src/modules/jira/agile/BacklogPage.test.tsx index 8999968..e3afc77 100644 --- a/web/src/modules/jira/agile/BacklogPage.test.tsx +++ b/web/src/modules/jira/agile/BacklogPage.test.tsx @@ -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 = {}) => iss(key, { issue_type: "Фича", summary: `Эпик ${key}`, ...over }); + const inEpic = (key: string, epic: string | null) => iss(key, { epic_key: epic }); + const epicData = (over: Partial = {}) => 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); + }); + }); }); diff --git a/web/src/modules/jira/agile/BacklogPage.tsx b/web/src/modules/jira/agile/BacklogPage.tsx index ff8a00a..174b8fb 100644 --- a/web/src/modules/jira/agile/BacklogPage.tsx +++ b/web/src/modules/jira/agile/BacklogPage.tsx @@ -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

{total > 0 ? "Нет задач по фильтрам" : "Нет задач"}

; return ; @@ -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 (
@@ -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 ( <> - + + + + +
+ {view.epicsPanel && } +
{live.map((s) => ( toggle(String(s.id))} - shown={s.issues.filter((i) => !isEpic(i) && matches(i, filter))} /> + shown={visible(s.issues, filter)} /> ))}
@@ -109,6 +120,8 @@ export function BacklogPage({ project }: { project: Project }) { {past.map((s) => )}
)} + + ); }} diff --git a/web/src/modules/jira/agile/EpicsPanel.tsx b/web/src/modules/jira/agile/EpicsPanel.tsx new file mode 100644 index 0000000..1be1fb4 --- /dev/null +++ b/web/src/modules/jira/agile/EpicsPanel.tsx @@ -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): 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) => ( +
  • + +
  • + ); + return ( + + ); +} + +export function EpicSelect(props: Props) { + return ( + + ); +} diff --git a/web/src/modules/jira/agile/agile.css b/web/src/modules/jira/agile/agile.css index ca2a8df..07d4494 100644 --- a/web/src/modules/jira/agile/agile.css +++ b/web/src/modules/jira/agile/agile.css @@ -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} +}