From 5c7a845a6e40952cdb9d112cb688ce572d7ecb91 Mon Sep 17 00:00:00 2001 From: Andrey Epifancev Date: Sun, 25 Oct 2026 19:59:22 +0400 Subject: [PATCH 01/23] =?UTF-8?q?openspec:=20change=20agile-web-polish=20?= =?UTF-8?q?=E2=80=94=20=D0=B4=D0=BE=D1=80=D0=B0=D0=B1=D0=BE=D1=82=D0=BA?= =?UTF-8?q?=D0=B8=20=C2=AB=D0=91=D1=8D=D0=BA=D0=BB=D0=BE=D0=B3=D0=B0=C2=BB?= =?UTF-8?q?=20=D0=B8=20=C2=AB=D0=A1=D0=BF=D1=80=D0=B8=D0=BD=D1=82=D0=B0?= =?UTF-8?q?=C2=BB=20(#112)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5.5 --- .../changes/agile-web-polish/.openspec.yaml | 2 + openspec/changes/agile-web-polish/design.md | 123 +++++++++++++++++ openspec/changes/agile-web-polish/proposal.md | 38 +++++ .../agile-web-polish/specs/web-app/spec.md | 130 ++++++++++++++++++ 4 files changed, 293 insertions(+) create mode 100644 openspec/changes/agile-web-polish/.openspec.yaml create mode 100644 openspec/changes/agile-web-polish/design.md create mode 100644 openspec/changes/agile-web-polish/proposal.md create mode 100644 openspec/changes/agile-web-polish/specs/web-app/spec.md diff --git a/openspec/changes/agile-web-polish/.openspec.yaml b/openspec/changes/agile-web-polish/.openspec.yaml new file mode 100644 index 0000000..7ccf7c3 --- /dev/null +++ b/openspec/changes/agile-web-polish/.openspec.yaml @@ -0,0 +1,2 @@ +schema: spec-driven +created: 2026-10-25 diff --git a/openspec/changes/agile-web-polish/design.md b/openspec/changes/agile-web-polish/design.md new file mode 100644 index 0000000..10ac145 --- /dev/null +++ b/openspec/changes/agile-web-polish/design.md @@ -0,0 +1,123 @@ +## Context + +После выкатки #108 (25.10.2026) владелец посмотрел «Бэклог», «Спринт» и «Kanban» на живой доске AWPCA (4216, три +спринта) и записал 10 замечаний в sdlc-hub#112. Владелец попросил не проводить brainstorming-вопросы и писать дизайн +по тексту issue, поэтому всё, что в issue не сказано прямо, — решения агента (раздел «Decisions made without +discussion»). + +Текущее состояние (`web/src/modules/jira/agile/`): `BacklogPage` рисует секции `Panel` без свёртки, эпики идут строками +наравне с задачами; `SprintPage` показывает один активный спринт, переключатель `Segmented` — только между активными; +`BoardColumns` — flex-ряд с `align-items:flex-start` и `overflow-x:auto`; `TypeIcon` знает три вида (эпик — +`IconBoard`, ошибка — `IconCritical` (молния), прочее — `IconCheck`). Фильтров нет. Все нужные данные уже приходят в +`GET /agile` (`AgileIssue`: `issue_type`, `assignee`, `epic_key`, `done`, `rank`, `status`) — сервер не меняется. + +## Goals / Non-Goals + +Цели — 10 пунктов #112 и попутно веб-маркеры из hub#111 (M-6, T10.3, T9.4, T12.2–T12.4). Не цели — правки Jira из +веба, изменения API/схемы/агента/MCP, настройки доски AWPCA в Jira, серверные пункты hub#111. + +## Decisions + +1. **Фильтры — на клиенте, чистой моделью.** Новый модуль `agile/filters.ts`: тип состояния страницы + (`q`, `assignee`, `epic`, `hideDone`, `collapsed`, `hiddenColumns`, `epicsPanel`), функция «видна ли задача» + (поиск по `key` и `summary` без учёта регистра; `assignee`: `""` — все, `"-"` — без исполнителя; `epic`: `""` — все, + `"-"` — без эпика, иначе `epic_key === epic`; `hideDone`), списки вариантов исполнителей и эпиков. Страницы и + компоненты только рисуют. Модель покрыта unit-тестами, страницы — тестами поведения. + +2. **Запоминание — один ключ `sdlc-hub-agile`.** JSON `{ "<метка>": { "backlog": {...}, "sprint": {...}, + "kanban": {...} } }`; хранится всё, кроме `q` и выбора спринтов. Чтение и запись — в `try/catch` (закрытое + хранилище, мусор, чужая форма — значения по умолчанию), как `sidebarPref`/`legendPref`. Миграция `jira-hub-` не нужна + — ключ новый. Свёрнутые секции — по id спринта и `"backlog"`; id закрытых/удалённых спринтов при чтении не мешают + (просто не совпадают). Скрытые колонки — по имени колонки (`"Без колонки"` — тоже имя). + +3. **Панель фильтров** — общий компонент `AgileToolbar` для трёх страниц, набор элементов — пропсами. Используются + готовые `SearchInput`, `Select`, `Checkbox`/`Button aria-pressed`, `Dropdown`/`Menu` из `web/src/ui`; новые элементы + UI-кита не вводятся, кроме значков (п. 7). Устаревшее значение исполнителя/эпика остаётся в списке (как + `withCurrent` у списка задач), чтобы пустой результат объяснялся видимым фильтром. + +4. **Свёртка секций «Бэклога».** Заголовок секции — `} + + ); +} diff --git a/web/src/modules/jira/agile/agile.css b/web/src/modules/jira/agile/agile.css index d997592..08af104 100644 --- a/web/src/modules/jira/agile/agile.css +++ b/web/src/modules/jira/agile/agile.css @@ -57,3 +57,6 @@ .agile-card__summary{overflow-wrap:anywhere} .agile-card__foot{display:flex;align-items:center;gap:var(--space-2);min-width:0} .agile-card__assignee{flex:1 1 auto;min-width:0;color:var(--color-text-secondary);overflow:hidden;text-overflow:ellipsis;white-space:nowrap} +/* панель фильтров */ +.agile-toolbar{display:flex;flex-wrap:wrap;align-items:center;gap:var(--space-3)} +.agile-toolbar__search{flex:1 1 220px;min-width:0} diff --git a/web/src/modules/jira/agile/useAgileView.ts b/web/src/modules/jira/agile/useAgileView.ts new file mode 100644 index 0000000..49b72eb --- /dev/null +++ b/web/src/modules/jira/agile/useAgileView.ts @@ -0,0 +1,43 @@ +// состояние фильтров страницы доски: вид (хранится по метке и странице) + поиск q (только в памяти) +import { useCallback, useMemo, useRef, useState } from "react"; +import { readView, saveView } from "./agilePref.ts"; +import type { AgilePage, AgileView, IssueFilter } from "./filters.ts"; + +export function useAgileView(label: string, page: AgilePage) { + const id = `${label}\u0000${page}`; + const [state, setState] = useState(() => ({ id, view: readView(label, page), q: "" })); + // метка или страница сменились — читаем вид заново, поиск сбрасывается + let cur = state; + if (state.id !== id) { + cur = { id, view: readView(label, page), q: "" }; + setState(cur); + } + const ref = useRef(cur); + ref.current = cur; + + const commit = useCallback((next: typeof state) => { + ref.current = next; + setState(next); + }, []); + + const set = useCallback((patch: Partial) => { + const next = { ...ref.current, view: { ...ref.current.view, ...patch } }; + saveView(label, page, next.view); // запись вне функции-обновителя setState (T8.1 #69) + commit(next); + }, [label, page, commit]); + + const setQ = useCallback((q: string) => commit({ ...ref.current, q }), [commit]); + + const reset = useCallback(() => { + const next = { ...ref.current, q: "", view: { ...ref.current.view, assignee: "", epic: "", hideDone: false } }; + saveView(label, page, next.view); + commit(next); + }, [label, page, commit]); + + const { view, q } = cur; + const filter: IssueFilter = useMemo( + () => ({ q, assignee: view.assignee, epic: view.epic, hideDone: view.hideDone }), + [q, view.assignee, view.epic, view.hideDone], + ); + return { view, set, q, setQ, filter, reset }; +} From e79b07318030cd925153298ea47ebf88545ee27d Mon Sep 17 00:00:00 2001 From: Andrey Epifancev Date: Sun, 25 Oct 2026 20:27:58 +0400 Subject: [PATCH 09/23] =?UTF-8?q?fix(web):=20=D1=82=D0=B5=D1=81=D1=82?= =?UTF-8?q?=D1=8B=20=D1=81=D0=BC=D0=B5=D0=BD=D1=8B=20=D0=BC=D0=B5=D1=82?= =?UTF-8?q?=D0=BA=D0=B8=20=D0=B8=20=D1=81=D0=B1=D1=80=D0=BE=D1=81=D0=B0,?= =?UTF-8?q?=20hideDone=20=D0=B1=D0=B5=D0=B7=20=D1=84=D0=BB=D0=B0=D0=B6?= =?UTF-8?q?=D0=BA=D0=B0=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/AgileToolbar.test.tsx | 34 +++++++++++++++++++ web/src/modules/jira/agile/AgileToolbar.tsx | 2 +- web/src/modules/jira/agile/useAgileView.ts | 1 + 3 files changed, 36 insertions(+), 1 deletion(-) diff --git a/web/src/modules/jira/agile/AgileToolbar.test.tsx b/web/src/modules/jira/agile/AgileToolbar.test.tsx index 101927d..a5882ef 100644 --- a/web/src/modules/jira/agile/AgileToolbar.test.tsx +++ b/web/src/modules/jira/agile/AgileToolbar.test.tsx @@ -79,4 +79,38 @@ describe("AgileToolbar", () => { render(); expect(assignee().value).toBe(""); }); + + it("смена метки и страницы без размонтирования: q сброшен, чужие значения не протекают, прежние данные целы", () => { + const r = render(); + fireEvent.change(assignee(), { target: { value: "ivanov" } }); + fireEvent.click(screen.getByLabelText("Скрыть выполненные")); + fireEvent.change(search(), { target: { value: "abc" } }); + const before = localStorage.getItem(AGILE_KEY); + r.rerender(); + expect(search().value).toBe(""); + expect(assignee().value).toBe(""); + expect((screen.getByLabelText("Скрыть выполненные") as HTMLInputElement).checked).toBe(false); + expect(localStorage.getItem(AGILE_KEY)).toBe(before); + r.rerender(); + expect(assignee().value).toBe(""); + expect(localStorage.getItem(AGILE_KEY)).toBe(before); + r.rerender(); + expect(assignee().value).toBe("ivanov"); + expect(search().value).toBe(""); + }); + + it("сброс сохраняет collapsed, hiddenColumns и epicsPanel", () => { + localStorage.setItem(AGILE_KEY, JSON.stringify({ AWPCA: { backlog: { + assignee: "ivanov", epic: "E-1", hideDone: true, collapsed: ["E-1"], hiddenColumns: ["Done"], epicsPanel: false } } })); + render(); + fireEvent.click(screen.getByText("Сбросить фильтры")); + expect(stored().AWPCA.backlog).toEqual({ + assignee: "", epic: "", hideDone: false, collapsed: ["E-1"], hiddenColumns: ["Done"], epicsPanel: false }); + }); + + it("showHideDone=false: сохранённый hideDone не показывает «Сбросить фильтры»", () => { + localStorage.setItem(AGILE_KEY, JSON.stringify({ AWPCA: { backlog: { hideDone: true } } })); + render(); + expect(screen.queryByText("Сбросить фильтры")).toBeNull(); + }); }); diff --git a/web/src/modules/jira/agile/AgileToolbar.tsx b/web/src/modules/jira/agile/AgileToolbar.tsx index 6cfbc2a..83880a9 100644 --- a/web/src/modules/jira/agile/AgileToolbar.tsx +++ b/web/src/modules/jira/agile/AgileToolbar.tsx @@ -19,7 +19,7 @@ export function AgileToolbar({ filter, setQ, set, onReset, assignees, showHideDo set({ hideDone: e.target.checked })} /> )} {children} - {hasFilters(filter) && } + {hasFilters({ ...filter, hideDone: filter.hideDone && showHideDone }) && } ); } diff --git a/web/src/modules/jira/agile/useAgileView.ts b/web/src/modules/jira/agile/useAgileView.ts index 49b72eb..36e8e78 100644 --- a/web/src/modules/jira/agile/useAgileView.ts +++ b/web/src/modules/jira/agile/useAgileView.ts @@ -13,6 +13,7 @@ export function useAgileView(label: string, page: AgilePage) { setState(cur); } const ref = useRef(cur); + // ref обновляется при рендере: set/reset в одном событии должны видеть актуальный вид, а не замыкание прошлого рендера ref.current = cur; const commit = useCallback((next: typeof state) => { From eae6ec317f2990b4dcfb02d69a997a1d65cb1d7a Mon Sep 17 00:00:00 2001 From: Andrey Epifancev Date: Sun, 25 Oct 2026 20:30:51 +0400 Subject: [PATCH 10/23] =?UTF-8?q?feat(web):=20=C2=AB=D0=91=D1=8D=D0=BA?= =?UTF-8?q?=D0=BB=D0=BE=D0=B3=C2=BB=20=E2=80=94=20=D1=84=D0=B8=D0=BB=D1=8C?= =?UTF-8?q?=D1=82=D1=80=D1=8B,=20=D1=81=D0=B2=D1=91=D1=80=D1=82=D0=BA?= =?UTF-8?q?=D0=B0=20=D1=81=D0=B5=D0=BA=D1=86=D0=B8=D0=B9,=20=D1=8D=D0=BF?= =?UTF-8?q?=D0=B8=D0=BA=D0=B8=20=D0=BD=D0=B5=20=D1=81=D1=82=D1=80=D0=BE?= =?UTF-8?q?=D0=BA=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
    {issues.map((i) => )}
; } -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} From b0f8e61a6c186919efc51f78b721f456b15138f3 Mon Sep 17 00:00:00 2001 From: Andrey Epifancev Date: Sun, 25 Oct 2026 20:32:39 +0400 Subject: [PATCH 11/23] =?UTF-8?q?openspec:=20=D1=80=D1=83=D0=BB=D0=B8?= =?UTF-8?q?=D0=BD=D0=B3=20R-16=20(#112)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Sonnet 5.5 --- openspec/changes/agile-web-polish/design.md | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/openspec/changes/agile-web-polish/design.md b/openspec/changes/agile-web-polish/design.md index c93329d..5662088 100644 --- a/openspec/changes/agile-web-polish/design.md +++ b/openspec/changes/agile-web-polish/design.md @@ -131,3 +131,9 @@ discussion»). без сида воспроизведение невозможно, порядок плана противоречив. Цена ошибки — сид приезжает на коммит раньше. - R-15 (controller, 25.10.2026, design #35): рулинги контроллера нумеруются с R-14 (R-13 занят дизайном; в плане T9 написано «R-13…»). Цена ошибки — нулевая. +- R-16 (controller, 25.10.2026, design #36, из ревью T4.2/T4.3/T4.4): (а) у прошлого спринта при активных фильтрах + в сводке добавляется « · показано K» (спека описывает «показано K» только для активных и будущих; строка + сценария «Прошлый спринт» без фильтров не меняется); (б) список исполнителей строится по всем задачам-не-эпикам + страницы, включая прошлые спринты (исполнитель фильтрует и их); (в) «N задач» секции спринта — серверное `count` + (включает эпики, R-4), а строки и «показано K» считаются по задачам-не-эпикам, поэтому спринт с эпиком может + показать «N задач» больше числа строк. Цена ошибки — правка двух выражений в BacklogPage.tsx. From f4c3bd50a5723f3bdc385f028065347e974b4263 Mon Sep 17 00:00:00 2001 From: Andrey Epifancev Date: Sun, 25 Oct 2026 20:35:35 +0400 Subject: [PATCH 12/23] =?UTF-8?q?feat(web):=20=D0=BF=D0=B0=D0=BD=D0=B5?= =?UTF-8?q?=D0=BB=D1=8C=20=C2=AB=D0=AD=D0=BF=D0=B8=D0=BA=D0=B8=C2=BB=20?= =?UTF-8?q?=D0=BD=D0=B0=20=C2=AB=D0=91=D1=8D=D0=BA=D0=BB=D0=BE=D0=B3=D0=B5?= =?UTF-8?q?=C2=BB=20(#112)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Панель эпиков слева (240px, на узком окне скрыта, вместо неё выбор «Эпик»), кнопка «Эпики» в панели фильтров, фильтр эпика в том числе для прошлых спринтов. Заодно T4.1 (регэксп секции), T4.6 (хелпер visible), T4.7 (проверка кнопок). Co-Authored-By: Claude Sonnet 5.5 --- .../modules/jira/agile/BacklogPage.test.tsx | 135 +++++++++++++++++- web/src/modules/jira/agile/BacklogPage.tsx | 25 +++- web/src/modules/jira/agile/EpicsPanel.tsx | 48 +++++++ web/src/modules/jira/agile/agile.css | 21 +++ 4 files changed, 217 insertions(+), 12 deletions(-) create mode 100644 web/src/modules/jira/agile/EpicsPanel.tsx 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
    {issues.map((i) => )}
; @@ -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} +} From dea051e7f96262586cffdcc839110120e4512fd3 Mon Sep 17 00:00:00 2001 From: Andrey Epifancev Date: Sun, 25 Oct 2026 20:41:21 +0400 Subject: [PATCH 13/23] =?UTF-8?q?feat(web):=20=C2=AB=D0=A1=D0=BF=D1=80?= =?UTF-8?q?=D0=B8=D0=BD=D1=82=C2=BB=20=E2=80=94=20=D0=B2=D1=8B=D0=B1=D0=BE?= =?UTF-8?q?=D1=80=20=D0=B0=D0=BA=D1=82=D0=B8=D0=B2=D0=BD=D1=8B=D1=85=20?= =?UTF-8?q?=D0=B8=20=D0=B1=D1=83=D0=B4=D1=83=D1=89=D0=B8=D1=85=20=D1=81?= =?UTF-8?q?=D0=BF=D1=80=D0=B8=D0=BD=D1=82=D0=BE=D0=B2,=20=D1=84=D0=B8?= =?UTF-8?q?=D0=BB=D1=8C=D1=82=D1=80=D1=8B=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/SprintPage.test.tsx | 138 +++++++++++++++--- web/src/modules/jira/agile/SprintPage.tsx | 53 ++++--- web/src/modules/jira/agile/SprintPicker.tsx | 31 ++++ 3 files changed, 178 insertions(+), 44 deletions(-) create mode 100644 web/src/modules/jira/agile/SprintPicker.tsx diff --git a/web/src/modules/jira/agile/SprintPage.test.tsx b/web/src/modules/jira/agile/SprintPage.test.tsx index 55cef31..cac26ee 100644 --- a/web/src/modules/jira/agile/SprintPage.test.tsx +++ b/web/src/modules/jira/agile/SprintPage.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"; @@ -96,42 +96,138 @@ describe("«Спринт»", () => { expect(await within(await page()).findByText("срок прошёл")).toBeInTheDocument(); }); - it("два активных: переключатель «Спринты», выбор — ?sprint= и его задачи", async () => { - hub(agile([s102(), sprint({ id: 104, name: "AWPCA_Sprint_5", issues: [iss("AURORA-77")], count: 1 })])); - const router = open("/app/jira/sprint?project=AWPCA"); - const p = within(await page()); - await p.findByText("Тема AURORA-1"); - const seg = p.getByRole("navigation", { name: "Спринты" }); - await userEvent.click(within(seg).getByRole("link", { name: "AWPCA_Sprint_5" })); - expect(await p.findByText("Тема AURORA-77")).toBeInTheDocument(); - expect(p.queryByText("Тема AURORA-1")).toBeNull(); - expect(router.state.location.search).toContain("sprint=104"); - expect(router.state.location.search).toContain("project=AWPCA"); + const s104 = () => sprint({ id: 104, name: "AWPCA_Sprint_5", issues: [iss("AURORA-77")], count: 1 }); + const s103 = () => sprint({ id: 103, name: "AWPCA_Sprint_4", state: "future", start_date: iso(5), end_date: iso(15), issues: [iss("AURORA-88")], count: 1 }); + const picker = (p: HTMLElement) => p.querySelector("button[aria-haspopup]")!; + const sprintGroup = () => screen.getByRole("group", { name: "Спринты" }); + + it("по умолчанию выбраны все активные: два активных — два заголовка и задачи обоих", async () => { + hub(agile([s102(), s104()])); + open("/app/jira/sprint?project=AWPCA"); + const p = await page(); + await within(p).findByText("Тема AURORA-1"); + expect(within(p).getByText("Тема AURORA-77")).toBeInTheDocument(); + expect(within(p).getAllByRole("heading", { level: 2 }).map((h) => h.textContent)).toEqual( + expect.arrayContaining(["AWPCA_Sprint_3", "AWPCA_Sprint_5"])); + expect(picker(p)).toHaveTextContent("Спринты: 2"); }); - it("один активный: переключателя нет", async () => { + it("один активный: на кнопке «Спринты» его имя", async () => { hub(agile()); open("/app/jira/sprint?project=AWPCA"); - const p = within(await page()); - await p.findByText("Тема AURORA-1"); - expect(p.queryByRole("navigation", { name: "Спринты" })).toBeNull(); + const p = await page(); + await within(p).findByText("Тема AURORA-1"); + expect(picker(p)).toHaveTextContent("AWPCA_Sprint_3"); }); - it("устаревший ?sprint= — первый активный", async () => { + it("«Будущий спринт на доске»: отметить будущий — ?sprint=101,102 через replace, «осталось» только у активного", async () => { + hub(agile([sprint({ id: 101, name: "Активный", issues: [iss("AURORA-5")], count: 1 }), sprint({ id: 102, name: "Будущий", state: "future", start_date: iso(5), end_date: iso(15), issues: [iss("AURORA-6")], count: 1 })])); + const router = open("/app/jira/sprint?project=AWPCA"); + const p = await page(); + await within(p).findByText("Тема AURORA-5"); + expect(within(p).queryByText("Тема AURORA-6")).toBeNull(); + await userEvent.click(picker(p)); + await userEvent.click(within(sprintGroup()).getByRole("checkbox", { name: /Будущий/ })); + expect(await within(p).findByText("Тема AURORA-6")).toBeInTheDocument(); + expect(router.state.location.search).toContain("sprint=101%2C102"); + expect(router.state.location.search).toContain("project=AWPCA"); + expect(router.state.historyAction).toBe("REPLACE"); + expect(within(p).getAllByRole("heading", { level: 2, name: /^(Активный|Будущий)$/ })).toHaveLength(2); + expect(within(p).getAllByText(/осталось \d+ дн\./)).toHaveLength(1); + }); + + it("в списке спринтов подписи «активный»/«будущий»; последнюю отметку снять нельзя", async () => { + hub(agile([s102(), s103()])); + open("/app/jira/sprint?project=AWPCA"); + const p = await page(); + await within(p).findByText("Тема AURORA-1"); + await userEvent.click(picker(p)); + const g = within(sprintGroup()); + expect(g.getByRole("checkbox", { name: /AWPCA_Sprint_3.*активный/ })).toBeChecked(); + expect(g.getByRole("checkbox", { name: /AWPCA_Sprint_4.*будущий/ })).not.toBeChecked(); + expect(g.getByRole("checkbox", { name: /AWPCA_Sprint_3/ })).toBeDisabled(); + await userEvent.click(g.getByRole("checkbox", { name: /AWPCA_Sprint_4/ })); + expect(g.getByRole("checkbox", { name: /AWPCA_Sprint_3/ })).toBeEnabled(); + }); + + it("задача в обоих выбранных спринтах — одна карточка", async () => { + hub(agile([s102(), sprint({ id: 104, name: "AWPCA_Sprint_5", issues: [iss("AURORA-1", { story_points: 5 })], count: 1 })])); + open("/app/jira/sprint?project=AWPCA"); + const p = await page(); + await within(p).findByText("Тема AURORA-1"); + expect(within(p).getAllByText("Тема AURORA-1")).toHaveLength(1); + }); + + it("?sprint=999 и ?sprint=abc — все активные", async () => { + hub(agile([s102(), s103()])); + open("/app/jira/sprint?project=AWPCA&sprint=999"); + expect(await within(await page()).findByText("Тема AURORA-1")).toBeInTheDocument(); + expect(screen.queryByText("Тема AURORA-88")).toBeNull(); + }); + + it("мусорный ?sprint=abc — все активные", async () => { + hub(agile()); + open("/app/jira/sprint?project=AWPCA&sprint=abc"); + expect(await within(await page()).findByText("Тема AURORA-1")).toBeInTheDocument(); + }); + + it("?sprint=103 (будущий) — только он", async () => { + hub(agile([s102(), s103()])); + open("/app/jira/sprint?project=AWPCA&sprint=103"); + const p = await page(); + expect(await within(p).findByText("Тема AURORA-88")).toBeInTheDocument(); + expect(within(p).queryByText("Тема AURORA-1")).toBeNull(); + }); + + it("устаревший ?sprint= (закрытый) — все активные", async () => { hub(agile([s102(), sprint({ id: 101, name: "Закрытый", state: "closed", issues: [iss("AURORA-99")] })])); open("/app/jira/sprint?project=AWPCA&sprint=101"); expect(await within(await page()).findByText("Тема AURORA-1")).toBeInTheDocument(); expect(screen.queryByText("Тема AURORA-99")).toBeNull(); }); - it("мусорный ?sprint=abc — первый активный", async () => { + it("нет активного, есть будущий — «Нет активного спринта», «Бэклог», выбор доступен; выбор будущего показывает доску", async () => { + hub(agile([s103()])); + open("/app/jira/sprint?project=AWPCA"); + const p = within(await page()); + expect(await p.findByText("Нет активного спринта")).toBeInTheDocument(); + expect(p.getByRole("link", { name: "Бэклог" })).toHaveAttribute("href", "/app/jira/backlog?project=AWPCA"); + await userEvent.click(picker(await page())); + await userEvent.click(within(sprintGroup()).getByRole("checkbox", { name: /AWPCA_Sprint_4/ })); + expect(await p.findByText("Тема AURORA-88")).toBeInTheDocument(); + expect(p.queryByText("Нет активного спринта")).toBeNull(); + }); + + it("«Исполнитель на доске спринта»: только его карточки, числа колонок по видимым; на «Бэклоге» «все»", async () => { + const sp = s102({ issues: [iss("AURORA-1", { assignee: "ivanov" }), iss("AURORA-2", { assignee: "petrov" }), iss("AURORA-3", { assignee: "ivanov", status: "Готово", done: true })] }); + hub(agile([sp])); + open("/app/jira/sprint?project=AWPCA"); + const p = await page(); + await within(p).findByText("Тема AURORA-2"); + await userEvent.selectOptions(within(p).getByLabelText("Исполнитель"), "ivanov"); + await waitFor(() => expect(within(p).queryByText("Тема AURORA-2")).toBeNull()); + expect(within(p).getByText("Тема AURORA-1")).toBeInTheDocument(); + const cols = Array.from(p.querySelectorAll(".agile-col")); + expect(cols.map((c) => c.querySelector(".agile-col__count")!.textContent)).toEqual(["1", "1"]); + cleanup(); + hub(agile([sp])); + open("/app/jira/backlog?project=AWPCA"); + const b = await screen.findByTestId("page-backlog"); + expect(await within(b).findByLabelText("Исполнитель")).toHaveValue(""); + }); + + it("«Скрыть выполненные» убирает выполненные карточки", async () => { hub(agile()); - open("/app/jira/sprint?project=AWPCA&sprint=abc"); - expect(await within(await page()).findByText("Тема AURORA-1")).toBeInTheDocument(); + open("/app/jira/sprint?project=AWPCA"); + const p = await page(); + await within(p).findByText("Тема AURORA-2"); + await userEvent.click(within(p).getByRole("checkbox", { name: "Скрыть выполненные" })); + expect(within(p).queryByText("Тема AURORA-2")).toBeNull(); + expect(within(p).getByText("Тема AURORA-1")).toBeInTheDocument(); }); it("нет активного — «Нет активного спринта» и ссылка «Бэклог»", async () => { - hub(agile([sprint({ id: 103, name: "F", state: "future" })])); + hub(agile([sprint({ id: 105, name: "C", state: "closed" })])); open("/app/jira/sprint?project=AWPCA"); const p = within(await page()); expect(await p.findByText("Нет активного спринта")).toBeInTheDocument(); diff --git a/web/src/modules/jira/agile/SprintPage.tsx b/web/src/modules/jira/agile/SprintPage.tsx index 35508f4..6858646 100644 --- a/web/src/modules/jira/agile/SprintPage.tsx +++ b/web/src/modules/jira/agile/SprintPage.tsx @@ -1,16 +1,20 @@ import { Link, useSearchParams } from "react-router"; import type { Project } from "../../../api/types.ts"; -import { PageHeader, Panel, Segmented } from "../../../ui/index.ts"; +import { PageHeader, Panel } from "../../../ui/index.ts"; import type { AgileSprint } from "../api/types.ts"; import { RefreshButton } from "../RefreshButton.tsx"; import { AgileGate } from "./AgileGate.tsx"; import { BoardColumns } from "./BoardColumns.tsx"; +import { AgileToolbar } from "./AgileToolbar.tsx"; import { columnsOf } from "./columns.ts"; -import { daysLeft, fmtRange, fmtSp, tasksWord } from "./model.ts"; +import { assigneeOptions, matches, parseSprintIds, selectableSprints, sprintIssues } from "./filters.ts"; +import { SprintPicker } from "./SprintPicker.tsx"; +import { useAgileView } from "./useAgileView.ts"; +import { daysLeft, fmtRange, fmtSp, isEpic, tasksWord } from "./model.ts"; import "./agile.css"; function SprintHead({ s }: { s: AgileSprint }) { - const left = daysLeft(s.end_date, new Date()); + const left = s.state === "active" ? daysLeft(s.end_date, new Date()) : null; return (

    {s.name}

    @@ -23,35 +27,38 @@ function SprintHead({ s }: { s: AgileSprint }) { ); } -// «Спринт» (spec web-app): колонки доски активного спринта; только просмотр +// «Спринт» (spec web-app): колонки доски выбранных спринтов (по умолчанию все активные); только просмотр export function SprintPage({ project }: { project: Project }) { - const [params] = useSearchParams(); - const wanted = params.get("sprint"); + const [params, setParams] = useSearchParams(); + const { view, set, setQ, filter, reset } = useAgileView(project.label, "sprint"); return (
    } /> {(d) => { - const active = d.sprints.filter((s) => s.state === "active"); - // устаревший или мусорный ?sprint= — первый активный (Review Focus 2) - const sprint = active.find((s) => String(s.id) === wanted) ?? active[0]; - if (!sprint) { - return ( - -

    Нет активного спринта

    - Бэклог -
    - ); - } - const proj = encodeURIComponent(project.label); + const selectable = selectableSprints(d.sprints); + const ids = parseSprintIds(params.get("sprint"), d.sprints); + const chosen = selectable.filter((s) => ids.includes(s.id)); + const issues = sprintIssues(chosen).filter((i) => !isEpic(i)); + const shown = issues.filter((i) => matches(i, filter)); + // выбор спринтов — в адресе, без новой записи истории + const pick = (next: number[]) => setParams((p) => { const n = new URLSearchParams(p); n.set("sprint", next.join(",")); return n; }, { replace: true }); return ( <> - {active.length > 1 && ( - ({ value: String(s.id), label: s.name, href: `/jira/sprint?project=${proj}&sprint=${s.id}` }))} /> + + {selectable.length > 0 && } + + {chosen.length === 0 ? ( + +

    Нет активного спринта

    + Бэклог +
    + ) : ( + <> + {chosen.map((s) => )} + + )} - - ); }} diff --git a/web/src/modules/jira/agile/SprintPicker.tsx b/web/src/modules/jira/agile/SprintPicker.tsx new file mode 100644 index 0000000..87e60b3 --- /dev/null +++ b/web/src/modules/jira/agile/SprintPicker.tsx @@ -0,0 +1,31 @@ +// выбор спринтов доски: активные и будущие; последнюю отметку снять нельзя +import { Badge, Checkbox, Dropdown, IconChevronDown } from "../../../ui/index.ts"; +import type { AgileSprint } from "../api/types.ts"; + +export function SprintPicker({ sprints, selected, onChange }: { + sprints: AgileSprint[]; selected: number[]; onChange(ids: number[]): void; +}) { + const chosen = sprints.filter((s) => selected.includes(s.id)); + const label = chosen.length === 1 ? chosen[0].name : `Спринты: ${chosen.length}`; + const flip = (id: number, on: boolean) => onChange(sprints.map((s) => s.id).filter((x) => (x === id ? on : selected.includes(x)))); + return ( + ( + + )}> + {() => ( +
    + {sprints.map((s) => { + const on = selected.includes(s.id); + return ( + flip(s.id, e.target.checked)} + label={<>{s.name} {s.state === "active" ? "активный" : "будущий"}} /> + ); + })} +
    + )} +
    + ); +} From d4d578e10ef553fcf3e5e11157de529a0204b639 Mon Sep 17 00:00:00 2001 From: Andrey Epifancev Date: Sun, 25 Oct 2026 20:44:06 +0400 Subject: [PATCH 14/23] =?UTF-8?q?fix(web):=20=C2=AB=D0=A1=D0=BF=D1=80?= =?UTF-8?q?=D0=B8=D0=BD=D1=82=C2=BB=20=E2=80=94=20=D1=8D=D0=BF=D0=B8=D0=BA?= =?UTF-8?q?=D0=B8=20=D0=BE=D1=81=D1=82=D0=B0=D1=8E=D1=82=D1=81=D1=8F=20?= =?UTF-8?q?=D0=BA=D0=B0=D1=80=D1=82=D0=BE=D1=87=D0=BA=D0=B0=D0=BC=D0=B8,?= =?UTF-8?q?=20=D0=BF=D0=BE=D0=B4=D0=BF=D0=B8=D1=81=D1=8C=20=D0=BF=D1=83?= =?UTF-8?q?=D1=81=D1=82=D0=BE=D0=B3=D0=BE=20=D0=B2=D1=8B=D0=B1=D0=BE=D1=80?= =?UTF-8?q?=D0=B0,=20=D1=87=D0=B8=D1=81=D0=BB=D0=B0=20=D0=BA=D0=BE=D0=BB?= =?UTF-8?q?=D0=BE=D0=BD=D0=BE=D0=BA=20=D0=B2=20=D1=82=D0=B5=D1=81=D1=82?= =?UTF-8?q?=D0=B5=20(#112)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Sonnet 5.5 --- web/src/modules/jira/agile/SprintPage.test.tsx | 11 +++++++++++ web/src/modules/jira/agile/SprintPage.tsx | 4 ++-- web/src/modules/jira/agile/SprintPicker.tsx | 2 +- 3 files changed, 14 insertions(+), 3 deletions(-) diff --git a/web/src/modules/jira/agile/SprintPage.test.tsx b/web/src/modules/jira/agile/SprintPage.test.tsx index cac26ee..9f8f09a 100644 --- a/web/src/modules/jira/agile/SprintPage.test.tsx +++ b/web/src/modules/jira/agile/SprintPage.test.tsx @@ -224,6 +224,17 @@ describe("«Спринт»", () => { await userEvent.click(within(p).getByRole("checkbox", { name: "Скрыть выполненные" })); expect(within(p).queryByText("Тема AURORA-2")).toBeNull(); expect(within(p).getByText("Тема AURORA-1")).toBeInTheDocument(); + const cols = Array.from(p.querySelectorAll(".agile-col")); + expect(cols.map((c) => c.querySelector(".agile-col__count")!.textContent)).toEqual(["1", "0", "1"]); + }); + + it("эпик на доске спринта — карточка, учитывается в числе колонки", async () => { + hub(agile([s102({ issues: [iss("AURORA-1"), iss("AURORA-E", { issue_type: "Эпик" })] })])); + open("/app/jira/sprint?project=AWPCA"); + const p = await page(); + expect(await within(p).findByText("Тема AURORA-E")).toBeInTheDocument(); + const cols = Array.from(p.querySelectorAll(".agile-col")); + expect(cols[0].querySelector(".agile-col__count")!.textContent).toBe("2"); }); it("нет активного — «Нет активного спринта» и ссылка «Бэклог»", async () => { diff --git a/web/src/modules/jira/agile/SprintPage.tsx b/web/src/modules/jira/agile/SprintPage.tsx index 6858646..0c20af2 100644 --- a/web/src/modules/jira/agile/SprintPage.tsx +++ b/web/src/modules/jira/agile/SprintPage.tsx @@ -10,7 +10,7 @@ import { columnsOf } from "./columns.ts"; import { assigneeOptions, matches, parseSprintIds, selectableSprints, sprintIssues } from "./filters.ts"; import { SprintPicker } from "./SprintPicker.tsx"; import { useAgileView } from "./useAgileView.ts"; -import { daysLeft, fmtRange, fmtSp, isEpic, tasksWord } from "./model.ts"; +import { daysLeft, fmtRange, fmtSp, tasksWord } from "./model.ts"; import "./agile.css"; function SprintHead({ s }: { s: AgileSprint }) { @@ -39,7 +39,7 @@ export function SprintPage({ project }: { project: Project }) { const selectable = selectableSprints(d.sprints); const ids = parseSprintIds(params.get("sprint"), d.sprints); const chosen = selectable.filter((s) => ids.includes(s.id)); - const issues = sprintIssues(chosen).filter((i) => !isEpic(i)); + const issues = sprintIssues(chosen); const shown = issues.filter((i) => matches(i, filter)); // выбор спринтов — в адресе, без новой записи истории const pick = (next: number[]) => setParams((p) => { const n = new URLSearchParams(p); n.set("sprint", next.join(",")); return n; }, { replace: true }); diff --git a/web/src/modules/jira/agile/SprintPicker.tsx b/web/src/modules/jira/agile/SprintPicker.tsx index 87e60b3..fec0ada 100644 --- a/web/src/modules/jira/agile/SprintPicker.tsx +++ b/web/src/modules/jira/agile/SprintPicker.tsx @@ -6,7 +6,7 @@ export function SprintPicker({ sprints, selected, onChange }: { sprints: AgileSprint[]; selected: number[]; onChange(ids: number[]): void; }) { const chosen = sprints.filter((s) => selected.includes(s.id)); - const label = chosen.length === 1 ? chosen[0].name : `Спринты: ${chosen.length}`; + const label = chosen.length === 1 ? chosen[0].name : chosen.length === 0 ? "Спринты" : `Спринты: ${chosen.length}`; const flip = (id: number, on: boolean) => onChange(sprints.map((s) => s.id).filter((x) => (x === id ? on : selected.includes(x)))); return ( ( From f0bdb2bc636882527b1cc88c80f39cfb489b518c Mon Sep 17 00:00:00 2001 From: Andrey Epifancev Date: Sun, 25 Oct 2026 20:50:49 +0400 Subject: [PATCH 15/23] =?UTF-8?q?test(web):=20=D1=81=D0=B8=D0=B4=20WIDE=5F?= =?UTF-8?q?E2E=20=E2=80=94=20=D1=88=D0=B8=D1=80=D0=BE=D0=BA=D0=B0=D1=8F=20?= =?UTF-8?q?scrum-=D0=B4=D0=BE=D1=81=D0=BA=D0=B0=20(#112)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Sonnet 5.5 --- web/scripts/seed-hub.py | 17 +++++++++++++++++ 1 file changed, 17 insertions(+) diff --git a/web/scripts/seed-hub.py b/web/scripts/seed-hub.py index e38ae95..5982c6f 100644 --- a/web/scripts/seed-hub.py +++ b/web/scripts/seed-hub.py @@ -41,6 +41,19 @@ issues += [{"key": k, "id": k, "summary": f"Задача канбана {k[-4:]} "labels": ["KANBAN_E2E"], "rank": f"0|k{n:04d}:", "sprint_ids": [], **({"jira_updated": now_iso} if st == "Готово" else {})} for n, (k, st) in enumerate([("AURORA-9101", "Backlog"), ("AURORA-9102", "В работе"), ("AURORA-9103", "Готово")], 1)] jira_projects.create(conn, "KANBAN_E2E", "Канбан e2e", 5120) +# WIDE_E2E: широкая scrum-доска (11 колонок, 40 задач в одной, статус вне колонок) — воспроизводит баг прокрутки (#112, T10.3) +WIDE_COLS = [f"Колонка {n}" for n in range(1, 12)] +wide_statuses = [f"Статус {n}" for n in range(1, 12)] +wide = [] +for n in range(1, 41): # 40 задач в первой колонке + wide.append((f"AURORA-92{n:02d}", wide_statuses[0])) +for c in range(1, 11): # по 1-3 в остальных + for j in range(1, c % 3 + 2): + wide.append((f"AURORA-93{c:02d}{j}", wide_statuses[c])) +wide.append(("AURORA-9400", "Чужой статус")) # вне колонок -> «Без колонки» +issues += [{"key": k, "id": k, "summary": f"Задача широкой доски {k[-4:]}", "issue_type": "Задача", "status": st, "priority": "Средний", + "labels": ["WIDE_E2E"], "rank": f"0|w{n:04d}:", "sprint_ids": [201]} for n, (k, st) in enumerate(wide, 1)] +jira_projects.create(conn, "WIDE_E2E", "Широкая доска e2e", 6100) boards = [ {"id": 4216, "type": "scrum", "kanban_backlog": None, "columns": [{"name": "К выполнению", "statuses": ["Backlog", "К выполнению", "Ожидание", "Ожидание разработки"]}, @@ -53,6 +66,10 @@ boards = [ {"id": 5120, "type": "kanban", "kanban_backlog": True, "columns": [{"name": "Бэклог", "statuses": ["Backlog"]}, {"name": "В работе", "statuses": ["В работе"]}, {"name": "Готово", "statuses": ["Готово"]}]}, + {"id": 6100, "type": "scrum", "kanban_backlog": None, + "columns": [{"name": n, "statuses": [st]} for n, st in zip(WIDE_COLS, wide_statuses)], + "sprints": [{"id": 201, "name": "WIDE_Спринт 1", "state": "active", "start_date": iso(today - timedelta(days=3), "09:00"), + "end_date": iso(today + timedelta(days=11), "12:00"), "complete_date": None, "goal": None}]}, ] mirror.apply_state(conn, issues, links, neighbor_keys=[i["key"] for i in snap["issues"] if i["neighbor"]], boards=boards) for i in snap["issues"]: From 2cd69bd4e810f9fdcb5297eee7c2985f2701cbae Mon Sep 17 00:00:00 2001 From: Andrey Epifancev Date: Sun, 25 Oct 2026 20:50:49 +0400 Subject: [PATCH 16/23] =?UTF-8?q?fix(web):=20=D0=BA=D0=BE=D0=BB=D0=BE?= =?UTF-8?q?=D0=BD=D0=BA=D0=B8=20=D0=B4=D0=BE=D1=81=D0=BA=D0=B8=20=E2=80=94?= =?UTF-8?q?=20=D0=BE=D0=B4=D0=BD=D0=B0=20=D0=B2=D1=8B=D1=81=D0=BE=D1=82?= =?UTF-8?q?=D0=B0,=20=D0=BF=D1=80=D0=BE=D0=BA=D1=80=D1=83=D1=82=D0=BA?= =?UTF-8?q?=D0=B0=20=D0=B2=20=D0=BE=D0=BA=D0=BD=D0=B5,=20=D1=81=D0=BA?= =?UTF-8?q?=D1=80=D1=8B=D1=82=D0=B8=D0=B5=20=D0=BA=D0=BE=D0=BB=D0=BE=D0=BD?= =?UTF-8?q?=D0=BE=D0=BA=20(#112,=20T10.3)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Sonnet 5.5 --- web/src/modules/jira/agile/BoardColumns.tsx | 11 ++-- web/src/modules/jira/agile/ColumnsMenu.tsx | 26 ++++++++++ .../modules/jira/agile/KanbanPage.test.tsx | 45 ++++++++++++++++ web/src/modules/jira/agile/KanbanPage.tsx | 20 ++++++-- .../modules/jira/agile/SprintPage.test.tsx | 51 +++++++++++++++++++ web/src/modules/jira/agile/SprintPage.tsx | 5 +- web/src/modules/jira/agile/agile.css | 15 ++++-- web/src/modules/jira/agile/columns.ts | 6 +++ 8 files changed, 164 insertions(+), 15 deletions(-) create mode 100644 web/src/modules/jira/agile/ColumnsMenu.tsx diff --git a/web/src/modules/jira/agile/BoardColumns.tsx b/web/src/modules/jira/agile/BoardColumns.tsx index 152edd3..e449574 100644 --- a/web/src/modules/jira/agile/BoardColumns.tsx +++ b/web/src/modules/jira/agile/BoardColumns.tsx @@ -1,15 +1,16 @@ -import type { ColumnView } from "./columns.ts"; +import { effectiveHidden, type ColumnView } from "./columns.ts"; import { AgileCard } from "./AgileCard.tsx"; -// колонки доски; контейнер прокручивается по горизонтали внутри себя, страница не растягивается -export function BoardColumns({ columns, lastCaption }: { columns: ColumnView[]; lastCaption?: string }) { +// колонки доски; контейнер прокручивается по горизонтали внутри себя; колонки одной высоты, у каждой прокручивается список +export function BoardColumns({ columns, lastCaption, hidden = [] }: { columns: ColumnView[]; lastCaption?: string; hidden?: string[] }) { const lastIdx = columns.reduce((acc, c, i) => (c.extra ? acc : i), -1); + const gone = effectiveHidden(columns.map((c) => c.name), hidden); return (
    - {columns.map((c, i) => ( + {columns.map((c, i) => gone.includes(c.name) ? null : (
    -

    {c.name}

    +

    {c.name}

    {c.issues.length}
    {lastCaption && i === lastIdx &&

    {lastCaption}

    } diff --git a/web/src/modules/jira/agile/ColumnsMenu.tsx b/web/src/modules/jira/agile/ColumnsMenu.tsx new file mode 100644 index 0000000..438ec50 --- /dev/null +++ b/web/src/modules/jira/agile/ColumnsMenu.tsx @@ -0,0 +1,26 @@ +// меню «Колонки» экранов «Спринт» и «Kanban»: скрыть колонки доски; последнюю видимую скрыть нельзя +import { Badge, Checkbox, Dropdown, IconChevronDown } from "../../../ui/index.ts"; +import { effectiveHidden } from "./columns.ts"; + +export function ColumnsMenu({ names, hidden, onChange }: { names: string[]; hidden: string[]; onChange(hidden: string[]): void }) { + const gone = effectiveHidden(names, hidden); // имена, которых на доске больше нет, не показываются и не считаются + const visible = names.filter((n) => !gone.includes(n)); + const flip = (name: string, show: boolean) => onChange(names.filter((n) => (n === name ? !show : gone.includes(n)))); + return ( + ( + + )}> + {() => ( +
    + {names.map((n) => { + const on = !gone.includes(n); + return flip(n, e.target.checked)} />; + })} +
    + )} +
    + ); +} diff --git a/web/src/modules/jira/agile/KanbanPage.test.tsx b/web/src/modules/jira/agile/KanbanPage.test.tsx index 85eafb4..1e536b0 100644 --- a/web/src/modules/jira/agile/KanbanPage.test.tsx +++ b/web/src/modules/jira/agile/KanbanPage.test.tsx @@ -1,4 +1,5 @@ import { render, screen, within } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; import { createMemoryRouter } from "react-router"; import { beforeEach, describe, expect, it } from "vitest"; import { App } from "../../../App.tsx"; @@ -110,4 +111,48 @@ describe("«Kanban»", () => { expect(p.getByRole("link", { name: "Связи" })).toHaveAttribute("href", "/app/jira/links?project=AWPCA"); expect(urls).not.toContain("GET /agile"); }); + + it("поиск и исполнитель фильтруют колонки и «Бэклог (N)»; флажка «Скрыть выполненные» нет, меню «Колонки» есть", async () => { + const d = agile(true); + d.backlog = [iss("AURORA-1", { assignee: "Иванов" }), iss("AURORA-2")]; + d.board_issues = [iss("AURORA-3", { status: "В работе", assignee: "Иванов" }), iss("AURORA-4", { status: "Готово", done: true })]; + hub(d); + open("/app/jira/kanban?project=KAN"); + const p = await page(); + await within(p).findByText("Бэклог (2)"); + expect(within(p).queryByRole("checkbox", { name: "Скрыть выполненные" })).toBeNull(); + expect(within(p).getByRole("button", { name: /^Колонки/ })).toBeInTheDocument(); + await userEvent.selectOptions(within(p).getByLabelText("Исполнитель"), "Иванов"); + expect(within(p).getByText("Бэклог (1)")).toBeInTheDocument(); + expect(within(p).queryByText("Тема AURORA-4")).toBeNull(); + expect(within(p).getByText("Тема AURORA-3")).toBeInTheDocument(); + await userEvent.selectOptions(within(p).getByLabelText("Исполнитель"), ""); + await userEvent.type(within(p).getByRole("searchbox"), "AURORA-4"); + expect(within(p).getByText("Бэклог (0)")).toBeInTheDocument(); + expect(within(p).getByText("Тема AURORA-4")).toBeInTheDocument(); + expect(within(p).queryByText("Тема AURORA-3")).toBeNull(); + }); + + it("hideDone=true в хранилище на «Kanban» не применяется: выполненные остаются", async () => { + localStorage.setItem("sdlc-hub-agile", JSON.stringify({ KAN: { kanban: { hideDone: true } } })); + hub(agile(false)); + open("/app/jira/kanban?project=KAN"); + const p = await page(); + expect(await within(p).findByText("Тема AURORA-4")).toBeInTheDocument(); + expect(within(p).queryByRole("button", { name: "Сбросить фильтры" })).toBeNull(); + }); + + it("скрытая колонка: снять «Готово» — колонки нет; имя колонки, которой нет, в хранилище не ломает меню", async () => { + localStorage.setItem("sdlc-hub-agile", JSON.stringify({ KAN: { kanban: { hiddenColumns: ["Старая"] } } })); + hub(agile(false)); + open("/app/jira/kanban?project=KAN"); + const p = await page(); + await within(p).findByText("Тема AURORA-3"); + const btn = within(p).getByRole("button", { name: /^Колонки/ }); + expect(btn).toHaveTextContent(/^Колонки$/); + await userEvent.click(btn); + await userEvent.click(within(screen.getByRole("group", { name: "Колонки" })).getByRole("checkbox", { name: "Готово" })); + expect(colNames(p)).toEqual(["Бэклог", "В работе", "Без колонки"]); + expect(btn).toHaveTextContent("Колонки1"); + }); }); diff --git a/web/src/modules/jira/agile/KanbanPage.tsx b/web/src/modules/jira/agile/KanbanPage.tsx index 8dc910a..50fcd21 100644 --- a/web/src/modules/jira/agile/KanbanPage.tsx +++ b/web/src/modules/jira/agile/KanbanPage.tsx @@ -2,13 +2,20 @@ import type { Project } from "../../../api/types.ts"; import { PageHeader } from "../../../ui/index.ts"; import { RefreshButton } from "../RefreshButton.tsx"; import { AgileGate } from "./AgileGate.tsx"; +import { AgileToolbar } from "./AgileToolbar.tsx"; import { BoardColumns } from "./BoardColumns.tsx"; +import { ColumnsMenu } from "./ColumnsMenu.tsx"; import { columnsOf } from "./columns.ts"; +import { assigneeOptions, matches } from "./filters.ts"; import { AgileIssueRow } from "./IssueRow.tsx"; +import { useAgileView } from "./useAgileView.ts"; import "./agile.css"; -// «Kanban» (spec web-app): колонки доски; при включённом бэклоге Jira первая колонка не рисуется, её задачи — в «Бэклог (N)» (R-18) +// «Kanban» (spec web-app): колонки доски; при включённом бэклоге Jira первая колонка не рисуется, её задачи — в «Бэклог (N)» (R-18). +// «Скрыть выполненные» здесь нет: даже сохранённое hideDone не применяется (последняя колонка и так ограничена «за 14 дней») export function KanbanPage({ project }: { project: Project }) { + const { view, set, setQ, filter, reset } = useAgileView(project.label, "kanban"); + const kfilter = { ...filter, hideDone: false }; return (
    } /> @@ -16,17 +23,22 @@ export function KanbanPage({ project }: { project: Project }) { {(d) => { const withBacklog = d.board?.kanban_backlog === true; const cols = d.board?.columns ?? null; - const issues = d.board_issues ?? []; // сервер уже убрал задачи первой колонки, если бэклог включён - const backlog = d.backlog ?? []; + const issues = (d.board_issues ?? []).filter((i) => matches(i, kfilter)); // сервер уже убрал задачи первой колонки, если бэклог включён + const backlog = (d.backlog ?? []).filter((i) => matches(i, kfilter)); + const views = columnsOf(withBacklog && cols ? cols.slice(1) : cols, issues); return ( <> + + c.name)} hidden={view.hiddenColumns} onChange={(hiddenColumns) => set({ hiddenColumns })} /> + {withBacklog && (
    {`Бэклог (${backlog.length})`}
      {backlog.map((i) => )}
    )} - +