diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index d8908ec..ec2c6be 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -104,7 +104,12 @@ Экраны доски модуля jira — `web/src/modules/jira/agile/`: `model` (даты Jira, «осталось N дн.», склонение, состояние данных `boardState`), `columns` (раскладка задач по колонкам доски, «Без колонки»), `AgileGate` (тип доски проекта, загрузка, ошибка, три состояния данных), `BoardColumns` и страницы `BacklogPage` (`/jira/backlog`), `SprintPage` (`/jira/sprint`), `KanbanPage` -(`/jira/kanban`). Данные — один запрос `GET /agile?project=<метка>` (`modules/jira/agile.py`); страницы только смотрят. +(`/jira/kanban`). Фильтры и вид: `filters` (модель фильтров — исполнитель, «Эпик», «Скрыть выполненные», свёртка, скрытые колонки; +чистые функции), `agilePref` (запоминание вида в `localStorage` под ключом `sdlc-hub-agile`: `{метка: {backlog|sprint|kanban: вид}}`; +поиск и выбор спринтов не хранятся), `useAgileView` (хук состояния вида и поиска), `AgileToolbar` (панель фильтров), `EpicsPanel` +(панель «Эпики» бэклога и выбор эпика в узкой панели), `SprintPicker` (выбор спринтов), `ColumnsMenu` (скрытие колонок «Спринт» и +«Kanban»). «Бэклог» — фильтры, свёртка секций, панель эпиков; «Спринт» — фильтры, выбор спринтов (в адресе `?sprint=`, не в `localStorage`), скрытие колонок; «Kanban» — поиск/исполнитель и скрытие колонок. +Данные — один запрос `GET /agile?project=<метка>` (`modules/jira/agile.py`); страницы только смотрят (фильтры и вид в Jira не пишут). Модуль `wiki` (`web/src/modules/wiki/index.tsx`): страница «Вики» (`/app/wiki`): дерево (`Tree`) с поиском (`Search`), страница (`WikiPage`, `PageView` — `react-markdown` + `remark-gfm`, без `rehype-raw`; ссылки `page:` ведут на страницу вики, `attachment:` diff --git a/docs/superpowers/plans/2026-10-25-agile-web-polish.md b/docs/superpowers/plans/2026-10-25-agile-web-polish.md new file mode 100644 index 0000000..7171004 --- /dev/null +++ b/docs/superpowers/plans/2026-10-25-agile-web-polish.md @@ -0,0 +1,325 @@ +# Доработки «Бэклога» и «Спринта» (#112) — план реализации + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** фильтры, свёртка, панель эпиков, выбор спринтов, скрытие колонок, значки типов и доска во всю высоту окна на +страницах «Бэклог», «Спринт», «Kanban» веб-приложения sdlc-hub — только просмотр. + +**Architecture:** только фронт (`web/src/modules/jira/agile/`), данные — прежний `GET /agile`. Фильтры — чистая +модель `filters.ts` + запоминание `agilePref.ts` + хук `useAgileView`; страницы и компоненты только рисуют. Общие +части — панель фильтров `AgileToolbar`, меню `ColumnsMenu`, `BoardColumns`. + +**Tech Stack:** React 19 + TypeScript + Vite, TanStack Query, Vitest + Testing Library, Playwright; UI-кит +`web/src/ui`, токены дизайн-системы AURA. + +**Ветка:** `feat/112-agile-web-polish` (worktree `.wt/agile-polish`). + +**Spec:** `openspec/changes/agile-web-polish/` (требование «Спринты и бэклог» в `specs/web-app/spec.md`, решения — +`design.md`, рулинги дизайна R-1…R-13; рулинги контроллера — с R-14). + +## Global Constraints + +- Веб только смотрит: MUST NOT появляться действий, меняющих Jira; ни одна кнопка не ставит команд. +- Сервер, API, схема, агент, MCP — не меняются (никаких правок вне `web/` и `openspec/`/`docs/`, кроме сида + `web/scripts/seed-hub.py`). +- Ключ хранилища — ровно `sdlc-hub-agile`; JSON `{ "<метка>": { "backlog"|"sprint"|"kanban": AgileView } }`; поиск + (`q`) и выбор спринтов не хранятся; любая ошибка хранилища — значения по умолчанию, без исключения наружу. +- Тексты интерфейса — дословно из спеки: «Ключ или тема», «Исполнитель», «все», «Без исполнителя», «Скрыть + выполненные», «Сбросить фильтры», «Эпики», «Все задачи», «Без эпика», «Эпик», «Колонки», «Спринты», «показано K», + «Нет задач», «Нет задач по фильтрам», «Нет активного спринта», «Без колонки». +- Стили — только токены дизайн-системы (как в `agile.css`), без новых цветов-литералов. +- Работа только в worktree `/Users/andrey.epifancev/projects/jira-bridge/sdlc-hub/.wt/agile-polish`: никаких записей + в основной checkout и в другие репозитории (O-6 #108); никаких команд, ждущих stdin (`cat > файл` без heredoc и т.п.). +- Зелёный = `make check` целиком (pytest + `npm run lint` + `npm test`) и `cd web && npm run e2e` целиком. + +## Review Focus + +1. **Задача в двух выбранных спринтах** (перенос из активного в будущий) — на доске одна карточка, не две → тест в Task 2 + (`sprintIssues`) и Task 6. +2. **Сохранённое значение, которого больше нет** (исполнитель ушёл, эпик закрыт и пропал, колонку переименовали в + Jira) — значение остаётся видимым и снимаемым, страница не пустеет молча → тесты в Task 2 (`assigneeOptions`), Task 5, + Task 7. +3. **Испорченное хранилище** (`sdlc-hub-agile` = `"oops"`, `{"AWPCA": 5}`, поле `collapsed: "x"`, `setItem` бросает) + — страница открывается со значениями по умолчанию → тесты в Task 2. +4. **Проект сменён на открытой странице** — фильтры другого проекта не протекают (страница пересоздаётся по `key` + проекта, состояние читается по метке) → тест в Task 4. +5. **Длинная колонка и узкое окно** — 900 px: страница без горизонтальной прокрутки, доска прокручивается внутри, + полоса прокрутки доски в окне; 2560 px — колонки используют ширину окна → e2e в Task 8. + +--- + +### Task 0: Окружение worktree (без коммита) + +- [ ] `cd /Users/andrey.epifancev/projects/jira-bridge/sdlc-hub/.wt/agile-polish && uv sync && (cd web && npm ci)` +- [ ] `make check` — зелёный (база до правок; запомнить число тестов в ledger). + +### Task 1: Формат SP и значки типов + +**Files:** +- Modify: `web/src/modules/jira/agile/model.ts` (`fmtSp`, новое `typeKind`) +- Modify: `web/src/modules/jira/agile/TypeIcon.tsx`, `web/src/modules/jira/agile/agile.css` +- Modify: `web/src/ui/icons.tsx` (новые символы) +- Test: `web/src/modules/jira/agile/model.test.ts`, новый `web/src/modules/jira/agile/TypeIcon.test.tsx` + +**Interfaces:** +- Produces: `fmtSp(n: number | null): string`; `type TypeKind = "epic" | "story" | "task" | "bug" | "subtask" | "other"`; + `typeKind(type: string | null): TypeKind`; `isEpic(i: { issue_type: string | null }): boolean` — всё в `model.ts`. + Значки: `IconBookmark`, `IconCircle`, `IconSquares`, `IconDot` в `web/src/ui/icons.tsx`. + +- [ ] **Step 1: тесты.** `model.test.ts`: `fmtSp(null) === "—"`, `fmtSp(3) === "3"`, `fmtSp(0.1 + 0.2) === "0.3"`, + `fmtSp(2.5) === "2.5"`, `fmtSp(1/3) === "0.33"`, `fmtSp(0) === "0"`. `typeKind`: «Эпик», «epic», «Фича» → `epic`; «История», «Story» → `story`; «Задача», «Task» → + `task`; «Ошибка», «BUG» → `bug`; «Подзадача», «Sub-task», «Subtask» → `subtask`; `null`, `""`, «Риск» → `other`. + `TypeIcon.test.tsx`: для каждого вида — `role="img"` с `aria-label` = исходное имя типа (для `null` — «Задача» не + подставлять: `aria-label="Тип не указан"`), класс `agile-type--`. +- [ ] **Step 2:** `cd web && npx vitest run src/modules/jira/agile/model.test.ts src/modules/jira/agile/TypeIcon.test.tsx` — FAIL. +- [ ] **Step 3: реализация.** `fmtSp`: `Number(n.toFixed(2))` → `String` (M-6). `typeKind` — сравнение без учёта + регистра со списками имён из спеки. Символы в `icons.tsx` тем же `make(...)`: закладка, круг, два квадрата, точка; + эпик — существующий `IconCritical` (молния), задача — `IconCheck`. `TypeIcon`: `` — квадрат 16 px с `border-radius:var(--radius-xs)`, фон — токен вида (эпик `--k-ui`, история + `--color-status-success`, задача и подзадача `--color-action-primary`, ошибка `--color-status-error`, прочее — + `--color-text-secondary`), символ 12 px цвета `--color-text-on-primary`. Контраст символа проверить в светлой и + тёмной теме; не хватает — рулинг с выбранным токеном, не литерал. +- [ ] **Step 4:** тот же vitest — PASS; `npm run lint` — PASS. +- [ ] **Step 5:** коммит `feat(web): значки типов по образцу Jira и формат SP (#112, M-6)`. + +### Task 2: Модель фильтров и запоминание + +**Files:** +- Create: `web/src/modules/jira/agile/filters.ts`, `web/src/modules/jira/agile/agilePref.ts` +- Test: `web/src/modules/jira/agile/filters.test.ts`, `web/src/modules/jira/agile/agilePref.test.ts` + +**Interfaces:** +- Consumes: `isEpic`, `typeKind` (Task 1); типы `AgileIssue`, `AgileSprint`, `AgileResponse` (`../api/types.ts`). +- Produces (`filters.ts`): + - `type AgilePage = "backlog" | "sprint" | "kanban"` + - `interface AgileView { assignee: string; epic: string; hideDone: boolean; collapsed: string[]; hiddenColumns: string[]; epicsPanel: boolean }` + - `const DEFAULT_VIEW: AgileView` = `{ assignee: "", epic: "", hideDone: false, collapsed: [], hiddenColumns: [], epicsPanel: true }` + - `const NONE = "-"` — значение «Без исполнителя» / «Без эпика» + - `interface IssueFilter { q: string; assignee: string; epic: string; hideDone: boolean }` + - `matches(i: AgileIssue, f: IssueFilter): boolean` — И всех условий; `q` — подстрока без учёта регистра в `key` или `summary` (пробелы по краям `q` отбрасываются) + - `hasFilters(f: IssueFilter): boolean` + - `type Opt = { value: string; label: string }` + - `assigneeOptions(issues: AgileIssue[], current: string): Opt[]` — `["", "все"]`, `[NONE, "Без исполнителя"]`, затем уникальные исполнители по `localeCompare("ru")`; `current`, которого нет, добавляется в конец с подписью = значению + - `interface EpicEntry { key: string; title: string; count: number; done: boolean }` + - `epicEntries(d: AgileResponse): EpicEntry[]` — по спеке: строки-эпики из всех спринтов и бэклога по `rank` (без повторов), затем ключи из `epic_key`, строк которых нет, по ключу (`title` = ключ, `done` = false); `count` — задачи-не-эпики с этим `epic_key` в активных, будущих спринтах и бэклоге (без повторов по ключу) + - `byRank(a: AgileIssue, b: AgileIssue): number` — `rank` строкой, `null` в конце, затем по `key` + - `sprintIssues(sprints: AgileSprint[]): AgileIssue[]` — объединение без повторов по `key`, сортировка `byRank` + - `selectableSprints(sprints: AgileSprint[]): AgileSprint[]` — активные (в порядке входа), затем будущие по `start_date` (через `jiraDate`), без даты — в конце + - `parseSprintIds(param: string | null, sprints: AgileSprint[]): number[]` — id из `"101,102"`, только среди `selectableSprints`, порядок `selectableSprints`; пусто → id всех активных +- Produces (`agilePref.ts`): `const AGILE_KEY = "sdlc-hub-agile"`; `readView(label: string, page: AgilePage): AgileView`; + `saveView(label: string, page: AgilePage, v: AgileView): void` (не трогает другие страницы и проекты в JSON). + +- [ ] **Step 1: тесты `filters.test.ts`** (фабрики `iss`/`sprint` — как в `BacklogPage.test.tsx`): `matches` по каждому + условию и их И (`q: " aurora-1 "` находит `AURORA-1` и `AURORA-10`; `q: "тема"` — по теме; `assignee: NONE` — только + `null`; `epic: NONE` — только `epic_key === null`; `hideDone`); `hasFilters` для пустого и каждого поля; + `assigneeOptions` — порядок, «Без исполнителя», ушедший `current` в конце; `epicEntries` на данных сценария «Фильтр + по эпику» спеки (`AURORA-10`: 3 в бэклоге + 2 в активном → `count` 5), эпик, на который ссылаются, но строки нет, + — в конце с `title` = ключ, задачи прошлых спринтов не считаются; `sprintIssues` — задача в двух спринтах одна, + `rank: null` в конце; `selectableSprints` — закрытые отброшены, будущий без даты последним; `parseSprintIds` — + `"102,999"` → `[102]`, `"abc"` → все активные, `null` → все активные, будущий `"103"` → `[103]`. +- [ ] **Step 2: тесты `agilePref.test.ts`:** нет ключа → `DEFAULT_VIEW`; запись `AWPCA/backlog` не меняет + `AWPCA/sprint` и `STOCKS/backlog`; мусор: `"oops"`, `"[1]"`, `{"AWPCA": 5}`, `{"AWPCA":{"backlog":{"collapsed":"x","hideDone":"yes"}}}` + → по полю значения по умолчанию (`collapsed: []`, `hideDone: false`), годные поля сохраняются; `localStorage.setItem` + бросает (`vi.spyOn(Storage.prototype, "setItem")`) — `saveView` не бросает; `getItem` бросает — `readView` → + `DEFAULT_VIEW`. +- [ ] **Step 3:** `npx vitest run src/modules/jira/agile/filters.test.ts src/modules/jira/agile/agilePref.test.ts` — FAIL. +- [ ] **Step 4: реализация** по Interfaces; разбор хранилища — поле за полем с проверкой типа (строка, булево, массив строк). +- [ ] **Step 5:** тот же vitest — PASS; `npm run lint` — PASS. +- [ ] **Step 6:** коммит `feat(web): модель фильтров экранов доски и их запоминание (#112)`. + +### Task 3: Хук состояния и панель фильтров + +**Files:** +- Create: `web/src/modules/jira/agile/useAgileView.ts`, `web/src/modules/jira/agile/AgileToolbar.tsx` +- Modify: `web/src/modules/jira/agile/agile.css` +- Test: `web/src/modules/jira/agile/AgileToolbar.test.tsx` + +**Interfaces:** +- Consumes: Task 2 целиком. +- Produces: + - `useAgileView(label: string, page: AgilePage): { view: AgileView; set(patch: Partial): void; q: string; setQ(v: string): void; filter: IssueFilter; reset(): void }` + — `set` пишет в хранилище (`saveView`) вне функции-обновителя (как T8.1 #69); `reset` очищает `q`, `assignee`, + `epic`, `hideDone` и сохраняет остальное. + - `AgileToolbar({ filter, setQ, set, onReset, assignees, showHideDone, children }: { filter: IssueFilter; setQ(v: string): void; set(patch: Partial): void; onReset(): void; assignees: Opt[]; showHideDone: boolean; children?: ReactNode })` + — `Panel` с `SearchInput label="Поиск по ключу или теме" placeholder="Ключ или тема"`, `Select aria-label="Исполнитель"`, + при `showHideDone` — `Checkbox label="Скрыть выполненные"`, слот `children` (кнопка «Эпики», выбор спринтов, меню + «Колонки»), кнопка «Сбросить фильтры» только при `hasFilters(filter)`. Без кнопок, ставящих команды. + +- [ ] **Step 1: тесты `AgileToolbar.test.tsx`** (рендер с хуком в обёртке): ввод в поиск меняет `filter.q`; выбор + исполнителя сохраняется в `localStorage["sdlc-hub-agile"]` по метке и странице, `q` — нет; «Сбросить фильтры» + появляется после ввода и снимает всё; `showHideDone={false}` — флажка нет; повторный монтаж с той же меткой и + страницей — исполнитель и флажок восстановлены. +- [ ] **Step 2:** `npx vitest run src/modules/jira/agile/AgileToolbar.test.tsx` — FAIL. +- [ ] **Step 3: реализация**; стили `.agile-toolbar` (flex-wrap, gap `--space-3`, поиск растягивается) в `agile.css`. +- [ ] **Step 4:** тот же vitest — PASS; `npm run lint` — PASS. +- [ ] **Step 5:** коммит `feat(web): панель фильтров экранов доски (#112)`. + +### Task 4: «Бэклог»: фильтры, свёртка секций, без строк эпиков + +**Files:** +- Modify: `web/src/modules/jira/agile/BacklogPage.tsx`, `web/src/modules/jira/agile/agile.css` +- Test: `web/src/modules/jira/agile/BacklogPage.test.tsx` + +**Interfaces:** +- Consumes: `useAgileView(label, "backlog")`, `AgileToolbar`, `matches`, `isEpic`, `assigneeOptions` (Tasks 1–3). +- Produces: `SprintSection`/секция «Бэклог» принимают `collapsed: boolean; onToggle(): void; shown: AgileIssue[]` — + Task 5 добавляет фильтр эпика через тот же `filter`. + +- [ ] **Step 1: тесты** (дописать в `BacklogPage.test.tsx`, существующие оставить зелёными): + - заголовок секции — кнопка с `aria-expanded="true"`; щелчок — `false`, задач секции нет, итоги видны; после + повторного монтажа (`unmount` + `open`) секция свёрнута (сценарий «Свёрнутый активный спринт»); свернуть можно и + будущий, и «Бэклог»; + - эпик (`issue_type: "Фича"`) в бэклоге и в спринте — строкой не показан; «N задач» секции «Бэклог» его не считает; + - «Скрыть выполненные» на спринте из 10 задач (4 выполнены, `count: 10`): 6 строк, «10 задач» и «показано 6»; + прошлый спринт раскрыт — все его задачи (сценарий «Скрыть выполненные»); + - поиск «AURORA-50» — в «Бэклоге» одна строка, в секции спринта «Нет задач по фильтрам»; + - смена проекта (Review Focus 4): исполнитель выбран у `AWPCA`, открыт тот же адрес с другим scrum-проектом — + исполнитель «все». + - «Бэклог только смотрит»: на странице нет элементов с `draggable`, все кнопки — свёртка, панель фильтров или + раскрытие (перечислить по `getAllByRole("button")` и их именам). +- [ ] **Step 2:** `npx vitest run src/modules/jira/agile/BacklogPage.test.tsx` — новые FAIL. +- [ ] **Step 3: реализация.** Видимые задачи секции = `issues.filter(i => !isEpic(i) && matches(i, f))`; для прошлых + спринтов `hideDone` не применяется (`{...f, hideDone: false}`). «показано K» — когда `shown.length` меньше числа + задач-не-эпиков секции. Заголовок — `} + + ); +} diff --git a/web/src/modules/jira/agile/BacklogPage.test.tsx b/web/src/modules/jira/agile/BacklogPage.test.tsx index d11c922..96c7718 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,233 @@ 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()); + 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, 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/ }); + 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()); + expect(names.length).toBeGreaterThan(0); + for (const n of names) { + 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("M-3: панель «Эпики» скрыта при выбранном эпике — выбор «Эпик» показан, значение видно и снимается", async () => { + localStorage.setItem("sdlc-hub-agile", JSON.stringify({ AWPCA: { backlog: { epic: "AURORA-10", epicsPanel: false } } })); + hub({ data: epicData() }); + open("/app/jira/backlog?project=AWPCA"); + const p = within(await loaded()); + const sel = p.getByLabelText("Эпик"); + expect(sel.closest(".agile-epic-select")).toHaveClass("is-forced"); + expect(sel).toHaveValue("AURORA-10"); + await userEvent.selectOptions(sel, ""); + expect(sel).toHaveValue(""); + }); + + 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 2df073e..1d52170 100644 --- a/web/src/modules/jira/agile/BacklogPage.tsx +++ b/web/src/modules/jira/agile/BacklogPage.tsx @@ -1,41 +1,75 @@ 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 { 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, 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

Нет задач

; +// задачи страницы: без строк-эпиков, подходящие под фильтр +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) => )}
; } -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 = visible(all, { ...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 +77,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,22 +87,41 @@ 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 = 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 ( <> - {live.map((s) => )} + + + + +
+ {view.epicsPanel && } +
+ {live.map((s) => ( + toggle(String(s.id))} + shown={visible(s.issues, 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/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..460fa6d --- /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/EpicsPanel.tsx b/web/src/modules/jira/agile/EpicsPanel.tsx new file mode 100644 index 0000000..ff73fe0 --- /dev/null +++ b/web/src/modules/jira/agile/EpicsPanel.tsx @@ -0,0 +1,49 @@ +// панель «Эпики» бэклога и её узкий вариант — выбор «Эпик» (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 ( + + ); +} + +// forced — панель «Эпики» скрыта, а выбранный эпик должен оставаться виден и снимаем (M-3) +export function EpicSelect(props: Props & { forced?: boolean }) { + return ( + + ); +} 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..feb7734 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,24 @@ 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 boardCols = withBacklog && cols ? cols.slice(1) : cols; + const views = columnsOf(boardCols, issues); + const allNames = columnsOf(boardCols, d.board_issues ?? []).map((c) => c.name); // меню — по задачам без фильтров (T7.2) return ( <> + + {withBacklog && (
    {`Бэклог (${backlog.length})`}
      {backlog.map((i) => )}
    )} - +