From eae6ec317f2990b4dcfb02d69a997a1d65cb1d7a Mon Sep 17 00:00:00 2001 From: Andrey Epifancev Date: Sun, 25 Oct 2026 20:30:51 +0400 Subject: [PATCH] =?UTF-8?q?feat(web):=20=C2=AB=D0=91=D1=8D=D0=BA=D0=BB?= =?UTF-8?q?=D0=BE=D0=B3=C2=BB=20=E2=80=94=20=D1=84=D0=B8=D0=BB=D1=8C=D1=82?= =?UTF-8?q?=D1=80=D1=8B,=20=D1=81=D0=B2=D1=91=D1=80=D1=82=D0=BA=D0=B0=20?= =?UTF-8?q?=D1=81=D0=B5=D0=BA=D1=86=D0=B8=D0=B9,=20=D1=8D=D0=BF=D0=B8?= =?UTF-8?q?=D0=BA=D0=B8=20=D0=BD=D0=B5=20=D1=81=D1=82=D1=80=D0=BE=D0=BA?= =?UTF-8?q?=D0=B0=D0=BC=D0=B8=20(#112)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Sonnet 5.5 --- .../modules/jira/agile/BacklogPage.test.tsx | 106 ++++++++++++++++-- web/src/modules/jira/agile/BacklogPage.tsx | 71 +++++++++--- web/src/modules/jira/agile/agile.css | 2 + 3 files changed, 154 insertions(+), 25 deletions(-) diff --git a/web/src/modules/jira/agile/BacklogPage.test.tsx b/web/src/modules/jira/agile/BacklogPage.test.tsx index d11c922..8999968 100644 --- a/web/src/modules/jira/agile/BacklogPage.test.tsx +++ b/web/src/modules/jira/agile/BacklogPage.test.tsx @@ -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); + } + }); }); diff --git a/web/src/modules/jira/agile/BacklogPage.tsx b/web/src/modules/jira/agile/BacklogPage.tsx index 2df073e..ff8a00a 100644 --- a/web/src/modules/jira/agile/BacklogPage.tsx +++ b/web/src/modules/jira/agile/BacklogPage.tsx @@ -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

Нет задач

; +function Issues({ issues, total, markUndone }: { issues: AgileIssue[]; total: number; markUndone?: boolean }) { + if (!issues.length) return

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

; return ; } -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 ( +

+ +

+ ); +} + +interface SectionProps { collapsed: boolean; onToggle(): void; shown: AgileIssue[] } + +function Shown({ shown, total }: { shown: AgileIssue[]; total: number }) { + return shown.length < total ? {`показано ${shown.length}`} : 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 (
-

{s.name}

+ {s.name} {s.state === "active" ? "активный" : "будущий"} {fmtRange(s.start_date, s.end_date)} {s.goal && {s.goal}} {`${s.count} ${tasksWord(s.count)}`} + {`${fmtSp(s.points)} SP (выполнено ${fmtSp(s.points_done)})`} {s.unestimated > 0 && {`без оценки: ${s.unestimated}`}}
- +
); } -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 (
- {`${s.name} · закрыт ${fmtDay(s.complete_date)} · выполнено ${s.count_done} из ${s.count} · ${fmtSp(s.points)} SP`} - + {`${s.name} · закрыт ${fmtDay(s.complete_date)} · выполнено ${s.count_done} из ${s.count} · ${fmtSp(s.points)} SP${more}`} +
); } @@ -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 (
} /> @@ -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) => )} + + {live.map((s) => ( + toggle(String(s.id))} + shown={s.issues.filter((i) => !isEpic(i) && matches(i, filter))} /> + ))}
-

Бэклог

- {`${d.backlog?.length ?? 0} ${tasksWord(d.backlog?.length ?? 0)}`} + toggle("backlog")}>Бэклог + {`${backlog.length} ${tasksWord(backlog.length)}`} +
- +
{past.length > 0 && (
{`Прошлые спринты (${past.length})`} - {past.map((s) => )} + {past.map((s) => )}
)} diff --git a/web/src/modules/jira/agile/agile.css b/web/src/modules/jira/agile/agile.css index 08af104..ca2a8df 100644 --- a/web/src/modules/jira/agile/agile.css +++ b/web/src/modules/jira/agile/agile.css @@ -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}