Merge pull request 'Закрытые задачи: скрыть в списке, приглушить на доске; без статуса по факту в приложении (#57)' (#58) from feat/57-hide-closed into main
This commit is contained in:
commit
dde40973bd
@ -47,7 +47,7 @@
|
||||
- `mcp_server` не импортирует `queue`/`mirror`/`db` напрямую и ходит только через `client`; из `files` берёт только предел
|
||||
`MAX_SIZE` (файлы в хранилище кладёт хаб по `POST /files`).
|
||||
- `commands`, `mirror`, `queue` не знают про HTTP; HTTP живёт только в `api`, `client` и `relay` (реле — клиент своего же HTTP).
|
||||
- Веб-приложение (`web/`) ходит в хаб только через HTTP API (`web/src/api/client.ts`); словари `kind_labels`, `link_types`, `status_labels` берёт из `GET /settings` и своих копий не держит.
|
||||
- Веб-приложение (`web/`) ходит в хаб только через HTTP API (`web/src/api/client.ts`); словари `kind_labels`, `link_types` и набор закрытых статусов `closed_statuses` берёт из `GET /settings` и своих копий не держит.
|
||||
|
||||
## Открытые вопросы
|
||||
- Правки из UI в Jira (спринт, срок, связи, исполнитель) — change `viewer-edit`; страницы очереди, агента и настроек — позже.
|
||||
|
||||
@ -150,7 +150,7 @@ uv run jira-hub agent publish ../jira-agent/src/agent.py
|
||||
| `GET /agent/known-keys` | агент | ключи зеркала |
|
||||
| `GET /agent/release` | агент | опубликованная версия агента `{version, sha256, content}`; 404 `no_release` |
|
||||
| `GET /agent/files/{sha256}` | агент | файл вложения `{file, size, content}` (`content` — base64); 404 `no_file` |
|
||||
| `GET /issues` | клиент | фильтры `sprint`, `assignee`, `work_kind`, `fact_status`, `status`, `label`, `epic`, `neighbor`, `q`, `limit`, `offset` |
|
||||
| `GET /issues` | клиент | фильтры `sprint`, `assignee`, `work_kind`, `fact_status`, `status`, `label`, `epic`, `neighbor`, `q`, `hide_closed` (`1` — без задач в статусах из настройки `closed_statuses`, по умолчанию «Готово», «Снято»), `limit`, `offset` |
|
||||
| `GET /issues/{key}` | клиент | задача целиком |
|
||||
| `GET /board` | клиент | снимок для доски: все задачи с `fact_status`/`work_kind`, все связи, `last_sync` — последний успешный полный прогон |
|
||||
| `GET /`, `GET /links` | — | перенаправление на `/app/board` |
|
||||
@ -186,7 +186,10 @@ launchctl kickstart -k gui/$(id -u)/ru.aepif.jira-hub
|
||||
читает те же ключи `localStorage`, что прежняя страница. Разработка: `bash web/scripts/test-hub.sh` (тестовый хаб
|
||||
:8791 с синтетическими данными) и `cd web && npm run dev` (http://127.0.0.1:5173/app/).
|
||||
|
||||
Карточка задачи: в шапке — статус Jira, статус по факту, исполнитель, срок (просроченный — красным), спринт и эпик;
|
||||
Список задач по умолчанию скрывает закрытые (статус Jira из настройки `closed_statuses`, по умолчанию «Готово», «Снято»):
|
||||
флажок «скрыть закрытые», снятый — `closed=1` в адресе; на доске закрытые задачи и их связи приглушены.
|
||||
|
||||
Карточка задачи: в шапке — статус Jira, исполнитель, срок (просроченный — красным; у закрытой задачи пометки нет), спринт и эпик;
|
||||
связи сгруппированы «Зависит от» / «Блокирует» / «Связана с» / «Прочие» с темой и статусом связанной задачи;
|
||||
комментарии из Jira (агент v10+) новые сверху, «Скрыть» запоминается в браузере; заметки — режим чтения, «Править»
|
||||
открывает форму. Комментарии появляются после первого обновления из Jira агентом v10.
|
||||
|
||||
305
docs/superpowers/plans/2026-10-17-hide-closed.md
Normal file
305
docs/superpowers/plans/2026-10-17-hide-closed.md
Normal file
@ -0,0 +1,305 @@
|
||||
# Закрытые задачи: скрыть в списке, приглушить на доске — Implementation Plan
|
||||
|
||||
> **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:** закрытые задачи (статус Jira из настройки `closed_statuses`, по умолчанию «Готово», «Снято») скрыты в списке
|
||||
по умолчанию и приглушены на доске; «статуса по факту» в веб-приложении нет.
|
||||
|
||||
**Architecture:** хаб получает настройку `closed_statuses` и фильтр `GET /issues?hide_closed=1` (фильтр на сервере —
|
||||
список порциями по 50). Приложение берёт набор из `GET /settings` одним помощником `web/src/api/closed.ts`; список
|
||||
шлёт `hide_closed`, доска вешает классы при отрисовке (модель и golden-эталоны не меняются), карточка считает
|
||||
«просрочено» по статусу Jira. Поле `fact_status` уходит из кода приложения, в API и базе остаётся.
|
||||
|
||||
**Tech Stack:** Python 3.13 (stdlib http, SQLite), pytest; React 19 + TypeScript + Vite, TanStack Query, Vitest,
|
||||
Testing Library, Playwright.
|
||||
|
||||
**Spec:** `openspec/changes/hide-closed/` (proposal.md, design.md, specs/web-app, specs/hub-api, specs/data-model)
|
||||
|
||||
## Global Constraints
|
||||
|
||||
- Закрытые статусы по умолчанию — ровно `["Готово", "Снято"]`; сравнение — точное совпадение строки статуса.
|
||||
- `closed_statuses` не входит в `AGENT_KEYS`.
|
||||
- Задача без статуса (`NULL` / `""`) — не закрыта.
|
||||
- Без `closed_statuses` в настройках (или не список) — ничего не закрыто, сообщения «нет словаря» нет.
|
||||
- Адрес списка: по умолчанию параметра нет (закрытые скрыты); снятый флажок — `closed=1`; `closed` в `GET /issues` не
|
||||
уходит; `fact_status` из адреса игнорируется.
|
||||
- Выбранный в «Статус Jira» статус из `closed_statuses` — `hide_closed` не отправляется.
|
||||
- Доска: модель, видимая выборка, критический путь, проблемы, раскладка, экспорт JSON от приглушения не зависят —
|
||||
golden-эталоны `web/src/board/__fixtures__/*.expected.json` не перегенерировать.
|
||||
- `dangerouslySetInnerHTML` не появляется; новых зависимостей нет.
|
||||
- Язык комментариев, коммитов, подписей — русский; коммиты заканчиваются строкой
|
||||
`Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>`.
|
||||
- Зелёным считается только весь набор: `make check` (pytest + lint и тесты фронта) и `cd web && npm run e2e`.
|
||||
|
||||
## Review Focus
|
||||
|
||||
1. Список, когда настройки ещё не загружены, а в адресе `status=Готово`: таблица не должна навсегда остаться пустой —
|
||||
после загрузки настроек запрос уходит без `hide_closed` (тест в Task 2).
|
||||
2. Карточка-сосед, она же закрытая (`out` + `closed`): должна быть прозрачнее соседа, а не `.7` — порядок правил CSS
|
||||
(тест класса в Task 4, правило `.card.closed` ниже `.card.out` в `board.css`).
|
||||
3. Сохранение заметки у задачи с непустым `fact_status`: `PUT` не должен отправить `fact_status: null` (тест в Task 3).
|
||||
4. `PUT /settings {closed_statuses: "Готово"}` (строка вместо списка) — 422, а не поломка фильтра (тест в Task 1).
|
||||
5. Пустой `closed_statuses = []` — `hide_closed=1` ничего не скрывает и не падает на `NOT IN ()` (тест в Task 1).
|
||||
|
||||
---
|
||||
|
||||
### Task 1: Хаб — настройка `closed_statuses` и фильтр `hide_closed`
|
||||
|
||||
**Files:**
|
||||
- Modify: `src/jira_hub/settings.py` (`DEFAULTS`)
|
||||
- Modify: `src/jira_hub/mirror.py` (`list_issues`)
|
||||
- Modify: `src/jira_hub/api.py` (`ISSUE_FILTERS`, `h_issues`)
|
||||
- Modify: `README.md:153` (строка `GET /issues` — добавить `hide_closed`), `ARCHITECTURE.md:50` (словари из
|
||||
`GET /settings` — добавить `closed_statuses`, убрать `status_labels`: приложение его больше не читает)
|
||||
- Test: `tests/test_settings.py`, `tests/test_mirror.py`, `tests/test_api.py`
|
||||
|
||||
**Interfaces:**
|
||||
- Produces: настройка `closed_statuses: list[str]` в `GET /settings`; `GET /issues?hide_closed=1`;
|
||||
`mirror.list_issues(conn, filters, limit, offset, closed_statuses=())` — новый необязательный параметр.
|
||||
|
||||
- [ ] **Step 1: Write the failing tests**
|
||||
|
||||
`tests/test_settings.py`:
|
||||
```python
|
||||
def test_closed_statuses_default(conn):
|
||||
assert settings.get(conn, "closed_statuses") == ["Готово", "Снято"]
|
||||
assert "closed_statuses" not in settings.AGENT_KEYS
|
||||
assert "closed_statuses" not in settings.for_agent(conn)
|
||||
|
||||
def test_closed_statuses_must_be_list():
|
||||
with pytest.raises(ValueError):
|
||||
settings.validate("closed_statuses", "Готово")
|
||||
settings.validate("closed_statuses", ["Готово"])
|
||||
```
|
||||
(`conn` — фикстура модуля, уже вызывает `ensure_defaults`; если нет — вызвать.)
|
||||
|
||||
`tests/test_mirror.py`:
|
||||
```python
|
||||
def test_list_issues_hide_closed(conn):
|
||||
mirror.apply_state(conn, [issue("A-1", status="В работе"), issue("A-2", status="Готово"),
|
||||
issue("A-3", status="Снято"), issue("A-4", status=None)], [])
|
||||
closed = ["Готово", "Снято"]
|
||||
keys = lambda f, c=closed: sorted(i["key"] for i in mirror.list_issues(conn, f, closed_statuses=c))
|
||||
assert keys({"hide_closed": "1"}) == ["A-1", "A-4"]
|
||||
assert keys({"hide_closed": "true"}) == ["A-1", "A-4"]
|
||||
assert keys({}) == ["A-1", "A-2", "A-3", "A-4"]
|
||||
assert keys({"hide_closed": "0"}) == ["A-1", "A-2", "A-3", "A-4"]
|
||||
assert keys({"hide_closed": "1"}, []) == ["A-1", "A-2", "A-3", "A-4"] # пустой набор — ничего не скрыто
|
||||
assert keys({"hide_closed": "1", "status": "Готово"}) == [] # сервер не отменяет — это делает приложение
|
||||
```
|
||||
(если помощник `issue()` не принимает `status` — передать его так же, как `sprint` в соседнем тесте.)
|
||||
|
||||
`tests/test_api.py` — сценарий «Без закрытых» из `specs/hub-api`: четыре задачи как выше, `GET /issues?hide_closed=1`
|
||||
→ ключи `A-1`, `A-4`; без параметра — все четыре; после `PUT /settings {closed_statuses: ["Готово"]}` —
|
||||
`hide_closed=1` отдаёт `A-1`, `A-3`, `A-4`. И `PUT /settings {closed_statuses: "Готово"}` → 422, значение не
|
||||
изменилось (добавить случай в параметризацию `test_settings_put_invalid_value_is_422`, если она по ключу/значению).
|
||||
|
||||
- [ ] **Step 2: Run tests to verify they fail**
|
||||
|
||||
Run: `uv run pytest tests/test_settings.py tests/test_mirror.py tests/test_api.py -k "closed" -v`
|
||||
Expected: FAIL (`closed_statuses` нет в настройках, `list_issues` не знает `closed_statuses`).
|
||||
|
||||
- [ ] **Step 3: Implement**
|
||||
|
||||
- `settings.DEFAULTS["closed_statuses"] = ["Готово", "Снято"]` — тип проверит существующая ветка `validate` по
|
||||
значению по умолчанию (список).
|
||||
- `mirror.list_issues(conn, filters=None, limit=50, offset=0, closed_statuses=())`: при `filters["hide_closed"]` из
|
||||
`(True, 1, "1", "true")` и непустом `closed_statuses` — условие
|
||||
`(i.status IS NULL OR i.status NOT IN (?, …))` с плейсхолдерами по числу статусов.
|
||||
- `api.h_issues`: `hide_closed` в разобранные фильтры (добавить в `ISSUE_FILTERS`), `closed_statuses` — из
|
||||
`settings.get(conn, "closed_statuses")`, если это список строк, иначе `()`.
|
||||
- README и ARCHITECTURE — по строкам из **Files**.
|
||||
|
||||
- [ ] **Step 4: Run tests to verify they pass**
|
||||
|
||||
Run: `uv run pytest` — весь набор зелёный.
|
||||
|
||||
- [ ] **Step 5: Commit**
|
||||
|
||||
```bash
|
||||
git add src/jira_hub tests README.md ARCHITECTURE.md
|
||||
git commit -m "Хаб: настройка closed_statuses и фильтр GET /issues?hide_closed (#57)"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 2: Приложение — помощник закрытых статусов и список «Задачи»
|
||||
|
||||
**Files:**
|
||||
- Create: `web/src/api/closed.ts`, `web/src/api/closed.test.ts`
|
||||
- Modify: `web/src/api/types.ts` (`HubSettings`: `closed_statuses?: string[]`; убрать `status_labels` и комментарий о нём)
|
||||
- Modify: `web/src/api/queries.ts` (`ISSUE_FILTER_NAMES` без `fact_status`; `IssueFilters` допускает `hide_closed`)
|
||||
- Modify: `web/src/issues/IssuesPage.tsx`, `web/src/issues/options.ts`
|
||||
- Modify: `web/src/board/__fixtures__/settings.json` (добавить `"closed_statuses": ["Готово", "Снято"]`)
|
||||
- Test: `web/src/issues/IssuesPage.test.tsx`, `web/e2e/issues.spec.ts`
|
||||
|
||||
**Interfaces:**
|
||||
- Produces (`web/src/api/closed.ts`):
|
||||
- `closedStatuses(s: HubSettings | undefined): Set<string>` — строки из `s.closed_statuses`, если это массив; иначе
|
||||
пустой набор.
|
||||
- `isClosed(status: string | null | undefined, closed: Set<string>): boolean` — `!!status && closed.has(status)`.
|
||||
- `useClosedStatuses(): Set<string>` — `closedStatuses(useSettings().data)`, мемоизировано по `settings.data`.
|
||||
- Consumes: `GET /issues?hide_closed=1` (Task 1).
|
||||
|
||||
- [ ] **Step 1: Write the failing tests**
|
||||
|
||||
`closed.test.ts`: `closedStatuses({closed_statuses: ["Готово", "Снято"]})` — набор из двух; `closedStatuses({})`,
|
||||
`closedStatuses(undefined)`, `closedStatuses({closed_statuses: "Готово"})` — пустой; `isClosed("Снято", set)` — true,
|
||||
`isClosed("", set)`, `isClosed(null, set)`, `isClosed("В работе", set)` — false.
|
||||
|
||||
`IssuesPage.test.tsx` (хаб из `hub()` уже отдаёт `settings.json` с `closed_statuses`):
|
||||
- «закрытые скрыты по умолчанию»: открыт `/issues` → флажок «скрыть закрытые» (`getByRole("checkbox", {name:
|
||||
"скрыть закрытые"})`) отмечен, последний вызов `/issues` содержит `hide_closed=1`, не содержит `closed`; снятие
|
||||
флажка → адрес содержит `closed=1`, последний вызов `/issues` без `hide_closed`.
|
||||
- «адрес closed=1»: открыт `/issues?closed=1` → флажок снят, запрос без `hide_closed`.
|
||||
- «выбран закрытый статус»: открыт `/issues?status=Готово` → запрос `status=Готово` без `hide_closed`; открыт
|
||||
`/issues?status=В работе` → с `hide_closed=1`.
|
||||
- «настройки позже» (Review Focus 1): ответ `/settings` задержан (`deferred`), адрес `/issues?status=Готово`; после
|
||||
выдачи настроек последний вызов `/issues` — без `hide_closed`.
|
||||
- «нет статуса по факту»: нет `combobox` «Статус по факту», нет заголовка колонки «Статус по факту»; открыт
|
||||
`/issues?fact_status=В работе` → в вызове `/issues` нет `fact_status`.
|
||||
- Существующие тесты, которые сравнивают точный набор параметров `/issues`, поправить на `hide_closed=1`.
|
||||
|
||||
`web/e2e/issues.spec.ts`: в тесте «окно поверх списка» ожидание `toHaveCount(10)` → `9` (AURORA-9006 «Готово»
|
||||
скрыта); новый тест: `/app/issues` — AURORA-9006 нет в таблице; снят флажок «скрыть закрытые» — `toHaveURL(/closed=1/)`,
|
||||
AURORA-9006 есть, строк 10.
|
||||
|
||||
- [ ] **Step 2: Run tests to verify they fail**
|
||||
|
||||
Run: `cd web && npx vitest run src/api/closed.test.ts src/issues/IssuesPage.test.tsx`
|
||||
Expected: FAIL (нет модуля `closed.ts`, нет флажка).
|
||||
|
||||
- [ ] **Step 3: Implement**
|
||||
|
||||
- `closed.ts` — по Interfaces.
|
||||
- `IssuesPage`: из `SELECTS` убрать `fact_status`, из таблицы — колонку «Статус по факту»; `showClosed =
|
||||
params.get("closed") === "1"`; флажок `<label className="check"><input type="checkbox" checked={!showClosed} …
|
||||
/>скрыть закрытые</label>` рядом с «только соседи», снятие ставит `closed=1`, отметка — удаляет `closed`;
|
||||
в `useIssues` уходят `filters` плюс `hide_closed: "1"`, если `!showClosed && !isClosed(filters.status, closed)`.
|
||||
- `options.ts`: убрать `fact_status` из `filterOptions`.
|
||||
|
||||
- [ ] **Step 4: Run tests to verify they pass**
|
||||
|
||||
Run: `make check`, затем `cd web && npm run e2e`
|
||||
Expected: всё зелёное.
|
||||
|
||||
- [ ] **Step 5: Commit**
|
||||
|
||||
```bash
|
||||
git add web
|
||||
git commit -m "Список задач: «скрыть закрытые» по умолчанию, без статуса по факту (#57)"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 3: Карточка задачи и заметки без статуса по факту
|
||||
|
||||
**Files:**
|
||||
- Modify: `web/src/issues/card.ts` (`isOverdue`; удалить `factClass`, `CLOSED_FACT`)
|
||||
- Modify: `web/src/issues/IssueCard.tsx` (шапка без бейджа факта; `isOverdue` по статусу Jira)
|
||||
- Modify: `web/src/issues/Notes.tsx` (`ROWS` без `fact_status`), `web/src/issues/NoteForm.tsx` (поле, `datalist`,
|
||||
`facts`, `useId` если больше не нужен)
|
||||
- Modify: `web/src/api/types.ts` (`NOTE_FIELDS` без `fact_status`; тип `Note` — `fact_status?: string | null` остаётся
|
||||
необязательным полем ответа)
|
||||
- Modify: `web/src/issues/issues.css` (удалить `.badge.fact*`)
|
||||
- Modify: `web/src/test/issueFixtures.ts` только если типы требуют
|
||||
- Test: `web/src/issues/card.test.ts`, `web/src/issues/IssueCard.test.tsx`
|
||||
|
||||
**Interfaces:**
|
||||
- Consumes: `useClosedStatuses()`, `closedStatuses`, `isClosed` (Task 2).
|
||||
- Produces: `isOverdue(due: string, status: string | null | undefined, closed: Set<string>, today: string): boolean`.
|
||||
|
||||
- [ ] **Step 1: Write the failing tests**
|
||||
|
||||
`card.test.ts` (заменить тесты `factClass` и R-2):
|
||||
```ts
|
||||
const closed = new Set(["Готово", "Снято"]);
|
||||
it("срок прошёл, статус не закрытый — просрочено", () => {
|
||||
expect(isOverdue("2026-10-01", "В работе", closed, "2026-10-17")).toBe(true);
|
||||
expect(isOverdue("2026-10-01", null, closed, "2026-10-17")).toBe(true);
|
||||
});
|
||||
it("закрытая задача или срок не прошёл — не просрочено", () => {
|
||||
expect(isOverdue("2026-10-01", "Готово", closed, "2026-10-17")).toBe(false);
|
||||
expect(isOverdue("2026-10-01", "Снято", closed, "2026-10-17")).toBe(false);
|
||||
expect(isOverdue("2026-10-17", "В работе", closed, "2026-10-17")).toBe(false);
|
||||
expect(isOverdue("", "В работе", closed, "2026-10-17")).toBe(false);
|
||||
});
|
||||
```
|
||||
`IssueCard.test.tsx`:
|
||||
- шапка: нет элемента `.badge.fact` и текста «факт:»; строка фактов как прежде с «просрочено» (у фикстуры статус не
|
||||
закрытый); новый тест — та же задача со `status: "Готово"` → «просрочено» нет (сценарий «Просрочка закрытой задачи»).
|
||||
- заметки в чтении: нет `dt` «Статус по факту»; в форме нет поля с подписью «Статус по факту» (заменить тест
|
||||
со строки ~390 про `datalist`).
|
||||
- Review Focus 3: у заметки `fact_status: "В работе"`, в форме изменено только «Осталось», «Сохранить» → тело `PUT` —
|
||||
ровно `{rest_text: "…", updated_by: "web"}`, ключа `fact_status` нет.
|
||||
|
||||
- [ ] **Step 2: Run tests to verify they fail**
|
||||
|
||||
Run: `cd web && npx vitest run src/issues/card.test.ts src/issues/IssueCard.test.tsx`
|
||||
Expected: FAIL.
|
||||
|
||||
- [ ] **Step 3: Implement** — по Files и Interfaces; в `IssueCard` набор — `useClosedStatuses()`, вызов
|
||||
`isOverdue(due, i.status, closed, todayISO())`.
|
||||
|
||||
- [ ] **Step 4: Run tests to verify they pass**
|
||||
|
||||
Run: `make check`; `grep -rn "fact_status\|status_labels\|factClass" web/src --exclude-dir=__fixtures__` — остаются
|
||||
только необязательные поля типов ответа API (`BoardIssue`, `IssueRow`, `Note`) и тестовые фикстуры.
|
||||
|
||||
- [ ] **Step 5: Commit**
|
||||
|
||||
```bash
|
||||
git add web
|
||||
git commit -m "Карточка задачи: без статуса по факту, «просрочено» по статусу Jira (#57)"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 4: Доска — приглушение закрытых карточек и стрелок
|
||||
|
||||
**Files:**
|
||||
- Modify: `web/src/board/components/Card.tsx` (`CardProps.closed?: boolean`, класс `closed`, `cardPropsEqual`)
|
||||
- Modify: `web/src/board/components/Edges.tsx` (`closedKeys: Set<string>`, `className="closed"` у `<path>`)
|
||||
- Modify: `web/src/board/components/BoardCanvas.tsx` (`BoardCanvasProps.closedStatuses?: Set<string>`)
|
||||
- Modify: `web/src/board/BoardPage.tsx` (передать `useClosedStatuses()`)
|
||||
- Modify: `web/src/board/board.css`
|
||||
- Test: `web/src/board/components/BoardCanvas.test.tsx`, `web/src/board/components/Card.test.ts`
|
||||
|
||||
**Interfaces:**
|
||||
- Consumes: `useClosedStatuses()`, `isClosed` (Task 2).
|
||||
- Produces: `BoardCanvas` принимает `closedStatuses` (по умолчанию пустой набор); `Card` — `closed`; `Edges` —
|
||||
`closedKeys` (ключи закрытых задач среди `vis.nodes`, считаются в `BoardCanvas` через `useMemo`).
|
||||
|
||||
- [ ] **Step 1: Write the failing tests**
|
||||
|
||||
`BoardCanvas.test.tsx` — через `baseProps`/`draw` с `vis`, где AURORA-1620 «В работе», AURORA-1621 «Готово»,
|
||||
AURORA-1622 «Снято», связь AURORA-1620 → AURORA-1621, `closedStatuses: new Set(["Готово", "Снято"])`:
|
||||
- `[data-key="AURORA-1621"]` и `[data-key="AURORA-1622"]` имеют класс `closed`, `[data-key="AURORA-1620"]` — нет;
|
||||
- `<path>` связи в `svg.edges` имеет класс `closed`;
|
||||
- без `closedStatuses` — ни у одной карточки и стрелки класса `closed` нет;
|
||||
- закрытая карточка-сосед (`dim: true`, «Готово») — классы `out` и `closed` оба.
|
||||
`Card.test.ts`: `cardPropsEqual` возвращает `false`, если отличается только `closed`.
|
||||
CSS проверяется правилом в `board.css` (jsdom стили не считает): `.card.closed{opacity:.4}` стоит после `.card.out`.
|
||||
|
||||
- [ ] **Step 2: Run tests to verify they fail**
|
||||
|
||||
Run: `cd web && npx vitest run src/board/components`
|
||||
Expected: FAIL.
|
||||
|
||||
- [ ] **Step 3: Implement**
|
||||
|
||||
- CSS (после правила `.card.out`): `.card.closed{opacity:.4}`,
|
||||
`.card.closed:hover,.card.closed:focus-within{opacity:1}`, `.edges path.closed{opacity:.4}`.
|
||||
- Карточки слоя измерения (`BoardCanvas.tsx:204`) класс `closed` не получают — на размер он не влияет.
|
||||
|
||||
- [ ] **Step 4: Run tests to verify they pass**
|
||||
|
||||
Run: `make check`, `cd web && npm run e2e` (golden-тесты доски — без перегенерации эталонов).
|
||||
Expected: всё зелёное.
|
||||
|
||||
- [ ] **Step 5: Commit**
|
||||
|
||||
```bash
|
||||
git add web
|
||||
git commit -m "Доска: закрытые задачи и их связи приглушены (#57)"
|
||||
```
|
||||
2
openspec/changes/hide-closed/.openspec.yaml
Normal file
2
openspec/changes/hide-closed/.openspec.yaml
Normal file
@ -0,0 +1,2 @@
|
||||
schema: spec-driven
|
||||
created: 2026-10-17
|
||||
103
openspec/changes/hide-closed/design.md
Normal file
103
openspec/changes/hide-closed/design.md
Normal file
@ -0,0 +1,103 @@
|
||||
## Context
|
||||
|
||||
`main` 9f4bb75. Устройство, на которое опираются правки:
|
||||
|
||||
- **Настройки** — `src/jira_hub/settings.py`: `DEFAULTS` (ключ → значение по умолчанию), `ensure_defaults` при открытии
|
||||
базы добавляет недостающие ключи (`INSERT OR IGNORE`), `validate` проверяет тип по значению по умолчанию,
|
||||
`AGENT_KEYS` — что уходит агенту. `GET /settings` отдаёт всё, кроме токенов.
|
||||
- **Список на сервере** — `api.ISSUE_FILTERS` → `mirror.list_issues(conn, filters, limit, offset)`: `_FILTERS`
|
||||
(точное совпадение по колонке) плюс отдельные ветки для `label`, `neighbor`, `q`.
|
||||
- **Список в приложении** — `web/src/issues/IssuesPage.tsx`: фильтры из query-строки по `ISSUE_FILTER_NAMES`
|
||||
(`web/src/api/queries.ts`), все уходят в `GET /issues` как есть; `SELECTS` — выпадающие списки, варианты —
|
||||
`filterOptions` (`web/src/issues/options.ts`), «только соседи» — флажок `neighbor=1`. Порции по 50 (`useIssues`).
|
||||
- **Доска** — `web/src/board/`: модель из `GET /board` / CSV / JSON → видимая выборка (`analysis/visible.ts`, `VisNode`
|
||||
с `dim` — сосед, класс карточки `out`) → `BoardCanvas` → `Card.tsx` (класс `card`, `out`, `crit`, `focus`) и
|
||||
`Edges.tsx` (`<path>` на каждую связь). Настройки — `useSettings()` в `BoardPage.tsx`, словари — `dicts.ts`.
|
||||
Golden-тесты сравнивают модель, выборку, критический путь и проблемы с `links.html@3931a88`.
|
||||
- **Карточка** — `web/src/issues/IssueCard.tsx`: шапка с бейджем статуса по факту (`factClass` по `status_labels`),
|
||||
«просрочено» — `isOverdue(due, fact, statusLabels, today)` (`card.ts`, R-2 #37). `Notes.tsx` — строки заметки,
|
||||
`NoteForm.tsx` — форма, поле `fact_status` с подсказками из `status_labels`; сохраняются только изменённые поля.
|
||||
- **Живые данные** (17.10.2026): закрытые статусы AURORA — «Готово» и «Снято»; `transitions_map` и `status_labels`
|
||||
в настройках ключуются статусами по факту — это касается будущего полного удаления, не этой задачи.
|
||||
|
||||
## Goals / Non-Goals
|
||||
|
||||
**Goals:** в списке по умолчанию только незакрытые задачи, закрытые — одним флажком; на доске закрытые не отвлекают, но
|
||||
связи через них видны; «статуса по факту» в веб-приложении нет.
|
||||
|
||||
**Non-Goals:** см. proposal.md.
|
||||
|
||||
## Decisions
|
||||
|
||||
1. **Набор закрытых статусов — настройка хаба `closed_statuses`**, список строк, по умолчанию `["Готово", "Снято"]`.
|
||||
Приложение по правилу «Справочники из настроек хаба» своих копий словарей не держит, а сервер нужен для фильтра
|
||||
списка. В `DEFAULTS`, поэтому на живом хабе ключ появится при первом открытии базы после выкатки (`ensure_defaults`);
|
||||
тип проверяет `validate` (список). В `AGENT_KEYS` не входит. Сравнение — точное совпадение строки статуса.
|
||||
2. **Фильтр списка — на сервере:** `GET /issues?hide_closed=1` добавляет условие `i.status IS NULL OR i.status NOT IN
|
||||
(…closed_statuses)` (задача без статуса — не закрыта). Клиентский фильтр поверх порций по 50 ломал бы «Показать
|
||||
ещё» (порция могла бы прийти пустой). Значение `1` / `true` — включено, иное или отсутствие — выключено (как
|
||||
`neighbor`). Пустой `closed_statuses` — фильтр ничего не скрывает.
|
||||
3. **Адрес списка:** по умолчанию закрытые скрыты, в адресе ничего нет; снятый флажок «скрыть закрытые» — `closed=1`.
|
||||
Приложение отправляет `hide_closed=1`, пока `closed` не `1`. `closed` — параметр приложения, в `GET /issues` не
|
||||
уходит. Флажок — рядом с «только соседи».
|
||||
4. **Явный выбор статуса сильнее флажка:** если в фильтре «Статус Jira» выбран статус из `closed_statuses`,
|
||||
`hide_closed` не отправляется — иначе выборка всегда пуста. Флажок при этом остаётся как есть.
|
||||
5. **Доска:** признак закрытости вычисляется при отрисовке, а не в модели — модель, выборка, критический путь и
|
||||
проблемы остаются как у `links.html` (golden-тесты не трогаются). Карточка получает класс `closed`
|
||||
(`opacity: .4`), под указателем и с фокусом внутри — полная яркость (`.card.closed:hover`, `.card.closed:focus-within`;
|
||||
признак `VisNode.focus` нигде не включается, выделения карточек на доске нет). Стрелка, у которой хотя бы один конец —
|
||||
закрытая задача, — класс/атрибут с той же прозрачностью. Сосед (`out`) и закрытая — признаки независимые, вместе
|
||||
— оба стиля. Набор закрытых — из `useSettings()`; карточки из CSV и JSON приглушаются по тому же правилу (статус у
|
||||
них есть).
|
||||
6. **Нет `closed_statuses` в настройках** (хаб старее приложения) — ничего не считается закрытым: список без фильтра,
|
||||
доска без приглушения, ошибки «нет словаря» нет. Хаб и приложение выкатываются вместе, это окно короткое.
|
||||
7. **«Просрочено» в карточке** — по статусу Jira: срок раньше сегодня и статус не из `closed_statuses` (было — по
|
||||
классу статуса по факту, R-2 #37). Без статуса по факту прежнее правило не на чем считать.
|
||||
8. **«Статус по факту» из приложения:** убрать из `ISSUE_FILTER_NAMES` и `SELECTS`, колонку таблицы, вариант из
|
||||
`filterOptions`, бейдж в шапке карточки (`factClass`, стили `.badge.fact*`), строку в `Notes.tsx`, поле и подсказки
|
||||
в `NoteForm.tsx`. Тип `NoteFields` / `NOTE_FIELDS` приложения — без `fact_status`; так форма не может отправить его
|
||||
и в `PUT` значение не трогается. Тип ответа API (`IssueRow.fact_status`, `Note.fact_status`) — поле остаётся
|
||||
необязательным, приложение его не читает. Старый адрес с `fact_status=` — параметр игнорируется (нет в
|
||||
`ISSUE_FILTER_NAMES`).
|
||||
|
||||
## Risks / Trade-offs
|
||||
|
||||
- Новый статус Jira закрытого смысла (например, «Отклонено») не будет скрыт, пока его не добавят в `closed_statuses`
|
||||
— осознанно: владелец назвал ровно два статуса.
|
||||
- Пока хаб не перезапущен после выкатки, сборка приложения новее хаба: `hide_closed` хаб старой версии игнорирует, и
|
||||
список покажет закрытые — безвредно.
|
||||
|
||||
## Decisions made without discussion
|
||||
|
||||
1. 2026-10-17 — **набор закрытых статусов в настройке хаба `closed_statuses`**, а не константой в коде. Обоснование:
|
||||
правило «Справочники из настроек хаба», фильтр нужен и серверу, и приложению. Цена ошибки: лишний ключ настроек;
|
||||
убрать — правка `DEFAULTS` и двух мест чтения.
|
||||
2. 2026-10-17 — **фильтр `hide_closed` на сервере**, а не в браузере. Обоснование: порции по 50. Цена ошибки: один
|
||||
параметр API; поведение MCP не меняется.
|
||||
3. 2026-10-17 — **адрес: `closed=1` — показать закрытые; по умолчанию параметра нет.** Обоснование: умолчание владельца
|
||||
«скрыты», адрес без лишних параметров. Цена ошибки: переименование параметра.
|
||||
4. 2026-10-17 — **выбранный в «Статус Jira» закрытый статус отменяет скрытие.** Обоснование: иначе выборка всегда пуста.
|
||||
Цена ошибки: поведение одного сочетания фильтров.
|
||||
5. 2026-10-17 — **приглушение: `opacity .4`, полная яркость под указателем и с фокусом клавиатуры внутри; стрелки — если закрыт хотя бы один
|
||||
конец.** Обоснование: владелец подтвердил «связи тоже бледнеют»; степень прозрачности — на глаз, отличается от
|
||||
соседа (`.7`, пунктир). Цена ошибки: правка CSS.
|
||||
6. 2026-10-17 — **без `closed_statuses` — ничего не закрыто, без сообщения об ошибке.** Обоснование: это не словарь
|
||||
подписей, без него страница работает как раньше. Цена ошибки: нет.
|
||||
7. 2026-10-17 — **«просрочено» — по статусу Jira из `closed_statuses`.** Обоснование: статус по факту из приложения
|
||||
убран. Цена ошибки: пометка «просрочено» у задачи, закрытой по факту, но не в Jira, — теперь видна (это и есть
|
||||
статус Jira).
|
||||
|
||||
Решения исполнения (маркеры по ledger SDD):
|
||||
|
||||
8. 2026-10-17 — **R-1: e2e-шаг флажка — `click()` + `not.toBeChecked()` вместо `uncheck()`.** Обоснование: флажок
|
||||
управляется адресом, React Router меняет его навигацией асинхронно, `uncheck()` проверяет состояние сразу после
|
||||
щелчка; так же устроен «только соседи». Цена ошибки: нет, поведение то же.
|
||||
9. 2026-10-17 — **R-2: «PUT заметки не шлёт `fact_status`» отдельным тестом не дублируется.** Обоснование: тест
|
||||
сохранения в `IssueCard.test.tsx` идёт на фикстуре с `fact_status` и сравнивает тело целиком. Цена ошибки: нет.
|
||||
10. 2026-10-17 — **R-3: в заметке без данных — 4 прочерка вместо 5.** Обоснование: полей заметки в приложении
|
||||
четыре по спеке. Цена ошибки: нет.
|
||||
11. 2026-10-17 — **R-4: тест приглушения на доске считает закрытые по статусам выборки.** Обоснование: в фикстуре
|
||||
`hub-basic` уже есть AURORA-9006 «Готово». Цена ошибки: нет.
|
||||
12. 2026-10-17 — **R-5: находки финального ревью M-1 (README описывал статус по факту в карточке) и M-2 (формат даты в
|
||||
сценарии спеки) исправлены, а не отложены.** Обоснование: документация и спека — источник истины — противоречили
|
||||
поведению; тестом не проверяются. Цена ошибки: нет.
|
||||
35
openspec/changes/hide-closed/proposal.md
Normal file
35
openspec/changes/hide-closed/proposal.md
Normal file
@ -0,0 +1,35 @@
|
||||
## Why
|
||||
|
||||
В списке «Задачи» и на доске видны в том числе закрытые задачи: они занимают место и мешают видеть то, что ещё в
|
||||
работе. Кроме того, «статус по факту» из служебных заметок вносит шум: в списке и карточке рядом два статуса.
|
||||
|
||||
Решения владельца 17.10.2026 (brainstorming, jira-hub#57):
|
||||
- закрытая задача — та, у которой **статус Jira** «Готово» или «Снято»; других признаков закрытости нет;
|
||||
- список: закрытые можно **скрыть** — переключатель в фильтрах, по умолчанию они скрыты, выбор хранится в адресе, как
|
||||
остальные фильтры;
|
||||
- доска: закрытые **приглушены** — карточка и её связи полупрозрачные, всегда, без переключателя; карточка под
|
||||
указателем или с фокусом клавиатуры внутри — в полную яркость (выделения карточек на доске нет);
|
||||
- «статус по факту» убрать **из веб-приложения** (колонка и фильтр списка, шапка карточки, заметки в карточке и форма);
|
||||
в хабе (база, API, MCP, `status_labels`, `transitions_map`) пока остаётся; полностью от него избавиться — позже,
|
||||
отдельной задачей.
|
||||
|
||||
## What Changes
|
||||
|
||||
- **Настройки хаба:** новый ключ `closed_statuses` — список имён статусов Jira, считающихся закрытыми, по умолчанию
|
||||
`["Готово", "Снято"]`; агенту не передаётся.
|
||||
- **API хаба:** `GET /issues` принимает фильтр `hide_closed=1` — без задач, у которых `status` из `closed_statuses`.
|
||||
- **Веб-приложение, список «Задачи»:** флажок «скрыть закрытые», по умолчанию включён; выключенный — `closed=1` в
|
||||
адресе; колонки и фильтра «Статус по факту» нет.
|
||||
- **Веб-приложение, доска:** карточки задач со статусом из `closed_statuses` и стрелки, у которых хотя бы один конец —
|
||||
такая задача, приглушены; модель, видимая выборка, критический путь и проблемы не меняются.
|
||||
- **Веб-приложение, карточка задачи:** в шапке нет статуса по факту; «просрочено» — по статусу Jira (срок прошёл и
|
||||
статус не из `closed_statuses`); в «Служебных заметках» и их форме нет поля «Статус по факту» (сохранение его не
|
||||
трогает).
|
||||
|
||||
## Non-Goals
|
||||
|
||||
- Удаление `fact_status` из базы, API `GET /issues` (фильтр и поле), MCP `issues` (колонка), `status_labels`,
|
||||
`transitions_map`, импорта — отдельная задача после этой.
|
||||
- Переключатель приглушения на доске, скрытие закрытых с доски.
|
||||
- Правка `closed_statuses` из веб-приложения (меняется через `PUT /settings` / MCP `settings_set`).
|
||||
- MCP `issues` не получает параметр `hide_closed`.
|
||||
30
openspec/changes/hide-closed/specs/data-model/spec.md
Normal file
30
openspec/changes/hide-closed/specs/data-model/spec.md
Normal file
@ -0,0 +1,30 @@
|
||||
## MODIFIED Requirements
|
||||
|
||||
### Requirement: Настройки
|
||||
Таблица `setting` SHALL хранить парами ключ–JSON: `base_jql`, `field_ids`, `issue_types`, `priority_map`, `link_types`,
|
||||
`transitions_map`, `pacing`, `board_id`, `tokens` (агентский и клиентский), `settings_version` (увеличивается при любой
|
||||
правке), `backup_keep`, `auto_refresh_minutes`, `relay_dir` (абсолютный путь папки реле или `null`, по умолчанию `null`;
|
||||
относительный путь и не-строка при записи — 422; агенту не передаётся), `closed_statuses` (список имён статусов Jira, считающихся закрытыми, по умолчанию
|
||||
`["Готово", "Снято"]`; не-список при записи — 422; агенту не передаётся; на существующей базе появляется со значением
|
||||
по умолчанию при открытии хаба). Спринты агент читает с доски `board_id`,
|
||||
отдельной настройки имён спринтов нет. Начальные значения SHALL импортироваться из `admin/jira-sync/config.example.json`
|
||||
командой `jira-hub init`; пароль и логин Jira в настройках MUST NOT присутствовать. `issue_types` — объект
|
||||
`{task, epic, bug}`, у каждого `{name, id}` (`id` может быть `null`); `init` берёт их из ключей конфига
|
||||
`issue_type_<тип>` и `issue_type_<тип>_id`; `bug` попадает в `issue_types`, только если в конфиге есть хотя бы
|
||||
один из ключей `issue_type_bug`, `issue_type_bug_id` (`task` и `epic` — как раньше, всегда).
|
||||
|
||||
#### Scenario: Правка настроек поднимает версию
|
||||
- **WHEN** через API изменён `pacing`
|
||||
- **THEN** `settings_version` увеличен, следующий `claim` отдаёт агенту новую версию
|
||||
|
||||
#### Scenario: Конфиг без ключей ошибки
|
||||
- **WHEN** `jira-hub init --config` с `issue_type_task`, `issue_type_epic`, без `issue_type_bug`
|
||||
- **THEN** `issue_types` содержит `task` и `epic`, `bug` нет; постановка `ensure_issue` с `issue_type = bug` — 422
|
||||
|
||||
#### Scenario: Неверный relay_dir
|
||||
- **WHEN** `PUT /settings` с `relay_dir = "rshb/jira-bridge"`
|
||||
- **THEN** 422, значение не записано, `settings_version` не изменился
|
||||
|
||||
#### Scenario: Закрытые статусы по умолчанию
|
||||
- **WHEN** хаб открыл базу, в которой нет ключа `closed_statuses`
|
||||
- **THEN** `GET /settings` отдаёт `closed_statuses = ["Готово", "Снято"]`; `claim` агенту этого ключа не передаёт
|
||||
69
openspec/changes/hide-closed/specs/hub-api/spec.md
Normal file
69
openspec/changes/hide-closed/specs/hub-api/spec.md
Normal file
@ -0,0 +1,69 @@
|
||||
## MODIFIED Requirements
|
||||
|
||||
### Requirement: Маршруты интерфейсов
|
||||
Хаб SHALL предоставлять интерфейсам (MCP, позже просмотрщик) следующие маршруты под клиентским токеном:
|
||||
- `GET /issues` с фильтрами `sprint`, `assignee`, `work_kind`, `fact_status`, `status`, `label`, `epic`, `neighbor`,
|
||||
`q` (подстрока темы), `hide_closed` (`1` / `true` — без задач, чей `status` входит в настройку `closed_statuses`;
|
||||
задача без статуса не закрыта), `limit`/`offset` — строки зеркала (все поля `issue`) с `fact_status` и `work_kind` из заметок;
|
||||
сокращение до карточки — дело интерфейса.
|
||||
- `GET /issues/{key}` — задача целиком: поля, `epic_summary` (тема эпика из зеркала или `null`), связи в обе стороны
|
||||
(`links_out: [{target_key, link_type, summary, status}]`, `links_in: [{source_key, link_type, summary, status}]`, где
|
||||
`summary` и `status` — связанной задачи из зеркала или `null`, если её там нет), заметки, последние 10 команд по ней,
|
||||
`comments: [{comment_id, author, created, updated, body}]` — все комментарии, новые сверху (по `created`, затем
|
||||
`comment_id`, по убыванию).
|
||||
- `PUT /issues/{key}/note` — заметки (частичное обновление, `updated_by` из тела).
|
||||
- `POST /batches` `{title, author, commands: [...]}` → `{id, estimate_seconds}`; 422 при неизвестном типе, неверных
|
||||
параметрах (в том числе неверного типа значения), временном ключе без `ensure_issue`, двух и более `ensure_issue` с
|
||||
одинаковой `summary` (сравнение после обрезки пробелов по краям; в ответе перечислены повторяющиеся темы), а также
|
||||
по проверкам постановки из `commands` (тип `bug` без настройки, файл вложения не загружен).
|
||||
- `POST /files` `{content}` (`content` — base64 содержимого файла) → `{file, size}`, где `file` — sha256 содержимого
|
||||
(64 hex-символа, нижний регистр); см. «Хранилище файлов вложений».
|
||||
- `GET /batches?limit=`, `GET /batches/{id}` (с командами), `POST /batches/{id}/cancel` — отменяет `queued` команды
|
||||
пакета; ответ — вид пакета с полем `still_claimed`: список `{id, type}` команд пакета в `claimed` (они будут выполнены
|
||||
агентом; остановить — отмена команды с `force`).
|
||||
- `POST /commands/{id}/retry` — новая команда с теми же параметрами в новом пакете «Повтор команды N».
|
||||
- `POST /commands/{id}/cancel` — только для `queued`; для `claimed` — 409, если не передан `force: true`.
|
||||
- `POST /refresh` `{keys | jql | full: true}` → пакет из одного `refresh`.
|
||||
- `GET /agent`, `GET /sync-runs?limit=`, `GET /settings`, `PUT /settings` (частично; поднимает `settings_version`;
|
||||
ответ — все настройки после записи, как у `GET /settings`, включая `settings_version`).
|
||||
|
||||
#### Scenario: Фильтр по спринту
|
||||
- **WHEN** `GET /issues?sprint=Спринт 2`
|
||||
- **THEN** только задачи с таким `sprint`, отсортированы по сроку, затем по ключу
|
||||
|
||||
#### Scenario: Отмена взятой команды
|
||||
- **WHEN** `POST /commands/15/cancel` без `force`, команда `claimed`
|
||||
- **THEN** 409 с `claimed_at`; с `force: true` — `cancelled`, следующий отчёт агента по ней принимается, статус не меняется
|
||||
|
||||
#### Scenario: Поздний отчёт по отменённому созданию
|
||||
- **WHEN** `ensure_issue {temp_key: new:7, …}` отменена с `force: true`, затем агент прислал `done` с `result.key = AURORA-1620`
|
||||
- **THEN** команда `cancelled`, в `result` — `AURORA-1620`; заметка `new:7` перенесена на `AURORA-1620`
|
||||
|
||||
#### Scenario: Дубль темы в пакете
|
||||
- **WHEN** `POST /batches` с двумя `ensure_issue`, у обеих `summary = "Шахматка: комментарии"`
|
||||
- **THEN** 422 с темой «Шахматка: комментарии», пакет не создан
|
||||
|
||||
#### Scenario: Тема не строка
|
||||
- **WHEN** `POST /batches` с `ensure_issue {temp_key: new:1, summary: 42, …}`
|
||||
- **THEN** 422 с указанием параметра `summary`, пакет не создан
|
||||
|
||||
#### Scenario: Отмена пакета с командой у агента
|
||||
- **WHEN** в пакете 9 команда 30 `claimed`, команды 31 и 32 `queued`; `POST /batches/9/cancel`
|
||||
- **THEN** 31 и 32 `cancelled`; в ответе `still_claimed = [{id: 30, type: …}]`; 30 остаётся `claimed`
|
||||
|
||||
#### Scenario: Ответ на запись настроек
|
||||
- **WHEN** `PUT /settings {relay_dir: "/Volumes/externalSSD/utm/rshb/jira-bridge"}`
|
||||
- **THEN** 200; ответ содержит `relay_dir` с записанным значением, новую `settings_version` и остальные настройки;
|
||||
токенов в ответе нет
|
||||
|
||||
#### Scenario: Карточка с комментариями и связями
|
||||
- **WHEN** у AURORA-9001 два комментария (14.10 и 15.10), исходящая связь на AURORA-9002 («API свода», «Новая») и на
|
||||
AURORA-1, которой нет в зеркале; эпик AURORA-9100 «Тестовый эпик»
|
||||
- **THEN** `GET /issues/AURORA-9001` отдаёт `comments` с комментарием от 15.10 первым, у связи на AURORA-9002 —
|
||||
`summary = "API свода"`, `status = "Новая"`, у связи на AURORA-1 — `summary = null`, `status = null`;
|
||||
`epic_summary = "Тестовый эпик"`
|
||||
|
||||
#### Scenario: Без закрытых
|
||||
- **WHEN** `closed_statuses = ["Готово", "Снято"]`, в зеркале задачи в статусах «В работе», «Готово», «Снято» и без
|
||||
статуса; `GET /issues?hide_closed=1`
|
||||
- **THEN** в ответе задачи «В работе» и без статуса; без `hide_closed` — все четыре
|
||||
192
openspec/changes/hide-closed/specs/web-app/spec.md
Normal file
192
openspec/changes/hide-closed/specs/web-app/spec.md
Normal file
@ -0,0 +1,192 @@
|
||||
## MODIFIED Requirements
|
||||
|
||||
### Requirement: Справочники из настроек хаба
|
||||
Приложение SHALL брать перевод меток видов работ (`kind_labels`) и типов связей Jira (`link_types`) из `GET /settings`
|
||||
и набор закрытых статусов Jira (`closed_statuses`) из `GET /settings`
|
||||
и не содержать собственных копий этих словарей. Если нужного словаря в настройках нет, страница SHALL сообщать, какого
|
||||
словаря не хватает, вместо пустой доски или списка без подписей; исключение — `closed_statuses`: без него ни одна
|
||||
задача не считается закрытой, сообщения нет.
|
||||
|
||||
#### Scenario: Новый тип связи в настройках
|
||||
- **WHEN** в `link_types` настроек добавлен тип и в зеркале есть связь этого типа
|
||||
- **THEN** после перезагрузки страницы доска показывает связь этого типа без изменения кода приложения
|
||||
|
||||
### Requirement: Доска связей
|
||||
Страница `/app/board` SHALL воспроизводить поведение доски связей хаба (`links.html` ревизии 3931a88) без изменений,
|
||||
кроме перечисленных в этом требовании и в требовании «Источник данных доски»: построение модели из `GET /board`, CSV
|
||||
фильтра Jira и JSON доски (`arm-ka-links/1`); дорожки по эпикам со свёрткой и перестановкой, служебные дорожки внизу;
|
||||
карточки с перетаскиванием, растяжкой, размером по содержимому и привязкой к сетке; стрелки связей с выравниванием;
|
||||
фильтры по спринтам и исполнителям; легенда со скрытием видов работ и типов связей; «+ соседи по связям», «⇄ передачи»,
|
||||
«скрыть без связей», «⚑ критический путь», «▥ по датам» с колонками дат и их растяжкой; ящик «Проблемы»; таблица
|
||||
связей; экспорт и импорт JSON; тема; «Сбросить раскладку». Раскладка, дорожки, фильтры и тема SHALL храниться в
|
||||
`localStorage` под теми же ключами `graph-<имя>…`, что у `links.html`, с той же миграцией старых ключей.
|
||||
Результат построения модели, видимой выборки, критического пути и проблем SHALL совпадать с результатом `links.html`
|
||||
на одних и тех же данных и фильтрах. У карточки задачи (не у заглушки) SHALL быть значок «Открыть карточку»,
|
||||
открывающий `/app/issues/{key}` окном поверх доски; ключ карточки по-прежнему открывает задачу в Jira. Пока окно открыто
|
||||
и после его закрытия доска SHALL сохранять прокрутку, раскладку, фильтры и строку состояния пакета. Действия «Сменить токен» в меню
|
||||
доски нет — оно в верхней панели приложения. Ещё два отличия от `links.html` (R-10, R-18): двойной щелчок по уголку
|
||||
карточки меряет высоту по содержимому при новой ширине, а не при прежней; указатель при перетаскивании и растяжке
|
||||
захватывается на нажатом элементе, а не на карточке, поэтому двойной щелчок по уголку сбрасывает размер. Загрузка файла CSV/JSON доступна только после входа, с экрана входа её нет.
|
||||
Карточка задачи, статус Jira которой входит в `closed_statuses`, SHALL быть приглушена (полупрозрачна), под
|
||||
указателем и с фокусом клавиатуры внутри карточки — в полную яркость; стрелка связи, у которой хотя бы один конец — такая задача, SHALL быть приглушена так же.
|
||||
Приглушение — только вид: модель, видимая выборка, критический путь, проблемы, раскладка и экспорт JSON от него не
|
||||
зависят; переключателя нет; правило одно для данных хаба, CSV и JSON.
|
||||
При возврате на `/app/board` с другой страницы приложения в той же вкладке доска SHALL восстанавливать прежнюю
|
||||
прокрутку; после перезагрузки страницы доска открывается сверху.
|
||||
|
||||
#### Scenario: Раскладка переживает переход
|
||||
- **WHEN** раскладка карточек сохранена страницей `links.html`, открыта `/app/board` с теми же данными
|
||||
- **THEN** карточки стоят на тех же местах, дорожки в том же порядке и свёртке, фильтры те же
|
||||
|
||||
#### Scenario: Совпадение со старой доской
|
||||
- **WHEN** на одной фикстуре `GET /board` и одном наборе фильтров построены модель, видимая выборка, критический путь
|
||||
и проблемы старой страницей и приложением
|
||||
- **THEN** результаты совпадают
|
||||
|
||||
#### Scenario: Открыть карточку
|
||||
- **WHEN** на карточке AURORA-1620 нажат значок «Открыть карточку»
|
||||
- **THEN** адрес `/app/issues/AURORA-1620`, карточка задачи открыта окном, под ним доска
|
||||
|
||||
#### Scenario: Возврат с карточки
|
||||
- **WHEN** доска прокручена вниз, нажат значок «Открыть карточку», затем окно закрыто
|
||||
- **THEN** адрес `/app/board`, доска показана с той же прокруткой и раскладкой, без повторной загрузки `GET /board`
|
||||
|
||||
#### Scenario: Возврат из другого раздела
|
||||
- **WHEN** доска прокручена вниз, выбран раздел «Задачи», затем раздел «Доска»
|
||||
- **THEN** доска показана с той же прокруткой
|
||||
|
||||
#### Scenario: Закрытые на доске приглушены
|
||||
- **WHEN** на доске AURORA-1620 «В работе», AURORA-1621 «Готово», AURORA-1622 «Снято», связь AURORA-1620 → AURORA-1621
|
||||
- **THEN** карточки AURORA-1621 и AURORA-1622 и стрелка AURORA-1620 → AURORA-1621 приглушены, AURORA-1620 — нет;
|
||||
под указателем AURORA-1621 — в полную яркость; критический путь и ящик «Проблемы» те же, что без приглушения
|
||||
|
||||
### Requirement: Список задач
|
||||
Страница `/app/issues` SHALL показывать задачи зеркала из `GET /issues` таблицей: ключ (открывает карточку задачи окном поверх списка), тема,
|
||||
тип, статус Jira, исполнитель, спринт, срок (`planned_end`, иначе `duedate`), эпик (имя эпика, иначе ключ), вид работы,
|
||||
признак «сосед»; статуса по факту в списке нет. Фильтры SHALL соответствовать параметрам `GET /issues` (спринт, исполнитель, вид
|
||||
работы, статус Jira, метка, эпик, только соседи, поиск по ключу или теме, скрыть закрытые), храниться в query-строке
|
||||
адреса и применяться сервером; варианты выпадающих списков SHALL браться из снимка `GET /board`; значение фильтра из адреса, которого нет среди
|
||||
вариантов, SHALL показываться в списке с пометкой «(нет на доске)». Флажок «скрыть закрытые» SHALL
|
||||
быть включён по умолчанию (в адресе ничего), снятый — `closed=1` в адресе; включённый — запрос с `hide_closed=1`,
|
||||
кроме случая, когда в фильтре «Статус Jira» выбран статус из `closed_statuses`. Параметр `fact_status` в адресе
|
||||
игнорируется. Задачи SHALL
|
||||
загружаться порциями по 50 строк; «Показать ещё» SHALL быть доступна, пока последняя порция полная.
|
||||
|
||||
#### Scenario: Фильтр в адресе
|
||||
- **WHEN** выбран спринт «AWPCA_Спринт 2» и исполнитель
|
||||
- **THEN** адрес содержит `sprint=` и `assignee=`, таблица — результат `GET /issues` с этими параметрами; открытие этого
|
||||
адреса в новой вкладке показывает ту же выборку
|
||||
|
||||
#### Scenario: Конец списка
|
||||
- **WHEN** вторая порция вернула 17 задач
|
||||
- **THEN** в таблице 67 задач, «Показать ещё» не показана
|
||||
|
||||
#### Scenario: Фильтр по закрытому спринту
|
||||
- **WHEN** открыт адрес `/app/issues?sprint=AWPCA_Спринт 0`, а в снимке доски такого спринта нет
|
||||
- **THEN** выпадающий список «Спринт» показывает «AWPCA_Спринт 0 (нет на доске)» выбранным, таблица — по этому фильтру
|
||||
|
||||
#### Scenario: Карточка поверх списка
|
||||
- **WHEN** список отфильтрован по спринту, прокручен и догружен «Показать ещё», открыта карточка задачи и окно закрыто
|
||||
- **THEN** адрес списка с тем же фильтром, в таблице те же строки, прокрутка та же
|
||||
|
||||
#### Scenario: Закрытые скрыты по умолчанию
|
||||
- **WHEN** открыт `/app/issues`, в зеркале задачи в статусах «В работе», «Готово», «Снято»
|
||||
- **THEN** флажок «скрыть закрытые» включён, запрос `GET /issues` с `hide_closed=1`, в таблице только «В работе»; после
|
||||
снятия флажка адрес содержит `closed=1`, в таблице все три
|
||||
|
||||
#### Scenario: Выбран закрытый статус
|
||||
- **WHEN** флажок «скрыть закрытые» включён и в «Статус Jira» выбрано «Готово»
|
||||
- **THEN** запрос `GET /issues?status=Готово` без `hide_closed`, в таблице задачи в статусе «Готово»
|
||||
|
||||
### Requirement: Карточка задачи
|
||||
Адрес `/app/issues/{key}` SHALL открывать карточку задачи из `GET /issues/{key}` модальным окном поверх страницы, с
|
||||
которой её открыли (список «Задачи» или доска); страница под окном SHALL оставаться смонтированной и сохранять фильтры,
|
||||
загруженные строки, прокрутку и раскладку. Перезагрузка страницы с открытым окном SHALL сохранять страницу под окном. Открытый напрямую
|
||||
адрес (новая вкладка, закладка) SHALL
|
||||
показывать окно поверх списка задач без фильтров. Окно SHALL закрываться кнопкой ✕, клавишей Esc и щелчком по фону (нажатие и отпускание — на фоне; выделение текста,
|
||||
отпущенное за краем окна, окно не закрывает) и
|
||||
возвращать на страницу, с которой его открыли, даже после переходов по связям внутри окна; «назад» браузера после
|
||||
закрытия MUST NOT открывать карточку снова. Переход по связанной задаче SHALL менять содержимое окна и адрес, «назад»
|
||||
браузера — возвращать к прежней карточке. Пока окно открыто, фокус SHALL оставаться в окне, страница под ним не
|
||||
прокручивается.
|
||||
|
||||
Содержимое окна:
|
||||
- шапка: ключ, статус Jira, «Открыть в Jira», ✕, тема; строка
|
||||
«исполнитель · срок · спринт · эпик» без пустых частей, срок — `planned_end`, иначе `duedate`, с пометкой «просрочено»,
|
||||
если он раньше сегодняшней даты и статус Jira не входит в `closed_statuses`; эпик — темой
|
||||
(`epic_summary`), иначе ключом;
|
||||
- «Связи» по группам в порядке: «Зависит от» (входящие связи логических типов `блокирует начало` и `блокирует
|
||||
завершение` из `link_types`, с меткой «начало» / «завершение»), «Блокирует» (исходящие тех же типов, с теми же метками),
|
||||
«Связана с» (тип `связанные` в обе стороны), «Прочие» (тип Jira вне `link_types`: имя типа и направление «→» / «←»);
|
||||
в строке — ключ (открывает связанную задачу в окне), тема и статус Jira, а если задачи нет в зеркале — ключ и «нет в
|
||||
зеркале»; пустые группы не показываются, без связей — «связей нет»;
|
||||
- «Описание» — текст, отрендеренный по требованию «Разметка Jira в карточке», с кнопкой «исходник» / «текст» в
|
||||
заголовке раздела; длиннее 8 строк — свёрнуто с кнопкой «развернуть» / «свернуть» в обоих режимах; без описания
|
||||
раздела нет;
|
||||
- «Комментарии (N)» — новые сверху, автор, время, кнопка «исходник» / «текст» у каждого комментария, текст,
|
||||
отрендеренный по требованию «Разметка Jira в карточке»; кнопка «Скрыть» / «Показать» сворачивает раздел, выбор SHALL
|
||||
запоминаться в браузере для всех карточек; без комментариев — «комментариев нет»;
|
||||
- «Служебные заметки» (требование «Служебные заметки»);
|
||||
- «Прочие поля» — непустые из: тип, приоритет, автор, метки, плановое начало, обновлена в Jira, сосед по связям;
|
||||
- «Команды моста (N)» — 10 последних команд по задаче (id, пакет, тип, статус, время завершения, ошибка), раздел свёрнут
|
||||
при открытии; если команд не было — текст «команд по задаче не было».
|
||||
На ширине от 900 px «Связи», «Описание», «Комментарии» — в левой колонке, заметки, прочие поля и команды — в правой;
|
||||
уже — одна колонка: заметки, связи, описание, комментарии, прочие поля, команды. Если задачи нет в зеркале (404), окно
|
||||
SHALL сообщать об этом с кнопкой «Закрыть»; хаб не отвечает — сообщение и «Повторить», ранее загруженные данные
|
||||
остаются.
|
||||
|
||||
#### Scenario: Связанная задача
|
||||
- **WHEN** у AURORA-1620 исходящая связь `ASUPR_Блокер_Начала` на AURORA-1621 «Расчёт свода» в статусе «В работе»
|
||||
- **THEN** в карточке в группе «Блокирует» строка «начало · AURORA-1621 · Расчёт свода · В работе»; щелчок по ключу
|
||||
меняет содержимое окна на AURORA-1621, адрес — `/app/issues/AURORA-1621`; «назад» браузера возвращает AURORA-1620
|
||||
|
||||
#### Scenario: Входящий блокер
|
||||
- **WHEN** у AURORA-1620 входящая связь `ASUPR_Блокер_Завершения` от AURORA-1500
|
||||
- **THEN** строка с AURORA-1500 и меткой «завершение» — в группе «Зависит от»
|
||||
|
||||
#### Scenario: Задача без команд
|
||||
- **WHEN** по AURORA-1620 не было команд
|
||||
- **THEN** в разделе «Команды моста» — текст «команд по задаче не было», таблицы нет
|
||||
|
||||
#### Scenario: Закрытие после переходов
|
||||
- **WHEN** со списка «Задачи» открыта AURORA-1620, в окне — переход по связи на AURORA-1621, нажата Esc
|
||||
- **THEN** окно закрыто, адрес — адрес списка с прежними фильтрами; «назад» браузера не открывает карточку
|
||||
|
||||
#### Scenario: Прямой адрес
|
||||
- **WHEN** в новой вкладке открыт `/app/issues/AURORA-1620`, затем нажат ✕
|
||||
- **THEN** под окном был список задач без фильтров; после закрытия адрес `/app/issues`, «назад» не открывает карточку
|
||||
|
||||
#### Scenario: Скрытые комментарии
|
||||
- **WHEN** в карточке нажато «Скрыть» у комментариев, затем страница перезагружена и открыта другая карточка
|
||||
- **THEN** комментарии в ней скрыты, видна кнопка «Показать» и число комментариев
|
||||
|
||||
#### Scenario: Просрочка закрытой задачи
|
||||
- **WHEN** у AURORA-1620 срок 2026-10-01, сегодня 2026-10-17, статус Jira «Готово»
|
||||
- **THEN** в строке под темой «срок 01.10» без пометки «просрочено»; при статусе «В работе» — с пометкой
|
||||
|
||||
### Requirement: Служебные заметки
|
||||
Карточка задачи SHALL показывать служебные заметки в режиме чтения: «Вид работы», «Сделано»,
|
||||
«Осталось», «Заметка владельца» (пустые — «—») и время и автор последнего изменения; кнопка «Править» SHALL открывать
|
||||
форму: «вид работы» — выбор из значений `kind_labels` настроек (и текущего значения, если его там нет, и пустого),
|
||||
«сделано», «осталось», «заметка владельца» —
|
||||
многострочный текст; «Отмена» SHALL возвращать режим чтения, при несохранённых правках — после подтверждения.
|
||||
Сохранение SHALL отправлять `PUT /issues/{key}/note` только с изменёнными полями и `updated_by: "web"`; очищенное поле
|
||||
отправляется как `null`. После успеха SHALL показываться режим чтения с сохранённой заметкой; список задач под окном SHALL
|
||||
показывать новый «вид работы» без «Перечитать», доска под окном SHALL перечитать снимок
|
||||
(`GET /board`) без потери раскладки и прокрутки; при 422 — текст ошибки у формы без
|
||||
потери введённого. Закрытие окна, переход по связи и уход в другой раздел с несохранёнными правками SHALL требовать
|
||||
подтверждения. Статуса по факту в заметках приложения нет: форма его не показывает и не отправляет, сохранённое в хабе значение не
|
||||
меняется. Заметки пишутся только в хаб, в Jira приложение ничего не отправляет.
|
||||
|
||||
#### Scenario: Только изменённые поля
|
||||
- **WHEN** в заметке нажато «Править», изменено только «осталось» и нажато «Сохранить»
|
||||
- **THEN** тело `PUT /issues/{key}/note` — `{rest_text: "…", updated_by: "web"}`; карточка снова в режиме чтения с новым
|
||||
«Осталось»
|
||||
|
||||
#### Scenario: Уход с несохранёнными правками
|
||||
- **WHEN** в форме заметки есть несохранённая правка и нажата Esc
|
||||
- **THEN** приложение спрашивает подтверждение; при отказе окно открыто, форма с введённым текстом
|
||||
|
||||
#### Scenario: Заметка видна на доске
|
||||
- **WHEN** карточка AURORA-9002 открыта с доски, вид работы изменён на «тестирование» и сохранён
|
||||
- **THEN** доска под окном остаётся на месте и перезапрашивается (`GET /board` отдаёт `work_kind` «тестирование»); окно закрывается без потери раскладки и прокрутки доски
|
||||
@ -214,12 +214,15 @@ def h_known_keys(hub, conn, m, q, body):
|
||||
return 200, {"keys": mirror.known_keys(conn)}
|
||||
|
||||
|
||||
ISSUE_FILTERS = ("sprint", "assignee", "work_kind", "fact_status", "status", "label", "epic", "neighbor", "q")
|
||||
ISSUE_FILTERS = ("sprint", "assignee", "work_kind", "fact_status", "status", "label", "epic", "neighbor", "q", "hide_closed")
|
||||
|
||||
|
||||
def h_issues(hub, conn, m, q, body):
|
||||
filters = {k: q.get(k) for k in ISSUE_FILTERS}
|
||||
return 200, {"issues": mirror.list_issues(conn, filters, _int_param(q, "limit", 50), _int_param(q, "offset", 0))}
|
||||
closed = settings.get(conn, "closed_statuses")
|
||||
closed = [s for s in closed if isinstance(s, str)] if isinstance(closed, list) else []
|
||||
return 200, {"issues": mirror.list_issues(conn, filters, _int_param(q, "limit", 50), _int_param(q, "offset", 0),
|
||||
closed_statuses=closed)}
|
||||
|
||||
|
||||
def h_issue(hub, conn, m, q, body):
|
||||
|
||||
@ -80,7 +80,8 @@ def _issue_dict(row) -> dict:
|
||||
return d
|
||||
|
||||
|
||||
def list_issues(conn, filters=None, limit=50, offset=0) -> list[dict]:
|
||||
def list_issues(conn, filters=None, limit=50, offset=0, closed_statuses=()) -> list[dict]:
|
||||
"""hide_closed — без задач, чей status из closed_statuses (задача без статуса не закрыта)."""
|
||||
filters = filters or {}
|
||||
where, args = [], []
|
||||
for name, sql in _FILTERS.items():
|
||||
@ -93,6 +94,9 @@ def list_issues(conn, filters=None, limit=50, offset=0) -> list[dict]:
|
||||
if filters.get("neighbor") not in (None, ""):
|
||||
where.append("i.neighbor = ?")
|
||||
args.append(1 if filters["neighbor"] in (True, 1, "1", "true") else 0)
|
||||
if filters.get("hide_closed") in (True, 1, "1", "true") and closed_statuses:
|
||||
where.append(f"(i.status IS NULL OR i.status NOT IN ({', '.join('?' * len(closed_statuses))}))")
|
||||
args += list(closed_statuses)
|
||||
if filters.get("q"):
|
||||
where.append("(i.summary LIKE ? OR i.key LIKE ?)")
|
||||
args += [f"%{filters['q']}%", f"%{filters['q']}%"]
|
||||
|
||||
@ -31,6 +31,7 @@ DEFAULTS = {
|
||||
"link_types": {},
|
||||
"board_id": None,
|
||||
"relay_dir": None,
|
||||
"closed_statuses": ["Готово", "Снято"], # статусы Jira закрытых задач: список и доска приложения (hide-closed)
|
||||
}
|
||||
|
||||
# Что получает агент (без токенов и служебных ключей хаба)
|
||||
|
||||
@ -266,7 +266,7 @@ def test_settings_put_protected_key_in_batch_rejects_whole_batch(hub):
|
||||
{"pacing": 5}, {"pacing": {"ensure_fields": "abc"}}, {"pacing": {"ensure_fields": -1}},
|
||||
{"pacing": {"неизвестная": 1}}, {"port": "x"}, {"port": 0}, {"port": 70000}, {"backup_keep": -1},
|
||||
{"auto_refresh_minutes": 1.5}, {"min_agent_version": "2"}, {"link_types": []}, {"base_jql": 5},
|
||||
{"labels": {}}, {"initialized": 1},
|
||||
{"labels": {}}, {"initialized": 1}, {"closed_statuses": "Готово"},
|
||||
{"pacing": {"refresh": 3}, "backup_keep": -1}, # хорошее рядом с плохим не записывается
|
||||
])
|
||||
def test_settings_put_invalid_value_is_422(hub, body):
|
||||
@ -727,3 +727,18 @@ def test_attachment_flow_over_api(hub):
|
||||
a.post(f"/agent/commands/{cmd['id']}/result", {"status": "failed", "error": "HTTP 500"})
|
||||
retry = c.post(f"/commands/{cmd['id']}/retry", {"author": "claude"})
|
||||
assert c.get(f"/batches/{retry['id']}")["commands"][0]["params"] == {"key": "A-1", "file": sha, "filename": "ОСВ 60.xlsx"}
|
||||
|
||||
|
||||
def test_issues_hide_closed(hub):
|
||||
c = hub["client"]
|
||||
hub["agent"].post("/agent/state", {"issues": [{"key": "A-1", "summary": "Т", "status": "В работе", "labels": []},
|
||||
{"key": "A-2", "summary": "Т", "status": "Готово", "labels": []},
|
||||
{"key": "A-3", "summary": "Т", "status": "Снято", "labels": []},
|
||||
{"key": "A-4", "summary": "Т", "labels": []}], "links": []})
|
||||
|
||||
def keys(**q):
|
||||
return sorted(i["key"] for i in c.get("/issues", **q)["issues"])
|
||||
assert keys(hide_closed="1") == ["A-1", "A-4"]
|
||||
assert keys() == ["A-1", "A-2", "A-3", "A-4"]
|
||||
c.put("/settings", {"closed_statuses": ["Готово"]})
|
||||
assert keys(hide_closed="1") == ["A-1", "A-3", "A-4"]
|
||||
|
||||
@ -240,3 +240,18 @@ def test_check_comments_rejects(bad, field):
|
||||
|
||||
def test_check_comments_accepts_missing_and_valid():
|
||||
mirror.check_comments([issue("A-1"), issue("A-2", comments=[comment("1"), comment("2", author=None, body="")])])
|
||||
|
||||
|
||||
def test_list_issues_hide_closed(conn):
|
||||
mirror.apply_state(conn, [issue("A-1", status="В работе"), issue("A-2", status="Готово"),
|
||||
issue("A-3", status="Снято"), issue("A-4", status=None)], [])
|
||||
closed = ["Готово", "Снято"]
|
||||
|
||||
def keys(f, c=closed):
|
||||
return sorted(i["key"] for i in mirror.list_issues(conn, f, closed_statuses=c))
|
||||
assert keys({"hide_closed": "1"}) == ["A-1", "A-4"] # без статуса — не закрыта
|
||||
assert keys({"hide_closed": "true"}) == ["A-1", "A-4"]
|
||||
assert keys({}) == ["A-1", "A-2", "A-3", "A-4"]
|
||||
assert keys({"hide_closed": "0"}) == ["A-1", "A-2", "A-3", "A-4"]
|
||||
assert keys({"hide_closed": "1"}, []) == ["A-1", "A-2", "A-3", "A-4"] # пустой набор — ничего не скрыто
|
||||
assert keys({"hide_closed": "1", "status": "Готово"}) == [] # отменяет скрытие приложение, не сервер
|
||||
|
||||
@ -94,3 +94,15 @@ def test_attachment_pacing(conn):
|
||||
assert settings.pacing(conn)["ensure_attachment"] == 30
|
||||
assert settings.pacing_from_config({"issue_seconds": 90, "comment_seconds": 20})["ensure_attachment"] == 20
|
||||
settings.validate("pacing", {"ensure_attachment": 10})
|
||||
|
||||
|
||||
def test_closed_statuses_default(conn):
|
||||
assert settings.get(conn, "closed_statuses") == ["Готово", "Снято"]
|
||||
assert "closed_statuses" not in settings.AGENT_KEYS
|
||||
assert "closed_statuses" not in settings.for_agent(conn)
|
||||
|
||||
|
||||
def test_closed_statuses_must_be_list():
|
||||
with pytest.raises(ValueError):
|
||||
settings.validate("closed_statuses", "Готово")
|
||||
settings.validate("closed_statuses", ["Готово"])
|
||||
|
||||
@ -23,7 +23,7 @@ test("фильтр → карточка окном → заметка сохра
|
||||
test("окно поверх списка: прокрутка списка на месте при открытии и после закрытия", async ({ page }) => {
|
||||
await page.setViewportSize({ width: 1280, height: 320 });
|
||||
await login(page, "/app/issues");
|
||||
await expect(page.locator("table.grid tbody tr")).toHaveCount(10);
|
||||
await expect(page.locator("table.grid tbody tr")).toHaveCount(9); // AURORA-9006 «Готово» скрыта
|
||||
const y = await page.evaluate(() => { window.scrollTo(0, document.documentElement.scrollHeight); return window.scrollY; });
|
||||
expect(y).toBeGreaterThan(50);
|
||||
await page.locator("table.grid tbody tr").last().getByRole("link").click();
|
||||
@ -173,3 +173,15 @@ test("узкий экран: окно на всю ширину, заметки
|
||||
await expect(d.getByRole("columnheader", { name: "Источник" })).toBeAttached();
|
||||
expect(await d.evaluate((el) => el.scrollWidth <= el.clientWidth + 1)).toBe(true);
|
||||
});
|
||||
|
||||
test("закрытые скрыты по умолчанию, снятый флажок показывает их", async ({ page }) => {
|
||||
await login(page, "/app/issues");
|
||||
await expect(page.locator("table.grid tbody tr")).toHaveCount(9);
|
||||
await expect(page.getByRole("link", { name: "AURORA-9006", exact: true })).toHaveCount(0);
|
||||
const hide = page.getByRole("checkbox", { name: "скрыть закрытые" });
|
||||
await hide.click(); // не uncheck(): адрес меняется навигацией, флажок следует за ним не сразу
|
||||
await expect(page).toHaveURL(/closed=1/);
|
||||
await expect(hide).not.toBeChecked();
|
||||
await expect(page.getByRole("link", { name: "AURORA-9006", exact: true })).toBeVisible();
|
||||
await expect(page.locator("table.grid tbody tr")).toHaveCount(10);
|
||||
});
|
||||
|
||||
25
web/src/api/closed.test.ts
Normal file
25
web/src/api/closed.test.ts
Normal file
@ -0,0 +1,25 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { closedStatuses, isClosed } from "./closed.ts";
|
||||
|
||||
describe("закрытые статусы", () => {
|
||||
const set = closedStatuses({ closed_statuses: ["Готово", "Снято"] });
|
||||
|
||||
it("набор — из closed_statuses настроек", () => {
|
||||
expect([...set]).toEqual(["Готово", "Снято"]);
|
||||
});
|
||||
|
||||
it("нет настройки или она не список — ничего не закрыто", () => {
|
||||
expect(closedStatuses({}).size).toBe(0);
|
||||
expect(closedStatuses(undefined).size).toBe(0);
|
||||
expect(closedStatuses({ closed_statuses: "Готово" }).size).toBe(0);
|
||||
expect([...closedStatuses({ closed_statuses: ["Готово", 5] })]).toEqual(["Готово"]);
|
||||
});
|
||||
|
||||
it("закрыта — только задача со статусом из набора", () => {
|
||||
expect(isClosed("Снято", set)).toBe(true);
|
||||
expect(isClosed("В работе", set)).toBe(false);
|
||||
expect(isClosed("", set)).toBe(false);
|
||||
expect(isClosed(null, set)).toBe(false);
|
||||
expect(isClosed(undefined, set)).toBe(false);
|
||||
});
|
||||
});
|
||||
14
web/src/api/closed.ts
Normal file
14
web/src/api/closed.ts
Normal file
@ -0,0 +1,14 @@
|
||||
import { useMemo } from "react";
|
||||
import { useSettings } from "./queries.ts";
|
||||
import type { HubSettings } from "./types.ts";
|
||||
|
||||
// закрытые задачи (hide-closed): статус Jira из настройки closed_statuses; нет настройки или она не список — закрытых нет
|
||||
export const closedStatuses = (s: HubSettings | undefined): Set<string> =>
|
||||
new Set(Array.isArray(s?.closed_statuses) ? s.closed_statuses.filter((x): x is string => typeof x === "string") : []);
|
||||
|
||||
export const isClosed = (status: string | null | undefined, closed: Set<string>): boolean => !!status && closed.has(status);
|
||||
|
||||
export function useClosedStatuses(): Set<string> {
|
||||
const settings = useSettings();
|
||||
return useMemo(() => closedStatuses(settings.data), [settings.data]);
|
||||
}
|
||||
@ -2,8 +2,9 @@ import { useInfiniteQuery, useMutation, useQuery, useQueryClient } from "@tansta
|
||||
import { api } from "./client.ts";
|
||||
import type { BoardSnapshot, HubSettings, IssueDetail, IssueRow, Note, NoteFields } from "./types.ts";
|
||||
|
||||
export const ISSUE_FILTER_NAMES = ["sprint", "assignee", "work_kind", "fact_status", "status", "label", "epic", "neighbor", "q"] as const;
|
||||
export type IssueFilters = Partial<Record<(typeof ISSUE_FILTER_NAMES)[number], string>>;
|
||||
// фильтры списка в адресе; hide_closed в адрес не пишется — его выводит список из флажка «скрыть закрытые» (closed=1 — снят)
|
||||
export const ISSUE_FILTER_NAMES = ["sprint", "assignee", "work_kind", "status", "label", "epic", "neighbor", "q"] as const;
|
||||
export type IssueFilters = Partial<Record<(typeof ISSUE_FILTER_NAMES)[number] | "hide_closed", string>>;
|
||||
export const ISSUE_PAGE = 50;
|
||||
|
||||
export const qk = {
|
||||
|
||||
@ -11,11 +11,11 @@ export interface SyncRun { started_at: string; finished_at: string; issues_count
|
||||
export interface BoardSnapshot { generated_at: string; last_sync: SyncRun | null; issues: BoardIssue[]; links: BoardLink[] }
|
||||
|
||||
// словари настроек: kind_labels — вид работы → метка Jira, link_types — логический тип → имя типа связи Jira,
|
||||
// status_labels — статус по факту → метка Jira
|
||||
// closed_statuses — статусы Jira закрытых задач
|
||||
export interface HubSettings {
|
||||
kind_labels?: Record<string, string>;
|
||||
link_types?: Record<string, string>;
|
||||
status_labels?: Record<string, string>;
|
||||
closed_statuses?: unknown;
|
||||
project_key?: string;
|
||||
[key: string]: unknown;
|
||||
}
|
||||
@ -28,10 +28,11 @@ export interface IssueRow {
|
||||
neighbor: boolean; fact_status?: string | null; work_kind?: string | null;
|
||||
}
|
||||
|
||||
export const NOTE_FIELDS = ["fact_status", "work_kind", "done_text", "rest_text", "owner_note"] as const;
|
||||
// поля заметки, которые приложение показывает и правит; fact_status хаб ещё отдаёт, приложение его не читает (hide-closed)
|
||||
export const NOTE_FIELDS = ["work_kind", "done_text", "rest_text", "owner_note"] as const;
|
||||
export type NoteField = (typeof NOTE_FIELDS)[number];
|
||||
export type NoteFields = Partial<Record<NoteField, string | null>>;
|
||||
export type Note = Record<NoteField, string | null> & { key: string; updated_at: string; updated_by: string };
|
||||
export type Note = Record<NoteField, string | null> & { key: string; fact_status?: string | null; updated_at: string; updated_by: string };
|
||||
|
||||
export interface IssueCommand {
|
||||
id: number; batch_id: number; type: string; status: string; finished_at: string | null; error: string | null;
|
||||
|
||||
@ -1,4 +1,5 @@
|
||||
import { useCallback, useMemo, useRef, useState } from "react";
|
||||
import { useClosedStatuses } from "../api/closed.ts";
|
||||
import { useSettings } from "../api/queries.ts";
|
||||
import { computeVisible, emptyReason } from "./analysis/visible.ts";
|
||||
import { BoardCanvas } from "./components/BoardCanvas.tsx";
|
||||
@ -17,6 +18,7 @@ import "./board.css";
|
||||
|
||||
export function BoardPage() {
|
||||
const settings = useSettings();
|
||||
const closedStatuses = useClosedStatuses();
|
||||
const d = useMemo(() => (settings.data ? dictsFromSettings(settings.data) : null), [settings.data]);
|
||||
const src = useBoardSource(d && !d.missing.length ? d.dicts : null);
|
||||
const prefs = useBoardPrefs(src.model);
|
||||
@ -76,7 +78,7 @@ export function BoardPage() {
|
||||
</div>
|
||||
{exportText !== null && <ExportBox text={exportText} onClose={() => setExportText(null)} />}
|
||||
<BoardCanvas vis={vis} byDate={prefs.filters.byDate} snap={prefs.snap} today={today} store={prefs.store}
|
||||
sprintSel={prefs.filters.sprints} emptyText={vis.nodes.length ? "" : emptyReason(src.model, vis, prefs.filters)}
|
||||
sprintSel={prefs.filters.sprints} emptyText={vis.nodes.length ? "" : emptyReason(src.model, vis, prefs.filters)} closedStatuses={closedStatuses}
|
||||
onToggleLane={prefs.toggleLane} onMoveLane={(name, to) => prefs.moveLane(vis.lanes.map((l) => l.name), name, to)}
|
||||
onColWidth={prefs.setColWidth} saveRef={saveRef}>
|
||||
{prefs.problems && <ProblemsDrawer problems={vis.problems} onClose={() => prefs.toggle("problems")} />}
|
||||
|
||||
@ -1,5 +1,6 @@
|
||||
{
|
||||
"kind_labels": {"аналитика": "kind-analytics", "решение": "kind-decision", "актуализация": "kind-docs", "разработка": "kind-dev", "UI": "kind-ui", "тестирование": "kind-test", "организационная": "kind-org"},
|
||||
"link_types": {"блокирует начало": "ASUPR_Блокер_Начала", "блокирует завершение": "ASUPR_Блокер_Завершения", "связанные": "ASUPR_Связанные"},
|
||||
"status_labels": {"Готово — закрыть": "fact-done", "В работе": "fact-in-progress", "Ожидание разработки": "fact-open"}
|
||||
"status_labels": {"Готово — закрыть": "fact-done", "В работе": "fact-in-progress", "Ожидание разработки": "fact-open"},
|
||||
"closed_statuses": ["Готово", "Снято"]
|
||||
}
|
||||
|
||||
@ -90,6 +90,9 @@ svg.guides line{stroke:var(--guide);stroke-width:1;stroke-dasharray:4 3}
|
||||
.card{position:absolute;width:240px;height:120px;box-sizing:border-box;background:var(--card);border:1px solid var(--line);border-radius:6px;padding:12px 14px;display:flex;flex-direction:column;gap:4px;overflow:hidden;cursor:default;touch-action:none}
|
||||
.card:hover{border-color:var(--accent)}
|
||||
.card.out{border-style:dashed;opacity:.7}
|
||||
.card.closed{opacity:.4}
|
||||
.card.closed:hover,.card.closed:focus-within{opacity:1}
|
||||
.edges path.closed{opacity:.4}
|
||||
.card.crit{box-shadow:0 0 0 2px var(--guide)}
|
||||
.card.focus{box-shadow:0 0 0 2px var(--accent)}
|
||||
.card.drag{cursor:grabbing;border-color:var(--accent);box-shadow:0 6px 18px rgba(0,0,0,.18);z-index:2}
|
||||
|
||||
@ -32,6 +32,41 @@ function draw(over: Partial<Parameters<typeof BoardCanvas>[0]> = {}) {
|
||||
const board = () => document.querySelector(".board") as HTMLElement;
|
||||
|
||||
describe("BoardCanvas", () => {
|
||||
// закрытые (hide-closed): src первой видимой связи — «Готово», её dst — «Снято» (плюс закрытые из фикстуры)
|
||||
function closedVis() {
|
||||
const vis = baseProps().vis;
|
||||
const l = vis.links[0];
|
||||
const st: Record<string, string> = { [l.src]: "Готово", [l.dst]: "Снято" };
|
||||
return { vis: { ...vis, nodes: vis.nodes.map((n) => (st[n.key] ? { ...n, status: st[n.key] } : n)) }, l };
|
||||
}
|
||||
const CLOSED = new Set(["Готово", "Снято"]);
|
||||
|
||||
it("закрытые карточки и стрелки, у которых закрыт хоть один конец, — класс closed", () => {
|
||||
const { vis, l } = closedVis();
|
||||
draw({ vis, closedStatuses: CLOSED });
|
||||
const keys = vis.nodes.filter((n) => CLOSED.has(n.status)).map((n) => n.key); // в фикстуре ещё AURORA-9006 «Готово»
|
||||
expect(keys).toEqual(expect.arrayContaining([l.src, l.dst]));
|
||||
const closedCards = [...board().querySelectorAll(".card.closed[data-key]")].map((e) => e.getAttribute("data-key")).sort();
|
||||
expect(closedCards).toEqual([...keys].sort());
|
||||
const touching = vis.links.filter((x) => keys.includes(x.src) || keys.includes(x.dst)).length;
|
||||
expect(document.querySelectorAll("svg.edges > path.closed")).toHaveLength(touching);
|
||||
expect(document.querySelectorAll("svg.edges > path:not(.closed)").length).toBe(vis.links.length - touching);
|
||||
expect(document.querySelectorAll(".measure .card.closed")).toHaveLength(0); // слой измерения — без приглушения
|
||||
});
|
||||
|
||||
it("без closedStatuses — ничего не приглушено", () => {
|
||||
draw({ vis: closedVis().vis });
|
||||
expect(document.querySelectorAll(".card.closed, svg.edges > path.closed")).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("закрытый сосед — и out, и closed", () => {
|
||||
const { vis, l } = closedVis();
|
||||
draw({ vis: { ...vis, nodes: vis.nodes.map((n) => (n.key === l.src ? { ...n, dim: true } : n)) }, closedStatuses: CLOSED });
|
||||
const card = board().querySelector(`.card[data-key="${l.src}"]`)!;
|
||||
expect(card).toHaveClass("out");
|
||||
expect(card).toHaveClass("closed");
|
||||
});
|
||||
|
||||
it("дорожки по эпикам и карточки задач с заглушкой", () => {
|
||||
draw();
|
||||
const lanes = [...board().querySelectorAll(".lane-name")].map((e) => e.textContent);
|
||||
|
||||
@ -1,4 +1,5 @@
|
||||
import { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState, type PointerEvent, type ReactNode } from "react";
|
||||
import { isClosed } from "../../api/closed.ts";
|
||||
import { CW, GRID, MINH, MINW, TOP } from "../constants.ts";
|
||||
import { alignEdges } from "../layout/align.ts";
|
||||
import { clampToCol, computeLayout, measuredHeight, place, type Layout, type Placed, type RelBox } from "../layout/layout.ts";
|
||||
@ -15,6 +16,7 @@ export interface LayoutStore { laneOrder: string[]; folded: Set<string>; colWidt
|
||||
export interface BoardCanvasProps {
|
||||
vis: Visible; byDate: boolean; snap: boolean; today: string; store: LayoutStore; sprintSel: Set<string>;
|
||||
emptyText: string;
|
||||
closedStatuses?: Set<string>; // статусы Jira закрытых задач: их карточки и стрелки приглушены (hide-closed), на модель не влияют
|
||||
saveRef?: { current: (() => void) | null };
|
||||
onToggleLane(name: string): void;
|
||||
onMoveLane(name: string, to: number): void;
|
||||
@ -22,7 +24,9 @@ export interface BoardCanvasProps {
|
||||
children?: ReactNode;
|
||||
}
|
||||
|
||||
export function BoardCanvas({ vis, byDate, snap, today, store, sprintSel, emptyText, onToggleLane, onMoveLane, onColWidth, saveRef, children }: BoardCanvasProps) {
|
||||
const NONE = new Set<string>();
|
||||
|
||||
export function BoardCanvas({ vis, byDate, snap, today, store, sprintSel, emptyText, closedStatuses = NONE, onToggleLane, onMoveLane, onColWidth, saveRef, children }: BoardCanvasProps) {
|
||||
const boardRef = useRef<HTMLDivElement>(null);
|
||||
const measureRef = useRef<HTMLDivElement>(null);
|
||||
const wrapRef = useRef<HTMLDivElement>(null);
|
||||
@ -33,6 +37,7 @@ export function BoardCanvas({ vis, byDate, snap, today, store, sprintSel, emptyT
|
||||
const [guides, setGuides] = useState<{ x: number | null; y: number | null } | null>(null);
|
||||
const [scroll, setScroll] = useState({ left: 0, wide: false, wrapW: 0, scrollW: 0 });
|
||||
const [dragTarget, setDragTarget] = useState<{ from: number; to: number } | null>(null);
|
||||
const closedKeys = useMemo(() => new Set(vis.nodes.filter((n) => isClosed(n.status, closedStatuses)).map((n) => n.key)), [vis.nodes, closedStatuses]);
|
||||
|
||||
// раскладка как render() links.html: при новой выборке, режиме и ширинах колонок — заново, с сохранённой из хранилища
|
||||
useLayoutEffect(() => {
|
||||
@ -185,9 +190,9 @@ export function BoardCanvas({ vis, byDate, snap, today, store, sprintSel, emptyT
|
||||
))}
|
||||
{Object.entries(placed.pos).filter(([k]) => byKey[k]).map(([k, box]) => (
|
||||
<Card key={k} node={byKey[k]} box={box} rel={rel[k]} multiSprint={multiSprint} sprintSel={sprintSel}
|
||||
onPointerDown={onCardDown} onAutoSize={onAutoSize} />
|
||||
closed={closedKeys.has(k)} onPointerDown={onCardDown} onAutoSize={onAutoSize} />
|
||||
))}
|
||||
<Edges links={vis.links} placed={placed} />
|
||||
<Edges links={vis.links} placed={placed} closedKeys={closedKeys} />
|
||||
<Guides x={guides?.x ?? null} y={guides?.y ?? null} width={placed.width} height={placed.height} />
|
||||
</>
|
||||
)}
|
||||
|
||||
@ -12,5 +12,6 @@ describe("cardPropsEqual (T11.1)", () => {
|
||||
expect(cardPropsEqual(base, { ...base, box: { x: 9, y: 2, w: 240, h: 120 } })).toBe(false);
|
||||
expect(cardPropsEqual(base, { ...base, node: { key: "A-2" } as CardProps["node"] })).toBe(false);
|
||||
expect(cardPropsEqual(base, { ...base, sprintSel: new Set(["S"]) })).toBe(false);
|
||||
expect(cardPropsEqual(base, { ...base, closed: true })).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
@ -12,6 +12,7 @@ export interface CardProps {
|
||||
rel?: RelBox;
|
||||
multiSprint: boolean;
|
||||
sprintSel: Set<string>;
|
||||
closed?: boolean; // задача закрыта (статус из closed_statuses) — карточка приглушена
|
||||
onPointerDown?: (ev: PointerEvent<HTMLDivElement>, key: string) => void;
|
||||
onAutoSize?: (key: string) => void;
|
||||
}
|
||||
@ -19,12 +20,12 @@ export interface CardProps {
|
||||
// T11.1: при перетаскивании одной карточки place() создаёт новые box у всех — сравниваем box по значению
|
||||
export function cardPropsEqual(a: CardProps, b: CardProps): boolean {
|
||||
const sameBox = a.box === b.box || (!!a.box && !!b.box && a.box.x === b.box.x && a.box.y === b.box.y && a.box.w === b.box.w && a.box.h === b.box.h);
|
||||
return sameBox && a.node === b.node && a.rel === b.rel && a.multiSprint === b.multiSprint && a.sprintSel === b.sprintSel &&
|
||||
return sameBox && a.node === b.node && a.rel === b.rel && a.multiSprint === b.multiSprint && a.sprintSel === b.sprintSel && !!a.closed === !!b.closed &&
|
||||
a.onPointerDown === b.onPointerDown && a.onAutoSize === b.onAutoSize;
|
||||
}
|
||||
|
||||
export const Card = memo(function Card({ node: n, box, rel, multiSprint, sprintSel, onPointerDown, onAutoSize }: CardProps) {
|
||||
const cls = "card" + (n.dim || n.stub ? " out" : "") + (n.crit ? " crit" : "") + (n.focus ? " focus" : "");
|
||||
export const Card = memo(function Card({ node: n, box, rel, multiSprint, sprintSel, closed, onPointerDown, onAutoSize }: CardProps) {
|
||||
const cls = "card" + (n.dim || n.stub ? " out" : "") + (closed ? " closed" : "") + (n.crit ? " crit" : "") + (n.focus ? " focus" : "");
|
||||
const style = box ? { left: box.x, top: box.y, width: box.w, height: box.h } : { width: CW };
|
||||
const showTag = !n.stub && (multiSprint || n.dim); // метка спринта — если спринтов на доске больше одного или это сосед
|
||||
return (
|
||||
|
||||
@ -3,7 +3,7 @@ import { edgePath } from "../layout/edges.ts";
|
||||
import { RELATED } from "../constants.ts";
|
||||
import type { VisLink } from "../types.ts";
|
||||
|
||||
export function Edges({ links, placed }: { links: VisLink[]; placed: Placed }) {
|
||||
export function Edges({ links, placed, closedKeys }: { links: VisLink[]; placed: Placed; closedKeys?: Set<string> }) {
|
||||
return (
|
||||
<svg className="edges">
|
||||
<defs>
|
||||
@ -18,7 +18,8 @@ export function Edges({ links, placed }: { links: VisLink[]; placed: Placed }) {
|
||||
const a = placed.pos[l.src], b = placed.pos[l.dst];
|
||||
if (!a || !b) return null;
|
||||
const related = l.type === RELATED;
|
||||
return <path key={`${l.src}>${l.dst}${l.type}`} d={edgePath(a, b)} fill="none"
|
||||
const closed = !!closedKeys && (closedKeys.has(l.src) || closedKeys.has(l.dst)); // закрыт хоть один конец — приглушена
|
||||
return <path key={`${l.src}>${l.dst}${l.type}`} d={edgePath(a, b)} fill="none" className={closed ? "closed" : undefined}
|
||||
stroke={related ? "var(--edge)" : l.hand ? "var(--guide)" : "var(--edge-strong)"}
|
||||
strokeWidth={l.crit ? "4" : related ? "1.4" : "1.8"}
|
||||
strokeDasharray={related ? "5 4" : undefined}
|
||||
|
||||
@ -40,14 +40,15 @@ function overflow(scroll: number, client: number) {
|
||||
describe("карточка задачи", () => {
|
||||
beforeEach(() => setToken("tok"));
|
||||
|
||||
it("шапка: ключ, статусы, тема, исполнитель · срок · спринт · эпик по имени; ссылка в Jira", async () => {
|
||||
it("шапка: ключ, статус Jira, тема, исполнитель · срок · спринт · эпик по имени; ссылка в Jira", async () => {
|
||||
mockFetch((_m, url) => url.pathname === "/settings" ? json(200, settings) : json(200, issue("AURORA-9001", { planned_end: "2020-01-10" })));
|
||||
open("/issues/AURORA-9001");
|
||||
expect(await screen.findByRole("heading", { level: 2, name: "Тема AURORA-9001" })).toBeInTheDocument();
|
||||
const head = screen.getByTestId("issue-head");
|
||||
expect(within(head).getByText("AURORA-9001")).toBeInTheDocument();
|
||||
expect(within(head).getByText("В работе", { selector: ".badge.status" })).toBeInTheDocument();
|
||||
expect(within(head).getByText("факт: В работе", { selector: ".badge.fact" })).toHaveClass("fact-in-progress"); // как в макете раздела 1
|
||||
expect(head.querySelector(".badge.fact")).toBeNull(); // статуса по факту в карточке нет (hide-closed)
|
||||
expect(within(head).queryByText(/факт:/)).toBeNull();
|
||||
expect(within(head).getByTestId("issue-facts")).toHaveTextContent("Иванов Иван Иванович · срок 10.01 просрочено · AWPCA_Спринт 2 · эпик: Тестовый эпик");
|
||||
expect(within(head).getByText("просрочено")).toHaveClass("overdue");
|
||||
expect(screen.getByRole("link", { name: "Открыть в Jira ↗" })).toHaveAttribute("href", "https://sdlc.go.rshbank.ru/jira/browse/AURORA-9001");
|
||||
@ -383,14 +384,24 @@ describe("карточка задачи", () => {
|
||||
expect(screen.queryByRole("columnheader", { name: "Команда" })).toBeNull();
|
||||
});
|
||||
|
||||
it("T14.3: id подсказок «статуса по факту» уникален и связан с полем", async () => {
|
||||
it("закрытая задача со сроком в прошлом — без пометки «просрочено»", async () => {
|
||||
mockFetch((_m, url) => url.pathname === "/settings" ? json(200, settings) : json(200, issue("AURORA-9001", { planned_end: "2020-01-10", status: "Готово" })));
|
||||
open("/issues/AURORA-9001");
|
||||
const facts = await screen.findByTestId("issue-facts");
|
||||
expect(facts).toHaveTextContent("срок 10.01");
|
||||
expect(within(facts).queryByText("просрочено")).toBeNull();
|
||||
});
|
||||
|
||||
it("статуса по факту нет ни в чтении заметок, ни в форме", async () => {
|
||||
mockFetch((_m, url) => url.pathname === "/settings" ? json(200, settings) : json(200, issue("AURORA-9001")));
|
||||
open("/issues/AURORA-9001");
|
||||
await userEvent.click(await screen.findByRole("button", { name: "Править" }));
|
||||
const input = await screen.findByLabelText("Статус по факту");
|
||||
const id = input.getAttribute("list")!;
|
||||
expect(id).not.toBe("fact-statuses");
|
||||
expect(document.getElementById(id)?.tagName).toBe("DATALIST");
|
||||
const view = await screen.findByRole("region", { name: "Служебные заметки" });
|
||||
expect(view).not.toHaveTextContent("Статус по факту");
|
||||
expect(view).toHaveTextContent("Вид работы");
|
||||
await userEvent.click(screen.getByRole("button", { name: "Править" }));
|
||||
await screen.findByLabelText("Осталось");
|
||||
expect(screen.queryByLabelText("Статус по факту")).toBeNull();
|
||||
expect(document.querySelector("datalist")).toBeNull();
|
||||
});
|
||||
|
||||
it("заметки в режиме чтения: пустые — «—», время и автор; «Отмена» без правок — сразу", async () => {
|
||||
@ -425,7 +436,7 @@ describe("карточка задачи", () => {
|
||||
mockFetch((_m, url) => url.pathname === "/settings" ? json(200, settings) : json(200, issue("AURORA-9001", { note: null })));
|
||||
open("/issues/AURORA-9001");
|
||||
const view = await screen.findByRole("region", { name: "Служебные заметки" });
|
||||
expect(within(view).getAllByText("—")).toHaveLength(5);
|
||||
expect(within(view).getAllByText("—")).toHaveLength(4); // вид работы, сделано, осталось, заметка владельца
|
||||
expect(within(view).getByRole("button", { name: "Править" })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
|
||||
@ -1,10 +1,11 @@
|
||||
import { type RefObject, useLayoutEffect, useRef, useState } from "react";
|
||||
import { ApiError } from "../api/client.ts";
|
||||
import { useClosedStatuses } from "../api/closed.ts";
|
||||
import { useIssue, useSettings } from "../api/queries.ts";
|
||||
import type { IssueComment, IssueDetail } from "../api/types.ts";
|
||||
import { JIRA_URL } from "../board/constants.ts";
|
||||
import { fmtDate, fmtStamp } from "../board/model/util.ts";
|
||||
import { factClass, groupLinks, isOverdue, todayISO } from "./card.ts";
|
||||
import { groupLinks, isOverdue, todayISO } from "./card.ts";
|
||||
import { useCommentsHidden } from "./commentsPref.ts";
|
||||
import { IssueLink } from "./IssueLink.tsx";
|
||||
import { Notes } from "./Notes.tsx";
|
||||
@ -17,6 +18,7 @@ const DESC_LINES = 8;
|
||||
export function IssueCard({ issueKey: key, onClose }: { issueKey: string; onClose: () => void }) {
|
||||
const issue = useIssue(key);
|
||||
const settings = useSettings();
|
||||
const closed = useClosedStatuses();
|
||||
const close = <button className="close" type="button" aria-label="Закрыть" onClick={onClose}>✕</button>;
|
||||
if (issue.isPending) return <div className="issue" data-testid="issue-card"><div className="issue-head"><p className="hint">загрузка {key}…</p>{close}</div></div>;
|
||||
if (issue.error instanceof ApiError && issue.error.status === 404) {
|
||||
@ -32,13 +34,10 @@ export function IssueCard({ issueKey: key, onClose }: { issueKey: string; onClos
|
||||
);
|
||||
}
|
||||
const i = issue.data!;
|
||||
const labels = settings.data?.status_labels as Record<string, string> | undefined;
|
||||
const fact = i.note?.fact_status ?? null;
|
||||
const cls = factClass(fact, labels);
|
||||
const due = dueOf(i);
|
||||
const facts = [
|
||||
i.assignee && <span key="who">{i.assignee}</span>,
|
||||
due && <span key="due">срок {fmtDate(due)}{isOverdue(due, fact, labels, todayISO()) && <> <span className="overdue">просрочено</span></>}</span>,
|
||||
due && <span key="due">срок {fmtDate(due)}{isOverdue(due, i.status, closed, todayISO()) && <> <span className="overdue">просрочено</span></>}</span>,
|
||||
i.sprint && <span key="sprint">{i.sprint}</span>,
|
||||
i.epic_key && <span key="epic">эпик: {i.epic_summary ?? i.epic_key}</span>,
|
||||
].filter(Boolean);
|
||||
@ -48,7 +47,6 @@ export function IssueCard({ issueKey: key, onClose }: { issueKey: string; onClos
|
||||
<div className="row1">
|
||||
<span className="key">{i.key}</span>
|
||||
{i.status && <span className="badge status">{i.status}</span>}
|
||||
{fact && <span className={"badge fact" + (cls ? " " + cls : "")}>факт: {fact}</span>}
|
||||
<span className="spacer" />
|
||||
<a href={`${JIRA_URL}/browse/${i.key}`} target="_blank" rel="noopener">Открыть в Jira ↗</a>
|
||||
{close}
|
||||
|
||||
@ -150,4 +150,72 @@ describe("список задач", () => {
|
||||
expect(within(sel).queryByRole("option", { name: /нет на доске/ })).toBeNull();
|
||||
expect(within(sel).getByRole("option", { name: "AWPCA_Спринт 2" })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
const hideBox = () => screen.getByRole("checkbox", { name: "скрыть закрытые" });
|
||||
|
||||
it("закрытые скрыты по умолчанию; снятый флажок — closed=1 и запрос без hide_closed", async () => {
|
||||
const fetch = hub(() => [row(1)]);
|
||||
const { router } = renderWithProviders(<IssuesPage />, { route: "/issues", path: "/issues" });
|
||||
await screen.findByRole("link", { name: "AURORA-9001" });
|
||||
expect(hideBox()).toBeChecked();
|
||||
let q = issuesCalls(fetch).at(-1)!.searchParams;
|
||||
expect(q.get("hide_closed")).toBe("1");
|
||||
expect(q.has("closed")).toBe(false);
|
||||
await userEvent.click(hideBox());
|
||||
await waitFor(() => expect(router.state.location.search).toBe("?closed=1"));
|
||||
await waitFor(() => expect(issuesCalls(fetch).at(-1)!.searchParams.has("hide_closed")).toBe(false));
|
||||
q = issuesCalls(fetch).at(-1)!.searchParams;
|
||||
expect(q.has("closed")).toBe(false);
|
||||
await userEvent.click(hideBox());
|
||||
await waitFor(() => expect(router.state.location.search).toBe(""));
|
||||
expect(hideBox()).toBeChecked(); // выборка с hide_closed=1 уже в кэше — повторного запроса может не быть
|
||||
});
|
||||
|
||||
it("адрес closed=1 — флажок снят, запрос без hide_closed", async () => {
|
||||
const fetch = hub(() => [row(1)]);
|
||||
renderWithProviders(<IssuesPage />, { route: "/issues?closed=1", path: "/issues" });
|
||||
await screen.findByRole("link", { name: "AURORA-9001" });
|
||||
expect(hideBox()).not.toBeChecked();
|
||||
expect(issuesCalls(fetch).every((u) => !u.searchParams.has("hide_closed"))).toBe(true);
|
||||
});
|
||||
|
||||
it("выбран закрытый статус Jira — hide_closed не отправляется", async () => {
|
||||
const fetch = hub(() => [row(1)]);
|
||||
renderWithProviders(<IssuesPage />, { route: "/issues?status=Готово", path: "/issues" });
|
||||
await screen.findByRole("link", { name: "AURORA-9001" });
|
||||
const q = issuesCalls(fetch).at(-1)!.searchParams;
|
||||
expect(q.get("status")).toBe("Готово");
|
||||
expect(q.has("hide_closed")).toBe(false);
|
||||
expect(hideBox()).toBeChecked();
|
||||
});
|
||||
|
||||
it("выбран незакрытый статус Jira — hide_closed отправляется", async () => {
|
||||
const fetch = hub(() => [row(1)]);
|
||||
renderWithProviders(<IssuesPage />, { route: "/issues?status=В работе", path: "/issues" });
|
||||
await screen.findByRole("link", { name: "AURORA-9001" });
|
||||
expect(issuesCalls(fetch).at(-1)!.searchParams.get("hide_closed")).toBe("1");
|
||||
});
|
||||
|
||||
it("настройки пришли позже — запрос по закрытому статусу без hide_closed", async () => {
|
||||
const st = deferred<Response>();
|
||||
const fetch = mockFetch((_m, url) => {
|
||||
if (url.pathname === "/settings") return st.promise;
|
||||
if (url.pathname === "/board") return json(200, SNAP);
|
||||
return json(200, { issues: [row(1)] });
|
||||
});
|
||||
renderWithProviders(<IssuesPage />, { route: "/issues?status=Снято", path: "/issues" });
|
||||
st.resolve(json(200, settings));
|
||||
await screen.findByRole("link", { name: "AURORA-9001" });
|
||||
const calls = () => fetch.mock.calls.map(([u]) => new URL(String(u), "http://hub.test")).filter((u) => u.pathname === "/issues");
|
||||
await waitFor(() => expect(calls().at(-1)!.searchParams.has("hide_closed")).toBe(false));
|
||||
});
|
||||
|
||||
it("статуса по факту нет: ни фильтра, ни колонки; fact_status из адреса не уходит в запрос", async () => {
|
||||
const fetch = hub(() => [row(1)]);
|
||||
renderWithProviders(<IssuesPage />, { route: "/issues?fact_status=В работе", path: "/issues" });
|
||||
await screen.findByRole("link", { name: "AURORA-9001" });
|
||||
expect(screen.queryByLabelText("Статус по факту")).toBeNull();
|
||||
expect(screen.queryByRole("columnheader", { name: "Статус по факту" })).toBeNull();
|
||||
expect(issuesCalls(fetch).every((u) => !u.searchParams.has("fact_status"))).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
@ -1,13 +1,14 @@
|
||||
import { useEffect, useMemo, useRef, useState } from "react";
|
||||
import { useSearchParams } from "react-router";
|
||||
import { isClosed, useClosedStatuses } from "../api/closed.ts";
|
||||
import { ISSUE_FILTER_NAMES, useBoard, useIssues, useSettings, type IssueFilters } from "../api/queries.ts";
|
||||
import { IssueLink } from "./IssueLink.tsx";
|
||||
import { dueOf, epicNames, filterOptions, withCurrent } from "./options.ts";
|
||||
import "./issues.css";
|
||||
|
||||
const SELECTS = [
|
||||
["sprint", "Спринт"], ["assignee", "Исполнитель"], ["work_kind", "Вид работы"], ["fact_status", "Статус по факту"],
|
||||
["status", "Статус Jira"], ["label", "Метка"], ["epic", "Эпик"],
|
||||
["sprint", "Спринт"], ["assignee", "Исполнитель"], ["work_kind", "Вид работы"], ["status", "Статус Jira"],
|
||||
["label", "Метка"], ["epic", "Эпик"],
|
||||
] as const;
|
||||
const Q_DELAY = 300; // поиск — после паузы ввода (R-7)
|
||||
|
||||
@ -37,7 +38,12 @@ export function IssuesPage() {
|
||||
const board = useBoard(), settings = useSettings();
|
||||
const opts = useMemo(() => filterOptions(board.data, settings.data), [board.data, settings.data]);
|
||||
const names = useMemo(() => epicNames(board.data), [board.data]);
|
||||
const list = useIssues(filters);
|
||||
// закрытые скрыты, пока флажок не снят (closed=1); выбранный закрытый статус Jira скрытие отменяет
|
||||
const showClosed = params.get("closed") === "1";
|
||||
const closed = useClosedStatuses();
|
||||
const query = useMemo<IssueFilters>(() => (showClosed || isClosed(filters.status, closed) ? filters : { ...filters, hide_closed: "1" }),
|
||||
[filters, showClosed, closed]);
|
||||
const list = useIssues(query);
|
||||
const rows = list.data?.pages.flat() ?? [];
|
||||
|
||||
return (
|
||||
@ -53,6 +59,7 @@ export function IssuesPage() {
|
||||
</label>
|
||||
))}
|
||||
<label className="check"><input type="checkbox" checked={filters.neighbor === "1"} onChange={(e) => set("neighbor", e.target.checked ? "1" : "")} />только соседи</label>
|
||||
<label className="check"><input type="checkbox" checked={!showClosed} onChange={(e) => set("closed", e.target.checked ? "" : "1")} />скрыть закрытые</label>
|
||||
<input type="search" aria-label="Поиск по ключу или теме" placeholder="ключ или тема" value={q} onChange={(e) => setQ(e.target.value)} />
|
||||
</div>
|
||||
{list.isError && (
|
||||
@ -60,13 +67,13 @@ export function IssuesPage() {
|
||||
<button className="btn sm" type="button" onClick={() => void list.refetch()}>Повторить</button></p>
|
||||
)}
|
||||
<table className="grid">
|
||||
<thead><tr><th>Ключ</th><th>Тема</th><th>Тип</th><th>Статус Jira</th><th>Исполнитель</th><th>Спринт</th><th>Срок</th><th>Эпик</th><th>Вид работы</th><th>Статус по факту</th><th>Сосед</th></tr></thead>
|
||||
<thead><tr><th>Ключ</th><th>Тема</th><th>Тип</th><th>Статус Jira</th><th>Исполнитель</th><th>Спринт</th><th>Срок</th><th>Эпик</th><th>Вид работы</th><th>Сосед</th></tr></thead>
|
||||
<tbody>
|
||||
{rows.map((i) => (
|
||||
<tr key={i.key}>
|
||||
<td><IssueLink issueKey={i.key} /></td><td>{i.summary}</td><td>{i.issue_type}</td><td>{i.status}</td>
|
||||
<td>{i.assignee}</td><td>{i.sprint}</td><td>{dueOf(i)}</td><td>{i.epic_key ? names[i.epic_key] ?? i.epic_key : ""}</td>
|
||||
<td>{i.work_kind}</td><td>{i.fact_status}</td><td>{i.neighbor ? "да" : ""}</td>
|
||||
<td>{i.work_kind}</td><td>{i.neighbor ? "да" : ""}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
|
||||
@ -1,4 +1,4 @@
|
||||
import { useEffect, useId, useMemo, useRef, useState } from "react";
|
||||
import { useEffect, useMemo, useRef, useState } from "react";
|
||||
import { useBlocker } from "react-router";
|
||||
import { useCloseAbort } from "./closeAbort.ts";
|
||||
import { ApiError } from "../api/client.ts";
|
||||
@ -41,8 +41,6 @@ export function NoteForm({ issue, onDone }: { issue: IssueDetail; onDone: () =>
|
||||
|
||||
const kinds = Object.keys(settings.data?.kind_labels ?? {});
|
||||
const kindOptions = values.work_kind && !kinds.includes(values.work_kind) ? [values.work_kind, ...kinds] : kinds;
|
||||
const facts = Object.keys(settings.data?.status_labels ?? {});
|
||||
const listId = useId();
|
||||
const set = (f: NoteField) => (e: { target: { value: string } }) => setValues((v) => ({ ...v, [f]: e.target.value }));
|
||||
|
||||
const submit = () => save.mutate(changed, {
|
||||
@ -60,10 +58,6 @@ export function NoteForm({ issue, onDone }: { issue: IssueDetail; onDone: () =>
|
||||
{kindOptions.map((k) => <option key={k} value={k}>{k}</option>)}
|
||||
</select>
|
||||
</label>
|
||||
<label>Статус по факту
|
||||
<input list={listId} value={values.fact_status} onChange={set("fact_status")} />
|
||||
<datalist id={listId}>{facts.map((f) => <option key={f} value={f} />)}</datalist>
|
||||
</label>
|
||||
{TEXTS.map(([f, label]) => (
|
||||
<label key={f}>{label}<textarea rows={3} value={values[f]} onChange={set(f)} /></label>
|
||||
))}
|
||||
|
||||
@ -3,7 +3,7 @@ import type { IssueDetail, NoteField } from "../api/types.ts";
|
||||
import { fmtStamp } from "../board/model/util.ts";
|
||||
import { NoteForm } from "./NoteForm.tsx";
|
||||
|
||||
const ROWS: [NoteField, string][] = [["work_kind", "Вид работы"], ["fact_status", "Статус по факту"], ["done_text", "Сделано"],
|
||||
const ROWS: [NoteField, string][] = [["work_kind", "Вид работы"], ["done_text", "Сделано"],
|
||||
["rest_text", "Осталось"], ["owner_note", "Заметка владельца"]];
|
||||
|
||||
// служебные заметки (design 20): по умолчанию чтение, «Править» — форма; после сохранения или отмены — снова чтение
|
||||
|
||||
@ -1,9 +1,9 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import settings from "../board/__fixtures__/settings.json";
|
||||
import { factClass, groupLinks, isOverdue, todayISO } from "./card.ts";
|
||||
import { groupLinks, isOverdue, todayISO } from "./card.ts";
|
||||
|
||||
const types = settings.link_types as Record<string, string>;
|
||||
const labels = { ...(settings.status_labels as Record<string, string>), "Снято": "fact-cancelled" };
|
||||
const closed = new Set(["Готово", "Снято"]);
|
||||
const out = (k: string, t: string, s: string | null = `Тема ${k}`, st: string | null = "В работе") => ({ target_key: k, link_type: t, summary: s, status: st });
|
||||
const inn = (k: string, t: string, s: string | null = `Тема ${k}`, st: string | null = "В работе") => ({ source_key: k, link_type: t, summary: s, status: st });
|
||||
|
||||
@ -35,19 +35,16 @@ describe("groupLinks", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("просрочка и класс статуса по факту", () => {
|
||||
it("класс из status_labels", () => {
|
||||
expect(factClass("Готово — закрыть", labels)).toBe("fact-done");
|
||||
expect(factClass("свой статус", labels)).toBeNull();
|
||||
expect(factClass(null, labels)).toBeNull();
|
||||
describe("просрочка", () => {
|
||||
it("срок прошёл, статус Jira не закрытый — просрочено", () => {
|
||||
expect(isOverdue("2026-10-01", "В работе", closed, "2026-10-17")).toBe(true);
|
||||
expect(isOverdue("2026-10-01", null, closed, "2026-10-17")).toBe(true);
|
||||
});
|
||||
it("срок раньше сегодня и не закрыта по факту — просрочено (R-2)", () => {
|
||||
expect(isOverdue("2026-10-14", "В работе", labels, "2026-10-15")).toBe(true);
|
||||
expect(isOverdue("2026-10-14", null, labels, "2026-10-15")).toBe(true);
|
||||
expect(isOverdue("2026-10-15", "В работе", labels, "2026-10-15")).toBe(false);
|
||||
expect(isOverdue("2026-10-14", "Готово — закрыть", labels, "2026-10-15")).toBe(false);
|
||||
expect(isOverdue("2026-10-14", "Снято", labels, "2026-10-15")).toBe(false);
|
||||
expect(isOverdue("", "В работе", labels, "2026-10-15")).toBe(false);
|
||||
it("закрытая задача или срок не прошёл — не просрочено", () => {
|
||||
expect(isOverdue("2026-10-01", "Готово", closed, "2026-10-17")).toBe(false);
|
||||
expect(isOverdue("2026-10-01", "Снято", closed, "2026-10-17")).toBe(false);
|
||||
expect(isOverdue("2026-10-17", "В работе", closed, "2026-10-17")).toBe(false);
|
||||
expect(isOverdue("", "В работе", closed, "2026-10-17")).toBe(false);
|
||||
});
|
||||
it("todayISO — местная дата YYYY-MM-DD", () => {
|
||||
expect(todayISO(new Date(2026, 0, 5, 23, 30))).toBe("2026-01-05");
|
||||
|
||||
@ -1,3 +1,4 @@
|
||||
import { isClosed } from "../api/closed.ts";
|
||||
import type { IssueDetail } from "../api/types.ts";
|
||||
|
||||
export type LinkGroupId = "depends" | "blocks" | "related" | "other";
|
||||
@ -23,15 +24,9 @@ export function groupLinks(d: Pick<IssueDetail, "links_out" | "links_in">, linkT
|
||||
return GROUPS.filter(([id]) => groups[id].length).map(([id, title]) => [id, title, groups[id].sort(byKey)]);
|
||||
}
|
||||
|
||||
const CLOSED_FACT = new Set(["fact-done", "fact-cancelled"]);
|
||||
export const factClass = (fact: string | null | undefined, statusLabels: Record<string, string> | undefined): string | null =>
|
||||
(fact && statusLabels?.[fact]) || null;
|
||||
|
||||
// R-2: просрочено — срок раньше сегодня, а статус по факту не «закрыт» (классы fact-done, fact-cancelled)
|
||||
export function isOverdue(due: string, fact: string | null | undefined, statusLabels: Record<string, string> | undefined, today: string): boolean {
|
||||
if (!due || due >= today) return false;
|
||||
const cls = factClass(fact, statusLabels);
|
||||
return !(cls && CLOSED_FACT.has(cls));
|
||||
// просрочено — срок раньше сегодня, а статус Jira не из closed_statuses (hide-closed; было — по статусу по факту, R-2 #37)
|
||||
export function isOverdue(due: string, status: string | null | undefined, closed: Set<string>, today: string): boolean {
|
||||
return !!due && due < today && !isClosed(status, closed);
|
||||
}
|
||||
|
||||
export const todayISO = (d = new Date()): string =>
|
||||
|
||||
@ -16,9 +16,6 @@
|
||||
.issue .overdue{color:var(--p-crit);font-weight:600}
|
||||
.issue .close{font-size:18px;line-height:1;background:none;border:0;color:var(--muted);cursor:pointer;padding:4px 6px}
|
||||
.badge{display:inline-block;font-size:12px;line-height:18px;padding:0 8px;border-radius:9px;background:var(--soft);color:var(--fg);white-space:nowrap}
|
||||
.badge.fact{background:transparent;border:1px solid var(--accent);color:var(--accent)}
|
||||
.badge.fact.fact-done{border-color:var(--k-test);color:var(--k-test)}
|
||||
.badge.fact.fact-cancelled{border-color:var(--muted);color:var(--muted);text-decoration:line-through}
|
||||
.issue .cols{display:grid;grid-template-columns:minmax(0,1fr) 320px;gap:24px;align-items:start}
|
||||
.issue .side{display:flex;flex-direction:column;gap:16px;min-width:0}
|
||||
.issue .side .note{align-self:stretch} /* .note{align-self:start} — с прежней страницы; в колонке окна — на всю ширину */
|
||||
|
||||
@ -24,7 +24,6 @@ export function filterOptions(snap: BoardSnapshot | undefined, s: HubSettings |
|
||||
sprint: opt(uniq(issues.map((i) => i.sprint))),
|
||||
assignee: opt(uniq(issues.map((i) => i.assignee))),
|
||||
work_kind: opt(uniq([...Object.keys(s?.kind_labels ?? {}), ...issues.map((i) => i.work_kind)])),
|
||||
fact_status: opt(uniq([...Object.keys(s?.status_labels ?? {}), ...issues.map((i) => i.fact_status)])),
|
||||
status: opt(uniq(issues.map((i) => i.status))),
|
||||
label: opt(uniq(issues.flatMap((i) => i.labels))),
|
||||
epic: uniq(issues.map((i) => i.epic_key)).map((k) => ({ value: k, label: names[k] ?? k })),
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user