Merge pull request 'Карточка задачи окном и комментарии из Jira (#37)' (#40) from feat/37-issue-card-modal into main
This commit is contained in:
commit
2ab455731b
1
.gitignore
vendored
1
.gitignore
vendored
@ -16,3 +16,4 @@ src/jira_hub/web/app/
|
||||
web/test-results/
|
||||
web/playwright-report/
|
||||
web/.e2e-hub/
|
||||
*.tsbuildinfo
|
||||
|
||||
@ -9,7 +9,7 @@
|
||||
| `paths` | каталог данных (`JIRA_HUB_HOME`), пути базы и `backups/`, права 0700 (`secure_dir`) | stdlib |
|
||||
| `db` | схема SQLite, миграции по `schema_version` (копия базы перед миграцией), `connect` | `backup`, `paths` |
|
||||
| `settings` | настройки (`setting`), токены, темп, перенос из `config.example.json` | `db` |
|
||||
| `mirror` | зеркало задач и связей, `apply_state`, заметки, фильтры, снимок доски `board` | `db` |
|
||||
| `mirror` | зеркало задач, связей и комментариев, `apply_state` (с проверкой `check_comments`), заметки, фильтры, снимок доски `board` | `db` |
|
||||
| `commands` | контракт типов команд, валидация, временные ключи, оценка, подтверждение по состоянию | `mirror` |
|
||||
| `queue` | пакеты, выдача (`claim`), отчёты, отмена, повтор, автообновление | `commands`, `mirror`, `settings`, `db` |
|
||||
| `backup` | копии файла базы, ротация | `paths` |
|
||||
@ -22,7 +22,8 @@
|
||||
| `cli` | `init`, `serve`, `import`, `mcp`, `tokens`, `agent publish` | `api`, `mcp_server`, `importer`, `release`, `settings`, `db`, `paths` |
|
||||
|
||||
`web/` — веб-приложение хаба (React + TypeScript + Vite): вход по клиентскому токену, страницы «Доска» (`/app/board`)
|
||||
и «Задачи» (`/app/issues`, `/app/issues/{key}`). Собирается `make web` в `src/jira_hub/web/app/`; `api` отдаёт сборку
|
||||
и «Задачи» (`/app/issues`); карточка `/app/issues/{key}` — окно поверх страницы, с которой её открыли: `shell/Layout.tsx` рисует
|
||||
страницу по адресу из `location.state.background` (`useRoutes` с явным адресом) и `issues/IssueModal.tsx` поверх неё. Собирается `make web` в `src/jira_hub/web/app/`; `api` отдаёт сборку
|
||||
без токена (`/app/assets/*` — файлы, прочие `/app…` — `index.html`, 503 без сборки), `/` и `/links` перенаправляет на
|
||||
`/app/board`. Данные — только через HTTP API с клиентским токеном; `mirror.board` отдаёт зеркало без преобразований,
|
||||
перевод в модель доски — в `web/src/board/model`. Логика доски — чистые модули `web/src/board/{model,analysis,layout}`
|
||||
|
||||
@ -158,7 +158,7 @@ curl -s -H "Authorization: Bearer $TOKEN" "http://127.0.0.1:8790/issues?sprint=
|
||||
|
||||
## Веб-приложение
|
||||
|
||||
Адреса: `http://127.0.0.1:8790/app/` — доска `/app/board`, задачи `/app/issues`, карточка `/app/issues/{key}`; `/` и
|
||||
Адреса: `http://127.0.0.1:8790/app/` — доска `/app/board`, задачи `/app/issues`, карточка `/app/issues/{key}` — окно поверх списка или доски (закрытие — ✕, Esc, щелчок по фону; прямой адрес открывает окно поверх списка задач); `/` и
|
||||
`/links` перенаправляют на доску. Вход по клиентскому токену (`uv run jira-hub tokens`), токен хранится в браузере;
|
||||
`localhost` и `127.0.0.1` — разные сайты для браузера, токен и раскладка у каждого свои. Выкатка на Mac mini:
|
||||
|
||||
@ -174,6 +174,11 @@ 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 (агент v10+) новые сверху, «Скрыть» запоминается в браузере; заметки — режим чтения, «Править»
|
||||
открывает форму. Комментарии появляются после первого обновления из Jira агентом v10.
|
||||
|
||||
## Контракт команд
|
||||
|
||||
Каждая команда описывает желаемое состояние одной задачи; агент читает, сравнивает и пишет только разницу, повтор с тем же
|
||||
|
||||
1949
docs/superpowers/plans/2026-10-15-issue-card-modal.md
Normal file
1949
docs/superpowers/plans/2026-10-15-issue-card-modal.md
Normal file
File diff suppressed because it is too large
Load Diff
2
openspec/changes/issue-card-modal/.openspec.yaml
Normal file
2
openspec/changes/issue-card-modal/.openspec.yaml
Normal file
@ -0,0 +1,2 @@
|
||||
schema: spec-driven
|
||||
created: 2026-10-15
|
||||
173
openspec/changes/issue-card-modal/design.md
Normal file
173
openspec/changes/issue-card-modal/design.md
Normal file
@ -0,0 +1,173 @@
|
||||
## Context
|
||||
|
||||
`main` 13e6c16. Устройство, на которое опираются правки:
|
||||
|
||||
- **Агент** (`jira-agent/src/agent.py`, v9): `refresh` читает задачи `search` с `fields = STATE_FIELDS` (+ плановые даты и
|
||||
поле эпика), `to_state` собирает словарь задачи, пачки по 100 уходят в `POST /agent/state`. Комментарии агент читает
|
||||
только в `ensure_comment` (`GET /issue/{key}/comment`, постранично по 100).
|
||||
- **Хаб**: `mirror.apply_state` пишет в `issue` только столбцы `ISSUE_COLUMNS` — лишние поля задачи (в том числе будущее
|
||||
`comments`) молча пропускаются, поэтому агент v10 совместим с нынешним хабом. `mirror.get_issue` отдаёт задачу, связи
|
||||
(`target_key`/`source_key`, `link_type`), заметку, 10 последних команд. Схема базы — v2 (`db.SCHEMA_VERSION`),
|
||||
миграция с копией базы — `db.migrate`. Тело запроса ограничено 16 МБ (`api.MAX_BODY`).
|
||||
- **Веб-приложение**: `createBrowserRouter(routes, {basename: "/app"})`; `Layout` с `<Outlet>`; маршруты `board`,
|
||||
`issues`, `issues/:key` (страница `IssuePage`). Ссылки на карточку — ключ в `IssuesPage` и «ⓘ» в `board/components/
|
||||
Card.tsx`. `NoteForm` — форма заметок с `useBlocker` (смена `pathname` при правках требует подтверждения) и
|
||||
`beforeunload`. `useSaveNote` после успеха обновляет кэш задачи и инвалидирует `["issues"]` и `board`.
|
||||
Прокрутка доски при возврате на `/app/board` восстанавливается (M-3 #29).
|
||||
- **Справочники**: `link_types` настроек — логическое имя → тип Jira (`блокирует начало` → `ASUPR_Блокер_Начала`,
|
||||
`блокирует завершение` → `ASUPR_Блокер_Завершения`, `связанные` → `ASUPR_Связанные`); `status_labels` — статус по факту →
|
||||
класс (`fact-done`, `fact-cancelled`, …).
|
||||
|
||||
## Goals / Non-Goals
|
||||
|
||||
**Goals:** карточка открывается окном поверх списка и доски без потери их состояния; по шапке видно состояние задачи,
|
||||
по связям — что держит задачу и что ждёт её, с темой и статусом связанных; комментарии задачи из Jira в карточке со
|
||||
скрытием; агент v10 и хаб совместимы в любом порядке выкатки.
|
||||
|
||||
**Non-Goals:** см. proposal.md.
|
||||
|
||||
## Decisions
|
||||
|
||||
### Агент v10 (решения агентской части — `jira-agent/openspec/changes/agent-v10-comments/design.md`)
|
||||
|
||||
1. **Поле `comment` в том же `search`.** `STATE_FIELDS` получает `comment`; новых вызовов Jira на обычную задачу нет.
|
||||
Jira Server отдаёт `fields.comment = {comments: [...], total, maxResults, startAt}`.
|
||||
2. **Дочитывание.** Если `len(comments) < total`, агент читает комментарии этой задачи `GET /issue/{key}/comment`
|
||||
(`startAt`, `maxResults = 100`) до конца и берёт их вместо частичного списка. Вызовы учитываются в темпе и 429 как
|
||||
любые вызовы Jira.
|
||||
3. **Форма.** `comments: [{id, author, created, updated, body}]` в порядке Jira (старые первыми): `id` — строка,
|
||||
`author` — `author.displayName`, иначе `author.name`, иначе `null`; `created`/`updated` — строки Jira как есть;
|
||||
`body` — текст как есть (`""`, если в Jira пусто). Поле `comments` есть у каждой задачи, прочитанной агентом v10
|
||||
(пустой список — комментариев нет).
|
||||
4. **Версия и выкатка.** `AGENT_VERSION = 10`; после merge PR агента — `jira-hub agent publish src/agent.py` (пишет в
|
||||
каталог живого хаба: выполняет владелец или агент по его отдельной команде).
|
||||
|
||||
### Хаб
|
||||
|
||||
5. **Таблица `issue_comment`** `(key, comment_id, author, created, updated, body, PRIMARY KEY (key, comment_id))`; выборка
|
||||
по `key` идёт по первичному ключу, отдельный индекс не нужен. Схема v3; миграция v2 → v3 создаёт таблицу (копия базы `schema-v2` — существующим механизмом).
|
||||
6. **Запись.** `apply_state`: для задачи с полем `comments` (список) комментарии этой задачи удаляются и вставляются
|
||||
присланные — в одной транзакции с задачей; задача без поля `comments` своих комментариев не теряет (агент до v10).
|
||||
7. **Проверка формы** в `h_agent_state` до записи: `comments` — список объектов; `id` — непустая строка; `body` — строка;
|
||||
`author`, `created`, `updated` — строка или `null`. Нарушение — 422 `bad_state` с ключом задачи и полем, пачка не
|
||||
применяется целиком (агент получает ошибку хаба в отчёте `refresh`).
|
||||
8. **`GET /issues/{key}`**: добавляются `comments` (новые сверху: по `created` убыв., затем `comment_id` убыв.),
|
||||
`epic_summary` (тема задачи-эпика из зеркала или `null`), у каждой связи — `summary` и `status` связанной задачи
|
||||
(`LEFT JOIN issue`, `null`, если её нет в зеркале). Прежние поля и их форма не меняются.
|
||||
9. **MCP `issue`**: после заметок — «комментарии (N):» и 5 последних (автор, время, текст до 300 символов с «…»), затем
|
||||
«ещё M», если их больше 5; нет комментариев — «комментариев нет».
|
||||
|
||||
### Веб-приложение: окно
|
||||
|
||||
10. **Фоновая страница.** Ссылки на карточку (ключ в списке, «ⓘ» на доске, связи в карточке) передают в
|
||||
`location.state` `{background, depth, direct}`: `background` — адрес страницы под окном (`pathname + search`),
|
||||
`depth` — сколько записей истории занято карточками поверх неё (1 при открытии со страницы, +1 при переходе по
|
||||
связи), `direct` — окно открыто прямым адресом (переносится в переходы по связям). Маршрут верхнего уровня —
|
||||
`{path: "*", element: <Layout/>}`; `Layout` всегда рисует страницы через `useRoutes(PAGE_ROUTES, pageLocation)` с
|
||||
явным адресом: `background` при открытом окне, иначе текущий адрес. Явный адрес нужен всегда: так дерево
|
||||
компонентов страницы не меняется при открытии окна (страница не пересоздаётся), а `useSearchParams`/`useLocation`
|
||||
внутри фоновой страницы видят её собственный адрес, и фильтры списка не сбрасываются. Состояние страницы (фильтры,
|
||||
загруженные порции, прокрутка, раскладка доски, опрос пакета) сохраняется. `ErrorBoundary` страницы получает ключ по
|
||||
адресу страницы, у окна — свой.
|
||||
11. **Прямой адрес** `/app/issues/{key}` без `state` (новая вкладка, закладка): `Layout` заменяет запись истории тем же
|
||||
адресом со `state = {background: /issues, depth: 0, direct: true}`; под окном — список задач без фильтров.
|
||||
Перезагрузка страницы с открытым окном сохраняет `state` (браузер хранит его в записи истории).
|
||||
12. **Закрытие** (✕, Esc, щелчок по фону): `depth = 0` — `navigate("/issues", {replace: true})`; `depth ≥ 1` —
|
||||
`navigate(-depth)`, браузер возвращается к записи страницы под окном, и «назад» после закрытия ведёт на шаг раньше
|
||||
неё; если окно было открыто прямым адресом (`direct`), запись прямого адреса, на которую вернулись, заменяется на
|
||||
`/issues` (`replace`). Закрытие проходит через блокировщик заметок (п. 14). Вкладки «Доска»/«Задачи» в шапке
|
||||
подсвечиваются по адресу страницы под окном.
|
||||
13. **Элемент `<dialog>`** с `showModal()`: фокус удерживается в окне, фон недоступен, Esc вызывает `cancel` (обрабатываем
|
||||
сами — закрытие по п. 12 с проверкой заметок; если `cancel` не отменяемый — браузер закрыл окно сам без активации
|
||||
пользователя, — по событию `close` окно открывается снова и закрытие идёт тем же путём). Щелчок по фону — щелчок по
|
||||
самому `<dialog>` вне области содержимого, причём нажатие (`pointerdown`) тоже было на фоне (I-2: выделение текста,
|
||||
отпущенное за краем окна, иначе закрывало окно).
|
||||
Прокрутка страницы под окном на время открытия блокируется (`overflow: hidden` у `body`), содержимое окна
|
||||
прокручивается внутри, шапка окна закреплена. Ширина до 1000 px, высота до 90vh; на экранах уже 700 px — на всю
|
||||
ширину.
|
||||
14. **Несохранённые заметки.** Подтверждение «Есть несохранённые правки заметки. Уйти без сохранения?» при закрытии окна,
|
||||
переходе по связи и уходе в раздел; условие блокировки — смена `pathname` при правках (как сейчас), закрытие по
|
||||
п. 12 проходит через тот же блокировщик. Отказ — окно остаётся с введённым текстом.
|
||||
15. **Страница под окном после сохранения заметки.** Сохранение уже инвалидирует `board` и `["issues"]`
|
||||
(`useSaveNote`): фоновая страница смонтирована и перечитывается. Список задач показывает новые «вид работы» и
|
||||
«статус по факту» (колонки из заметки). Доска перечитывает снимок, но её карточки берут вид работы из меток
|
||||
`kind-*` Jira, а статус — из Jira (поведение `links.html`, требование «Доска связей»), поэтому правка заметки на
|
||||
карточке доски не видна (R-7). Отдельного кода нет — поведение закрепляется тестом.
|
||||
|
||||
### Веб-приложение: раскладка карточки
|
||||
|
||||
16. **Шапка**: ключ; статус Jira (плашка); статус по факту (плашка цветом класса из `status_labels`, если есть);
|
||||
«Открыть в Jira ↗»; ✕; тема; строка «исполнитель · срок · спринт · эпик: <тема эпика>» — пустые части пропускаются,
|
||||
эпик без темы в зеркале — ключом. Срок — `planned_end`, иначе `duedate`; «просрочено» (красным) — срок раньше
|
||||
сегодняшней даты и статус по факту не относится к классам `fact-done`, `fact-cancelled`.
|
||||
17. **Связи по смыслу.** По `link_types` настроек: входящие `блокирует начало` / `блокирует завершение` — группа «Зависит
|
||||
от», исходящие — «Блокирует»; у строки метка «начало» / «завершение». `связанные` в обе стороны — «Связана с».
|
||||
Тип, которого нет в `link_types`, — «Прочие» с именем типа Jira и направлением («→» исходящая, «←» входящая).
|
||||
Строка: ключ (ссылка — открывает связанную задачу в окне), тема, статус Jira (плашка); связанной задачи нет в
|
||||
зеркале — ключ и «нет в зеркале». Пустая группа не показывается; связей нет — «связей нет». Порядок групп: «Зависит
|
||||
от», «Блокирует», «Связана с», «Прочие»; внутри — по ключу.
|
||||
18. **Описание** — `pre-wrap` без разбора разметки; выше 8 строк сворачивается с кнопкой «развернуть» / «свернуть»
|
||||
(кнопка только если текст не помещается). Нет описания — раздела нет.
|
||||
19. **Комментарии** — «Комментарии (N)» с кнопкой «Скрыть» / «Показать»; новые сверху; автор, время (`fmtStamp`), текст
|
||||
`pre-wrap`. Выбор «скрыто» — один на все карточки, в `localStorage` `jira-hub-comments-hidden` (`"1"` — скрыты);
|
||||
недоступный `localStorage` — показаны. Нет комментариев — «комментариев нет» (кнопки нет).
|
||||
20. **Заметки** — по умолчанию режим чтения: «Вид работы», «Статус по факту», «Сделано», «Осталось», «Заметка владельца»
|
||||
(пустые — «—»), строка «изменено <время>, <автор>»; кнопка «Править» показывает нынешнюю форму `NoteForm`
|
||||
(поля, «Сохранить», «Отмена»); после успешного сохранения — снова режим чтения; «Отмена» без правок — сразу, с
|
||||
правками — подтверждение «Отменить правки заметки?». Заметки ещё нет — режим чтения с «—» и «Править».
|
||||
21. **Прочие поля**: тип, приоритет, автор, метки, плановое начало, обновлена в Jira, «сосед по связям» — непустые.
|
||||
22. **Команды моста (N)** — свёрнуты (`<details>`), внутри нынешняя таблица; команд нет — «команд по задаче не было» и
|
||||
раздел не сворачивается.
|
||||
23. **Колонки**: под шапкой слева — «Связи», «Описание», «Комментарии»; справа (≈320 px) «Заметки», «Прочие
|
||||
поля», «Команды моста». Уже 900 px — одна колонка: заметки, связи, описание, комментарии, прочие поля, команды.
|
||||
24. **Состояния окна**: загрузка — «загрузка <key>…»; 404 — «Задачи <key> нет в зеркале хаба.» и «Закрыть»; хаб не
|
||||
отвечает — сообщение и «Повторить», ранее загруженные данные остаются; поверх ошибки при данных — предупреждение
|
||||
в шапке окна.
|
||||
|
||||
### Проверки
|
||||
|
||||
25. **Тестовый хаб**: `web/scripts/seed-extras.json` — дополнения к фикстуре доски только для тестового хаба: у AURORA-9001
|
||||
описание длиннее 8 строк, три комментария и две команды (пакет автора `seed`, команды завершены). `seed-hub.py`
|
||||
применяет их после фикстуры. Связи не добавляются (они меняют доску и её e2e); группа «Прочие» и связь на задачу вне
|
||||
зеркала проверяются в vitest, а связь на задачу вне зеркала (`lower-1`) уже есть в фикстуре. Фикстура доски и её
|
||||
golden-эталоны не меняются.
|
||||
26. **e2e** (Playwright на :8791): открытие из списка (фильтр и прокрутка списка после закрытия на месте), по «ⓘ» с доски
|
||||
(прокрутка и раскладка доски на месте), переход по связи и «назад», Esc / ✕ / фон, «назад» после закрытия не
|
||||
открывает карточку, прямой адрес и перезагрузка с открытым окном, подтверждение при несохранённой заметке, ширина
|
||||
600 px, «Скрыть» комментарии переживает перезагрузку.
|
||||
|
||||
## Risks / Trade-offs
|
||||
|
||||
- **Фоновая страница в `Layout`** — нестандартный для data router приём (две отрисовки маршрутов). Риск: `useBlocker`
|
||||
только один на страницу — блокировщик заметок остаётся в окне, фоновая страница блокировщиков не имеет. Проверяется
|
||||
тестами п. 14 и e2e.
|
||||
- **`navigate(-depth)`** полагается на то, что все `depth` шагов истории — наши переходы. Ручное изменение адреса внутри
|
||||
окна сбрасывает `state` → `depth = 0`, закрытие ведёт в список задач (`replace`). Принято.
|
||||
- **Объём состояния**: комментарии увеличивают пачку `POST /agent/state`; при 100 задачах и типичных комментариях это
|
||||
сотни килобайт при пределе 16 МБ. Принято без разбиения.
|
||||
- **Просрочка по статусу по факту** (п. 16): задача, закрытая в Jira без заметки, покажется просроченной. Цена — лишняя
|
||||
красная пометка; списка «закрытых» статусов Jira в настройках нет.
|
||||
|
||||
## Migration Plan
|
||||
|
||||
1. PR jira-agent (v10) → merge → `jira-hub agent publish` → `agent_status` «обновление: ok v9 → v10».
|
||||
2. PR jira-hub (#37) → merge → `git pull --ff-only`, `make web` в основном checkout → перезапуск хаба владельцем
|
||||
(`launchctl kickstart -k gui/$(id -u)/ru.aepif.jira-hub`): миграция v2 → v3 с копией базы → `/mcp` reconnect.
|
||||
3. Ближайший `refresh` (час или «Обновить из Jira») заполняет комментарии.
|
||||
Откат хаба: копия `backups/<…>-schema-v2.sqlite` и предыдущий коммит; агент v10 со старым хабом работает (п. Context).
|
||||
|
||||
## Open Questions
|
||||
|
||||
Нет.
|
||||
|
||||
## Decisions made without discussion
|
||||
|
||||
| Маркер | Дата | Решение | Обоснование | Цена ошибки |
|
||||
|---|---|---|---|---|
|
||||
| R-1 | 2026-10-15 | Требования к агенту v10 — дельтой `specs/agent` в этом change jira-hub; в jira-agent — change `agent-v10-comments` только с решениями агентской части (design.md) | Заведённый порядок: спека агента живёт в `jira-hub/openspec/specs/agent/`, так сделаны v9 (hub#30 + agent#24) и предыдущие; «один change на репозиторий» соблюдается — у агента свой change и свой PR | Низкая: перенос дельты в другой change — правка документов |
|
||||
| R-2 | 2026-10-15 | Просрочка — срок раньше сегодня и статус по факту не `fact-done`/`fact-cancelled`; статус Jira не учитывается | Списка «закрытых» статусов Jira в настройках нет, а свой словарь в приложении запрещён требованием «Справочники из настроек хаба» | Лишняя красная пометка у закрытой в Jira задачи без заметки |
|
||||
| R-3 | 2026-10-15 | В карточке показываются все комментарии задачи, без постраничности; в MCP — 5 последних | В задачах проекта комментариев единицы; окно прокручивается внутри | При десятках длинных комментариев — длинная прокрутка окна; лечится «Скрыть» |
|
||||
| R-4 | 2026-10-15 | «Команды моста» свёрнуты всегда при открытии, выбор не запоминается; «Скрыть» комментарии запоминается | Команды нужны редко и по конкретному поводу; комментарии владелец просил скрывать — это предпочтение | Лишний щелчок, если команды смотрят часто |
|
||||
| R-5 | 2026-10-15 | Отмена правки заметки — кнопка «Отмена» с подтверждением при правках (в разделе 1 дизайна был только «Править»/«Сохранить») | Из режима правки нужен выход без сохранения, не закрывая окно | Лишний вопрос при отмене |
|
||||
| R-6 | 2026-10-15 | В Task 4 тесты адаптированы минимально: `within(main)` вместо глобального `getByRole("table")` (в окне есть таблица команд); убран `{exact: true}` (нет в типах); тест ErrorBoundary в App.test.tsx добавляет Boom-маршрут в `PAGE_ROUTES` (unshift/splice в finally), т.к. Layout больше не рисует Outlet | Код плана не компилировался/находил две таблицы; поведение и проверяемые утверждения не изменены | Низкая: только тесты |
|
||||
| R-7 | 2026-10-15 | Сценарий «Заметка видна на доске» (web-app) сужен: проверяется перезапрос `GET /board` с новым `work_kind` и сохранность доски под окном, а не отображение вида работы на карточке доски | Доска берёт вид работы из меток `kind-*` Jira, `work_kind` заметки в карточку доски не попадает; менять отрисовку доски вне объёма change (Non-Goals) | Владелец может ждать видимого обновления вида работы на доске; нужен отдельный change |
|
||||
62
openspec/changes/issue-card-modal/proposal.md
Normal file
62
openspec/changes/issue-card-modal/proposal.md
Normal file
@ -0,0 +1,62 @@
|
||||
## Why
|
||||
|
||||
Карточка задачи (`/app/issues/{key}`, change `web-app` #21) — отдельная страница: уход со списка или доски теряет
|
||||
контекст, а сама карточка плохо читается — поля одного веса, связи без темы и статуса связанной задачи, входящие связи
|
||||
записаны «X → тип → эта задача», техническая таблица команд на виду, форма заметок занимает пол-экрана. Комментариев
|
||||
задачи из Jira в карточке нет: хаб их не хранит, агент их не читает.
|
||||
|
||||
Решения владельца 15.10.2026 (brainstorming, jira-hub#37):
|
||||
- карточку открывают, чтобы быстро понять состояние задачи (статус, кто, срок, что осталось) и разобраться в связях
|
||||
(что держит задачу, что ждёт её); в карточке нужны комментарии задачи из Jira с возможностью их скрыть;
|
||||
- карточка открывается модальным окном поверх того места, откуда её открыли (список «Задачи» или доска), у окна свой
|
||||
адрес `/app/issues/{key}`; переходы по связям — внутри окна, «назад» браузера возвращает к прежней карточке;
|
||||
- раскладка карточки — по разделу 1 дизайна (шапка состояния, связи по смыслу, заметки в режиме чтения, комментарии,
|
||||
второстепенное внизу); визуальные макеты не нужны, раскладку предлагает агент;
|
||||
- комментарии — два change по репозиториям, сначала агент: jira-agent v10 (aep/jira-agent#27) передаёт комментарии в
|
||||
состоянии задачи, jira-hub (#37) хранит, отдаёт и показывает их. Требования к агенту по заведённому порядку живут в
|
||||
этом change (`specs/agent`), решения агентской части — в `jira-agent/openspec/changes/agent-v10-comments/design.md`.
|
||||
|
||||
## What Changes
|
||||
|
||||
- **Агент v10** (`AGENT_VERSION = 10`): `refresh` запрашивает в том же `search` поле `comment`; по каждой задаче
|
||||
передаёт `comments: [{id, author, created, updated, body}]` (автор — отображаемое имя, иначе логин; текст как есть);
|
||||
если Jira отдала не все комментарии задачи — дочитывание через `GET /issue/{key}/comment`. Выкатка самообновлением.
|
||||
- **Хаб, данные**: таблица `issue_comment`, схема базы v2 → v3 (копия базы перед миграцией — как сейчас). Присланные
|
||||
комментарии задачи заменяют её прежние; задача без поля `comments` (агент до v10) свои комментарии не теряет.
|
||||
Неверная форма комментария — 422 на всю пачку `POST /agent/state`.
|
||||
- **Хаб, API**: `GET /issues/{key}` дополнительно отдаёт `comments` (новые сверху), у связей — `summary` и `status`
|
||||
связанной задачи (`null`, если её нет в зеркале), `epic_summary`. Прежние поля не меняются.
|
||||
- **MCP**: инструмент `issue` показывает 5 последних комментариев и «ещё N».
|
||||
- **Веб-приложение**:
|
||||
- карточка задачи — модальное окно (`<dialog>`) поверх списка «Задачи» или доски; адрес `/app/issues/{key}`; прямой
|
||||
адрес открывает окно поверх списка задач без фильтров; закрытие (✕, Esc, щелчок по фону) возвращает туда, откуда
|
||||
открыли, и «назад» после закрытия карточку не открывает;
|
||||
- новая раскладка: шапка (ключ, статус Jira, статус по факту, тема, исполнитель · срок · спринт · эпик по имени,
|
||||
«Открыть в Jira», ✕); связи по группам «Зависит от», «Блокирует», «Связана с», «Прочие» с темой и статусом;
|
||||
описание свёрнуто до 8 строк; комментарии со «Скрыть/Показать», выбор запоминается в браузере; заметки в режиме
|
||||
чтения с «Править»; «Прочие поля»; «Команды моста» свёрнуты;
|
||||
- после сохранения заметки список задач под окном показывает новые «вид работы» и «статус по факту»; доска под
|
||||
окном перечитывает снимок, но её карточки берут вид работы из меток Jira, а статус — из Jira (R-7), поэтому
|
||||
правка заметки на карточке доски не видна.
|
||||
- Спеки: `agent` — MODIFIED «Чтение состояния»; `data-model` — MODIFIED «Схема базы хаба», «Зеркало задачи»; `hub-api` —
|
||||
MODIFIED «Маршруты агента», «Маршруты интерфейсов»; `mcp` — MODIFIED «Инструменты»; `web-app` — MODIFIED «Оболочка и
|
||||
адреса», «Доска связей», «Список задач», «Карточка задачи», «Служебные заметки».
|
||||
|
||||
## Non-Goals
|
||||
|
||||
- Запись в Jira из карточки (правка полей, статуса, добавление комментариев) — следующий change `viewer-edit`.
|
||||
- Разбор разметки Jira (wiki) в описании и комментариях: текст показывается как есть, с переносами строк.
|
||||
- Комментарии в списке задач, на доске и в `GET /board`.
|
||||
- Изменения поведения доски, кроме открытия карточки окном и обновления заметки под окном.
|
||||
- Хранение истории правок комментариев: в зеркале — последнее состояние из Jira.
|
||||
|
||||
## Impact
|
||||
|
||||
- jira-agent: `src/agent.py` (`STATE_FIELDS`, `to_state`, дочитывание комментариев), тесты `tests/test_refresh.py`,
|
||||
фейк `tests/fake_jira.py`; `AGENT_VERSION = 10`; публикация `jira-hub agent publish` после merge.
|
||||
- jira-hub: `db.py` (v3, `issue_comment`), `mirror.py` (`apply_state`, `get_issue`), `api.py` (`h_agent_state`
|
||||
валидация), `mcp_server.py` (`issue`), тесты; `web/src/routes.tsx`, `shell/Layout.tsx`, `issues/*` (окно, раскладка,
|
||||
заметки), `board/components/Card.tsx` (ссылка «ⓘ»), `api/types.ts`, `api/queries.ts`, e2e; фикстура тестового хаба
|
||||
с описанием, комментариями и командами.
|
||||
- Порядок выкатки: агент v10 (любой хаб принимает его состояние — лишнее поле `comments` нынешний хаб пропускает), затем
|
||||
хаб; комментарии появляются после первого `refresh` агента v10.
|
||||
36
openspec/changes/issue-card-modal/specs/agent/spec.md
Normal file
36
openspec/changes/issue-card-modal/specs/agent/spec.md
Normal file
@ -0,0 +1,36 @@
|
||||
## MODIFIED Requirements
|
||||
|
||||
### Requirement: Чтение состояния
|
||||
`refresh` SHALL выполняться запросом `search` (`jql`, `fields` = поля зеркала включая `issuelinks`, `comment` и спринт,
|
||||
`maxResults = 100`, постранично) и отправляться хабу пачками по 100 задач со связями; ключи из `issuelinks`, которых нет
|
||||
в выборке и в зеркале хаба (хаб сообщает список известных), агент SHALL дочитать одним `search` по `key in (...)`
|
||||
и отправить с признаком `neighbor`. Ошибка хаба при запросе известных ключей или отправке пачки SHALL попадать в
|
||||
`error` с префиксом «хаб: »; недоступность хаба и его 5xx повторяются, как обрыв связи, с источником «хаб».
|
||||
Каждая задача в состоянии (с v10) SHALL нести `comments` — все комментарии задачи в порядке Jira, каждый
|
||||
`{id, author, created, updated, body}`: `id` — строка; `author` — отображаемое имя автора, иначе его логин, иначе `null`;
|
||||
`created`, `updated` — время Jira как есть; `body` — текст как есть (пустой — `""`). Если `search` отдал у задачи меньше
|
||||
комментариев, чем `comment.total`, агент SHALL дочитать комментарии этой задачи через `GET /issue/{key}/comment`
|
||||
постранично (`maxResults = 100`) и передать полный список.
|
||||
|
||||
#### Scenario: Полная синхронизация
|
||||
- **WHEN** `refresh {jql: "project = AURORA AND labels = AWPCA"}` при 180 задачах
|
||||
- **THEN** 2 вызова `search` + 1 на соседей; `sync_run.issues_count = 180 + соседи`
|
||||
|
||||
#### Scenario: Хаб не ответил при отправке состояния
|
||||
- **WHEN** `refresh` через папку, реле не ответило на отправку пачки ни в одной из 4 попыток
|
||||
- **THEN** отчёт `failed` с ошибкой «результат неизвестен (обрыв связи с хабом), повтор безопасен: хаб: не ответил через
|
||||
папку …»; в логе повторов — «обрыв связи с хабом»
|
||||
|
||||
#### Scenario: Комментарии в состоянии
|
||||
- **WHEN** у AURORA-1620 в Jira два комментария, автор первого — `{name: "petrov", displayName: "Петров Пётр"}`, у автора
|
||||
второго нет `displayName`, его `name` — `ivanov`
|
||||
- **THEN** в состоянии AURORA-1620 `comments` из двух элементов в порядке Jira, `author` — «Петров Пётр» и «ivanov»; число
|
||||
вызовов `search` не изменилось
|
||||
|
||||
#### Scenario: Неполный список комментариев
|
||||
- **WHEN** `search` вернул у AURORA-1620 20 комментариев при `comment.total = 130`
|
||||
- **THEN** агент дочитал комментарии двумя вызовами `GET /issue/AURORA-1620/comment`; в состоянии 130 комментариев
|
||||
|
||||
#### Scenario: Задача без комментариев
|
||||
- **WHEN** у задачи в Jira нет комментариев
|
||||
- **THEN** в её состоянии `comments = []`
|
||||
66
openspec/changes/issue-card-modal/specs/data-model/spec.md
Normal file
66
openspec/changes/issue-card-modal/specs/data-model/spec.md
Normal file
@ -0,0 +1,66 @@
|
||||
## MODIFIED Requirements
|
||||
|
||||
### Requirement: Схема базы хаба
|
||||
База хаба SHALL быть одним файлом SQLite (WAL) с таблицами `issue`, `issue_link`, `issue_comment`, `issue_note`, `batch`,
|
||||
`command`, `sync_run`, `agent_status`, `setting`. Схема SHALL создаваться и мигрироваться самим хабом при старте по номеру
|
||||
версии в `setting.schema_version` (текущая — 3). Миграция существующей базы SHALL выполняться в одной транзакции с
|
||||
блокировкой записи; в той же транзакции, после повторной проверки версии, хаб SHALL скопировать файл базы в
|
||||
`backups/<дата-время>-schema-v<старая>.sqlite` рядом с базой — одна копия на миграцию, сколько бы соединений ни
|
||||
открывалось одновременно. Каталог данных хаба и `backups/` SHALL иметь права `0700` (в базе токены): хаб выставляет их
|
||||
при создании и при каждом старте; если выставить не удалось — предупреждение в лог, работа продолжается. Денежных и
|
||||
дробных полей нет; даты — ISO-8601 строки в UTC, кроме `duedate` и плановых дат (даты без времени) и времени
|
||||
комментариев (строки Jira как есть).
|
||||
|
||||
#### Scenario: Первый запуск
|
||||
- **WHEN** хаб стартует с несуществующим файлом базы
|
||||
- **THEN** файл создаётся, таблицы и индексы на месте, `setting.schema_version` = текущая; каталог данных с правами `0700`
|
||||
|
||||
#### Scenario: Миграция базы v1
|
||||
- **WHEN** хаб стартует с базой `schema_version = 1`, в которой есть задачи, заметки и команда в `claimed`
|
||||
- **THEN** в `backups/` копия базы v1; добавлена `command.deliveries` (у `claimed` — 1, у остальных — 0); добавлена пустая
|
||||
`issue_comment`; данные на месте; `schema_version = 3`
|
||||
|
||||
#### Scenario: Миграция базы v2
|
||||
- **WHEN** хаб стартует с базой `schema_version = 2`, в которой есть задачи, связи и заметки
|
||||
- **THEN** в `backups/` копия базы v2; добавлена пустая `issue_comment`; данные на месте; `schema_version = 3`
|
||||
|
||||
#### Scenario: Два соединения к базе v1
|
||||
- **WHEN** к базе `schema_version = 1` одновременно открываются два соединения
|
||||
- **THEN** миграция выполнена один раз, в `backups/` одна копия `schema-v1`
|
||||
|
||||
#### Scenario: Открытый каталог данных
|
||||
- **WHEN** хаб стартует, каталог данных существует с правами `0755`
|
||||
- **THEN** права каталога и `backups/` — `0700`
|
||||
|
||||
### Requirement: Зеркало задачи
|
||||
Таблица `issue` SHALL хранить задачу Jira как есть: `key` (PK), `id`, `issue_type`, `summary`, `description` (wiki),
|
||||
`status`, `priority`, `assignee`, `reporter`, `duedate`, `planned_start`, `planned_end`, `epic_key`, `labels` (JSON-список),
|
||||
`sprint`, `jira_updated`, `fetched_at`, `neighbor` (0/1 — вне базовой JQL, дочитана по связи), `created`. Таблица `issue_link`
|
||||
SHALL хранить связи `(source_key, target_key, link_type)` с направлением по семантике Jira: `source` блокирует/ссылается
|
||||
на `target` (у `source` в GET это `outwardIssue`). Таблица `issue_comment` SHALL хранить комментарии задачи как есть:
|
||||
`key`, `comment_id` (PK вместе с `key`), `author`, `created`, `updated`, `body` (wiki). Обновлять `issue`, `issue_link` и
|
||||
`issue_comment` SHALL только загрузка состояния от агента; HTTP API интерфейсов и MCP MUST NOT писать в них. Присланные
|
||||
комментарии задачи SHALL заменять все её прежние комментарии; задача, присланная без поля `comments`, свои комментарии
|
||||
SHALL сохранять. Полный прогон (`refresh` по базовой JQL), завершившийся `done`, SHALL помечать `neighbor = 1` задачи, не
|
||||
обновлённые им, — только если прогон загрузил хотя бы одну задачу.
|
||||
|
||||
#### Scenario: Загрузка состояния заменяет связи
|
||||
- **WHEN** агент прислал состояние задачи X с двумя связями
|
||||
- **THEN** все строки `issue_link` с `source_key = X` заменены на присланные; строки с `target_key = X`, не присланные
|
||||
со стороны X, не трогаются до загрузки их источника
|
||||
|
||||
#### Scenario: Соседняя задача
|
||||
- **WHEN** в `issuelinks` задачи из базовой JQL есть ключ Y вне JQL
|
||||
- **THEN** Y сохраняется с `neighbor = 1`; при следующем полном `refresh`, если Y вошла в JQL, `neighbor = 0`
|
||||
|
||||
#### Scenario: Пустой полный прогон
|
||||
- **WHEN** полный `refresh` завершился `done`, загрузив 0 задач
|
||||
- **THEN** признак `neighbor` задач зеркала не меняется
|
||||
|
||||
#### Scenario: Комментарий удалён в Jira
|
||||
- **WHEN** у X в зеркале комментарии 101 и 102, агент прислал X с `comments = [{id: "102", …}]`
|
||||
- **THEN** у X в `issue_comment` только 102
|
||||
|
||||
#### Scenario: Состояние от агента до v10
|
||||
- **WHEN** у X в зеркале три комментария, агент v9 прислал X без поля `comments`
|
||||
- **THEN** поля X обновлены, три комментария на месте
|
||||
125
openspec/changes/issue-card-modal/specs/hub-api/spec.md
Normal file
125
openspec/changes/issue-card-modal/specs/hub-api/spec.md
Normal file
@ -0,0 +1,125 @@
|
||||
## MODIFIED Requirements
|
||||
|
||||
### Requirement: Маршруты агента
|
||||
Хаб SHALL предоставлять агенту следующие маршруты (все под агентским токеном):
|
||||
- `POST /agent/claim` `{agent_version, settings_version, last_jira_call_at, update_state, launcher_version}` — долгий
|
||||
запрос до 30 с; ответ `{command: {id, type, params, pause_seconds}, settings_version, agent_release}` или `{command:
|
||||
null, settings_version, agent_release}`, где `agent_release` — `{version, sha256}` опубликованной версии агента или
|
||||
`null`; обновляет `agent_status` (`last_jira_call_at = null` не затирает прежнее значение). `update_state` (объект или
|
||||
`null`, `error` хранится до 500 символов) и `launcher_version` (целое или `null`) необязательны; хаб SHALL хранить
|
||||
последние значения в памяти процесса, значения неверного типа игнорировать, не отвергая `claim`. Если есть команда в
|
||||
`claimed`, `claim` SHALL выдать её снова (повторная выдача, см. `commands`), а не следующую из очереди.
|
||||
- `GET /agent/settings` — настройки для агента (без токенов).
|
||||
- `GET /agent/commands/{id}` — команда по id (для восстановления из журнала), включая `pause_seconds`.
|
||||
- `POST /agent/commands/{id}/result` `{status, result, error, jira_calls, rate_limited}` — идемпотентно: повторный
|
||||
отчёт по команде в терминальном статусе отвечает 200 без изменений, кроме позднего отчёта `done` с `result.key` по
|
||||
`ensure_issue` в `cancelled` или `failed` без результата (результат записывается, статус не меняется, заметка `new:N`
|
||||
переносится на ключ). Отчёт по команде в `queued` SHALL отвергаться 409 `not_claimed`. `error` хаб SHALL обрезать до
|
||||
1500 символов.
|
||||
- `POST /agent/state` `{command_id, issues: [...], links: [...], neighbor_keys: [...], page, last_page}` — загрузка состояния
|
||||
пачками; `neighbor_keys` — ключи вне базовой JQL, дочитанные по связям; хаб обновляет зеркало и `sync_run`. Каждая
|
||||
пачка применяется независимо; `page` и `last_page` необязательны и хабом не используются. Поле задачи `comments`
|
||||
необязательно; если оно есть, хаб SHALL до записи проверить его форму: список объектов, `id` — непустая строка, `body` —
|
||||
строка, `author`, `created`, `updated` — строка или `null`; нарушение — 422 `bad_state` с ключом задачи и именем поля,
|
||||
пачка не применяется.
|
||||
- `GET /agent/known-keys` — ключи зеркала (для определения соседей).
|
||||
- `GET /agent/release` — опубликованная версия агента `{version, sha256, content}` (`content` — текст `agent.py`);
|
||||
нет публикации или файл не сходится с манифестом по sha256 — 404 `no_release`.
|
||||
|
||||
#### Scenario: Выдача с подстановкой ключа
|
||||
- **WHEN** следующая команда ссылается на `new:60`, а `ensure_issue` для `new:60` завершилась с `AURORA-1590`
|
||||
- **THEN** `claim` отдаёт команду с `AURORA-1590` в параметрах
|
||||
|
||||
#### Scenario: Повторный отчёт
|
||||
- **WHEN** агент дважды прислал `result` для команды 15
|
||||
- **THEN** второй ответ 200, статус и результат не изменились
|
||||
|
||||
#### Scenario: Отчёт по невыданной команде
|
||||
- **WHEN** `POST /agent/commands/16/result` с `status = done`, команда 16 в `queued`
|
||||
- **THEN** 409 `not_claimed`, команда осталась `queued`
|
||||
|
||||
#### Scenario: Длинная ошибка
|
||||
- **WHEN** агент прислал `failed` с `error` длиной 5000 символов
|
||||
- **THEN** в `command.error` первые 1500 символов
|
||||
|
||||
#### Scenario: Поздний отчёт после предела выдач
|
||||
- **WHEN** `ensure_issue {temp_key: new:8, …}` переведена хабом в `failed` «агент не отчитался после 3 выдач», затем агент
|
||||
прислал `done` с `result.key = AURORA-1630`
|
||||
- **THEN** команда `failed`, в `result` — `AURORA-1630`; заметка `new:8` перенесена на `AURORA-1630`
|
||||
|
||||
#### Scenario: Публикация в ответе claim
|
||||
- **WHEN** опубликована v6, агент v5 отправил `claim`, команд нет
|
||||
- **THEN** ответ `{command: null, settings_version, agent_release: {version: 6, sha256: …}}`
|
||||
|
||||
#### Scenario: Состояние обновления от агента
|
||||
- **WHEN** агент прислал в `claim` `update_state = {state: rolled_back, from_version: 6, to_version: 7, error: "код 1 через 3 с"}`
|
||||
и `launcher_version = 1`
|
||||
- **THEN** `GET /agent` отдаёт это `update_state`, `launcher_version = 1` и `update_hint` «откат с v7 на v6: код 1 через 3 с»
|
||||
|
||||
#### Scenario: Скачивание версии
|
||||
- **WHEN** агентским токеном `GET /agent/release`, опубликована v6
|
||||
- **THEN** 200 `{version: 6, sha256, content}`, sha256 от `content` в UTF-8 равен `sha256`; клиентским токеном — 401;
|
||||
без публикации — 404 `no_release`
|
||||
|
||||
#### Scenario: Неверный комментарий в пачке
|
||||
- **WHEN** `POST /agent/state` с задачами X и Y, у Y комментарий `{id: "7", body: 42}`
|
||||
- **THEN** 422 `bad_state` с ключом Y и полем `body`; ни X, ни Y в зеркале не изменились
|
||||
|
||||
### Requirement: Маршруты интерфейсов
|
||||
Хаб SHALL предоставлять интерфейсам (MCP, позже просмотрщик) следующие маршруты под клиентским токеном:
|
||||
- `GET /issues` с фильтрами `sprint`, `assignee`, `work_kind`, `fact_status`, `status`, `label`, `epic`, `neighbor`,
|
||||
`q` (подстрока темы), `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` (сравнение после обрезки пробелов по краям; в ответе перечислены повторяющиеся темы).
|
||||
- `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 = "Тестовый эпик"`
|
||||
53
openspec/changes/issue-card-modal/specs/mcp/spec.md
Normal file
53
openspec/changes/issue-card-modal/specs/mcp/spec.md
Normal file
@ -0,0 +1,53 @@
|
||||
## MODIFIED Requirements
|
||||
|
||||
### Requirement: Инструменты
|
||||
| Инструмент | Параметры | Результат |
|
||||
|---|---|---|
|
||||
| `issues` | фильтры как у `GET /issues`, `limit` (по умолчанию 50) | таблица: ключ, тема (до 80 символов), статус, срок, исполнитель, спринт, статус по факту |
|
||||
| `issue` | `key` | поля, связи (обе стороны, с типом и направлением), заметки, последние команды, комментарии: «комментарии (N):» и 5 последних (автор, время, текст до 300 символов с «…»), «ещё M», если их больше 5; без комментариев — «комментариев нет» |
|
||||
| `notes_set` | `key`, подмножество полей заметок | обновлённые заметки |
|
||||
| `batch_create` | `title`, `commands` (список объектов по контракту `commands`) | `id`, `estimate_seconds`, число команд; 422 — текст ошибки валидации |
|
||||
| `batch_status` | `id` | статусы команд, ошибки, оценка остатка |
|
||||
| `batch_wait` | `id`, `timeout_minutes` (≤ 60) | как `batch_status`, возвращается по завершении или по таймауту |
|
||||
| `batches` | `limit` | последние пакеты со статусом |
|
||||
| `command_retry` | `id` | новый пакет |
|
||||
| `batch_cancel` | `id` | вид пакета; для команд пакета, уже взятых агентом, — строка «команда #N уже у агента и будет выполнена; остановить — `POST /commands/N/cancel` с `force: true`» |
|
||||
| `refresh` | `keys` или `full`, `wait` (bool) | без `wait` — строка с id поставленного пакета; с `wait` — итог пакета, как у `batch_wait` |
|
||||
| `agent_status` | — | онлайн/офлайн, версия, текущая команда, довыданные команды (id, тип, число выдач), 429 за сутки, версия настроек; публикация агента («публикация: v<N> от <дата>» или «публикации нет»), состояние обновления («обновление: <state> v<from> → v<to>»), запускатель («запускатель: v<N>», «без запускателя» или, пока `agent_reported = false`, «запускатель: неизвестно (агент ещё не выходил на связь после перезапуска хаба)»), `update_hint`, если он есть |
|
||||
| `sync_log` | `limit` | последние `sync_run` |
|
||||
| `settings_get`, `settings_set` | ключ, значение | текущее значение / новая `settings_version` |
|
||||
|
||||
Описание инструмента `batch_create` SHALL предупреждать, что `ensure_issue` должна нести `labels` с базовой меткой
|
||||
проекта (`AWPCA`): хаб и агент метки сами не добавляют, и задача без неё выпадает из базовой JQL и из зеркала.
|
||||
|
||||
Автор команд из MCP SHALL быть `claude`; из API без автора — `api`.
|
||||
|
||||
#### Scenario: Пакет из чата
|
||||
- **WHEN** `batch_create {title: "1538 в спринт 2", commands: [ensure_fields{…}, ensure_link{…}, ensure_sprint{…}]}`
|
||||
- **THEN** пакет создан с четырьмя командами (три + `refresh`), ответ содержит оценку в минутах
|
||||
|
||||
#### Scenario: Ожидание результата
|
||||
- **WHEN** `batch_wait {id, timeout_minutes: 10}` и пакет завершился через 6 минут
|
||||
- **THEN** ответ через 6 минут со статусами всех команд
|
||||
|
||||
#### Scenario: Статус агента с довыдачей
|
||||
- **WHEN** агент на связи, команда 40 выдана дважды и ещё без отчёта
|
||||
- **THEN** ответ `agent_status` содержит «агент онлайн» и строку о команде 40 с числом выдач 2
|
||||
|
||||
#### Scenario: Отмена пакета с командой у агента
|
||||
- **WHEN** `batch_cancel {id: 9}`, команда 30 пакета в `claimed`
|
||||
- **THEN** ответ содержит вид пакета и строку «команда #30 уже у агента и будет выполнена; остановить — …»
|
||||
|
||||
#### Scenario: Статус агента после отката
|
||||
- **WHEN** опубликована v7, агент v6 под запускателем v1 прислал `update_state = rolled_back` для v7
|
||||
- **THEN** ответ `agent_status` содержит «публикация: v7 от …», «обновление: rolled_back v6 → v7», «запускатель: v1» и
|
||||
«откат с v7 на v6: …»
|
||||
|
||||
#### Scenario: Статус агента сразу после перезапуска хаба
|
||||
- **WHEN** хаб перезапущен, агент ещё не прислал `claim`
|
||||
- **THEN** ответ `agent_status` содержит «запускатель: неизвестно (агент ещё не выходил на связь после перезапуска
|
||||
хаба)» и не содержит «без запускателя» и «агент запущен без run.py»
|
||||
|
||||
#### Scenario: Задача с семью комментариями
|
||||
- **WHEN** `issue {key: "AURORA-9001"}`, у задачи 7 комментариев, у самого нового текст 500 символов
|
||||
- **THEN** ответ содержит «комментарии (7):», 5 последних комментариев (самый новый — первым, обрезан до 300 символов с «…») и «ещё 2»
|
||||
165
openspec/changes/issue-card-modal/specs/web-app/spec.md
Normal file
165
openspec/changes/issue-card-modal/specs/web-app/spec.md
Normal file
@ -0,0 +1,165 @@
|
||||
## MODIFIED Requirements
|
||||
|
||||
### Requirement: Оболочка и адреса
|
||||
Приложение SHALL открываться под префиксом `/app/` и иметь страницы `/app/board` (доска связей), `/app/issues` (список
|
||||
задач) и `/app/issues/{key}` (карточка задачи — окно поверх страницы, требование «Карточка задачи»); `/app` и `/app/` SHALL вести на `/app/board`, неизвестный путь —
|
||||
на страницу «не найдено» со ссылками на разделы. Верхняя панель SHALL содержать переходы «Доска», «Задачи» и действие
|
||||
«Сменить токен». Неожиданная ошибка отрисовки страницы SHALL показываться сообщением с кнопкой «Перезагрузить», не
|
||||
пустым экраном.
|
||||
|
||||
#### Scenario: Переход между разделами
|
||||
- **WHEN** на доске выбран раздел «Задачи»
|
||||
- **THEN** адрес — `/app/issues`, показан список задач; «назад» возвращает на доску
|
||||
|
||||
### 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 доступна только после входа, с экрана входа её нет.
|
||||
При возврате на `/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** доска показана с той же прокруткой
|
||||
|
||||
### Requirement: Список задач
|
||||
Страница `/app/issues` SHALL показывать задачи зеркала из `GET /issues` таблицей: ключ (открывает карточку задачи окном поверх списка), тема,
|
||||
тип, статус Jira, исполнитель, спринт, срок (`planned_end`, иначе `duedate`), эпик (имя эпика, иначе ключ), вид работы,
|
||||
статус по факту, признак «сосед». Фильтры SHALL соответствовать параметрам `GET /issues` (спринт, исполнитель, вид
|
||||
работы, статус по факту, статус Jira, метка, эпик, только соседи, поиск по ключу или теме), храниться в query-строке
|
||||
адреса и применяться сервером; варианты выпадающих списков SHALL браться из снимка `GET /board`; значение фильтра из адреса, которого нет среди
|
||||
вариантов, SHALL показываться в списке с пометкой «(нет на доске)». Задачи 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** адрес списка с тем же фильтром, в таблице те же строки, прокрутка та же
|
||||
|
||||
### Requirement: Карточка задачи
|
||||
Адрес `/app/issues/{key}` SHALL открывать карточку задачи из `GET /issues/{key}` модальным окном поверх страницы, с
|
||||
которой её открыли (список «Задачи» или доска); страница под окном SHALL оставаться смонтированной и сохранять фильтры,
|
||||
загруженные строки, прокрутку и раскладку. Перезагрузка страницы с открытым окном SHALL сохранять страницу под окном. Открытый напрямую
|
||||
адрес (новая вкладка, закладка) SHALL
|
||||
показывать окно поверх списка задач без фильтров. Окно SHALL закрываться кнопкой ✕, клавишей Esc и щелчком по фону (нажатие и отпускание — на фоне; выделение текста,
|
||||
отпущенное за краем окна, окно не закрывает) и
|
||||
возвращать на страницу, с которой его открыли, даже после переходов по связям внутри окна; «назад» браузера после
|
||||
закрытия MUST NOT открывать карточку снова. Переход по связанной задаче SHALL менять содержимое окна и адрес, «назад»
|
||||
браузера — возвращать к прежней карточке. Пока окно открыто, фокус SHALL оставаться в окне, страница под ним не
|
||||
прокручивается.
|
||||
|
||||
Содержимое окна:
|
||||
- шапка: ключ, статус Jira, статус по факту (цветом класса из `status_labels`), «Открыть в Jira», ✕, тема; строка
|
||||
«исполнитель · срок · спринт · эпик» без пустых частей, срок — `planned_end`, иначе `duedate`, с пометкой «просрочено»,
|
||||
если он раньше сегодняшней даты и класс статуса по факту не `fact-done` и не `fact-cancelled`; эпик — темой
|
||||
(`epic_summary`), иначе ключом;
|
||||
- «Связи» по группам в порядке: «Зависит от» (входящие связи логических типов `блокирует начало` и `блокирует
|
||||
завершение` из `link_types`, с меткой «начало» / «завершение»), «Блокирует» (исходящие тех же типов, с теми же метками),
|
||||
«Связана с» (тип `связанные` в обе стороны), «Прочие» (тип Jira вне `link_types`: имя типа и направление «→» / «←»);
|
||||
в строке — ключ (открывает связанную задачу в окне), тема и статус Jira, а если задачи нет в зеркале — ключ и «нет в
|
||||
зеркале»; пустые группы не показываются, без связей — «связей нет»;
|
||||
- «Описание» — текст с сохранёнными переносами строк, без разбора разметки Jira; длиннее 8 строк — свёрнуто с кнопкой
|
||||
«развернуть» / «свернуть»; без описания раздела нет;
|
||||
- «Комментарии (N)» — новые сверху, автор, время, текст с сохранёнными переносами без разбора разметки; кнопка
|
||||
«Скрыть» / «Показать» сворачивает раздел, выбор 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** комментарии в ней скрыты, видна кнопка «Показать» и число комментариев
|
||||
|
||||
### Requirement: Служебные заметки
|
||||
Карточка задачи SHALL показывать служебные заметки в режиме чтения: «Вид работы», «Статус по факту», «Сделано»,
|
||||
«Осталось», «Заметка владельца» (пустые — «—») и время и автор последнего изменения; кнопка «Править» SHALL открывать
|
||||
форму: «вид работы» — выбор из значений `kind_labels` настроек (и текущего значения, если его там нет, и пустого),
|
||||
«статус по факту» — текст с подсказками из ключей `status_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` «тестирование»); окно закрывается без потери раскладки и прокрутки доски
|
||||
@ -163,8 +163,13 @@ def h_agent_result(hub, conn, m, q, body):
|
||||
|
||||
|
||||
def h_agent_state(hub, conn, m, q, body):
|
||||
issues = body.get("issues") or []
|
||||
try:
|
||||
mirror.check_comments(issues) # до записи: неверная пачка не применяется целиком
|
||||
except ValueError as e:
|
||||
raise ApiError(422, "bad_state", str(e))
|
||||
with db.transaction(conn):
|
||||
out = mirror.apply_state(conn, body.get("issues") or [], body.get("links") or [], body.get("neighbor_keys") or [],
|
||||
out = mirror.apply_state(conn, issues, body.get("links") or [], body.get("neighbor_keys") or [],
|
||||
authoritative=queue.is_full_refresh(conn, body.get("command_id")))
|
||||
if body.get("command_id"):
|
||||
queue.record_state(conn, body["command_id"], out["issues"], out["links"])
|
||||
|
||||
@ -8,7 +8,7 @@ from pathlib import Path
|
||||
|
||||
from . import backup, paths
|
||||
|
||||
SCHEMA_VERSION = 2
|
||||
SCHEMA_VERSION = 3
|
||||
|
||||
SCHEMA = """
|
||||
CREATE TABLE IF NOT EXISTS setting (
|
||||
@ -43,6 +43,15 @@ CREATE TABLE IF NOT EXISTS issue_link (
|
||||
PRIMARY KEY (source_key, target_key, link_type)
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS ix_issue_link_target ON issue_link(target_key);
|
||||
CREATE TABLE IF NOT EXISTS issue_comment (
|
||||
key TEXT NOT NULL,
|
||||
comment_id TEXT NOT NULL,
|
||||
author TEXT,
|
||||
created TEXT,
|
||||
updated TEXT,
|
||||
body TEXT NOT NULL DEFAULT '',
|
||||
PRIMARY KEY (key, comment_id)
|
||||
);
|
||||
CREATE TABLE IF NOT EXISTS issue_note (
|
||||
key TEXT PRIMARY KEY,
|
||||
fact_status TEXT,
|
||||
@ -105,6 +114,7 @@ CREATE TABLE IF NOT EXISTS agent_status (
|
||||
MIGRATIONS = {
|
||||
1: ["ALTER TABLE command ADD COLUMN deliveries INTEGER NOT NULL DEFAULT 0",
|
||||
"UPDATE command SET deliveries = 1 WHERE status = 'claimed'"], # уже выданные — одна выдача
|
||||
2: [], # v2 → v3: issue_comment создаётся SCHEMA (CREATE TABLE IF NOT EXISTS), данных переносить не нужно
|
||||
}
|
||||
|
||||
|
||||
|
||||
@ -80,8 +80,14 @@ def fmt_issue(d) -> str:
|
||||
note = d.get("note") or {}
|
||||
notes = [f" {k}: {note[k]}" for k in ("fact_status", "work_kind", "done_text", "rest_text", "owner_note") if note.get(k)]
|
||||
cmds = [f" #{c['id']} {c['type']} {c['status']} {c.get('finished_at') or ''} {c.get('error') or ''}".rstrip() for c in d.get("commands", [])]
|
||||
return "\n".join(head + ["связи:"] + (links or [" —"]) + ["заметки:"] + (notes or [" —"]) + ["команды:"] + (cmds or [" —"])
|
||||
+ ["описание:", d.get("description") or "—"])
|
||||
comments = d.get("comments") or []
|
||||
if comments:
|
||||
shown = [f" {_v(c.get('author'))} {_v(c.get('created'))}: {_cut(c.get('body'), 300)}" for c in comments[:5]]
|
||||
cmts = [f"комментарии ({len(comments)}):"] + shown + ([f" ещё {len(comments) - 5}"] if len(comments) > 5 else [])
|
||||
else:
|
||||
cmts = ["комментариев нет"]
|
||||
return "\n".join(head + ["связи:"] + (links or [" —"]) + ["заметки:"] + (notes or [" —"]) + cmts
|
||||
+ ["команды:"] + (cmds or [" —"]) + ["описание:", d.get("description") or "—"])
|
||||
|
||||
|
||||
def fmt_batch(b) -> str:
|
||||
|
||||
@ -1,4 +1,4 @@
|
||||
"""Зеркало задач Jira (issue, issue_link) и служебные заметки (issue_note).
|
||||
"""Зеркало задач Jira (issue, issue_link, issue_comment) и служебные заметки (issue_note).
|
||||
Зеркало пишет только загрузка состояния от агента; заметки обновление зеркала не трогает."""
|
||||
import json
|
||||
|
||||
@ -8,6 +8,26 @@ ISSUE_COLUMNS = ["key", "id", "issue_type", "summary", "description", "status",
|
||||
"duedate", "planned_start", "planned_end", "epic_key", "labels", "sprint", "created", "jira_updated"]
|
||||
NOTE_FIELDS = ["fact_status", "work_kind", "done_text", "rest_text", "owner_note"]
|
||||
|
||||
def check_comments(issues) -> None:
|
||||
"""Форма поля comments у задач состояния (агент v10+); поле необязательно. Нарушение — ValueError с ключом и полем."""
|
||||
for it in issues:
|
||||
if "comments" not in it:
|
||||
continue
|
||||
key, lst = it.get("key"), it["comments"]
|
||||
if not isinstance(lst, list):
|
||||
raise ValueError(f"{key}: comments — список комментариев")
|
||||
for n, c in enumerate(lst):
|
||||
where = f"{key}: comments[{n}]"
|
||||
if not isinstance(c, dict):
|
||||
raise ValueError(f"{where} — объект комментария")
|
||||
if not isinstance(c.get("id"), str) or not c["id"]:
|
||||
raise ValueError(f"{where}.id — непустая строка")
|
||||
if not isinstance(c.get("body"), str) and c.get("body") is not None:
|
||||
raise ValueError(f"{where}.body — строка")
|
||||
for f in ("author", "created", "updated"):
|
||||
if c.get(f) is not None and not isinstance(c[f], str):
|
||||
raise ValueError(f"{where}.{f} — строка или null")
|
||||
|
||||
_FILTERS = {"sprint": "i.sprint = ?", "assignee": "i.assignee = ?", "status": "i.status = ?", "epic": "i.epic_key = ?",
|
||||
"work_kind": "n.work_kind = ?", "fact_status": "n.fact_status = ?"}
|
||||
|
||||
@ -35,6 +55,13 @@ def apply_state(conn, issues, links, neighbor_keys=(), fetched_at=None, authorit
|
||||
"ON CONFLICT(key) DO UPDATE SET " + ", ".join(sets),
|
||||
[row[c] for c in cols])
|
||||
keys.append(it["key"])
|
||||
if isinstance(it.get("comments"), list): # без поля (агент до v10) комментарии не трогаем
|
||||
conn.execute("DELETE FROM issue_comment WHERE key = ?", (it["key"],))
|
||||
conn.executemany(
|
||||
"INSERT OR REPLACE INTO issue_comment(key, comment_id, author, created, updated, body) "
|
||||
"VALUES (?, ?, ?, ?, ?, ?)",
|
||||
[(it["key"], c["id"], c.get("author"), c.get("created"), c.get("updated"), c.get("body") or "")
|
||||
for c in it["comments"]])
|
||||
conn.executemany("DELETE FROM issue_link WHERE source_key = ?", [(k,) for k in keys])
|
||||
for link in links:
|
||||
conn.execute("INSERT OR IGNORE INTO issue_link(source_key, target_key, link_type) VALUES (?, ?, ?)",
|
||||
@ -80,10 +107,17 @@ def get_issue(conn, key) -> dict | None:
|
||||
if row is None:
|
||||
return None
|
||||
d = _issue_dict(row)
|
||||
epic = conn.execute("SELECT summary FROM issue WHERE key = ?", (d["epic_key"],)).fetchone() if d.get("epic_key") else None
|
||||
d["epic_summary"] = epic["summary"] if epic else None
|
||||
d["links_out"] = [dict(r) for r in conn.execute(
|
||||
"SELECT target_key, link_type FROM issue_link WHERE source_key = ? ORDER BY link_type, target_key", (key,))]
|
||||
"SELECT l.target_key, l.link_type, i.summary, i.status FROM issue_link l LEFT JOIN issue i ON i.key = l.target_key "
|
||||
"WHERE l.source_key = ? ORDER BY l.link_type, l.target_key", (key,))]
|
||||
d["links_in"] = [dict(r) for r in conn.execute(
|
||||
"SELECT source_key, link_type FROM issue_link WHERE target_key = ? ORDER BY link_type, source_key", (key,))]
|
||||
"SELECT l.source_key, l.link_type, i.summary, i.status FROM issue_link l LEFT JOIN issue i ON i.key = l.source_key "
|
||||
"WHERE l.target_key = ? ORDER BY l.link_type, l.source_key", (key,))]
|
||||
d["comments"] = [dict(r) for r in conn.execute(
|
||||
"SELECT comment_id, author, created, updated, body FROM issue_comment WHERE key = ? "
|
||||
"ORDER BY created DESC, CAST(comment_id AS INTEGER) DESC, comment_id DESC", (key,))]
|
||||
d["note"] = get_note(conn, key)
|
||||
d["commands"] = [dict(r) for r in conn.execute(
|
||||
"SELECT id, batch_id, type, status, finished_at, error FROM command WHERE params LIKE ? ORDER BY id DESC LIMIT 10",
|
||||
|
||||
@ -72,7 +72,7 @@ def test_agent_flow(hub):
|
||||
a.post(f"/agent/commands/{tail['id']}/result", {"status": "done", "jira_calls": 1})
|
||||
assert c.get("/batches/%d" % b["id"])["status"] == "done"
|
||||
issue = c.get("/issues/A-1")
|
||||
assert issue["links_out"] == [{"target_key": "B-1", "link_type": "ASUPR_Связанные"}]
|
||||
assert issue["links_out"] == [{"target_key": "B-1", "link_type": "ASUPR_Связанные", "summary": "Сосед", "status": None}]
|
||||
assert c.get("/issues/B-1")["neighbor"] is True
|
||||
runs = c.get("/sync-runs", limit=5)["runs"]
|
||||
assert runs[0]["issues_count"] == 2 and runs[0]["finished_at"]
|
||||
@ -572,3 +572,29 @@ def test_tick_creates_auto_refresh_once(hub):
|
||||
assert [b["title"] for b in batches] == ["Автообновление"] and batches[0]["author"] == "hub"
|
||||
hub["hub"].tick()
|
||||
assert len(hub["client"].get("/batches", limit=5)["batches"]) == 1
|
||||
|
||||
|
||||
def test_agent_state_comments_validated_whole_batch(hub):
|
||||
a, c = hub["agent"], hub["client"]
|
||||
a.post("/agent/state", {"issues": [{"key": "X-1", "summary": "Икс", "labels": []}], "links": []})
|
||||
with pytest.raises(HubError) as e:
|
||||
a.post("/agent/state", {"issues": [{"key": "X-1", "summary": "Икс новый", "labels": []},
|
||||
{"key": "Y-1", "summary": "Игрек", "labels": [],
|
||||
"comments": [{"id": "7", "body": 42}]}], "links": []})
|
||||
assert e.value.status == 422 and e.value.error == "bad_state"
|
||||
assert "Y-1" in e.value.detail and "body" in e.value.detail
|
||||
assert c.get("/issues/X-1")["summary"] == "Икс" # пачка не применена целиком
|
||||
with pytest.raises(HubError) as e:
|
||||
c.get("/issues/Y-1")
|
||||
assert e.value.status == 404
|
||||
|
||||
|
||||
def test_issue_detail_with_comments(hub):
|
||||
hub["agent"].post("/agent/state", {"issues": [
|
||||
{"key": "A-1", "summary": "Схема", "labels": [], "epic_key": "E-1", "comments": [
|
||||
{"id": "1", "author": "Петров Пётр", "created": "2026-10-14T12:00:00.000+0300", "updated": None, "body": "старый"},
|
||||
{"id": "2", "author": None, "created": "2026-10-15T12:00:00.000+0300", "updated": None, "body": "новый"}]},
|
||||
{"key": "E-1", "summary": "Эпик", "labels": []}], "links": []})
|
||||
got = hub["client"].get("/issues/A-1")
|
||||
assert [x["body"] for x in got["comments"]] == ["новый", "старый"]
|
||||
assert got["epic_summary"] == "Эпик"
|
||||
|
||||
@ -10,8 +10,9 @@ from jira_hub import backup, db, paths, queue, settings
|
||||
def test_connect_creates_schema(open_db):
|
||||
conn = open_db()
|
||||
names = {r["name"] for r in conn.execute("SELECT name FROM sqlite_master WHERE type = 'table'")}
|
||||
assert {"setting", "issue", "issue_link", "issue_note", "batch", "command", "sync_run", "agent_status"} <= names
|
||||
assert conn.execute("SELECT value FROM setting WHERE key = 'schema_version'").fetchone()["value"] == "2"
|
||||
assert {"setting", "issue", "issue_link", "issue_comment", "issue_note", "batch", "command", "sync_run",
|
||||
"agent_status"} <= names
|
||||
assert conn.execute("SELECT value FROM setting WHERE key = 'schema_version'").fetchone()["value"] == "3"
|
||||
assert conn.execute("SELECT COUNT(*) AS n FROM agent_status").fetchone()["n"] == 1
|
||||
cols = {r["name"] for r in conn.execute("PRAGMA table_info(command)")}
|
||||
assert "deliveries" in cols
|
||||
@ -34,7 +35,7 @@ def test_migrates_v1_with_backup(tmp_path):
|
||||
path = tmp_path / "hub.sqlite"
|
||||
_v1_database(path)
|
||||
conn = db.connect(path)
|
||||
assert conn.execute("SELECT value FROM setting WHERE key = 'schema_version'").fetchone()["value"] == "2"
|
||||
assert conn.execute("SELECT value FROM setting WHERE key = 'schema_version'").fetchone()["value"] == "3"
|
||||
rows = conn.execute("SELECT status, deliveries FROM command ORDER BY id").fetchall()
|
||||
assert [(r["status"], r["deliveries"]) for r in rows] == [("claimed", 1), ("done", 0), ("queued", 0)]
|
||||
assert conn.execute("SELECT fact_status FROM issue_note WHERE key = 'A-1'").fetchone()["fact_status"] == "В работе"
|
||||
@ -181,7 +182,26 @@ def test_parallel_connect_makes_one_backup(tmp_path):
|
||||
assert errors == []
|
||||
assert len(list((tmp_path / "backups").glob("*-schema-v1.sqlite"))) == 1
|
||||
conn = db.connect(path)
|
||||
assert conn.execute("SELECT value FROM setting WHERE key = 'schema_version'").fetchone()["value"] == "2"
|
||||
assert conn.execute("SELECT value FROM setting WHERE key = 'schema_version'").fetchone()["value"] == "3"
|
||||
conn.close()
|
||||
|
||||
|
||||
def test_migrates_v2_with_backup(tmp_path):
|
||||
"""База v2 (без issue_comment) с задачей и заметкой → v3: копия schema-v2, таблица комментариев пустая, данные на месте."""
|
||||
path = tmp_path / "hub.sqlite"
|
||||
conn = db.connect(path)
|
||||
conn.execute("INSERT INTO issue(key, summary, fetched_at) VALUES ('A-1', 'Т', '2026-10-15T00:00:00+00:00')")
|
||||
conn.execute("INSERT INTO issue_note(key, fact_status) VALUES ('A-1', 'В работе')")
|
||||
conn.execute("DROP TABLE issue_comment")
|
||||
conn.execute("UPDATE setting SET value = '2' WHERE key = 'schema_version'")
|
||||
conn.close()
|
||||
conn = db.connect(path)
|
||||
assert conn.execute("SELECT value FROM setting WHERE key = 'schema_version'").fetchone()["value"] == "3"
|
||||
assert conn.execute("SELECT COUNT(*) AS n FROM issue_comment").fetchone()["n"] == 0
|
||||
assert conn.execute("SELECT summary FROM issue WHERE key = 'A-1'").fetchone()["summary"] == "Т"
|
||||
assert conn.execute("SELECT fact_status FROM issue_note WHERE key = 'A-1'").fetchone()["fact_status"] == "В работе"
|
||||
copies = list((tmp_path / "backups").glob("*-schema-v2.sqlite"))
|
||||
assert len(copies) == 1
|
||||
conn.close()
|
||||
|
||||
|
||||
|
||||
@ -75,5 +75,6 @@ def test_create_link_sprint_flow(live):
|
||||
assert issue["note"]["work_kind"] == "разработка"
|
||||
assert len(issue["links_out"]) == 1
|
||||
assert issue["links_out"][0]["target_key"] == "A-1" and issue["links_out"][0]["link_type"] == "ASUPR_Связанные"
|
||||
assert client.get("/issues/A-1")["links_in"] == [{"source_key": "A-90", "link_type": "ASUPR_Связанные"}]
|
||||
links_in = client.get("/issues/A-1")["links_in"]
|
||||
assert [(l["source_key"], l["link_type"]) for l in links_in] == [("A-90", "ASUPR_Связанные")]
|
||||
assert client.get("/sync-runs")["runs"][0]["issues_count"] == 2
|
||||
|
||||
@ -236,3 +236,25 @@ def test_agent_status_right_after_hub_start(live):
|
||||
live["agent"].post("/agent/claim", {"agent_version": 9, "launcher_version": 2})
|
||||
text = mcp_server.agent_status()
|
||||
assert "запускатель: v2" in text and "неизвестно" not in text
|
||||
|
||||
|
||||
def _detail(**over):
|
||||
d = {"key": "A-1", "summary": "Т", "labels": [], "links_out": [], "links_in": [], "note": None, "commands": [],
|
||||
"comments": [], "description": None}
|
||||
d.update(over)
|
||||
return d
|
||||
|
||||
|
||||
def test_fmt_issue_comments():
|
||||
assert "комментариев нет" in mcp_server.fmt_issue(_detail())
|
||||
comments = [{"comment_id": str(n), "author": f"Автор {n}", "created": f"2026-10-1{n}T12:00:00.000+0300",
|
||||
"updated": None, "body": ("д" * 500 if n == 7 else f"текст {n}\nстрока")} for n in range(7, 0, -1)]
|
||||
text = mcp_server.fmt_issue(_detail(comments=comments))
|
||||
assert "комментарии (7):" in text
|
||||
assert "д" * 299 + "…" in text and "д" * 300 not in text # самый новый — первым, обрезан до 300
|
||||
assert "Автор 3" in text and "Автор 2" not in text # 5 последних: 7…3
|
||||
assert "ещё 2" in text
|
||||
assert text.index("заметки:") < text.index("комментарии (7):") < text.index("команды:")
|
||||
no_author = mcp_server.fmt_issue(_detail(comments=[{"comment_id": "1", "author": None, "created": None,
|
||||
"updated": None, "body": ""}]))
|
||||
assert "комментарии (1):" in no_author and "ещё" not in no_author
|
||||
|
||||
@ -27,15 +27,18 @@ def test_apply_state_upserts_and_replaces_links(conn):
|
||||
[{"source_key": "A-1", "target_key": "A-2", "link_type": "ASUPR_Связанные"}])
|
||||
got = mirror.get_issue(conn, "A-1")
|
||||
assert got["summary"] == "Новая тема" and got["labels"] == ["AWPCA"]
|
||||
assert got["links_out"] == [{"target_key": "A-2", "link_type": "ASUPR_Связанные"}]
|
||||
assert mirror.get_issue(conn, "A-2")["links_in"] == [{"source_key": "A-1", "link_type": "ASUPR_Связанные"}]
|
||||
assert got["links_out"] == [{"target_key": "A-2", "link_type": "ASUPR_Связанные", "summary": "Тема A-2",
|
||||
"status": "Ожидание разработки"}]
|
||||
assert mirror.get_issue(conn, "A-2")["links_in"] == [{"source_key": "A-1", "link_type": "ASUPR_Связанные",
|
||||
"summary": "Новая тема", "status": "Ожидание разработки"}]
|
||||
|
||||
|
||||
def test_links_from_other_source_survive(conn):
|
||||
mirror.apply_state(conn, [issue("A-1"), issue("A-2")],
|
||||
[{"source_key": "A-2", "target_key": "A-1", "link_type": "ASUPR_Связанные"}])
|
||||
mirror.apply_state(conn, [issue("A-1")], []) # пришла только A-1 без связей
|
||||
assert mirror.get_issue(conn, "A-1")["links_in"] == [{"source_key": "A-2", "link_type": "ASUPR_Связанные"}]
|
||||
assert mirror.get_issue(conn, "A-1")["links_in"] == [{"source_key": "A-2", "link_type": "ASUPR_Связанные",
|
||||
"summary": "Тема A-2", "status": "Ожидание разработки"}]
|
||||
|
||||
|
||||
def test_apply_state_marks_neighbors(conn):
|
||||
@ -183,3 +186,57 @@ def test_board_inside_open_transaction_keeps_it_open(conn):
|
||||
assert conn.in_transaction
|
||||
assert not conn.in_transaction
|
||||
assert [i["key"] for i in snap["issues"]] == ["AURORA-1620"]
|
||||
|
||||
|
||||
def comment(cid, created="2026-10-14T12:00:00.000+0300", **over):
|
||||
base = {"id": cid, "author": "Петров Пётр", "created": created, "updated": created, "body": f"текст {cid}"}
|
||||
base.update(over)
|
||||
return base
|
||||
|
||||
|
||||
def test_comments_replaced_and_kept_without_field(conn):
|
||||
mirror.apply_state(conn, [issue("A-1", comments=[comment("101"), comment("102")])], [])
|
||||
mirror.apply_state(conn, [issue("A-1", comments=[comment("102", body="новый текст")])], [])
|
||||
got = mirror.get_issue(conn, "A-1")["comments"]
|
||||
assert [(c["comment_id"], c["body"]) for c in got] == [("102", "новый текст")]
|
||||
mirror.apply_state(conn, [issue("A-1", summary="Без поля comments")], []) # агент до v10
|
||||
assert [c["comment_id"] for c in mirror.get_issue(conn, "A-1")["comments"]] == ["102"]
|
||||
mirror.apply_state(conn, [issue("A-1", comments=[])], [])
|
||||
assert mirror.get_issue(conn, "A-1")["comments"] == []
|
||||
|
||||
|
||||
def test_comments_newest_first_and_shape(conn):
|
||||
mirror.apply_state(conn, [issue("A-1", comments=[
|
||||
comment("9", created="2026-10-14T10:00:00.000+0300"),
|
||||
comment("10", created="2026-10-15T10:00:00.000+0300", author=None, body=None),
|
||||
comment("11", created="2026-10-15T10:00:00.000+0300")])], [])
|
||||
got = mirror.get_issue(conn, "A-1")["comments"]
|
||||
assert [c["comment_id"] for c in got] == ["11", "10", "9"] # то же время — по номеру, как число
|
||||
assert got[1] == {"comment_id": "10", "author": None, "created": "2026-10-15T10:00:00.000+0300",
|
||||
"updated": "2026-10-15T10:00:00.000+0300", "body": ""}
|
||||
|
||||
|
||||
def test_issue_epic_summary_and_absent_link(conn):
|
||||
mirror.apply_state(conn, [issue("A-1", epic_key="E-1"), issue("E-1", summary="Эпик свода", epic_key=None)],
|
||||
[{"source_key": "A-1", "target_key": "Z-9", "link_type": "ASUPR_Связанные"}])
|
||||
got = mirror.get_issue(conn, "A-1")
|
||||
assert got["epic_summary"] == "Эпик свода"
|
||||
assert got["links_out"] == [{"target_key": "Z-9", "link_type": "ASUPR_Связанные", "summary": None, "status": None}]
|
||||
assert mirror.get_issue(conn, "E-1")["epic_summary"] is None
|
||||
|
||||
|
||||
@pytest.mark.parametrize("bad, field", [
|
||||
("x", "comments"), (["x"], "comments[0]"), ([{"id": "", "body": "т"}], "comments[0].id"),
|
||||
([{"id": 5, "body": "т"}], "comments[0].id"), ([{"id": "5", "body": 42}], "comments[0].body"),
|
||||
([{"id": "5", "body": "т", "author": 7}], "comments[0].author"),
|
||||
([{"id": "5", "body": "т", "created": 1}], "comments[0].created"),
|
||||
([{"id": "5", "body": "т", "updated": []}], "comments[0].updated"),
|
||||
])
|
||||
def test_check_comments_rejects(bad, field):
|
||||
with pytest.raises(ValueError) as e:
|
||||
mirror.check_comments([issue("A-1"), issue("A-2", comments=bad)])
|
||||
assert str(e.value).startswith(f"A-2: {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="")])])
|
||||
|
||||
@ -53,13 +53,15 @@ test("«Обновить из Jira» с учебным агентом доход
|
||||
await expect(page.locator(".board .card[data-key]")).not.toHaveCount(0);
|
||||
});
|
||||
|
||||
test("ⓘ открывает карточку задачи в приложении", async ({ page }) => {
|
||||
test("ⓘ открывает карточку окном поверх доски", async ({ page }) => {
|
||||
await page.locator('.board .card[data-key="AURORA-9001"]').getByRole("link", { name: "Открыть карточку AURORA-9001" }).click();
|
||||
await expect(page).toHaveURL(/\/app\/issues\/AURORA-9001$/);
|
||||
await expect(page.getByRole("heading", { name: /AURORA-9001/ })).toBeVisible();
|
||||
await expect(page.getByRole("dialog").getByRole("heading", { name: "Схема свода" })).toBeVisible();
|
||||
await expect(page.getByTestId("page-board")).toBeAttached();
|
||||
await expect(page.getByRole("link", { name: "Доска", exact: true })).toHaveClass(/active/);
|
||||
});
|
||||
|
||||
test("M-3: прокрутка доски после возврата из карточки задачи", async ({ page }) => {
|
||||
test("M-3: прокрутка и данные доски после окна карточки и после другого раздела", async ({ page }) => {
|
||||
await page.setViewportSize({ width: 1600, height: 600 });
|
||||
await expect(page.locator(".board .card[data-key]").first()).toBeVisible();
|
||||
const y = await page.evaluate(() => { window.scrollTo(0, document.documentElement.scrollHeight); return window.scrollY; });
|
||||
@ -72,9 +74,37 @@ test("M-3: прокрутка доски после возврата из кар
|
||||
return a?.closest<HTMLElement>(".card")?.dataset.key ?? null;
|
||||
});
|
||||
expect(key).not.toBeNull();
|
||||
const link = page.locator(`.board .card[data-key="${key}"] a.open-card`);
|
||||
await link.click();
|
||||
await expect(page).toHaveURL(/\/app\/issues\//);
|
||||
await page.getByRole("link", { name: "Доска", exact: true }).click();
|
||||
let boardLoads = 0;
|
||||
page.on("request", (r) => { if (new URL(r.url()).pathname === "/board") boardLoads++; });
|
||||
await page.locator(`.board .card[data-key="${key}"] a.open-card`).click();
|
||||
await expect(page.getByRole("dialog")).toBeVisible();
|
||||
await page.keyboard.press("Escape");
|
||||
await expect(page).toHaveURL(/\/app\/board$/);
|
||||
expect(await page.evaluate(() => window.scrollY)).toBeGreaterThan(y - 2);
|
||||
expect(boardLoads).toBe(0); // доска не перезагружалась
|
||||
// ссылки меню вверху страницы: обычный click прокрутил бы доску к ним до перехода и затёр запомненное, поэтому dispatchEvent
|
||||
await page.getByRole("link", { name: "Задачи", exact: true }).dispatchEvent("click");
|
||||
await expect(page).toHaveURL(/\/app\/issues$/);
|
||||
await page.getByRole("link", { name: "Доска", exact: true }).dispatchEvent("click");
|
||||
await expect.poll(() => page.evaluate(() => window.scrollY)).toBeGreaterThan(y - 2);
|
||||
});
|
||||
|
||||
test("заметка из окна уходит в хаб, карточка на доске остаётся на месте, прокрутка сохраняется", async ({ page }) => {
|
||||
// доска показывает вид работы по меткам Jira (fromHub), заметка work_kind в карточку доски не попадает: проверяем снимок /board
|
||||
await page.locator('.board .card[data-key="AURORA-9002"]').evaluate((e) => { (e as HTMLElement).dataset.marker = "keep"; });
|
||||
await page.locator('.board .card[data-key="AURORA-9002"] a.open-card').click();
|
||||
const d = page.getByRole("dialog");
|
||||
const y = await page.evaluate(() => window.scrollY);
|
||||
await d.getByRole("button", { name: "Править" }).click();
|
||||
await d.getByLabel("Вид работы").selectOption("тестирование");
|
||||
const snap = page.waitForResponse((r) => new URL(r.url()).pathname === "/board" && r.request().method() === "GET");
|
||||
await d.getByRole("button", { name: "Сохранить" }).click();
|
||||
const body = await (await snap).json();
|
||||
expect(body.issues.find((i: { key: string }) => i.key === "AURORA-9002").work_kind).toBe("тестирование");
|
||||
await expect(d.getByRole("button", { name: "Править" })).toBeVisible();
|
||||
await page.keyboard.press("Escape");
|
||||
await expect(page.getByTestId("page-board")).toBeAttached();
|
||||
await expect(page.locator('.board .card[data-key="AURORA-9002"]')).toBeVisible();
|
||||
await expect(page.locator('.board .card[data-key="AURORA-9002"]')).toHaveAttribute("data-marker", "keep"); // узел не пересоздан
|
||||
expect(await page.evaluate(() => window.scrollY)).toBe(y);
|
||||
});
|
||||
|
||||
@ -66,12 +66,13 @@ test("смена спринта и исполнителя, убирающая к
|
||||
expect(errs).toEqual([]);
|
||||
});
|
||||
|
||||
test("h1 на страницах доски, задач и карточки", async ({ page }) => {
|
||||
test("h1 на страницах доски и задач, h2 в окне карточки", async ({ page }) => {
|
||||
await login(page);
|
||||
await expect(page.locator("h1")).toHaveText("Доска связей");
|
||||
await page.getByRole("link", { name: "Задачи" }).click();
|
||||
await expect(page.getByRole("heading", { level: 1, name: "Задачи" })).toBeVisible();
|
||||
await page.getByRole("link", { name: "AURORA-9002" }).click();
|
||||
await expect(page.getByRole("heading", { level: 1, name: /AURORA-9002/ })).toBeVisible();
|
||||
// карточка — окно поверх списка: её заголовок h2 (тема задачи), h1 остаётся у страницы под окном
|
||||
await expect(page.getByRole("dialog").getByRole("heading", { level: 2, name: "API свода" })).toBeVisible();
|
||||
await expect(page.locator("h1")).toHaveCount(1);
|
||||
});
|
||||
|
||||
@ -1,15 +1,121 @@
|
||||
import { expect, test } from "@playwright/test";
|
||||
import { login } from "./hub.ts";
|
||||
|
||||
test("фильтр → карточка → заметка сохраняется", async ({ page }) => {
|
||||
test("фильтр → карточка окном → заметка сохраняется и переживает перезагрузку", async ({ page }) => {
|
||||
await login(page, "/app/issues");
|
||||
await page.getByLabel("Исполнитель").selectOption("Петров Пётр Петрович");
|
||||
await expect(page).toHaveURL(/assignee=/);
|
||||
await page.getByRole("link", { name: "AURORA-9002" }).click();
|
||||
const rest = page.getByLabel("Осталось");
|
||||
await rest.fill("e2e: дописать тесты");
|
||||
await page.getByRole("button", { name: "Сохранить" }).click();
|
||||
await expect(page.getByText(/сохранено .*web/)).toBeVisible();
|
||||
await page.getByRole("link", { name: "AURORA-9002", exact: true }).click();
|
||||
const d = page.getByRole("dialog");
|
||||
await expect(d.getByRole("heading", { name: "API свода" })).toBeVisible();
|
||||
await d.getByRole("button", { name: "Править" }).click();
|
||||
await d.getByLabel("Осталось").fill("e2e: дописать тесты");
|
||||
await d.getByRole("button", { name: "Сохранить" }).click();
|
||||
await expect(d.getByText("e2e: дописать тесты")).toBeVisible();
|
||||
await page.reload();
|
||||
await expect(page.getByLabel("Осталось")).toHaveValue("e2e: дописать тесты");
|
||||
await expect(page.getByRole("dialog").getByText("e2e: дописать тесты")).toBeVisible();
|
||||
await page.keyboard.press("Escape");
|
||||
await expect(page.getByRole("dialog")).toHaveCount(0);
|
||||
await expect(page).toHaveURL(/\/app\/issues\?assignee=/);
|
||||
await expect(page.getByLabel("Исполнитель")).toHaveValue("Петров Пётр Петрович");
|
||||
});
|
||||
|
||||
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);
|
||||
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();
|
||||
await expect(page.getByRole("dialog")).toBeVisible();
|
||||
expect(await page.evaluate(() => window.scrollY)).toBe(y);
|
||||
await page.keyboard.press("Escape");
|
||||
await expect(page.getByRole("dialog")).toHaveCount(0);
|
||||
expect(await page.evaluate(() => window.scrollY)).toBe(y);
|
||||
});
|
||||
|
||||
test("переход по связи и «назад»; ✕ и щелчок по фону; после закрытия «назад» не открывает карточку", async ({ page }) => {
|
||||
await login(page); // доска
|
||||
await page.getByRole("link", { name: "Задачи", exact: true }).click();
|
||||
await page.getByRole("link", { name: "AURORA-9001", exact: true }).click();
|
||||
const d = page.getByRole("dialog");
|
||||
await d.getByRole("link", { name: "AURORA-9002", exact: true }).click();
|
||||
await expect(page).toHaveURL(/\/app\/issues\/AURORA-9002$/);
|
||||
await expect(d.getByRole("heading", { name: "API свода" })).toBeVisible();
|
||||
await page.goBack();
|
||||
await expect(d.getByRole("heading", { name: "Схема свода" })).toBeVisible();
|
||||
await d.getByRole("link", { name: "AURORA-9002", exact: true }).click();
|
||||
await d.getByRole("button", { name: "Закрыть" }).click();
|
||||
await expect(page).toHaveURL(/\/app\/issues$/);
|
||||
await expect(page.getByRole("dialog")).toHaveCount(0);
|
||||
await page.getByRole("link", { name: "AURORA-9003", exact: true }).click();
|
||||
await expect(page.getByRole("dialog")).toBeVisible();
|
||||
await page.mouse.click(5, 300); // фон слева от окна
|
||||
await expect(page.getByRole("dialog")).toHaveCount(0);
|
||||
await page.goBack();
|
||||
await expect(page).toHaveURL(/\/app\/board$/);
|
||||
await expect(page.getByRole("dialog")).toHaveCount(0);
|
||||
});
|
||||
|
||||
test("прямой адрес и перезагрузка: окно поверх списка, ✕ ведёт в список", async ({ page }) => {
|
||||
await login(page, "/app/issues/AURORA-9001");
|
||||
const d = page.getByRole("dialog");
|
||||
await expect(d.getByRole("heading", { name: "Схема свода" })).toBeVisible();
|
||||
await expect(page.getByTestId("page-issues")).toBeAttached();
|
||||
await page.reload();
|
||||
await expect(page.getByRole("dialog").getByRole("heading", { name: "Схема свода" })).toBeVisible();
|
||||
await page.getByRole("dialog").getByRole("button", { name: "Закрыть" }).click();
|
||||
await expect(page).toHaveURL(/\/app\/issues$/);
|
||||
await expect(page.getByRole("dialog")).toHaveCount(0);
|
||||
});
|
||||
|
||||
test("карточка: описание свёрнуто, комментарии новые сверху и скрываются с запоминанием, команды свёрнуты", async ({ page }) => {
|
||||
await login(page, "/app/issues/AURORA-9001");
|
||||
const d = page.getByRole("dialog");
|
||||
await expect(d.getByRole("heading", { name: "Комментарии (3)" })).toBeVisible();
|
||||
await expect(d.getByTestId("comments").getByRole("listitem").first()).toContainText("[мост] служебный комментарий");
|
||||
await expect(d.getByRole("button", { name: "развернуть" })).toBeVisible();
|
||||
await expect(d.locator("pre.description")).toHaveClass(/clamped/); // свёрнуто построчным обрезанием (текст в DOM остаётся)
|
||||
const hClamped = (await d.locator("pre.description").boundingBox())!.height;
|
||||
expect(await d.locator("pre.description").evaluate((e) => e.scrollHeight > e.clientHeight)).toBe(true);
|
||||
await d.getByRole("button", { name: "развернуть" }).click();
|
||||
await expect(d.locator("pre.description")).not.toHaveClass(/clamped/);
|
||||
expect((await d.locator("pre.description").boundingBox())!.height).toBeGreaterThan(hClamped);
|
||||
await expect(d.getByText("Конец описания.")).toBeVisible();
|
||||
await expect(d.getByRole("cell", { name: "ensure_fields" })).toBeHidden();
|
||||
await d.getByText("Команды моста (2)").click();
|
||||
await expect(d.getByRole("cell", { name: "ensure_fields" })).toBeVisible();
|
||||
await d.getByRole("button", { name: "Скрыть" }).click();
|
||||
await expect(d.getByText("Первый комментарий e2e")).toHaveCount(0);
|
||||
await page.reload();
|
||||
await expect(page.getByRole("dialog").getByRole("button", { name: "Показать" })).toBeVisible();
|
||||
await expect(page.getByRole("dialog").getByText("Первый комментарий e2e")).toHaveCount(0);
|
||||
});
|
||||
|
||||
test("несохранённая заметка: Esc спрашивает, «Остаться» оставляет окно, «Уйти» закрывает", async ({ page }) => {
|
||||
await login(page, "/app/issues");
|
||||
await page.getByRole("link", { name: "AURORA-9003", exact: true }).click();
|
||||
const d = page.getByRole("dialog");
|
||||
await d.getByRole("button", { name: "Править" }).click();
|
||||
await d.getByLabel("Сделано").fill("черновик");
|
||||
await page.keyboard.press("Escape");
|
||||
await expect(d.getByText("Есть несохранённые правки заметки. Уйти без сохранения?")).toBeVisible();
|
||||
await d.getByRole("button", { name: "Остаться" }).click();
|
||||
await expect(d.getByLabel("Сделано")).toHaveValue("черновик");
|
||||
await page.keyboard.press("Escape");
|
||||
await d.getByRole("button", { name: "Уйти" }).click();
|
||||
await expect(page.getByRole("dialog")).toHaveCount(0);
|
||||
await expect(page).toHaveURL(/\/app\/issues$/);
|
||||
});
|
||||
|
||||
test("узкий экран: окно на всю ширину, заметки выше связей, без горизонтальной прокрутки", async ({ page }) => {
|
||||
await page.setViewportSize({ width: 600, height: 800 });
|
||||
await login(page, "/app/issues/AURORA-9001");
|
||||
const d = page.getByRole("dialog");
|
||||
await expect(d.getByRole("heading", { name: "Схема свода" })).toBeVisible();
|
||||
expect((await d.boundingBox())!.width).toBeGreaterThan(590);
|
||||
const notesY = (await d.getByRole("heading", { name: "Служебные заметки" }).boundingBox())!.y;
|
||||
const linksY = (await d.getByRole("heading", { name: "Связи", exact: true }).boundingBox())!.y;
|
||||
expect(notesY).toBeLessThan(linksY);
|
||||
expect(await d.evaluate((el) => el.scrollWidth <= el.clientWidth + 1)).toBe(true);
|
||||
});
|
||||
|
||||
16
web/scripts/seed-extras.json
Normal file
16
web/scripts/seed-extras.json
Normal file
@ -0,0 +1,16 @@
|
||||
{
|
||||
"issues": {
|
||||
"AURORA-9001": {
|
||||
"description": "Цель: схема свода для КФО.\nКонтекст: ТЗ 6.4.\nШаг 1 — таблицы.\nШаг 2 — связи.\nШаг 3 — индексы.\nШаг 4 — миграции.\nШаг 5 — проверки.\nШаг 6 — документация.\nШаг 7 — ревью.\nШаг 8 — выкатка.\nШаг 9 — приёмка.\nКонец описания.",
|
||||
"comments": [
|
||||
{"id": "501", "author": "Петров Пётр Петрович", "created": "2026-10-13T10:00:00.000+0300", "updated": "2026-10-13T10:00:00.000+0300", "body": "Первый комментарий e2e\nвторая строка"},
|
||||
{"id": "502", "author": "Иванов Иван Иванович", "created": "2026-10-14T11:30:00.000+0300", "updated": "2026-10-14T12:00:00.000+0300", "body": "Схему согласовали с аналитиком."},
|
||||
{"id": "503", "author": null, "created": "2026-10-15T09:15:00.000+0300", "updated": "2026-10-15T09:15:00.000+0300", "body": "[мост] служебный комментарий"}
|
||||
]
|
||||
}
|
||||
},
|
||||
"commands": [
|
||||
{"type": "ensure_fields", "params": {"key": "AURORA-9001", "fields": {"duedate": "2026-10-20"}}, "status": "done"},
|
||||
{"type": "ensure_status", "params": {"key": "AURORA-9001", "status": "В работе"}, "status": "failed", "error": "нет перехода в «В работе»"}
|
||||
]
|
||||
}
|
||||
@ -1,5 +1,6 @@
|
||||
"""Заполнить зеркало тестового хаба фикстурой доски и записать токены рядом с базой (для e2e и npm run dev).
|
||||
Пишет в базу напрямую (mirror.apply_state, set_note), до запуска serve: гонки с первым запросом нет."""
|
||||
Пишет в базу напрямую (mirror.apply_state, set_note), до запуска serve: гонки с первым запросом нет.
|
||||
Дополнения только для тестового хаба (описание, комментарии, команды) — из seed-extras.json рядом со скриптом."""
|
||||
import json
|
||||
import sys
|
||||
from pathlib import Path
|
||||
@ -7,15 +8,28 @@ from pathlib import Path
|
||||
from jira_hub import db, mirror, paths, settings
|
||||
|
||||
snap = json.loads(Path(sys.argv[1]).read_text(encoding="utf-8"))
|
||||
extras_path = Path(__file__).with_name("seed-extras.json")
|
||||
extras = json.loads(extras_path.read_text(encoding="utf-8")) if extras_path.exists() else {}
|
||||
extra_issues = extras.get("issues", {})
|
||||
conn = db.connect(paths.db_path())
|
||||
settings.ensure_defaults(conn)
|
||||
issues = [{k: v for k, v in i.items() if k not in ("fact_status", "work_kind", "neighbor")} | {"id": i["key"]} for i in snap["issues"]]
|
||||
issues = [{k: v for k, v in i.items() if k not in ("fact_status", "work_kind", "neighbor")} | {"id": i["key"]}
|
||||
| extra_issues.get(i["key"], {}) for i in snap["issues"]]
|
||||
links = [{"source_key": l["source"], "target_key": l["target"], "link_type": l["link_type"]} for l in snap["links"]]
|
||||
mirror.apply_state(conn, issues, links, neighbor_keys=[i["key"] for i in snap["issues"] if i["neighbor"]])
|
||||
for i in snap["issues"]:
|
||||
note = {k: i[k] for k in ("fact_status", "work_kind") if i.get(k)}
|
||||
if note:
|
||||
mirror.set_note(conn, i["key"], note, "seed")
|
||||
if extras.get("commands"): # завершённые команды по задачам — для раздела «Команды моста» карточки
|
||||
with db.transaction(conn):
|
||||
batch = conn.execute("INSERT INTO batch(title, author, created_at) VALUES ('Тестовые команды', 'seed', ?)",
|
||||
(db.now(),)).lastrowid
|
||||
for seq, c in enumerate(extras["commands"], 1):
|
||||
conn.execute("INSERT INTO command(batch_id, seq, type, params, status, finished_at, error) "
|
||||
"VALUES (?, ?, ?, ?, ?, ?, ?)",
|
||||
(batch, seq, c["type"], json.dumps(c["params"], ensure_ascii=False), c["status"], db.now(),
|
||||
c.get("error")))
|
||||
(paths.home() / "tokens.json").write_text(json.dumps(settings.tokens(conn)), encoding="utf-8")
|
||||
conn.close()
|
||||
print("зеркало:", len(issues), "задач,", len(links), "связей; токены —", paths.home() / "tokens.json")
|
||||
|
||||
@ -5,7 +5,7 @@ import { beforeEach, describe, expect, it } from "vitest";
|
||||
import { App } from "./App.tsx";
|
||||
import { clearToken, getAuth, setToken } from "./auth/token.ts";
|
||||
import { routes } from "./routes.tsx";
|
||||
import { Layout } from "./shell/Layout.tsx";
|
||||
import { PAGE_ROUTES } from "./shell/pages.tsx";
|
||||
import { json, mockFetch } from "./test/fetchMock.ts";
|
||||
|
||||
const at = (path: string) => createMemoryRouter(routes, { initialEntries: [path], basename: "/app" });
|
||||
@ -45,12 +45,15 @@ describe("оболочка", () => {
|
||||
|
||||
it("исключение в странице — сообщение и «Перезагрузить», панель на месте", async () => {
|
||||
const Boom = () => { throw new Error("сломалось"); };
|
||||
const router = createMemoryRouter([{ path: "/", element: <Layout />, children: [{ path: "x", element: <Boom /> }] }],
|
||||
{ initialEntries: ["/x"] });
|
||||
render(<App router={router} />);
|
||||
expect(await screen.findByText(/сломалось/)).toBeInTheDocument();
|
||||
expect(screen.getByRole("button", { name: "Перезагрузить" })).toBeInTheDocument();
|
||||
expect(screen.getByRole("link", { name: "Доска" })).toBeInTheDocument();
|
||||
const boom = { path: "/x", element: <Boom /> };
|
||||
PAGE_ROUTES.unshift(boom);
|
||||
try {
|
||||
const router = createMemoryRouter(routes, { initialEntries: ["/app/x"], basename: "/app" });
|
||||
render(<App router={router} />);
|
||||
expect(await screen.findByText(/сломалось/)).toBeInTheDocument();
|
||||
expect(screen.getByRole("button", { name: "Перезагрузить" })).toBeInTheDocument();
|
||||
expect(screen.getByRole("link", { name: "Доска" })).toBeInTheDocument();
|
||||
} finally { PAGE_ROUTES.splice(PAGE_ROUTES.indexOf(boom), 1); }
|
||||
});
|
||||
|
||||
it("без токена — только экран входа", () => {
|
||||
|
||||
@ -35,11 +35,17 @@ export type Note = Record<NoteField, string | null> & { key: string; updated_at:
|
||||
export interface IssueCommand {
|
||||
id: number; batch_id: number; type: string; status: string; finished_at: string | null; error: string | null;
|
||||
}
|
||||
export interface IssueLinkOut { target_key: string; link_type: string; summary: string | null; status: string | null }
|
||||
export interface IssueLinkIn { source_key: string; link_type: string; summary: string | null; status: string | null }
|
||||
// комментарий задачи из Jira (зеркало хаба, агент v10+): текст и время — как в Jira
|
||||
export interface IssueComment { comment_id: string; author: string | null; created: string | null; updated: string | null; body: string }
|
||||
export interface IssueDetail extends IssueRow {
|
||||
links_out: { target_key: string; link_type: string }[];
|
||||
links_in: { source_key: string; link_type: string }[];
|
||||
epic_summary: string | null;
|
||||
links_out: IssueLinkOut[];
|
||||
links_in: IssueLinkIn[];
|
||||
note: Note | null;
|
||||
commands: IssueCommand[];
|
||||
comments: IssueComment[];
|
||||
}
|
||||
|
||||
export type BatchStatus = "queued" | "running" | "done" | "cancelled";
|
||||
|
||||
@ -1,5 +1,6 @@
|
||||
import { memo, type PointerEvent } from "react";
|
||||
import { Link } from "react-router";
|
||||
import { BOARD_CARD_STATE } from "../../issues/modal.ts";
|
||||
import { CW, JIRA_URL, KIND, PRIO } from "../constants.ts";
|
||||
import { lineClamp, type Rect, type RelBox } from "../layout/layout.ts";
|
||||
import { fmtDate, shortName, unassigned } from "../model/util.ts";
|
||||
@ -31,7 +32,7 @@ export const Card = memo(function Card({ node: n, box, rel, multiSprint, sprintS
|
||||
onPointerDown={onPointerDown ? (e) => onPointerDown(e, n.key) : undefined}>
|
||||
<div className="head">
|
||||
<a className="key" href={`${JIRA_URL}/browse/${n.key}`} target="_blank" rel="noopener">{n.key}</a>
|
||||
{!n.stub && <Link className="open-card" to={`/issues/${n.key}`} title="Открыть карточку задачи"
|
||||
{!n.stub && <Link className="open-card" to={`/issues/${n.key}`} state={BOARD_CARD_STATE} title="Открыть карточку задачи"
|
||||
aria-label={`Открыть карточку ${n.key}`}>ⓘ</Link>}
|
||||
{showTag && (n.sprint
|
||||
? <span className={"sprint-tag" + (sprintSel.has(n.sprint) ? " sel" : "")} title={n.sprint}>{n.sprint.replace(/^AWPCA_/, "")}</span>
|
||||
|
||||
341
web/src/issues/IssueCard.test.tsx
Normal file
341
web/src/issues/IssueCard.test.tsx
Normal file
@ -0,0 +1,341 @@
|
||||
import { act, cleanup, render, screen, waitFor, within } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { QueryClientProvider } from "@tanstack/react-query";
|
||||
import { createMemoryRouter, RouterProvider, useNavigate, useParams } from "react-router";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { qk } from "../api/queries.ts";
|
||||
import { makeQueryClient } from "../api/queryClient.ts";
|
||||
import { setToken } from "../auth/token.ts";
|
||||
import settings from "../board/__fixtures__/settings.json";
|
||||
import { json, mockFetch } from "../test/fetchMock.ts";
|
||||
import { issue, note } from "../test/issueFixtures.ts";
|
||||
import { IssueCard } from "./IssueCard.tsx";
|
||||
|
||||
const deferred = <T,>() => { let resolve!: (v: T) => void; const promise = new Promise<T>((r) => { resolve = r; }); return { promise, resolve }; };
|
||||
|
||||
function CardAt() {
|
||||
const { key = "" } = useParams();
|
||||
const navigate = useNavigate();
|
||||
return <IssueCard issueKey={key} onClose={() => void navigate("/board")} />;
|
||||
}
|
||||
|
||||
function open(path: string, client = makeQueryClient()) {
|
||||
const router = createMemoryRouter([
|
||||
{ path: "/issues/:key", element: <CardAt /> },
|
||||
{ path: "/board", element: <p>доска</p> },
|
||||
], { initialEntries: [path] });
|
||||
render(<QueryClientProvider client={client}><RouterProvider router={router} /></QueryClientProvider>);
|
||||
return router;
|
||||
}
|
||||
|
||||
describe("карточка задачи", () => {
|
||||
beforeEach(() => setToken("tok"));
|
||||
|
||||
it("шапка: ключ, статусы, тема, исполнитель · срок · спринт · эпик по имени; ссылка в 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(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");
|
||||
});
|
||||
|
||||
it("связи по группам с темой и статусом; задача вне зеркала; прочий тип с направлением", async () => {
|
||||
mockFetch((_m, url) => url.pathname === "/settings" ? json(200, settings) : json(200, issue("AURORA-9001", {
|
||||
links_out: [{ target_key: "AURORA-9002", link_type: "ASUPR_Блокер_Начала", summary: "API свода", status: "Новая" },
|
||||
{ target_key: "lower-1", link_type: "ASUPR_Связанные", summary: null, status: null }],
|
||||
links_in: [{ source_key: "AURORA-9003", link_type: "ASUPR_Блокер_Завершения", summary: "Макет экрана", status: "Готово" },
|
||||
{ source_key: "AURORA-9008", link_type: "Duplicate", summary: "Актуализация ТЗ", status: "В работе" }],
|
||||
})));
|
||||
open("/issues/AURORA-9001");
|
||||
const depends = await screen.findByRole("group", { name: "Зависит от" });
|
||||
expect(within(depends).getByRole("listitem")).toHaveTextContent("завершение AURORA-9003 Макет экрана Готово");
|
||||
const blocks = screen.getByRole("group", { name: "Блокирует" });
|
||||
expect(within(blocks).getByRole("link", { name: "AURORA-9002" })).toHaveAttribute("href", "/issues/AURORA-9002");
|
||||
expect(within(blocks).getByRole("listitem")).toHaveTextContent("начало AURORA-9002 API свода Новая");
|
||||
expect(within(screen.getByRole("group", { name: "Связана с" })).getByRole("listitem")).toHaveTextContent("lower-1 нет в зеркале");
|
||||
expect(within(screen.getByRole("group", { name: "Прочие" })).getByRole("listitem")).toHaveTextContent("← Duplicate AURORA-9008");
|
||||
expect(screen.getByRole("link", { name: "AURORA-9002" }).closest(".issue")).not.toBeNull(); // O-10: цвет темы
|
||||
});
|
||||
|
||||
it("без связей — «связей нет»", async () => {
|
||||
mockFetch((_m, url) => url.pathname === "/settings" ? json(200, settings) : json(200, issue("AURORA-9001", { links_out: [], links_in: [] })));
|
||||
open("/issues/AURORA-9001");
|
||||
expect(await screen.findByText("связей нет")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("описание свыше 8 строк свёрнуто, «развернуть»/«свернуть»; короткое — без кнопки", async () => {
|
||||
const long = Array.from({ length: 10 }, (_, n) => `строка ${n + 1}`).join("\n");
|
||||
mockFetch((_m, url) => url.pathname === "/settings" ? json(200, settings) : json(200, issue("AURORA-9001", { description: long })));
|
||||
open("/issues/AURORA-9001");
|
||||
const pre = await screen.findByText(long, { normalizer: (s) => s });
|
||||
expect(pre).toHaveClass("description", "clamped");
|
||||
await userEvent.click(screen.getByRole("button", { name: "развернуть" }));
|
||||
expect(pre).not.toHaveClass("clamped");
|
||||
await userEvent.click(screen.getByRole("button", { name: "свернуть" }));
|
||||
expect(pre).toHaveClass("clamped");
|
||||
});
|
||||
|
||||
it("короткое описание — без кнопки; нет описания — нет раздела", async () => {
|
||||
let d: string | null = "строка 1\nстрока 2";
|
||||
mockFetch((_m, url) => url.pathname === "/settings" ? json(200, settings) : json(200, issue("AURORA-9001", { description: d })));
|
||||
open("/issues/AURORA-9001");
|
||||
expect(await screen.findByText("строка 1\nстрока 2", { normalizer: (s) => s })).toHaveClass("description");
|
||||
expect(screen.queryByRole("button", { name: "развернуть" })).toBeNull();
|
||||
d = null;
|
||||
cleanup();
|
||||
open("/issues/AURORA-9001");
|
||||
await screen.findByRole("heading", { level: 2 });
|
||||
expect(screen.queryByRole("heading", { name: "Описание" })).toBeNull();
|
||||
});
|
||||
|
||||
it("комментарии новые сверху; «Скрыть» запоминается для следующей карточки", async () => {
|
||||
const comments = [
|
||||
{ comment_id: "2", author: "Петров Пётр", created: "2026-10-15T12:00:00.000+0300", updated: null, body: "новый\nвторая строка" },
|
||||
{ comment_id: "1", author: "Иванов Иван", created: "2026-10-14T12:00:00.000+0300", updated: null, body: "старый" }];
|
||||
mockFetch((_m, url) => url.pathname === "/settings" ? json(200, settings) : json(200, issue("AURORA-9001", { comments })));
|
||||
open("/issues/AURORA-9001");
|
||||
expect(await screen.findByRole("heading", { name: "Комментарии (2)" })).toBeInTheDocument();
|
||||
const items = within(screen.getByTestId("comments")).getAllByRole("listitem");
|
||||
expect(items[0]).toHaveTextContent("Петров Пётр");
|
||||
expect(within(items[0]).getByText("новый\nвторая строка", { normalizer: (s) => s })).toBeInTheDocument();
|
||||
await userEvent.click(screen.getByRole("button", { name: "Скрыть" }));
|
||||
expect(screen.queryByText("старый")).toBeNull();
|
||||
expect(localStorage.getItem("jira-hub-comments-hidden")).toBe("1");
|
||||
cleanup();
|
||||
open("/issues/AURORA-9001");
|
||||
expect(await screen.findByRole("button", { name: "Показать" })).toBeInTheDocument();
|
||||
expect(screen.queryByText("старый")).toBeNull();
|
||||
});
|
||||
|
||||
it("комментарий без автора и текста не ломает карточку; без комментариев — «комментариев нет»", async () => {
|
||||
let comments = [{ comment_id: "1", author: null, created: null, updated: null, body: "" }];
|
||||
mockFetch((_m, url) => url.pathname === "/settings" ? json(200, settings) : json(200, issue("AURORA-9001", { comments })));
|
||||
open("/issues/AURORA-9001");
|
||||
expect(await screen.findByRole("heading", { name: "Комментарии (1)" })).toBeInTheDocument();
|
||||
expect(within(screen.getByTestId("comments")).getByRole("listitem")).toHaveTextContent("—");
|
||||
comments = [];
|
||||
cleanup();
|
||||
open("/issues/AURORA-9001");
|
||||
expect(await screen.findByText("комментариев нет")).toBeInTheDocument();
|
||||
expect(screen.queryByRole("button", { name: "Скрыть" })).toBeNull();
|
||||
});
|
||||
|
||||
it("прочие поля и команды моста свёрнуты", async () => {
|
||||
mockFetch((_m, url) => url.pathname === "/settings" ? json(200, settings) : json(200, issue("AURORA-9001")));
|
||||
open("/issues/AURORA-9001");
|
||||
const other = await screen.findByRole("region", { name: "Прочие поля" });
|
||||
expect(other).toHaveTextContent("Приоритет");
|
||||
expect(other).toHaveTextContent("Высокий");
|
||||
const details = screen.getByText("Команды моста (1)").closest("details")!;
|
||||
expect(details).not.toHaveAttribute("open");
|
||||
expect(within(details).getByRole("cell", { name: "ensure_fields" })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("заголовок карточки — h2 с id для aria-labelledby окна", async () => {
|
||||
mockFetch((_m, url) => url.pathname === "/settings" ? json(200, settings) : json(200, issue("AURORA-9001")));
|
||||
open("/issues/AURORA-9001");
|
||||
expect(await screen.findByRole("heading", { level: 2, name: /AURORA-9001/ })).toHaveAttribute("id", "issue-modal-title");
|
||||
});
|
||||
|
||||
it("задачи нет в зеркале — сообщение и «Закрыть»", async () => {
|
||||
mockFetch((_m, url) => url.pathname === "/settings" ? json(200, settings) : json(404, { error: "not_found", detail: "issue" }));
|
||||
const router = open("/issues/AURORA-1");
|
||||
expect(await screen.findByText("Задачи AURORA-1 нет в зеркале хаба.")).toBeInTheDocument();
|
||||
await userEvent.click(screen.getByRole("button", { name: "Закрыть" }));
|
||||
expect(router.state.location.pathname).toBe("/board");
|
||||
});
|
||||
|
||||
it("сохраняются только изменённые поля, очищенное — null", async () => {
|
||||
let put: unknown = null;
|
||||
mockFetch((m, url, body) => {
|
||||
if (url.pathname === "/settings") return json(200, settings);
|
||||
if (m === "PUT") { put = body; return json(200, note({ rest_text: "тесты", done_text: null, updated_by: "web" })); }
|
||||
return json(200, issue("AURORA-9001"));
|
||||
});
|
||||
open("/issues/AURORA-9001");
|
||||
await userEvent.click(await screen.findByRole("button", { name: "Править" }));
|
||||
const rest = await screen.findByLabelText("Осталось");
|
||||
await userEvent.clear(rest); await userEvent.type(rest, "тесты");
|
||||
await userEvent.clear(screen.getByLabelText("Сделано"));
|
||||
await userEvent.click(screen.getByRole("button", { name: "Сохранить" }));
|
||||
await waitFor(() => expect(put).toEqual({ rest_text: "тесты", done_text: null, updated_by: "web" }));
|
||||
expect(await screen.findByText(/изменено .*web/)).toBeInTheDocument(); // снова режим чтения
|
||||
expect(screen.getByRole("button", { name: "Править" })).toBeInTheDocument();
|
||||
expect(screen.queryByRole("button", { name: "Сохранить" })).toBeNull();
|
||||
});
|
||||
|
||||
it("422 — текст ошибки у формы, введённое на месте", async () => {
|
||||
mockFetch((m, url) => {
|
||||
if (url.pathname === "/settings") return json(200, settings);
|
||||
if (m === "PUT") return json(422, { error: "bad_note", detail: "неизвестные поля заметки: x" });
|
||||
return json(200, issue("AURORA-9001"));
|
||||
});
|
||||
open("/issues/AURORA-9001");
|
||||
await userEvent.click(await screen.findByRole("button", { name: "Править" }));
|
||||
const owner = await screen.findByLabelText("Заметка владельца");
|
||||
await userEvent.type(owner, "проверить");
|
||||
await userEvent.click(screen.getByRole("button", { name: "Сохранить" }));
|
||||
expect(await screen.findByRole("alert")).toHaveTextContent("неизвестные поля заметки: x");
|
||||
expect(owner).toHaveValue("проверить");
|
||||
});
|
||||
|
||||
it("уход с несохранёнными правками требует подтверждения", async () => {
|
||||
mockFetch((_m, url) => url.pathname === "/settings" ? json(200, settings) : json(200, issue("AURORA-9001")));
|
||||
const router = open("/issues/AURORA-9001");
|
||||
await userEvent.click(await screen.findByRole("button", { name: "Править" }));
|
||||
await userEvent.type(await screen.findByLabelText("Заметка владельца"), "черновик");
|
||||
await act(async () => { await router.navigate("/board"); });
|
||||
expect(await screen.findByText("Есть несохранённые правки заметки. Уйти без сохранения?")).toBeInTheDocument();
|
||||
await userEvent.click(screen.getByRole("button", { name: "Остаться" }));
|
||||
expect(router.state.location.pathname).toBe("/issues/AURORA-9001");
|
||||
expect(screen.getByLabelText("Заметка владельца")).toHaveValue("черновик");
|
||||
});
|
||||
|
||||
it("поздний ответ прежней карточки не подменяет новую", async () => {
|
||||
const client = makeQueryClient();
|
||||
const slow = deferred<Response>();
|
||||
mockFetch((_m, url) => {
|
||||
if (url.pathname === "/settings") return json(200, settings);
|
||||
if (url.pathname === "/issues/AURORA-9001") return slow.promise;
|
||||
return json(200, issue("AURORA-9002"));
|
||||
});
|
||||
const router = open("/issues/AURORA-9001", client);
|
||||
await act(async () => { await router.navigate("/issues/AURORA-9002"); });
|
||||
expect(await screen.findByRole("heading", { name: /AURORA-9002/ })).toBeInTheDocument();
|
||||
slow.resolve(json(200, issue("AURORA-9001")));
|
||||
await waitFor(() => expect(client.getQueryData(qk.issue("AURORA-9001"))).toBeDefined());
|
||||
expect(screen.getByRole("heading", { name: /AURORA-9002/ })).toBeInTheDocument();
|
||||
expect(screen.queryByRole("heading", { name: /AURORA-9001/ })).toBeNull();
|
||||
});
|
||||
|
||||
it("ответ «Сохранить» после ухода в другую карточку не пишет её заметку в форму новой", async () => {
|
||||
const slowPut = deferred<Response>();
|
||||
mockFetch((m, url) => {
|
||||
if (url.pathname === "/settings") return json(200, settings);
|
||||
if (m === "PUT") return slowPut.promise;
|
||||
if (url.pathname === "/issues/AURORA-9002") return json(200, issue("AURORA-9002"));
|
||||
return json(200, issue("AURORA-9001"));
|
||||
});
|
||||
// 9002 уже в кэше: форма не размонтируется при переходе, её состояние переживает навигацию
|
||||
const client = makeQueryClient();
|
||||
client.setQueryData(qk.issue("AURORA-9002"), issue("AURORA-9002"));
|
||||
const router = open("/issues/AURORA-9001", client);
|
||||
await userEvent.click(await screen.findByRole("button", { name: "Править" }));
|
||||
await userEvent.type(await screen.findByLabelText("Заметка владельца"), "черновик");
|
||||
await userEvent.click(screen.getByRole("button", { name: "Сохранить" }));
|
||||
await act(async () => { await router.navigate("/issues/AURORA-9002"); });
|
||||
await userEvent.click(await screen.findByRole("button", { name: "Уйти" }));
|
||||
expect(await screen.findByRole("heading", { name: /AURORA-9002/ })).toBeInTheDocument();
|
||||
slowPut.resolve(json(200, note({ owner_note: "черновик", updated_by: "web" })));
|
||||
await waitFor(() => expect(client.isMutating()).toBe(0));
|
||||
expect(screen.getByRole("heading", { name: /AURORA-9002/ })).toBeInTheDocument();
|
||||
expect(screen.getByRole("button", { name: "Править" })).toBeInTheDocument(); // новая карточка — в режиме чтения
|
||||
expect(screen.queryByText("черновик")).toBeNull();
|
||||
});
|
||||
|
||||
it("T14.1: поля заметки заблокированы на время сохранения, ответ не затирает ввод", async () => {
|
||||
const slowPut = deferred<Response>();
|
||||
mockFetch((m, url) => {
|
||||
if (url.pathname === "/settings") return json(200, settings);
|
||||
if (m === "PUT") return slowPut.promise;
|
||||
return json(200, issue("AURORA-9001"));
|
||||
});
|
||||
open("/issues/AURORA-9001");
|
||||
await userEvent.click(await screen.findByRole("button", { name: "Править" }));
|
||||
await userEvent.type(await screen.findByLabelText("Заметка владельца"), "раз");
|
||||
await userEvent.click(screen.getByRole("button", { name: "Сохранить" }));
|
||||
const box = screen.getByLabelText("Заметка владельца");
|
||||
await waitFor(() => expect(box).toBeDisabled());
|
||||
slowPut.resolve(json(200, note({ owner_note: "раз", updated_by: "web" })));
|
||||
await screen.findByRole("button", { name: "Править" }); // режим чтения (findByText("раз") ловил бы ещё живой textarea)
|
||||
expect(screen.getByText("раз")).toBeInTheDocument(); // с сохранённым значением
|
||||
expect(screen.queryByLabelText("Заметка владельца")).toBeNull(); // форма закрыта
|
||||
});
|
||||
|
||||
it("M-2: ошибка повторной загрузки при уже имеющихся данных — данные остаются, строка с «Повторить»", async () => {
|
||||
const client = makeQueryClient();
|
||||
client.setQueryData(qk.issue("AURORA-9001"), issue("AURORA-9001"));
|
||||
let down = true;
|
||||
mockFetch((_m, url) => {
|
||||
if (url.pathname === "/settings") return json(200, settings);
|
||||
if (down) throw new TypeError("Failed to fetch");
|
||||
return json(200, issue("AURORA-9001"));
|
||||
});
|
||||
open("/issues/AURORA-9001", client);
|
||||
expect(await screen.findByRole("heading", { name: /AURORA-9001/ })).toBeInTheDocument();
|
||||
await act(async () => { await client.invalidateQueries({ queryKey: qk.issue("AURORA-9001") }); }); // повторная загрузка при данных в кэше
|
||||
expect(await screen.findByRole("alert")).toHaveTextContent("хаб не отвечает");
|
||||
expect(screen.getByRole("heading", { name: /AURORA-9001/ })).toBeInTheDocument();
|
||||
down = false;
|
||||
await userEvent.click(screen.getByRole("button", { name: "Повторить" }));
|
||||
await waitFor(() => expect(screen.queryByRole("alert")).toBeNull());
|
||||
});
|
||||
|
||||
it("O-11: задача без команд — текст вместо пустой таблицы", async () => {
|
||||
mockFetch((_m, url) => url.pathname === "/settings" ? json(200, settings) : json(200, issue("AURORA-9001", { commands: [] })));
|
||||
open("/issues/AURORA-9001");
|
||||
expect(await screen.findByText("команд по задаче не было")).toBeInTheDocument();
|
||||
expect(screen.queryByRole("columnheader", { name: "Команда" })).toBeNull();
|
||||
});
|
||||
|
||||
it("T14.3: id подсказок «статуса по факту» уникален и связан с полем", 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");
|
||||
});
|
||||
|
||||
it("заметки в режиме чтения: пустые — «—», время и автор; «Отмена» без правок — сразу", async () => {
|
||||
mockFetch((_m, url) => url.pathname === "/settings" ? json(200, settings) : json(200, issue("AURORA-9001")));
|
||||
open("/issues/AURORA-9001");
|
||||
const view = await screen.findByRole("region", { name: "Служебные заметки" });
|
||||
expect(view).toHaveTextContent("Сделано");
|
||||
expect(view).toHaveTextContent("схема");
|
||||
expect(within(view).getByText("Заметка владельца").nextElementSibling).toHaveTextContent("—");
|
||||
expect(view).toHaveTextContent(/изменено .*chat/);
|
||||
await userEvent.click(within(view).getByRole("button", { name: "Править" }));
|
||||
await userEvent.click(screen.getByRole("button", { name: "Отмена" }));
|
||||
expect(screen.getByRole("button", { name: "Править" })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
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: "Править" }));
|
||||
await userEvent.type(screen.getByLabelText("Заметка владельца"), "черновик");
|
||||
await userEvent.click(screen.getByRole("button", { name: "Отмена" }));
|
||||
expect(screen.getByText("Отменить правки заметки?")).toBeInTheDocument();
|
||||
await userEvent.click(screen.getByRole("button", { name: "Продолжить правку" }));
|
||||
expect(screen.getByLabelText("Заметка владельца")).toHaveValue("черновик");
|
||||
await userEvent.click(screen.getByRole("button", { name: "Отмена" }));
|
||||
await userEvent.click(screen.getByRole("button", { name: "Отменить правки" }));
|
||||
expect(screen.getByRole("button", { name: "Править" })).toBeInTheDocument();
|
||||
expect(screen.queryByText("черновик")).toBeNull();
|
||||
});
|
||||
|
||||
it("заметки ещё нет — режим чтения с «—» и «Править»", async () => {
|
||||
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).getByRole("button", { name: "Править" })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("T5.3: одинаковые связи не дают предупреждения о повторяющихся key", async () => {
|
||||
const err = vi.spyOn(console, "error").mockImplementation(() => {});
|
||||
const dup = { target_key: "AURORA-9002", link_type: "ASUPR_Блокер_Начала", summary: "API", status: "Новая" };
|
||||
mockFetch((_m, url) => url.pathname === "/settings" ? json(200, settings) : json(200, issue("AURORA-9001", { links_out: [dup, dup] })));
|
||||
open("/issues/AURORA-9001");
|
||||
await screen.findAllByRole("link", { name: "AURORA-9002" });
|
||||
expect(err.mock.calls.flat().join(" ")).not.toContain("same key");
|
||||
err.mockRestore();
|
||||
});
|
||||
});
|
||||
177
web/src/issues/IssueCard.tsx
Normal file
177
web/src/issues/IssueCard.tsx
Normal file
@ -0,0 +1,177 @@
|
||||
import { useLayoutEffect, useRef, useState } from "react";
|
||||
import { ApiError } from "../api/client.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 { useCommentsHidden } from "./commentsPref.ts";
|
||||
import { IssueLink } from "./IssueLink.tsx";
|
||||
import { Notes } from "./Notes.tsx";
|
||||
import { dueOf } from "./options.ts";
|
||||
import "./issues.css";
|
||||
|
||||
const DESC_LINES = 8;
|
||||
|
||||
export function IssueCard({ issueKey: key, onClose }: { issueKey: string; onClose: () => void }) {
|
||||
const issue = useIssue(key);
|
||||
const settings = useSettings();
|
||||
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) {
|
||||
return <div className="issue" data-testid="issue-card"><p>Задачи {key} нет в зеркале хаба.</p><button className="btn" type="button" onClick={onClose}>Закрыть</button></div>;
|
||||
}
|
||||
if (issue.isError && !issue.data) {
|
||||
return (
|
||||
<div className="issue" data-testid="issue-card">
|
||||
<p className="warn" role="alert">{issue.error.message}{" "}
|
||||
<button className="btn sm" type="button" onClick={() => void issue.refetch()}>Повторить</button></p>
|
||||
<button className="btn" type="button" onClick={onClose}>Закрыть</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
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>,
|
||||
i.sprint && <span key="sprint">{i.sprint}</span>,
|
||||
i.epic_key && <span key="epic">эпик: {i.epic_summary ?? i.epic_key}</span>,
|
||||
].filter(Boolean);
|
||||
return (
|
||||
<div className="issue" data-testid="issue-card">
|
||||
<header className="issue-head" data-testid="issue-head">
|
||||
<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}
|
||||
</div>
|
||||
<h2 id="issue-modal-title">{i.summary || i.key}</h2>
|
||||
{facts.length > 0 && <p className="facts" data-testid="issue-facts">{facts.flatMap((f, n) => (n ? [" · ", f] : [f]))}</p>}
|
||||
{issue.isError && (
|
||||
<p className="warn" role="alert">{issue.error.message}{" "}
|
||||
<button className="btn sm" type="button" onClick={() => void issue.refetch()}>Повторить</button></p>
|
||||
)}
|
||||
</header>
|
||||
<div className="cols">
|
||||
<div className="main">
|
||||
<Links issue={i} linkTypes={(settings.data?.link_types ?? {}) as Record<string, string>} />
|
||||
{i.description && <Description text={i.description} />}
|
||||
<Comments items={i.comments ?? []} />
|
||||
</div>
|
||||
<div className="side">
|
||||
<Notes key={i.key} issue={i} />
|
||||
<OtherFields issue={i} />
|
||||
<Commands issue={i} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function Links({ issue, linkTypes }: { issue: IssueDetail; linkTypes: Record<string, string> }) {
|
||||
const groups = groupLinks(issue, linkTypes);
|
||||
return (
|
||||
<section className="links-sec">
|
||||
<h3>Связи</h3>
|
||||
{!groups.length && <p className="hint">связей нет</p>}
|
||||
{groups.map(([id, title, links]) => (
|
||||
<div key={id} className="group" role="group" aria-labelledby={`links-${id}`}>
|
||||
<h4 id={`links-${id}`}>{title}</h4>
|
||||
<ul className="links">
|
||||
{links.map((l, n) => (
|
||||
<li key={`${n}:${l.key}:${l.tag ?? ""}`}>
|
||||
{l.tag && <><span className="tag">{l.tag}</span>{" "}</>}
|
||||
<IssueLink issueKey={l.key} />{" "}
|
||||
{l.summary === null && l.status === null
|
||||
? <span className="sum absent">нет в зеркале</span>
|
||||
: <><span className="sum">{l.summary}</span>{l.status && <>{" "}<span className="badge status">{l.status}</span></>}</>}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
))}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
function Description({ text }: { text: string }) {
|
||||
const ref = useRef<HTMLPreElement>(null);
|
||||
const [open, setOpen] = useState(false);
|
||||
const [overflow, setOverflow] = useState(false);
|
||||
useLayoutEffect(() => {
|
||||
const el = ref.current;
|
||||
if (el && !open) setOverflow(el.scrollHeight > el.clientHeight + 1);
|
||||
}, [text, open]);
|
||||
const long = text.split("\n").length > DESC_LINES || overflow;
|
||||
return (
|
||||
<section>
|
||||
<h3>Описание</h3>
|
||||
<pre ref={ref} className={"description" + (open ? "" : " clamped")}>{text}</pre>
|
||||
{(long || open) && <button className="link" type="button" onClick={() => setOpen(!open)}>{open ? "свернуть" : "развернуть"}</button>}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
function Comments({ items }: { items: IssueComment[] }) {
|
||||
const [hidden, setHidden] = useCommentsHidden();
|
||||
return (
|
||||
<section className="comments" data-testid="comments">
|
||||
<div className="sec-head">
|
||||
<h3>Комментарии ({items.length})</h3>
|
||||
{items.length > 0 && <button className="link" type="button" onClick={() => setHidden(!hidden)}>{hidden ? "Показать" : "Скрыть"}</button>}
|
||||
</div>
|
||||
{!items.length && <p className="hint">комментариев нет</p>}
|
||||
{items.length > 0 && !hidden && (
|
||||
<ul>
|
||||
{items.map((c) => (
|
||||
<li key={c.comment_id}>
|
||||
<div className="who">{c.author ?? "—"}{c.created ? `, ${fmtStamp(c.created)}` : ""}</div>
|
||||
<pre className="body">{c.body}</pre>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
function OtherFields({ issue: i }: { issue: IssueDetail }) {
|
||||
const rows: [string, string | null][] = [
|
||||
["Тип", i.issue_type], ["Приоритет", i.priority], ["Автор", i.reporter], ["Метки", i.labels.join(", ") || null],
|
||||
["Плановое начало", i.planned_start], ["Обновлена в Jira", i.jira_updated ? fmtStamp(i.jira_updated) : null],
|
||||
["Сосед по связям", i.neighbor ? "да" : null],
|
||||
];
|
||||
return (
|
||||
<section className="other" aria-labelledby="other-title">
|
||||
<h3 id="other-title">Прочие поля</h3>
|
||||
<dl className="fields">{rows.filter(([, v]) => v).map(([k, v]) => <div key={k}><dt>{k}</dt><dd>{v}</dd></div>)}</dl>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
function Commands({ issue: i }: { issue: IssueDetail }) {
|
||||
if (!i.commands.length) {
|
||||
return <section className="commands"><h3>Команды моста</h3><p className="hint">команд по задаче не было</p></section>;
|
||||
}
|
||||
return (
|
||||
<section className="commands">
|
||||
<details>
|
||||
<summary>Команды моста ({i.commands.length})</summary>
|
||||
<table className="grid">
|
||||
<thead><tr><th>Команда</th><th>Пакет</th><th>Тип</th><th>Статус</th><th>Завершена</th><th>Ошибка</th></tr></thead>
|
||||
<tbody>{i.commands.map((c) => (
|
||||
<tr key={c.id}><td>#{c.id}</td><td>#{c.batch_id}</td><td>{c.type}</td><td>{c.status}</td>
|
||||
<td>{c.finished_at ? fmtStamp(c.finished_at) : ""}</td><td>{c.error}</td></tr>
|
||||
))}</tbody>
|
||||
</table>
|
||||
</details>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
9
web/src/issues/IssueLink.tsx
Normal file
9
web/src/issues/IssueLink.tsx
Normal file
@ -0,0 +1,9 @@
|
||||
import type { ReactNode } from "react";
|
||||
import { Link, useLocation } from "react-router";
|
||||
import { openCardState } from "./modal.ts";
|
||||
|
||||
// ссылка на карточку задачи: открывает окно поверх текущей страницы (или меняет содержимое открытого окна)
|
||||
export function IssueLink({ issueKey, className, children }: { issueKey: string; className?: string; children?: ReactNode }) {
|
||||
const loc = useLocation();
|
||||
return <Link className={className} to={`/issues/${encodeURIComponent(issueKey)}`} state={openCardState(loc)}>{children ?? issueKey}</Link>;
|
||||
}
|
||||
169
web/src/issues/IssueModal.test.tsx
Normal file
169
web/src/issues/IssueModal.test.tsx
Normal file
@ -0,0 +1,169 @@
|
||||
import { act, fireEvent, render, screen, waitFor, within } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { createMemoryRouter } from "react-router";
|
||||
import { beforeEach, describe, expect, it } from "vitest";
|
||||
import { App } from "../App.tsx";
|
||||
import { setToken } from "../auth/token.ts";
|
||||
import board from "../board/__fixtures__/hub-basic.board.json";
|
||||
import settings from "../board/__fixtures__/settings.json";
|
||||
import { routes } from "../routes.tsx";
|
||||
import { json, mockFetch } from "../test/fetchMock.ts";
|
||||
import { issue } from "../test/issueFixtures.ts";
|
||||
|
||||
const rows = board.issues.map((i) => ({ ...i, id: i.key, description: null, reporter: null, created: null, jira_updated: null, fetched_at: "" }));
|
||||
|
||||
function hub() {
|
||||
return mockFetch((_m, url) => {
|
||||
const p = url.pathname;
|
||||
if (p === "/settings") return json(200, settings);
|
||||
if (p === "/board") return json(200, board);
|
||||
if (p === "/issues") return json(200, { issues: rows });
|
||||
if (p.startsWith("/issues/")) return json(200, issue(decodeURIComponent(p.slice("/issues/".length))));
|
||||
if (p === "/batches") return json(200, { batches: [] });
|
||||
return json(200, {});
|
||||
});
|
||||
}
|
||||
const at = (entries: string[], index = entries.length - 1) =>
|
||||
createMemoryRouter(routes, { initialEntries: entries, initialIndex: index, basename: "/app" });
|
||||
const dialog = () => screen.getByRole("dialog");
|
||||
|
||||
describe("карточка окном", () => {
|
||||
beforeEach(() => { setToken("tok"); hub(); });
|
||||
|
||||
it("из списка: окно поверх списка, фон не пересоздаётся, ✕ возвращает со старым фильтром, «назад» не открывает карточку", async () => {
|
||||
const router = at(["/app/board", "/app/issues?sprint=AWPCA_Спринт 2"]);
|
||||
render(<App router={router} />);
|
||||
const table = await screen.findByRole("table");
|
||||
expect(screen.getByLabelText("Спринт")).toHaveValue("AWPCA_Спринт 2");
|
||||
await userEvent.click(await within(table).findByRole("link", { name: "AURORA-9002" }));
|
||||
expect(router.state.location.pathname).toBe("/app/issues/AURORA-9002");
|
||||
expect(await within(dialog()).findByRole("heading", { name: /AURORA-9002/ })).toBeInTheDocument();
|
||||
expect(within(screen.getByRole("main")).getByRole("table")).toBe(table); // таблица команд карточки — в окне, не в main // фон не пересоздаётся
|
||||
expect(screen.getByLabelText("Спринт")).toHaveValue("AWPCA_Спринт 2"); // фильтр фона — из его адреса
|
||||
expect(screen.getByRole("link", { name: "Задачи" })).toHaveClass("active");
|
||||
await userEvent.click(within(dialog()).getByRole("button", { name: "Закрыть" }));
|
||||
await waitFor(() => expect(screen.queryByRole("dialog")).toBeNull());
|
||||
expect(router.state.location.pathname).toBe("/app/issues");
|
||||
expect(new URLSearchParams(router.state.location.search).get("sprint")).toBe("AWPCA_Спринт 2");
|
||||
expect(within(screen.getByRole("main")).getByRole("table")).toBe(table); // таблица команд карточки — в окне, не в main
|
||||
await act(async () => { await router.navigate(-1); });
|
||||
expect(router.state.location.pathname).toBe("/app/board");
|
||||
});
|
||||
|
||||
it("переход по связи меняет окно, «назад» возвращает прежнюю карточку, Esc закрывает всё", async () => {
|
||||
const router = at(["/app/issues"]);
|
||||
render(<App router={router} />);
|
||||
await userEvent.click(await screen.findByRole("link", { name: "AURORA-9001" }));
|
||||
await userEvent.click(await within(dialog()).findByRole("link", { name: "AURORA-9002" }));
|
||||
expect(await within(dialog()).findByRole("heading", { name: /AURORA-9002/ })).toBeInTheDocument();
|
||||
await act(async () => { await router.navigate(-1); });
|
||||
expect(await within(dialog()).findByRole("heading", { name: /AURORA-9001/ })).toBeInTheDocument();
|
||||
await userEvent.click(within(dialog()).getByRole("link", { name: "AURORA-9002" }));
|
||||
await within(dialog()).findByRole("heading", { name: /AURORA-9002/ });
|
||||
fireEvent(dialog(), new Event("cancel", { cancelable: true }));
|
||||
await waitFor(() => expect(screen.queryByRole("dialog")).toBeNull());
|
||||
expect(router.state.location.pathname).toBe("/app/issues");
|
||||
});
|
||||
|
||||
it("щелчок по фону окна закрывает его", async () => {
|
||||
const router = at(["/app/issues"]);
|
||||
render(<App router={router} />);
|
||||
await userEvent.click(await screen.findByRole("link", { name: "AURORA-9001" }));
|
||||
await within(dialog()).findByRole("heading", { name: /AURORA-9001/ });
|
||||
await userEvent.click(dialog()); // цель — сам <dialog>, не содержимое
|
||||
await waitFor(() => expect(screen.queryByRole("dialog")).toBeNull());
|
||||
expect(router.state.location.pathname).toBe("/app/issues");
|
||||
});
|
||||
|
||||
it("I-2: выделение текста внутри окна, отпущенное над фоном, окно не закрывает", async () => {
|
||||
const router = at(["/app/issues"]);
|
||||
render(<App router={router} />);
|
||||
await userEvent.click(await screen.findByRole("link", { name: "AURORA-9001" }));
|
||||
const title = await within(dialog()).findByRole("heading", { name: /AURORA-9001/ });
|
||||
fireEvent.pointerDown(title); // нажали на тексте карточки…
|
||||
fireEvent.pointerUp(dialog());
|
||||
fireEvent.click(dialog()); // …отпустили над фоном: click — на <dialog>
|
||||
expect(screen.getByRole("dialog")).toBeInTheDocument();
|
||||
expect(router.state.location.pathname).toBe("/app/issues/AURORA-9001");
|
||||
});
|
||||
|
||||
it("с доски: окно поверх доски, вкладка «Доска» активна, после закрытия доска та же", async () => {
|
||||
const router = at(["/app/board"]);
|
||||
render(<App router={router} />);
|
||||
const ilink = await screen.findByRole("link", { name: "Открыть карточку AURORA-9001" });
|
||||
const boardEl = screen.getByTestId("page-board");
|
||||
await userEvent.click(ilink);
|
||||
expect(await within(dialog()).findByRole("heading", { name: /AURORA-9001/ })).toBeInTheDocument();
|
||||
expect(screen.getByTestId("page-board")).toBe(boardEl);
|
||||
expect(screen.getByRole("link", { name: "Доска" })).toHaveClass("active");
|
||||
await userEvent.click(within(dialog()).getByRole("button", { name: "Закрыть" }));
|
||||
await waitFor(() => expect(router.state.location.pathname).toBe("/app/board"));
|
||||
expect(screen.getByTestId("page-board")).toBe(boardEl);
|
||||
});
|
||||
|
||||
it("прямой адрес: под окном список задач, ✕ ведёт в список заменой записи", async () => {
|
||||
const router = at(["/app/board", "/app/issues/AURORA-9001"]);
|
||||
render(<App router={router} />);
|
||||
expect(await within(await screen.findByRole("dialog")).findByRole("heading", { name: /AURORA-9001/ })).toBeInTheDocument();
|
||||
expect(await screen.findByTestId("page-issues")).toBeInTheDocument();
|
||||
await waitFor(() => expect(router.state.location.state).toMatchObject({ depth: 0, direct: true }));
|
||||
await userEvent.click(within(dialog()).getByRole("button", { name: "Закрыть" }));
|
||||
await waitFor(() => expect(router.state.location.pathname).toBe("/app/issues"));
|
||||
await act(async () => { await router.navigate(-1); });
|
||||
expect(router.state.location.pathname).toBe("/app/board"); // не карточка
|
||||
});
|
||||
|
||||
it("прямой адрес и переход по связи: закрытие возвращает в список, а не на первую карточку", async () => {
|
||||
const router = at(["/app/issues/AURORA-9001"]);
|
||||
render(<App router={router} />);
|
||||
await userEvent.click(await within(await screen.findByRole("dialog")).findByRole("link", { name: "AURORA-9002" }));
|
||||
await within(dialog()).findByRole("heading", { name: /AURORA-9002/ });
|
||||
fireEvent(dialog(), new Event("cancel", { cancelable: true }));
|
||||
await waitFor(() => expect(router.state.location.pathname).toBe("/app/issues"));
|
||||
await waitFor(() => expect(screen.queryByRole("dialog")).toBeNull());
|
||||
});
|
||||
|
||||
it("Esc с несохранённой заметкой спрашивает; «Остаться» оставляет окно с текстом", async () => {
|
||||
const router = at(["/app/issues"]);
|
||||
render(<App router={router} />);
|
||||
await userEvent.click(await screen.findByRole("link", { name: "AURORA-9001" }));
|
||||
await userEvent.click(await within(dialog()).findByRole("button", { name: "Править" }));
|
||||
const box = await within(dialog()).findByLabelText("Заметка владельца");
|
||||
await userEvent.type(box, "черновик");
|
||||
fireEvent(dialog(), new Event("cancel", { cancelable: true }));
|
||||
expect(await within(dialog()).findByText("Есть несохранённые правки заметки. Уйти без сохранения?")).toBeInTheDocument();
|
||||
await userEvent.click(within(dialog()).getByRole("button", { name: "Остаться" }));
|
||||
expect(router.state.location.pathname).toBe("/app/issues/AURORA-9001");
|
||||
expect(within(dialog()).getByLabelText("Заметка владельца")).toHaveValue("черновик");
|
||||
});
|
||||
|
||||
it("T4.1: отказ от закрытия («Остаться») сбрасывает ожидание закрытия: «назад» на запись прямого адреса не уводит в список", async () => {
|
||||
const router = at(["/app/issues/AURORA-9001"]);
|
||||
render(<App router={router} />);
|
||||
await userEvent.click(await within(await screen.findByRole("dialog")).findByRole("link", { name: "AURORA-9002" }));
|
||||
await within(dialog()).findByRole("heading", { name: /AURORA-9002/ });
|
||||
await userEvent.click(await within(dialog()).findByRole("button", { name: "Править" }));
|
||||
await userEvent.type(await within(dialog()).findByLabelText("Заметка владельца"), "черновик");
|
||||
fireEvent(dialog(), new Event("cancel", { cancelable: true }));
|
||||
await userEvent.click(await within(dialog()).findByRole("button", { name: "Остаться" }));
|
||||
await userEvent.click(within(dialog()).getByRole("button", { name: "Отмена" }));
|
||||
await userEvent.click(await within(dialog()).findByRole("button", { name: "Отменить правки" }));
|
||||
await act(async () => { await router.navigate(-1); });
|
||||
expect(await within(dialog()).findByRole("heading", { name: /AURORA-9001/ })).toBeInTheDocument();
|
||||
expect(router.state.location.pathname).toBe("/app/issues/AURORA-9001");
|
||||
});
|
||||
|
||||
it("T4.4: у окна есть имя в состоянии загрузки и 404", async () => {
|
||||
mockFetch((_m, url) => {
|
||||
if (url.pathname === "/settings") return json(200, settings);
|
||||
if (url.pathname === "/issues/AURORA-1") return new Promise<Response>(() => {});
|
||||
if (url.pathname === "/issues/AURORA-2") return json(404, { detail: "нет" });
|
||||
return json(200, {});
|
||||
});
|
||||
const r1 = render(<App router={at(["/app/issues/AURORA-1"])} />);
|
||||
expect(await screen.findByRole("dialog", { name: "AURORA-1" })).toBeInTheDocument();
|
||||
r1.unmount();
|
||||
render(<App router={at(["/app/issues/AURORA-2"])} />);
|
||||
expect(await screen.findByRole("dialog", { name: "AURORA-2" })).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
34
web/src/issues/IssueModal.tsx
Normal file
34
web/src/issues/IssueModal.tsx
Normal file
@ -0,0 +1,34 @@
|
||||
import { useEffect, useRef } from "react";
|
||||
import { ErrorBoundary } from "../shell/ErrorBoundary.tsx";
|
||||
import { IssueCard } from "./IssueCard.tsx";
|
||||
|
||||
// имя окна — заголовок карточки; пока его нет (загрузка, 404, ошибка), запасное имя — ключ задачи
|
||||
// окно карточки (design 13): <dialog> держит фокус и делает фон недоступным; Esc и щелчок по фону — onClose.
|
||||
// Сами окно не закрываем: оно исчезает вместе с адресом карточки. Если браузер закрыл его сам (Esc без активации
|
||||
// пользователя — cancel не отменяемый), открываем снова и просим закрытия по общему пути (блокировщик заметок).
|
||||
export function IssueModal({ issueKey, onClose }: { issueKey: string; onClose: () => void }) {
|
||||
const ref = useRef<HTMLDialogElement>(null);
|
||||
// I-2: закрывать щелчком по фону, только если и нажатие было на фоне — выделение текста, отпущенное за краем окна,
|
||||
// даёт click на самом <dialog>, но нажатие было внутри содержимого
|
||||
const downOnBackdrop = useRef(false);
|
||||
useEffect(() => {
|
||||
const d = ref.current;
|
||||
if (d && !d.open) d.showModal();
|
||||
const prev = document.body.style.overflow;
|
||||
document.body.style.overflow = "hidden"; // страница под окном не прокручивается
|
||||
return () => { document.body.style.overflow = prev; };
|
||||
}, []);
|
||||
return (
|
||||
<dialog ref={ref} className="issue-modal" aria-labelledby="issue-modal-title" aria-label={issueKey}
|
||||
onCancel={(e) => { if (e.cancelable) { e.preventDefault(); onClose(); } }}
|
||||
onClose={() => { const d = ref.current; if (d?.isConnected) { d.showModal(); onClose(); } }}
|
||||
onPointerDown={(e) => { downOnBackdrop.current = e.target === e.currentTarget; }}
|
||||
onClick={(e) => {
|
||||
const fromBackdrop = downOnBackdrop.current;
|
||||
downOnBackdrop.current = false;
|
||||
if (e.target === e.currentTarget && fromBackdrop) onClose();
|
||||
}}>
|
||||
<ErrorBoundary key={issueKey}><IssueCard issueKey={issueKey} onClose={onClose} /></ErrorBoundary>
|
||||
</dialog>
|
||||
);
|
||||
}
|
||||
@ -1,205 +0,0 @@
|
||||
import { act, render, screen, waitFor } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { QueryClientProvider } from "@tanstack/react-query";
|
||||
import { createMemoryRouter, RouterProvider } from "react-router";
|
||||
import { beforeEach, describe, expect, it } from "vitest";
|
||||
import { qk } from "../api/queries.ts";
|
||||
import { makeQueryClient } from "../api/queryClient.ts";
|
||||
import { setToken } from "../auth/token.ts";
|
||||
import settings from "../board/__fixtures__/settings.json";
|
||||
import { json, mockFetch } from "../test/fetchMock.ts";
|
||||
import { IssuePage } from "./IssuePage.tsx";
|
||||
|
||||
const note = (over = {}) => ({ key: "AURORA-9001", fact_status: "В работе", work_kind: "разработка", done_text: "схема",
|
||||
rest_text: "API", owner_note: null, updated_at: "2026-10-14T09:00:00+00:00", updated_by: "chat", ...over });
|
||||
const issue = (key: string, over = {}) => ({
|
||||
key, id: "1", issue_type: "Задача", summary: `Тема ${key}`, description: "строка 1\nстрока 2", status: "В работе",
|
||||
priority: "Высокий", assignee: "Иванов Иван Иванович", reporter: "me", duedate: null, planned_start: null,
|
||||
planned_end: "2026-10-20", epic_key: "AURORA-9100", labels: ["kind-dev"], sprint: "AWPCA_Спринт 2", created: null,
|
||||
jira_updated: null, fetched_at: "", neighbor: false,
|
||||
links_out: [{ target_key: "AURORA-9002", link_type: "ASUPR_Блокер_Начала" }],
|
||||
links_in: [{ source_key: "AURORA-9003", link_type: "Duplicate" }],
|
||||
note: note({ key }), commands: [{ id: 5, batch_id: 2, type: "ensure_fields", status: "done", finished_at: "2026-10-14T09:00:00+00:00", error: null }],
|
||||
...over,
|
||||
});
|
||||
const deferred = <T,>() => { let resolve!: (v: T) => void; const promise = new Promise<T>((r) => { resolve = r; }); return { promise, resolve }; };
|
||||
|
||||
function open(path: string, client = makeQueryClient()) {
|
||||
const router = createMemoryRouter([
|
||||
{ path: "/issues/:key", element: <IssuePage /> },
|
||||
{ path: "/board", element: <p>доска</p> },
|
||||
], { initialEntries: [path] });
|
||||
render(<QueryClientProvider client={client}><RouterProvider router={router} /></QueryClientProvider>);
|
||||
return router;
|
||||
}
|
||||
|
||||
describe("карточка задачи", () => {
|
||||
beforeEach(() => setToken("tok"));
|
||||
|
||||
it("поля, описание с переносами, связи с подписями, команды", async () => {
|
||||
mockFetch((_m, url) => url.pathname === "/settings" ? json(200, settings) : json(200, issue("AURORA-9001")));
|
||||
open("/issues/AURORA-9001");
|
||||
expect(await screen.findByRole("heading", { name: /AURORA-9001/ })).toBeInTheDocument();
|
||||
expect(screen.getByText("строка 1\nстрока 2", { normalizer: (s) => s })).toHaveClass("description");
|
||||
expect(await screen.findByText("блокирует начало")).toBeInTheDocument();
|
||||
expect(screen.getByRole("link", { name: "AURORA-9002" })).toHaveAttribute("href", "/issues/AURORA-9002");
|
||||
expect(screen.getByText("Duplicate")).toBeInTheDocument(); // неизвестный тип — имя Jira
|
||||
expect(screen.getByRole("cell", { name: "ensure_fields" })).toBeInTheDocument();
|
||||
expect(screen.getByRole("link", { name: "Открыть в Jira" })).toHaveAttribute("href", "https://sdlc.go.rshbank.ru/jira/browse/AURORA-9001");
|
||||
});
|
||||
|
||||
it("заголовок страницы — h1 (бренд в оболочке — span)", async () => {
|
||||
mockFetch((_m, url) => url.pathname === "/settings" ? json(200, settings) : json(200, issue("AURORA-9001")));
|
||||
open("/issues/AURORA-9001");
|
||||
expect(await screen.findByRole("heading", { level: 1, name: /AURORA-9001/ })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("задачи нет в зеркале — сообщение и ссылка на список", async () => {
|
||||
mockFetch((_m, url) => url.pathname === "/settings" ? json(200, settings) : json(404, { error: "not_found", detail: "issue" }));
|
||||
open("/issues/AURORA-1");
|
||||
expect(await screen.findByText(/Задачи AURORA-1 нет в зеркале/)).toBeInTheDocument();
|
||||
expect(screen.getByRole("link", { name: "к списку задач" })).toHaveAttribute("href", "/issues");
|
||||
});
|
||||
|
||||
it("сохраняются только изменённые поля, очищенное — null", async () => {
|
||||
let put: unknown = null;
|
||||
mockFetch((m, url, body) => {
|
||||
if (url.pathname === "/settings") return json(200, settings);
|
||||
if (m === "PUT") { put = body; return json(200, note({ rest_text: "тесты", done_text: null, updated_by: "web" })); }
|
||||
return json(200, issue("AURORA-9001"));
|
||||
});
|
||||
open("/issues/AURORA-9001");
|
||||
const rest = await screen.findByLabelText("Осталось");
|
||||
await userEvent.clear(rest); await userEvent.type(rest, "тесты");
|
||||
await userEvent.clear(screen.getByLabelText("Сделано"));
|
||||
await userEvent.click(screen.getByRole("button", { name: "Сохранить" }));
|
||||
await waitFor(() => expect(put).toEqual({ rest_text: "тесты", done_text: null, updated_by: "web" }));
|
||||
expect(await screen.findByText(/сохранено .*web/)).toBeInTheDocument();
|
||||
expect(screen.getByRole("button", { name: "Сохранить" })).toBeDisabled();
|
||||
});
|
||||
|
||||
it("422 — текст ошибки у формы, введённое на месте", async () => {
|
||||
mockFetch((m, url) => {
|
||||
if (url.pathname === "/settings") return json(200, settings);
|
||||
if (m === "PUT") return json(422, { error: "bad_note", detail: "неизвестные поля заметки: x" });
|
||||
return json(200, issue("AURORA-9001"));
|
||||
});
|
||||
open("/issues/AURORA-9001");
|
||||
const owner = await screen.findByLabelText("Заметка владельца");
|
||||
await userEvent.type(owner, "проверить");
|
||||
await userEvent.click(screen.getByRole("button", { name: "Сохранить" }));
|
||||
expect(await screen.findByRole("alert")).toHaveTextContent("неизвестные поля заметки: x");
|
||||
expect(owner).toHaveValue("проверить");
|
||||
});
|
||||
|
||||
it("уход с несохранёнными правками требует подтверждения", async () => {
|
||||
mockFetch((_m, url) => url.pathname === "/settings" ? json(200, settings) : json(200, issue("AURORA-9001")));
|
||||
const router = open("/issues/AURORA-9001");
|
||||
await userEvent.type(await screen.findByLabelText("Заметка владельца"), "черновик");
|
||||
await act(async () => { await router.navigate("/board"); });
|
||||
expect(await screen.findByText("Есть несохранённые правки заметки. Уйти без сохранения?")).toBeInTheDocument();
|
||||
await userEvent.click(screen.getByRole("button", { name: "Остаться" }));
|
||||
expect(router.state.location.pathname).toBe("/issues/AURORA-9001");
|
||||
expect(screen.getByLabelText("Заметка владельца")).toHaveValue("черновик");
|
||||
});
|
||||
|
||||
it("поздний ответ прежней карточки не подменяет новую", async () => {
|
||||
const client = makeQueryClient();
|
||||
const slow = deferred<Response>();
|
||||
mockFetch((_m, url) => {
|
||||
if (url.pathname === "/settings") return json(200, settings);
|
||||
if (url.pathname === "/issues/AURORA-9001") return slow.promise;
|
||||
return json(200, issue("AURORA-9002"));
|
||||
});
|
||||
const router = open("/issues/AURORA-9001", client);
|
||||
await act(async () => { await router.navigate("/issues/AURORA-9002"); });
|
||||
expect(await screen.findByRole("heading", { name: /AURORA-9002/ })).toBeInTheDocument();
|
||||
slow.resolve(json(200, issue("AURORA-9001")));
|
||||
await waitFor(() => expect(client.getQueryData(qk.issue("AURORA-9001"))).toBeDefined());
|
||||
expect(screen.getByRole("heading", { name: /AURORA-9002/ })).toBeInTheDocument();
|
||||
expect(screen.queryByRole("heading", { name: /AURORA-9001/ })).toBeNull();
|
||||
});
|
||||
|
||||
it("ответ «Сохранить» после ухода в другую карточку не пишет её заметку в форму новой", async () => {
|
||||
const slowPut = deferred<Response>();
|
||||
mockFetch((m, url) => {
|
||||
if (url.pathname === "/settings") return json(200, settings);
|
||||
if (m === "PUT") return slowPut.promise;
|
||||
if (url.pathname === "/issues/AURORA-9002") return json(200, issue("AURORA-9002"));
|
||||
return json(200, issue("AURORA-9001"));
|
||||
});
|
||||
// 9002 уже в кэше: форма не размонтируется при переходе, её состояние переживает навигацию
|
||||
const client = makeQueryClient();
|
||||
client.setQueryData(qk.issue("AURORA-9002"), issue("AURORA-9002"));
|
||||
const router = open("/issues/AURORA-9001", client);
|
||||
await userEvent.type(await screen.findByLabelText("Заметка владельца"), "черновик");
|
||||
await userEvent.click(screen.getByRole("button", { name: "Сохранить" }));
|
||||
await act(async () => { await router.navigate("/issues/AURORA-9002"); });
|
||||
await userEvent.click(await screen.findByRole("button", { name: "Уйти" }));
|
||||
expect(await screen.findByRole("heading", { name: /AURORA-9002/ })).toBeInTheDocument();
|
||||
slowPut.resolve(json(200, note({ owner_note: "черновик", updated_by: "web" })));
|
||||
await waitFor(() => expect(client.isMutating()).toBe(0));
|
||||
expect(screen.getByRole("heading", { name: /AURORA-9002/ })).toBeInTheDocument();
|
||||
expect(screen.getByLabelText("Заметка владельца")).toHaveValue("");
|
||||
expect(screen.getByRole("button", { name: "Сохранить" })).toBeDisabled();
|
||||
});
|
||||
|
||||
it("T14.1: поля заметки заблокированы на время сохранения, ответ не затирает ввод", async () => {
|
||||
const slowPut = deferred<Response>();
|
||||
mockFetch((m, url) => {
|
||||
if (url.pathname === "/settings") return json(200, settings);
|
||||
if (m === "PUT") return slowPut.promise;
|
||||
return json(200, issue("AURORA-9001"));
|
||||
});
|
||||
open("/issues/AURORA-9001");
|
||||
await userEvent.type(await screen.findByLabelText("Заметка владельца"), "раз");
|
||||
await userEvent.click(screen.getByRole("button", { name: "Сохранить" }));
|
||||
const box = screen.getByLabelText("Заметка владельца");
|
||||
await waitFor(() => expect(box).toBeDisabled());
|
||||
slowPut.resolve(json(200, note({ owner_note: "раз", updated_by: "web" })));
|
||||
await waitFor(() => expect(box).toBeEnabled());
|
||||
expect(box).toHaveValue("раз");
|
||||
});
|
||||
|
||||
it("M-2: ошибка повторной загрузки при уже имеющихся данных — данные остаются, строка с «Повторить»", async () => {
|
||||
const client = makeQueryClient();
|
||||
client.setQueryData(qk.issue("AURORA-9001"), issue("AURORA-9001"));
|
||||
let down = true;
|
||||
mockFetch((_m, url) => {
|
||||
if (url.pathname === "/settings") return json(200, settings);
|
||||
if (down) throw new TypeError("Failed to fetch");
|
||||
return json(200, issue("AURORA-9001"));
|
||||
});
|
||||
open("/issues/AURORA-9001", client);
|
||||
expect(await screen.findByRole("heading", { name: /AURORA-9001/ })).toBeInTheDocument();
|
||||
await act(async () => { await client.invalidateQueries({ queryKey: qk.issue("AURORA-9001") }); }); // повторная загрузка при данных в кэше
|
||||
expect(await screen.findByRole("alert")).toHaveTextContent("хаб не отвечает");
|
||||
expect(screen.getByRole("heading", { name: /AURORA-9001/ })).toBeInTheDocument();
|
||||
down = false;
|
||||
await userEvent.click(screen.getByRole("button", { name: "Повторить" }));
|
||||
await waitFor(() => expect(screen.queryByRole("alert")).toBeNull());
|
||||
});
|
||||
|
||||
it("O-10: ссылка в ветке «задачи нет в зеркале» стоит внутри .issue (цвет темы)", async () => {
|
||||
mockFetch((_m, url) => url.pathname === "/settings" ? json(200, settings) : json(404, { error: "not_found", detail: "issue" }));
|
||||
open("/issues/AURORA-1");
|
||||
const link = await screen.findByRole("link", { name: "к списку задач" });
|
||||
expect(link.closest(".issue")).not.toBeNull();
|
||||
});
|
||||
|
||||
it("O-11: задача без команд — текст вместо пустой таблицы", async () => {
|
||||
mockFetch((_m, url) => url.pathname === "/settings" ? json(200, settings) : json(200, issue("AURORA-9001", { commands: [] })));
|
||||
open("/issues/AURORA-9001");
|
||||
expect(await screen.findByText("команд по задаче не было")).toBeInTheDocument();
|
||||
expect(screen.queryByRole("columnheader", { name: "Команда" })).toBeNull();
|
||||
});
|
||||
|
||||
it("T14.3: id подсказок «статуса по факту» уникален и связан с полем", async () => {
|
||||
mockFetch((_m, url) => url.pathname === "/settings" ? json(200, settings) : json(200, issue("AURORA-9001")));
|
||||
open("/issues/AURORA-9001");
|
||||
const input = await screen.findByLabelText("Статус по факту");
|
||||
const id = input.getAttribute("list")!;
|
||||
expect(id).not.toBe("fact-statuses");
|
||||
expect(document.getElementById(id)?.tagName).toBe("DATALIST");
|
||||
});
|
||||
});
|
||||
@ -1,69 +0,0 @@
|
||||
import { Link, useParams } from "react-router";
|
||||
import { ApiError } from "../api/client.ts";
|
||||
import { useIssue, useSettings } from "../api/queries.ts";
|
||||
import { JIRA_URL } from "../board/constants.ts";
|
||||
import { fmtStamp } from "../board/model/util.ts";
|
||||
import { NoteForm } from "./NoteForm.tsx";
|
||||
import { dueOf } from "./options.ts";
|
||||
import "./issues.css";
|
||||
|
||||
export function IssuePage() {
|
||||
const { key = "" } = useParams();
|
||||
const issue = useIssue(key);
|
||||
const settings = useSettings();
|
||||
// подпись типа связи: логический тип из link_types настроек, иначе имя типа Jira
|
||||
const typeName = (jira: string) =>
|
||||
Object.entries(settings.data?.link_types ?? {}).find(([, j]) => j === jira)?.[0] ?? jira;
|
||||
|
||||
if (issue.isPending) return <p className="hint" data-testid="page-issue">загрузка {key}…</p>;
|
||||
if (issue.error instanceof ApiError && issue.error.status === 404) {
|
||||
return <p className="issue" data-testid="page-issue">Задачи {key} нет в зеркале хаба. <Link to="/issues">к списку задач</Link></p>;
|
||||
}
|
||||
if (issue.isError && !issue.data) {
|
||||
return (
|
||||
<p className="warn" role="alert" data-testid="page-issue">{issue.error.message}{" "}
|
||||
<button className="btn sm" type="button" onClick={() => void issue.refetch()}>Повторить</button></p>
|
||||
);
|
||||
}
|
||||
const i = issue.data!;
|
||||
const fields: [string, string | null][] = [
|
||||
["Тип", i.issue_type], ["Статус Jira", i.status], ["Приоритет", i.priority], ["Исполнитель", i.assignee],
|
||||
["Автор", i.reporter], ["Спринт", i.sprint], ["Срок", dueOf(i) || null], ["Плановое начало", i.planned_start],
|
||||
["Эпик", i.epic_key], ["Метки", i.labels.join(", ") || null], ["Сосед по связям", i.neighbor ? "да" : null],
|
||||
["Обновлена в Jira", i.jira_updated ? fmtStamp(i.jira_updated) : null],
|
||||
];
|
||||
return (
|
||||
<div data-testid="page-issue" className="issue">
|
||||
{issue.isError && (
|
||||
<p className="warn" role="alert">{issue.error.message}{" "}
|
||||
<button className="btn sm" type="button" onClick={() => void issue.refetch()}>Повторить</button></p>
|
||||
)}
|
||||
<p><Link to="/issues">← задачи</Link></p>
|
||||
<h1>{i.key} · {i.summary}</h1>
|
||||
<p><a href={`${JIRA_URL}/browse/${i.key}`} target="_blank" rel="noopener">Открыть в Jira</a></p>
|
||||
<div className="cols">
|
||||
<div>
|
||||
<dl className="fields">{fields.filter(([, v]) => v).map(([k, v]) => <div key={k}><dt>{k}</dt><dd>{v}</dd></div>)}</dl>
|
||||
{i.description && <pre className="description">{i.description}</pre>}
|
||||
<h3>Связи</h3>
|
||||
<ul className="links">
|
||||
{i.links_out.map((l) => <li key={"o" + l.target_key + l.link_type}><span>{typeName(l.link_type)}</span> → <Link to={`/issues/${l.target_key}`}>{l.target_key}</Link></li>)}
|
||||
{i.links_in.map((l) => <li key={"i" + l.source_key + l.link_type}><Link to={`/issues/${l.source_key}`}>{l.source_key}</Link> → <span>{typeName(l.link_type)}</span> → эта задача</li>)}
|
||||
{!i.links_out.length && !i.links_in.length && <li className="hint">связей нет</li>}
|
||||
</ul>
|
||||
<h3>Последние команды</h3>
|
||||
{i.commands.length ? (
|
||||
<table className="grid">
|
||||
<thead><tr><th>Команда</th><th>Пакет</th><th>Тип</th><th>Статус</th><th>Завершена</th><th>Ошибка</th></tr></thead>
|
||||
<tbody>{i.commands.map((c) => (
|
||||
<tr key={c.id}><td>#{c.id}</td><td>#{c.batch_id}</td><td>{c.type}</td><td>{c.status}</td>
|
||||
<td>{c.finished_at ? fmtStamp(c.finished_at) : ""}</td><td>{c.error}</td></tr>
|
||||
))}</tbody>
|
||||
</table>
|
||||
) : <p className="hint">команд по задаче не было</p>}
|
||||
</div>
|
||||
<NoteForm key={i.key} issue={i} />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@ -1,6 +1,7 @@
|
||||
import { useEffect, useMemo, useRef, useState } from "react";
|
||||
import { Link, useSearchParams } from "react-router";
|
||||
import { useSearchParams } from "react-router";
|
||||
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";
|
||||
|
||||
@ -63,7 +64,7 @@ export function IssuesPage() {
|
||||
<tbody>
|
||||
{rows.map((i) => (
|
||||
<tr key={i.key}>
|
||||
<td><Link to={`/issues/${i.key}`}>{i.key}</Link></td><td>{i.summary}</td><td>{i.issue_type}</td><td>{i.status}</td>
|
||||
<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>
|
||||
</tr>
|
||||
|
||||
@ -1,9 +1,9 @@
|
||||
import { useEffect, useId, useMemo, useState } from "react";
|
||||
import { useEffect, useId, useMemo, useRef, useState } from "react";
|
||||
import { useBlocker } from "react-router";
|
||||
import { useCloseAbort } from "./closeAbort.ts";
|
||||
import { ApiError } from "../api/client.ts";
|
||||
import { useSaveNote, useSettings } from "../api/queries.ts";
|
||||
import { NOTE_FIELDS, type IssueDetail, type NoteField, type NoteFields } from "../api/types.ts";
|
||||
import { fmtStamp } from "../board/model/util.ts";
|
||||
|
||||
type Values = Record<NoteField, string>;
|
||||
const valuesOf = (n: IssueDetail["note"]): Values =>
|
||||
@ -18,14 +18,17 @@ export function changedFields(initial: Values, values: Values): NoteFields {
|
||||
|
||||
const TEXTS: [NoteField, string][] = [["done_text", "Сделано"], ["rest_text", "Осталось"], ["owner_note", "Заметка владельца"]];
|
||||
|
||||
export function NoteForm({ issue }: { issue: IssueDetail }) {
|
||||
export function NoteForm({ issue, onDone }: { issue: IssueDetail; onDone: () => void }) {
|
||||
const settings = useSettings();
|
||||
const save = useSaveNote(issue.key);
|
||||
const [askCancel, setAskCancel] = useState(false);
|
||||
const confirmRef = useRef<HTMLDivElement>(null);
|
||||
const [initial, setInitial] = useState(() => valuesOf(issue.note));
|
||||
const [values, setValues] = useState(initial);
|
||||
const changed = useMemo(() => changedFields(initial, values), [initial, values]);
|
||||
const dirty = Object.keys(changed).length > 0;
|
||||
|
||||
const abortClose = useCloseAbort();
|
||||
const blocker = useBlocker(({ currentLocation, nextLocation }) => dirty && currentLocation.pathname !== nextLocation.pathname);
|
||||
useEffect(() => {
|
||||
if (!dirty) return;
|
||||
@ -33,6 +36,8 @@ export function NoteForm({ issue }: { issue: IssueDetail }) {
|
||||
window.addEventListener("beforeunload", warn);
|
||||
return () => window.removeEventListener("beforeunload", warn);
|
||||
}, [dirty]);
|
||||
// подтверждение ухода может оказаться ниже видимой части окна — прокрутить к нему
|
||||
useEffect(() => { if (blocker.state === "blocked") confirmRef.current?.scrollIntoView?.({ block: "nearest" }); }, [blocker.state]);
|
||||
|
||||
const kinds = Object.keys(settings.data?.kind_labels ?? {});
|
||||
const kindOptions = values.work_kind && !kinds.includes(values.work_kind) ? [values.work_kind, ...kinds] : kinds;
|
||||
@ -41,14 +46,13 @@ export function NoteForm({ issue }: { issue: IssueDetail }) {
|
||||
const set = (f: NoteField) => (e: { target: { value: string } }) => setValues((v) => ({ ...v, [f]: e.target.value }));
|
||||
|
||||
const submit = () => save.mutate(changed, {
|
||||
onSuccess: (note) => { const v = valuesOf(note); setInitial(v); setValues(v); },
|
||||
onSuccess: (note) => { const v = valuesOf(note); setInitial(v); setValues(v); onDone(); },
|
||||
});
|
||||
const err = save.error instanceof ApiError ? save.error.detail ?? save.error.message : save.error?.message;
|
||||
const saved = save.data ?? issue.note;
|
||||
|
||||
return (
|
||||
<form className="note" onSubmit={(e) => { e.preventDefault(); submit(); }}>
|
||||
<h3>Служебные заметки</h3>
|
||||
<form className="note" aria-labelledby="notes-title" onSubmit={(e) => { e.preventDefault(); submit(); }}>
|
||||
<h3 id="notes-title">Служебные заметки</h3>
|
||||
<fieldset className="note-fields" disabled={save.isPending}>
|
||||
<label>Вид работы
|
||||
<select value={values.work_kind} onChange={set("work_kind")}>
|
||||
@ -67,13 +71,20 @@ export function NoteForm({ issue }: { issue: IssueDetail }) {
|
||||
{err && <p className="err" role="alert">{err}</p>}
|
||||
<div className="row">
|
||||
<button className="btn" type="submit" disabled={!dirty || save.isPending}>Сохранить</button>
|
||||
{saved && <span className="hint">сохранено {fmtStamp(saved.updated_at)}, {saved.updated_by}</span>}
|
||||
<button className="btn" type="button" disabled={save.isPending} onClick={() => (dirty ? setAskCancel(true) : onDone())}>Отмена</button>
|
||||
</div>
|
||||
{askCancel && (
|
||||
<div className="confirm" role="alertdialog" aria-label="Отменить правки заметки?">
|
||||
<span>Отменить правки заметки?</span>
|
||||
<button className="btn" type="button" onClick={onDone}>Отменить правки</button>
|
||||
<button className="btn" type="button" onClick={() => setAskCancel(false)}>Продолжить правку</button>
|
||||
</div>
|
||||
)}
|
||||
{blocker.state === "blocked" && (
|
||||
<div className="confirm" role="dialog">
|
||||
<div className="confirm" role="alertdialog" ref={confirmRef}>
|
||||
<span>Есть несохранённые правки заметки. Уйти без сохранения?</span>
|
||||
<button className="btn" type="button" onClick={() => blocker.proceed()}>Уйти</button>
|
||||
<button className="btn" type="button" onClick={() => blocker.reset()}>Остаться</button>
|
||||
<button className="btn" type="button" onClick={() => { blocker.reset(); abortClose(); }}>Остаться</button>
|
||||
</div>
|
||||
)}
|
||||
</form>
|
||||
|
||||
26
web/src/issues/Notes.tsx
Normal file
26
web/src/issues/Notes.tsx
Normal file
@ -0,0 +1,26 @@
|
||||
import { useState } from "react";
|
||||
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", "Сделано"],
|
||||
["rest_text", "Осталось"], ["owner_note", "Заметка владельца"]];
|
||||
|
||||
// служебные заметки (design 20): по умолчанию чтение, «Править» — форма; после сохранения или отмены — снова чтение
|
||||
export function Notes({ issue }: { issue: IssueDetail }) {
|
||||
const [editing, setEditing] = useState(false);
|
||||
if (editing) return <NoteForm issue={issue} onDone={() => setEditing(false)} />;
|
||||
const n = issue.note;
|
||||
return (
|
||||
<section className="note view" aria-labelledby="notes-title">
|
||||
<div className="sec-head">
|
||||
<h3 id="notes-title">Служебные заметки</h3>
|
||||
<button className="btn sm" type="button" onClick={() => setEditing(true)}>Править</button>
|
||||
</div>
|
||||
<dl className="fields">
|
||||
{ROWS.map(([f, label]) => <div key={f}><dt>{label}</dt><dd>{n?.[f] || "—"}</dd></div>)}
|
||||
</dl>
|
||||
{n && <p className="hint">изменено {fmtStamp(n.updated_at)}, {n.updated_by}</p>}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
55
web/src/issues/card.test.ts
Normal file
55
web/src/issues/card.test.ts
Normal file
@ -0,0 +1,55 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import settings from "../board/__fixtures__/settings.json";
|
||||
import { factClass, 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 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 });
|
||||
|
||||
describe("groupLinks", () => {
|
||||
it("блокеры по направлению, связанные без повторов, прочие с направлением; порядок групп и ключей", () => {
|
||||
const g = groupLinks({
|
||||
links_out: [out("A-10", "ASUPR_Блокер_Завершения"), out("A-2", "ASUPR_Блокер_Начала"), out("A-5", "ASUPR_Связанные"),
|
||||
out("A-7", "Duplicate")],
|
||||
links_in: [inn("A-3", "ASUPR_Блокер_Начала"), inn("A-5", "ASUPR_Связанные"), inn("A-8", "Duplicate", null, null)],
|
||||
}, types);
|
||||
expect(g.map(([id, title]) => [id, title])).toEqual([["depends", "Зависит от"], ["blocks", "Блокирует"],
|
||||
["related", "Связана с"], ["other", "Прочие"]]);
|
||||
expect(g[0][2]).toEqual([{ key: "A-3", summary: "Тема A-3", status: "В работе", tag: "начало" }]);
|
||||
expect(g[1][2].map((l) => [l.key, l.tag])).toEqual([["A-2", "начало"], ["A-10", "завершение"]]); // ключи как числа
|
||||
expect(g[2][2].map((l) => l.key)).toEqual(["A-5"]);
|
||||
expect(g[3][2]).toEqual([{ key: "A-7", summary: "Тема A-7", status: "В работе", tag: "→ Duplicate" },
|
||||
{ key: "A-8", summary: null, status: null, tag: "← Duplicate" }]);
|
||||
});
|
||||
|
||||
it("пустые группы не возвращаются; без link_types всё — «Прочие» с именем Jira", () => {
|
||||
expect(groupLinks({ links_out: [], links_in: [] }, types)).toEqual([]);
|
||||
const g = groupLinks({ links_out: [out("A-2", "ASUPR_Блокер_Начала")], links_in: [] }, {});
|
||||
expect(g).toEqual([["other", "Прочие", [{ key: "A-2", summary: "Тема A-2", status: "В работе", tag: "→ ASUPR_Блокер_Начала" }]]]);
|
||||
});
|
||||
|
||||
it("логический тип из настроек вне трёх известных — «Прочие» с логическим именем", () => {
|
||||
const g = groupLinks({ links_out: [out("A-2", "X_Дубль")], links_in: [] }, { ...types, "дублирует": "X_Дубль" });
|
||||
expect(g[0][2][0].tag).toBe("→ дублирует");
|
||||
});
|
||||
});
|
||||
|
||||
describe("просрочка и класс статуса по факту", () => {
|
||||
it("класс из status_labels", () => {
|
||||
expect(factClass("Готово — закрыть", labels)).toBe("fact-done");
|
||||
expect(factClass("свой статус", labels)).toBeNull();
|
||||
expect(factClass(null, labels)).toBeNull();
|
||||
});
|
||||
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("todayISO — местная дата YYYY-MM-DD", () => {
|
||||
expect(todayISO(new Date(2026, 0, 5, 23, 30))).toBe("2026-01-05");
|
||||
});
|
||||
});
|
||||
38
web/src/issues/card.ts
Normal file
38
web/src/issues/card.ts
Normal file
@ -0,0 +1,38 @@
|
||||
import type { IssueDetail } from "../api/types.ts";
|
||||
|
||||
export type LinkGroupId = "depends" | "blocks" | "related" | "other";
|
||||
export interface CardLink { key: string; summary: string | null; status: string | null; tag: string | null }
|
||||
|
||||
const GROUPS: [LinkGroupId, string][] = [["depends", "Зависит от"], ["blocks", "Блокирует"], ["related", "Связана с"], ["other", "Прочие"]];
|
||||
const BLOCK_TAGS: Record<string, string> = { "блокирует начало": "начало", "блокирует завершение": "завершение" };
|
||||
const byKey = (a: CardLink, b: CardLink) =>
|
||||
a.key.localeCompare(b.key, "ru", { numeric: true }) || (a.tag ?? "").localeCompare(b.tag ?? "", "ru");
|
||||
|
||||
// связи карточки по смыслу (design 17): логическое имя типа — из link_types настроек (имя типа Jira → логическое)
|
||||
export function groupLinks(d: Pick<IssueDetail, "links_out" | "links_in">, linkTypes: Record<string, string>): [LinkGroupId, string, CardLink[]][] {
|
||||
const logical = new Map(Object.entries(linkTypes).map(([name, jira]) => [jira, name]));
|
||||
const groups: Record<LinkGroupId, CardLink[]> = { depends: [], blocks: [], related: [], other: [] };
|
||||
const add = (dir: "out" | "in", key: string, jiraType: string, summary: string | null, status: string | null) => {
|
||||
const name = logical.get(jiraType);
|
||||
if (name && BLOCK_TAGS[name]) groups[dir === "in" ? "depends" : "blocks"].push({ key, summary, status, tag: BLOCK_TAGS[name] });
|
||||
else if (name === "связанные") { if (!groups.related.some((l) => l.key === key)) groups.related.push({ key, summary, status, tag: null }); }
|
||||
else groups.other.push({ key, summary, status, tag: `${dir === "out" ? "→" : "←"} ${name ?? jiraType}` });
|
||||
};
|
||||
for (const l of d.links_out) add("out", l.target_key, l.link_type, l.summary, l.status);
|
||||
for (const l of d.links_in) add("in", l.source_key, l.link_type, l.summary, l.status);
|
||||
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));
|
||||
}
|
||||
|
||||
export const todayISO = (d = new Date()): string =>
|
||||
`${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, "0")}-${String(d.getDate()).padStart(2, "0")}`;
|
||||
5
web/src/issues/closeAbort.ts
Normal file
5
web/src/issues/closeAbort.ts
Normal file
@ -0,0 +1,5 @@
|
||||
import { createContext, useContext } from "react";
|
||||
|
||||
// окно закрывали, но пользователь отказался (блокировщик заметок, «Остаться»): Layout сбрасывает ожидание закрытия
|
||||
export const CloseAbortContext = createContext<() => void>(() => {});
|
||||
export const useCloseAbort = () => useContext(CloseAbortContext);
|
||||
15
web/src/issues/commentsPref.ts
Normal file
15
web/src/issues/commentsPref.ts
Normal file
@ -0,0 +1,15 @@
|
||||
import { useState } from "react";
|
||||
|
||||
// выбор «комментарии скрыты» — один на все карточки, в браузере (design 19); без localStorage — показаны
|
||||
export const COMMENTS_HIDDEN_KEY = "jira-hub-comments-hidden";
|
||||
const read = (): boolean => { try { return localStorage.getItem(COMMENTS_HIDDEN_KEY) === "1"; } catch { return false; } };
|
||||
|
||||
export function useCommentsHidden(): [boolean, (hidden: boolean) => void] {
|
||||
const [hidden, setHidden] = useState(read);
|
||||
const set = (v: boolean) => {
|
||||
setHidden(v);
|
||||
try { if (v) localStorage.setItem(COMMENTS_HIDDEN_KEY, "1"); else localStorage.removeItem(COMMENTS_HIDDEN_KEY); }
|
||||
catch { /* хранилище недоступно: выбор живёт до закрытия карточки */ }
|
||||
};
|
||||
return [hidden, set];
|
||||
}
|
||||
@ -7,16 +7,60 @@
|
||||
.grid th{color:var(--muted);font-weight:500;position:sticky;top:0;background:var(--bg)}
|
||||
.grid a{color:var(--accent)}
|
||||
.issue a{color:var(--accent)}
|
||||
.issue .cols{display:grid;grid-template-columns:minmax(0,1fr) 360px;gap:24px}
|
||||
@media (max-width:900px){.issue .cols{grid-template-columns:1fr}}
|
||||
.issue .fields{display:grid;grid-template-columns:repeat(auto-fill,minmax(220px,1fr));gap:4px 16px;margin:0}
|
||||
.issue-head{position:sticky;top:0;z-index:1;background:var(--bg);padding:14px 0 10px;border-bottom:1px solid var(--line);margin-bottom:14px}
|
||||
.issue-head .row1{display:flex;align-items:center;gap:8px;flex-wrap:wrap}
|
||||
.issue-head .key{font-family:var(--font-mono);font-size:13px;color:var(--muted)}
|
||||
.issue-head .spacer{flex:1 1 auto}
|
||||
.issue-head h2{margin:6px 0 4px;font-size:20px;line-height:1.3}
|
||||
.issue-head .facts{margin:0;color:var(--muted);font-size:14px}
|
||||
.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} — с прежней страницы; в колонке окна — на всю ширину */
|
||||
.issue .main{min-width:0}
|
||||
@media (max-width:899.98px){
|
||||
.issue .cols{display:flex;flex-direction:column;align-items:stretch;gap:16px}
|
||||
.issue .side{display:contents}
|
||||
.issue .side .note{order:-1}
|
||||
.issue .side .other{order:1}
|
||||
.issue .side .commands{order:2}
|
||||
}
|
||||
.issue section{margin-bottom:18px}
|
||||
.issue h3{font-size:13px;text-transform:uppercase;letter-spacing:.04em;color:var(--muted);margin:0 0 8px}
|
||||
.issue .sec-head{display:flex;align-items:center;justify-content:space-between;gap:8px}
|
||||
.issue .group{margin:0 0 10px}
|
||||
.issue .group h4{font-size:13px;font-weight:600;margin:0 0 4px}
|
||||
.issue .links{list-style:none;margin:0;padding:0}
|
||||
.issue .links li{display:flex;flex-wrap:wrap;align-items:baseline;gap:8px;padding:3px 0}
|
||||
.issue .links .tag{font-size:12px;color:var(--muted);min-width:76px}
|
||||
.issue .links .sum{flex:1 1 200px;min-width:0}
|
||||
.issue .links .absent{color:var(--muted);font-style:italic}
|
||||
.issue .description{white-space:pre-wrap;font:inherit;background:var(--panel);border:1px solid var(--line);border-radius:6px;padding:10px;margin:0 0 4px}
|
||||
.issue .description.clamped{display:-webkit-box;-webkit-line-clamp:8;-webkit-box-orient:vertical;overflow:hidden;
|
||||
/* нижний отступ — рамкой цвета фона: overflow обрезает по краю padding, и в нижнем padding была видна 9-я строка */
|
||||
padding-bottom:0;border-bottom:10px solid var(--panel);box-shadow:0 1px 0 var(--line)}
|
||||
.issue .comments ul{list-style:none;margin:0;padding:0;display:flex;flex-direction:column;gap:10px}
|
||||
.issue .comments .who{font-size:12px;color:var(--muted);margin-bottom:2px}
|
||||
.issue .comments .body{white-space:pre-wrap;font:inherit;margin:0}
|
||||
.issue .fields{display:grid;grid-template-columns:1fr;gap:6px;margin:0}
|
||||
.issue .fields dt{font-size:12px;color:var(--muted)}
|
||||
.issue .fields dd{margin:0}
|
||||
.issue .description{white-space:pre-wrap;font:inherit;background:var(--panel);border:1px solid var(--line);border-radius:6px;padding:10px}
|
||||
.issue .links{padding-left:18px}
|
||||
.issue .fields dd{margin:0;white-space:pre-wrap}
|
||||
.issue .commands summary{cursor:pointer;font-size:13px;color:var(--muted);text-transform:uppercase;letter-spacing:.04em}
|
||||
.issue .commands table{margin-top:8px;font-size:13px}
|
||||
.note{display:flex;flex-direction:column;gap:10px;background:var(--panel);border:1px solid var(--line);border-radius:8px;padding:12px;align-self:start}
|
||||
.note fieldset{display:contents;border:0;margin:0;padding:0;min-width:0}
|
||||
.note label{display:flex;flex-direction:column;gap:4px;font-size:13px;color:var(--muted)}
|
||||
.note input,.note select,.note textarea{font:inherit;font-size:14px;color:var(--fg);background:var(--card);border:1px solid var(--line);border-radius:6px;padding:6px 8px}
|
||||
.note .err{color:var(--p-crit)}
|
||||
.note .confirm{display:flex;gap:8px;align-items:center;flex-wrap:wrap}
|
||||
|
||||
/* окно карточки задачи (design 13) */
|
||||
.issue-modal{width:min(1000px,calc(100vw - 32px));max-width:none;max-height:90vh;padding:0;border:1px solid var(--line);border-radius:10px;background:var(--bg);color:var(--fg);overflow:auto}
|
||||
.issue-modal::backdrop{background:rgb(0 0 0 / .45)}
|
||||
.issue-modal > .issue{padding:0 20px 20px}
|
||||
@media (max-width:700px){.issue-modal{width:100vw;max-width:100vw;height:100vh;max-height:100vh;margin:0;border-radius:0}}
|
||||
|
||||
27
web/src/issues/modal.test.ts
Normal file
27
web/src/issues/modal.test.ts
Normal file
@ -0,0 +1,27 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { BOARD_CARD_STATE, cardState, LIST_BACKGROUND, openCardState } from "./modal.ts";
|
||||
|
||||
describe("состояние окна в истории", () => {
|
||||
it("со страницы — фон = адрес страницы, глубина 1", () => {
|
||||
expect(openCardState({ pathname: "/issues", search: "?sprint=S", state: null }))
|
||||
.toEqual({ background: { pathname: "/issues", search: "?sprint=S" }, depth: 1, direct: false });
|
||||
});
|
||||
it("из окна — тот же фон, глубина + 1, признак прямого адреса сохраняется", () => {
|
||||
const s = { background: LIST_BACKGROUND, depth: 0, direct: true };
|
||||
expect(openCardState({ pathname: "/issues/A-1", search: "", state: s })).toEqual({ background: LIST_BACKGROUND, depth: 1, direct: true });
|
||||
expect(openCardState({ pathname: "/issues/A-2", search: "", state: BOARD_CARD_STATE }))
|
||||
.toEqual({ background: { pathname: "/board", search: "" }, depth: 2, direct: false });
|
||||
});
|
||||
it("чужое или битое state — не состояние окна", () => {
|
||||
expect(cardState(null)).toBeNull();
|
||||
expect(cardState({ background: "/issues", depth: 1 })).toBeNull();
|
||||
expect(cardState({ background: { pathname: "/issues", search: "" }, depth: "1" })).toBeNull();
|
||||
expect(cardState({ background: { pathname: "/issues", search: "" }, depth: 1 }))
|
||||
.toEqual({ background: { pathname: "/issues", search: "" }, depth: 1, direct: false });
|
||||
});
|
||||
it("глубина — только целое неотрицательное, иначе 0", () => {
|
||||
const bg = { pathname: "/issues", search: "" };
|
||||
for (const depth of [-1, 1.5, NaN, Infinity]) expect(cardState({ background: bg, depth })?.depth).toBe(0);
|
||||
expect(cardState({ background: bg, depth: 3 })?.depth).toBe(3);
|
||||
});
|
||||
});
|
||||
23
web/src/issues/modal.ts
Normal file
23
web/src/issues/modal.ts
Normal file
@ -0,0 +1,23 @@
|
||||
// состояние окна карточки в записи истории (design 10–12): страница под окном, сколько записей заняли карточки
|
||||
// поверх неё, открыто ли окно прямым адресом
|
||||
export interface Background { pathname: string; search: string }
|
||||
export interface CardState { background: Background; depth: number; direct: boolean }
|
||||
|
||||
export const LIST_BACKGROUND: Background = { pathname: "/issues", search: "" };
|
||||
// доска не хранит состояние в адресе: «ⓘ» всегда открывает окно поверх /board
|
||||
export const BOARD_CARD_STATE: CardState = { background: { pathname: "/board", search: "" }, depth: 1, direct: false };
|
||||
|
||||
export function cardState(s: unknown): CardState | null {
|
||||
const c = s as { background?: { pathname?: unknown; search?: unknown }; depth?: unknown; direct?: unknown } | null;
|
||||
if (!c || typeof c !== "object" || !c.background || typeof c.background !== "object") return null;
|
||||
const { pathname, search } = c.background;
|
||||
if (typeof pathname !== "string" || typeof search !== "string" || typeof c.depth !== "number") return null;
|
||||
const depth = Number.isInteger(c.depth) && c.depth >= 0 ? c.depth : 0; // NaN, дробные и отрицательные не доходят до navigate(-depth)
|
||||
return { background: { pathname, search }, depth, direct: c.direct === true };
|
||||
}
|
||||
|
||||
// state ссылки на карточку: со страницы — фон = её адрес, глубина 1; из окна — тот же фон, глубина + 1
|
||||
export function openCardState(loc: { pathname: string; search: string; state: unknown }): CardState {
|
||||
const cur = cardState(loc.state);
|
||||
return cur ? { ...cur, depth: cur.depth + 1 } : { background: { pathname: loc.pathname, search: loc.search }, depth: 1, direct: false };
|
||||
}
|
||||
@ -1,19 +1,5 @@
|
||||
import { Navigate, type RouteObject } from "react-router";
|
||||
import { BoardPage } from "./board/BoardPage.tsx";
|
||||
import { IssuePage } from "./issues/IssuePage.tsx";
|
||||
import { IssuesPage } from "./issues/IssuesPage.tsx";
|
||||
import type { RouteObject } from "react-router";
|
||||
import { Layout } from "./shell/Layout.tsx";
|
||||
import { NotFound } from "./shell/NotFound.tsx";
|
||||
|
||||
// адреса относительно basename "/app": /app → /app/board
|
||||
export const routes: RouteObject[] = [{
|
||||
path: "/",
|
||||
element: <Layout />,
|
||||
children: [
|
||||
{ index: true, element: <Navigate to="/board" replace /> },
|
||||
{ path: "board", element: <BoardPage /> },
|
||||
{ path: "issues", element: <IssuesPage /> },
|
||||
{ path: "issues/:key", element: <IssuePage /> },
|
||||
{ path: "*", element: <NotFound /> },
|
||||
],
|
||||
}];
|
||||
// адреса относительно basename "/app": страницы и окно карточки рисует Layout (shell/pages.tsx, design 10)
|
||||
export const routes: RouteObject[] = [{ path: "*", element: <Layout /> }];
|
||||
|
||||
@ -1,26 +1,69 @@
|
||||
import { useQueryClient } from "@tanstack/react-query";
|
||||
import { NavLink, Outlet, useLocation } from "react-router";
|
||||
import { useCallback, useEffect, useMemo, useRef } from "react";
|
||||
import { Link, matchPath, useLocation, useNavigate, useRoutes } from "react-router";
|
||||
import { clearToken } from "../auth/token.ts";
|
||||
import { CloseAbortContext } from "../issues/closeAbort.ts";
|
||||
import { IssueModal } from "../issues/IssueModal.tsx";
|
||||
import { cardState, LIST_BACKGROUND } from "../issues/modal.ts";
|
||||
import { ErrorBoundary } from "./ErrorBoundary.tsx";
|
||||
import { PAGE_ROUTES } from "./pages.tsx";
|
||||
import "./shell.css";
|
||||
|
||||
export function Layout() {
|
||||
const qc = useQueryClient();
|
||||
const { pathname } = useLocation();
|
||||
const location = useLocation();
|
||||
const navigate = useNavigate();
|
||||
const issueKey = matchPath("/issues/:key", location.pathname)?.params.key ?? null;
|
||||
const card = issueKey ? cardState(location.state) : null;
|
||||
const bgPath = issueKey ? (card?.background ?? LIST_BACKGROUND).pathname : null;
|
||||
const bgSearch = issueKey ? (card?.background ?? LIST_BACKGROUND).search : "";
|
||||
// адрес страницы под окном; передаётся в useRoutes всегда (и без окна), чтобы дерево страницы не менялось при
|
||||
// открытии окна и useSearchParams внутри неё видел её собственный адрес (design 10)
|
||||
const bgLocation = useMemo(() => bgPath === null ? null : { pathname: bgPath, search: bgSearch, hash: "", state: null, key: "card-background" },
|
||||
[bgPath, bgSearch]);
|
||||
const pageLocation = bgLocation ?? location;
|
||||
const page = useRoutes(PAGE_ROUTES, pageLocation);
|
||||
|
||||
// прямой адрес карточки: запись истории получает state окна поверх списка задач (design 11)
|
||||
useEffect(() => {
|
||||
if (issueKey && !card) {
|
||||
void navigate(location.pathname + location.search, { replace: true, state: { background: LIST_BACKGROUND, depth: 0, direct: true } });
|
||||
}
|
||||
}, [issueKey, card, location.pathname, location.search, navigate]);
|
||||
|
||||
// закрытие (design 12): назад на depth записей; запись прямого адреса, на которую вернулись, заменяется списком
|
||||
const closingDirect = useRef(false);
|
||||
useEffect(() => {
|
||||
if (closingDirect.current && card?.direct && card.depth === 0) {
|
||||
closingDirect.current = false;
|
||||
void navigate("/issues", { replace: true });
|
||||
}
|
||||
}, [card, navigate]);
|
||||
const close = useCallback(() => {
|
||||
if (!card || card.depth === 0) { void navigate("/issues", { replace: true }); return; }
|
||||
closingDirect.current = card.direct;
|
||||
void navigate(-card.depth);
|
||||
}, [card, navigate]);
|
||||
|
||||
const abortClose = useCallback(() => { closingDirect.current = false; }, []);
|
||||
|
||||
const at = pageLocation.pathname;
|
||||
const tab = (to: string, label: string) => {
|
||||
const active = at === to || at.startsWith(to + "/");
|
||||
return <Link to={to} className={active ? "active" : undefined} aria-current={active ? "page" : undefined}>{label}</Link>;
|
||||
};
|
||||
return (
|
||||
<>
|
||||
<header className="appbar">
|
||||
<span className="brand">Хаб Jira</span>
|
||||
<nav>
|
||||
<NavLink to="/board">Доска</NavLink>
|
||||
<NavLink to="/issues" end={false}>Задачи</NavLink>
|
||||
</nav>
|
||||
<nav>{tab("/board", "Доска")}{tab("/issues", "Задачи")}</nav>
|
||||
<span className="spacer" />
|
||||
<button type="button" className="link" onClick={() => { clearToken(); qc.clear(); }}>Сменить токен</button>
|
||||
</header>
|
||||
<main className="page">
|
||||
<ErrorBoundary key={pathname}><Outlet /></ErrorBoundary>
|
||||
<ErrorBoundary key={at}>{page}</ErrorBoundary>
|
||||
</main>
|
||||
{issueKey && <CloseAbortContext value={abortClose}><IssueModal issueKey={issueKey} onClose={close} /></CloseAbortContext>}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
12
web/src/shell/pages.tsx
Normal file
12
web/src/shell/pages.tsx
Normal file
@ -0,0 +1,12 @@
|
||||
import { Navigate, type RouteObject } from "react-router";
|
||||
import { BoardPage } from "../board/BoardPage.tsx";
|
||||
import { IssuesPage } from "../issues/IssuesPage.tsx";
|
||||
import { NotFound } from "./NotFound.tsx";
|
||||
|
||||
// страницы приложения; Layout рисует их по адресу страницы под окном карточки (design 10)
|
||||
export const PAGE_ROUTES: RouteObject[] = [
|
||||
{ path: "/", element: <Navigate to="/board" replace /> },
|
||||
{ path: "/board", element: <BoardPage /> },
|
||||
{ path: "/issues", element: <IssuesPage /> },
|
||||
{ path: "*", element: <NotFound /> },
|
||||
];
|
||||
19
web/src/test/issueFixtures.ts
Normal file
19
web/src/test/issueFixtures.ts
Normal file
@ -0,0 +1,19 @@
|
||||
import type { IssueDetail, Note } from "../api/types.ts";
|
||||
|
||||
export const note = (over: Partial<Note> = {}): Note => ({
|
||||
key: "AURORA-9001", fact_status: "В работе", work_kind: "разработка", done_text: "схема", rest_text: "API",
|
||||
owner_note: null, updated_at: "2026-10-14T09:00:00+00:00", updated_by: "chat", ...over,
|
||||
});
|
||||
|
||||
export const issue = (key: string, over: Partial<IssueDetail> = {}): IssueDetail => ({
|
||||
key, id: "1", issue_type: "Задача", summary: `Тема ${key}`, description: "строка 1\nстрока 2", status: "В работе",
|
||||
priority: "Высокий", assignee: "Иванов Иван Иванович", reporter: "me", duedate: null, planned_start: null,
|
||||
planned_end: "2026-10-20", epic_key: "AURORA-9100", epic_summary: "Тестовый эпик", labels: ["kind-dev"],
|
||||
sprint: "AWPCA_Спринт 2", created: null, jira_updated: null, fetched_at: "", neighbor: false,
|
||||
links_out: [{ target_key: "AURORA-9002", link_type: "ASUPR_Блокер_Начала", summary: "API свода", status: "Новая" }],
|
||||
links_in: [{ source_key: "AURORA-9003", link_type: "Duplicate", summary: "Макет экрана", status: "Готово" }],
|
||||
note: note({ key }),
|
||||
commands: [{ id: 5, batch_id: 2, type: "ensure_fields", status: "done", finished_at: "2026-10-14T09:00:00+00:00", error: null }],
|
||||
comments: [],
|
||||
...over,
|
||||
});
|
||||
@ -3,6 +3,12 @@ import { cleanup } from "@testing-library/react";
|
||||
import { afterEach } from "vitest";
|
||||
import { saveBoardScroll } from "../board/scrollMemory.ts";
|
||||
|
||||
// jsdom 29 не умеет showModal/close у <dialog>: открытость — атрибут open, как в браузере
|
||||
if (typeof HTMLDialogElement !== "undefined" && !HTMLDialogElement.prototype.showModal) {
|
||||
HTMLDialogElement.prototype.showModal = function showModal(this: HTMLDialogElement) { this.setAttribute("open", ""); };
|
||||
HTMLDialogElement.prototype.close = function close(this: HTMLDialogElement) { this.removeAttribute("open"); };
|
||||
}
|
||||
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
saveBoardScroll(null);
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user