Merge pull request 'Веб: доработки «Бэклога» и «Спринта» по образцу Jira (#112)' (#114) from feat/112-agile-web-polish into main
This commit is contained in:
commit
e84f7b1b58
@ -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:`
|
||||
|
||||
325
docs/superpowers/plans/2026-10-25-agile-web-polish.md
Normal file
325
docs/superpowers/plans/2026-10-25-agile-web-polish.md
Normal file
@ -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--<kind>`.
|
||||
- [ ] **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`: `<span className="agile-type
|
||||
agile-type--<kind>">` — квадрат 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<AgileView>): 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<AgileView>): 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` меньше числа
|
||||
задач-не-эпиков секции. Заголовок — `<button className="agile-section__toggle" aria-expanded aria-controls>` с
|
||||
`IconChevronDown`/`IconChevronUp`; id секции в `collapsed` — `String(sprint.id)` и `"backlog"`.
|
||||
- [ ] **Step 4:** тот же vitest — PASS; `npm run lint` — PASS.
|
||||
- [ ] **Step 5:** коммит `feat(web): «Бэклог» — фильтры, свёртка секций, эпики не строками (#112)`.
|
||||
|
||||
### Task 5: Панель «Эпики» на «Бэклоге»
|
||||
|
||||
**Files:**
|
||||
- Create: `web/src/modules/jira/agile/EpicsPanel.tsx`
|
||||
- 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: `epicEntries`, `EpicEntry`, `NONE`, `useAgileView` (Task 2–3), страница из Task 4.
|
||||
- Produces: `EpicsPanel({ entries, value, onChange, hideDone }: { entries: EpicEntry[]; value: string; onChange(v: string): void; hideDone: boolean })`
|
||||
и `EpicSelect` с теми же пропсами (`Select aria-label="Эпик"`: «Все задачи», эпики, «Без эпика»).
|
||||
|
||||
- [ ] **Step 1: тесты:**
|
||||
- сценарий «Фильтр по эпику» спеки дословно (3 + 5 в бэклоге, 2 в спринте; выбран `AURORA-10` → в спринте 2 и
|
||||
«показано 2», в «Бэклоге» 3, строки эпика нет, у пункта в панели 5);
|
||||
- пункт — кнопка с `aria-pressed`; повторный щелчок по выбранному — «Все задачи» (`aria-pressed="true"`);
|
||||
- «Без эпика» — только задачи с `epic_key: null`;
|
||||
- эпик, на который ссылаются, но строки нет, — пункт с ключом; фильтр по нему работает;
|
||||
- «Скрыть выполненные»: выполненный эпик пропадает из панели, кроме выбранного (Review Focus 2);
|
||||
- сохранённый эпик, которого нет в данных, остаётся выбранным пунктом (по ключу), секции — «Нет задач по фильтрам»;
|
||||
- кнопка «Эпики» в панели фильтров (`aria-pressed`) скрывает и показывает панель, состояние переживает повторный монтаж;
|
||||
- фильтр эпика применяется и к задачам прошлого спринта.
|
||||
- [ ] **Step 2:** vitest файла — новые FAIL.
|
||||
- [ ] **Step 3: реализация.** Раскладка `.agile-backlog`: сетка `240px minmax(0,1fr)` (без панели — одна колонка);
|
||||
при `@container page (max-width: 899px)` панель скрыта, в панели фильтров показан `EpicSelect` (на широком —
|
||||
скрыт); кнопка «Эпики» на узком скрыта. Пункт: тема (или ключ), ключ моноширинным, число.
|
||||
- [ ] **Step 4:** vitest файла — PASS; `npm run lint` — PASS.
|
||||
- [ ] **Step 5:** коммит `feat(web): панель «Эпики» на «Бэклоге» (#112)`.
|
||||
|
||||
### Task 6: «Спринт»: выбор спринтов и фильтры
|
||||
|
||||
**Files:**
|
||||
- Create: `web/src/modules/jira/agile/SprintPicker.tsx`
|
||||
- Modify: `web/src/modules/jira/agile/SprintPage.tsx`
|
||||
- Test: `web/src/modules/jira/agile/SprintPage.test.tsx`
|
||||
|
||||
**Interfaces:**
|
||||
- Consumes: `selectableSprints`, `parseSprintIds`, `sprintIssues`, `matches`, `useAgileView(label, "sprint")`, `AgileToolbar`.
|
||||
- Produces: `SprintPicker({ sprints, selected, onChange }: { sprints: AgileSprint[]; selected: number[]; onChange(ids: number[]): void })`
|
||||
— `Dropdown` с кнопкой «Спринты» (на кнопке — имя единственного выбранного или «Спринты: N»), внутри `Checkbox` по
|
||||
спринту (имя, «активный»/«будущий»); снять последнюю отметку нельзя (`disabled`). `BoardColumns` получает
|
||||
отфильтрованные задачи (Task 7 добавит `hidden`).
|
||||
|
||||
- [ ] **Step 1: тесты** (переписать «два активных: переключатель…», «один активный: переключателя нет», «устаревший
|
||||
?sprint=», «мусорный ?sprint=abc» под новую спеку; остальные — оставить):
|
||||
- по умолчанию выбраны все активные; два активных → два заголовка и задачи обоих;
|
||||
- сценарий «Будущий спринт на доске»: отметить будущий 102 при активном 101 → адрес `?sprint=101,102` без новой
|
||||
записи истории (`router.state.historyAction === "REPLACE"`), два заголовка, «осталось N дн.» только у 101;
|
||||
- задача в обоих выбранных спринтах — одна карточка (Review Focus 1);
|
||||
- `?sprint=999` и `?sprint=abc` → все активные; `?sprint=103` (будущий) → только он;
|
||||
- нет активного, есть будущий → «Нет активного спринта», ссылка «Бэклог» и выбор спринтов доступен; выбор будущего
|
||||
показывает доску;
|
||||
- сценарий «Исполнитель на доске спринта»: выбран исполнитель → только его карточки, числа колонок — по видимым; на
|
||||
«Бэклоге» (`open("/jira/backlog?…")`) исполнитель «все»;
|
||||
- «Скрыть выполненные» убирает выполненные карточки.
|
||||
- [ ] **Step 2:** vitest файла — новые FAIL.
|
||||
- [ ] **Step 3: реализация** по Interfaces; заголовки — `SprintHead` на каждый выбранный, компактно (`agile-section__head`),
|
||||
«осталось…»/«срок прошёл» — только у `state === "active"`. Прежний `Segmented` убрать.
|
||||
- [ ] **Step 4:** vitest файла — PASS; `npm run lint` — PASS.
|
||||
- [ ] **Step 5:** коммит `feat(web): «Спринт» — выбор активных и будущих спринтов, фильтры (#112)`.
|
||||
|
||||
### Task 7: Колонки доски: скрытие, одна высота, прокрутка; «Kanban»
|
||||
|
||||
**Files:**
|
||||
- Create: `web/src/modules/jira/agile/ColumnsMenu.tsx`
|
||||
- Modify: `web/src/modules/jira/agile/BoardColumns.tsx`, `SprintPage.tsx`, `KanbanPage.tsx`, `agile.css`
|
||||
- Test: `web/src/modules/jira/agile/SprintPage.test.tsx`, `KanbanPage.test.tsx`, `columns.test.ts` при необходимости
|
||||
|
||||
**Interfaces:**
|
||||
- Consumes: `ColumnView`/`columnsOf` (без изменений), `useAgileView(label, "sprint" | "kanban")`, `AgileToolbar`.
|
||||
- Produces: `ColumnsMenu({ names, hidden, onChange }: { names: string[]; hidden: string[]; onChange(hidden: string[]): void })`
|
||||
— `Dropdown` с кнопкой «Колонки» (+ `Badge` с числом скрытых, если > 0), отметки по именам; последняя видимая —
|
||||
`disabled`; имя из `hidden`, которого нет в `names`, в меню не показывается и на `Badge` не считается.
|
||||
`BoardColumns({ columns, lastCaption, hidden }: { columns: ColumnView[]; lastCaption?: string; hidden?: string[] })`.
|
||||
|
||||
- [ ] **Step 1: воспроизвести баг п. 10** (superpowers:systematic-debugging): тестовый хаб (`bash web/scripts/test-hub.sh`
|
||||
в фоне + `npm run dev` или собранное приложение) с широкой доской из Task 8 Step 1 (если её ещё нет — временно в
|
||||
своём сиде), Playwright-скрипт в scratchpad: окно 2560 × 1300, замерить у `.page`, `.agile-columns` ширину и
|
||||
`getBoundingClientRect().bottom` против `innerHeight`. Записать причину в ledger. Ожидаемая по design #9 и R-12:
|
||||
полоса прокрутки ниже окна и ширина страницы ограничена `max-width:1440px` у `.page`. Если причина другая — чинить
|
||||
её, требование спеки то же.
|
||||
- [ ] **Step 2: тесты:** сценарий «Скрытая колонка» (снять «Готово» → колонки нет, на «Колонки» — 1, после повторного
|
||||
монтажа так же); последнюю видимую снять нельзя; заголовки колонок — `getAllByRole("heading", { level: 3 })` (T10.3);
|
||||
«Kanban»: поиск и исполнитель фильтруют колонки и «Бэклог (N)», флажка «Скрыть выполненные» нет, меню «Колонки» есть;
|
||||
скрытое имя колонки, которой больше нет, не ломает меню.
|
||||
- [ ] **Step 3:** vitest файлов — новые FAIL.
|
||||
- [ ] **Step 4: реализация.** Скрытие — фильтр `columns` по имени в `BoardColumns`. Раскладка (design #9, R-12):
|
||||
страница доски (`.agile--wide`) без ограничения `max-width` у `.page` (`.page:has(> .agile--wide)` или класс от
|
||||
страницы — выбрать и записать рулинг), доска занимает высоту окна под шапкой и панелью фильтров (`min-height:360px`;
|
||||
на экране < 640 px учесть закреплённую строку «Меню»), `.agile-columns{align-items:stretch}`, у колонки
|
||||
прокручивается `.agile-col__list` (`overflow-y:auto; min-height:0`). Горизонтальная прокрутка — у `.agile-columns`.
|
||||
- [ ] **Step 5:** vitest файлов — PASS; `npm run lint` — PASS; скрипт из Step 1 — полоса прокрутки в окне, последняя
|
||||
колонка достижима, колонки одной высоты.
|
||||
- [ ] **Step 6:** коммит `fix(web): колонки доски — одна высота, прокрутка в окне, скрытие колонок (#112, T10.3)`.
|
||||
|
||||
### Task 8: e2e, сид и попутные тесты hub#111
|
||||
|
||||
**Files:**
|
||||
- Modify: `web/scripts/seed-hub.py`, `web/e2e/agile.spec.ts`
|
||||
- Test: `web/src/modules/jira/agile/AgileGate.test.tsx` (новый), `web/src/shell/nav.test.ts` или `KanbanPage.test.tsx`
|
||||
|
||||
**Interfaces:**
|
||||
- Consumes: всё выше; сид — прежний формат `boards` в `mirror.apply_state`.
|
||||
|
||||
- [ ] **Step 1: сид.** Проект `WIDE_E2E` (scrum-доска 6100): 11 колонок по своим статусам + задачи со статусом вне
|
||||
колонок («Без колонки»), активный спринт, 40 задач в одной колонке, по 1–3 в остальных; эпики для AWPCA уже есть в
|
||||
фикстуре (`AURORA-9100` «Фича», ссылки на отсутствующие `AURORA-1359`, `AURORA-9999`). Фиксированный `jira_updated`
|
||||
у задач канбана вместо «сегодня 12:00», если от него не зависит «за 14 дней» (T12.2; если зависит — дата
|
||||
`today - 1 day` и комментарий). Проверить, что прочие e2e (`projects.spec.ts`, `overview.spec.ts`) не считают проекты
|
||||
жёстко; если считают — поправить ожидание.
|
||||
- [ ] **Step 2: e2e** (`agile.spec.ts`):
|
||||
- «Широкая доска» по спеке: окно 2560 × 1300, `WIDE_E2E` — высоты всех `.agile-col` равны (±1 px), нижний край
|
||||
`.agile-columns` ≤ `innerHeight`, `scrollWidth > clientWidth` у доски, после `scrollLeft = scrollWidth` колонка
|
||||
«Без колонки» целиком в окне, `document.documentElement.scrollWidth ≤ clientWidth`, ширина `.agile-columns` больше
|
||||
1440 px;
|
||||
- 900 px — прежний тест плюс нижний край доски ≤ `innerHeight`;
|
||||
- «Бэклог» AWPCA: панель «Эпики» с `AURORA-9100`, выбор — строки только его задач; свёртка секции переживает
|
||||
перезагрузку;
|
||||
- «Спринт» AWPCA: отметить «AWPCA_Спринт 3» → адрес `sprint=102,103`; скрыть «Готово» → после перезагрузки скрыта;
|
||||
- T12.3/T12.4: в существующих тестах файла — локаторы по роли и точному тексту вместо нестрогих `getByText` и
|
||||
`.last()`/`.first()` по порядку DOM.
|
||||
- [ ] **Step 3: T9.4** — vitest `AgileGate.test.tsx`: «Загрузка…» пока `/agile` висит; ошибка `/agile` → текст ошибки и
|
||||
«Повторить», щелчок повторяет запрос; меню kanban-проекта: «Kanban» есть, «Бэклог»/«Спринт» нет.
|
||||
- [ ] **Step 4:** `cd web && npm run e2e` — весь набор PASS; `make check` (из корня worktree) — PASS.
|
||||
- [ ] **Step 5:** коммит `test(web): e2e широкой доски и фильтров, сид WIDE_E2E, тесты AgileGate (#112, T9.4, T12.2–T12.4)`.
|
||||
|
||||
### Task 9: Документы
|
||||
|
||||
**Files:**
|
||||
- Modify: `openspec/changes/agile-web-polish/design.md` (рулинги контроллера R-13… в «Decisions made without discussion»),
|
||||
`CLAUDE.md` / `ARCHITECTURE.md` — только если упоминают состав `agile/` или ключи `localStorage` (проверить grep).
|
||||
|
||||
- [ ] `grep -n "agile\|sdlc-hub-legend" CLAUDE.md ARCHITECTURE.md` — дописать `sdlc-hub-agile` и новые файлы, где
|
||||
перечислены соседние; `openspec validate agile-web-polish --strict` — valid.
|
||||
- [ ] коммит `docs: доработки экранов доски — рулинги и карта (#112)`.
|
||||
|
||||
**После плана (сессия, не SDD):** перенести новые значки и стиль `agile-type` в kit скилла `aura-design`
|
||||
(вне worktree — только руками сессии), PR в Gitea, выкатка `make web` + kickstart — за владельцем.
|
||||
2
openspec/changes/agile-web-polish/.openspec.yaml
Normal file
2
openspec/changes/agile-web-polish/.openspec.yaml
Normal file
@ -0,0 +1,2 @@
|
||||
schema: spec-driven
|
||||
created: 2026-10-25
|
||||
166
openspec/changes/agile-web-polish/design.md
Normal file
166
openspec/changes/agile-web-polish/design.md
Normal file
@ -0,0 +1,166 @@
|
||||
## 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. **Свёртка секций «Бэклога».** Заголовок секции — `<button aria-expanded aria-controls>` с шевроном
|
||||
(`IconChevronDown`/`Up`) и прежним содержимым заголовка; свёрнутая секция показывает только заголовок с итогами.
|
||||
«Прошлые спринты» остаются `<details>` (не запоминаются — как сейчас).
|
||||
|
||||
5. **Эпики на «Бэклоге».** Эпик — задача, у которой `TypeIcon` определяет вид «эпик» (одна функция `typeKind` для
|
||||
значка и фильтра). Строки эпиков убираются из всех секций «Бэклога» (и из счёта «N задач» секции «Бэклог», который
|
||||
считается на клиенте). Панель — колонка слева шириной ~240 px в сетке страницы; при контейнере `page` уже 900 px —
|
||||
`Select «Эпик»` в панели фильтров. Число у эпика — задачи с этим `epic_key` в активных, будущих спринтах и «Бэклоге»
|
||||
без учёта остальных фильтров. На «Спринте» и «Kanban» эпики не трогаются (в issue речь только о бэклоге).
|
||||
|
||||
6. **Итоги секций — факты спринта.** Заголовок спринта показывает серверные `count`/`points`/`unestimated`; фильтры их
|
||||
не меняют, добавляется «показано K», когда видно меньше задач секции. Так ретро-цифры спринта не «плавают» от фильтра.
|
||||
Числа в заголовках колонок доски — по видимым карточкам.
|
||||
|
||||
7. **Значки типов по образцу Jira.** `typeKind(issue_type)` → `epic | story | task | bug | subtask | other`; значок —
|
||||
скруглённый квадрат 16 px цвета вида с белым символом (эпик — молния, история — закладка, задача — галочка, ошибка
|
||||
— круг, подзадача — два квадрата, прочее — нейтральный серый квадрат с точкой). Новые символы — в
|
||||
`web/src/ui/icons.tsx` тем же `make(...)`; цвета — токенами дизайн-системы (эпик — `--k-ui`, история —
|
||||
`--color-status-success`, задача — `--color-action-primary`, ошибка — `--color-status-error`, подзадача и прочее —
|
||||
нейтральные), контраст белого символа проверить в обеих темах. При завершении — перенести значки в kit скилла
|
||||
`aura-design`.
|
||||
|
||||
8. **Выбор спринтов на «Спринте».** `Dropdown` с отметками (активные, затем будущие по `start_date`, без даты — в
|
||||
конце), адрес `?sprint=<id>[,<id>…]` с `replace` (без записи истории). Задачи доски — объединение задач выбранных
|
||||
спринтов без повторов по ключу, порядок — по `rank` (строка LexoRank сравнивается как строка; `null` — в конце, затем
|
||||
по ключу). Заголовков столько, сколько выбрано спринтов, компактной строкой каждый; «осталось N дн.» — только у
|
||||
активного. Прежний одиночный `?sprint=<id>` остаётся рабочим адресом.
|
||||
|
||||
9. **Колонки одной высоты и прокрутка (пп. 8 и 10).** Гипотеза по багу (подтвердить воспроизведением в начале
|
||||
задачи, superpowers:systematic-debugging): горизонтальная полоса `.agile-columns` есть, но лежит под самой длинной
|
||||
колонкой, далеко ниже окна (и скрыта macOS до прокрутки), поэтому правые колонки на 2560 px выглядят обрезанными.
|
||||
Исправление: страницы доски растягиваются на высоту основной области оболочки (`flex:1; min-height:0` по цепочке,
|
||||
доска `min-height:360px`), `.agile-columns` — `align-items:stretch` и своя высота, у колонки прокручивается список
|
||||
(`overflow-y:auto`). Тогда полоса горизонтальной прокрутки всегда у нижнего края окна, колонки одной высоты. Если
|
||||
воспроизведение покажет другую причину — чинится причина, требование спеки то же.
|
||||
|
||||
10. **Скрытие колонок** — меню «Колонки» с отметками на «Спринте» и «Kanban»; последнюю видимую снять нельзя; число
|
||||
скрытых — `Badge` на кнопке. Скрытие — только вид, `columnsOf` не меняется, фильтруется результат.
|
||||
|
||||
11. **Попутные маркеры hub#111.** M-6 — `fmtSp` округляет до двух знаков и убирает хвостовые нули (веб-сторона;
|
||||
MCP `:g` не трогаем, расхождение для дробных до сотых исчезает). T10.3 — `h3` в заголовках колонок. T9.4 — тесты
|
||||
загрузки и ошибки `AgileGate` и пункта «Kanban» kanban-проекта в меню. T12.2–T12.4 — e2e доски: фиксированный
|
||||
`jira_updated` в сиде, строгие локаторы по роли и тексту, без зависимости от порядка DOM.
|
||||
|
||||
12. **Тестовые данные.** Сид e2e (`web/scripts/test-hub.sh` и фикстуры агента) дополняется так, чтобы были: эпик с
|
||||
задачами в спринте и бэклоге, задачи без эпика и без исполнителя, будущий спринт с задачами, доска с числом колонок
|
||||
и карточек, достаточным для широкой доски из сценария «Широкая доска».
|
||||
|
||||
## Risks / Trade-offs
|
||||
|
||||
- **Высота доски от оболочки.** Растяжение по высоте зависит от цепочки flex в `shell.css`/`Layout.tsx`; нельзя
|
||||
сломать прокрутку других страниц — e2e «страница без горизонтальной прокрутки» и сценарии оболочки остаются зелёными.
|
||||
- **Объединение спринтов.** Задача может числиться и в активном, и в будущем спринте (перенос) — без дедупликации
|
||||
карточка задвоится; дедупликация по ключу — в п. 8.
|
||||
- **Распознавание эпика по имени типа.** Тип зависит от локали Jira; список имён — один на значок и фильтр, расширяется
|
||||
в одном месте.
|
||||
|
||||
## Decisions made without discussion
|
||||
|
||||
Все — 25.10.2026, владелец попросил писать дизайн по issue без вопросов. Нумерация рулингов R-n — с R-1 в пределах
|
||||
этой фичи.
|
||||
|
||||
- R-1 (design #2): фильтры и свёртка запоминаются отдельно по странице (`backlog`/`sprint`/`kanban`) и проекту, поиск
|
||||
и выбор спринтов — не запоминаются. Обоснование: как в Jira — поиск разовый, выбор спринтов живёт в адресе.
|
||||
Цена ошибки — сделать исполнителя общим для страниц или запоминать поиск: правка одного модуля `filters.ts`.
|
||||
- R-2 (design #5): эпики убираются строками только на «Бэклоге»; на «Спринте» и «Kanban» остаются карточками.
|
||||
Обоснование: в issue речь о бэклоге. Цена ошибки — тот же фильтр на доске, одна строка.
|
||||
- R-3 (design #5): фильтр по эпику действует и на прошлые спринты; число у эпика — без учёта других фильтров и без
|
||||
прошлых спринтов. Цена ошибки — правка подсчёта в модели.
|
||||
- R-4 (design #6): итоги секций спринта не зависят от фильтров, добавляется «показано K»; числа колонок доски — по
|
||||
видимым. Цена ошибки — пересчитывать итоги на клиенте.
|
||||
- R-5 (spec): «Скрыть выполненные» не действует на прошлые спринты и на «Kanban» (у kanban последняя колонка — уже
|
||||
«за 14 дней»). Цена ошибки — включить флагом.
|
||||
- R-6 (design #8): выбор спринтов — несколько сразу (по умолчанию все активные), в адресе. Формулировка issue
|
||||
(«выбор отображаемых спринтов: и активные, и будущие») допускает и одиночный выбор; множественный — надмножество.
|
||||
Цена ошибки — заменить список отметок одиночным выбором.
|
||||
- R-7 (spec): поиск и исполнитель — на всех трёх страницах, включая «Kanban», хотя в issue названы «Бэклог» и доска
|
||||
спринта: общий компонент, единообразие. Цена ошибки — скрыть два поля на «Kanban».
|
||||
- R-8 (design #7): набор видов значков и их цвета (Jira: эпик фиолетовый с молнией, история зелёная с закладкой,
|
||||
задача синяя с галочкой, ошибка красная с кругом, подзадача). Цена ошибки — перерисовать значки.
|
||||
- R-9 (design #9): причина бага прокрутки — гипотеза, подлежит проверке воспроизведением; починка — через растяжение
|
||||
доски на высоту окна, совмещённую с п. 8. Цена ошибки — другая починка того же требования.
|
||||
- R-10 (proposal): из hub#111 взяты только веб-маркеры M-6 (веб-сторона), T10.3, T9.4, T12.2–T12.4; hub#111 после
|
||||
merge остаётся открытой с комментарием о закрытых маркерах. Цена ошибки — взять/отложить другие пункты.
|
||||
- R-11 (spec): узкий экран — панель эпиков превращается в выбор при контейнере уже 900 px. Цена ошибки — подобрать
|
||||
порог.
|
||||
- R-12 (plan, Task 7): на страницах доски («Спринт», «Kanban») страница не ограничена `max-width: 1440px` у `.page` —
|
||||
на 2560 px колонки используют ширину окна (комментарий в `agile.css` уже называл «Спринт» страницей во всю ширину, а
|
||||
ограничение оставалось). Обоснование: замечание владельца о 2560 px. Цена ошибки — вернуть ограничение одной строкой.
|
||||
- R-13 (plan, Task 1): значок задачи без типа подписан «Тип не указан» (сейчас — «Задача»), вид — нейтральный.
|
||||
Цена ошибки — вернуть прежнюю подпись.
|
||||
- R-14 (controller, 25.10.2026, design #34): сид `WIDE_E2E` (Task 8 Step 1) делается в Task 7 Step 1 — как
|
||||
воспроизводящая широкая доска для баги прокрутки; Task 8 Step 1 остаётся проверкой прочих e2e и T12.2. Обоснование:
|
||||
без сида воспроизведение невозможно, порядок плана противоречив. Цена ошибки — сид приезжает на коммит раньше.
|
||||
- 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.
|
||||
- R-17 (controller, 25.10.2026, design #37, Task 7 Step 1): гипотеза R-9/R-12 о причине бага прокрутки не подтвердилась
|
||||
воспроизведением (systematic-debugging). `max-width:1440px` у `.page` страниц доски уже снят (`.page--board`), доска на
|
||||
2560 px шире 1440 px. Настоящая причина: у `.agile-columns` не было ограничения по высоте — высота доски равна самой
|
||||
длинной колонке (3326 px), горизонтальная полоса прокрутки лежала на y=3616 при окне 1300 px, колонки были разной
|
||||
высоты (`align-items:flex-start`), список колонки не прокручивался. Починка: `.agile--wide` высотой окна, колонки
|
||||
`stretch`, прокрутка у `.agile-col__list`, горизонтальная — у `.agile-columns`. R-12 в части снятия `max-width`
|
||||
теряет силу (править нечего). Цена ошибки — нулевая: снятие ограничения не делалось.
|
||||
- R-18 (controller, 25.10.2026, design #38, из отчёта Task 7): раскрытый «Бэклог (N)» на «Kanban» ограничен по высоте
|
||||
(40dvh) с прокруткой; меню «Колонки» включает «Без колонки», если такая есть. Цена ошибки — правка CSS/списка меню.
|
||||
- R-19 (controller, 25.10.2026, design #39, из I-1 финального ревью): цвета значков типов — эпик `--k-ui`, история
|
||||
`--color-status-success`, задача `--k-dev`, подзадача `--k-analytics`, ошибка `--color-status-error`, прочее
|
||||
`--color-text-secondary`; символ — `--color-text-on-primary` (в тёмной теме тёмный, контраст ≥ 3:1 во всех парах,
|
||||
рассчитан по tokens.css). Прежний выбор плана (задача на `--color-action-primary`) давал один зелёный с историей,
|
||||
что противоречило «у каждого вида свои цвет и символ»; фраза спеки «белый символ» заменена на «символ цвета
|
||||
`--color-text-on-primary`». Цена ошибки — пересадить два токена в agile.css.
|
||||
- R-20 (controller, 25.10.2026, design #40, из I-3): на окне уже 640 px минимальная высота доски 240 px (а не 360):
|
||||
при 360 px полоса прокрутки доски уходит ниже окна (810 и 838 против 800 на 390×800, замеры Task 8 и волны
|
||||
финального ревью), а требование «полоса прокрутки в пределах окна» важнее. Спека поправлена (240 px на узком окне).
|
||||
Цена ошибки — вернуть 360 px и принять полосу ниже окна.
|
||||
- R-21 (controller, 25.10.2026, design #41, из I-3): (а) последняя отмеченная позиция в выборе спринтов недоступна
|
||||
(нельзя снять); (б) скрытая колонка, которой нет на доске, в меню «Колонки» не показывается и на кнопке не
|
||||
считается, а если скрыты все колонки — не скрыто ничего (доска не пустеет молча; список имён меню строится по
|
||||
нефильтрованным задачам); (в) подпись поля поиска для экранного диктора — «Поиск по ключу или теме», видимая —
|
||||
«Ключ или тема»; (г) подпись «Исполнитель» — inline слева от выбора (подпись сверху сдвигала выбор относительно
|
||||
поиска). Review Focus 2 плана («значение остаётся видимым и снимаемым») для колонок трактуется так: значение,
|
||||
которого нет в данных, не нужно показывать, потому что оно ни на что не влияет. Цена ошибки — показывать
|
||||
«призрачные» колонки в меню.
|
||||
38
openspec/changes/agile-web-polish/proposal.md
Normal file
38
openspec/changes/agile-web-polish/proposal.md
Normal file
@ -0,0 +1,38 @@
|
||||
## Why
|
||||
|
||||
25.10.2026, после выкатки #108, владелец посмотрел страницы «Бэклог», «Спринт» и «Kanban» на живых данных AWPCA и
|
||||
собрал 10 замечаний (sdlc-hub#112): страницам не хватает привычных по Jira средств навигации — свернуть спринт,
|
||||
отфильтровать по эпику, исполнителю, тексту, убрать выполненное, выбрать спринты и колонки доски; значки типов не
|
||||
читаются; колонки разной высоты дают визуальный шум, а на широком экране правые колонки обрезаны без прокрутки.
|
||||
Веб по-прежнему только для просмотра: всё ниже — вид и фильтры, Jira не меняется.
|
||||
|
||||
## What Changes
|
||||
|
||||
- **«Бэклог»:** сворачивается любая секция (активные и будущие спринты, «Бэклог»); эпики — панелью слева, выбор
|
||||
эпика фильтрует все секции, сами эпики строками списка больше не идут; быстрый поиск по ключу и теме; «Скрыть
|
||||
выполненные»; фильтр по исполнителю.
|
||||
- **«Спринт»:** выбор отображаемых спринтов — любые активные и будущие, несколько сразу (`?sprint=<id>,<id>`); тот же
|
||||
поиск, исполнитель, «Скрыть выполненные»; скрытие колонок; колонки одной высоты во всю доску, задачи прокручиваются
|
||||
внутри колонки, горизонтальная прокрутка доски всегда в пределах окна.
|
||||
- **«Kanban»:** общие с «Спринтом» колонки — та же высота, прокрутка и скрытие колонок; поиск и исполнитель.
|
||||
- **Значки типов** — по образцу Jira: эпик, история, задача, ошибка, подзадача, прочее.
|
||||
- **Запоминание:** свёрнутые секции, эпик, исполнитель, «Скрыть выполненные», скрытые колонки, видимость панели
|
||||
эпиков — в `localStorage` по проекту и странице (`sdlc-hub-agile`). Поиск и выбор спринтов не запоминаются.
|
||||
- **Попутно из hub#111:** M-6 (формат SP в вебе), T10.3 (заголовки колонок `h3`), T9.4 (тесты `AgileGate` и меню
|
||||
kanban-проекта), T12.2–T12.4 (нестрогие e2e доски).
|
||||
|
||||
## Non-Goals
|
||||
|
||||
- Правки Jira из веба — по-прежнему только просмотр.
|
||||
- Изменения API, схемы, агента, MCP: все данные (`epic_key`, `issue_type`, `assignee`, `done`, колонки) уже есть в
|
||||
`GET /agile`.
|
||||
- Настройки доски AWPCA в Jira («Протестировано» в «Нужно сделать», «Снято» без колонки, опечатка «К тестирвоанию») —
|
||||
делаются в Jira владельцем.
|
||||
- Прочие пункты hub#111 (сервер, MCP, агент, тесты API) — остаются в follow-up.
|
||||
|
||||
## Impact
|
||||
|
||||
- Спека `web-app`, требование «Спринты и бэклог» — переписано.
|
||||
- Код: `web/src/modules/jira/agile/*`, значки в `web/src/ui/icons.tsx`; новые значки — перенести в kit скилла
|
||||
`aura-design` (правки стиля хаба переносятся в kit).
|
||||
- Тесты: vitest страниц и модели фильтров, e2e `web/e2e/agile.spec.ts` (широкий экран, высота колонок, запоминание).
|
||||
130
openspec/changes/agile-web-polish/specs/web-app/spec.md
Normal file
130
openspec/changes/agile-web-polish/specs/web-app/spec.md
Normal file
@ -0,0 +1,130 @@
|
||||
## MODIFIED Requirements
|
||||
|
||||
### Requirement: Спринты и бэклог
|
||||
Модуль jira SHALL показывать для проекта с доской страницы по образцу Jira — только для просмотра: на них MUST NOT быть
|
||||
действий, меняющих Jira (перетаскивания, кнопок спринтов, смены статуса или ранга). Пункты меню группы «Jira» —
|
||||
«Связи», «Задачи», «Лента» и по типу доски текущего проекта (`board_type` из `GET /projects`): `scrum` — «Бэклог» и
|
||||
«Спринт»; `kanban` — «Kanban»; доски нет или тип неизвестен — новых пунктов нет, а прямой переход на их адрес
|
||||
показывает «У проекта нет scrum-доски» («…kanban-доски» для «Kanban») со ссылкой на «Связи». Данные — `GET
|
||||
/agile?project=<текущий>`; если доска есть, но её данных от агента нет (`board = null` или `board.fetched_at = null`) —
|
||||
«Нужен агент v15 и обновление из Jira» с кнопкой «Обновить» блока состояния модуля; данные доски есть (`fetched_at` задан), а колонок нет (`columns = null`) — «Не удалось
|
||||
прочитать настройки доски из Jira» и та же кнопка. Пока проекты не загружены, условные пункты меню скрыты; смена проекта
|
||||
на странице, которой у нового проекта нет, оставляет адрес и показывает сообщение «У проекта нет …-доски». Даты
|
||||
спринтов (ISO-8601 Jira) показываются «ДД.ММ» в часовом поясе браузера; «осталось N дн.» — от сегодня до конца спринта.
|
||||
|
||||
Строка задачи и карточка задачи на этих страницах: ключ, значок типа, тема, исполнитель (или «—»), оценка (число или
|
||||
«—»); выполненная — приглушена. Щелчок открывает карточку задачи (окно поверх страницы, как в списке задач). Оценки и
|
||||
их суммы SHALL показываться числом не более чем с двумя знаками после запятой без хвостовых нулей (`0.1 + 0.2` —
|
||||
«0.3», `3` — «3»).
|
||||
|
||||
**Значок типа** SHALL различать по `issue_type` без учёта регистра: эпик («Эпик», «Epic», «Фича»), история
|
||||
(«История», «Story»), задача («Задача», «Task»), ошибка («Ошибка», «Bug»), подзадача («Подзадача», «Sub-task»,
|
||||
«Subtask»); прочие и пустой тип — нейтральный значок. Значок — цветной квадрат с символом цвета `--color-text-on-primary`, контрастным в обеих темах, у каждого вида свои
|
||||
цвет и символ (эпик — молния, история — закладка, задача — галочка, ошибка — круг, подзадача — два квадрата), в обеих
|
||||
темах; название типа — в `aria-label` и подсказке.
|
||||
|
||||
**Панель фильтров** — над содержимым «Бэклога», «Спринта» и «Kanban»: поиск «Ключ или тема» (подстрока без учёта
|
||||
регистра в ключе или теме), выбор «Исполнитель» («все», «Без исполнителя», исполнители задач страницы по алфавиту),
|
||||
переключатель «Скрыть выполненные» (только «Бэклог» и «Спринт»); на «Бэклоге» — кнопка «Эпики» (показать или скрыть
|
||||
панель эпиков); на «Спринте» — выбор спринтов; на «Спринте» и «Kanban» — меню «Колонки». Если задан хотя бы один фильтр
|
||||
(поиск, исполнитель, эпик, «Скрыть выполненные») — кнопка «Сбросить фильтры», снимающая их все. Фильтры действуют вместе
|
||||
(И). Сохранённое значение исполнителя или эпика, которого нет в данных, SHALL оставаться выбранным и видимым в списке.
|
||||
|
||||
**Запоминание:** свёрнутые секции, выбранный эпик, исполнитель, «Скрыть выполненные», видимость панели эпиков и скрытые
|
||||
колонки SHALL храниться в `localStorage` под ключом `sdlc-hub-agile` — JSON по метке проекта и странице (`backlog`,
|
||||
`sprint`, `kanban`); у разных страниц и проектов свои значения. Поиск и выбор спринтов не запоминаются. Нечитаемое или
|
||||
закрытое хранилище — значения по умолчанию, страница работает.
|
||||
|
||||
**«Бэклог»** (`/app/jira/backlog`), сверху вниз: секции активных спринтов, будущих спринтов, «Бэклог», затем
|
||||
свёрнутый список «Прошлые спринты (N)». Заголовок секции спринта: имя, состояние, даты «ДД.ММ–ДД.ММ», цель (если есть),
|
||||
«N задач», «S SP (выполнено D)» и «без оценки: U», если `U > 0` — итоги спринта, фильтры их не меняют; если фильтры
|
||||
скрыли часть задач секции — ещё «показано K». Задачи секции — по рангу. Секция без задач показывает «Нет задач»; все
|
||||
задачи скрыты фильтрами — «Нет задач по фильтрам». Любую секцию (спринта и «Бэклог») SHALL можно свернуть и развернуть
|
||||
щелчком по заголовку (кнопка с `aria-expanded`); по умолчанию развёрнуты все. Прошлый спринт в списке — строка «имя ·
|
||||
закрыт ДД.ММ · выполнено X из N · S SP»; раскрытие показывает его задачи с пометкой «не выполнено» у невыполненных.
|
||||
|
||||
Эпики (задачи вида «эпик») MUST NOT показываться строками в секциях «Бэклога». Панель «Эпики» — слева от секций:
|
||||
пункт «Все задачи», затем эпики — каждая задача-эпик из данных страницы по рангу, затем эпики, на которые ссылается
|
||||
`epic_key` задач, но строки которых в данных нет, по ключу; в конце — «Без эпика». Пункт эпика: тема (или ключ, если
|
||||
темы нет), ключ, число задач эпика в секциях активных и будущих спринтов и «Бэклога». Выбор пункта (кнопка с
|
||||
`aria-pressed`) фильтрует все секции, включая прошлые спринты; повторный выбор выбранного эпика возвращает «Все
|
||||
задачи». При «Скрыть выполненные» выполненные эпики в панели не показываются (кроме выбранного). На узком экране
|
||||
(контейнер страницы уже 900 px) панель SHALL становиться выбором «Эпик» в панели фильтров.
|
||||
|
||||
«Скрыть выполненные» на «Бэклоге» скрывает выполненные задачи в секциях активных и будущих спринтов и «Бэклога»;
|
||||
прошлые спринты показываются полностью.
|
||||
|
||||
**«Спринт»** (`/app/jira/sprint`): выбранные спринты доски — по умолчанию все активные. Выбор спринтов — список с
|
||||
отметками: активные, затем будущие (по дате начала, без даты — в конце); выбор хранится в адресе
|
||||
`?sprint=<id>[,<id>…]` (переход без новой записи истории); идентификаторы, которых нет среди активных и будущих,
|
||||
отбрасываются, если не осталось ни одного — все активные. Над доской — заголовок каждого выбранного спринта (имя,
|
||||
даты, «осталось N дн.» или «срок прошёл» — только у активного, цель, суммы оценок), под ними колонки доски Jira с числом
|
||||
задач; задачи доски — задачи выбранных спринтов, карточки в колонке — по рангу; колонка «Без колонки» — для статусов вне
|
||||
настроек доски, только если в ней есть задачи. Нет активного спринта и ничего не выбрано — «Нет активного спринта» и
|
||||
ссылка на «Бэклог»; если есть будущие спринты, выбор спринтов при этом доступен.
|
||||
|
||||
**«Kanban»** (`/app/jira/kanban`): колонки доски из `board_issues`, карточки по рангу, у последней колонки подпись
|
||||
«за 14 дней»; если `kanban_backlog = true` — над колонками свёрнутый список «Бэклог (N)» по рангу; поиск и исполнитель
|
||||
действуют и на него.
|
||||
|
||||
**Колонки доски** («Спринт» и «Kanban»): заголовок колонки — `h3`. Меню «Колонки» — отметки по колонкам доски (и «Без
|
||||
колонки», если она есть); снятая отметка скрывает колонку, последнюю видимую скрыть нельзя (отметка недоступна); если
|
||||
скрыты колонки — на кнопке их число. Доска SHALL занимать высоту окна под заголовком и панелью фильтров (не меньше
|
||||
360 px; на окне уже 640 px — не меньше 240 px), все колонки — во всю высоту доски; карточки, не помещающиеся по высоте, прокручиваются внутри своей колонки.
|
||||
Колонки, не помещающиеся по ширине, SHALL прокручиваться по горизонтали внутри доски, не страницы, и полоса горизонтальной
|
||||
прокрутки доски SHALL находиться в пределах окна при любом числе карточек. Заголовок вкладки — «Бэклог», «Спринт»,
|
||||
«Kanban» по общему правилу оболочки.
|
||||
|
||||
#### Scenario: Меню scrum-проекта
|
||||
- **WHEN** текущий проект `AWPCA`, `board_type = scrum`
|
||||
- **THEN** в группе «Jira» пункты «Связи», «Бэклог», «Спринт», «Задачи», «Лента»; пункта «Kanban» нет
|
||||
|
||||
#### Scenario: Проект без доски
|
||||
- **WHEN** текущий проект `STOCKS_DRRK` без доски, открыт `/app/jira/backlog?project=STOCKS_DRRK`
|
||||
- **THEN** пунктов «Бэклог», «Спринт», «Kanban» в меню нет; страница — «У проекта нет scrum-доски» со ссылкой на «Связи»
|
||||
|
||||
#### Scenario: Бэклог только смотрит
|
||||
- **WHEN** открыт «Бэклог» `AWPCA`
|
||||
- **THEN** на странице нет перетаскиваемых элементов; кнопки и поля — только свёртка секций, панель фильтров, пункты
|
||||
панели эпиков и открытие карточки задачи; ни одно из них не ставит команд
|
||||
|
||||
#### Scenario: Прошлый спринт
|
||||
- **WHEN** спринт `AWPCA_Sprint_2_VGO` закрыт, в нём 73 задачи, 61 в последней колонке, оценки у 10 задач на 25 SP
|
||||
- **THEN** в «Прошлые спринты» строка «AWPCA_Sprint_2_VGO · закрыт ДД.ММ · выполнено 61 из 73 · 25 SP»; раскрытие —
|
||||
73 задачи, 12 с пометкой «не выполнено»
|
||||
|
||||
#### Scenario: Агент без досок
|
||||
- **WHEN** `AWPCA` scrum, агент v14 (данных доски нет), открыт «Спринт»
|
||||
- **THEN** «Нужен агент v15 и обновление из Jira» и кнопка «Обновить»
|
||||
|
||||
#### Scenario: Свёрнутый активный спринт
|
||||
- **WHEN** на «Бэклоге» свёрнута секция активного спринта, страница перезагружена
|
||||
- **THEN** секция свёрнута (видны заголовок и итоги, задач нет), остальные развёрнуты
|
||||
|
||||
#### Scenario: Фильтр по эпику
|
||||
- **WHEN** в бэклоге 3 задачи эпика `AURORA-10` и 5 других, в активном спринте 2 задачи `AURORA-10`; в панели «Эпики»
|
||||
выбран `AURORA-10`
|
||||
- **THEN** в секции спринта 2 задачи и «показано 2», в «Бэклоге» 3; строки самого `AURORA-10` в секциях нет; у пункта
|
||||
`AURORA-10` в панели — 5
|
||||
|
||||
#### Scenario: Скрыть выполненные
|
||||
- **WHEN** в активном спринте 10 задач, 4 выполнены; включено «Скрыть выполненные»
|
||||
- **THEN** в секции 6 задач, заголовок — «10 задач» и «показано 6»; «Прошлые спринты» показывают все свои задачи
|
||||
|
||||
#### Scenario: Исполнитель на доске спринта
|
||||
- **WHEN** на «Спринте» выбран исполнитель `ivanov`
|
||||
- **THEN** на доске только его карточки; числа в заголовках колонок — по видимым карточкам; на «Бэклоге» исполнитель не
|
||||
выбран (свой фильтр у каждой страницы)
|
||||
|
||||
#### Scenario: Будущий спринт на доске
|
||||
- **WHEN** у доски активный спринт 101 и будущий 102; в выборе спринтов отмечен и 102
|
||||
- **THEN** адрес — `?sprint=101,102`; над доской два заголовка, «осталось N дн.» только у 101; на доске задачи обоих
|
||||
|
||||
#### Scenario: Скрытая колонка
|
||||
- **WHEN** на «Спринте» в меню «Колонки» снята отметка «Выполнено», страница перезагружена
|
||||
- **THEN** колонки «Выполнено» нет, на кнопке «Колонки» — 1; остальные колонки на месте
|
||||
|
||||
#### Scenario: Широкая доска
|
||||
- **WHEN** окно 2560 × 1300 px, у доски 12 колонок, в одной из них 40 карточек
|
||||
- **THEN** все колонки одной высоты и помещаются в окно по высоте; полоса горизонтальной прокрутки доски видна в окне;
|
||||
прокрутка доски вправо до конца показывает «Без колонки» целиком; страница горизонтально не прокручивается
|
||||
@ -2,18 +2,23 @@ import { expect, test } from "@playwright/test";
|
||||
import { login } from "./hub.ts";
|
||||
|
||||
// экраны доски: «Бэклог», «Спринт», «Kanban» (spec web-app «Спринты и бэклог»); данные — сид тестового хаба (R-27)
|
||||
const side = (page: import("@playwright/test").Page) => page.getByRole("complementary", { name: "Панель приложения" });
|
||||
type Page = import("@playwright/test").Page;
|
||||
const side = (page: Page) => page.getByRole("complementary", { name: "Панель приложения" });
|
||||
// строка списка / карточка колонки по ключу задачи (ссылка с точным именем), без привязки к порядку в DOM
|
||||
const itemOf = (page: Page, key: string) => page.getByRole("listitem").filter({ has: page.getByRole("link", { name: key, exact: true }) });
|
||||
const colOf = (page: Page, name: string) => page.getByRole("region", { name, exact: true });
|
||||
|
||||
test("«Бэклог» AWPCA: секции, «Прошлые спринты (1)», щелчок открывает карточку", async ({ page }) => {
|
||||
await login(page, "/app/jira/backlog?project=AWPCA");
|
||||
await expect(page.getByRole("heading", { name: "AWPCA_Спринт 2" })).toBeVisible();
|
||||
await expect(page.getByRole("heading", { name: "AWPCA_Спринт 3" })).toBeVisible();
|
||||
await expect(page.getByRole("heading", { name: "Бэклог", exact: true, level: 2 })).toBeVisible();
|
||||
const past = page.getByText("Прошлые спринты (1)");
|
||||
const past = page.getByText("Прошлые спринты (1)", { exact: true });
|
||||
await expect(past).toBeVisible();
|
||||
await past.click();
|
||||
await page.getByText("AWPCA_Спринт 1").first().click();
|
||||
const row = page.locator(".agile-row").filter({ hasText: "AURORA-9001" }).last();
|
||||
const pastSprint = page.locator("details.agile-past__sprint").filter({ has: page.locator("summary", { hasText: "AWPCA_Спринт 1" }) });
|
||||
await pastSprint.locator("summary").click();
|
||||
const row = pastSprint.getByRole("listitem").filter({ has: page.getByRole("link", { name: "AURORA-9001", exact: true }) });
|
||||
await expect(row).toBeVisible();
|
||||
await row.click();
|
||||
await expect(page).toHaveURL(/\/app\/jira\/issues\/AURORA-9001/);
|
||||
@ -28,9 +33,9 @@ test("«Спринт» AWPCA: колонки доски, «осталось 7 д
|
||||
await expect(page.getByRole("heading", { name: "AWPCA_Спринт 2" })).toBeVisible();
|
||||
await expect(page.getByText(/осталось \d+ дн\./)).toBeVisible();
|
||||
for (const name of ["К выполнению", "В работе", "Готово"]) {
|
||||
await expect(page.locator(".agile-col__name", { hasText: name })).toBeVisible();
|
||||
await expect(page.getByRole("heading", { name, exact: true, level: 3 })).toBeVisible();
|
||||
}
|
||||
await page.locator(".agile-card").filter({ hasText: "AURORA-9001" }).click();
|
||||
await itemOf(page, "AURORA-9001").click();
|
||||
await expect(page).toHaveURL(/\/app\/jira\/issues\/AURORA-9001/);
|
||||
await expect(page.getByRole("dialog")).toBeVisible();
|
||||
await page.keyboard.press("Escape");
|
||||
@ -39,19 +44,19 @@ test("«Спринт» AWPCA: колонки доски, «осталось 7 д
|
||||
|
||||
test("«Kanban»: «Бэклог (1)», колонки «В работе» и «Готово», подпись «за 14 дней»", async ({ page }) => {
|
||||
await login(page, "/app/jira/kanban?project=KANBAN_E2E");
|
||||
await expect(page.getByText("Бэклог (1)")).toBeVisible();
|
||||
await expect(page.locator(".agile-col__name", { hasText: "В работе" })).toBeVisible();
|
||||
await expect(page.locator(".agile-col__name", { hasText: "Готово" })).toBeVisible();
|
||||
await expect(page.locator(".agile-col__name", { hasText: "Бэклог" })).toHaveCount(0);
|
||||
await expect(page.getByText("за 14 дней")).toBeVisible();
|
||||
await expect(page.locator(".agile-card").filter({ hasText: "AURORA-9102" })).toBeVisible();
|
||||
await expect(page.locator(".agile-card").filter({ hasText: "AURORA-9103" })).toBeVisible();
|
||||
await expect(page.getByText("Бэклог (1)", { exact: true })).toBeVisible();
|
||||
await expect(colOf(page, "В работе")).toBeVisible();
|
||||
await expect(colOf(page, "Готово")).toBeVisible();
|
||||
await expect(colOf(page, "Бэклог")).toHaveCount(0);
|
||||
await expect(page.getByText("за 14 дней", { exact: true })).toBeVisible();
|
||||
await expect(colOf(page, "В работе").getByRole("link", { name: "AURORA-9102", exact: true })).toBeVisible();
|
||||
await expect(colOf(page, "Готово").getByRole("link", { name: "AURORA-9103", exact: true })).toBeVisible();
|
||||
});
|
||||
|
||||
test("без scrum-доски: /app/jira/backlog?project=KANBAN_E2E — «У проекта нет scrum-доски», ссылка «Связи»", async ({ page }) => {
|
||||
await login(page, "/app/jira/backlog?project=KANBAN_E2E");
|
||||
await expect(page.getByText("У проекта нет scrum-доски")).toBeVisible();
|
||||
await page.getByRole("link", { name: "Связи", exact: true }).last().click();
|
||||
await page.getByRole("main").getByRole("link", { name: "Связи", exact: true }).click();
|
||||
await expect(page).toHaveURL(/\/app\/jira\/links\?project=KANBAN_E2E/);
|
||||
});
|
||||
|
||||
@ -69,13 +74,115 @@ test("меню по проекту: у AWPCA «Бэклог» и «Спринт
|
||||
test("900 px: колонки «Спринта» прокручиваются внутри доски, страница без горизонтальной прокрутки", async ({ page }) => {
|
||||
await page.setViewportSize({ width: 900, height: 900 });
|
||||
await login(page, "/app/jira/sprint?project=AWPCA");
|
||||
await expect(page.locator(".agile-col").first()).toBeVisible();
|
||||
await expect(colOf(page, "К выполнению")).toBeVisible();
|
||||
const m = await page.evaluate(() => {
|
||||
const d = document.documentElement, c = document.querySelector<HTMLElement>(".agile-columns")!;
|
||||
return { doc: [d.scrollWidth, d.clientWidth], cols: [c.scrollWidth, c.clientWidth],
|
||||
return { doc: [d.scrollWidth, d.clientWidth], cols: [c.scrollWidth, c.clientWidth], bottom: c.getBoundingClientRect().bottom,
|
||||
gutter: c.getBoundingClientRect().left - document.querySelector(".agile")!.getBoundingClientRect().left };
|
||||
});
|
||||
expect(m.gutter, "колонки не получают второй отступ внутри страницы (T10.1)").toBe(0);
|
||||
expect(m.doc[0]).toBeLessThanOrEqual(m.doc[1]);
|
||||
expect(m.cols[0]).toBeGreaterThan(m.cols[1]);
|
||||
expect(m.bottom, "нижний край доски в окне").toBeLessThanOrEqual(900);
|
||||
});
|
||||
|
||||
test("«Широкая доска» 2560×1300: колонки одной высоты, доска в окне, горизонтальная прокрутка внутри, «Без колонки» достижима", async ({ page }) => {
|
||||
await page.setViewportSize({ width: 2560, height: 1300 });
|
||||
await login(page, "/app/jira/sprint?project=WIDE_E2E");
|
||||
await expect(colOf(page, "Колонка 1")).toBeVisible();
|
||||
await expect(colOf(page, "Без колонки")).toHaveCount(1);
|
||||
const heights = () => page.evaluate(() => [...document.querySelectorAll<HTMLElement>(".agile-col")].map((c) => c.getBoundingClientRect().height));
|
||||
await expect.poll(async () => { const h = await heights(); return Math.max(...h) - Math.min(...h); }, { message: "высоты колонок равны" }).toBeLessThanOrEqual(1);
|
||||
const m = await page.evaluate(() => {
|
||||
const board = document.querySelector<HTMLElement>(".agile-columns")!;
|
||||
const heights = [...document.querySelectorAll<HTMLElement>(".agile-col")].map((c) => c.getBoundingClientRect().height);
|
||||
return { heights, bottom: board.getBoundingClientRect().bottom, inner: window.innerHeight, width: board.getBoundingClientRect().width,
|
||||
sw: board.scrollWidth, cw: board.clientWidth, count: heights.length };
|
||||
});
|
||||
expect(m.count, "11 колонок и «Без колонки»").toBe(12);
|
||||
expect(m.bottom, "нижний край доски в окне").toBeLessThanOrEqual(m.inner);
|
||||
expect(m.sw, "доска прокручивается по горизонтали").toBeGreaterThan(m.cw);
|
||||
expect(m.width, "доска шире 1440 px (нет max-width)").toBeGreaterThan(1440);
|
||||
await page.evaluate(() => { const b = document.querySelector<HTMLElement>(".agile-columns")!; b.scrollLeft = b.scrollWidth; });
|
||||
const extra = colOf(page, "Без колонки");
|
||||
await expect(extra).toBeInViewport({ ratio: 1 });
|
||||
const doc = await page.evaluate(() => [document.documentElement.scrollWidth, document.documentElement.clientWidth]);
|
||||
expect(doc[0], "страница без горизонтальной прокрутки").toBeLessThanOrEqual(doc[1]);
|
||||
});
|
||||
|
||||
test("узкий контейнер: на 900 px вместо панели «Эпики» — выбор «Эпик», на 1600 px — панель и кнопка", async ({ page }) => {
|
||||
await page.setViewportSize({ width: 900, height: 900 });
|
||||
await login(page, "/app/jira/backlog?project=AWPCA");
|
||||
await expect(page.getByRole("heading", { name: "AWPCA_Спринт 2" })).toBeVisible();
|
||||
await expect(page.getByRole("complementary", { name: "Эпики" })).toBeHidden();
|
||||
await expect(page.getByRole("button", { name: "Эпики", exact: true })).toBeHidden();
|
||||
await expect(page.getByRole("combobox", { name: "Эпик" })).toBeVisible();
|
||||
await page.setViewportSize({ width: 1600, height: 900 });
|
||||
await expect(page.getByRole("complementary", { name: "Эпики" })).toBeVisible();
|
||||
await expect(page.getByRole("button", { name: "Эпики", exact: true })).toBeVisible();
|
||||
await expect(page.getByRole("combobox", { name: "Эпик" })).toBeHidden();
|
||||
});
|
||||
|
||||
test("«Бэклог» AWPCA: панель «Эпики», выбор эпика оставляет только его задачи, свёртка секции переживает перезагрузку", async ({ page }) => {
|
||||
await login(page, "/app/jira/backlog?project=AWPCA");
|
||||
const panel = page.getByRole("complementary", { name: "Эпики" });
|
||||
await expect(panel).toBeVisible();
|
||||
await expect(itemOf(page, "AURORA-9004")).toBeVisible();
|
||||
await panel.getByRole("button", { name: /AURORA-9100/ }).click();
|
||||
await expect(itemOf(page, "AURORA-9001")).toBeVisible();
|
||||
await expect(itemOf(page, "AURORA-9004")).toHaveCount(0);
|
||||
await expect(itemOf(page, "AURORA-9008")).toHaveCount(0);
|
||||
await panel.getByRole("button", { name: "Все задачи" }).click();
|
||||
await expect(itemOf(page, "AURORA-9004")).toBeVisible();
|
||||
const toggle = page.getByRole("button", { name: "AWPCA_Спринт 3", exact: true });
|
||||
await expect(toggle).toHaveAttribute("aria-expanded", "true");
|
||||
await toggle.click();
|
||||
await expect(toggle).toHaveAttribute("aria-expanded", "false");
|
||||
await expect(itemOf(page, "AURORA-9004")).toHaveCount(0);
|
||||
await page.reload();
|
||||
await expect(page.getByRole("button", { name: "AWPCA_Спринт 3", exact: true })).toHaveAttribute("aria-expanded", "false");
|
||||
await expect(itemOf(page, "AURORA-9004")).toHaveCount(0);
|
||||
});
|
||||
|
||||
test("«Спринт» AWPCA: выбор двух спринтов пишет sprint=102,103 в адрес, скрытая колонка «Готово» переживает перезагрузку", async ({ page }) => {
|
||||
await login(page, "/app/jira/sprint?project=AWPCA");
|
||||
await expect(colOf(page, "Готово")).toBeVisible();
|
||||
await page.getByRole("button", { name: "AWPCA_Спринт 2" }).click();
|
||||
await page.getByRole("checkbox", { name: /AWPCA_Спринт 3/ }).click();
|
||||
await expect(page).toHaveURL(/sprint=102(,|%2C)103/);
|
||||
await expect(page.getByRole("heading", { name: "AWPCA_Спринт 3" })).toBeVisible();
|
||||
await page.keyboard.press("Escape");
|
||||
await page.getByRole("button", { name: "Колонки" }).click();
|
||||
await page.getByRole("checkbox", { name: "Готово", exact: true }).click();
|
||||
await expect(colOf(page, "Готово")).toHaveCount(0);
|
||||
await page.reload();
|
||||
await expect(colOf(page, "В работе")).toBeVisible();
|
||||
await expect(colOf(page, "Готово")).toHaveCount(0);
|
||||
});
|
||||
|
||||
test("390×800: документ без горизонтальной прокрутки, нижний край доски в окне (T8.1)", async ({ page }) => {
|
||||
await page.setViewportSize({ width: 390, height: 800 });
|
||||
await login(page, "/app/jira/sprint?project=WIDE_E2E");
|
||||
await expect(colOf(page, "Колонка 1")).toBeVisible();
|
||||
const m = await page.evaluate(() => {
|
||||
const d = document.documentElement, b = document.querySelector<HTMLElement>(".agile-columns")!;
|
||||
return { doc: [d.scrollWidth, d.clientWidth], bottom: b.getBoundingClientRect().bottom, inner: window.innerHeight, sw: b.scrollWidth, cw: b.clientWidth };
|
||||
});
|
||||
expect(m.doc[0], JSON.stringify(m)).toBeLessThanOrEqual(m.doc[1]);
|
||||
expect(m.bottom, JSON.stringify(m)).toBeLessThanOrEqual(m.inner);
|
||||
expect(m.sw).toBeGreaterThan(m.cw);
|
||||
});
|
||||
|
||||
test("панель фильтров на 1600 px: «Исполнитель» и поиск на одной строке, подпись видна (I-2, M-2)", async ({ page }) => {
|
||||
await page.setViewportSize({ width: 1600, height: 900 });
|
||||
await login(page, "/app/jira/sprint?project=AWPCA");
|
||||
const search = page.getByRole("searchbox", { name: "Поиск по ключу или теме" });
|
||||
const select = page.getByRole("combobox", { name: "Исполнитель" });
|
||||
await expect(select).toBeVisible();
|
||||
await expect(page.locator(".agile-toolbar__field").getByText("Исполнитель", { exact: true })).toBeVisible();
|
||||
const mid = async (l: typeof search) => { const b = (await l.boundingBox())!; return b.y + b.height / 2; };
|
||||
expect(Math.abs((await mid(search)) - (await mid(select))), "центры по вертикали").toBeLessThanOrEqual(2);
|
||||
const bar = (await page.locator(".agile-toolbar").boundingBox())!;
|
||||
const sel = (await select.boundingBox())!;
|
||||
expect(sel.width, "выбор не растянут на строку").toBeLessThan(bar.width / 2);
|
||||
});
|
||||
|
||||
@ -36,11 +36,25 @@ for n, i in enumerate(issues, 1):
|
||||
i["story_points"] = float(n % 5 + 1)
|
||||
today = date.today()
|
||||
iso = lambda d, hhmm: f"{d.isoformat()}T{hhmm}:00.000+03:00"
|
||||
now_iso = iso(today, "12:00")
|
||||
# фиксированная дата «Готово» — вчера в полдень: «за 14 дней» от «сегодня 12:00» не зависит (T12.2)
|
||||
done_iso = iso(today - timedelta(days=1), "12:00")
|
||||
issues += [{"key": k, "id": k, "summary": f"Задача канбана {k[-4:]}", "issue_type": "Задача", "status": st, "priority": "Средний",
|
||||
"labels": ["KANBAN_E2E"], "rank": f"0|k{n:04d}:", "sprint_ids": [], **({"jira_updated": now_iso} if st == "Готово" else {})}
|
||||
"labels": ["KANBAN_E2E"], "rank": f"0|k{n:04d}:", "sprint_ids": [], **({"jira_updated": done_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 +67,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"]:
|
||||
|
||||
69
web/src/modules/jira/agile/AgileGate.test.tsx
Normal file
69
web/src/modules/jira/agile/AgileGate.test.tsx
Normal file
@ -0,0 +1,69 @@
|
||||
import { 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";
|
||||
import { App } from "../../../App.tsx";
|
||||
import { setToken } from "../../../auth/token.ts";
|
||||
import settings from "../board/__fixtures__/settings.json";
|
||||
import { routes } from "../../../routes.tsx";
|
||||
import { json, mockFetch } from "../../../test/fetchMock.ts";
|
||||
import type { AgileResponse } from "../api/types.ts";
|
||||
|
||||
const S = { label: "AWPCA", name: "АРМ КА", board_id: 4216, board_type: "scrum", created_at: "", issues_count: 9, jira_synced_at: null };
|
||||
const K = { label: "KAN", name: "Канбан", board_id: 5120, board_type: "kanban", created_at: "", issues_count: 4, jira_synced_at: null };
|
||||
const data: AgileResponse = {
|
||||
board: { id: 4216, type: "scrum", columns: [{ name: "Новая", statuses: ["Новая"] }], kanban_backlog: null, fetched_at: "2026-10-24T10:00:00+00:00" },
|
||||
sprints: [], backlog: [], board_issues: [],
|
||||
};
|
||||
|
||||
const open = (path: string) => {
|
||||
const router = createMemoryRouter(routes, { initialEntries: [path], basename: "/app" });
|
||||
render(<App router={router} />);
|
||||
return router;
|
||||
};
|
||||
const base = (method: string, pathname: string, projects: unknown[]) => {
|
||||
const k = `${method} ${pathname}`;
|
||||
if (k === "GET /projects") return json(200, { projects });
|
||||
if (k === "GET /settings") return json(200, settings);
|
||||
if (k === "GET /issues") return json(200, { issues: [] });
|
||||
if (k === "GET /batches") return json(200, { batches: [] });
|
||||
if (k === "GET /agents") return json(200, { agents: [] });
|
||||
return null;
|
||||
};
|
||||
|
||||
describe("AgileGate (T9.4)", () => {
|
||||
beforeEach(() => { setToken("tok"); localStorage.clear(); });
|
||||
|
||||
it("пока /agile не ответил — «Загрузка…»", async () => {
|
||||
mockFetch((method, url) => {
|
||||
if (`${method} ${url.pathname}` === "GET /agile") return new Promise<Response>(() => {});
|
||||
return base(method, url.pathname, [S]) ?? json(404, { error: "not_found" });
|
||||
});
|
||||
open("/app/jira/sprint?project=AWPCA");
|
||||
expect(await screen.findByText("Загрузка…")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("ошибка /agile — текст ошибки и «Повторить», щелчок повторяет запрос", async () => {
|
||||
let calls = 0;
|
||||
mockFetch((method, url) => {
|
||||
if (`${method} ${url.pathname}` === "GET /agile") { calls += 1; return calls === 1 ? json(500, { error: "hub_down" }) : json(200, data); }
|
||||
return base(method, url.pathname, [S]) ?? json(404, { error: "not_found" });
|
||||
});
|
||||
open("/app/jira/sprint?project=AWPCA");
|
||||
const retry = await screen.findByRole("button", { name: "Повторить" });
|
||||
expect(screen.queryByText("Загрузка…")).not.toBeInTheDocument();
|
||||
expect(await screen.findByText("хаб ответил 500")).toBeInTheDocument();
|
||||
await userEvent.click(retry);
|
||||
await waitFor(() => expect(calls).toBe(2));
|
||||
await waitFor(() => expect(screen.queryByRole("button", { name: "Повторить" })).not.toBeInTheDocument());
|
||||
});
|
||||
|
||||
it("меню kanban-проекта: «Kanban» есть, «Бэклог» и «Спринт» нет", async () => {
|
||||
mockFetch((method, url) => base(method, url.pathname, [K]) ?? json(200, { ...data, board: { ...data.board, type: "kanban" }, sprints: [], backlog: null }));
|
||||
open("/app/jira/kanban?project=KAN");
|
||||
const menu = await screen.findByRole("group", { name: "Jira" });
|
||||
expect(await within(menu).findByRole("link", { name: "Kanban" })).toBeInTheDocument();
|
||||
expect(within(menu).queryByRole("link", { name: "Бэклог" })).not.toBeInTheDocument();
|
||||
expect(within(menu).queryByRole("link", { name: "Спринт" })).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
116
web/src/modules/jira/agile/AgileToolbar.test.tsx
Normal file
116
web/src/modules/jira/agile/AgileToolbar.test.tsx
Normal file
@ -0,0 +1,116 @@
|
||||
import { cleanup, fireEvent, render, screen } from "@testing-library/react";
|
||||
import { afterEach, beforeEach, describe, expect, it } from "vitest";
|
||||
import { AGILE_KEY } from "./agilePref.ts";
|
||||
import { assigneeOptions, type AgilePage } from "./filters.ts";
|
||||
import { AgileToolbar } from "./AgileToolbar.tsx";
|
||||
import { useAgileView } from "./useAgileView.ts";
|
||||
import type { AgileIssue } from "../api/types.ts";
|
||||
|
||||
const issues = [{ assignee: "ivanov" }, { assignee: "petrov" }] as AgileIssue[];
|
||||
|
||||
function Harness({ label = "AWPCA", page = "backlog", showHideDone = true }: { label?: string; page?: AgilePage; showHideDone?: boolean }) {
|
||||
const { view, set, setQ, filter, reset } = useAgileView(label, page);
|
||||
return (
|
||||
<>
|
||||
<AgileToolbar filter={filter} setQ={setQ} set={set} onReset={reset} assignees={assigneeOptions(issues, view.assignee)} showHideDone={showHideDone}>
|
||||
<span>слот</span>
|
||||
</AgileToolbar>
|
||||
<output data-testid="q">{filter.q}</output>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
const stored = () => JSON.parse(localStorage.getItem(AGILE_KEY) ?? "null");
|
||||
const search = () => screen.getByLabelText("Поиск по ключу или теме") as HTMLInputElement;
|
||||
const assignee = () => screen.getByLabelText("Исполнитель") as HTMLSelectElement;
|
||||
|
||||
beforeEach(() => localStorage.clear());
|
||||
afterEach(cleanup);
|
||||
|
||||
describe("AgileToolbar", () => {
|
||||
it("ввод в поиск меняет filter.q и не сохраняется", () => {
|
||||
render(<Harness />);
|
||||
fireEvent.change(search(), { target: { value: "abc" } });
|
||||
expect(screen.getByTestId("q").textContent).toBe("abc");
|
||||
expect(localStorage.getItem(AGILE_KEY)).toBeNull();
|
||||
});
|
||||
|
||||
it("исполнитель сохраняется по метке и странице", () => {
|
||||
render(<Harness />);
|
||||
fireEvent.change(assignee(), { target: { value: "petrov" } });
|
||||
expect(stored().AWPCA.backlog.assignee).toBe("petrov");
|
||||
expect(stored().AWPCA.backlog).not.toHaveProperty("q");
|
||||
});
|
||||
|
||||
it("«Сбросить фильтры» появляется после ввода и снимает всё", () => {
|
||||
render(<Harness />);
|
||||
expect(screen.queryByText("Сбросить фильтры")).toBeNull();
|
||||
fireEvent.change(search(), { target: { value: "abc" } });
|
||||
fireEvent.change(assignee(), { target: { value: "petrov" } });
|
||||
fireEvent.click(screen.getByLabelText("Скрыть выполненные"));
|
||||
fireEvent.click(screen.getByText("Сбросить фильтры"));
|
||||
expect(search().value).toBe("");
|
||||
expect(assignee().value).toBe("");
|
||||
expect((screen.getByLabelText("Скрыть выполненные") as HTMLInputElement).checked).toBe(false);
|
||||
expect(screen.queryByText("Сбросить фильтры")).toBeNull();
|
||||
expect(stored().AWPCA.backlog).toMatchObject({ assignee: "", epic: "", hideDone: false });
|
||||
});
|
||||
|
||||
it("showHideDone=false — флажка нет; слот показан", () => {
|
||||
render(<Harness showHideDone={false} />);
|
||||
expect(screen.queryByLabelText("Скрыть выполненные")).toBeNull();
|
||||
expect(screen.getByText("слот")).toBeTruthy();
|
||||
});
|
||||
|
||||
it("повторный монтаж с той же меткой и страницей восстанавливает исполнителя и флажок", () => {
|
||||
const a = render(<Harness />);
|
||||
fireEvent.change(assignee(), { target: { value: "ivanov" } });
|
||||
fireEvent.click(screen.getByLabelText("Скрыть выполненные"));
|
||||
a.unmount();
|
||||
render(<Harness />);
|
||||
expect(assignee().value).toBe("ivanov");
|
||||
expect((screen.getByLabelText("Скрыть выполненные") as HTMLInputElement).checked).toBe(true);
|
||||
});
|
||||
|
||||
it("другая страница или метка — свои значения", () => {
|
||||
const a = render(<Harness />);
|
||||
fireEvent.change(assignee(), { target: { value: "ivanov" } });
|
||||
a.unmount();
|
||||
render(<Harness page="sprint" />);
|
||||
expect(assignee().value).toBe("");
|
||||
});
|
||||
|
||||
it("смена метки и страницы без размонтирования: q сброшен, чужие значения не протекают, прежние данные целы", () => {
|
||||
const r = render(<Harness />);
|
||||
fireEvent.change(assignee(), { target: { value: "ivanov" } });
|
||||
fireEvent.click(screen.getByLabelText("Скрыть выполненные"));
|
||||
fireEvent.change(search(), { target: { value: "abc" } });
|
||||
const before = localStorage.getItem(AGILE_KEY);
|
||||
r.rerender(<Harness label="STOCKS" />);
|
||||
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(<Harness label="STOCKS" page="kanban" />);
|
||||
expect(assignee().value).toBe("");
|
||||
expect(localStorage.getItem(AGILE_KEY)).toBe(before);
|
||||
r.rerender(<Harness />);
|
||||
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(<Harness />);
|
||||
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(<Harness showHideDone={false} />);
|
||||
expect(screen.queryByText("Сбросить фильтры")).toBeNull();
|
||||
});
|
||||
});
|
||||
28
web/src/modules/jira/agile/AgileToolbar.tsx
Normal file
28
web/src/modules/jira/agile/AgileToolbar.tsx
Normal file
@ -0,0 +1,28 @@
|
||||
// панель фильтров экранов доски: поиск, исполнитель, «Скрыть выполненные», слот страницы, «Сбросить фильтры»
|
||||
import type { ReactNode } from "react";
|
||||
import { Button, Checkbox, Panel, SearchInput, Select } from "../../../ui/index.ts";
|
||||
import { hasFilters, type AgileView, type IssueFilter, type Opt } from "./filters.ts";
|
||||
|
||||
export function AgileToolbar({ filter, setQ, set, onReset, assignees, showHideDone, children }: {
|
||||
filter: IssueFilter; setQ(v: string): void; set(patch: Partial<AgileView>): void; onReset(): void;
|
||||
assignees: Opt[]; showHideDone: boolean; children?: ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<Panel className="agile-toolbar" padding="md">
|
||||
<div className="agile-toolbar__search">
|
||||
<SearchInput label="Поиск по ключу или теме" placeholder="Ключ или тема" value={filter.q} onChange={setQ} />
|
||||
</div>
|
||||
<label className="agile-toolbar__field">
|
||||
<span>Исполнитель</span>
|
||||
<Select aria-label="Исполнитель" value={filter.assignee} onChange={(e) => set({ assignee: e.target.value })}>
|
||||
{assignees.map((o) => <option key={o.value} value={o.value}>{o.label}</option>)}
|
||||
</Select>
|
||||
</label>
|
||||
{showHideDone && (
|
||||
<Checkbox label="Скрыть выполненные" checked={filter.hideDone} onChange={(e) => set({ hideDone: e.target.checked })} />
|
||||
)}
|
||||
{children}
|
||||
{hasFilters({ ...filter, hideDone: filter.hideDone && showHideDone }) && <Button variant="ghost" onClick={onReset}>Сбросить фильтры</Button>}
|
||||
</Panel>
|
||||
);
|
||||
}
|
||||
@ -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<AgileIssue> = {}) => iss(key, { issue_type: "Фича", summary: `Эпик ${key}`, ...over });
|
||||
const inEpic = (key: string, epic: string | null) => iss(key, { epic_key: epic });
|
||||
const epicData = (over: Partial<AgileResponse> = {}) => agile({
|
||||
sprints: [sprint({ id: 102, name: "AWPCA_Sprint_3", count: 3, issues: [epicRow("AURORA-10"), inEpic("AURORA-1", "AURORA-10"), inEpic("AURORA-2", "AURORA-10"), inEpic("AURORA-3", null)] }),
|
||||
sprint({ id: 101, name: "Old", state: "closed", complete_date: COMPLETE, count: 2, count_done: 1, issues: [inEpic("OLD-1", "AURORA-10"), inEpic("OLD-2", null)] })],
|
||||
backlog: [inEpic("AURORA-50", "AURORA-10"), inEpic("AURORA-51", "AURORA-10"), inEpic("AURORA-52", "AURORA-10"),
|
||||
...[60, 61, 62, 63, 64].map((n) => inEpic(`AURORA-${n}`, null))],
|
||||
...over,
|
||||
});
|
||||
const panel = () => within(screen.getByRole("complementary", { name: "Эпики" }));
|
||||
const item = (name: RegExp | string) => panel().getByRole("button", { name });
|
||||
const rows = (region: HTMLElement) => region.querySelectorAll(".agile-row");
|
||||
|
||||
it("«Фильтр по эпику»: 2 в спринте и «показано 2», 3 в «Бэклоге», строки эпика нет, у пункта 5", async () => {
|
||||
hub({ data: epicData() });
|
||||
open("/app/jira/backlog?project=AWPCA");
|
||||
const p = within(await loaded());
|
||||
expect(item(/AURORA-10/)).toHaveTextContent("5");
|
||||
await userEvent.click(item(/AURORA-10/));
|
||||
const sprintSec = p.getByRole("region", { name: /AWPCA_Sprint_3/ });
|
||||
expect(rows(sprintSec)).toHaveLength(2);
|
||||
expect(sprintSec).toHaveTextContent("показано 2");
|
||||
expect(rows(p.getByRole("region", { name: /Бэклог/ }))).toHaveLength(3);
|
||||
expect(p.queryByText("Эпик AURORA-10", { selector: ".agile-row *" })).toBeNull();
|
||||
expect(item(/AURORA-10/)).toHaveTextContent("5");
|
||||
expect(item(/AURORA-10/)).toHaveAttribute("aria-pressed", "true");
|
||||
});
|
||||
|
||||
it("повторный щелчок по выбранному пункту — «Все задачи»", async () => {
|
||||
hub({ data: epicData() });
|
||||
open("/app/jira/backlog?project=AWPCA");
|
||||
await loaded();
|
||||
expect(item("Все задачи")).toHaveAttribute("aria-pressed", "true");
|
||||
await userEvent.click(item(/AURORA-10/));
|
||||
expect(item("Все задачи")).toHaveAttribute("aria-pressed", "false");
|
||||
await userEvent.click(item(/AURORA-10/));
|
||||
expect(item("Все задачи")).toHaveAttribute("aria-pressed", "true");
|
||||
expect(item(/AURORA-10/)).toHaveAttribute("aria-pressed", "false");
|
||||
});
|
||||
|
||||
it("«Без эпика» — только задачи без epic_key", async () => {
|
||||
hub({ data: epicData() });
|
||||
open("/app/jira/backlog?project=AWPCA");
|
||||
const p = within(await loaded());
|
||||
await userEvent.click(item("Без эпика"));
|
||||
expect(rows(p.getByRole("region", { name: /Бэклог/ }))).toHaveLength(5);
|
||||
expect(rows(p.getByRole("region", { name: /AWPCA_Sprint_3/ }))).toHaveLength(1);
|
||||
});
|
||||
|
||||
it("эпик без строки — пункт с ключом, фильтр по нему работает", async () => {
|
||||
hub({ data: agile({ sprints: [], backlog: [inEpic("AURORA-50", "AURORA-77"), inEpic("AURORA-51", null)] }) });
|
||||
open("/app/jira/backlog?project=AWPCA");
|
||||
const p = within(await loaded());
|
||||
await userEvent.click(item(/AURORA-77/));
|
||||
expect(rows(p.getByRole("region", { name: /Бэклог/ }))).toHaveLength(1);
|
||||
expect(p.getByText("Тема AURORA-50")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("«Скрыть выполненные»: выполненный эпик пропадает из панели, выбранный остаётся", async () => {
|
||||
hub({ data: agile({ sprints: [], backlog: [epicRow("AURORA-10", { done: true }), epicRow("AURORA-11"), inEpic("AURORA-50", "AURORA-10")] }) });
|
||||
open("/app/jira/backlog?project=AWPCA");
|
||||
const p = within(await loaded());
|
||||
await userEvent.click(p.getByLabelText("Скрыть выполненные"));
|
||||
expect(panel().queryByRole("button", { name: /AURORA-10/ })).toBeNull();
|
||||
expect(item(/AURORA-11/)).toBeInTheDocument();
|
||||
await userEvent.click(p.getByLabelText("Скрыть выполненные"));
|
||||
await userEvent.click(item(/AURORA-10/));
|
||||
await userEvent.click(p.getByLabelText("Скрыть выполненные"));
|
||||
expect(item(/AURORA-10/)).toHaveAttribute("aria-pressed", "true");
|
||||
});
|
||||
|
||||
it("сохранённый эпик, которого нет в данных, остаётся выбранным пунктом; секции — «Нет задач по фильтрам»", async () => {
|
||||
localStorage.setItem("sdlc-hub-agile", JSON.stringify({ AWPCA: { backlog: { epic: "AURORA-999" } } }));
|
||||
hub({ data: epicData() });
|
||||
open("/app/jira/backlog?project=AWPCA");
|
||||
const p = within(await loaded());
|
||||
expect(item(/AURORA-999/)).toHaveAttribute("aria-pressed", "true");
|
||||
expect(p.getByRole("region", { name: /Бэклог/ })).toHaveTextContent("Нет задач по фильтрам");
|
||||
expect(p.getByRole("region", { name: /AWPCA_Sprint_3/ })).toHaveTextContent("Нет задач по фильтрам");
|
||||
});
|
||||
|
||||
it("кнопка «Эпики» скрывает и показывает панель, состояние переживает повторный монтаж", async () => {
|
||||
hub({ data: epicData() });
|
||||
open("/app/jira/backlog?project=AWPCA");
|
||||
const p = within(await loaded());
|
||||
const btn = () => p.getByRole("button", { name: "Эпики" });
|
||||
expect(btn()).toHaveAttribute("aria-pressed", "true");
|
||||
await userEvent.click(btn());
|
||||
expect(btn()).toHaveAttribute("aria-pressed", "false");
|
||||
expect(screen.queryByRole("complementary", { name: "Эпики" })).toBeNull();
|
||||
cleanup();
|
||||
open("/app/jira/backlog?project=AWPCA");
|
||||
const p2 = within(await loaded());
|
||||
expect(p2.getByRole("button", { name: "Эпики" })).toHaveAttribute("aria-pressed", "false");
|
||||
expect(screen.queryByRole("complementary", { name: "Эпики" })).toBeNull();
|
||||
await userEvent.click(p2.getByRole("button", { name: "Эпики" }));
|
||||
expect(screen.getByRole("complementary", { name: "Эпики" })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("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);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@ -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 <p className="agile__empty">Нет задач</p>;
|
||||
// задачи страницы: без строк-эпиков, подходящие под фильтр
|
||||
const visible = (issues: AgileIssue[], filter: IssueFilter) => issues.filter((i) => !isEpic(i) && matches(i, filter));
|
||||
|
||||
function Issues({ issues, total, markUndone }: { issues: AgileIssue[]; total: number; markUndone?: boolean }) {
|
||||
if (!issues.length) return <p className="agile__empty">{total > 0 ? "Нет задач по фильтрам" : "Нет задач"}</p>;
|
||||
return <ul className="agile-list">{issues.map((i) => <AgileIssueRow key={i.key} issue={i} mark={markUndone && !i.done ? "не выполнено" : undefined} />)}</ul>;
|
||||
}
|
||||
|
||||
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 (
|
||||
<h2 id={titleId} className="agile-section__title">
|
||||
<button type="button" className="agile-section__toggle" aria-expanded={!collapsed} aria-controls={bodyId} onClick={onToggle}>
|
||||
{children}
|
||||
{collapsed ? <IconChevronDown /> : <IconChevronUp />}
|
||||
</button>
|
||||
</h2>
|
||||
);
|
||||
}
|
||||
|
||||
interface SectionProps { collapsed: boolean; onToggle(): void; shown: AgileIssue[] }
|
||||
|
||||
function Shown({ shown, total }: { shown: AgileIssue[]; total: number }) {
|
||||
return shown.length < total ? <span className="agile-section__meta">{`показано ${shown.length}`}</span> : 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 (
|
||||
<Panel as="section" className="agile-section" aria-labelledby={id}>
|
||||
<header className="agile-section__head">
|
||||
<h2 id={id} className="agile-section__title">{s.name}</h2>
|
||||
<Toggle titleId={id} bodyId={bodyId} collapsed={collapsed} onToggle={onToggle}>{s.name}</Toggle>
|
||||
<Badge>{s.state === "active" ? "активный" : "будущий"}</Badge>
|
||||
<span className="agile-section__meta">{fmtRange(s.start_date, s.end_date)}</span>
|
||||
{s.goal && <span className="agile-section__goal">{s.goal}</span>}
|
||||
<span className="agile-section__meta">{`${s.count} ${tasksWord(s.count)}`}</span>
|
||||
<Shown shown={shown} total={total} />
|
||||
<span className="agile-section__meta">{`${fmtSp(s.points)} SP (выполнено ${fmtSp(s.points_done)})`}</span>
|
||||
{s.unestimated > 0 && <span className="agile-section__meta">{`без оценки: ${s.unestimated}`}</span>}
|
||||
</header>
|
||||
<Issues issues={s.issues} />
|
||||
<div id={bodyId} hidden={collapsed}>{!collapsed && <Issues issues={shown} total={total} />}</div>
|
||||
</Panel>
|
||||
);
|
||||
}
|
||||
|
||||
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 (
|
||||
<details className="agile-past__sprint">
|
||||
<summary>{`${s.name} · закрыт ${fmtDay(s.complete_date)} · выполнено ${s.count_done} из ${s.count} · ${fmtSp(s.points)} SP`}</summary>
|
||||
<Issues issues={s.issues} markUndone />
|
||||
<summary>{`${s.name} · закрыт ${fmtDay(s.complete_date)} · выполнено ${s.count_done} из ${s.count} · ${fmtSp(s.points)} SP${more}`}</summary>
|
||||
<Issues issues={shown} total={all.length} markUndone />
|
||||
</details>
|
||||
);
|
||||
}
|
||||
@ -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 (
|
||||
<div data-testid="page-backlog" className="agile">
|
||||
<PageHeader title="Бэклог" actions={<RefreshButton />} />
|
||||
@ -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) => <SprintSection key={s.id} s={s} />)}
|
||||
<AgileToolbar filter={filter} setQ={setQ} set={set} onReset={reset} assignees={assigneeOptions(allIssues, view.assignee)} showHideDone>
|
||||
<EpicSelect {...epicProps} forced={!view.epicsPanel} />
|
||||
<Button className="agile-epics-toggle" aria-pressed={view.epicsPanel} onClick={() => set({ epicsPanel: !view.epicsPanel })}>Эпики</Button>
|
||||
</AgileToolbar>
|
||||
<div className={`agile-backlog${view.epicsPanel ? " has-epics" : ""}`}>
|
||||
{view.epicsPanel && <EpicsPanel {...epicProps} />}
|
||||
<div className="agile-backlog__main">
|
||||
{live.map((s) => (
|
||||
<SprintSection key={s.id} s={s} collapsed={isCollapsed(String(s.id))} onToggle={() => toggle(String(s.id))}
|
||||
shown={visible(s.issues, filter)} />
|
||||
))}
|
||||
<Panel as="section" className="agile-section" aria-labelledby={backlogId}>
|
||||
<header className="agile-section__head">
|
||||
<h2 id={backlogId} className="agile-section__title">Бэклог</h2>
|
||||
<span className="agile-section__meta">{`${d.backlog?.length ?? 0} ${tasksWord(d.backlog?.length ?? 0)}`}</span>
|
||||
<Toggle titleId={backlogId} bodyId={`${backlogId}-body`} collapsed={isCollapsed("backlog")} onToggle={() => toggle("backlog")}>Бэклог</Toggle>
|
||||
<span className="agile-section__meta">{`${backlog.length} ${tasksWord(backlog.length)}`}</span>
|
||||
<Shown shown={shownBacklog} total={backlog.length} />
|
||||
</header>
|
||||
<Issues issues={d.backlog ?? []} />
|
||||
<div id={`${backlogId}-body`} hidden={isCollapsed("backlog")}>
|
||||
{!isCollapsed("backlog") && <Issues issues={shownBacklog} total={backlog.length} />}
|
||||
</div>
|
||||
</Panel>
|
||||
{past.length > 0 && (
|
||||
<details className="agile-past">
|
||||
<summary>{`Прошлые спринты (${past.length})`}</summary>
|
||||
{past.map((s) => <PastSprint key={s.id} s={s} />)}
|
||||
{past.map((s) => <PastSprint key={s.id} s={s} filter={filter} />)}
|
||||
</details>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}}
|
||||
|
||||
@ -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 (
|
||||
<div className="agile-columns">
|
||||
{columns.map((c, i) => (
|
||||
{columns.map((c, i) => gone.includes(c.name) ? null : (
|
||||
<section key={c.name + i} className={"agile-col" + (c.extra ? " is-extra" : "")} aria-label={c.name}>
|
||||
<header className="agile-col__head">
|
||||
<h2 className="agile-col__name">{c.name}</h2>
|
||||
<h3 className="agile-col__name">{c.name}</h3>
|
||||
<span className="agile-col__count">{c.issues.length}</span>
|
||||
</header>
|
||||
{lastCaption && i === lastIdx && <p className="agile-col__caption">{lastCaption}</p>}
|
||||
|
||||
26
web/src/modules/jira/agile/ColumnsMenu.tsx
Normal file
26
web/src/modules/jira/agile/ColumnsMenu.tsx
Normal file
@ -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 (
|
||||
<Dropdown className="agile-columns-menu" button={({ open, toggle }) => (
|
||||
<button type="button" className="ui-input multi-btn" aria-label={gone.length > 0 ? `Колонки, скрыто ${gone.length}` : "Колонки"} aria-haspopup="true" aria-expanded={open} onClick={toggle}>
|
||||
<span className="lbl">Колонки</span>{gone.length > 0 && <Badge>{gone.length}</Badge>}<IconChevronDown size={16} />
|
||||
</button>
|
||||
)}>
|
||||
{() => (
|
||||
<div className="dd-list" role="group" aria-label="Колонки">
|
||||
{names.map((n) => {
|
||||
const on = !gone.includes(n);
|
||||
return <Checkbox key={n} className="dd-item" label={n} checked={on} disabled={on && visible.length === 1}
|
||||
onChange={(e) => flip(n, e.target.checked)} />;
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</Dropdown>
|
||||
);
|
||||
}
|
||||
49
web/src/modules/jira/agile/EpicsPanel.tsx
Normal file
49
web/src/modules/jira/agile/EpicsPanel.tsx
Normal file
@ -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<Props, "entries" | "value" | "hideDone">): EpicEntry[] {
|
||||
const list = entries.filter((e) => !(hideDone && e.done && e.key !== value));
|
||||
if (value && value !== NONE && !entries.some((e) => e.key === value)) list.push({ key: value, title: value, count: 0, done: false });
|
||||
return list;
|
||||
}
|
||||
|
||||
export function EpicsPanel(props: Props) {
|
||||
const { value, onChange } = props;
|
||||
const item = (v: string, title: string, key?: string, count?: number) => (
|
||||
<li key={v}>
|
||||
<button type="button" className="agile-epics__item" aria-pressed={value === v} onClick={() => onChange(value === v ? "" : v)}>
|
||||
<span className="agile-epics__title">{title}</span>
|
||||
{key && key !== title && <span className="agile-epics__key">{key}</span>}
|
||||
{count !== undefined && <span className="agile-epics__count">{count}</span>}
|
||||
</button>
|
||||
</li>
|
||||
);
|
||||
return (
|
||||
<aside className="agile-epics" aria-label="Эпики">
|
||||
<ul className="agile-epics__list">
|
||||
<li>
|
||||
<button type="button" className="agile-epics__item" aria-pressed={value === ""} onClick={() => onChange("")}>
|
||||
<span className="agile-epics__title">Все задачи</span>
|
||||
</button>
|
||||
</li>
|
||||
{listed(props).map((e) => item(e.key, e.title, e.key, e.count))}
|
||||
{item(NONE, "Без эпика")}
|
||||
</ul>
|
||||
</aside>
|
||||
);
|
||||
}
|
||||
|
||||
// forced — панель «Эпики» скрыта, а выбранный эпик должен оставаться виден и снимаем (M-3)
|
||||
export function EpicSelect(props: Props & { forced?: boolean }) {
|
||||
return (
|
||||
<Select aria-label="Эпик" className={`agile-epic-select${props.forced ? " is-forced" : ""}`} value={props.value} onChange={(e) => props.onChange(e.target.value)}>
|
||||
<option value="">Все задачи</option>
|
||||
{listed(props).map((e) => <option key={e.key} value={e.key}>{e.title}</option>)}
|
||||
<option value={NONE}>Без эпика</option>
|
||||
</Select>
|
||||
);
|
||||
}
|
||||
@ -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");
|
||||
});
|
||||
});
|
||||
|
||||
@ -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 (
|
||||
<div data-testid="page-kanban" className="agile agile--wide">
|
||||
<PageHeader title="Kanban" actions={<RefreshButton />} />
|
||||
@ -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 (
|
||||
<>
|
||||
<AgileToolbar filter={kfilter} setQ={setQ} set={set} onReset={reset} showHideDone={false}
|
||||
assignees={assigneeOptions([...(d.board_issues ?? []), ...(d.backlog ?? [])], view.assignee)}>
|
||||
<ColumnsMenu names={allNames} hidden={view.hiddenColumns} onChange={(hiddenColumns) => set({ hiddenColumns })} />
|
||||
</AgileToolbar>
|
||||
{withBacklog && (
|
||||
<details className="agile-past">
|
||||
<summary>{`Бэклог (${backlog.length})`}</summary>
|
||||
<ul className="agile-list">{backlog.map((i) => <AgileIssueRow key={i.key} issue={i} />)}</ul>
|
||||
</details>
|
||||
)}
|
||||
<BoardColumns columns={columnsOf(withBacklog && cols ? cols.slice(1) : cols, issues)} lastCaption="за 14 дней" />
|
||||
<BoardColumns columns={views} lastCaption="за 14 дней" hidden={view.hiddenColumns} />
|
||||
</>
|
||||
);
|
||||
}}
|
||||
|
||||
@ -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,46 +96,221 @@ describe("«Спринт»", () => {
|
||||
expect(await within(await page()).findByText("срок прошёл")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("два активных: переключатель «Спринты», выбор — ?sprint=<id> и его задачи", 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<HTMLButtonElement>("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 () => {
|
||||
hub(agile());
|
||||
open("/app/jira/sprint?project=AWPCA&sprint=abc");
|
||||
expect(await within(await page()).findByText("Тема AURORA-1")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("нет активного — «Нет активного спринта» и ссылка «Бэклог»", async () => {
|
||||
hub(agile([sprint({ id: 103, name: "F", state: "future" })]));
|
||||
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");
|
||||
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();
|
||||
const cols = Array.from(p.querySelectorAll(".agile-col"));
|
||||
expect(cols.map((c) => c.querySelector(".agile-col__count")!.textContent)).toEqual(["1", "0", "1"]);
|
||||
});
|
||||
|
||||
it("заголовки колонок — h3 (T10.3)", async () => {
|
||||
hub(agile());
|
||||
open("/app/jira/sprint?project=AWPCA");
|
||||
const p = await page();
|
||||
await within(p).findByText("Тема AURORA-1");
|
||||
expect(within(p).getAllByRole("heading", { level: 3 }).map((h) => h.textContent)).toEqual(["К выполнению", "Готово", "Без колонки"]);
|
||||
});
|
||||
|
||||
it("«Скрытая колонка»: снять «Готово» — колонки нет, на «Колонки» — 1; после повторного монтажа так же", async () => {
|
||||
hub(agile());
|
||||
open("/app/jira/sprint?project=AWPCA");
|
||||
let p = await page();
|
||||
await within(p).findByText("Тема AURORA-1");
|
||||
await userEvent.click(within(p).getByRole("button", { name: /^Колонки/ }));
|
||||
await userEvent.click(within(screen.getByRole("group", { name: "Колонки" })).getByRole("checkbox", { name: "Готово" }));
|
||||
expect(colNames(p)).toEqual(["К выполнению", "Без колонки"]);
|
||||
expect(within(p).getByRole("button", { name: /^Колонки/ })).toHaveTextContent("Колонки1");
|
||||
cleanup();
|
||||
open("/app/jira/sprint?project=AWPCA");
|
||||
p = await page();
|
||||
await within(p).findByText("Тема AURORA-1");
|
||||
expect(colNames(p)).toEqual(["К выполнению", "Без колонки"]);
|
||||
expect(within(p).getByRole("button", { name: /^Колонки/ })).toHaveTextContent("Колонки1");
|
||||
});
|
||||
|
||||
it("последнюю видимую колонку снять нельзя", async () => {
|
||||
hub(agile());
|
||||
open("/app/jira/sprint?project=AWPCA");
|
||||
const p = await page();
|
||||
await within(p).findByText("Тема AURORA-1");
|
||||
await userEvent.click(within(p).getByRole("button", { name: /^Колонки/ }));
|
||||
const g = within(screen.getByRole("group", { name: "Колонки" }));
|
||||
await userEvent.click(g.getByRole("checkbox", { name: "Готово" }));
|
||||
await userEvent.click(g.getByRole("checkbox", { name: "Без колонки" }));
|
||||
expect(colNames(p)).toEqual(["К выполнению"]);
|
||||
expect(g.getByRole("checkbox", { name: "К выполнению" })).toBeDisabled();
|
||||
});
|
||||
|
||||
it("скрытая колонка, которой больше нет, не ломает меню и не считается", async () => {
|
||||
localStorage.setItem("sdlc-hub-agile", JSON.stringify({ AWPCA: { sprint: { hiddenColumns: ["Удалённая"] } } }));
|
||||
hub(agile());
|
||||
open("/app/jira/sprint?project=AWPCA");
|
||||
const p = await page();
|
||||
await within(p).findByText("Тема AURORA-1");
|
||||
expect(colNames(p)).toEqual(["К выполнению", "Готово", "Без колонки"]);
|
||||
const btn = within(p).getByRole("button", { name: /^Колонки/ });
|
||||
expect(btn).toHaveTextContent(/^Колонки$/);
|
||||
await userEvent.click(btn);
|
||||
expect(within(screen.getByRole("group", { name: "Колонки" })).getAllByRole("checkbox")).toHaveLength(3);
|
||||
});
|
||||
|
||||
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 () => {
|
||||
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();
|
||||
expect(p.getByRole("link", { name: "Бэклог" })).toHaveAttribute("href", "/app/jira/backlog?project=AWPCA");
|
||||
expect(p.queryByRole("button", { name: /^Колонки/ })).toBeNull(); // M-7: доски нет — меню не нужно
|
||||
});
|
||||
|
||||
it("T7.2: меню «Колонки» строится по задачам без фильтров — «Без колонки» не пропадает из меню вместе с поиском", async () => {
|
||||
localStorage.setItem("sdlc-hub-agile", JSON.stringify({ AWPCA: { sprint: { hiddenColumns: ["Без колонки"] } } }));
|
||||
hub(agile());
|
||||
open("/app/jira/sprint?project=AWPCA");
|
||||
const p = within(await page());
|
||||
await p.findByText("Тема AURORA-1");
|
||||
await userEvent.type(p.getByRole("searchbox", { name: "Поиск по ключу или теме" }), "AURORA-1");
|
||||
expect(colNames(await page())).toEqual(["К выполнению", "Готово"]);
|
||||
const btn = p.getByRole("button", { name: "Колонки, скрыто 1" });
|
||||
await userEvent.click(btn);
|
||||
const g = within(screen.getByRole("group", { name: "Колонки" }));
|
||||
expect(g.getByRole("checkbox", { name: "Без колонки" })).not.toBeChecked();
|
||||
await userEvent.click(g.getByRole("checkbox", { name: "Готово" }));
|
||||
expect(JSON.parse(localStorage.getItem("sdlc-hub-agile")!).AWPCA.sprint.hiddenColumns).toEqual(["Готово", "Без колонки"]);
|
||||
});
|
||||
|
||||
it("агент без досок — «Нужен агент v15 и обновление из Jira» и «Обновить из Jira»", async () => {
|
||||
|
||||
@ -1,16 +1,21 @@
|
||||
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 { ColumnsMenu } from "./ColumnsMenu.tsx";
|
||||
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, 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 (
|
||||
<header className="agile-section__head">
|
||||
<h2 className="agile-section__title">{s.name}</h2>
|
||||
@ -23,35 +28,41 @@ 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 (
|
||||
<div data-testid="page-sprint" className="agile agile--wide">
|
||||
<PageHeader title="Спринт" actions={<RefreshButton />} />
|
||||
<AgileGate project={project} kind="scrum">
|
||||
{(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 (
|
||||
<Panel className="agile-section">
|
||||
<p className="agile__empty">Нет активного спринта</p>
|
||||
<Link className="agile__link" to={`/jira/backlog?project=${encodeURIComponent(project.label)}`}>Бэклог</Link>
|
||||
</Panel>
|
||||
);
|
||||
}
|
||||
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);
|
||||
const shown = issues.filter((i) => matches(i, filter));
|
||||
const cols = columnsOf(d.board?.columns ?? null, shown);
|
||||
const allNames = columnsOf(d.board?.columns ?? null, issues).map((c) => c.name); // меню — по задачам без фильтров (T7.2)
|
||||
// выбор спринтов — в адресе, без новой записи истории
|
||||
const pick = (next: number[]) => setParams((p) => { const n = new URLSearchParams(p); n.set("sprint", next.join(",")); return n; }, { replace: true });
|
||||
return (
|
||||
<>
|
||||
{active.length > 1 && (
|
||||
<Segmented label="Спринты" value={String(sprint.id)}
|
||||
options={active.map((s) => ({ value: String(s.id), label: s.name, href: `/jira/sprint?project=${proj}&sprint=${s.id}` }))} />
|
||||
<AgileToolbar filter={filter} setQ={setQ} set={set} onReset={reset} assignees={assigneeOptions(issues, view.assignee)} showHideDone>
|
||||
{selectable.length > 0 && <SprintPicker sprints={selectable} selected={ids} onChange={pick} />}
|
||||
{chosen.length > 0 && <ColumnsMenu names={allNames} hidden={view.hiddenColumns} onChange={(hiddenColumns) => set({ hiddenColumns })} />}
|
||||
</AgileToolbar>
|
||||
{chosen.length === 0 ? (
|
||||
<Panel className="agile-section">
|
||||
<p className="agile__empty">Нет активного спринта</p>
|
||||
<Link className="agile__link" to={`/jira/backlog?project=${encodeURIComponent(project.label)}`}>Бэклог</Link>
|
||||
</Panel>
|
||||
) : (
|
||||
<>
|
||||
{chosen.map((s) => <SprintHead key={s.id} s={s} />)}
|
||||
<BoardColumns columns={cols} hidden={view.hiddenColumns} />
|
||||
</>
|
||||
)}
|
||||
<SprintHead s={sprint} />
|
||||
<BoardColumns columns={columnsOf(d.board?.columns ?? null, sprint.issues)} />
|
||||
</>
|
||||
);
|
||||
}}
|
||||
|
||||
31
web/src/modules/jira/agile/SprintPicker.tsx
Normal file
31
web/src/modules/jira/agile/SprintPicker.tsx
Normal file
@ -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 === 0 ? "Спринты" : `Спринты: ${chosen.length}`;
|
||||
const flip = (id: number, on: boolean) => onChange(sprints.map((s) => s.id).filter((x) => (x === id ? on : selected.includes(x))));
|
||||
return (
|
||||
<Dropdown className="agile-sprint-picker" button={({ open, toggle }) => (
|
||||
<button type="button" className="ui-input multi-btn" aria-label={chosen.length === 1 ? `Спринты: ${label}` : label} aria-haspopup="true" aria-expanded={open} onClick={toggle}>
|
||||
<span className="lbl">{label}</span><IconChevronDown size={16} />
|
||||
</button>
|
||||
)}>
|
||||
{() => (
|
||||
<div className="dd-list" role="group" aria-label="Спринты">
|
||||
{sprints.map((s) => {
|
||||
const on = selected.includes(s.id);
|
||||
return (
|
||||
<Checkbox key={s.id} className="dd-item" checked={on} disabled={on && selected.length === 1}
|
||||
onChange={(e) => flip(s.id, e.target.checked)}
|
||||
label={<>{s.name} <Badge>{s.state === "active" ? "активный" : "будущий"}</Badge></>} />
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</Dropdown>
|
||||
);
|
||||
}
|
||||
35
web/src/modules/jira/agile/TypeIcon.test.tsx
Normal file
35
web/src/modules/jira/agile/TypeIcon.test.tsx
Normal file
@ -0,0 +1,35 @@
|
||||
import { render, screen } from "@testing-library/react";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { TypeIcon } from "./TypeIcon.tsx";
|
||||
|
||||
describe("TypeIcon — значок типа по образцу Jira", () => {
|
||||
it.each([
|
||||
["Эпик", "epic"],
|
||||
["Story", "story"],
|
||||
["Задача", "task"],
|
||||
["Ошибка", "bug"],
|
||||
["Подзадача", "subtask"],
|
||||
["Риск", "other"],
|
||||
])("«%s» → вид %s: role=img, aria-label — исходное имя, класс agile-type--<вид>", (type, kind) => {
|
||||
const { container } = render(<TypeIcon type={type} />);
|
||||
const el = screen.getByRole("img", { name: type });
|
||||
expect(el).toHaveAttribute("aria-label", type);
|
||||
expect(el).toHaveClass("agile-type", `agile-type--${kind}`);
|
||||
expect(container.querySelector("svg")).not.toBeNull();
|
||||
});
|
||||
|
||||
it("пустая строка и пробелы — «Тип не указан», у значка есть доступное имя", () => {
|
||||
render(<TypeIcon type=" " />);
|
||||
const el = screen.getByRole("img", { name: "Тип не указан" });
|
||||
expect(el).toHaveClass("agile-type--other");
|
||||
render(<TypeIcon type="" />);
|
||||
expect(screen.getAllByRole("img", { name: "Тип не указан" })).toHaveLength(2);
|
||||
});
|
||||
|
||||
it("null — «Тип не указан», не «Задача»", () => {
|
||||
render(<TypeIcon type={null} />);
|
||||
const el = screen.getByRole("img", { name: "Тип не указан" });
|
||||
expect(el).toHaveAttribute("aria-label", "Тип не указан");
|
||||
expect(el).toHaveClass("agile-type--other");
|
||||
});
|
||||
});
|
||||
@ -1,9 +1,19 @@
|
||||
import { IconBoard, IconCritical, IconCheck } from "../../../ui/index.ts";
|
||||
import { IconBookmark, IconCheck, IconCircle, IconCritical, IconDot, IconSquares } from "../../../ui/index.ts";
|
||||
import { typeKind, type TypeKind } from "./model.ts";
|
||||
|
||||
// значок типа задачи: эпик, ошибка, прочее — задача; тип — в aria-label и title
|
||||
// значок типа задачи по образцу Jira: эпик — молния, история — закладка, задача — галочка, ошибка — круг, подзадача — два квадрата
|
||||
const ICONS: Record<TypeKind, typeof IconCritical> = {
|
||||
epic: IconCritical, story: IconBookmark, task: IconCheck, bug: IconCircle, subtask: IconSquares, other: IconDot,
|
||||
};
|
||||
|
||||
// тип — в aria-label и title; без типа — «Тип не указан»
|
||||
export function TypeIcon({ type }: { type: string | null }) {
|
||||
const label = type ?? "Задача";
|
||||
const kind = /^(Эпик|Epic|Фича)$/i.test(label) ? "epic" : /^(Ошибка|Bug)$/i.test(label) ? "bug" : "task";
|
||||
const Icon = kind === "epic" ? IconBoard : kind === "bug" ? IconCritical : IconCheck;
|
||||
return <span className={`agile-type agile-type--${kind}`} role="img" aria-label={label} title={label}><Icon size={16} /></span>;
|
||||
const kind = typeKind(type);
|
||||
const label = type?.trim() || "Тип не указан";
|
||||
const Icon = ICONS[kind];
|
||||
return (
|
||||
<span className={`agile-type agile-type--${kind}`} role="img" aria-label={label} title={label}>
|
||||
<Icon size={12} />
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
@ -23,10 +23,13 @@
|
||||
.agile-row__assignee{flex:0 1 160px;min-width:0;color:var(--color-text-secondary);overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
|
||||
.agile-row__sp{flex:0 0 32px;text-align:right;color:var(--color-text-secondary)}
|
||||
.agile-row__mark{flex:0 0 auto;font-size:var(--fs-caption);color:var(--color-status-warning)}
|
||||
.agile-type{display:inline-flex;flex:0 0 auto;color:var(--color-text-secondary)}
|
||||
.agile-type--epic{color:var(--k-ui)}
|
||||
.agile-type--bug{color:var(--color-status-error)}
|
||||
.agile-type--task{color:var(--color-status-success)}
|
||||
.agile-type{display:inline-flex;align-items:center;justify-content:center;flex:0 0 auto;width:16px;height:16px;border-radius:var(--radius-xs);
|
||||
color:var(--color-text-on-primary);background:var(--color-text-secondary)}
|
||||
.agile-type--epic{background:var(--k-ui)}
|
||||
.agile-type--story{background:var(--color-status-success)}
|
||||
.agile-type--task{background:var(--k-dev)}
|
||||
.agile-type--subtask{background:var(--k-analytics)}
|
||||
.agile-type--bug{background:var(--color-status-error)}
|
||||
.agile-past{display:flex;flex-direction:column;gap:var(--space-2)}
|
||||
.agile-past>summary{cursor:pointer;font-weight:600;padding:var(--space-2) 0}
|
||||
.agile-past__sprint{background:var(--color-bg-surface);border:1px solid var(--color-border-default);border-radius:var(--radius-lg);padding:var(--space-2) var(--space-4)}
|
||||
@ -35,17 +38,22 @@
|
||||
.agile-row{flex-wrap:wrap}
|
||||
.agile-row__assignee{flex-basis:100%}
|
||||
}
|
||||
/* «Спринт»: страница во всю ширину, колонки прокручиваются внутри контейнера */
|
||||
.agile--wide{min-width:0}
|
||||
.agile-columns{display:flex;align-items:flex-start;gap:var(--space-3);overflow-x:auto;padding-bottom:var(--space-2);min-width:0}
|
||||
.agile-col{flex:0 0 280px;min-width:0;display:flex;flex-direction:column;gap:var(--space-2);
|
||||
/* «Спринт» и «Kanban»: доска занимает высоту окна под шапкой и панелью фильтров (ограничения max-width у .page--board нет);
|
||||
колонки одной высоты, список колонки прокручивается внутри, горизонтальная прокрутка — у .agile-columns и в окне (T10.3) */
|
||||
.agile--wide{box-sizing:border-box;min-width:0;height:calc(100dvh - 32px)} /* 32px — нижний отступ .page */
|
||||
.agile--wide>*{flex:0 0 auto}
|
||||
.agile--wide>.agile-columns{flex:1 1 0}
|
||||
.agile--wide>.agile-past[open]{max-height:40dvh;overflow:auto}
|
||||
.agile-columns{display:flex;align-items:stretch;gap:var(--space-3);overflow-x:auto;padding-bottom:var(--space-2);min-width:0;min-height:360px}
|
||||
.agile-col{flex:0 0 280px;min-width:0;min-height:0;display:flex;flex-direction:column;gap:var(--space-2);
|
||||
background:var(--color-bg-subtle);border:1px solid var(--color-border-default);border-radius:var(--radius-xl);padding:var(--space-3)}
|
||||
@media (max-width:639px){.agile--wide{height:calc(100dvh - 56px - 32px)}.agile--wide>.agile-columns{min-height:240px}} /* 56px — закреплённая строка «Меню» */
|
||||
.agile-col.is-extra{border-style:dashed}
|
||||
.agile-col__head{display:flex;align-items:center;justify-content:space-between;gap:var(--space-2)}
|
||||
.agile-col__name{margin:0;font-size:var(--fs-body);line-height:var(--lh-body);font-weight:700;overflow-wrap:anywhere}
|
||||
.agile-col__count{font-size:var(--fs-caption);color:var(--color-text-secondary)}
|
||||
.agile-col__caption{margin:0;font-size:var(--fs-caption);color:var(--color-text-secondary)}
|
||||
.agile-col__list{list-style:none;margin:0;padding:0;display:flex;flex-direction:column;gap:var(--space-2)}
|
||||
.agile-col__list{list-style:none;margin:0;padding:0;display:flex;flex-direction:column;gap:var(--space-2);flex:1 1 auto;min-height:0;overflow-y:auto}
|
||||
.agile-card{display:flex;flex-direction:column;gap:var(--space-2);min-width:0;padding:var(--space-3);cursor:pointer;
|
||||
background:var(--color-bg-surface);border:1px solid var(--color-border-default);border-radius:var(--radius-lg);
|
||||
font-size:var(--fs-body);line-height:var(--lh-body)}
|
||||
@ -55,3 +63,32 @@
|
||||
.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}
|
||||
.agile-toolbar__field{display:inline-flex;align-items:center;gap:var(--space-2);margin:0;font-size:var(--fs-body);color:var(--color-text-secondary)}
|
||||
.agile-toolbar .ui-select{width:auto;flex:0 1 220px;min-width:160px}
|
||||
.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}
|
||||
.agile-epic-select.is-forced{display:inline-flex}
|
||||
@container page (max-width: 899px){
|
||||
.agile-backlog.has-epics{grid-template-columns:minmax(0,1fr)}
|
||||
.agile-epics{display:none}
|
||||
.agile-epic-select{display:inline-flex}
|
||||
.agile-epics-toggle{display:none}
|
||||
}
|
||||
|
||||
85
web/src/modules/jira/agile/agilePref.test.ts
Normal file
85
web/src/modules/jira/agile/agilePref.test.ts
Normal file
@ -0,0 +1,85 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { DEFAULT_VIEW, type AgileView } from "./filters.ts";
|
||||
import { AGILE_KEY, readView, saveView } from "./agilePref.ts";
|
||||
|
||||
const view = (over: Partial<AgileView> = {}): AgileView => ({ ...DEFAULT_VIEW, ...over });
|
||||
const stored = () => JSON.parse(localStorage.getItem(AGILE_KEY) ?? "null");
|
||||
|
||||
beforeEach(() => localStorage.clear());
|
||||
afterEach(() => vi.restoreAllMocks());
|
||||
|
||||
describe("agilePref", () => {
|
||||
it("ключ хранилища — sdlc-hub-agile", () => {
|
||||
expect(AGILE_KEY).toBe("sdlc-hub-agile");
|
||||
});
|
||||
|
||||
it("нет ключа → DEFAULT_VIEW", () => {
|
||||
expect(readView("AWPCA", "backlog")).toEqual(DEFAULT_VIEW);
|
||||
});
|
||||
|
||||
it("запись AWPCA/backlog не меняет AWPCA/sprint и STOCKS/backlog", () => {
|
||||
saveView("STOCKS", "backlog", view({ assignee: "petrov" }));
|
||||
saveView("AWPCA", "sprint", view({ hideDone: true }));
|
||||
saveView("AWPCA", "backlog", view({ epic: "AURORA-10", collapsed: ["AURORA-10"] }));
|
||||
expect(readView("AWPCA", "backlog")).toEqual(view({ epic: "AURORA-10", collapsed: ["AURORA-10"] }));
|
||||
expect(readView("AWPCA", "sprint")).toEqual(view({ hideDone: true }));
|
||||
expect(readView("STOCKS", "backlog")).toEqual(view({ assignee: "petrov" }));
|
||||
expect(readView("AWPCA", "kanban")).toEqual(DEFAULT_VIEW);
|
||||
});
|
||||
|
||||
it("запись не хранит поиск q", () => {
|
||||
saveView("AWPCA", "backlog", { ...view(), q: "секрет" } as AgileView);
|
||||
expect(stored().AWPCA.backlog).not.toHaveProperty("q");
|
||||
});
|
||||
|
||||
it("мусор: «oops» → DEFAULT_VIEW", () => {
|
||||
localStorage.setItem(AGILE_KEY, "oops");
|
||||
expect(readView("AWPCA", "backlog")).toEqual(DEFAULT_VIEW);
|
||||
});
|
||||
|
||||
it("испорченный JSON: saveView перезаписывает мусор, readView возвращает сохранённое", () => {
|
||||
localStorage.setItem(AGILE_KEY, "oops");
|
||||
saveView("AWPCA", "backlog", view({ hideDone: true, epic: "AURORA-10" }));
|
||||
expect(readView("AWPCA", "backlog")).toEqual(view({ hideDone: true, epic: "AURORA-10" }));
|
||||
});
|
||||
|
||||
it("мусор: «[1]» → DEFAULT_VIEW", () => {
|
||||
localStorage.setItem(AGILE_KEY, "[1]");
|
||||
expect(readView("AWPCA", "backlog")).toEqual(DEFAULT_VIEW);
|
||||
});
|
||||
|
||||
it("мусор: {\"AWPCA\": 5} → DEFAULT_VIEW", () => {
|
||||
localStorage.setItem(AGILE_KEY, JSON.stringify({ AWPCA: 5 }));
|
||||
expect(readView("AWPCA", "backlog")).toEqual(DEFAULT_VIEW);
|
||||
});
|
||||
|
||||
it("поле неверного типа — дефолт поля, годные поля сохраняются", () => {
|
||||
localStorage.setItem(AGILE_KEY, JSON.stringify({ AWPCA: { backlog: { collapsed: "x", hideDone: "yes", epic: "AURORA-10" } } }));
|
||||
const v = readView("AWPCA", "backlog");
|
||||
expect(v.collapsed).toEqual([]);
|
||||
expect(v.hideDone).toBe(false);
|
||||
expect(v.epic).toBe("AURORA-10");
|
||||
});
|
||||
|
||||
it("массив с не-строками отбрасывается по элементам", () => {
|
||||
localStorage.setItem(AGILE_KEY, JSON.stringify({ AWPCA: { kanban: { hiddenColumns: ["Готово", 3, null] } } }));
|
||||
expect(readView("AWPCA", "kanban").hiddenColumns).toEqual(["Готово"]);
|
||||
});
|
||||
|
||||
it("epicsPanel: булево сохраняется; не-булево — дефолт true", () => {
|
||||
localStorage.setItem(AGILE_KEY, JSON.stringify({ AWPCA: { backlog: { epicsPanel: false } } }));
|
||||
expect(readView("AWPCA", "backlog").epicsPanel).toBe(false);
|
||||
localStorage.setItem(AGILE_KEY, JSON.stringify({ AWPCA: { backlog: { epicsPanel: 0 } } }));
|
||||
expect(readView("AWPCA", "backlog").epicsPanel).toBe(true);
|
||||
});
|
||||
|
||||
it("saveView не бросает, если setItem бросает", () => {
|
||||
vi.spyOn(Storage.prototype, "setItem").mockImplementation(() => { throw new Error("quota"); });
|
||||
expect(() => saveView("AWPCA", "backlog", view({ hideDone: true }))).not.toThrow();
|
||||
});
|
||||
|
||||
it("readView → DEFAULT_VIEW, если getItem бросает", () => {
|
||||
vi.spyOn(Storage.prototype, "getItem").mockImplementation(() => { throw new Error("blocked"); });
|
||||
expect(readView("AWPCA", "backlog")).toEqual(DEFAULT_VIEW);
|
||||
});
|
||||
});
|
||||
60
web/src/modules/jira/agile/agilePref.ts
Normal file
60
web/src/modules/jira/agile/agilePref.ts
Normal file
@ -0,0 +1,60 @@
|
||||
// запоминание вида страниц доски в localStorage: { "<метка>": { "backlog"|"sprint"|"kanban": AgileView } }
|
||||
// любая ошибка хранилища — значения по умолчанию, наружу не выходит
|
||||
import { DEFAULT_VIEW, type AgilePage, type AgileView } from "./filters.ts";
|
||||
|
||||
export const AGILE_KEY = "sdlc-hub-agile";
|
||||
|
||||
type Obj = Record<string, unknown>;
|
||||
const isObj = (v: unknown): v is Obj => typeof v === "object" && v !== null && !Array.isArray(v);
|
||||
const str = (v: unknown, d: string): string => (typeof v === "string" ? v : d);
|
||||
const bool = (v: unknown, d: boolean): boolean => (typeof v === "boolean" ? v : d);
|
||||
const strArr = (v: unknown): string[] => (Array.isArray(v) ? v.filter((x): x is string => typeof x === "string") : []);
|
||||
|
||||
// поле за полем: неверный тип — значение по умолчанию, годные поля сохраняются
|
||||
function parseView(raw: unknown): AgileView {
|
||||
if (!isObj(raw)) return { ...DEFAULT_VIEW, collapsed: [], hiddenColumns: [] };
|
||||
return {
|
||||
assignee: str(raw.assignee, DEFAULT_VIEW.assignee),
|
||||
epic: str(raw.epic, DEFAULT_VIEW.epic),
|
||||
hideDone: bool(raw.hideDone, DEFAULT_VIEW.hideDone),
|
||||
collapsed: strArr(raw.collapsed),
|
||||
hiddenColumns: strArr(raw.hiddenColumns),
|
||||
epicsPanel: bool(raw.epicsPanel, DEFAULT_VIEW.epicsPanel),
|
||||
};
|
||||
}
|
||||
|
||||
function readRoot(): Obj {
|
||||
const raw = localStorage.getItem(AGILE_KEY);
|
||||
if (raw === null) return {};
|
||||
const parsed: unknown = JSON.parse(raw);
|
||||
return isObj(parsed) ? parsed : {};
|
||||
}
|
||||
|
||||
export function readView(label: string, page: AgilePage): AgileView {
|
||||
try {
|
||||
const project = readRoot()[label];
|
||||
return parseView(isObj(project) ? project[page] : undefined);
|
||||
} catch {
|
||||
return parseView(undefined);
|
||||
}
|
||||
}
|
||||
|
||||
export function saveView(label: string, page: AgilePage, v: AgileView): void {
|
||||
try {
|
||||
let root: Obj;
|
||||
try {
|
||||
root = readRoot();
|
||||
} catch {
|
||||
root = {}; // испорченный JSON перезаписывается, иначе вид не сохранится никогда
|
||||
}
|
||||
const project = isObj(root[label]) ? root[label] : {};
|
||||
const view: AgileView = {
|
||||
assignee: v.assignee, epic: v.epic, hideDone: v.hideDone,
|
||||
collapsed: v.collapsed, hiddenColumns: v.hiddenColumns, epicsPanel: v.epicsPanel,
|
||||
};
|
||||
root[label] = { ...project, [page]: view };
|
||||
localStorage.setItem(AGILE_KEY, JSON.stringify(root));
|
||||
} catch {
|
||||
// хранилище недоступно или переполнено — вид просто не запоминается
|
||||
}
|
||||
}
|
||||
@ -19,3 +19,9 @@ export function columnsOf(columns: BoardColumn[] | null, issues: AgileIssue[]):
|
||||
if (rest.length) out.push({ name: NO_COLUMN, issues: rest, extra: true });
|
||||
return out;
|
||||
}
|
||||
|
||||
// скрытые колонки, которые есть на доске; если скрыты все (колонки переименовали) — не скрыто ничего, доска не пустеет молча
|
||||
export function effectiveHidden(names: string[], hidden: string[]): string[] {
|
||||
const gone = names.filter((n) => hidden.includes(n));
|
||||
return gone.length >= names.length ? [] : gone;
|
||||
}
|
||||
|
||||
226
web/src/modules/jira/agile/filters.test.ts
Normal file
226
web/src/modules/jira/agile/filters.test.ts
Normal file
@ -0,0 +1,226 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import type { AgileIssue, AgileResponse, AgileSprint } from "../api/types.ts";
|
||||
import {
|
||||
DEFAULT_VIEW, NONE, assigneeOptions, byRank, epicEntries, hasFilters, matches, parseSprintIds, selectableSprints, sprintIssues,
|
||||
} from "./filters.ts";
|
||||
|
||||
const iss = (key: string, over: Partial<AgileIssue> = {}): AgileIssue => ({
|
||||
key, summary: `Тема ${key}`, issue_type: "Задача", status: "В работе", priority: null, assignee: null,
|
||||
epic_key: null, story_points: null, rank: "0|a", done: false, ...over,
|
||||
});
|
||||
const sprint = (over: Partial<AgileSprint>): AgileSprint => ({
|
||||
id: 1, name: "S", state: "active", start_date: "2026-10-12T09:00:00.000+0300", end_date: "2026-10-26T09:00:00.000+0300",
|
||||
complete_date: null, goal: null, count: 0, count_done: 0, unestimated: 0, points: 0, points_done: 0, issues: [], ...over,
|
||||
});
|
||||
const F = { q: "", assignee: "", epic: "", hideDone: false };
|
||||
|
||||
describe("matches", () => {
|
||||
const a = iss("AURORA-1", { summary: "Тема про отчёт", assignee: "ivanov", epic_key: "AURORA-10" });
|
||||
it("пустой фильтр пропускает всё", () => {
|
||||
expect(matches(iss("X-1", { done: true }), F)).toBe(true);
|
||||
});
|
||||
it("q — подстрока без учёта регистра по key и summary, пробелы по краям отбрасываются", () => {
|
||||
expect(matches(iss("AURORA-1"), { ...F, q: " aurora-1 " })).toBe(true);
|
||||
expect(matches(iss("AURORA-10"), { ...F, q: " aurora-1 " })).toBe(true);
|
||||
expect(matches(iss("AURORA-2"), { ...F, q: " aurora-1 " })).toBe(false);
|
||||
expect(matches(a, { ...F, q: "тема" })).toBe(true);
|
||||
expect(matches(a, { ...F, q: "ОТЧЁТ" })).toBe(true);
|
||||
expect(matches(a, { ...F, q: "нет такого" })).toBe(false);
|
||||
expect(matches(iss("X-1", { summary: null }), { ...F, q: "x-1" })).toBe(true);
|
||||
});
|
||||
it("assignee: NONE — только null; точное совпадение иначе", () => {
|
||||
expect(matches(iss("A-1", { assignee: null }), { ...F, assignee: NONE })).toBe(true);
|
||||
expect(matches(iss("A-1", { assignee: "" }), { ...F, assignee: NONE })).toBe(false);
|
||||
expect(matches(iss("A-1", { assignee: "ivanov" }), { ...F, assignee: NONE })).toBe(false);
|
||||
expect(matches(a, { ...F, assignee: "ivanov" })).toBe(true);
|
||||
expect(matches(a, { ...F, assignee: "petrov" })).toBe(false);
|
||||
});
|
||||
it("epic: NONE — только epic_key === null; точное совпадение иначе", () => {
|
||||
expect(matches(iss("A-1", { epic_key: null }), { ...F, epic: NONE })).toBe(true);
|
||||
expect(matches(a, { ...F, epic: NONE })).toBe(false);
|
||||
expect(matches(a, { ...F, epic: "AURORA-10" })).toBe(true);
|
||||
expect(matches(a, { ...F, epic: "AURORA-11" })).toBe(false);
|
||||
});
|
||||
it("hideDone скрывает выполненные", () => {
|
||||
expect(matches(iss("A-1", { done: true }), { ...F, hideDone: true })).toBe(false);
|
||||
expect(matches(iss("A-1"), { ...F, hideDone: true })).toBe(true);
|
||||
});
|
||||
it("условия складываются по И", () => {
|
||||
expect(matches(a, { q: "aurora", assignee: "ivanov", epic: "AURORA-10", hideDone: true })).toBe(true);
|
||||
expect(matches(a, { q: "aurora", assignee: "ivanov", epic: "AURORA-11", hideDone: true })).toBe(false);
|
||||
expect(matches(iss("A-1", { done: true, assignee: "ivanov" }), { ...F, assignee: "ivanov", hideDone: true })).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("hasFilters", () => {
|
||||
it("пустой фильтр — false, любое поле — true", () => {
|
||||
expect(hasFilters(F)).toBe(false);
|
||||
expect(hasFilters({ ...F, q: "x" })).toBe(true);
|
||||
expect(hasFilters({ ...F, q: " " })).toBe(false);
|
||||
expect(hasFilters({ ...F, assignee: "ivanov" })).toBe(true);
|
||||
expect(hasFilters({ ...F, epic: NONE })).toBe(true);
|
||||
expect(hasFilters({ ...F, hideDone: true })).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("DEFAULT_VIEW и NONE", () => {
|
||||
it("значения по ТЗ", () => {
|
||||
expect(DEFAULT_VIEW).toEqual({ assignee: "", epic: "", hideDone: false, collapsed: [], hiddenColumns: [], epicsPanel: true });
|
||||
expect(NONE).toBe("-");
|
||||
});
|
||||
});
|
||||
|
||||
describe("assigneeOptions", () => {
|
||||
it("«все», «Без исполнителя», затем исполнители по алфавиту ru", () => {
|
||||
const issues = [iss("A-1", { assignee: "Яковлев" }), iss("A-2", { assignee: null }), iss("A-3", { assignee: "Афанасьев" }), iss("A-4", { assignee: "Афанасьев" })];
|
||||
expect(assigneeOptions(issues, "")).toEqual([
|
||||
{ value: "", label: "все" },
|
||||
{ value: NONE, label: "Без исполнителя" },
|
||||
{ value: "Афанасьев", label: "Афанасьев" },
|
||||
{ value: "Яковлев", label: "Яковлев" },
|
||||
]);
|
||||
});
|
||||
it("current, которого нет среди исполнителей, — в конце с подписью = значению", () => {
|
||||
const opts = assigneeOptions([iss("A-1", { assignee: "ivanov" })], "gone");
|
||||
expect(opts.at(-1)).toEqual({ value: "gone", label: "gone" });
|
||||
expect(opts.map((o) => o.value)).toEqual(["", NONE, "ivanov", "gone"]);
|
||||
});
|
||||
it("current, который есть, не дублируется", () => {
|
||||
const opts = assigneeOptions([iss("A-1", { assignee: "ivanov" })], "ivanov");
|
||||
expect(opts.map((o) => o.value)).toEqual(["", NONE, "ivanov"]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("epicEntries", () => {
|
||||
// сценарий «Фильтр по эпику»: AURORA-10 — 3 в бэклоге и 2 в активном спринте → count 5
|
||||
const epic = iss("AURORA-10", { issue_type: "Эпик", summary: "Большой эпик", done: false, rank: "0|b" });
|
||||
const data = (over: Partial<AgileResponse> = {}): AgileResponse => ({
|
||||
board: null,
|
||||
sprints: [
|
||||
sprint({ id: 1, state: "active", issues: [
|
||||
iss("AURORA-1", { epic_key: "AURORA-10" }), iss("AURORA-2", { epic_key: "AURORA-10" }),
|
||||
] }),
|
||||
sprint({ id: 2, state: "future", issues: [] }),
|
||||
],
|
||||
backlog: [
|
||||
iss("AURORA-20", { epic_key: "AURORA-10" }), iss("AURORA-21", { epic_key: "AURORA-10" }), iss("AURORA-22", { epic_key: "AURORA-10" }),
|
||||
iss("AURORA-23"), iss("AURORA-24"), iss("AURORA-25"), iss("AURORA-26"), iss("AURORA-27"),
|
||||
],
|
||||
board_issues: null, ...over,
|
||||
});
|
||||
|
||||
it("сценарий: строка эпика из бэклога, count 5 (3 бэклог + 2 активный спринт)", () => {
|
||||
const d = data({ backlog: [epic, ...data().backlog!] });
|
||||
const e = epicEntries(d);
|
||||
expect(e).toEqual([{ key: "AURORA-10", title: "Большой эпик", count: 5, done: false }]);
|
||||
});
|
||||
|
||||
it("эпик выполнен — done true", () => {
|
||||
const d = data({ backlog: [{ ...epic, done: true }] });
|
||||
expect(epicEntries(d)[0]).toMatchObject({ key: "AURORA-10", done: true });
|
||||
});
|
||||
|
||||
it("строки эпиков из спринтов и бэклога — без повторов по key", () => {
|
||||
const d = data({
|
||||
sprints: [sprint({ id: 1, state: "active", issues: [epic] })],
|
||||
backlog: [epic],
|
||||
});
|
||||
expect(epicEntries(d).map((e) => e.key)).toEqual(["AURORA-10"]);
|
||||
});
|
||||
|
||||
it("эпик, на который ссылаются, но строки нет — в конце, title = ключ, done = false", () => {
|
||||
const d = data({ backlog: [epic, iss("AURORA-40", { epic_key: "AURORA-99" })] });
|
||||
const e = epicEntries(d);
|
||||
expect(e.map((x) => x.key)).toEqual(["AURORA-10", "AURORA-99"]);
|
||||
expect(e[1]).toEqual({ key: "AURORA-99", title: "AURORA-99", count: 1, done: false });
|
||||
});
|
||||
|
||||
it("задачи прошлых спринтов не считаются в count; эпики в count не входят", () => {
|
||||
const d = data({
|
||||
sprints: [
|
||||
sprint({ id: 1, state: "active", issues: [iss("AURORA-1", { epic_key: "AURORA-10" })] }),
|
||||
sprint({ id: 9, state: "closed", issues: [iss("AURORA-90", { epic_key: "AURORA-10" }), iss("AURORA-91", { epic_key: "AURORA-10" })] }),
|
||||
],
|
||||
backlog: [epic],
|
||||
});
|
||||
expect(epicEntries(d)).toEqual([{ key: "AURORA-10", title: "Большой эпик", count: 1, done: false }]);
|
||||
});
|
||||
|
||||
it("задача в двух активных/будущих местах считается один раз", () => {
|
||||
const shared = iss("AURORA-1", { epic_key: "AURORA-10" });
|
||||
const d = data({
|
||||
sprints: [sprint({ id: 1, state: "active", issues: [shared] }), sprint({ id: 2, state: "future", issues: [shared] })],
|
||||
backlog: [epic, shared],
|
||||
});
|
||||
expect(epicEntries(d)[0].count).toBe(1);
|
||||
});
|
||||
|
||||
it("пустые данные — пустой список", () => {
|
||||
expect(epicEntries({ board: null, sprints: [], backlog: null, board_issues: null })).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("byRank", () => {
|
||||
it("по rank строкой, null в конце, затем по key", () => {
|
||||
const list = [iss("B-1", { rank: null }), iss("B-3", { rank: "0|b" }), iss("B-2", { rank: "0|a" }), iss("B-0", { rank: null })];
|
||||
expect(list.sort(byRank).map((i) => i.key)).toEqual(["B-2", "B-3", "B-0", "B-1"]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("sprintIssues", () => {
|
||||
it("задача в двух спринтах — одна запись; rank null в конце", () => {
|
||||
const moved = iss("AURORA-1", { rank: "0|a" });
|
||||
const res = sprintIssues([
|
||||
sprint({ id: 1, state: "active", issues: [moved, iss("AURORA-2", { rank: null })] }),
|
||||
sprint({ id: 2, state: "future", issues: [moved, iss("AURORA-3", { rank: "0|b" })] }),
|
||||
]);
|
||||
expect(res.map((i) => i.key)).toEqual(["AURORA-1", "AURORA-3", "AURORA-2"]);
|
||||
});
|
||||
it("пустой список спринтов — пусто", () => {
|
||||
expect(sprintIssues([])).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("selectableSprints", () => {
|
||||
it("закрытые отброшены; активные в порядке входа, затем будущие по start_date; без даты — последним", () => {
|
||||
const res = selectableSprints([
|
||||
sprint({ id: 1, state: "closed" }),
|
||||
sprint({ id: 2, state: "future", start_date: null }),
|
||||
sprint({ id: 3, state: "future", start_date: "2026-11-05T09:00:00.000+0300" }),
|
||||
sprint({ id: 4, state: "active" }),
|
||||
sprint({ id: 5, state: "future", start_date: "2026-11-01T09:00:00.000+0300" }),
|
||||
]);
|
||||
expect(res.map((s) => s.id)).toEqual([4, 5, 3, 2]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("parseSprintIds", () => {
|
||||
const sprints = [
|
||||
sprint({ id: 101, state: "closed" }),
|
||||
sprint({ id: 102, state: "active" }),
|
||||
sprint({ id: 103, state: "future" }),
|
||||
];
|
||||
it("id из строки, только среди selectableSprints", () => {
|
||||
expect(parseSprintIds("102,999", sprints)).toEqual([102]);
|
||||
expect(parseSprintIds("999,103", sprints)).toEqual([103]);
|
||||
});
|
||||
it("если не осталось ни одного допустимого id — все активные", () => {
|
||||
expect(parseSprintIds("999", sprints)).toEqual([102]);
|
||||
expect(parseSprintIds("101", sprints)).toEqual([102]);
|
||||
});
|
||||
it("токены — только десятичные цифры (1e2, 0x10, 1.0 отбрасываются)", () => {
|
||||
expect(parseSprintIds("1e2,0x66,102.0", sprints)).toEqual([102]);
|
||||
expect(parseSprintIds("1e2,103", sprints)).toEqual([103]);
|
||||
});
|
||||
it("порядок — как в selectableSprints, не как в строке", () => {
|
||||
expect(parseSprintIds("103,102", sprints)).toEqual([102, 103]);
|
||||
});
|
||||
it("«abc», null и пустая строка — все активные", () => {
|
||||
expect(parseSprintIds("abc", sprints)).toEqual([102]);
|
||||
expect(parseSprintIds(null, sprints)).toEqual([102]);
|
||||
expect(parseSprintIds("", sprints)).toEqual([102]);
|
||||
});
|
||||
it("будущий спринт по явному id", () => {
|
||||
expect(parseSprintIds("103", sprints)).toEqual([103]);
|
||||
});
|
||||
});
|
||||
104
web/src/modules/jira/agile/filters.ts
Normal file
104
web/src/modules/jira/agile/filters.ts
Normal file
@ -0,0 +1,104 @@
|
||||
// модель фильтров экранов доски (Спринт, Бэклог, Kanban): чистые функции без React и хранилища
|
||||
import type { AgileIssue, AgileResponse, AgileSprint } from "../api/types.ts";
|
||||
import { isEpic, jiraDate } from "./model.ts";
|
||||
|
||||
export type AgilePage = "backlog" | "sprint" | "kanban";
|
||||
|
||||
// сохраняемый вид страницы (хранилище — agilePref.ts); поиск q и выбор спринтов сюда не входят
|
||||
export interface AgileView {
|
||||
assignee: string; epic: string; hideDone: boolean; collapsed: string[]; hiddenColumns: string[]; epicsPanel: boolean;
|
||||
}
|
||||
export const DEFAULT_VIEW: AgileView = { assignee: "", epic: "", hideDone: false, collapsed: [], hiddenColumns: [], epicsPanel: true };
|
||||
|
||||
// значение «Без исполнителя» / «Без эпика»
|
||||
export const NONE = "-";
|
||||
|
||||
export interface IssueFilter { q: string; assignee: string; epic: string; hideDone: boolean }
|
||||
|
||||
// И всех условий; q — подстрока без учёта регистра в key или summary
|
||||
export function matches(i: AgileIssue, f: IssueFilter): boolean {
|
||||
const q = f.q.trim().toLowerCase();
|
||||
if (q && !i.key.toLowerCase().includes(q) && !(i.summary ?? "").toLowerCase().includes(q)) return false;
|
||||
if (f.assignee === NONE ? i.assignee !== null : f.assignee !== "" && i.assignee !== f.assignee) return false;
|
||||
if (f.epic === NONE ? i.epic_key !== null : f.epic !== "" && i.epic_key !== f.epic) return false;
|
||||
if (f.hideDone && i.done) return false;
|
||||
return true;
|
||||
}
|
||||
|
||||
export function hasFilters(f: IssueFilter): boolean {
|
||||
return f.q.trim() !== "" || f.assignee !== "" || f.epic !== "" || f.hideDone;
|
||||
}
|
||||
|
||||
export type Opt = { value: string; label: string };
|
||||
|
||||
// «все», «Без исполнителя», затем исполнители по алфавиту; current, которого нет в данных, — в конце (значение не теряется)
|
||||
export function assigneeOptions(issues: AgileIssue[], current: string): Opt[] {
|
||||
const names = [...new Set(issues.map((i) => i.assignee).filter((a): a is string => !!a))]
|
||||
.sort((a, b) => a.localeCompare(b, "ru"));
|
||||
const opts: Opt[] = [
|
||||
{ value: "", label: "все" },
|
||||
{ value: NONE, label: "Без исполнителя" },
|
||||
...names.map((n) => ({ value: n, label: n })),
|
||||
];
|
||||
if (current && !opts.some((o) => o.value === current)) opts.push({ value: current, label: current });
|
||||
return opts;
|
||||
}
|
||||
|
||||
export interface EpicEntry { key: string; title: string; count: number; done: boolean }
|
||||
|
||||
// порядок по rank (null в конце), затем по key
|
||||
export function byRank(a: AgileIssue, b: AgileIssue): number {
|
||||
if (a.rank === null && b.rank !== null) return 1;
|
||||
if (a.rank !== null && b.rank === null) return -1;
|
||||
if (a.rank !== null && b.rank !== null && a.rank !== b.rank) return a.rank < b.rank ? -1 : 1;
|
||||
return a.key < b.key ? -1 : a.key > b.key ? 1 : 0;
|
||||
}
|
||||
|
||||
// первое вхождение каждого key
|
||||
const uniqByKey = (list: AgileIssue[]): AgileIssue[] => {
|
||||
const seen = new Set<string>();
|
||||
return list.filter((i) => {
|
||||
if (seen.has(i.key)) return false;
|
||||
seen.add(i.key);
|
||||
return true;
|
||||
});
|
||||
};
|
||||
|
||||
// объединение задач спринтов без повторов по key, по rank
|
||||
export function sprintIssues(sprints: AgileSprint[]): AgileIssue[] {
|
||||
return uniqByKey(sprints.flatMap((s) => s.issues)).sort(byRank);
|
||||
}
|
||||
|
||||
// активные (в порядке входа), затем будущие по дате начала; без даты — в конце; закрытые не выбираются
|
||||
export function selectableSprints(sprints: AgileSprint[]): AgileSprint[] {
|
||||
const active = sprints.filter((s) => s.state === "active");
|
||||
const future = sprints.filter((s) => s.state === "future");
|
||||
const startMs = (s: AgileSprint) => jiraDate(s.start_date)?.getTime() ?? Infinity;
|
||||
return [...active, ...future.sort((a, b) => startMs(a) - startMs(b))];
|
||||
}
|
||||
|
||||
// id из «101,102» среди selectableSprints, в их порядке; недопустимые отбрасываются; если не осталось ни одного — все активные
|
||||
export function parseSprintIds(param: string | null, sprints: AgileSprint[]): number[] {
|
||||
const selectable = selectableSprints(sprints);
|
||||
const ids = new Set(
|
||||
(param ?? "").split(",").map((x) => x.trim()).filter((x) => /^\d+$/.test(x)).map(Number),
|
||||
);
|
||||
const picked = selectable.filter((s) => ids.has(s.id)).map((s) => s.id);
|
||||
if (picked.length > 0) return picked;
|
||||
return selectable.filter((s) => s.state === "active").map((s) => s.id);
|
||||
}
|
||||
|
||||
// строки-эпики из всех спринтов и бэклога (по rank, без повторов), затем ключи из epic_key без строки (по ключу);
|
||||
// count — задачи-не-эпики с этим epic_key в активных, будущих спринтах и бэклоге, без повторов по key
|
||||
export function epicEntries(d: AgileResponse): EpicEntry[] {
|
||||
const backlog = d.backlog ?? [];
|
||||
const live = [...d.sprints.filter((s) => s.state !== "closed").flatMap((s) => s.issues), ...backlog];
|
||||
const liveTasks = uniqByKey(live.filter((i) => !isEpic(i)));
|
||||
const rows = uniqByKey([...d.sprints.flatMap((s) => s.issues), ...backlog].filter(isEpic)).sort(byRank);
|
||||
const count = (key: string) => liveTasks.filter((t) => t.epic_key === key).length;
|
||||
|
||||
const entries: EpicEntry[] = rows.map((e) => ({ key: e.key, title: e.summary ?? e.key, count: count(e.key), done: e.done }));
|
||||
const known = new Set(rows.map((e) => e.key));
|
||||
const missing = [...new Set(liveTasks.map((t) => t.epic_key).filter((k): k is string => !!k && !known.has(k)))].sort();
|
||||
return [...entries, ...missing.map((key) => ({ key, title: key, count: count(key), done: false }))];
|
||||
}
|
||||
@ -1,6 +1,6 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import type { AgileBoard, AgileResponse } from "../api/types.ts";
|
||||
import { boardState, daysLeft, fmtDay, fmtRange, fmtSp, jiraDate, tasksWord } from "./model.ts";
|
||||
import { boardState, daysLeft, fmtDay, fmtRange, fmtSp, isEpic, jiraDate, tasksWord, typeKind } from "./model.ts";
|
||||
|
||||
const resp = (over: Partial<AgileBoard>): AgileResponse => ({
|
||||
board: { id: 4216, type: "scrum", columns: [{ name: "Готово", statuses: ["Готово"] }], kanban_backlog: null, fetched_at: "2026-10-24T10:00:00+00:00", ...over },
|
||||
@ -29,6 +29,23 @@ describe("даты и числа экранов доски", () => {
|
||||
expect([fmtSp(null), fmtSp(3), fmtSp(2.5)]).toEqual(["—", "3", "2.5"]);
|
||||
expect([1, 2, 5, 11, 21, 73].map(tasksWord)).toEqual(["задача", "задачи", "задач", "задач", "задача", "задачи"]);
|
||||
});
|
||||
it("fmtSp — до двух знаков без хвостов (M-6)", () => {
|
||||
expect([fmtSp(0.1 + 0.2), fmtSp(1 / 3), fmtSp(0)]).toEqual(["0.3", "0.33", "0"]);
|
||||
});
|
||||
it("typeKind — имена типов Jira, без учёта регистра", () => {
|
||||
const kinds = (types: (string | null)[]) => types.map(typeKind);
|
||||
expect(kinds(["Эпик", "epic", "Фича"])).toEqual(["epic", "epic", "epic"]);
|
||||
expect(kinds(["История", "Story"])).toEqual(["story", "story"]);
|
||||
expect(kinds(["Задача", "Task"])).toEqual(["task", "task"]);
|
||||
expect(kinds(["Ошибка", "BUG"])).toEqual(["bug", "bug"]);
|
||||
expect(kinds(["Подзадача", "Sub-task", "Subtask"])).toEqual(["subtask", "subtask", "subtask"]);
|
||||
expect(kinds([null, "", "Риск"])).toEqual(["other", "other", "other"]);
|
||||
});
|
||||
it("isEpic — по виду типа", () => {
|
||||
expect(isEpic({ issue_type: "Фича" })).toBe(true);
|
||||
expect(isEpic({ issue_type: null })).toBe(false);
|
||||
expect(isEpic({ issue_type: "История" })).toBe(false);
|
||||
});
|
||||
it("boardState — три состояния (design #23)", () => {
|
||||
expect(boardState({ board: null, sprints: [], backlog: null, board_issues: null })).toBe("no-data");
|
||||
expect(boardState(resp({ fetched_at: null, columns: null }))).toBe("no-data");
|
||||
|
||||
@ -22,7 +22,23 @@ export function daysLeft(endIso: string | null, now: Date): number | null {
|
||||
return Math.round((midnight(end) - midnight(now)) / 86_400_000);
|
||||
}
|
||||
|
||||
export const fmtSp = (n: number | null): string => (n === null ? "—" : String(n));
|
||||
// очки до двух знаков без хвостов: 0.1+0.2 → «0.3», 1/3 → «0.33» (M-6)
|
||||
export const fmtSp = (n: number | null): string => (n === null ? "—" : String(Number(n.toFixed(2))));
|
||||
|
||||
// вид типа задачи для значка и логики (имена Jira, без учёта регистра); прочее и пустое — other
|
||||
export type TypeKind = "epic" | "story" | "task" | "bug" | "subtask" | "other";
|
||||
const TYPE_KINDS: [RegExp, TypeKind][] = [
|
||||
[/^(Эпик|Epic|Фича)$/i, "epic"],
|
||||
[/^(История|Story)$/i, "story"],
|
||||
[/^(Задача|Task)$/i, "task"],
|
||||
[/^(Ошибка|Bug)$/i, "bug"],
|
||||
[/^(Подзадача|Sub-task|Subtask)$/i, "subtask"],
|
||||
];
|
||||
export function typeKind(type: string | null): TypeKind {
|
||||
const name = (type ?? "").trim();
|
||||
return TYPE_KINDS.find(([re]) => re.test(name))?.[1] ?? "other";
|
||||
}
|
||||
export const isEpic = (i: { issue_type: string | null }): boolean => typeKind(i.issue_type) === "epic";
|
||||
|
||||
// «1 задача», «2 задачи», «5 задач» (R-24)
|
||||
export function tasksWord(n: number): string {
|
||||
|
||||
44
web/src/modules/jira/agile/useAgileView.ts
Normal file
44
web/src/modules/jira/agile/useAgileView.ts
Normal file
@ -0,0 +1,44 @@
|
||||
// состояние фильтров страницы доски: вид (хранится по метке и странице) + поиск 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 обновляется при рендере: set/reset в одном событии должны видеть актуальный вид, а не замыкание прошлого рендера
|
||||
ref.current = cur;
|
||||
|
||||
const commit = useCallback((next: typeof state) => {
|
||||
ref.current = next;
|
||||
setState(next);
|
||||
}, []);
|
||||
|
||||
const set = useCallback((patch: Partial<AgileView>) => {
|
||||
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 };
|
||||
}
|
||||
@ -1,7 +1,7 @@
|
||||
import type { ReactNode } from "react";
|
||||
|
||||
// иконки дизайн-системы: React-SVG 24x24, штрих currentColor 1.5; без dangerouslySetInnerHTML
|
||||
export type IconProps = { size?: 16 | 20 };
|
||||
export type IconProps = { size?: 12 | 16 | 20 };
|
||||
|
||||
function make(children: ReactNode) {
|
||||
return ({ size = 20 }: IconProps) => (
|
||||
@ -47,3 +47,7 @@ export const IconBook = make(<><path d="M5 4.5A1.5 1.5 0 0 1 6.5 3H19v15H6.5A1.5
|
||||
export const IconSprint = make(<><circle cx="12" cy="12" r="8"/><path d="M12 7.5V12l3 2"/></>);
|
||||
export const IconBacklog = make(<><rect x="4" y="4" width="16" height="4.5" rx="1.5"/><rect x="4" y="10" width="16" height="4.5" rx="1.5"/><rect x="4" y="16" width="10" height="4" rx="1.5"/></>);
|
||||
export const IconKanban = make(<><rect x="4" y="4" width="4.5" height="16" rx="1.5"/><rect x="9.75" y="4" width="4.5" height="10" rx="1.5"/><rect x="15.5" y="4" width="4.5" height="13" rx="1.5"/></>);
|
||||
export const IconBookmark = make(<><path d="M6.5 4h11v16.5L12 16.5 6.5 20.5z"/></>);
|
||||
export const IconCircle = make(<><circle cx="12" cy="12" r="8"/></>);
|
||||
export const IconSquares = make(<><rect x="4" y="4" width="7" height="7" rx="1.5"/><rect x="13" y="13" width="7" height="7" rx="1.5"/></>);
|
||||
export const IconDot = make(<><circle cx="12" cy="12" r="2.5" fill="currentColor"/></>);
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user