Пакет веб-багов: карточка, лента, вики-разметка, состояние модулей (#124) #125

Merged
aep merged 17 commits from feat/124-web-bugs into main 2026-10-26 00:55:02 +03:00
40 changed files with 1992 additions and 110 deletions

View File

@ -0,0 +1,505 @@
# Пакет веб-багов (web-bugfixes) Implementation Plan
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** убрать видимые пользователю ошибки веба хаба из пакета A сверки (sdlc-hub#124), каждую закрепив тестом, который до правки падает.
**Architecture:** только клиент (`web/src/`): точечные правки окна карточки, разборщика вики-разметки, трекера
состояния модулей, вложений, доски и UI-кита. Одна новая чистая функция `wikiPlain` для текста ленты. API, схема, MCP и
агенты не меняются.
**Tech Stack:** React 19 + TypeScript + Vite, React Router (data router), TanStack Query, Vitest + Testing Library, Playwright.
**Spec:** `openspec/changes/web-bugfixes/` — `design.md` (решения 1–21, ссылки на строки main 1405ccd),
`specs/web-app/spec.md` (дельта требований и сценарии), `proposal.md`.
## Global Constraints
- Меняется только `web/src/` (+ `web/e2e/issues.spec.ts`). Сервер, API, MCP, агенты — без изменений.
- Язык комментариев и сообщений коммитов — русский; идентификаторы — английские. Плотность комментариев — как в окружающем коде.
- Разметка MUST NOT превращаться в HTML строкой (`innerHTML`, `dangerouslySetInnerHTML`).
- Тесты — рядом с кодом (`*.test.ts(x)`), запросы к хабу мокаются `mockFetch`/`json` из `web/src/test/fetchMock.ts`.
- Каждая задача начинается с теста, который падает до правки (RED подтверждается запуском и фиксируется в отчёте).
- Тексты интерфейса — дословно из спеки: «Вид работы в Jira», «Назад», «Закрыть», «Остаться», «Уйти без сохранения
заметки?», «Повторить», «Загрузка…», «хаб не отвечает», «нет в зеркале», «Задачи {key} нет в зеркале хаба.».
- Коммит в конце каждой задачи; сообщение заканчивается строкой `Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>`.
- Зелёным считается только весь набор: финально `make check` из корня репозитория и `cd web && npm run e2e`.
## Review Focus
1. Ctrl/Cmd/средний щелчок по ключу в списке «Задачи» по-прежнему открывает карточку в новой вкладке (перехват только
обычного щелчка) — тест в Task 4.
2. Окно, открытое прямым адресом (`depth = 0`), и двойной ✕: адрес — список, дальше по истории не уходит — тест в Task 4.
3. `wikiPlain` на незакрытой разметке, неизвестных макросах и пустом тексте не бросает и отдаёт текст как есть; пустой
комментарий в ленте — по-прежнему «комментарий: —» — тест в Task 2.
4. Настройки ещё не пришли или без `kind_labels`: все метки — в «Метках», строки «Вид работы в Jira» нет — тест в Task 3.
5. Смена проекта A → B → A во время опроса: пакет A снова подхватывается при возврате (сброс не «запирает» модуль) — тест в Task 5.
---
### Task 1: Вики-разметка — краевые случаи (#56 M-5, M-6)
**Files:**
- Modify: `web/src/modules/jira/issues/wiki/inline.ts` (`ESCAPABLE` :21, `bracket` :148-152)
- Modify: `web/src/modules/jira/issues/wiki/parse.ts` (`MACRO` :17, `closeMacro` :103-116, `row` :118-139)
- Test: `web/src/modules/jira/issues/wiki/inline.test.ts`, `web/src/modules/jira/issues/wiki/parse.test.ts`
**Interfaces:**
- Consumes: —
- Produces: прежние `parseWiki(text, opts): Block[]`, `parseInline(line, opts, label?): Inline[]` с исправленным разбором.
- [ ] **Step 1: Write the failing tests**
В `inline.test.ts`:
```ts
it("\\# — символ решётки (M-5)", () => {
expect(parseInline("\\# не пункт")).toEqual([{ t: "text", text: "# не пункт" }]);
});
it("[~логин|текст] — упоминание логина без подписи (M-6)", () => {
expect(parseInline("[~ivanov|Иванов]")).toEqual([{ t: "mention", login: "ivanov" }]);
});
```
В `parse.test.ts`:
```ts
it("\\# в начале строки — абзац, не список", () => {
expect(parseWiki("\\# не пункт")).toEqual([{ t: "p", children: [{ t: "text", text: "# не пункт" }] }]);
});
it("quote и noformat с параметрами (M-5)", () => {
expect(parseWiki("{quote:title=Итог}\nсогласовано\n{quote}")).toEqual([{ t: "quote", children: [{ t: "p", children: [{ t: "text", text: "согласовано" }] }] }]);
expect(parseWiki("{noformat:borderStyle=solid}\n*x*\n{noformat}")).toEqual([{ t: "pre", text: "*x*" }]);
});
it("пробелы после открывающего {code} не дают пустой строки (M-6)", () => {
expect(parseWiki("{code} \nx = 1\n{code}")).toEqual([{ t: "pre", text: "x = 1" }]);
});
it("непарная [ в ячейке не съедает разделители (M-6)", () => {
const [table] = parseWiki("|[draft|итог|готово|");
expect(table).toMatchObject({ t: "table" });
expect((table as { rows: { children: { text: string }[] }[][] }).rows[0].map((c) => c.children[0].text)).toEqual(["[draft", "итог", "готово"]);
});
it("| внутри [текст|адрес] ячейку по-прежнему не делит", () => {
const [table] = parseWiki("|[док|https://x.ru]|b|");
expect((table as { rows: unknown[][] }).rows[0]).toHaveLength(2);
});
```
- [ ] **Step 2: Run tests to verify they fail**
Run: `cd web && npx vitest run src/modules/jira/issues/wiki/`
Expected: FAIL — новые тесты (кроме последнего, он фиксирует текущее поведение и должен быть зелёным).
- [ ] **Step 3: Implement**
- `ESCAPABLE` — добавить `#`.
- `MACRO = /^\s*\{(code|noformat|quote)(?::[^}]*)?\}/`; имя тега в `closeMacro` — первая группа (без параметров), закрывающий тег — `{имя}`.
- `closeMacro`: остаток строки открывающего тега, состоящий из одних пробелов, в `body` не попадает (`text.trim()` вместо `text` в условии для `k === start`).
- `bracket`: при `body.startsWith("~")` логин — `body.slice(1).split("|")[0].trim()`; пустой или с пробелом — `null`, как сейчас.
- `row`: `[` увеличивает `depth`, только если в `s` правее позиции есть `]` (`s.indexOf("]", j + 1) >= 0`).
- [ ] **Step 4: Run tests to verify they pass**
Run: `cd web && npx vitest run src/modules/jira/issues/wiki/`
Expected: PASS (все тесты каталога, включая прежние).
- [ ] **Step 5: Commit**
```bash
git add web/src/modules/jira/issues/wiki/
git commit -m "Вики-разметка: \\#, макросы с параметрами, [~логин|текст], пробелы после {code}, непарная [ в таблице (#124, #56 M-5, M-6)"
```
---
### Task 2: Текст комментария в ленте без разметки (#103 С6)
**Files:**
- Create: `web/src/modules/jira/issues/wiki/plain.ts`
- Modify: `web/src/modules/jira/feed/events.ts` (`eventText`, ветка `comment` :72-75)
- Test: `web/src/modules/jira/issues/wiki/plain.test.ts` (новый), `web/src/modules/jira/feed/events.test.ts`
**Interfaces:**
- Consumes: `parseWiki`, `Block`, `Inline`, `WikiOptions` из Task 1 (`parse.ts`, `inline.ts`).
- Produces: `export function wikiPlain(text: string, opts?: WikiOptions): string` — текст как читается отрендеренным,
блоки через пробел (не одна строка — «одна строка» и обрезку делает `eventText`).
- [ ] **Step 1: Write the failing tests**
`plain.test.ts`:
```ts
import { wikiPlain } from "./plain.ts";
it("выделение, ссылки, упоминание, заголовок", () => {
expect(wikiPlain("h3. Итог\n*Готово*, см. [протокол|https://x.ru/p] и [~ivanov]").replace(/\s+/g, " ").trim())
.toBe("Итог Готово, см. протокол и @ivanov");
});
it("[адрес] — сам адрес, {{моно}} и {code} — содержимое, ---- — пусто", () => {
expect(wikiPlain("[https://x.ru] {{a_b}}\n----\n{code}\nx = 1\n{code}").replace(/\s+/g, " ").trim()).toBe("https://x.ru a_b x = 1");
});
it("ключ задачи и вложение — текстом", () => {
const opts = { projectKey: "AURORA", attachments: [{ id: "1", filename: "скрин.png", created: null }] };
expect(wikiPlain("см. AURORA-1 и !скрин.png!", opts).trim()).toBe("см. AURORA-1 и скрин.png");
});
it("незакрытая и неизвестная разметка — как есть, пустой текст — пусто (Review Focus 3)", () => {
expect(wikiPlain("{color:red}важно *без конца").trim()).toBe("{color:red}важно *без конца");
expect(wikiPlain("")).toBe("");
});
```
В `events.test.ts`:
```ts
it("комментарий в ленте — без вики-разметки (С6)", () => {
expect(eventText({ ...commentEvent, to: "h3. Итог\n*Готово*, см. [протокол|https://x.ru/p] и [~ivanov]" }))
.toBe("комментарий: Итог Готово, см. протокол и @ivanov");
});
it("пустой комментарий — по-прежнему «—»", () => {
expect(eventText({ ...commentEvent, to: " " })).toBe("комментарий: —");
});
```
(`commentEvent` — событие `kind: "comment"` по образцу уже имеющихся в `events.test.ts`.)
- [ ] **Step 2: Run tests to verify they fail**
Run: `cd web && npx vitest run src/modules/jira/issues/wiki/plain.test.ts src/modules/jira/feed/events.test.ts`
Expected: FAIL — нет модуля `plain.ts`; тест С6 видит сырую разметку.
- [ ] **Step 3: Implement**
`wikiPlain` обходит дерево `parseWiki`: `text` — текст; метки выделения — дети; `code` — текст; `link` — дети
(у `[адрес]` дети уже содержат адрес); `issue` — `key`, если детей нет, иначе дети; `attachment` — дети; `mention` —
`"@" + login`; `br` — пробел; блоки (`heading`, `p`, `list` с пунктами и вложенными списками, `table` по ячейкам,
`pre` — `text`, `quote` — вложенные блоки, `hr` — пусто) соединяются пробелом. В `eventText` ветка `comment`:
`oneLine(wikiPlain(e.to ?? ""))`, остальное (обрезка 300 и «…», «—» для пустого) — как сейчас.
- [ ] **Step 4: Run tests to verify they pass**
Run: `cd web && npx vitest run src/modules/jira/issues/wiki/ src/modules/jira/feed/`
Expected: PASS.
- [ ] **Step 5: Commit**
```bash
git add web/src/modules/jira/issues/wiki/plain.ts web/src/modules/jira/issues/wiki/plain.test.ts web/src/modules/jira/feed/
git commit -m "Лента: текст комментария без вики-разметки Jira (#124, #103 С6)"
```
---
### Task 3: Карточка — связи «нет в зеркале», окно 404, метки видов, пустое описание (#42 M-5, #103, #56 M-4)
**Files:**
- Modify: `web/src/modules/jira/issues/card.ts` (новая функция)
- Modify: `web/src/modules/jira/issues/IssueCard.tsx` (404/ошибка :34-49, `Links` :110-134, описание :79, `OtherFields` :225-237)
- Modify: `web/src/modules/jira/issues/overlay.tsx` (передать в карточку `onBack`, если `card.depth >= 1` … см. Step 3)
- Test: `web/src/modules/jira/issues/card.test.ts` (если нет — создать), `web/src/modules/jira/issues/IssueCard.test.tsx`, `web/src/modules/jira/issues/IssueModal.test.tsx`
**Interfaces:**
- Consumes: `JIRA_URL` (`../board/constants.ts`), `settings.data.kind_labels` (вид → метка).
- Produces:
- `export function splitKindLabels(labels: string[], kindLabels: Record<string, string> | undefined): { kinds: string[]; rest: string[] }` в `card.ts` — `kinds` — названия видов в порядке меток задачи, `rest` — прочие метки;
- `IssueCard` получает необязательный проп `onBack?: () => void`; `IssueModal` прокидывает его из `useIssueOverlay`.
- [ ] **Step 1: Write the failing tests**
`card.test.ts`:
```ts
it("метки видов работ отдельно от прочих", () => {
expect(splitKindLabels(["AWPCA", "kind-dev", "kind-ui"], { разработка: "kind-dev", UI: "kind-ui" }))
.toEqual({ kinds: ["разработка", "UI"], rest: ["AWPCA"] });
});
it("без kind_labels — все метки прочие (Review Focus 4)", () => {
expect(splitKindLabels(["AWPCA", "kind-dev"], undefined)).toEqual({ kinds: [], rest: ["AWPCA", "kind-dev"] });
});
```
`IssueCard.test.tsx`:
- «Прочие поля»: задача с метками `["AWPCA", "kind-dev"]`, настройки фикстуры `settings.json` → видны пары «Вид работы в Jira» / «разработка» и «Метки» / «AWPCA», текста `kind-dev` в карточке нет.
- описание `" \n\n"` → нет заголовка «Описание».
- связь на ключ вне зеркала (`summary: null, status: null`) → в «Связях» `getByRole("link", { name: /AURORA-1999/ })` имеет `href` = `${JIRA_URL}/browse/AURORA-1999` и `target="_blank"`; рядом текст «нет в зеркале».
`IssueModal.test.tsx` (сценарий спеки «Задача не в зеркале после перехода»): из списка открыта AURORA-9002, в окне
переход по ссылке на ключ, для которого хаб отвечает 404 (`json(404, {})` в `hub()` для этого ключа) → в окне «Задачи
AURORA-1999 нет в зеркале хаба.» и кнопки «Назад» и «Закрыть»; «Назад» — заголовок окна снова AURORA-9002; при новом
переходе и «Закрыть» — окна нет, адрес — список. Прямой адрес `/app/jira/issues/AURORA-1999` (404) → только «Закрыть».
- [ ] **Step 2: Run tests to verify they fail**
Run: `cd web && npx vitest run src/modules/jira/issues/card.test.ts src/modules/jira/issues/IssueCard.test.tsx src/modules/jira/issues/IssueModal.test.tsx`
Expected: FAIL — нет `splitKindLabels`, метка `kind-dev` видна в «Метках», рамка описания есть, ссылка ведёт на `/jira/issues/…`, кнопки «Назад» нет.
- [ ] **Step 3: Implement**
- `OtherFields`: строки «Вид работы в Jira» (`kinds.join(", ")`) и «Метки» (`rest.join(", ")`) вместо прежней «Метки»;
`kind_labels` — из `useSettings()` (передать пропом из `IssueCard`). Положение «Вид работы в Jira» — сразу перед «Метками».
- Описание: условие `i.description?.trim()`.
- `Links`: при `l.summary === null && l.status === null` ключ — `<a href={`${JIRA_URL}/browse/${l.key}`} target="_blank" rel="noopener">` со значком `IconExternal` (как «Открыть в Jira»), иначе — `IssueLink`, как сейчас.
- `useIssueOverlay`: «Назад» — только если окно открыто переходом из другой карточки. По `openCardState`
(`modal.ts:25-28`): из списка `depth = 1, direct = false`; прямой адрес — `depth = 0, direct = true`; каждый переход
внутри окна — `depth + 1`. Отсюда `onBack = card && (card.depth >= 2 || (card.direct && card.depth >= 1)) ? () => void
navigate(-1) : undefined`; прокинуть через `IssueModal` в `IssueCard`. Тесты: «из списка — без Назад», «после перехода —
с Назад», «прямой адрес — без Назад», «переход из прямого адреса — с Назад».
- 404 и ошибка загрузки: `action` — «Назад» (если `onBack`) и «Закрыть» / «Повторить», «Назад» (если есть), «Закрыть».
- [ ] **Step 4: Run tests to verify they pass**
Run: `cd web && npx vitest run src/modules/jira/issues/`
Expected: PASS.
- [ ] **Step 5: Commit**
```bash
git add web/src/modules/jira/issues/
git commit -m "Карточка: связь вне зеркала ведёт в Jira, «Назад» у окна 404, метки видов работ отдельно, пустое описание (#124, #42 M-5, #103, #56 M-4)"
```
---
### Task 4: Окно карточки — поиск, двойное закрытие, подтверждение ухода (#42 M-1, M-2, M-4)
**Files:**
- Modify: `web/src/modules/jira/issues/IssuesPage.tsx` (:14-38, `open` :50)
- Modify: `web/src/modules/jira/issues/IssueTable.tsx` (ключ :38 — обычный щелчок через `onOpen`)
- Modify: `web/src/modules/jira/issues/overlay.tsx` (`close` :37-41)
- Modify: `web/src/modules/jira/issues/NoteForm.tsx` (подтверждение ухода :82-89, эффект :42)
- Test: `web/src/modules/jira/issues/IssueModal.test.tsx`, `web/src/modules/jira/issues/IssuesPage.test.tsx`, `web/e2e/issues.spec.ts`
**Interfaces:**
- Consumes: `openCardState`, `cardPath` (`modal.ts`, `IssueLink.tsx`).
- Produces: `IssueTable` сохраняет проп `onOpen(key: string): void` — теперь его зовут и строка, и обычный щелчок по ключу.
- [ ] **Step 1: Write the failing tests**
`IssueModal.test.tsx`:
- «поиск перед открытием не закрывает окно» (сценарий спеки): список, в поле поиска (`getByRole("searchbox")`)
набрано «свод», сразу щёлкнут ключ AURORA-9002 (без ожидания 300 мс) → окно открыто; после `await new Promise(r => setTimeout(r, 400))`
окно всё ещё открыто (`screen.getByRole("dialog")`), `router.state.location.pathname` — `/app/jira/issues/AURORA-9002`;
после ✕ адрес — `/app/jira/issues` с `q=свод`. То же для щелчка по ячейке темы строки.
- «двойной щелчок по ✕ не уводит дальше назад»: история `["/app/jira/links", "/app/jira/issues"]`, открыта AURORA-9002,
переход по связи на AURORA-9001, `await userEvent.dblClick(✕)` → адрес — `/app/jira/issues`, не `/app/jira/links`.
- то же для прямого адреса (Review Focus 2): история `["/app/jira/issues/AURORA-9002"]`, двойной ✕ → `/app/jira/issues`.
- подтверждение ухода: в форме заметки правка, нажата Esc → `getByRole("alertdialog", { name: "Уйти без сохранения заметки?" })`,
`document.activeElement` — кнопка «Остаться».
`IssuesPage.test.tsx` (Review Focus 1): у ключа строки `href` прежний (`/jira/issues/AURORA-9001`), щелчок с `ctrlKey`
(`fireEvent.click(link, { ctrlKey: true })`) не вызывает навигацию внутри приложения (адрес страницы не меняется).
`web/e2e/issues.spec.ts`: сценарий «поиск перед открытием карточки» — ввести текст в поиск и сразу щёлкнуть первую
строку; через 600 мс окно открыто; Escape — адрес списка содержит `q=`.
- [ ] **Step 2: Run tests to verify they fail**
Run: `cd web && npx vitest run src/modules/jira/issues/IssueModal.test.tsx src/modules/jira/issues/IssuesPage.test.tsx`
Expected: FAIL — окно закрывается через 300 мс; двойной ✕ уходит на `/app/jira/links`; у подтверждения нет имени и фокуса.
- [ ] **Step 3: Implement**
- `IssuesPage`: функция `openCard(key)` — снять таймер поиска; если `q` отличается от `filters.q`, записать `q` в адрес
(`replace`) и открыть карточку с фоном, у которого `search` уже содержит `q` (`navigate(cardPath(key), { state:
openCardState({ ...loc, search: <поиск с q> }) })`, `written.current = q`). Её получает `IssueTable` как `onOpen`.
Таймер не перезаводится, пока страница под окном (адрес `filters.q` совпадает с `q` после записи — эффект ничего не делает).
- `IssueTable`: ключ — та же ссылка `IssueLink` (адрес и состояние для новой вкладки), но обычный щелчок (левая кнопка
без `ctrl`/`meta`/`shift`/`alt`) — `preventDefault()` и `onOpen(key)`.
- `overlay.tsx`: `closingKey = useRef<string | null>(null)`; `close` выходит, если `closingKey.current === location.key`,
иначе запоминает `location.key` и закрывает как сейчас. Сброс — эффект по смене `location.key`.
- `NoteForm`: у блока подтверждения ухода `aria-label="Уйти без сохранения заметки?"`; ref на кнопку «Остаться»; при
`blocker.state === "blocked"` — `scrollIntoView` (как сейчас) и `focus()` этой кнопки.
- [ ] **Step 4: Run tests to verify they pass**
Run: `cd web && npx vitest run src/modules/jira/issues/ && npm run e2e -- issues.spec.ts`
Expected: PASS.
- [ ] **Step 5: Commit**
```bash
git add web/src/modules/jira/issues/ web/e2e/issues.spec.ts
git commit -m "Окно карточки: поиск не закрывает окно, повторное закрытие игнорируется, фокус подтверждения ухода (#124, #42 M-1, M-2, M-4)"
```
---
### Task 5: Строка состояния — ответ опроса прежнего проекта (#116)
**Files:**
- Modify: `web/src/shell/activity/ActivityProvider.tsx` (эффект опроса :126-154)
- Test: `web/src/shell/activity/activity.test.tsx`
**Interfaces:**
- Consumes: —
- Produces: поведение `ModuleActivity` без изменений интерфейса.
- [ ] **Step 1: Write the failing test**
В `activity.test.tsx` — детерминированный тест «ответ опроса после смены проекта не возвращает событие»: ответ
`GET /batches/9` (пакет `wiki_sync` проекта AWPCA) задерживается промисом-заглушкой (`deferred` из `web/src/test/batchHub.ts`);
когда первый ответ показан (`event.kind === "batch"`), следующий ответ держится; в одном `act` вызвать
`m.setProject("STOCKS_DRRK")` и разрешить задержанный ответ → после `await waitFor(...)` и `sleep(50)` `m.now().wiki.event`
равно `null`. Исполнитель подбирает порядок так, чтобы тест падал на текущем коде (RED обязателен, см. Risks в design.md);
если воспроизвести в одном такте не удаётся — описать в отчёте, как получен RED (например, `pollMs` 0 и разрешение
ответа синхронно перед `setProject`).
Второй тест (Review Focus 5): A → B → A — после возврата на AWPCA незавершённый пакет 9 снова подхватывается
(`event` `{ kind: "batch", id: 9 }`).
- [ ] **Step 2: Run tests to verify they fail**
Run: `cd web && npx vitest run src/shell/activity/activity.test.tsx`
Expected: FAIL — первый тест: событие `batch` id 9 после смены проекта.
- [ ] **Step 3: Implement**
В начале эффекта опроса: `if (!active || activeRef.current !== active) return;` — ответ обрабатывается, только если
отслеживаемый пакет тот же объект, что в `activeRef` (смена проекта синхронно обнуляет `activeRef` в `setActiveNow`).
Проверить, что `setActiveNow({ ...active, own: true })` (:138) и `track`/`start` по-прежнему проходят: они сами
обновляют `activeRef`.
- [ ] **Step 4: Run tests to verify they pass**
Run: `cd web && npx vitest run src/shell/activity/activity.test.tsx` и прогон в цикле:
`for i in $(seq 20); do npx vitest run src/shell/activity/activity.test.tsx --silent || break; done`
Expected: PASS все 20 прогонов.
- [ ] **Step 5: Commit**
```bash
git add web/src/shell/activity/
git commit -m "Состояние модулей: ответ опроса прежнего проекта не возвращает событие (#124, #116)"
```
---
### Task 6: Список задач ждёт настройки (#60 M-3)
**Files:**
- Modify: `web/src/modules/jira/issues/IssuesPage.tsx` (запрос :45-48, «Загрузка…» :60-61)
- Modify: `web/src/modules/jira/api/queries.ts` (`useIssues` — необязательный параметр `enabled`)
- Test: `web/src/modules/jira/issues/IssuesPage.test.tsx`
**Interfaces:**
- Consumes: `useSettings()` (`isPending`).
- Produces: `useIssues(filters: IssueFilters, opts?: { enabled?: boolean })`.
- [ ] **Step 1: Write the failing test**
«закрытый статус в адресе до настроек» (сценарий спеки): ответ `/settings` задержан; маршрут `/issues?status=Готово`;
пока настройки не пришли — `issuesCalls(fetch)` пуст, видно «Загрузка…», нет «По этим фильтрам задач нет»; после
ответа `/settings` (с «Готово» в `closed_statuses`) — ровно один вызов `/issues` с `status=Готово` и без `hide_closed`.
Второй тест (R-11): `/settings` отвечает 500 → один вызов `/issues` с `hide_closed=1`.
- [ ] **Step 2: Run test to verify it fails**
Run: `cd web && npx vitest run src/modules/jira/issues/IssuesPage.test.tsx`
Expected: FAIL — первый вызов `/issues` уходит до настроек с `hide_closed=1`.
- [ ] **Step 3: Implement**
`useIssues(query, { enabled: !settings.isPending })`; пока настройки грузятся, «Загрузка…» (условие подсказки — по
`list.isPending || settings.isPending`), сообщение «По этим фильтрам задач нет» — только после ответа списка.
- [ ] **Step 4: Run tests to verify they pass**
Run: `cd web && npx vitest run src/modules/jira/issues/`
Expected: PASS.
- [ ] **Step 5: Commit**
```bash
git add web/src/modules/jira/
git commit -m "Список задач: запрос после настроек, без мелькания «задач нет» (#124, #60 M-3)"
```
---
### Task 7: Вложения — прежняя ошибка, 401 при опросе, порядок хаба (#86 T6.2, T6.3, M-4)
**Files:**
- Modify: `web/src/modules/jira/issues/attachments/useAttachmentDownload.ts`
- Modify: `web/src/modules/jira/issues/attachments/AttachmentList.tsx` (`newestFirst` :14-17, ошибка :36)
- Modify: `web/src/modules/jira/issues/wiki/inline.ts` (`findAttachment` :36-45)
- Test: `web/src/modules/jira/issues/attachments/useAttachmentDownload.test.tsx`, `web/src/modules/jira/issues/IssueCard.test.tsx`, `web/src/modules/jira/issues/wiki/inline.test.ts`
**Interfaces:**
- Consumes: `dropTokenOn`, `AuthError`, `HubDownError` (`web/src/api/client.ts`).
- Produces: `DownloadStatus` дополняется полем `ok: boolean` (успех в этой карточке); `IDLE.ok = false`.
- [ ] **Step 1: Write the failing tests**
- `useAttachmentDownload.test.tsx`: опрос `GET /attachments/{id}` отвечает 401 → вызван сброс токена (токен в хранилище
пуст / экран входа по образцу других тестов `AuthError`), текста ошибки нет; ответ — сеть недоступна (`HubDownError`)
→ `status(id).error === "хаб не отвечает"`.
- `IssueCard.test.tsx` (сценарий «Повтор после ошибки»): вложение с `state: "failed", error: "сбой"`, «Повторить», хаб
отдаёт `ready` и файл, перечитанная карточка всё ещё отдаёт `failed` (задержать) → текста «сбой» нет.
- `IssueCard.test.tsx`: вложения в ответе хаба в порядке `[b (created null), a (created 2026-10-20)]` → в разделе в том же порядке.
- `inline.test.ts`: два вложения «скрин.png» — `[{id: "5", created: null}, {id: "9", created: "2026-10-20T10:00:00.000+0300"}]` →
`!скрин.png!` даёт `attachment` с `id: "5"` (первое в порядке хаба).
- [ ] **Step 2: Run tests to verify they fail**
Run: `cd web && npx vitest run src/modules/jira/issues/attachments/ src/modules/jira/issues/IssueCard.test.tsx src/modules/jira/issues/wiki/inline.test.ts`
Expected: FAIL.
- [ ] **Step 3: Implement**
- В `useAttachmentDownload`: общая функция разбора ошибки — `AuthError` → `dropTokenOn(e)` без текста; `HubDownError` →
«хаб не отвечает»; прочие — `errorText`. Успех — `put(id, { busy: false, error: null, ok: true })`; `start` сбрасывает `ok`.
- `AttachmentList`: порядок — как в `items`; серверный `failed` показывается, только если `!ok`. Убрать `newestFirst`
(и его экспорт, если других потребителей нет).
- `findAttachment`: первое вложение с этим именем в порядке `opts.attachments`.
- [ ] **Step 4: Run tests to verify they pass**
Run: `cd web && npx vitest run src/modules/jira/issues/`
Expected: PASS.
- [ ] **Step 5: Commit**
```bash
git add web/src/modules/jira/issues/
git commit -m "Вложения: без прежней ошибки после повтора, 401 — экран входа, порядок хаба (#124, #86 T6.2, T6.3, M-4)"
```
---
### Task 8: Доска, проекты, поиск UI-кита (#35 T4.1/M-15, #64 T9.1, M-6, #68 T3.3) и итоговая проверка
**Files:**
- Modify: `web/src/modules/jira/board/useBoardPrefs.ts` (`toggle`, `toggleHidden` :39-54)
- Modify: `web/src/modules/jira/board/model/fromHub.ts` (`hasSprint` :30)
- Modify: `web/src/projects/ProjectGate.tsx` (:11)
- Modify: `web/src/ui/inputs.tsx` (`SearchInput` :15-24)
- Test: `web/src/modules/jira/board/BoardPage.test.tsx` или новый `useBoardPrefs.test.tsx`, тест модели доски рядом с `fromHub.ts`, `web/src/projects/projects.test.tsx`, `web/src/ui/forms.test.tsx`
**Interfaces:**
- Consumes: `useProjects()` (`refetch`) из `web/src/api/queries.ts`.
- Produces: —
- [ ] **Step 1: Write the failing tests**
- `useBoardPrefs`: `renderHook`; в одном `act` два вызова `toggle(<флаг A>)` и `toggle(<флаг B>)` → оба флага включены
и в состоянии, и в `localStorage`; в одном `act` `toggleHidden("kind", "x")` и `toggleHidden("kind", "y")` → скрыты оба.
- `fromHub`: снимок, где спринт только у эпика (тип «Эпик», `neighbor: false`), у остальных своих задач спринта нет →
`hasSprint === false`.
- `projects.test.tsx`: `GET /projects` → 500, затем 200 → видно «Проекты не загрузились: …» и кнопка «Повторить»;
после нажатия — содержимое страницы проекта.
- `forms.test.tsx`: `SearchInput` со значением, щелчок «Очистить» → `onChange("")` и фокус в поле поиска
(`document.activeElement === getByRole("searchbox")`) — с обёрткой-состоянием, чтобы кнопка исчезла.
- [ ] **Step 2: Run tests to verify they fail**
Run: `cd web && npx vitest run src/modules/jira/board/ src/projects/ src/ui/`
Expected: FAIL.
- [ ] **Step 3: Implement**
- `useBoardPrefs`: `prefsRef` с последним значением; `toggle`/`toggleHidden` считают новое значение из `prefsRef.current`,
синхронно присваивают `prefsRef.current = next`, пишут `st.writeFlag`/`st.writeHidden` и `setPrefs(next)`.
- `fromHub`: `hasSprint = snap.issues.some((it) => !isEpic(it) && !it.neighbor && !!it.sprint)`.
- `ProjectGate`: `action={<Button onClick={retry}>Повторить</Button>}`, где `retry` перезапрашивает `GET /projects`.
- `SearchInput`: `ref` на `input`; «Очистить» — `onChange("")`, затем `ref.current?.focus()`.
- [ ] **Step 4: Run the whole suite**
Run (из корня репозитория): `make check`, затем `cd web && npm run e2e`
Expected: всё зелёное (pytest, lint, vitest, e2e).
- [ ] **Step 5: Commit**
```bash
git add web/src/
git commit -m "Доска, проекты, поиск: два переключения не теряются, спринт эпика, «Повторить», фокус после «Очистить» (#124, #35 T4.1, #64 T9.1, M-6, #68 T3.3)"
```

View File

@ -0,0 +1,2 @@
schema: spec-driven
created: 2026-10-25

View File

@ -0,0 +1,218 @@
## Context
25.10.2026 пять сабагентов сверили открытые follow-up issue хаба и агентов с main (hub 1405ccd). Каждый маркер получил
статус с доказательством `file:line`. Итоги записаны комментариями в issue. Владелец выбрал первым пакет A — видимые
пользователю ошибки веба (sdlc-hub#124). Brainstorming-вопросы владелец пропустил («делай сам… не хочу с ними
сталкиваться в будущем»), поэтому все решения ниже приняты агентом. Они перечислены в «Decisions made without
discussion».
Состав пакета: #42 M-1, M-2, M-4, M-5; #116; #103 С6 и метки `kind-*`; #56 M-4, M-5, M-6; #35 T4.1/M-15; #60 M-3;
#64 M-6, T9.1; #86 T6.2, T6.3, M-4; #68 T3.3. Ссылки на строки ниже — по main 1405ccd.
## Goals / Non-Goals
Цель — убрать перечисленные ошибки. Каждую закрепить тестом, который падает до правки: юнит или поведение компонента в
vitest, для окна карточки дополнительно e2e. Сервер, API, MCP и агенты не меняются.
Не цели:
- прочие маркеры исходных issue: тесты, стиль, доступность UI-кита (#68), квадратичный разбор (#56 M-2), фокус окна
(#42 M-3), `100vh` окна (#42 M-6);
- новые возможности.
## Decisions
### Окно карточки
1. **Поиск списка не закрывает окно (#42 M-1).** Сейчас `IssuesPage.tsx:31-38` через 300 мс после ввода делает
`setParams(..., {replace: true})`. Если за это время открыто окно карточки, запись заменяет адрес окна адресом списка,
и окно закрывается.
Решение — перед открытием карточки со страницы «Задачи» (строка, ключ) несохранённый поиск записывается в адрес сразу,
таймер снимается. Окно открывается поверх списка уже с этим поиском. Отложенная запись MUST NOT срабатывать, пока
открыто окно.
Реализация на усмотрение исполнителя, например:
- `IssuesPage` даёт таблице свою обёртку `open`, которая сначала делает flush;
- или эффект проверяет, что текущий адрес браузера — сам список.
2. **Повторное закрытие не уводит назад дальше (#42 M-2).** `overlay.tsx:37-41`: `close()` при каждом вызове делает
`navigate(-depth)`. Двойной щелчок по ✕ или фону, Esc сразу после щелчка — два шага назад.
Решение — закрытие выполняется один раз для одной записи истории окна. Повторные вызовы игнорируются, пока адрес не
сменился. Признак — `location.key` записи, с которой начато закрытие. Отметка снимается также при «Остаться» (`abortClose`): навигация заблокирована, ключ записи не меняется, и без сброса следующий ✕ молча игнорировался бы (R-15).
3. **Подтверждение ухода с несохранённой заметкой (#42 M-4).** `NoteForm.tsx:82`: блок `role="alertdialog"` без имени,
фокус в него не переводится. Решение:
- блоку — `aria-label` «Уйти без сохранения заметки?»;
- при появлении фокус переходит на «Остаться» (безопасное действие по умолчанию);
- прокрутка к блоку остаётся.
4. **Связанная задача «нет в зеркале» и окно 404 (#42 M-5).**
- В «Связях» (`IssueCard.tsx:123-125`) ключ задачи вне зеркала открывает задачу в Jira в новой вкладке (как «Открыть
в Jira» в шапке) со значком внешней ссылки, а не окно хаба. Пометка «нет в зеркале» остаётся.
- Окно 404 (прямой адрес, ключ из текста, гонка) показывает «Закрыть» (закрывает окно целиком, как ✕). Если окно
открыто переходом из другой карточки (`depth ≥ 1`), добавляется «Назад» — `navigate(-1)` к прежней карточке.
- То же «Назад» — у ошибки загрузки с «Повторить».
### Строка состояния
5. **Ответ опроса прежнего проекта отбрасывается (#116).** `ActivityProvider.tsx`:
- эффект смены проекта (`:75-90`) сбрасывает `activeRef`/`active` и событие;
- эффект опроса (`:126-154`) в том же коммите видит `active` из замыкания старого рендера и снова ставит событие
`batch` (`:139`);
- после этого `active = null`, событие больше никто не снимает.
Решение — эффект опроса обрабатывает ответ, только если отслеживаемый пакет на момент обработки тот же:
`activeRef.current` совпадает с `active` из замыкания (по `id` и `project`). Иначе ответ игнорируется. `finish` по
устаревшему ответу тоже не вызывается.
Тест — детерминированный, без нагрузки: ответ `/batches/{id}` приходит в том же такте, что и смена проекта; после
этого событие модуля `null`. Существующий тест `activity.test.tsx:331` при этом стабилен (прогон в цикле ≥ 20 раз
зелёный).
### Лента и карточка
6. **Текст комментария в ленте без разметки (#103 С6).** Новая чистая функция `wikiPlain(text, opts)` рядом с
разборщиком (`issues/wiki/`) переводит вики-разметку в обычный текст тем же разбором `parseWiki`:
- выделение и `{{моно}}` — содержимое;
- ссылка — её текст (у `[адрес]` — адрес);
- ключ задачи — ключ, `[~логин]` — «@логин», вложение — имя;
- блоки — их текст через пробел, заголовок — его текст, `----` — пусто.
`eventText` для `comment` берёт `wikiPlain`, затем прежние «одна строка» и обрезку до 300 символов с «…». Модуль ленты
импортирует функцию из модуля задач того же модуля jira — граница модулей веба не нарушается. Та же функция
применяется к «Истории» карточки: она рисуется тем же `EventList`.
7. **Метки видов работ отдельно (#103).** В «Прочих полях» (`IssueCard.tsx:227`):
- строка «Вид работы в Jira» — названия видов из `kind_labels`, чьи метки есть у задачи, через запятую, в порядке
меток задачи;
- строка «Метки» — остальные метки.
Строка с пустым значением, как и сейчас, не показывается. Сравнение меток — точное, как у доски (`dicts.ts`).
Служебное поле заметки «Вид работы» (`work_kind`) не трогается. Это заметка хаба, а не метка Jira, и они могут
расходиться — отсюда подпись «в Jira».
8. **Описание из пробелов (#56 M-4).** `IssueCard.tsx:79`: раздел «Описание» показывается, только если
`description.trim()` не пуст.
### Вики-разметка (#56 M-5, M-6)
9. **`\#`.** `#` добавляется в `ESCAPABLE` (`inline.ts:21`). Строка `\# текст` не список (уже так — `LIST` требует `#`
первым символом), а `\#` показывается как `#`.
10. **Макросы с параметрами.** `MACRO` (`parse.ts:17`) принимает параметры у всех трёх тегов:
`\{(code|noformat|quote)(?::[^}]*)?\}`. Закрывающий тег — по имени без параметров, как сейчас.
11. **Пробелы после открывающего тега.** В `closeMacro` (`parse.ts:111`) остаток строки открывающего тега из одних
пробелов в тело блока не попадает. Ведущие пробелы строк внутри блока сохраняются.
12. **`[~логин|текст]`.** В `bracket` (`inline.ts:149-152`) логин — часть до `|`, обрезанная. Получается «@логин»,
подпись не показывается. Пустой логин или логин с пробелом — текст как есть, как сейчас.
13. **Непарная `[` в ячейке таблицы.** В `row` (`parse.ts:127-132`) `[` увеличивает глубину, только если дальше в
строке есть парная `]`. Иначе `[` — обычный символ, и `|` делит ячейки.
### Список задач, доска, проекты
14. **Список ждёт настройки (#60 M-3).** Запрос `GET /issues` (`useIssues`) включается, когда запрос `/settings`
завершён — успехом или ошибкой. Пока нет — «Загрузка…», без «По этим фильтрам задач нет». При ошибке настроек
закрытых статусов нет, запрос уходит с `hide_closed=1`, как сейчас.
15. **Два быстрых переключения фильтра доски (#35 T4.1/M-15).** `useBoardPrefs.ts:39-54`: `toggle` и `toggleHidden`
считают новое значение из `prefs` замыкания, и второй клик в том же батче теряет первый. Решение — последнее значение
держится в `ref`, обновляемом синхронно вместе с `setPrefs`. Новое значение считается из `ref` и тем же значением
пишется в `localStorage`. Чистота updater-функции сохраняется: StrictMode не пишет дважды.
16. **Фильтр спринтов не из-за эпика (#64 T9.1).** `fromHub.ts:30`: `hasSprint` — только по своим задачам, которые
не эпики (`!isEpic(it) && !it.neighbor && !!it.sprint`), то есть по узлам доски.
17. **«Повторить» при ошибке загрузки проектов (#64 M-6).** `ProjectGate.tsx:11`: у сообщения «Проекты не загрузились»
есть кнопка «Повторить», которая перезапрашивает `GET /projects` (`refetch` запроса проектов). Через контекст
проекта или прямо — на усмотрение исполнителя.
### Вложения
18. **Прежняя ошибка не мигает после успешного повтора (#86 T6.2).** После успешного скачивания хук помнит «успех в
этой карточке» для вложения. Строка (`AttachmentList.tsx:36`) не показывает серверный `state = failed`, пока
данные карточки не перечитаны. Отметку снимает приход данных, где `state` вложения уже не `failed`, или новый
`start`.
19. **401 при опросе скачивания — экран входа (#86 T6.3).** В `useAttachmentDownload` (`:74`, `:84`) `AuthError` идёт
в `dropTokenOn(e)`, как в остальном приложении, а не текстом под вложением. `HubDownError` — текст «хаб не отвечает»
под вложением с «Повторить» (требование «Ошибки хаба»).
20. **Порядок вложений — как у хаба (#86 M-4).** Хаб уже отдаёт вложения новыми сверху (`mirror.py:300-301`: по
`created_at` UTC, затем `attachment_id`). Веб не пересортирует (`newestFirst` убирается из `AttachmentList`).
`findAttachment` (`inline.ts:36-45`) при одинаковом имени берёт первое вложение в порядке хаба. Так выбор совпадает
с `attachment_ids` хаба и MCP и не зависит от `Date.parse` строк Jira.
### UI-кит
21. **Фокус после «Очистить» (#68 T3.3).** `SearchInput` (`ui/inputs.tsx:22`): после нажатия «Очистить» фокус
переводится в поле ввода (ref). Иначе кнопка исчезает вместе с фокусом.
## Risks / Trade-offs
- **#116 — гонка в порядке эффектов.** Решение опирается на то, что эффект смены проекта стоит раньше эффекта опроса и
синхронно меняет `activeRef`. Тест должен воспроизводить именно один коммит со сменой проекта и ответом опроса, иначе
он проходит и без правки. Исполнитель проверяет RED.
- **`wikiPlain` и разбор.** Плоский текст строится разбором карточки. Ошибка разбора в ленте даёт исходник — так же
безопасно, как в карточке (рендер блока с ошибкой — исходник).
- **Ссылка «нет в зеркале» в Jira.** Адрес Jira — тот же `JIRA_URL`, что у «Открыть в Jira». Задача может быть и вне
Jira (опечатка в связи) — тогда Jira покажет свою 404, это приемлемо.
- **Окно 404 и «Назад».** `depth` берётся из state окна. При прямом адресе `depth = 0` — только «Закрыть».
## Decisions made without discussion
Все — 25.10.2026: владелец пропустил вопросы brainstorming («делай сам»). Рулинги R-n — с R-1 в пределах фичи.
- R-1 (design #1): при открытии карточки отложенный поиск записывается сразу, окно открывается поверх списка с ним.
Обоснование: пользователь ввёл поиск и видит отфильтрованную строку, на которую щёлкнул, — потерять поиск хуже.
Цена ошибки — вместо этого отбрасывать поиск: правка одной функции.
- R-2 (design #2): повторное закрытие окна игнорируется до смены адреса. Цена ошибки — нет.
- R-3 (design #3): фокус подтверждения ухода — на «Остаться». Обоснование: безопасное действие по умолчанию, данные не
теряются от лишнего Enter. Цена ошибки — перенести фокус на другую кнопку.
- R-4 (design #4): ключ связи «нет в зеркале» открывает Jira в новой вкладке, а не окно хаба с 404. Обоснование: в хабе
про задачу ничего нет, полезное действие — посмотреть её в Jira. Цена ошибки — вернуть окно: одна ветка в `Links`.
- R-5 (design #4): у окна 404 после перехода по связи — «Назад» (на шаг) и «Закрыть» (всё окно). Цена ошибки — убрать
кнопку.
- R-6 (design #5): #116 чинится в провайдере (ответ сверяется с текущим `activeRef`), тест не ослабляется. Цена ошибки
— нет: гипотеза подтверждена чтением кода при сверке.
- R-7 (design #6): лента показывает текст комментария без разметки по разбору карточки; «История» карточки — тоже.
Альтернатива — показывать начало комментария отрендеренным — отвергнута: строка ленты однострочная. Цена ошибки —
правка `eventText`.
- R-8 (design #7): метки видов работ — отдельной строкой «Вид работы в Jira» с названиями видов, из «Меток» убраны.
Альтернатива — просто скрыть. Отвергнута: метка вида — единственный след вида работы в Jira, и он может расходиться
с заметкой. Цена ошибки — скрыть строку.
- R-9 (design #12): `[~логин|текст]` показывается «@логин» без подписи. Jira такой формы не документирует, логин важнее
подписи. Цена ошибки — показывать подпись.
- R-10 (design #13): непарная `[` в строке таблицы — обычный символ. Цена ошибки — нет.
- R-11 (design #14): при ошибке `/settings` список всё равно запрашивается (с `hide_closed=1`, закрытых статусов нет),
а не блокируется. Обоснование: список полезнее пустого экрана, ошибку настроек покажет страница. Цена ошибки — нет.
- R-12 (design #19): `HubDownError` при опросе скачивания остаётся текстом под вложением с «Повторить», отдельного
экрана нет — данные карточки остаются на экране (требование «Ошибки хаба»). Цена ошибки — нет.
- R-13 (design #20): веб берёт порядок вложений у хаба, а при одинаковых именах — первое в этом порядке. Обоснование:
совпадение с `attachment_ids` хаба и MCP. Цена ошибки — вернуть сортировку на клиенте.
- R-14 (proposal): в пакет не взяты #42 M-3 (возврат фокуса после закрытия окна), M-6 (`100vh`), доступность
UI-кита #68 и производительность разбора #56 M-2. Это не ошибки поведения, они остаются в своих issue. Цена ошибки —
следующий пакет.
- R-15 (design #2, 26.10.2026): `closingKey` в `overlay.tsx` сбрасывается и в `abortClose` («Остаться»), а не только при смене
`location.key`. Обоснование: при заблокированной навигации ключ записи не меняется, и без сброса второй Esc/✕ после
«Остаться» молча игнорировался бы (дефект плана, пойман e2e Task 4). Цена ошибки — повторный ✕ при блокере станет
повторно вызывать navigate; безвредно.
- R-16 (design #6): в ленте вложение читается именем без списка вложений карточки — флаг `WikiOptions.anyAttachment`,
выставляется только `wikiPlain`; карточка его не передаёт. Обоснование: финальное ревью I-1, `!скрин.png|thumbnail!` и
`[^отчёт.pdf]` иначе остаются сырой разметкой. Цена ошибки — правка в одном месте.
- R-17: #18 выполнен частично (отметка «успех» не снимается приходом данных) и разбор всего комментария ради 300 символов
в ленте — отложены (M-2, M-6 финального ревью). Обоснование: редкий случай и общая тема #56 M-2. Цена ошибки — редкая
невидимая ошибка вложения в открытой карточке; задержка ленты на очень длинных комментариях.
- R-18 (design #2): ссылка R-15 указывает на решение 2, а не на несуществующее #22; к решению 2 дописан сброс в
`abortClose`. Цена ошибки — нет (документация).
- R-19: N-1 (пере-ревью финальной волны) — в режиме `anyAttachment` ветка `!имя!` принимает любую пару «!», поэтому в ленте
«Срочно! Проверьте! Ок» теряет восклицательные знаки — отложено в follow-up (требовать расширение файла в имени);
второй волны правок нет. Цена ошибки — искажённые «!» в тексте строки ленты, данные не страдают.
- R-20 (design #6, 26.10.2026, решение владельца по R-19): N-1 исправлена до PR, а не отложена. В режиме `anyAttachment`
`!имя!` без списка вложений считается вложением, только если имя — имя файла с расширением без пробелов по краям
(`FILE_NAME` в `inline.ts`); `[^имя]` — явная ссылка на вложение, проверка к ней не применяется. Обоснование: ветка
сама внесла искажение «!» в строке ленты, владелец не хочет таких ошибок. Цена ошибки — `!слово.слово!` в обычном
тексте ленты («Ура!Сделано.ру!») прочитается как имя вложения без «!»; данные не страдают.

View File

@ -0,0 +1,47 @@
## Why
25.10.2026 владелец попросил сверить все открытые follow-up issue хаба и агентов с текущим кодом. В веб-приложении
хаба подтвердились ошибки, которые пользователь может увидеть: окно карточки закрывается само или уводит назад по
истории дальше нужного, лента показывает сырую разметку Jira, после смены проекта в строке состояния остаётся чужое
событие пакета, вики-разметка в отдельных случаях ломает таблицу или остаётся исходником. Владелец с ними пока не
сталкивался, но не хочет столкнуться в будущем. Это пакет A сверки (sdlc-hub#124).
## What Changes
- **Окно карточки** (#42): отложенный поиск списка больше не закрывает только что открытое окно (M-1); повторное
закрытие не уводит дальше назад по истории (M-2); подтверждение «Уйти без сохранения?» получает имя и фокус (M-4);
связанная задача «нет в зеркале» ведёт в Jira, а не в окно 404, у окна 404 после перехода по связи есть «Назад»
(M-5).
- **Строка состояния** (#116): ответ опроса пакета прежнего проекта после смены проекта отбрасывается — событие не
«залипает», тест `activity.test.tsx` перестаёт флейкать.
- **Лента** (#103 С6): текст комментария — без вики-разметки Jira.
- **Карточка** (#103, #56 M-4): метки видов работ (`kind_labels`) не смешиваются с прочими метками — отдельная строка
«Вид работы в Jira»; описание из одних пробелов — раздела нет.
- **Вики-разметка** (#56 M-5, M-6): `\#`, `{quote:…}`/`{noformat:…}` с параметрами, `[~логин|текст]`, пробелы после
`{code}`, непарная `[` в ячейке таблицы.
- **Список задач** (#60 M-3): запрос не уходит, пока не пришли настройки (`closed_statuses`) — нет лишнего запроса и
мелькания «По этим фильтрам задач нет».
- **Доска связей** (#35 T4.1/M-15, #64 T9.1): два быстрых переключения фильтра не теряют первое; фильтр спринтов не
появляется из-за спринта у эпика.
- **Проекты** (#64 M-6): «Повторить» при ошибке загрузки проектов.
- **Вложения** (#86 T6.2, T6.3, M-4): после успешного повтора не мигает прежняя ошибка; 401 при опросе скачивания —
экран входа; порядок вложений и выбор при одинаковых именах — как у хаба.
- **UI-кит** (#68 T3.3): после «Очистить» в поиске фокус остаётся в поле.
## Non-Goals
- Изменения API, схемы, MCP, агентов: всё чинится на клиенте, хаб уже отдаёт нужные данные.
- Прочие маркеры исходных issue (тесты, стиль, доступность UI-кита из #68, производительность разбора вики-разметки
#56 M-2, фокус окна #42 M-3) — остаются в своих issue.
- Правки Jira из веба — веб по-прежнему только для просмотра (служебные заметки — данные хаба).
## Impact
- Спека `web-app`: требования «Список задач», «Карточка задачи», «Служебные заметки», «Разметка Jira в карточке»,
«Проект в приложении», «Лента изменений», «Скачивание вложений в карточке», «Состояние и обновление модулей».
- Код (только `web/src/`): `modules/jira/issues/` (страница, окно, карточка, заметки, вложения, вики-разметка),
`modules/jira/feed/`, `modules/jira/board/`, `shell/activity/ActivityProvider.tsx`, `projects/ProjectGate.tsx`,
`ui/inputs.tsx`.
- Тесты: vitest рядом с кодом; e2e — сценарии окна карточки (`web/e2e/issues.spec.ts`).
- После merge — комментарии в исходных issue (#35, #42, #56, #60, #64, #68, #86, #103) о закрытых маркерах; #116
закрывается.

View File

@ -0,0 +1,519 @@
## MODIFIED Requirements
### Requirement: Список задач
Страница `/app/issues` SHALL показывать задачи текущего проекта из `GET /issues?project=<текущий>` таблицей: ключ
(открывает карточку задачи окном поверх списка) с пометкой «сосед» (относительно проекта), тема, статус Jira,
исполнитель, срок (`planned_end`, иначе `duedate`; просроченный срок незакрытой задачи выделен), спринт, эпик (имя
эпика, иначе ключ), вид работы, тип; статуса по факту в списке нет. Щелчок по строке (кроме ссылок и выделения текста)
SHALL открывать карточку задачи так же, как ключ. Шапка страницы SHALL показывать число показанных задач.
Фильтры SHALL соответствовать параметрам `GET /issues` (спринт, исполнитель, вид работы, статус Jira, метка, эпик,
только соседи, поиск по ключу или теме, скрыть закрытые), храниться в query-строке адреса вместе с `project` и
применяться сервером. На виду SHALL быть поиск, переключатель «Открытые | Все», переключатель «Только соседи» и
кнопка «Фильтры» с числом выбранных выпадающих списков; шесть выпадающих списков (спринт, исполнитель, вид работы,
статус Jira, метка, эпик) — в панели, которая раскрывается этой кнопкой; каждый выбранный список SHALL показываться
снимаемой плашкой «<подпись>: <значение>» и под строкой фильтров должно быть действие «Сбросить», снимающее все шесть
списков. Варианты выпадающих списков SHALL браться из снимка `GET /board?project=<текущий>`; значение фильтра из
адреса, которого нет среди вариантов, SHALL показываться в списке с пометкой «(нет на доске)». Положение «Открытые»
SHALL быть по умолчанию (в адресе ничего), «Все» — `closed=1` в адресе; «Открытые» — запрос с `hide_closed=1`,
кроме случая, когда в фильтре «Статус Jira» выбран статус из `closed_statuses`. Параметр `fact_status` в адресе
игнорируется. Задачи SHALL загружаться порциями по 50 строк; «Показать ещё» SHALL быть доступна, пока последняя
порция полная. Запрос `GET /issues` MUST NOT уходить, пока не завершён запрос настроек хаба (`closed_statuses`): до этого страница показывает «Загрузка…», а не «По этим фильтрам задач нет»; настройки не загрузились — запрос уходит как без закрытых статусов. Поиск SHALL записываться в адрес после паузы ввода; если до записи открыта карточка задачи, поиск SHALL записываться в адрес списка сразу, до открытия окна, и окно поверх списка с этим поиском MUST NOT закрываться отложенной записью.
#### Scenario: Фильтр в адресе
- **WHEN** выбран спринт «AWPCA_Спринт 2» и исполнитель
- **THEN** адрес содержит `project=`, `sprint=` и `assignee=`, таблица — результат `GET /issues` с этими параметрами; открытие этого
адреса в новой вкладке показывает ту же выборку, кнопка «Фильтры» показывает 2, видны плашки спринта и исполнителя
#### Scenario: Снять фильтр плашкой
- **WHEN** выбраны спринт и исполнитель, нажат ✕ на плашке спринта
- **THEN** в адресе нет `sprint=`, `assignee=` остался, таблица перезапрошена
#### Scenario: Конец списка
- **WHEN** вторая порция вернула 17 задач
- **THEN** в таблице 67 задач, «Показать ещё» не показана
#### Scenario: Фильтр по закрытому спринту
- **WHEN** открыт адрес `/app/issues?project=AWPCA&sprint=AWPCA_Спринт 0`, а в снимке доски такого спринта нет
- **THEN** выпадающий список «Спринт» показывает «AWPCA_Спринт 0 (нет на доске)» выбранным, таблица — по этому фильтру
#### Scenario: Карточка поверх списка
- **WHEN** список отфильтрован по спринту, прокручен и догружен «Показать ещё», открыта карточка задачи и окно закрыто
- **THEN** адрес списка с тем же фильтром, в таблице те же строки, прокрутка та же
#### Scenario: Щелчок по строке
- **WHEN** в таблице щёлкнута ячейка темы строки AURORA-1620
- **THEN** адрес `/app/issues/AURORA-1620`, карточка открыта окном поверх списка
#### Scenario: Закрытые скрыты по умолчанию
- **WHEN** открыт `/app/issues`, в зеркале задачи в статусах «В работе», «Готово», «Снято»
- **THEN** выбрано «Открытые», запрос `GET /issues` с `hide_closed=1`, в таблице только «В работе»; после выбора «Все»
адрес содержит `closed=1`, в таблице все три
#### Scenario: Выбран закрытый статус
- **WHEN** выбрано «Открытые» и в «Статус Jira» выбрано «Готово»
- **THEN** запрос `GET /issues?status=Готово` без `hide_closed`, в таблице задачи в статусе «Готово»
#### Scenario: Поиск перед открытием карточки
- **WHEN** в поиске списка введено «свод» и меньше чем через 300 мс щёлкнута строка AURORA-1620
- **THEN** окно карточки AURORA-1620 открыто и остаётся открытым; после его закрытия адрес списка содержит `q=свод`
#### Scenario: Закрытый статус в адресе до настроек
- **WHEN** открыт `/app/jira/issues?project=AWPCA&status=Готово`, ответ `/settings` ещё не пришёл
- **THEN** запросов `GET /issues` нет, показано «Загрузка…»; после ответа `/settings` (где «Готово» в `closed_statuses`) —
один запрос `GET /issues?status=Готово` без `hide_closed`; сообщение «По этим фильтрам задач нет» не появлялось
### Requirement: Карточка задачи
Адрес `/app/issues/{key}` SHALL открывать карточку задачи из `GET /issues/{key}` модальным окном поверх страницы, с
которой её открыли (список «Задачи» или доска); страница под окном SHALL оставаться смонтированной и сохранять фильтры,
загруженные строки, прокрутку и раскладку. Перезагрузка страницы с открытым окном SHALL сохранять страницу под окном. Открытый напрямую
адрес (новая вкладка, закладка) SHALL
показывать окно поверх списка задач без фильтров. Окно SHALL закрываться кнопкой ✕, клавишей Esc и щелчком по фону (нажатие и отпускание — на фоне; выделение текста,
отпущенное за краем окна, окно не закрывает) и
возвращать на страницу, с которой его открыли, даже после переходов по связям внутри окна; «назад» браузера после
закрытия MUST NOT открывать карточку снова. Переход по связанной задаче SHALL менять содержимое окна и адрес, «назад»
браузера — возвращать к прежней карточке. Пока окно открыто, фокус SHALL оставаться в окне, страница под ним не
прокручивается. Повторное закрытие, пока окно закрывается (двойной щелчок по ✕ или фону, Esc сразу после щелчка), MUST NOT уводить дальше назад по истории браузера.
Содержимое окна:
- шапка: ключ, статус Jira, «Открыть в Jira», ✕, тема; строка
«исполнитель · срок · спринт · эпик» без пустых частей, срок — `planned_end`, иначе `duedate`, с пометкой «просрочено»,
если он раньше сегодняшней даты и статус Jira не входит в `closed_statuses`; эпик — темой
(`epic_summary`), иначе ключом;
- «Связи» по группам в порядке: «Зависит от» (входящие связи логических типов `блокирует начало` и `блокирует
завершение` из `link_types`, с меткой «начало» / «завершение»), «Блокирует» (исходящие тех же типов, с теми же метками),
«Связана с» (тип `связанные` в обе стороны), «Прочие» (тип Jira вне `link_types`: имя типа и направление «→» / «←»);
в строке — ключ (открывает связанную задачу в окне), тема и статус Jira, а если задачи нет в зеркале — ключ ссылкой на задачу в Jira (новая вкладка, значок внешней ссылки) и «нет в
зеркале»; окно хаба такая ссылка не открывает; пустые группы не показываются, без связей — «связей нет»;
- «Описание» — текст, отрендеренный по требованию «Разметка Jira в карточке», с кнопкой «исходник» / «текст» в
заголовке раздела; длиннее 8 строк — свёрнуто с кнопкой «развернуть» / «свернуть» в обоих режимах; без описания
(пустое или из одних пробелов и переводов строк) раздела нет;
- «Вложения (N)» — новые сверху: значок по типу файла (картинка, таблица, PDF, прочее), имя, размер, «автор · дата» и
кнопка скачивания (требование «Скачивание вложений в карточке»); без вложений раздела нет;
- «Комментарии (N)» — новые сверху, автор, время, кнопка «исходник» / «текст» у каждого комментария, текст,
отрендеренный по требованию «Разметка Jira в карточке», под текстом — вложения, на которые ссылается комментарий
(`attachment_ids`): строка «плашек» с именем и размером, щелчок — скачивание; кнопка «Скрыть» / «Показать» сворачивает раздел, выбор SHALL
запоминаться в браузере для всех карточек; без комментариев — «комментариев нет»;
- «Служебные заметки» (требование «Служебные заметки»);
- «Прочие поля» — непустые из: тип, приоритет, автор, «Вид работы в Jira» (названия видов из `kind_labels`, метки которых
есть у задачи, через запятую), метки (кроме меток видов из `kind_labels`), плановое начало, обновлена в Jira, сосед
по связям;
- «Команды моста (N)» — 10 последних команд по задаче (id, пакет, тип, статус, время завершения, ошибка), раздел свёрнут
при открытии; если команд не было — текст «команд по задаче не было».
На ширине от 900 px «Связи», «Описание», «Вложения», «Комментарии» — в левой колонке, заметки, прочие поля и команды — в правой;
уже — одна колонка: заметки, связи, описание, вложения, комментарии, прочие поля, команды. Если задачи нет в зеркале (404), окно
SHALL сообщать об этом с кнопкой «Закрыть» (закрывает окно целиком, как ✕); если окно открыто переходом из другой карточки — ещё и с кнопкой «Назад», возвращающей к ней (так же у ошибки загрузки); хаб не отвечает — сообщение и «Повторить», ранее загруженные данные
остаются.
#### Scenario: Связанная задача
- **WHEN** у AURORA-1620 исходящая связь `ASUPR_Блокер_Начала` на AURORA-1621 «Расчёт свода» в статусе «В работе»
- **THEN** в карточке в группе «Блокирует» строка «начало · AURORA-1621 · Расчёт свода · В работе»; щелчок по ключу
меняет содержимое окна на AURORA-1621, адрес — `/app/issues/AURORA-1621`; «назад» браузера возвращает AURORA-1620
#### Scenario: Входящий блокер
- **WHEN** у AURORA-1620 входящая связь `ASUPR_Блокер_Завершения` от AURORA-1500
- **THEN** строка с AURORA-1500 и меткой «завершение» — в группе «Зависит от»
#### Scenario: Задача без команд
- **WHEN** по AURORA-1620 не было команд
- **THEN** в разделе «Команды моста» — текст «команд по задаче не было», таблицы нет
#### Scenario: Закрытие после переходов
- **WHEN** со списка «Задачи» открыта AURORA-1620, в окне — переход по связи на AURORA-1621, нажата Esc
- **THEN** окно закрыто, адрес — адрес списка с прежними фильтрами; «назад» браузера не открывает карточку
#### Scenario: Прямой адрес
- **WHEN** в новой вкладке открыт `/app/issues/AURORA-1620`, затем нажат ✕
- **THEN** под окном был список задач без фильтров; после закрытия адрес `/app/issues`, «назад» не открывает карточку
#### Scenario: Скрытые комментарии
- **WHEN** в карточке нажато «Скрыть» у комментариев, затем страница перезагружена и открыта другая карточка
- **THEN** комментарии в ней скрыты, видна кнопка «Показать» и число комментариев
#### Scenario: Просрочка закрытой задачи
- **WHEN** у AURORA-1620 срок 2026-10-01, сегодня 2026-10-17, статус Jira «Готово»
- **THEN** в строке под темой «срок 01.10» без пометки «просрочено»; при статусе «В работе» — с пометкой
#### Scenario: Двойной щелчок по закрытию
- **WHEN** со списка «Задачи» открыта AURORA-1620, в окне — переход по связи на AURORA-1621, по ✕ щёлкнули дважды подряд
- **THEN** окно закрыто, адрес — адрес списка; запись истории перед списком не открыта
#### Scenario: Связанная задача не в зеркале
- **WHEN** у AURORA-1620 связь на AURORA-1999, которой нет в зеркале
- **THEN** в «Связях» строка «AURORA-1999 · нет в зеркале»; ключ — ссылка `<адрес Jira>/browse/AURORA-1999` в новой
вкладке, окно карточки остаётся на AURORA-1620
#### Scenario: Задача не в зеркале после перехода
- **WHEN** в окне AURORA-1620 щёлкнут ключ AURORA-1999 в тексте комментария, хаб ответил 404
- **THEN** окно сообщает «Задачи AURORA-1999 нет в зеркале хаба» с кнопками «Назад» и «Закрыть»; «Назад» возвращает
окно к AURORA-1620, «Закрыть» закрывает окно и возвращает на страницу под ним
#### Scenario: Метки вида работы
- **WHEN** у задачи метки `AWPCA` и `kind-dev`, в `kind_labels` есть «разработка» → `kind-dev`
- **THEN** в «Прочих полях» строки «Вид работы в Jira: разработка» и «Метки: AWPCA»
#### Scenario: Описание из пробелов
- **WHEN** описание задачи — три пробела и два перевода строки
- **THEN** раздела «Описание» нет
### Requirement: Служебные заметки
Карточка задачи SHALL показывать служебные заметки в режиме чтения: «Вид работы», «Сделано»,
«Осталось», «Заметка владельца» (пустые — «—») и время и автор последнего изменения; кнопка «Править» SHALL открывать
форму: «вид работы» — выбор из значений `kind_labels` настроек (и текущего значения, если его там нет, и пустого),
«сделано», «осталось», «заметка владельца» —
многострочный текст; «Отмена» SHALL возвращать режим чтения, при несохранённых правках — после подтверждения.
Сохранение SHALL отправлять `PUT /issues/{key}/note` только с изменёнными полями и `updated_by: "web"`; очищенное поле
отправляется как `null`. После успеха SHALL показываться режим чтения с сохранённой заметкой; список задач под окном SHALL
показывать новый «вид работы» без «Перечитать», доска под окном SHALL перечитать снимок
(`GET /board`) без потери раскладки и прокрутки; при 422 — текст ошибки у формы без
потери введённого. Закрытие окна, переход по связи и уход в другой раздел с несохранёнными правками SHALL требовать
подтверждения; блок подтверждения — с доступным именем «Уйти без сохранения заметки?», при появлении фокус 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` «тестирование»); окно закрывается без потери раскладки и прокрутки доски
### Requirement: Разметка Jira в карточке
Описание задачи и текст каждого комментария в карточке SHALL показываться отрендеренной вики-разметкой Jira (хаб
хранит их как есть, в wiki) так, как их показывает Jira. Разбираются:
- блоки: заголовки `h1.`–`h6.` в начале строки; списки — строки, начинающиеся (после необязательных пробелов) с
маркеров `*`, `#` и пробела (уровень вложенности — числом маркеров, `*` — маркированный, `#` — нумерованный,
смешанная вложенность `*#` — по последнему маркеру) или с одного `-` и пробела (маркированный, первый уровень);
`--`, `*-` и прочие сочетания с `-` — не список; таблицы — строки, начинающиеся с
`|`, ячейки `||заголовок||` и `|ячейка|` (`|` внутри `[текст|адрес]` ячейку не делит; `[` без парной `]` дальше в строке —
обычный символ и на деление ячеек не влияет); `{code}` / `{code:параметры}` … `{code}` и `{noformat}` / `{noformat:параметры}` … `{noformat}` —
моноширинный блок без разбора разметки внутри; `{quote}` / `{quote:параметры}` … `{quote}` и строка `bq. ` — цитата; открывающий тег
блока — в начале строки, закрывающий — в любом месте той же или последующей строки, текст после закрывающего тега
разбирается дальше как обычные строки; пробелы после открывающего тега в той же строке в блок не попадают; тег не в начале строки — текст как есть; строка `----` —
горизонтальная черта; прочие строки — абзацы, разделённые пустыми строками, одиночный перенос внутри абзаца —
перенос строки;
- внутри строки: `*жирный*`, `_курсив_`, `-зачёркнутый-`, `+подчёркнутый+`, `^верхний^`, `~нижний~`, `{{моно}}`
(`{{…}}` — без разбора внутри); маркер выделения действует, только если открывающий стоит в начале текста или после
пробела либо знака препинания и за ним не пробел и не тот же символ (`**x**` — текст как есть), а закрывающий стоит после не-пробела и за ним не буква и не
цифра (`data_type`, `2026-10-15`, `счёт-фактура`, `a*b*c` — обычный текст); `\\` — перенос строки; `\` перед
символом разметки (в том числе `#`: `\#` — символ `#`) — сам символ;
- ссылки: `[текст|адрес]`, `[адрес]` и голые адреса `http://`, `https://` — ссылка в новой вкладке, только для схем
`http`, `https`, `mailto`, иначе текст как есть; `[ключ задачи]`, `[текст|ключ задачи]` и ключ задачи в тексте вида
`ПРОЕКТ-123`, где `ПРОЕКТ` — `project_key` из настроек хаба, — ссылка, открывающая карточку этой задачи в окне
(`UTF-8`, `SHA-256` и ключи других проектов — текст);
`[~логин]` и `[~логин|текст]` — «@логин»;
- ссылки на вложения задачи — `!имя!`, `!имя|параметры!`, `[^имя]`, `[текст|^имя]` с тем же разбором имени, что у хаба
(`hub-api`, «Ссылки на вложения в тексте»), имя есть среди вложений задачи: ссылка-кнопка со значком скрепки и текстом
— именем файла (у `[текст|^имя]` — текстом), щелчок — скачивание; картинка в тексте не показывается; имени нет среди
вложений — исходник как есть.
Всё, что разборщик не распознал (`{panel}`, `{color}`, `!картинка!` и `[^имя]` без такого вложения у задачи, незакрытые маркеры и блоки и прочее), SHALL
показываться как текст исходника без потерь. Разметка MUST NOT превращаться в HTML строкой (`innerHTML`,
`dangerouslySetInnerHTML`): текст задачи не может добавить на страницу элементы, атрибуты или скрипты. Если рендер
блока завершился ошибкой, блок SHALL показываться исходником.
Кнопка «исходник» у описания и у каждого комментария SHALL переключать этот блок на исходник — текст с сохранёнными
переносами строк, как без разбора разметки, — и обратно кнопкой «текст»; остальные блоки не переключаются. При каждом
открытии карточки (в том числе при переходе по связи) все блоки показываются отрендеренными, выбор не запоминается.
#### Scenario: Описание со структурой
- **WHEN** описание AURORA-1620 — `h3. Контекст`, строка `Шаги:`, затем `# Загрузить ОСВ` и `# Открыть документ`,
затем пустая строка и `*Итог:* ошибка в {{data_type}}`
- **THEN** в «Описании» заголовок «Контекст», абзац «Шаги:», нумерованный список из двух пунктов и абзац, где «Итог:» —
жирным, «data_type» — моноширинным; символов `h3.`, `#`, `*`, `{{` не видно
#### Scenario: Переключение на исходник
- **WHEN** в карточке AURORA-1620 у описания нажато «исходник»
- **THEN** описание показано исходником с переносами строк (видны `h3. Контекст` и `# Загрузить ОСВ`), кнопка — «текст»;
комментарии остаются отрендеренными; после «текст» описание снова отрендерено
#### Scenario: Исходник у одного комментария
- **WHEN** у второго комментария нажато «исходник», затем открыта связанная задача и возврат «назад»
- **THEN** сразу после нажатия исходником показан только второй комментарий; после возврата все комментарии
отрендерены
#### Scenario: Ключ задачи в тексте
- **WHEN** комментарий — `П. 1 ведётся в AURORA-1445; AURORA-1645 снята как дубль.`
- **THEN** «AURORA-1445» и «AURORA-1645» — ссылки; щелчок по первой меняет содержимое окна на AURORA-1445
#### Scenario: Неизвестная разметка и опасный адрес
- **WHEN** описание — `{color:red}важно{color}`, `!схема.png!` и `[нажми|javascript:alert(1)]`
- **THEN** все три показаны текстом исходника как есть, ссылки и картинки нет, скрипт не выполняется
#### Scenario: Списки с отступом и пустыми строками
- **WHEN** описание — `Шаги воспроизведения:`, ` * Загрузить ОСВ.`, пустая строка, ` * Дождаться обработки.`
- **THEN** абзац «Шаги воспроизведения:» и маркированные пункты «Загрузить ОСВ.» и «Дождаться обработки.» без
звёздочек
#### Scenario: Обычный текст не портится
- **WHEN** описание — `Поле data_type = null, дата 2026-10-15, оборот 18 803 903,65 - 9 351 102,89, a*b*c, UTF-8,
**важно**`
- **THEN** текст показан как есть, без курсива, зачёркивания, жирного и ссылок
#### Scenario: Скриншот в тексте комментария
- **WHEN** комментарий — «см. !скрин 1.png|thumbnail!», у задачи есть вложение «скрин 1.png»
- **THEN** вместо `!скрин 1.png|thumbnail!` — кнопка со скрепкой «скрин 1.png»; под комментарием плашка «скрин 1.png ·
234,4 КБ»; щелчок по любой из них скачивает файл
#### Scenario: Экранированная решётка и цитата с параметрами
- **WHEN** описание — `\# не пункт`, затем `{quote:title=Итог}`, `согласовано`, `{quote}`
- **THEN** абзац «# не пункт» (не нумерованный список) и цитата «согласовано»; тегов `{quote` не видно
#### Scenario: Таблица с непарной скобкой
- **WHEN** описание — `|[draft|итог|готово|`
- **THEN** таблица из одной строки с ячейками «[draft», «итог», «готово»
#### Scenario: Упоминание с подписью и код с пробелами
- **WHEN** комментарий — `[~ivanov|Иванов]`, затем строка `{code} `, `x = 1`, `{code}`
- **THEN** «@ivanov» и моноширинный блок из одной строки `x = 1` без пустой строки перед ней
### Requirement: Проект в приложении
«Обзор», доска, список задач, лента и вики SHALL показывать один проект — текущий. Текущий проект SHALL задаваться
параметром `project` в адресе страниц; без параметра — последний выбранный в этом браузере (`localStorage`), если он
есть в `GET /projects`, иначе первый из `GET /projects`; выбранный проект записывается в адрес без новой записи истории.
Проекта из адреса нет в `GET /projects` — страница SHALL показывать «проект <метка> не найден» и подсказку выбрать
проект в списке «Проект» боковой панели (на узком экране — в «Меню») вместо данных. Проектов нет — страницы SHALL
показывать приглашение создать проект («+ Новый проект…»). Список проектов не загрузился — сообщение с ошибкой и кнопкой «Повторить», перезапрашивающей `GET /projects`. Переходы между страницами SHALL сохранять текущий проект;
смена проекта на странице, которой нет у нового проекта, открывает «Обзор»; исключение — «Бэклог», «Спринт», «Kanban»:
адрес остаётся, страница показывает «У проекта нет …-доски» (требование «Спринты и бэклог»). Карточка задачи
(`/app/jira/issues/{key}`) от проекта не зависит.
Доска SHALL загружать `GET /board?project=<текущий>`. Раскладка, дорожки и фильтры доски в `localStorage` SHALL
храниться отдельно для каждого проекта; у проекта `AWPCA` — на прежних ключах (раскладка, сделанная до проектов,
сохраняется). Фильтр спринтов SHALL показываться, только если хотя бы у одной своей задачи снимка (`neighbor = false`), не
эпика, есть спринт; иначе — «колонки «Спринт» нет — показаны все задачи».
Форма «Новый проект» (пункт «+ Новый проект…» выпадающего списка) SHALL открываться окном поверх страницы: метка (с
подсказкой «метка задач в Jira и имя проекта в хабе») и название, затем поля каждого модуля в своей группе с
заголовком — названием модуля: «Jira» — номер доски Jira (необязательно; подсказка «scrum или kanban определится
сам»), «Вики» — корень вики (необязательно; подсказка «DOC-… или ссылка на страницу Eva»). Подсказка — текстом под
полем, не в placeholder. Пустое необязательное поле в запрос не попадает; числовое поле с нецелым значением — ошибка у
поля, запрос не уходит. «Создать» — `POST /projects` с автором `viewer`; ошибка хаба 409/422 — текст `detail` рядом с
кнопкой, окно остаётся открытым; успех — окно закрывается, текущим становится новый проект, каждый пакет из ответа
(`batches`) отслеживается своим модулем (требование «Состояние и обновление модулей»).
#### Scenario: Первый вход после выкатки
- **WHEN** открыт `/app/jira/links` без `project`, в `localStorage` выбора нет, проекты `AWPCA` и `STOCKS_DRRK`
- **THEN** адрес — `/app/jira/links?project=AWPCA`, доска — `GET /board?project=AWPCA` с прежней раскладкой карточек
#### Scenario: Переключение проекта
- **WHEN** на доске `AWPCA` в выпадающем списке выбран `STOCKS_DRRK`, затем открыт раздел «Задачи»
- **THEN** доска — `GET /board?project=STOCKS_DRRK` со своей раскладкой; адрес «Задач» —
`/app/jira/issues?project=STOCKS_DRRK`
#### Scenario: Неизвестный проект в адресе
- **WHEN** открыт `/app/jira/issues?project=NOPE`
- **THEN** «проект NOPE не найден» и подсказка выбрать проект в боковой панели; запроса `GET /issues` нет
#### Scenario: Создание проекта из формы
- **WHEN** в форме введены `STOCKS_DRRK`, «Запасы ДРРК», доска и корень вики пусто; нажато «Создать»
- **THEN** `POST /projects {label: "STOCKS_DRRK", name: "Запасы ДРРК", author: "viewer"}`; текущий проект — `STOCKS_DRRK`;
строка «Jira» блока состояния показывает «пакет #N выполняется»
#### Scenario: Проект с корнем вики из формы
- **WHEN** в форме введены `STOCKS_DRRK`, «Запасы ДРРК», корень `DOC-001234`; нажато «Создать»
- **THEN** `POST /projects {label: "STOCKS_DRRK", name: "Запасы ДРРК", wiki_root: "DOC-001234", author: "viewer"}`; строка
«Jira» показывает пакет обновления, строка «Вики» — пакет синхронизации
#### Scenario: Метка занята
- **WHEN** в форме введена метка `awpca`, хаб ответил 409
- **THEN** окно открыто, рядом с «Создать» — текст ошибки хаба
#### Scenario: Канбан-проект
- **WHEN** текущий проект — `STOCKS_DRRK`, ни у одной своей задачи снимка нет спринта (у соседа из `AWPCA` спринт есть)
- **THEN** вместо фильтра спринтов — «колонки «Спринт» нет — показаны все задачи»
#### Scenario: Нецелый номер доски
- **WHEN** в поле «Номер доски Jira» введено `51.5`, нажато «Создать»
- **THEN** у поля — «нужно целое число», запроса `POST /projects` нет
#### Scenario: Спринт только у эпика
- **WHEN** у эпика AURORA-9100 проекта спринт «AWPCA_Спринт 2», у остальных своих задач спринта нет
- **THEN** вместо фильтра спринтов — «колонки «Спринт» нет — показаны все задачи»
#### Scenario: Проекты не загрузились
- **WHEN** `GET /projects` ответил 500, затем нажато «Повторить», хаб ответил списком проектов
- **THEN** сначала «Проекты не загрузились: …» с кнопкой «Повторить»; после нажатия — страница текущего проекта
### Requirement: Лента изменений
Страница `/app/feed?project=<метка>` SHALL показывать события журнала изменений своих задач текущего проекта за период
(`GET /changes` с `project`, `since`, `until`, `kinds`). Период SHALL выбираться переключателем «2 часа» / «Сегодня» /
«Вчера» / «7 дней» (по умолчанию «Сегодня»; границы — по местному времени браузера: «Сегодня» — с начала текущих суток
до сейчас, «Вчера» — с начала прошлых суток до начала текущих) и храниться в адресе (`period=2h|today|yesterday|7d`;
«Сегодня» в адрес не пишется). Виды событий SHALL выбираться флажками «Статусы», «Описание», «Сроки», «Комментарии»,
«Прочие поля» (по умолчанию все, кроме «Прочие поля») и храниться в адресе (`kinds=` через запятую; набор по умолчанию в
адрес не пишется; в адресе нет ни одного известного вида — набор по умолчанию); снятые все флажки — сообщение «выберите
хотя бы один вид», запроса нет.
События SHALL быть сгруппированы по задаче; задачи — по времени последнего события, новые сверху. У группы: ключ —
ссылка на карточку задачи (окно поверх ленты, требование «Карточка задачи»), тема и текущий статус; под ней события по
времени, новые сверху, каждое — время (`ЧЧ:ММ`, с датой `ДД.ММ`, если событие не в текущих сутках), автор и текст:
статус — «статус «было» → «стало»»; описание — «описание изменено»; срок, плановое начало и окончание — «срок ДД.ММ →
ДД.ММ» (дата по значению `from_value`/`to_value`, иначе строка журнала; пустое — «—»); комментарий — «комментарий:» и
первые 300 символов текста без вики-разметки Jira (так, как текст читается отрендеренным: без `*`, `{code}`, `h3.`; у `[текст|адрес]` — текст, `[~логин]` — «@логин») одной строкой с «…»; прочие поля — «<название поля>: «было» → «стало»» (значения до 80
символов с «…»), где название — подпись из словаря приложения (исполнитель, спринт, метки, приоритет, тема, эпик, связь,
вложение, резолюция), иначе имя поля Jira. Подзаголовок страницы — число событий и границы периода. Событий нет —
сообщение «за период изменений нет»; `truncated` — сообщение «показаны последние N событий — выберите период короче».
Ошибка хаба — сообщение с кнопкой «Повторить». Ленту SHALL перечитывать завершение «Обновить из Jira» (вместе со списками
задач) и возврат на вкладку браузера.
#### Scenario: Что закрылось за два часа
- **WHEN** открыта лента проекта AWPCA, выбран период «2 часа», сняты все флажки, кроме «Статусы»
- **THEN** адрес — `/app/feed?project=AWPCA&period=2h&kinds=status`; запрос `GET /changes?project=AWPCA&since=<сейчас − 2 ч>&kinds=status`;
задачи сгруппированы, у каждой — события «ЧЧ:ММ автор: статус «В работе» → «Готово»»
#### Scenario: Карточка из ленты
- **WHEN** в ленте нажат ключ AURORA-9002
- **THEN** поверх ленты открыто окно карточки AURORA-9002; закрытие окна возвращает ленту с тем же периодом
#### Scenario: Пустой период
- **WHEN** за выбранный период событий нет
- **THEN** показано «за период изменений нет»
#### Scenario: Вчера
- **WHEN** выбран период «Вчера», сейчас 22.10 14:05 местного времени
- **THEN** запрос с `since` = 21.10 00:00 и `until` = 22.10 00:00 местного времени
#### Scenario: Комментарий с разметкой
- **WHEN** в ленте событие комментария с текстом `h3. Итог` и `*Готово*, см. [протокол|https://x.ru/p] и [~ivanov]`
- **THEN** строка события — «комментарий: Итог Готово, см. протокол и @ivanov»
### Requirement: Скачивание вложений в карточке
Кнопка скачивания вложения (в разделе «Вложения», плашка под комментарием, ссылка в тексте) SHALL работать одинаково:
1. `POST /attachments/{id}/fetch {author: "web"}`;
2. `state = ready` — `GET /attachments/{id}/content` с клиентским токеном, браузер сохраняет файл под именем `filename`
(через `Blob` и временную ссылку `download`; адрес с токеном в строке браузера MUST NOT появляться);
3. `state = fetching` — у всех кнопок этого вложения в карточке вместо «Скачать» — «Загружается…» (неактивна), опрос
`GET /attachments/{id}` раз в 2 с, пока окно открыто на этой задаче; `ready` — шаг 2 сам, без второго нажатия; закрыли
окно или ушли на другую задачу — опрос прекращается, команда остаётся в очереди (файл ляжет в хаб);
4. `failed` — под вложением в разделе «Вложения» текст ошибки и кнопка «Повторить» (новый запрос); 409 `agent_too_old`
и прочие ошибки хаба — их текст там же; хаб не отвечает — «хаб не отвечает» там же; 401 на любом шаге — экран входа.
После успешного повтора прежняя ошибка `failed` из данных карточки MUST NOT показываться.
Вложения в разделе SHALL идти в порядке ответа хаба (новые сверху); ссылка в тексте на имя, которое носят несколько
вложений, SHALL вести к первому из них в этом порядке (как `attachment_ids` хаба).
Вложение с `state = ready` в разделе «Вложения» помечается «у хаба» и скачивается сразу. Размер неизвестен — «размер неизвестен» и обычная кнопка.
Вложение больше 10 МБ —
вместо кнопки ссылка «Открыть в Jira» (`<адрес Jira>/secure/attachment/<id>/<имя в URL-кодировке>`, новая вкладка) с
подсказкой «больше 10 МБ — только в Jira»; плашка и ссылка в тексте ведут туда же.
#### Scenario: Скачать скриншот
- **WHEN** в карточке AURORA-1650 нажато «Скачать» у «скрин 1.png», файла у хаба нет, агент онлайн
- **THEN** кнопка — «Загружается…», через несколько секунд браузер сохраняет «скрин 1.png», кнопка снова «Скачать», у
вложения отметка «у хаба»
#### Scenario: Окно закрыли во время скачивания
- **WHEN** нажато «Скачать», затем окно карточки закрыто
- **THEN** опросов `GET /attachments/{id}` больше нет; при следующем открытии карточки, если файл уже у хаба, вложение с
отметкой «у хаба»
#### Scenario: Большой файл
- **WHEN** у вложения «запись.mp4» размер 25 МБ
- **THEN** вместо «Скачать» — ссылка «Открыть в Jira» с подсказкой «больше 10 МБ — только в Jira»
#### Scenario: Агент старой версии
- **WHEN** нажато «Скачать», хаб ответил 409 «агент v13 не умеет скачивать вложения — нужен v14»
- **THEN** под вложением этот текст и «Повторить»
#### Scenario: Повтор после ошибки
- **WHEN** у вложения «скрин 1.png» в данных карточки `state = failed`, нажато «Повторить», файл скачан
- **THEN** браузер сохранил файл, текста ошибки под вложением нет — и до, и после перечитывания карточки
#### Scenario: Токен отозван во время скачивания
- **WHEN** нажато «Скачать», во время опроса `GET /attachments/{id}` хаб ответил 401
- **THEN** приложение показывает экран входа
### Requirement: Состояние и обновление модулей
Каждый веб-модуль SHALL описывать своё состояние данными, а рисовать их SHALL ядро. Описание модуля: название для
показа («Jira», «Вики»); агент (имя модуля в `GET /agents`) или его отсутствие; свежесть данных для текущего проекта —
время, охват («все проекты» или «проект») и ошибка последнего обновления; действие обновления (название, охват,
постановка пакета) или его отсутствие; правило, какие пакеты принадлежат модулю; данные, которые надо перечитать после
пакета модуля; есть ли у модуля данные для проекта (у вики без корня — нет).
Ядро SHALL:
- опрашивать `GET /agents` при входе и раз в 60 с — один опрос на приложение; агент модуля: «на связи», «офлайн» или
«обновите агента» (есть `warning` или `update_hint`, текст — в подсказке);
- вести один трекер пакетов на приложение: на модуль — не больше одного отслеживаемого пакета; пакет подхватывается при
входе, раз в 60 с и после действия обновления, если он не завершён, принадлежит модулю (поле `module` в `GET
/batches`) и текущему проекту (для модулей с охватом «проект») — любого автора, в том числе автообновления хаба и
MCP; опрос `GET /batches/{id}` каждые 3 с до терминального статуса; 4xx (404 — «пакета нет в хабе») и 5 ошибок «хаб
не отвечает» подряд — опрос остановлен с сообщением; по завершении — перечитать данные модуля и список проектов; при смене проекта отслеживаемый пакет модуля с
охватом «проект» сбрасывается вместе с событием, и ответ опроса этого пакета, пришедший после смены, MUST NOT
возвращать событие;
- ответ 409 на постановку пакета — отслеживать уже идущий пакет модуля, без сообщения об ошибке;
- агент модуля офлайн при обновлении — пакет всё равно ставится, событие «агент офлайн — пакет выполнится, когда он
выйдет на связь; пакет #N в очереди…».
Блок состояния в развёрнутой боковой панели — по строке на модуль, у которого есть данные для проекта: название модуля,
точка агента (цвет и текст «агент на связи» / «агент офлайн» / «обновите агента» — не только цвет), кнопка-значок
обновления с подписью действия в подсказке и `aria-label` («Обновить из Jira — все проекты», «Обновить вики — проект
AWPCA»); под ними — «данные на ДД.ММ ЧЧ:ММ» с охватом («все проекты» / «проект»), «обновления ещё не было» или ошибка
последнего обновления; под ними — одно текущее событие пакета модуля: в очереди / выполняется (с оставшимся временем,
если хаб его дал, и пометками «агент офлайн», «хаб не отвечает, повтор»), выполнен (исчезает через 10 с), не выполнен
с текстом ошибки, отменён, опрос остановлен с причиной. Кнопка обновления модуля недоступна (с признаком загрузки), пока
запрос постановки в пути или выполняется пакет действия обновления модуля (у jira — пакет с первой командой `refresh`, у
wiki — `wiki_sync`); прочие пакеты модуля показываются, но обновление не блокируют: нажатие ставит пакет обновления, и
трекер переключается на него. Свёрнутая панель — по значку модуля с точкой агента, подробности в
подсказке, нажатие — обновление. На узком экране — сводная точка в верхней строке (требование «Оболочка и адреса»),
событие пакета — строкой под верхней строкой.
Модуль jira: агент `jira`; свежесть — `jira_synced_at` из `GET /projects`, охват «все проекты»; действие «Обновить из
Jira» — `POST /refresh {full: true, author: "viewer"}`; после пакета перечитываются снимки досок, списки задач, лента и
открытая карточка; доска, показывающая снимок из файла, его не заменяет. Модуль wiki: агент `wiki`; свежесть —
`wiki_synced_at`, `wiki_sync_error` из `GET /projects`, охват «проект»; данные для проекта есть, только если есть
`wiki_root`; действие «Обновить вики» — `POST /wiki/sync {project, author: "viewer"}`; после пакета перечитываются дерево,
страницы и поиск. Блок состояния MUST NOT загружать снимок доски или `GET /wiki/status`.
Страница модуля с действием обновления SHALL дублировать его вторичной кнопкой в шапке страницы; основной (primary)
кнопки обновления в приложении нет.
#### Scenario: Обновление со списка задач
- **WHEN** открыт `/app/jira/issues`, нажато «Обновить из Jira» в строке «Jira», пакет выполнен
- **THEN** в хабе один пакет `refresh` автора `viewer`; список задач перезапрошен; строка «Jira» показывала «пакет #N
выполняется», затем «пакет #N выполнен»; строка «Вики» не изменилась
#### Scenario: Один прогон на модуль
- **WHEN** нажата «Обновить из Jira» и, пока пакет выполняется, выполнен переход между разделами и нажата «Обновить из
Jira» в шапке доски
- **THEN** кнопки «Обновить из Jira» недоступны, в хабе один пакет, его опрашивает один опрос
#### Scenario: Опрос получил 404
- **WHEN** опрос пакета вики получил 404
- **THEN** опрос остановлен, строка «Вики» показывает «пакета нет в хабе», «Обновить вики» снова доступна
#### Scenario: Автообновление хаба
- **WHEN** хаб поставил пакет «Автообновление» (автор `hub`, модуль `jira`), приложение открыто на странице «Вики»
- **THEN** не позже чем через 60 с строка «Jira» показывает «пакет #N выполняется», по завершении — новое «данные на …»
#### Scenario: Пакет другого проекта
- **WHEN** идёт пакет `wiki_sync` проекта `STOCKS_DRRK`, текущий проект — `AWPCA`
- **THEN** строка «Вики» его не показывает, «Обновить вики» доступна
#### Scenario: Пакет с другими командами
- **WHEN** незавершённый пакет модуля jira автора `viewer` состоит из команд `ensure_fields`
- **THEN** строка «Jira» показывает «пакет #N выполняется», кнопка «Обновить из Jira» доступна; её нажатие ставит пакет
`refresh`, и строка показывает его
#### Scenario: Агент офлайн
- **WHEN** агент `wiki` офлайн и нажато «Обновить вики»
- **THEN** пакет поставлен; строка «Вики» — точка с текстом «агент офлайн» и событие «агент офлайн — пакет выполнится,
когда он выйдет на связь; пакет #N в очереди…»
#### Scenario: Синхронизация уже идёт
- **WHEN** нажато «Обновить вики», хаб ответил 409
- **THEN** строка «Вики» показывает идущий пакет, сообщения об ошибке нет
#### Scenario: Проект без корня вики
- **WHEN** текущий проект `sfm_kedr` без корня вики
- **THEN** в блоке состояния только строка «Jira»; на свёрнутой панели — один значок состояния
#### Scenario: Агенту нужно обновление
- **WHEN** `GET /agents` отдаёт агента `jira` с `warning`
- **THEN** строка «Jira» — точка с текстом «обновите агента», текст `warning` в подсказке
#### Scenario: Смена проекта во время опроса
- **WHEN** строка «Вики» показывает пакет `wiki_sync` проекта `AWPCA`, текущий проект сменён на `STOCKS_DRRK`, и в тот же
момент пришёл ответ опроса этого пакета
- **THEN** строка «Вики» без события пакета; опроса этого пакета больше нет

View File

@ -207,3 +207,16 @@ test("закрытые скрыты по умолчанию, «Все» пока
await expect(page.getByRole("link", { name: "AURORA-9006", exact: true })).toBeVisible(); await expect(page.getByRole("link", { name: "AURORA-9006", exact: true })).toBeVisible();
await expect(page.locator("table.issue-table tbody tr")).toHaveCount(10); await expect(page.locator("table.issue-table tbody tr")).toHaveCount(10);
}); });
test("поиск перед открытием карточки: окно не закрывается, адрес списка сохраняет q", async ({ page }) => {
await login(page, "/app/jira/issues");
await expect(page.locator("table.issue-table tbody tr")).toHaveCount(9);
await page.getByRole("searchbox").fill("свод");
await page.locator("table.issue-table tbody tr").first().getByRole("link").click();
await expect(page.getByRole("dialog")).toBeVisible();
await page.waitForTimeout(600);
await expect(page.getByRole("dialog")).toBeVisible();
await page.keyboard.press("Escape");
await expect(page.getByRole("dialog")).toHaveCount(0);
await expect(page).toHaveURL(/\/app\/jira\/issues\?.*q=/);
});

View File

@ -41,8 +41,9 @@ export const useIssue = (key: string) =>
useQuery({ queryKey: qk.issue(key), queryFn: () => api.get<IssueDetail>(`/issues/${encodeURIComponent(key)}`) }); useQuery({ queryKey: qk.issue(key), queryFn: () => api.get<IssueDetail>(`/issues/${encodeURIComponent(key)}`) });
// список порциями по ISSUE_PAGE: следующая порция есть, пока последняя полная // список порциями по ISSUE_PAGE: следующая порция есть, пока последняя полная
export const useIssues = (filters: IssueFilters) => export const useIssues = (filters: IssueFilters, opts?: { enabled?: boolean }) =>
useInfiniteQuery({ useInfiniteQuery({
enabled: opts?.enabled ?? true,
queryKey: qk.issues(filters), queryKey: qk.issues(filters),
initialPageParam: 0, initialPageParam: 0,
queryFn: ({ pageParam }) => queryFn: ({ pageParam }) =>

View File

@ -27,6 +27,6 @@ export function fromHub(snap: BoardSnapshot, dicts: Dicts): BoardModel {
const missing = addStubs(nodes, links, NOT_IN_HUB); const missing = addStubs(nodes, links, NOT_IN_HUB);
const problems = snap.last_sync ? [] : ["в хабе ещё не было полного обновления из Jira — нажмите «Обновить из Jira»"]; const problems = snap.last_sync ? [] : ["в хабе ещё не было полного обновления из Jira — нажмите «Обновить из Jira»"];
// фильтр спринтов — только по своим задачам: спринт соседа из другого проекта не делает канбан-проект «спринтовым» // фильтр спринтов — только по своим задачам: спринт соседа из другого проекта не делает канбан-проект «спринтовым»
const hasSprint = snap.issues.some((it) => !it.neighbor && !!it.sprint); const hasSprint = snap.issues.some((it) => !isEpic(it) && !it.neighbor && !!it.sprint);
return { nodes, links, sprints, fileName: "Хаб", problems, hasSprint, missing, stubLane: NOT_IN_HUB }; return { nodes, links, sprints, fileName: "Хаб", problems, hasSprint, missing, stubLane: NOT_IN_HUB };
} }

View File

@ -32,6 +32,11 @@ describe("модель доски = links.html@3931a88", () => {
expect(fromHub(neighborOnly, dicts).hasSprint).toBe(false); expect(fromHub(neighborOnly, dicts).hasSprint).toBe(false);
expect(fromHub(snap, dicts).hasSprint).toBe(true); expect(fromHub(snap, dicts).hasSprint).toBe(true);
}); });
it("hasSprint: спринт только у эпика → false", () => {
const snap = JSON.parse(fx("hub-basic.board.json")) as BoardSnapshot;
const onlyEpic = { ...snap, issues: snap.issues.map((i) => ({ ...i, sprint: i.key === "AURORA-9100" ? "AWPCA_Спринт 1" : null })) };
expect(fromHub(onlyEpic, dicts).hasSprint).toBe(false);
});
it("parseCsv и fromCsv — BOM, CRLF, кавычки, повторные колонки, даты разных локалей", () => { it("parseCsv и fromCsv — BOM, CRLF, кавычки, повторные колонки, даты разных локалей", () => {
const text = fx("jira-export.csv"); const text = fx("jira-export.csv");
const g = golden("jira-export.expected.json"); const g = golden("jira-export.expected.json");

View File

@ -0,0 +1,36 @@
import { act, renderHook } from "@testing-library/react";
import { beforeEach, describe, expect, it } from "vitest";
import { readPrefs } from "./storage.ts";
import type { BoardModel } from "./types.ts";
import { useBoardPrefs } from "./useBoardPrefs.ts";
const model = { nodes: [], links: [], sprints: {}, fileName: "x", problems: [], hasSprint: false, missing: [], stubLane: "" } as unknown as BoardModel;
describe("useBoardPrefs: два переключения в одном такте", () => {
beforeEach(() => localStorage.clear());
it("toggle двух флагов не теряет первый", () => {
const { result } = renderHook(() => useBoardPrefs(model, "P"));
const a = result.current.snap, b = result.current.table;
act(() => { result.current.toggle("snap"); result.current.toggle("table"); });
expect(result.current.snap).toBe(!a);
expect(result.current.table).toBe(!b);
});
it("два toggle одного флага в одном такте возвращают исходное значение, в хранилище то же", () => {
const { result } = renderHook(() => useBoardPrefs(model, "P"));
const before = result.current.snap;
act(() => { result.current.toggle("snap"); result.current.toggle("snap"); });
expect(result.current.snap).toBe(before);
expect(readPrefs().snap).toBe(before);
});
it("toggleHidden двух значений скрывает оба", () => {
const { result } = renderHook(() => useBoardPrefs(model, "P"));
act(() => { result.current.toggleHidden("kind", "x"); result.current.toggleHidden("kind", "y"); });
expect([...result.current.filters.hideKind].sort()).toEqual(["x", "y"]);
const saved = Object.values(localStorage).join("|");
expect(saved).toContain("x");
expect(saved).toContain("y");
});
});

View File

@ -1,4 +1,4 @@
import { useCallback, useMemo, useState } from "react"; import { useCallback, useMemo, useRef, useState } from "react";
import { initialSprints, initialWho } from "./analysis/facets.ts"; import { initialSprints, initialWho } from "./analysis/facets.ts";
import type { LayoutStore } from "./components/BoardCanvas.tsx"; import type { LayoutStore } from "./components/BoardCanvas.tsx";
import * as st from "./storage.ts"; import * as st from "./storage.ts";
@ -29,6 +29,7 @@ export function useBoardPrefs(model: BoardModel, project: string): BoardPrefsApi
if (st.isLegacyStore()) { st.migrateMode(); st.migrateStore(); } if (st.isLegacyStore()) { st.migrateMode(); st.migrateStore(); }
return st.readPrefs(); return st.readPrefs();
}); });
const prefsRef = useRef(prefs); // последнее значение: два вызова в одном такте не теряют первый
const [store, setStore] = useState<LayoutStore>(() => readStore(0)); const [store, setStore] = useState<LayoutStore>(() => readStore(0));
const [sel, setSel] = useState(() => pick(model, prefs.filters)); const [sel, setSel] = useState(() => pick(model, prefs.filters));
if (sel.model !== model) setSel(pick(model, prefs.filters)); // новая модель — выбор заново, как fillSprints/fillWho if (sel.model !== model) setSel(pick(model, prefs.filters)); // новая модель — выбор заново, как fillSprints/fillWho
@ -36,32 +37,35 @@ export function useBoardPrefs(model: BoardModel, project: string): BoardPrefsApi
const filters = useMemo<Filters>(() => ({ ...prefs.filters, sprints: sel.sprints, who: sel.who }), [prefs.filters, sel]); const filters = useMemo<Filters>(() => ({ ...prefs.filters, sprints: sel.sprints, who: sel.who }), [prefs.filters, sel]);
const commit = useCallback((next: st.BoardPrefs) => { prefsRef.current = next; setPrefs(next); }, []);
const toggle = useCallback((name: st.FlagName | "snap" | "table" | "problems") => { const toggle = useCallback((name: st.FlagName | "snap" | "table" | "problems") => {
const p = prefsRef.current;
const isView = name === "snap" || name === "table" || name === "problems"; const isView = name === "snap" || name === "table" || name === "problems";
const on = isView ? !prefs[name] : !prefs.filters[name]; const on = isView ? !p[name] : !p.filters[name];
st.writeFlag(name, on); st.writeFlag(name, on);
setPrefs((p) => (isView ? { ...p, [name]: on } : { ...p, filters: { ...p.filters, [name]: on } })); commit(isView ? { ...p, [name]: on } : { ...p, filters: { ...p.filters, [name]: on } });
}, [prefs]); }, [commit]);
const setSprints = useCallback((s: Set<string>) => { st.writeSprints(s); setSel((x) => ({ ...x, sprints: new Set(s) })); }, []); const setSprints = useCallback((s: Set<string>) => { st.writeSprints(s); setSel((x) => ({ ...x, sprints: new Set(s) })); }, []);
const setWho = useCallback((s: Set<string>) => { st.writeWho(s); setSel((x) => ({ ...x, who: new Set(s) })); }, []); const setWho = useCallback((s: Set<string>) => { st.writeWho(s); setSel((x) => ({ ...x, who: new Set(s) })); }, []);
const toggleHidden = useCallback((what: "kind" | "edge", value: string) => { const toggleHidden = useCallback((what: "kind" | "edge", value: string) => {
const p = prefsRef.current;
const key = what === "kind" ? "hideKind" : "hideEdge"; const key = what === "kind" ? "hideKind" : "hideEdge";
const next = new Set(prefs.filters[key]); const set = new Set(p.filters[key]);
if (next.has(value)) next.delete(value); else next.add(value); if (set.has(value)) set.delete(value); else set.add(value);
const filters = { ...prefs.filters, [key]: next }; const filters = { ...p.filters, [key]: set };
st.writeHidden(filters.hideKind, filters.hideEdge); st.writeHidden(filters.hideKind, filters.hideEdge);
setPrefs((p) => ({ ...p, filters: { ...p.filters, [key]: next } })); commit({ ...p, filters });
}, [prefs]); }, [commit]);
const toggleLane = useCallback((name: string) => { st.toggleLane(name); bump(); }, [bump]); const toggleLane = useCallback((name: string) => { st.toggleLane(name); bump(); }, [bump]);
const moveLane = useCallback((current: string[], name: string, to: number) => { if (st.moveLane(current, name, to)) bump(); }, [bump]); const moveLane = useCallback((current: string[], name: string, to: number) => { if (st.moveLane(current, name, to)) bump(); }, [bump]);
const setColWidth = useCallback((date: string, w: number | null) => { st.setColWidth(date, w); bump(); }, [bump]); const setColWidth = useCallback((date: string, w: number | null) => { st.setColWidth(date, w); bump(); }, [bump]);
const resetLayout = useCallback(() => { st.resetLayout(prefs.filters.byDate); bump(); }, [prefs.filters.byDate, bump]); const resetLayout = useCallback(() => { st.resetLayout(prefs.filters.byDate); bump(); }, [prefs.filters.byDate, bump]);
const applyJson = useCallback((r: JsonImport) => { const applyJson = useCallback((r: JsonImport) => {
const patch = st.applyJsonSettings(r); const patch = st.applyJsonSettings(r);
setPrefs((p) => ({ ...p, filters: { ...p.filters, ...patch } })); commit({ ...prefsRef.current, filters: { ...prefsRef.current.filters, ...patch } });
setSel(pick(model, { ...prefs.filters, ...patch })); setSel(pick(model, { ...prefsRef.current.filters, ...patch }));
bump(); bump();
}, [model, prefs.filters, bump]); }, [model, commit, bump]);
return { filters, snap: prefs.snap, table: prefs.table, problems: prefs.problems, store, return { filters, snap: prefs.snap, table: prefs.table, problems: prefs.problems, store,
toggle, setSprints, setWho, toggleHidden, toggleLane, moveLane, setColWidth, resetLayout, applyJson }; toggle, setSprints, setWho, toggleHidden, toggleLane, moveLane, setColWidth, resetLayout, applyJson };

View File

@ -55,6 +55,20 @@ describe("текст события", () => {
expect(eventText(ev({ kind: "comment", field: "comment", from: null, to: null }))).toBe("комментарий: —"); // M-6 expect(eventText(ev({ kind: "comment", field: "comment", from: null, to: null }))).toBe("комментарий: —"); // M-6
}); });
it("комментарий в ленте — без вики-разметки (С6)", () => {
expect(eventText(ev({ kind: "comment", field: "comment", from: null, to: "h3. Итог\n*Готово*, см. [протокол|https://x.ru/p] и [~ivanov]" })))
.toBe("комментарий: Итог Готово, см. протокол и @ivanov");
});
it("вложения в комментарии — именем, даже без списка вложений (I-1)", () => {
expect(eventText(ev({ kind: "comment", field: "comment", from: null, to: "см. !скрин.png|thumbnail! и [^отчёт.pdf]" })))
.toBe("комментарий: см. скрин.png и отчёт.pdf");
});
it("пустой комментарий — по-прежнему «—»", () => {
expect(eventText(ev({ kind: "comment", field: "comment", from: null, to: " " }))).toBe("комментарий: —");
});
it("прочие поля — подпись из словаря или имя поля", () => { it("прочие поля — подпись из словаря или имя поля", () => {
expect(eventText(ev({ kind: "other", field: "assignee", from: null, to: "ivanov" }))).toBe("исполнитель: «—» → «ivanov»"); expect(eventText(ev({ kind: "other", field: "assignee", from: null, to: "ivanov" }))).toBe("исполнитель: «—» → «ivanov»");
expect(eventText(ev({ kind: "other", field: "Компания", from: "А", to: "Б" }))).toBe("Компания: «А» → «Б»"); expect(eventText(ev({ kind: "other", field: "Компания", from: "А", to: "Б" }))).toBe("Компания: «А» → «Б»");

View File

@ -1,4 +1,5 @@
import type { ChangeEvent, ChangeKind } from "../api/types.ts"; import type { ChangeEvent, ChangeKind } from "../api/types.ts";
import { wikiPlain } from "../issues/wiki/plain.ts";
// период ленты: границы по местному времени браузера; «Сегодня» — по умолчанию и в адрес не пишется (spec web-app «Лента изменений») // период ленты: границы по местному времени браузера; «Сегодня» — по умолчанию и в адрес не пишется (spec web-app «Лента изменений»)
export type Period = "2h" | "today" | "yesterday" | "7d"; export type Period = "2h" | "today" | "yesterday" | "7d";
@ -70,7 +71,7 @@ export function eventText(e: ChangeEvent): string {
case "description": return "описание изменено"; case "description": return "описание изменено";
case "dates": return `${DATE_LABELS[e.field] ?? e.field} ${day(e.from_value, e.from)} → ${day(e.to_value, e.to)}`; case "dates": return `${DATE_LABELS[e.field] ?? e.field} ${day(e.from_value, e.from)} → ${day(e.to_value, e.to)}`;
case "comment": { // первые 300 символов текста и «…» (M-4); без текста — «—» (M-6) case "comment": { // первые 300 символов текста и «…» (M-4); без текста — «—» (M-6)
const s = oneLine(e.to); const s = oneLine(wikiPlain(e.to ?? ""));
return "комментарий: " + (!s ? "—" : s.length <= 300 ? s : s.slice(0, 300) + "…"); return "комментарий: " + (!s ? "—" : s.length <= 300 ? s : s.slice(0, 300) + "…");
} }
default: { default: {

View File

@ -73,6 +73,34 @@ describe("карточка задачи", () => {
expect(screen.getByRole("link", { name: "AURORA-9002" }).closest(".issue")).not.toBeNull(); // O-10: цвет темы expect(screen.getByRole("link", { name: "AURORA-9002" }).closest(".issue")).not.toBeNull(); // O-10: цвет темы
}); });
it("связь на ключ вне зеркала ведёт в Jira, а не в карточку (#42 M-5)", async () => {
mockFetch((_m, url) => url.pathname === "/settings" ? json(200, settings) : json(200, issue("AURORA-9001", {
links_out: [{ target_key: "AURORA-1999", link_type: "ASUPR_Связанные", summary: null, status: null }], links_in: [],
})));
open("/jira/issues/AURORA-9001");
const link = await screen.findByRole("link", { name: /AURORA-1999/ });
expect(link).toHaveAttribute("href", "https://sdlc.go.rshbank.ru/jira/browse/AURORA-1999");
expect(link).toHaveAttribute("target", "_blank");
expect(link.closest("li")).toHaveTextContent("нет в зеркале");
});
it("«Прочие поля»: вид работы отдельно от меток (#103)", async () => {
mockFetch((_m, url) => url.pathname === "/settings" ? json(200, settings) : json(200, issue("AURORA-9001", { labels: ["AWPCA", "kind-dev"] })));
open("/jira/issues/AURORA-9001");
await screen.findByRole("heading", { level: 2, name: "Тема AURORA-9001" });
const other = screen.getByRole("region", { name: "Прочие поля" });
expect(within(other).getByText("Вид работы в Jira").nextSibling).toHaveTextContent("разработка");
expect(within(other).getByText("Метки").nextSibling).toHaveTextContent("AWPCA");
expect(within(other).queryByText(/kind-dev/)).toBeNull();
});
it("пустое описание (одни пробелы) — без раздела «Описание» (#56 M-4)", async () => {
mockFetch((_m, url) => url.pathname === "/settings" ? json(200, settings) : json(200, issue("AURORA-9001", { description: " \n\n" })));
open("/jira/issues/AURORA-9001");
await screen.findByRole("heading", { level: 2, name: "Тема AURORA-9001" });
expect(screen.queryByRole("heading", { name: "Описание" })).toBeNull();
});
it("без связей — «связей нет»", async () => { it("без связей — «связей нет»", async () => {
mockFetch((_m, url) => url.pathname === "/settings" ? json(200, settings) : json(200, issue("AURORA-9001", { links_out: [], links_in: [] }))); mockFetch((_m, url) => url.pathname === "/settings" ? json(200, settings) : json(200, issue("AURORA-9001", { links_out: [], links_in: [] })));
open("/jira/issues/AURORA-9001"); open("/jira/issues/AURORA-9001");
@ -496,7 +524,7 @@ describe("вложения в карточке", () => {
return mockFetch((m, url) => extra?.(m, url) ?? (url.pathname === "/settings" ? json(200, settings) : json(200, issue("AURORA-1650", { ...base, ...over })))); return mockFetch((m, url) => extra?.(m, url) ?? (url.pathname === "/settings" ? json(200, settings) : json(200, issue("AURORA-1650", { ...base, ...over }))));
} }
it("раздел «Вложения (2)» после «Описание», новые сверху; размер, автор · дата", async () => { it("раздел «Вложения (2)» после «Описание», порядок хаба; размер, автор · дата", async () => {
hub({}); hub({});
open("/jira/issues/AURORA-1650"); open("/jira/issues/AURORA-1650");
const sec = within(await screen.findByTestId("attachments")); const sec = within(await screen.findByTestId("attachments"));
@ -504,12 +532,39 @@ describe("вложения в карточке", () => {
const heads = screen.getAllByRole("heading", { level: 3 }).map((h) => h.textContent); const heads = screen.getAllByRole("heading", { level: 3 }).map((h) => h.textContent);
expect(heads.indexOf("Вложения (2)")).toBe(heads.indexOf("Описание") + 1); expect(heads.indexOf("Вложения (2)")).toBe(heads.indexOf("Описание") + 1);
const rows = sec.getAllByRole("listitem"); const rows = sec.getAllByRole("listitem");
expect(rows[0]).toHaveTextContent("ОСВ 60.xlsx"); expect(rows[1]).toHaveTextContent("ОСВ 60.xlsx");
expect(rows[0]).toHaveTextContent("1,0 КБ"); expect(rows[1]).toHaveTextContent("1,0 КБ");
expect(rows[1]).toHaveTextContent("скрин 1.png"); expect(rows[0]).toHaveTextContent("скрин 1.png");
expect(rows[1]).toHaveTextContent("234,4 КБ"); expect(rows[0]).toHaveTextContent("234,4 КБ");
expect(rows[1]).toHaveTextContent("Иванов Иван · 15.10"); expect(rows[0]).toHaveTextContent("Иванов Иван · 15.10");
expect(within(rows[1]).getByRole("button", { name: "Скачать" })).toBeInTheDocument(); expect(within(rows[0]).getByRole("button", { name: "Скачать" })).toBeInTheDocument();
});
it("порядок вложений — как у хаба, без пересортировки по дате", async () => {
hub({ attachments: [att("1", "b.bin", { created: null }), att("2", "a.bin", { created: "2026-10-20T10:00:00.000+0300" })], comments: [] });
open("/jira/issues/AURORA-1650");
const rows = within(await screen.findByTestId("attachments")).getAllByRole("listitem");
expect(rows[0]).toHaveTextContent("b.bin");
expect(rows[1]).toHaveTextContent("a.bin");
});
it("повтор после ошибки: успех в карточке скрывает прежний failed из несвежего ответа хаба", async () => {
let fetched = false;
hub({ attachments: [att("31337", "скрин 1.png", { state: "failed", error: "сбой" })], comments: [] }, (_m, url) => {
if (url.pathname === "/attachments/31337/fetch") { fetched = true; return json(200, { attachment_id: "31337", state: "ready", error: null, command_id: 1, batch_id: 1 }); }
if (url.pathname === "/attachments/31337/content") return new Response("PNG", { status: 200 });
return undefined;
});
URL.createObjectURL = vi.fn(() => "blob:fake");
URL.revokeObjectURL = vi.fn();
vi.spyOn(HTMLAnchorElement.prototype, "click").mockImplementation(() => {});
open("/jira/issues/AURORA-1650");
const sec = within(await screen.findByTestId("attachments"));
expect(await sec.findByText("сбой")).toBeInTheDocument();
await userEvent.click(sec.getByRole("button", { name: "Повторить" }));
await waitFor(() => expect(fetched).toBe(true));
await waitFor(() => expect(sec.queryByText("сбой")).toBeNull());
expect(sec.getByRole("button", { name: "Скачать" })).toBeInTheDocument();
}); });
it("без поля attachments (старый хаб) раздела нет", async () => { it("без поля attachments (старый хаб) раздела нет", async () => {
@ -568,7 +623,7 @@ describe("вложения в карточке", () => {
? json(409, { error: "agent_too_old", detail: "агент v13 не умеет скачивать вложения — нужен v14" }) : undefined); ? json(409, { error: "agent_too_old", detail: "агент v13 не умеет скачивать вложения — нужен v14" }) : undefined);
open("/jira/issues/AURORA-1650"); open("/jira/issues/AURORA-1650");
const sec = within(await screen.findByTestId("attachments")); const sec = within(await screen.findByTestId("attachments"));
await userEvent.click(sec.getAllByRole("button", { name: "Скачать" })[1]); await userEvent.click(sec.getAllByRole("button", { name: "Скачать" })[0]);
expect(await sec.findByText("агент v13 не умеет скачивать вложения — нужен v14")).toBeInTheDocument(); expect(await sec.findByText("агент v13 не умеет скачивать вложения — нужен v14")).toBeInTheDocument();
await userEvent.click(sec.getByRole("button", { name: "Повторить" })); await userEvent.click(sec.getByRole("button", { name: "Повторить" }));
expect(f.mock.calls.filter(([u]) => String(u).endsWith("/fetch"))).toHaveLength(2); expect(f.mock.calls.filter(([u]) => String(u).endsWith("/fetch"))).toHaveLength(2);

View File

@ -7,7 +7,7 @@ import type { ChangeEvent, IssueAttachment, IssueComment, IssueDetail } from "..
import { JIRA_URL } from "../board/constants.ts"; import { JIRA_URL } from "../board/constants.ts";
import { fmtDate, fmtStamp } from "../../../ui/format.ts"; import { fmtDate, fmtStamp } from "../../../ui/format.ts";
import { EventList } from "../feed/EventList.tsx"; import { EventList } from "../feed/EventList.tsx";
import { groupLinks, isOverdue, todayISO } from "./card.ts"; import { groupLinks, isOverdue, splitKindLabels, todayISO } from "./card.ts";
import { useCommentsHidden } from "./commentsPref.ts"; import { useCommentsHidden } from "./commentsPref.ts";
import { IssueLink } from "./IssueLink.tsx"; import { IssueLink } from "./IssueLink.tsx";
import { Badge, Button, IconButton, IconClose, IconExternal, Notice, Panel } from "../../../ui/index.ts"; import { Badge, Button, IconButton, IconClose, IconExternal, Notice, Panel } from "../../../ui/index.ts";
@ -22,26 +22,27 @@ import "./issues.css";
const DESC_LINES = 8; const DESC_LINES = 8;
export function IssueCard({ issueKey: key, onClose }: { issueKey: string; onClose: () => void }) { export function IssueCard({ issueKey: key, onClose, onBack }: { issueKey: string; onClose: () => void; onBack?: () => void }) {
const issue = useIssue(key); const issue = useIssue(key);
const settings = useSettings(); const settings = useSettings();
const closed = useClosedStatuses(); const closed = useClosedStatuses();
const dl = useAttachmentDownload(key); // одно состояние на вложение для всех кнопок карточки const dl = useAttachmentDownload(key); // одно состояние на вложение для всех кнопок карточки
const close = <IconButton label="Закрыть" icon={<IconClose />} onClick={onClose} />; const close = <IconButton label="Закрыть" icon={<IconClose />} onClick={onClose} />;
const back = onBack && <Button onClick={onBack}>Назад</Button>;
if (issue.isPending) { if (issue.isPending) {
return <div className="issue" data-testid="issue-card"><div className="issue-head"><div className="row1"><span className="key">{key}</span><span className="spacer" />{close}</div><p className="hint">загрузка {key}…</p></div></div>; return <div className="issue" data-testid="issue-card"><div className="issue-head"><div className="row1"><span className="key">{key}</span><span className="spacer" />{close}</div><p className="hint">загрузка {key}…</p></div></div>;
} }
if (issue.error instanceof ApiError && issue.error.status === 404) { if (issue.error instanceof ApiError && issue.error.status === 404) {
return ( return (
<div className="issue issue--state" data-testid="issue-card"> <div className="issue issue--state" data-testid="issue-card">
<Notice tone="info" action={<Button onClick={onClose}>Закрыть</Button>}>Задачи {key} нет в зеркале хаба.</Notice> <Notice tone="info" action={<>{back}<Button onClick={onClose}>Закрыть</Button></>}>Задачи {key} нет в зеркале хаба.</Notice>
</div> </div>
); );
} }
if (issue.isError && !issue.data) { if (issue.isError && !issue.data) {
return ( return (
<div className="issue issue--state" data-testid="issue-card"> <div className="issue issue--state" data-testid="issue-card">
<Notice tone="error" action={<><Button onClick={() => void issue.refetch()}>Повторить</Button><Button onClick={onClose}>Закрыть</Button></>}> <Notice tone="error" action={<><Button onClick={() => void issue.refetch()}>Повторить</Button>{back}<Button onClick={onClose}>Закрыть</Button></>}>
{issue.error.message} {issue.error.message}
</Notice> </Notice>
</div> </div>
@ -76,14 +77,14 @@ export function IssueCard({ issueKey: key, onClose }: { issueKey: string; onClos
<div className="cols"> <div className="cols">
<div className="main"> <div className="main">
<Links issue={i} linkTypes={(settings.data?.link_types ?? {}) as Record<string, string>} /> <Links issue={i} linkTypes={(settings.data?.link_types ?? {}) as Record<string, string>} />
{i.description && <Description text={i.description} projectKey={settings.data?.project_key} wiki={wiki} />} {i.description?.trim() && <Description text={i.description} projectKey={settings.data?.project_key} wiki={wiki} />}
{atts && atts.length > 0 && <AttachmentList items={atts} dl={dl} />} {atts && atts.length > 0 && <AttachmentList items={atts} dl={dl} />}
<Comments items={i.comments ?? []} projectKey={settings.data?.project_key} wiki={wiki} atts={atts ?? []} dl={dl} /> <Comments items={i.comments ?? []} projectKey={settings.data?.project_key} wiki={wiki} atts={atts ?? []} dl={dl} />
{i.changes && <History items={i.changes} />} {i.changes && <History items={i.changes} />}
</div> </div>
<Panel className="side"> <Panel className="side">
<Notes key={i.key} issue={i} /> <Notes key={i.key} issue={i} />
<OtherFields issue={i} /> <OtherFields issue={i} kindLabels={settings.data?.kind_labels as Record<string, string> | undefined} />
<Commands issue={i} /> <Commands issue={i} />
</Panel> </Panel>
</div> </div>
@ -107,6 +108,8 @@ function wikiAttachments(atts: IssueAttachment[] | undefined, dl: Download): Wik
}; };
} }
const absent = (l: { summary: string | null; status: string | null }) => l.summary === null && l.status === null;
function Links({ issue, linkTypes }: { issue: IssueDetail; linkTypes: Record<string, string> }) { function Links({ issue, linkTypes }: { issue: IssueDetail; linkTypes: Record<string, string> }) {
const groups = groupLinks(issue, linkTypes); const groups = groupLinks(issue, linkTypes);
return ( return (
@ -120,8 +123,10 @@ function Links({ issue, linkTypes }: { issue: IssueDetail; linkTypes: Record<str
{links.map((l, n) => ( {links.map((l, n) => (
<li key={`${n}:${l.key}:${l.tag ?? ""}`}> <li key={`${n}:${l.key}:${l.tag ?? ""}`}>
{l.tag && <><span className="tag">{l.tag}</span>{" "}</>} {l.tag && <><span className="tag">{l.tag}</span>{" "}</>}
<IssueLink issueKey={l.key} />{" "} {absent(l)
{l.summary === null && l.status === null ? <a href={`${JIRA_URL}/browse/${l.key}`} target="_blank" rel="noopener">{l.key}<IconExternal size={12} /></a>
: <IssueLink issueKey={l.key} />}{" "}
{absent(l)
? <span className="sum absent">нет в зеркале</span> ? <span className="sum absent">нет в зеркале</span>
: <><span className="sum">{l.summary}</span>{l.status && <>{" "}<Badge>{l.status}</Badge></>}</>} : <><span className="sum">{l.summary}</span>{l.status && <>{" "}<Badge>{l.status}</Badge></>}</>}
</li> </li>
@ -222,9 +227,11 @@ function History({ items }: { items: ChangeEvent[] }) {
); );
} }
function OtherFields({ issue: i }: { issue: IssueDetail }) { function OtherFields({ issue: i, kindLabels }: { issue: IssueDetail; kindLabels?: Record<string, string> }) {
const { kinds, rest } = splitKindLabels(i.labels, kindLabels);
const rows: [string, string | null][] = [ const rows: [string, string | null][] = [
["Тип", i.issue_type], ["Приоритет", i.priority], ["Автор", i.reporter], ["Метки", i.labels.join(", ") || null], ["Тип", i.issue_type], ["Приоритет", i.priority], ["Автор", i.reporter],
["Вид работы в Jira", kinds.join(", ") || null], ["Метки", rest.join(", ") || null],
["Плановое начало", i.planned_start], ["Обновлена в Jira", i.jira_updated ? fmtStamp(i.jira_updated) : null], ["Плановое начало", i.planned_start], ["Обновлена в Jira", i.jira_updated ? fmtStamp(i.jira_updated) : null],
["Сосед по связям", i.neighbor ? "да" : null], ["Сосед по связям", i.neighbor ? "да" : null],
]; ];

View File

@ -1,8 +1,8 @@
import type { ReactNode } from "react"; import type { MouseEvent, ReactNode } from "react";
import { Link, useLocation, useNavigate } from "react-router"; import { Link, useLocation, useNavigate } from "react-router";
import { openCardState } from "./modal.ts"; import { openCardState } from "./modal.ts";
const cardPath = (key: string) => `/jira/issues/${encodeURIComponent(key)}`; export const cardPath = (key: string) => `/jira/issues/${encodeURIComponent(key)}`;
// переход к карточке задачи: окно поверх текущей страницы (или смена содержимого открытого окна) — как у ссылки-ключа // переход к карточке задачи: окно поверх текущей страницы (или смена содержимого открытого окна) — как у ссылки-ключа
export function useOpenIssue(): (key: string) => void { export function useOpenIssue(): (key: string) => void {
@ -12,7 +12,13 @@ export function useOpenIssue(): (key: string) => void {
} }
// ссылка на карточку задачи: открывает окно поверх текущей страницы (или меняет содержимое открытого окна) // ссылка на карточку задачи: открывает окно поверх текущей страницы (или меняет содержимое открытого окна)
export function IssueLink({ issueKey, className, children }: { issueKey: string; className?: string; children?: ReactNode }) { // onOpen: обычный щелчок (левая кнопка без модификаторов) отдаётся вызывающему; Ctrl/Cmd/Shift/средняя кнопка — обычная ссылка
export function IssueLink({ issueKey, className, children, onOpen }: { issueKey: string; className?: string; children?: ReactNode; onOpen?(key: string): void }) {
const loc = useLocation(); const loc = useLocation();
return <Link className={className} to={cardPath(issueKey)} state={openCardState(loc)}>{children ?? issueKey}</Link>; const click = onOpen && ((e: MouseEvent<HTMLAnchorElement>) => {
if (e.defaultPrevented || e.button !== 0 || e.ctrlKey || e.metaKey || e.shiftKey || e.altKey) return;
e.preventDefault();
onOpen(issueKey);
});
return <Link className={className} to={cardPath(issueKey)} state={openCardState(loc)} onClick={click}>{children ?? issueKey}</Link>;
} }

View File

@ -137,6 +137,8 @@ describe("карточка окном", () => {
await userEvent.click(within(dialog()).getByRole("button", { name: "Остаться" })); await userEvent.click(within(dialog()).getByRole("button", { name: "Остаться" }));
expect(router.state.location.pathname).toBe("/app/jira/issues/AURORA-9001"); expect(router.state.location.pathname).toBe("/app/jira/issues/AURORA-9001");
expect(within(dialog()).getByLabelText("Заметка владельца")).toHaveValue("черновик"); expect(within(dialog()).getByLabelText("Заметка владельца")).toHaveValue("черновик");
fireEvent(dialog(), new Event("cancel", { cancelable: true })); // после «Остаться» повторный Esc снова спрашивает (closingKey сброшен)
expect(await within(dialog()).findByRole("button", { name: "Уйти" })).toBeInTheDocument();
}); });
it("T4.1: отказ от закрытия («Остаться») сбрасывает ожидание закрытия: «назад» на запись прямого адреса не уводит в список", async () => { it("T4.1: отказ от закрытия («Остаться») сбрасывает ожидание закрытия: «назад» на запись прямого адреса не уводит в список", async () => {
@ -155,6 +157,59 @@ describe("карточка окном", () => {
expect(router.state.location.pathname).toBe("/app/jira/issues/AURORA-9001"); expect(router.state.location.pathname).toBe("/app/jira/issues/AURORA-9001");
}); });
it("поиск перед открытием не закрывает окно (#42 M-1): ключ и ячейка темы", async () => {
for (const open of ["key", "sum"] as const) {
const router = at(["/app/jira/issues"]);
const { unmount } = render(<App router={router} />);
await screen.findByRole("table");
await userEvent.type(screen.getByRole("searchbox"), "свод");
if (open === "key") await userEvent.click(screen.getByRole("link", { name: "AURORA-9002" }));
else await userEvent.click(screen.getByRole("cell", { name: "API свода" }));
await within(dialog()).findByRole("heading", { name: /AURORA-9002/ });
await new Promise((r) => setTimeout(r, 400));
expect(screen.getByRole("dialog")).toBeInTheDocument();
expect(router.state.location.pathname).toBe("/app/jira/issues/AURORA-9002");
await userEvent.click(within(dialog()).getByRole("button", { name: "Закрыть" }));
await waitFor(() => expect(screen.queryByRole("dialog")).toBeNull());
expect(router.state.location.pathname).toBe("/app/jira/issues");
expect(new URLSearchParams(router.state.location.search).get("q")).toBe("свод");
unmount();
}
});
it("двойной щелчок по ✕ не уводит дальше назад (#42 M-2)", async () => {
const router = at(["/app/jira/links", "/app/jira/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" }));
await within(dialog()).findByRole("heading", { name: /AURORA-9002/ });
const x = within(dialog()).getByRole("button", { name: "Закрыть" });
await act(async () => { fireEvent.click(x); fireEvent.click(x); }); // оба щелчка до перерисовки
await waitFor(() => expect(screen.queryByRole("dialog")).toBeNull());
expect(router.state.location.pathname).toBe("/app/jira/issues");
});
it("двойной ✕ у прямого адреса: адрес списка, дальше по истории не уходит (Review Focus 2)", async () => {
const router = at(["/app/jira/issues/AURORA-9002"]);
render(<App router={router} />);
await within(await screen.findByRole("dialog")).findByRole("heading", { name: /AURORA-9002/ });
await waitFor(() => expect(router.state.location.state).toMatchObject({ depth: 0, direct: true }));
await userEvent.dblClick(within(dialog()).getByRole("button", { name: "Закрыть" }));
await waitFor(() => expect(screen.queryByRole("dialog")).toBeNull());
expect(router.state.location.pathname).toBe("/app/jira/issues");
});
it("подтверждение ухода: имя и фокус на «Остаться» (#42 M-4)", async () => {
const router = at(["/app/jira/issues"]);
render(<App router={router} />);
await userEvent.click(await screen.findByRole("link", { name: "AURORA-9001" }));
await userEvent.click(await within(dialog()).findByRole("button", { name: "Править" }));
await userEvent.type(await within(dialog()).findByLabelText("Заметка владельца"), "черновик");
fireEvent(dialog(), new Event("cancel", { cancelable: true }));
const ask = await within(dialog()).findByRole("alertdialog", { name: "Уйти без сохранения заметки?" });
await waitFor(() => expect(document.activeElement).toBe(within(ask).getByRole("button", { name: "Остаться" })));
});
it("T4.4: у окна есть имя в состоянии загрузки и 404", async () => { it("T4.4: у окна есть имя в состоянии загрузки и 404", async () => {
mockFetch((_m, url) => { mockFetch((_m, url) => {
if (url.pathname === "/settings") return json(200, settings); if (url.pathname === "/settings") return json(200, settings);
@ -168,4 +223,111 @@ describe("карточка окном", () => {
render(<App router={at(["/app/jira/issues/AURORA-2"])} />); render(<App router={at(["/app/jira/issues/AURORA-2"])} />);
expect(await screen.findByRole("dialog", { name: "AURORA-2" })).toBeInTheDocument(); expect(await screen.findByRole("dialog", { name: "AURORA-2" })).toBeInTheDocument();
}); });
describe("задача не в зеркале после перехода (#42 M-5)", () => {
const mirror = () => mockFetch((_m, url) => {
const p = url.pathname;
if (p === "/issues/AURORA-1999") return json(404, {});
if (p === "/issues/AURORA-9002") return json(200, issue("AURORA-9002", {
links_out: [{ target_key: "AURORA-1999", link_type: "ASUPR_Связанные", summary: "Тема", status: "Новая" }], links_in: [],
}));
if (p === "/settings") return json(200, settings);
if (p === "/projects") return json(200, { projects: [{ label: "AWPCA", name: "АРМ КА", board_id: null, board_type: null, created_at: "", issues_count: 9 }] });
if (p === "/issues") return json(200, { issues: rows });
if (p === "/batches") return json(200, { batches: [] });
return json(200, {});
});
const msg = "Задачи AURORA-1999 нет в зеркале хаба.";
it("«Назад» возвращает прежнюю карточку; при новом переходе «Закрыть» закрывает окно", async () => {
mirror();
const router = at(["/app/jira/issues"]);
render(<App router={router} />);
await userEvent.click(await screen.findByRole("link", { name: "AURORA-9002" }));
await userEvent.click(await within(dialog()).findByRole("link", { name: "AURORA-1999" }));
expect(await within(dialog()).findByText(msg)).toBeInTheDocument();
await userEvent.click(within(dialog()).getByRole("button", { name: "Назад" }));
expect(await within(dialog()).findByRole("heading", { name: /AURORA-9002/ })).toBeInTheDocument();
await userEvent.click(within(dialog()).getByRole("link", { name: "AURORA-1999" }));
await within(dialog()).findByText(msg);
await userEvent.click(within(dialog()).getByRole("button", { name: "Закрыть" }));
await waitFor(() => expect(screen.queryByRole("dialog")).toBeNull());
expect(router.state.location.pathname).toBe("/app/jira/issues");
});
it("прямой адрес 404 — только «Закрыть»", async () => {
mirror();
render(<App router={at(["/app/jira/issues/AURORA-1999"])} />);
expect(await within(await screen.findByRole("dialog")).findByText(msg)).toBeInTheDocument();
expect(within(dialog()).getByRole("button", { name: "Закрыть" })).toBeInTheDocument();
expect(within(dialog()).queryByRole("button", { name: "Назад" })).toBeNull();
});
it("«Назад» есть только после перехода: из списка — нет, после перехода из списка — есть", async () => {
mirror();
render(<App router={at(["/app/jira/issues"])} />);
await userEvent.click(await screen.findByRole("link", { name: "AURORA-9002" }));
await within(dialog()).findByRole("heading", { name: /AURORA-9002/ });
expect(within(dialog()).queryByRole("button", { name: "Назад" })).toBeNull();
await userEvent.click(within(dialog()).getByRole("link", { name: "AURORA-1999" }));
await within(dialog()).findByText(msg);
expect(within(dialog()).getByRole("button", { name: "Назад" })).toBeInTheDocument();
});
it("двойной щелчок по «Назад» — один шаг, окно остаётся (M-1)", async () => {
mirror();
const router = at(["/app/jira/issues"]);
render(<App router={router} />);
await userEvent.click(await screen.findByRole("link", { name: "AURORA-9002" }));
await userEvent.click(await within(dialog()).findByRole("link", { name: "AURORA-1999" }));
await within(dialog()).findByText(msg);
const back = within(dialog()).getByRole("button", { name: "Назад" });
await act(async () => { fireEvent.click(back); fireEvent.click(back); });
expect(await within(dialog()).findByRole("heading", { name: /AURORA-9002/ })).toBeInTheDocument();
expect(router.state.location.pathname).toBe("/app/jira/issues/AURORA-9002");
});
it("двойной «Назад» из прямого адреса не уходит со страницы приложения (M-1)", async () => {
mirror();
const router = at(["/app/jira/issues/AURORA-9002"]);
render(<App router={router} />);
await userEvent.click(await within(await screen.findByRole("dialog")).findByRole("link", { name: "AURORA-1999" }));
await within(dialog()).findByText(msg);
const back = within(dialog()).getByRole("button", { name: "Назад" });
await act(async () => { fireEvent.click(back); fireEvent.click(back); });
expect(await within(dialog()).findByRole("heading", { name: /AURORA-9002/ })).toBeInTheDocument();
expect(router.state.location.pathname).toBe("/app/jira/issues/AURORA-9002");
});
it("ошибка загрузки после перехода: «Повторить», «Назад», «Закрыть» (T3.2)", async () => {
mockFetch((_m, url) => {
const p = url.pathname;
if (p === "/issues/AURORA-1999") return json(500, { detail: "сбой" });
if (p === "/issues/AURORA-9002") return json(200, issue("AURORA-9002", {
links_out: [{ target_key: "AURORA-1999", link_type: "ASUPR_Связанные", summary: "Тема", status: "Новая" }], links_in: [],
}));
if (p === "/settings") return json(200, settings);
if (p === "/projects") return json(200, { projects: [{ label: "AWPCA", name: "АРМ КА", board_id: null, board_type: null, created_at: "", issues_count: 9 }] });
if (p === "/issues") return json(200, { issues: rows });
if (p === "/batches") return json(200, { batches: [] });
return json(200, {});
});
const router = at(["/app/jira/issues"]);
render(<App router={router} />);
await userEvent.click(await screen.findByRole("link", { name: "AURORA-9002" }));
await userEvent.click(await within(dialog()).findByRole("link", { name: "AURORA-1999" }));
expect(await within(dialog()).findByRole("button", { name: "Повторить" })).toBeInTheDocument();
expect(within(dialog()).getByRole("button", { name: "Закрыть" })).toBeInTheDocument();
await userEvent.click(within(dialog()).getByRole("button", { name: "Назад" }));
expect(await within(dialog()).findByRole("heading", { name: /AURORA-9002/ })).toBeInTheDocument();
});
it("переход из прямого адреса — «Назад» есть", async () => {
mirror();
render(<App router={at(["/app/jira/issues/AURORA-9002"])} />);
await userEvent.click(await within(await screen.findByRole("dialog")).findByRole("link", { name: "AURORA-1999" }));
await within(dialog()).findByText(msg);
expect(within(dialog()).getByRole("button", { name: "Назад" })).toBeInTheDocument();
});
});
}); });

View File

@ -7,7 +7,7 @@ import { IssueCard } from "./IssueCard.tsx";
// окно карточки (design 13): <dialog> держит фокус и делает фон недоступным; Esc и щелчок по фону — onClose. // окно карточки (design 13): <dialog> держит фокус и делает фон недоступным; Esc и щелчок по фону — onClose.
// Сами окно не закрываем: оно исчезает вместе с адресом карточки. Если браузер закрыл его сам (Esc без активации // Сами окно не закрываем: оно исчезает вместе с адресом карточки. Если браузер закрыл его сам (Esc без активации
// пользователя — cancel не отменяемый), открываем снова и просим закрытия по общему пути (блокировщик заметок). // пользователя — cancel не отменяемый), открываем снова и просим закрытия по общему пути (блокировщик заметок).
export function IssueModal({ issueKey, onClose }: { issueKey: string; onClose: () => void }) { export function IssueModal({ issueKey, onClose, onBack }: { issueKey: string; onClose: () => void; onBack?: () => void }) {
// I-2: закрывать щелчком по фону, только если и нажатие было на фоне — выделение текста, отпущенное за краем окна, // I-2: закрывать щелчком по фону, только если и нажатие было на фоне — выделение текста, отпущенное за краем окна,
// даёт click на самом <dialog>, но нажатие было внутри содержимого // даёт click на самом <dialog>, но нажатие было внутри содержимого
const downOnBackdrop = useRef(false); const downOnBackdrop = useRef(false);
@ -26,7 +26,7 @@ export function IssueModal({ issueKey, onClose }: { issueKey: string; onClose: (
downOnBackdrop.current = false; downOnBackdrop.current = false;
if (e.target === e.currentTarget && fromBackdrop) onClose(); if (e.target === e.currentTarget && fromBackdrop) onClose();
}}> }}>
<ErrorBoundary key={issueKey}><IssueCard issueKey={issueKey} onClose={onClose} /></ErrorBoundary> <ErrorBoundary key={issueKey}><IssueCard issueKey={issueKey} onClose={onClose} onBack={onBack} /></ErrorBoundary>
</Dialog> </Dialog>
); );
} }

View File

@ -35,7 +35,7 @@ export function IssueTable({ rows, epicNames, closed, onOpen }: {
const due = dueOf(i); const due = dueOf(i);
return ( return (
<tr key={i.key} onClick={(e) => rowClick(e, i.key, onOpen)}> <tr key={i.key} onClick={(e) => rowClick(e, i.key, onOpen)}>
<td className="issue-table__key"><IssueLink issueKey={i.key} />{i.neighbor && <Badge tone="neutral">сосед</Badge>}</td> <td className="issue-table__key"><IssueLink issueKey={i.key} onOpen={onOpen} />{i.neighbor && <Badge tone="neutral">сосед</Badge>}</td>
<td className="issue-table__sum">{i.summary}</td> <td className="issue-table__sum">{i.summary}</td>
<td>{i.status && <Badge tone="info">{i.status}</Badge>}</td> <td>{i.status && <Badge tone="info">{i.status}</Badge>}</td>
<td>{i.assignee}</td> <td>{i.assignee}</td>

View File

@ -1,4 +1,4 @@
import { act, screen, waitFor, within } from "@testing-library/react"; import { act, fireEvent, screen, waitFor, within } from "@testing-library/react";
import userEvent from "@testing-library/user-event"; import userEvent from "@testing-library/user-event";
import { readFileSync } from "node:fs"; import { readFileSync } from "node:fs";
import { join } from "node:path"; import { join } from "node:path";
@ -41,6 +41,16 @@ describe("список задач", () => {
expect(q.get("offset")).toBe("0"); expect(q.get("offset")).toBe("0");
}); });
it("ключ строки: href прежний, щелчок с Ctrl не открывает карточку внутри приложения (Review Focus 1)", async () => {
hub(() => [row(1)]);
const { router } = renderWithProviders(<IssuesPage project="AWPCA" />, { route: "/issues", path: "/issues" });
const link = await screen.findByRole("link", { name: "AURORA-9001" });
expect(link).toHaveAttribute("href", "/jira/issues/AURORA-9001");
fireEvent.click(link, { ctrlKey: true });
fireEvent.click(link, { metaKey: true });
expect(router.state.location.pathname).toBe("/issues");
});
it("выбор исполнителя пишет его в адрес и в запрос", async () => { it("выбор исполнителя пишет его в адрес и в запрос", async () => {
expanded(); expanded();
const fetch = hub(() => [row(1)]); const fetch = hub(() => [row(1)]);
@ -236,6 +246,37 @@ describe("список задач", () => {
await waitFor(() => expect(calls().at(-1)!.searchParams.has("hide_closed")).toBe(false)); await waitFor(() => expect(calls().at(-1)!.searchParams.has("hide_closed")).toBe(false));
}); });
it("закрытый статус в адресе до настроек: запроса нет, «Загрузка…», после настроек — один запрос без hide_closed (#60 M-3)", async () => {
const st = deferred<Response>();
const fetch = mockFetch((_m, url) => {
if (url.pathname === "/settings") return st.promise;
if (url.pathname === "/board") return json(200, SNAP);
return json(200, { issues: [row(1)] });
});
renderWithProviders(<IssuesPage project="AWPCA" />, { route: "/issues?status=Готово", path: "/issues" });
await screen.findByText("Загрузка…");
expect(issuesCalls(fetch)).toHaveLength(0);
expect(screen.queryByText("По этим фильтрам задач нет")).toBeNull();
st.resolve(json(200, { ...settings, closed_statuses: ["Готово"] }));
await screen.findByRole("link", { name: "AURORA-9001" });
expect(issuesCalls(fetch)).toHaveLength(1);
const q = issuesCalls(fetch)[0].searchParams;
expect(q.get("status")).toBe("Готово");
expect(q.has("hide_closed")).toBe(false);
});
it("R-11: /settings отвечает 500 — список всё равно запрашивается с hide_closed=1", async () => {
const fetch = mockFetch((_m, url) => {
if (url.pathname === "/settings") return json(500, { error: "boom" });
if (url.pathname === "/board") return json(200, SNAP);
return json(200, { issues: [row(1)] });
});
renderWithProviders(<IssuesPage project="AWPCA" />, { route: "/issues", path: "/issues" });
await screen.findByRole("link", { name: "AURORA-9001" });
expect(issuesCalls(fetch)).toHaveLength(1);
expect(issuesCalls(fetch)[0].searchParams.get("hide_closed")).toBe("1");
});
it("статуса по факту нет: ни фильтра, ни колонки; fact_status из адреса не уходит в запрос", async () => { it("статуса по факту нет: ни фильтра, ни колонки; fact_status из адреса не уходит в запрос", async () => {
const fetch = hub(() => [row(1)]); const fetch = hub(() => [row(1)]);
renderWithProviders(<IssuesPage project="AWPCA" />, { route: "/issues?fact_status=В работе", path: "/issues" }); renderWithProviders(<IssuesPage project="AWPCA" />, { route: "/issues?fact_status=В работе", path: "/issues" });

View File

@ -1,11 +1,12 @@
import { useEffect, useMemo, useRef, useState } from "react"; import { useEffect, useMemo, useRef, useState } from "react";
import { useSearchParams } from "react-router"; import { useLocation, useNavigate, useSearchParams } from "react-router";
import { isClosed, useClosedStatuses } from "../api/closed.ts"; import { isClosed, useClosedStatuses } from "../api/closed.ts";
import { useSettings } from "../../../api/queries.ts"; import { useSettings } from "../../../api/queries.ts";
import { ISSUE_FILTER_NAMES, useBoard, useIssues, type IssueFilters as Filters } from "../api/queries.ts"; import { ISSUE_FILTER_NAMES, useBoard, useIssues, type IssueFilters as Filters } from "../api/queries.ts";
import { Button, Notice, PageHeader } from "../../../ui/index.ts"; import { Button, Notice, PageHeader } from "../../../ui/index.ts";
import { IssueFilters, SELECTS } from "./IssueFilters.tsx"; import { IssueFilters, SELECTS } from "./IssueFilters.tsx";
import { useOpenIssue } from "./IssueLink.tsx"; import { cardPath } from "./IssueLink.tsx";
import { openCardState } from "./modal.ts";
import { RefreshButton } from "../RefreshButton.tsx"; import { RefreshButton } from "../RefreshButton.tsx";
import { IssueTable } from "./IssueTable.tsx"; import { IssueTable } from "./IssueTable.tsx";
import { epicNames, filterOptions } from "./options.ts"; import { epicNames, filterOptions } from "./options.ts";
@ -28,14 +29,32 @@ export function IssuesPage({ project }: { project: string }) {
written.current = fromUrl; written.current = fromUrl;
setQ(fromUrl); setQ(fromUrl);
}, [filters.q]); }, [filters.q]);
const timer = useRef<ReturnType<typeof setTimeout> | undefined>(undefined);
useEffect(() => { useEffect(() => {
if (q === (filters.q ?? "")) return; if (q === (filters.q ?? "")) return;
const t = setTimeout(() => { timer.current = setTimeout(() => {
written.current = q; written.current = q;
setParams((p) => { const n = new URLSearchParams(p); if (q) n.set("q", q); else n.delete("q"); return n; }, { replace: true }); setParams((p) => { const n = new URLSearchParams(p); if (q) n.set("q", q); else n.delete("q"); return n; }, { replace: true });
}, Q_DELAY); }, Q_DELAY);
return () => clearTimeout(t); return () => clearTimeout(timer.current);
}, [q, filters.q, setParams]); }, [q, filters.q, setParams]);
// открытие карточки до срабатывания таймера поиска: q сначала пишется в запись списка (replace), окно открывается поверх неё —
// иначе таймер переписал бы адрес карточки и закрыл окно (#42 M-1)
const loc = useLocation();
const navigate = useNavigate();
const open = (key: string) => {
clearTimeout(timer.current);
let bg = loc;
if (q !== (filters.q ?? "")) {
const n = new URLSearchParams(loc.search);
if (q) n.set("q", q); else n.delete("q");
const search = n.size ? "?" + n.toString() : "";
written.current = q;
void navigate({ pathname: loc.pathname, search }, { replace: true, state: loc.state });
bg = { ...loc, search };
}
void navigate(cardPath(key), { state: openCardState(bg) });
};
const board = useBoard(project), settings = useSettings(); const board = useBoard(project), settings = useSettings();
const opts = useMemo(() => filterOptions(board.data, settings.data), [board.data, settings.data]); const opts = useMemo(() => filterOptions(board.data, settings.data), [board.data, settings.data]);
@ -45,20 +64,20 @@ export function IssuesPage({ project }: { project: string }) {
const closed = useClosedStatuses(); const closed = useClosedStatuses();
const query = useMemo<Filters>(() => (showClosed || isClosed(filters.status, closed) ? { ...filters, project } : { ...filters, project, hide_closed: "1" }), const query = useMemo<Filters>(() => (showClosed || isClosed(filters.status, closed) ? { ...filters, project } : { ...filters, project, hide_closed: "1" }),
[filters, project, showClosed, closed]); [filters, project, showClosed, closed]);
const list = useIssues(query); const list = useIssues(query, { enabled: !settings.isPending }); // закрытые статусы — из настроек: без них запрос ушёл бы с неверным hide_closed (#60 M-3); при ошибке настроек isPending=false (R-11)
const rows = list.data?.pages.flat() ?? []; const rows = list.data?.pages.flat() ?? [];
const open = useOpenIssue(); const loading = list.isPending || settings.isPending;
return ( return (
<div data-testid="page-issues" className="issues"> <div data-testid="page-issues" className="issues">
<PageHeader title="Задачи" subtitle={list.isPending ? undefined : `показано ${rows.length}${list.hasNextPage ? " · есть ещё" : ""}`} actions={<RefreshButton />} /> <PageHeader title="Задачи" subtitle={loading ? undefined : `показано ${rows.length}${list.hasNextPage ? " · есть ещё" : ""}`} actions={<RefreshButton />} />
<IssueFilters filters={filters} set={set} onReset={reset} q={q} setQ={setQ} showClosed={showClosed} opts={opts} hasBoard={!!board.data} /> <IssueFilters filters={filters} set={set} onReset={reset} q={q} setQ={setQ} showClosed={showClosed} opts={opts} hasBoard={!!board.data} />
{list.isError && ( {list.isError && (
<Notice tone="error" action={<Button size="md" onClick={() => void list.refetch()}>Повторить</Button>}>{(list.error as Error).message}</Notice> <Notice tone="error" action={<Button size="md" onClick={() => void list.refetch()}>Повторить</Button>}>{(list.error as Error).message}</Notice>
)} )}
{rows.length > 0 && <IssueTable rows={rows} epicNames={names} closed={closed} onOpen={open} />} {rows.length > 0 && <IssueTable rows={rows} epicNames={names} closed={closed} onOpen={open} />}
{list.isPending && <p className="issues__hint">Загрузка…</p>} {loading && <p className="issues__hint">Загрузка…</p>}
{!list.isPending && !rows.length && !list.isError && <Notice tone="info">По этим фильтрам задач нет</Notice>} {!loading && !rows.length && !list.isError && <Notice tone="info">По этим фильтрам задач нет</Notice>}
{list.hasNextPage && ( {list.hasNextPage && (
<div className="issues__more"> <div className="issues__more">
<Button disabled={list.isFetchingNextPage} onClick={() => void list.fetchNextPage()}>Показать ещё</Button> <Button disabled={list.isFetchingNextPage} onClick={() => void list.fetchNextPage()}>Показать ещё</Button>

View File

@ -39,7 +39,11 @@ export function NoteForm({ issue, onDone }: { issue: IssueDetail; onDone: () =>
return () => window.removeEventListener("beforeunload", warn); return () => window.removeEventListener("beforeunload", warn);
}, [dirty]); }, [dirty]);
// подтверждение ухода может оказаться ниже видимой части окна — прокрутить к нему // подтверждение ухода может оказаться ниже видимой части окна — прокрутить к нему
useEffect(() => { if (blocker.state === "blocked") confirmRef.current?.scrollIntoView?.({ block: "nearest" }); }, [blocker.state]); useEffect(() => {
if (blocker.state !== "blocked") return;
confirmRef.current?.scrollIntoView?.({ block: "nearest" });
confirmRef.current?.querySelector<HTMLButtonElement>("[data-stay]")?.focus();
}, [blocker.state]);
const kinds = Object.keys(settings.data?.kind_labels ?? {}); const kinds = Object.keys(settings.data?.kind_labels ?? {});
const kindOptions = values.work_kind && !kinds.includes(values.work_kind) ? [values.work_kind, ...kinds] : kinds; const kindOptions = values.work_kind && !kinds.includes(values.work_kind) ? [values.work_kind, ...kinds] : kinds;
@ -79,11 +83,11 @@ export function NoteForm({ issue, onDone }: { issue: IssueDetail; onDone: () =>
</div> </div>
)} )}
{blocker.state === "blocked" && ( {blocker.state === "blocked" && (
<div className="note-confirm" role="alertdialog" ref={confirmRef}> <div className="note-confirm" role="alertdialog" aria-label="Уйти без сохранения заметки?" ref={confirmRef}>
<span>Есть несохранённые правки заметки. Уйти без сохранения?</span> <span>Есть несохранённые правки заметки. Уйти без сохранения?</span>
<div className="row"> <div className="row">
<Button onClick={() => blocker.proceed()}>Уйти</Button> <Button onClick={() => blocker.proceed()}>Уйти</Button>
<Button variant="primary" onClick={() => { blocker.reset(); abortClose(); }}>Остаться</Button> <Button variant="primary" data-stay onClick={() => { blocker.reset(); abortClose(); }}>Остаться</Button>
</div> </div>
</div> </div>
)} )}

View File

@ -10,19 +10,13 @@ export type Download = ReturnType<typeof useAttachmentDownload>;
const KIND_ICON = { image: IconImage, sheet: IconSheet, pdf: IconFile, other: IconFile }; const KIND_ICON = { image: IconImage, sheet: IconSheet, pdf: IconFile, other: IconFile };
const KIND_LABEL = { image: "картинка", sheet: "таблица", pdf: "PDF", other: "файл" }; const KIND_LABEL = { image: "картинка", sheet: "таблица", pdf: "PDF", other: "файл" };
// новые сверху: по времени Jira, затем по числовому id
export function newestFirst(items: IssueAttachment[]): IssueAttachment[] {
const t = (a: IssueAttachment) => (a.created ? Date.parse(a.created) || 0 : 0);
return [...items].sort((a, b) => t(b) - t(a) || (Number(b.attachment_id) || 0) - (Number(a.attachment_id) || 0));
}
// раздел «Вложения (N)»: значок по типу, имя, размер, «автор · дата», отметка «у хаба», кнопка скачивания // раздел «Вложения (N)»: значок по типу, имя, размер, «автор · дата», отметка «у хаба», кнопка скачивания
export function AttachmentList({ items, dl }: { items: IssueAttachment[]; dl: Download }) { export function AttachmentList({ items, dl }: { items: IssueAttachment[]; dl: Download }) {
return ( return (
<section className="attachments" data-testid="attachments"> <section className="attachments" data-testid="attachments">
<div className="sec-head"><h3>Вложения ({items.length})</h3></div> <div className="sec-head"><h3>Вложения ({items.length})</h3></div>
<ul className="att-list"> <ul className="att-list">
{newestFirst(items).map((a) => <Row key={a.attachment_id} a={a} dl={dl} />)} {items.map((a) => <Row key={a.attachment_id} a={a} dl={dl} />)}
</ul> </ul>
</section> </section>
); );
@ -31,9 +25,9 @@ export function AttachmentList({ items, dl }: { items: IssueAttachment[]; dl: Do
function Row({ a, dl }: { a: IssueAttachment; dl: Download }) { function Row({ a, dl }: { a: IssueAttachment; dl: Download }) {
const kind = fileKind(a.mime_type, a.filename); const kind = fileKind(a.mime_type, a.filename);
const Icon = KIND_ICON[kind]; const Icon = KIND_ICON[kind];
const { busy, error: local } = dl.status(a.attachment_id); const { busy, error: local, ok } = dl.status(a.attachment_id);
// ошибка: своя (последняя попытка в этой карточке) или команда, закончившаяся failed до открытия карточки // ошибка: своя (последняя попытка в этой карточке) или команда, закончившаяся failed до открытия карточки
const error = busy ? null : local ?? (a.state === "failed" ? a.error ?? "не удалось скачать вложение" : null); const error = busy ? null : local ?? (a.state === "failed" && !ok ? a.error ?? "не удалось скачать вложение" : null);
const who = [a.author, a.created ? fmtStamp(a.created) : null].filter(Boolean).join(" · "); const who = [a.author, a.created ? fmtStamp(a.created) : null].filter(Boolean).join(" · ");
return ( return (
<li className="att-row"> <li className="att-row">

View File

@ -4,7 +4,7 @@ import type { ReactNode } from "react";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { makeQueryClient } from "../../../../api/queryClient.ts"; import { makeQueryClient } from "../../../../api/queryClient.ts";
import type { IssueAttachment } from "../../api/types.ts"; import type { IssueAttachment } from "../../api/types.ts";
import { setToken } from "../../../../auth/token.ts"; import { getToken, setToken } from "../../../../auth/token.ts";
import { json, mockFetch } from "../../../../test/fetchMock.ts"; import { json, mockFetch } from "../../../../test/fetchMock.ts";
import { useAttachmentDownload } from "./useAttachmentDownload.ts"; import { useAttachmentDownload } from "./useAttachmentDownload.ts";
@ -38,7 +38,7 @@ describe("useAttachmentDownload", () => {
const { result } = renderHook(() => useAttachmentDownload("AURORA-1650"), { wrapper }); const { result } = renderHook(() => useAttachmentDownload("AURORA-1650"), { wrapper });
act(() => result.current.start(att)); act(() => result.current.start(att));
await flush(); await flush();
expect(result.current.status("31337")).toEqual({ busy: true, error: null }); expect(result.current.status("31337")).toEqual({ busy: true, error: null, ok: false });
expect(JSON.parse(String(f.mock.calls[0][1]?.body))).toEqual({ author: "web" }); expect(JSON.parse(String(f.mock.calls[0][1]?.body))).toEqual({ author: "web" });
await act(async () => { await vi.advanceTimersByTimeAsync(2000); }); await act(async () => { await vi.advanceTimersByTimeAsync(2000); });
expect(polls).toBe(1); expect(polls).toBe(1);
@ -48,7 +48,7 @@ describe("useAttachmentDownload", () => {
expect(URL.revokeObjectURL).not.toHaveBeenCalled(); // I-2: отзыв отложен expect(URL.revokeObjectURL).not.toHaveBeenCalled(); // I-2: отзыв отложен
await act(async () => { await vi.advanceTimersByTimeAsync(60_000); }); await act(async () => { await vi.advanceTimersByTimeAsync(60_000); });
expect(URL.revokeObjectURL).toHaveBeenCalledWith("blob:fake"); expect(URL.revokeObjectURL).toHaveBeenCalledWith("blob:fake");
expect(result.current.status("31337")).toEqual({ busy: false, error: null }); expect(result.current.status("31337")).toEqual({ busy: false, error: null, ok: true });
await act(async () => { await vi.advanceTimersByTimeAsync(10000); }); await act(async () => { await vi.advanceTimersByTimeAsync(10000); });
expect(polls).toBe(2); expect(polls).toBe(2);
expect(f.mock.calls.filter(([u]) => String(u).endsWith("/content"))).toHaveLength(1); expect(f.mock.calls.filter(([u]) => String(u).endsWith("/content"))).toHaveLength(1);
@ -84,7 +84,7 @@ describe("useAttachmentDownload", () => {
const n = f.mock.calls.length; const n = f.mock.calls.length;
await act(async () => { await vi.advanceTimersByTimeAsync(10000); }); await act(async () => { await vi.advanceTimersByTimeAsync(10000); });
expect(f.mock.calls.length).toBe(n); expect(f.mock.calls.length).toBe(n);
expect(result.current.status("31337")).toEqual({ busy: false, error: null }); expect(result.current.status("31337")).toEqual({ busy: false, error: null, ok: false });
}); });
it("409 — ошибка с текстом хаба", async () => { it("409 — ошибка с текстом хаба", async () => {
@ -92,10 +92,29 @@ describe("useAttachmentDownload", () => {
const { result } = renderHook(() => useAttachmentDownload("AURORA-1650"), { wrapper }); const { result } = renderHook(() => useAttachmentDownload("AURORA-1650"), { wrapper });
act(() => result.current.start(att)); act(() => result.current.start(att));
await flush(); await flush();
expect(result.current.status("31337")).toEqual({ busy: false, error: "агент v13 не умеет скачивать вложения — нужен v14" }); expect(result.current.status("31337")).toEqual({ busy: false, error: "агент v13 не умеет скачивать вложения — нужен v14", ok: false });
expect(clicked).toHaveLength(0); expect(clicked).toHaveLength(0);
}); });
it("401 при опросе — сброс токена, без текста ошибки", async () => {
mockFetch((m) => m === "POST" ? json(200, st("fetching")) : json(401, { error: "unauthorized" }));
const { result } = renderHook(() => useAttachmentDownload("AURORA-1650"), { wrapper });
act(() => result.current.start(att));
await flush();
await act(async () => { await vi.advanceTimersByTimeAsync(2000); });
expect(getToken()).toBe("");
expect(result.current.status("31337").error).toBeNull();
});
it("сеть недоступна при опросе — «хаб не отвечает»", async () => {
mockFetch((m) => { if (m === "POST") return json(200, st("fetching")); throw new TypeError("fetch failed"); });
const { result } = renderHook(() => useAttachmentDownload("AURORA-1650"), { wrapper });
act(() => result.current.start(att));
await flush();
await act(async () => { await vi.advanceTimersByTimeAsync(2000); });
expect(result.current.status("31337").error).toBe("хаб не отвечает");
});
it("failed при опросе — ошибка команды; повторное start сбрасывает ошибку", async () => { it("failed при опросе — ошибка команды; повторное start сбрасывает ошибку", async () => {
let n = 0; let n = 0;
mockFetch((m) => m === "POST" ? json(200, st("fetching")) : json(200, ++n === 1 ? st("failed", { error: "Jira: 404" }) : st("fetching"))); mockFetch((m) => m === "POST" ? json(200, st("fetching")) : json(200, ++n === 1 ? st("failed", { error: "Jira: 404" }) : st("fetching")));
@ -103,9 +122,9 @@ describe("useAttachmentDownload", () => {
act(() => result.current.start(att)); act(() => result.current.start(att));
await flush(); await flush();
await act(async () => { await vi.advanceTimersByTimeAsync(2000); }); await act(async () => { await vi.advanceTimersByTimeAsync(2000); });
expect(result.current.status("31337")).toEqual({ busy: false, error: "Jira: 404" }); expect(result.current.status("31337")).toEqual({ busy: false, error: "Jira: 404", ok: false });
act(() => result.current.start(att)); act(() => result.current.start(att));
await flush(); await flush();
expect(result.current.status("31337")).toEqual({ busy: true, error: null }); expect(result.current.status("31337")).toEqual({ busy: true, error: null, ok: false });
}); });
}); });

View File

@ -1,13 +1,13 @@
import { useQueryClient } from "@tanstack/react-query"; import { useQueryClient } from "@tanstack/react-query";
import { useCallback, useEffect, useRef, useState } from "react"; import { useCallback, useEffect, useRef, useState } from "react";
import { ApiError } from "../../../../api/client.ts"; import { ApiError, AuthError, dropTokenOn, HubDownError } from "../../../../api/client.ts";
import { api } from "../../../../api/client.ts"; import { api } from "../../../../api/client.ts";
import { qk } from "../../api/queries.ts"; import { qk } from "../../api/queries.ts";
import type { AttachmentState, IssueAttachment } from "../../api/types.ts"; import type { AttachmentState, IssueAttachment } from "../../api/types.ts";
export const POLL_MS = 2000; export const POLL_MS = 2000;
export interface DownloadStatus { busy: boolean; error: string | null } export interface DownloadStatus { busy: boolean; error: string | null; ok: boolean } // ok — успех в этой карточке
const IDLE: DownloadStatus = { busy: false, error: null }; const IDLE: DownloadStatus = { busy: false, error: null, ok: false };
// браузер сохраняет файл под своим именем: Blob → временная ссылка download (токен в адресе не появляется) // браузер сохраняет файл под своим именем: Blob → временная ссылка download (токен в адресе не появляется)
function save(blob: Blob, filename: string) { function save(blob: Blob, filename: string) {
@ -56,7 +56,12 @@ export function useAttachmentDownload(issueKey: string) {
if (mapRef.current[id]?.busy) return; if (mapRef.current[id]?.busy) return;
const my = gen.current; const my = gen.current;
const alive = () => gen.current === my; const alive = () => gen.current === my;
const fail = (error: string) => { if (alive()) put(id, { busy: false, error }); }; const fail = (error: string) => { if (alive()) put(id, { busy: false, error, ok: false }); };
// 401 — экран входа без текста ошибки; сеть/5xx — «хаб не отвечает»; прочее — текст ошибки
const onError = (e: unknown) => {
if (e instanceof AuthError) { dropTokenOn(e); if (alive()) put(id, IDLE); }
else fail(e instanceof HubDownError ? "хаб не отвечает" : errorText(e));
};
// разбор состояния: ready — забрать файл, fetching — подождать и спросить снова, иначе — ошибка // разбор состояния: ready — забрать файл, fetching — подождать и спросить снова, иначе — ошибка
const handle = async (st: AttachmentState): Promise<void> => { const handle = async (st: AttachmentState): Promise<void> => {
@ -65,13 +70,13 @@ export function useAttachmentDownload(issueKey: string) {
const blob = await api.blob(`/attachments/${encodeURIComponent(id)}/content`); const blob = await api.blob(`/attachments/${encodeURIComponent(id)}/content`);
if (!alive()) return; if (!alive()) return;
save(blob, a.filename); save(blob, a.filename);
put(id, IDLE); put(id, { busy: false, error: null, ok: true });
void qc.invalidateQueries({ queryKey: qk.issue(issueKey) }); // отметка «у хаба» void qc.invalidateQueries({ queryKey: qk.issue(issueKey) }); // отметка «у хаба»
} else if (st.state === "fetching") { } else if (st.state === "fetching") {
const t = setTimeout(() => { const t = setTimeout(() => {
timers.current.delete(t); timers.current.delete(t);
if (!alive()) return; if (!alive()) return;
api.get<AttachmentState>(`/attachments/${encodeURIComponent(id)}`).then(handle).catch((e) => fail(errorText(e))); api.get<AttachmentState>(`/attachments/${encodeURIComponent(id)}`).then(handle).catch(onError);
}, POLL_MS); }, POLL_MS);
timers.current.add(t); timers.current.add(t);
} else { } else {
@ -79,9 +84,9 @@ export function useAttachmentDownload(issueKey: string) {
} }
}; };
put(id, { busy: true, error: null }); put(id, { busy: true, error: null, ok: false });
api.post<AttachmentState>(`/attachments/${encodeURIComponent(id)}/fetch`, { author: "web" }) api.post<AttachmentState>(`/attachments/${encodeURIComponent(id)}/fetch`, { author: "web" })
.then(handle).catch((e) => fail(errorText(e))); .then(handle).catch(onError);
}, [issueKey, put, qc]); }, [issueKey, put, qc]);
const status = useCallback((id: string): DownloadStatus => map[id] ?? IDLE, [map]); const status = useCallback((id: string): DownloadStatus => map[id] ?? IDLE, [map]);

View File

@ -1,6 +1,6 @@
import { describe, expect, it } from "vitest"; import { describe, expect, it } from "vitest";
import settings from "../board/__fixtures__/settings.json"; import settings from "../board/__fixtures__/settings.json";
import { groupLinks, isOverdue, todayISO } from "./card.ts"; import { groupLinks, isOverdue, splitKindLabels, todayISO } from "./card.ts";
const types = settings.link_types as Record<string, string>; const types = settings.link_types as Record<string, string>;
const closed = new Set(["Готово", "Снято"]); const closed = new Set(["Готово", "Снято"]);
@ -50,3 +50,13 @@ describe("просрочка", () => {
expect(todayISO(new Date(2026, 0, 5, 23, 30))).toBe("2026-01-05"); expect(todayISO(new Date(2026, 0, 5, 23, 30))).toBe("2026-01-05");
}); });
}); });
describe("splitKindLabels", () => {
it("метки видов работ отдельно от прочих", () => {
expect(splitKindLabels(["AWPCA", "kind-dev", "kind-ui"], { разработка: "kind-dev", UI: "kind-ui" }))
.toEqual({ kinds: ["разработка", "UI"], rest: ["AWPCA"] });
});
it("без kind_labels — все метки прочие (Review Focus 4)", () => {
expect(splitKindLabels(["AWPCA", "kind-dev"], undefined)).toEqual({ kinds: [], rest: ["AWPCA", "kind-dev"] });
});
});

View File

@ -31,3 +31,14 @@ export function isOverdue(due: string, status: string | null | undefined, closed
export const todayISO = (d = new Date()): string => export const todayISO = (d = new Date()): string =>
`${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, "0")}-${String(d.getDate()).padStart(2, "0")}`; `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, "0")}-${String(d.getDate()).padStart(2, "0")}`;
// метки задачи: виды работ (метка из kind_labels → название вида) отдельно от прочих меток
export function splitKindLabels(labels: string[], kindLabels: Record<string, string> | undefined): { kinds: string[]; rest: string[] } {
const byLabel = new Map(Object.entries(kindLabels ?? {}).map(([kind, label]) => [label, kind]));
const kinds: string[] = [], rest: string[] = [];
for (const l of labels) {
const k = byLabel.get(l);
if (k !== undefined) kinds.push(k); else rest.push(l);
}
return { kinds, rest };
}

View File

@ -34,16 +34,31 @@ export function useIssueOverlay(): Overlay {
void navigate(ISSUES_PATH + direct.search, { replace: true }); void navigate(ISSUES_PATH + direct.search, { replace: true });
} }
}, [card, navigate, direct.search]); }, [card, navigate, direct.search]);
// повторное закрытие (двойной щелчок по ✕) на той же записи истории игнорируется (#42 M-2)
const closingKey = useRef<string | null>(null);
useEffect(() => { closingKey.current = null; }, [location.key]);
const close = useCallback(() => { const close = useCallback(() => {
if (closingKey.current === location.key) return;
closingKey.current = location.key;
if (!card || card.depth === 0) { void navigate(ISSUES_PATH + direct.search, { replace: true }); return; } if (!card || card.depth === 0) { void navigate(ISSUES_PATH + direct.search, { replace: true }); return; }
closingDirect.current = card.direct; closingDirect.current = card.direct;
void navigate(-card.depth); void navigate(-card.depth);
}, [card, navigate, direct.search]); }, [card, navigate, direct.search, location.key]);
const abortClose = useCallback(() => { closingDirect.current = false; }, []); const abortClose = useCallback(() => { closingDirect.current = false; closingKey.current = null; }, []);
// «Назад» в окне — только если оно открыто переходом из другой карточки (из списка depth = 1; прямой адрес — direct, depth 0)
// повторный щелчок на той же записи истории игнорируется тем же closingKey (M-1)
const onBack = card && (card.depth >= 2 || (card.direct && card.depth >= 1))
? () => {
if (closingKey.current === location.key) return;
closingKey.current = location.key;
void navigate(-1);
}
: undefined;
const element = issueKey const element = issueKey
? <CloseAbortContext value={abortClose}><IssueModal issueKey={issueKey} onClose={close} /></CloseAbortContext> ? <CloseAbortContext value={abortClose}><IssueModal issueKey={issueKey} onClose={close} onBack={onBack} /></CloseAbortContext>
: null; : null;
return { pageLocation, element }; return { pageLocation, element };
} }

View File

@ -89,13 +89,17 @@ describe("parseInline: ссылки на вложения", () => {
expect(parseInline("см. !скрин 1.png|thumbnail!", A)).toEqual([t("см. "), att("31337", t("скрин 1.png"))]); expect(parseInline("см. !скрин 1.png|thumbnail!", A)).toEqual([t("см. "), att("31337", t("скрин 1.png"))]);
expect(parseInline("!скрин 1.png!", A)).toEqual([att("31337", t("скрин 1.png"))]); expect(parseInline("!скрин 1.png!", A)).toEqual([att("31337", t("скрин 1.png"))]);
}); });
it("два вложения с одним именем — первое в порядке хаба, даже если created null", () => {
const o = { attachments: [{ id: "5", filename: "скрин.png", created: null }, { id: "9", filename: "скрин.png", created: "2026-10-20T10:00:00.000+0300" }] };
expect(parseInline("!скрин.png!", o)).toEqual([att("5", t("скрин.png"))]);
});
it("[^имя] и [текст|^имя]", () => { it("[^имя] и [текст|^имя]", () => {
expect(parseInline("[^a.pdf]", A)).toEqual([att("31338", t("a.pdf"))]); expect(parseInline("[^a.pdf]", A)).toEqual([att("31338", t("a.pdf"))]);
expect(parseInline("[отчёт|^a.pdf]", A)).toEqual([att("31338", t("отчёт"))]); expect(parseInline("[отчёт|^a.pdf]", A)).toEqual([att("31338", t("отчёт"))]);
}); });
it("имя точно (с регистром); несколько с одним именем — самое новое", () => { it("имя точно (с регистром); несколько с одним именем — первое в порядке хаба", () => {
expect(parseInline("!Скрин 1.png!", A)).toEqual([t("!Скрин 1.png!")]); expect(parseInline("!Скрин 1.png!", A)).toEqual([t("!Скрин 1.png!")]);
expect(parseInline("!дубль.png!", A)).toEqual([att("20", t("дубль.png"))]); expect(parseInline("!дубль.png!", A)).toEqual([att("10", t("дубль.png"))]); // первое в порядке хаба
}); });
it("имени нет среди вложений — исходник как есть", () => { it("имени нет среди вложений — исходник как есть", () => {
expect(parseInline("!нет.png!", A)).toEqual([t("!нет.png!")]); expect(parseInline("!нет.png!", A)).toEqual([t("!нет.png!")]);
@ -116,4 +120,10 @@ describe("parseInline: ссылки на вложения", () => {
it("внутри подписи ссылки вложения нет (кнопка в ссылке недопустима)", () => { it("внутри подписи ссылки вложения нет (кнопка в ссылке недопустима)", () => {
expect(parseInline("[!a.pdf!|https://x.y]", A)).toEqual([link("https://x.y", t("!a.pdf!"))]); expect(parseInline("[!a.pdf!|https://x.y]", A)).toEqual([link("https://x.y", t("!a.pdf!"))]);
}); });
it("\\# — символ решётки (M-5)", () => {
expect(parseInline("\\# не пункт")).toEqual([{ t: "text", text: "# не пункт" }]);
});
it("[~логин|текст] — упоминание логина без подписи (M-6)", () => {
expect(parseInline("[~ivanov|Иванов]")).toEqual([{ t: "mention", login: "ivanov" }]);
});
}); });

View File

@ -1,9 +1,10 @@
// Строчная вики-разметка Jira (design wiki-render #4, #5): выделение, {{моно}}, ссылки, упоминания, ключи задач. // Строчная вики-разметка Jira (design wiki-render #4, #5): выделение, {{моно}}, ссылки, упоминания, ключи задач.
// Вход — одна строка без переводов строк; разбор не бросает исключений, нераспознанное остаётся текстом как есть. // Вход — одна строка без переводов строк; разбор не бросает исключений, нераспознанное остаётся текстом как есть.
// вложения задачи для разбора ссылок `!имя!`, `[^имя]` (как у хаба: имя точно, при повторе имени — самое новое) // вложения задачи для разбора ссылок `!имя!`, `[^имя]` (как у хаба: имя точно, при повторе имени — первое в порядке хаба, новые сверху)
export interface WikiAttachment { id: string; filename: string; created: string | null } export interface WikiAttachment { id: string; filename: string; created: string | null }
export interface WikiOptions { projectKey?: string; attachments?: WikiAttachment[] } // anyAttachment — только для плоского текста (wikiPlain): `!имя!` и `[^имя]` — вложение, даже если имени нет в списке
export interface WikiOptions { projectKey?: string; attachments?: WikiAttachment[]; anyAttachment?: boolean }
type Mark = "strong" | "em" | "del" | "ins" | "sup" | "sub"; type Mark = "strong" | "em" | "del" | "ins" | "sup" | "sub";
@ -18,13 +19,15 @@ export type Inline =
| { t: "br" }; | { t: "br" };
const MARKS: Record<string, Mark> = { "*": "strong", _: "em", "-": "del", "+": "ins", "^": "sup", "~": "sub" }; const MARKS: Record<string, Mark> = { "*": "strong", _: "em", "-": "del", "+": "ins", "^": "sup", "~": "sub" };
const ESCAPABLE = new Set([..."*_-+^~{}[]|!\\"]); const ESCAPABLE = new Set([..."*_-+^~{}[]|!#\\"]);
const WORD = /[\p{L}\p{N}]/u; const WORD = /[\p{L}\p{N}]/u;
const SPACE = /\s/; const SPACE = /\s/;
const URL = /https?:\/\/[^\s\]|<>"]+/y; const URL = /https?:\/\/[^\s\]|<>"]+/y;
const URL_TAIL = /[.,;:!?)]+$/; const URL_TAIL = /[.,;:!?)]+$/;
const IMAGE = /!([^!|\n]+)(?:\|[^!\n]*)?!/y; const IMAGE = /!([^!|\n]+)(?:\|[^!\n]*)?!/y;
const ATT_LINK = /^(?:([^|]*)\|)?\^(.+)$/; const ATT_LINK = /^(?:([^|]*)\|)?\^(.+)$/;
// имя файла с расширением, без пробелов по краям: только такое `!имя!` без списка вложений — вложение (N-1)
const FILE_NAME = /^\S(?:.*\S)?\.[\p{L}\p{N}]{1,10}$/u;
// схема ссылки — только http, https, mailto (спека «Разметка Jira в карточке»); иначе ссылки нет // схема ссылки — только http, https, mailto (спека «Разметка Jira в карточке»); иначе ссылки нет
function safeHref(raw: string): string | null { function safeHref(raw: string): string | null {
@ -32,16 +35,11 @@ function safeHref(raw: string): string | null {
return /^(https?:\/\/|mailto:)\S/i.test(href) ? href : null; return /^(https?:\/\/|mailto:)\S/i.test(href) ? href : null;
} }
// вложение по имени: несколько с одним именем — самое новое по created, затем по числовому id // вложение по имени: несколько с одним именем — первое в порядке хаба; image — `!имя!`, без списка только имя файла,
function findAttachment(opts: WikiOptions, name: string): string | null { // иначе «Срочно! Проверьте!» в ленте теряет «!» (N-1)
const same = (opts.attachments ?? []).filter((a) => a.filename === name); function findAttachment(opts: WikiOptions, name: string, image = false): string | null {
if (!same.length) return null; const found = (opts.attachments ?? []).find((a) => a.filename === name)?.id;
const rank = (a: WikiAttachment): [number, number] => [a.created ? Date.parse(a.created) || 0 : 0, Number(a.id) || 0]; return found ?? (opts.anyAttachment && (!image || FILE_NAME.test(name)) ? "" : null);
return same.reduce((best, a) => {
const [bt, bi] = rank(best);
const [at, ai] = rank(a);
return at > bt || (at === bt && ai > bi) ? a : best;
}).id;
} }
function keyPattern(projectKey: string | undefined): RegExp | null { function keyPattern(projectKey: string | undefined): RegExp | null {
@ -88,11 +86,11 @@ export function parseInline(line: string, opts: WikiOptions = {}, label = false)
text(c); i += 1; continue; text(c); i += 1; continue;
} }
if (c === "!" && !label && opts.attachments?.length) { if (c === "!" && !label && (opts.attachments?.length || opts.anyAttachment)) {
IMAGE.lastIndex = i; IMAGE.lastIndex = i;
const m = IMAGE.exec(line); const m = IMAGE.exec(line);
const id = m && !m[1].includes("://") ? findAttachment(opts, m[1]) : null; const id = m && !m[1].includes("://") ? findAttachment(opts, m[1], true) : null;
if (m && id) { out.push({ t: "attachment", id, children: [{ t: "text", text: m[1] }] }); i += m[0].length; continue; } if (m && id !== null) { out.push({ t: "attachment", id, children: [{ t: "text", text: m[1] }] }); i += m[0].length; continue; }
} }
if (c === "h" && !label && prevOk(i)) { if (c === "h" && !label && prevOk(i)) {
@ -147,13 +145,13 @@ function closing(line: string, i: number): number {
// содержимое [...]: упоминание, ключ задачи, ссылка с проверенной схемой; иначе null — текст как есть // содержимое [...]: упоминание, ключ задачи, ссылка с проверенной схемой; иначе null — текст как есть
function bracket(body: string, opts: WikiOptions, isKey: (s: string) => boolean): Inline | null { function bracket(body: string, opts: WikiOptions, isKey: (s: string) => boolean): Inline | null {
if (body.startsWith("~")) { if (body.startsWith("~")) {
const login = body.slice(1).trim(); const login = body.slice(1).split("|")[0].trim();
return login && !SPACE.test(login) ? { t: "mention", login } : null; return login && !SPACE.test(login) ? { t: "mention", login } : null;
} }
const ref = ATT_LINK.exec(body); const ref = ATT_LINK.exec(body);
if (ref) { if (ref) {
const id = findAttachment(opts, ref[2]); const id = findAttachment(opts, ref[2]);
return id ? { t: "attachment", id, children: ref[1] ? parseInline(ref[1], opts, true) : [{ t: "text", text: ref[2] }] } : null; return id !== null ? { t: "attachment", id, children: ref[1] ? parseInline(ref[1], opts, true) : [{ t: "text", text: ref[2] }] } : null;
} }
const bar = body.indexOf("|"); const bar = body.indexOf("|");
const label = bar >= 0 ? body.slice(0, bar) : ""; const label = bar >= 0 ? body.slice(0, bar) : "";

View File

@ -77,4 +77,34 @@ describe("parseWiki: блоки-макросы", () => {
it("{quote} не в начале строки — текст (R-8)", () => expect(parseWiki("см. {quote}x{quote}")).toEqual([p(t("см. {quote}x{quote}"))])); it("{quote} не в начале строки — текст (R-8)", () => expect(parseWiki("см. {quote}x{quote}")).toEqual([p(t("см. {quote}x{quote}"))]));
it.each(["{color:red}важно{color}", "!схема.png!", "{panel}x{panel}"])("неизвестное %s — текст как есть", (src) => it.each(["{color:red}важно{color}", "!схема.png!", "{panel}x{panel}"])("неизвестное %s — текст как есть", (src) =>
expect(parseWiki(src)).toEqual([p(t(src))])); expect(parseWiki(src)).toEqual([p(t(src))]));
it("\\# в начале строки — абзац, не список", () => {
expect(parseWiki("\\# не пункт")).toEqual([{ t: "p", children: [{ t: "text", text: "# не пункт" }] }]);
});
it("quote и noformat с параметрами (M-5)", () => {
expect(parseWiki("{quote:title=Итог}\nсогласовано\n{quote}")).toEqual([{ t: "quote", children: [{ t: "p", children: [{ t: "text", text: "согласовано" }] }] }]);
expect(parseWiki("{noformat:borderStyle=solid}\n*x*\n{noformat}")).toEqual([{ t: "pre", text: "*x*" }]);
});
it("пробелы после открывающего {code} не дают пустой строки (M-6)", () => {
expect(parseWiki("{code} \nx = 1\n{code}")).toEqual([{ t: "pre", text: "x = 1" }]);
});
it("непарная [ в ячейке не съедает разделители (M-6)", () => {
const [table] = parseWiki("|[draft|итог|готово|");
expect(table).toMatchObject({ t: "table" });
expect((table as { rows: { children: { text: string }[] }[][] }).rows[0].map((c) => c.children[0].text)).toEqual(["[draft", "итог", "готово"]);
});
it("непарная [ не берёт чужую ] из ссылки в следующей ячейке (T1.1)", () => {
const [table] = parseWiki("|[draft|итог|[док|https://x.ru]|");
const cells = (table as { rows: { children: Inline[] }[][] }).rows[0];
expect(cells).toHaveLength(3);
expect(cells[0].children[0]).toEqual({ t: "text", text: "[draft" });
expect(cells[1].children[0]).toEqual({ t: "text", text: "итог" });
expect(cells[2].children[0]).toMatchObject({ t: "link", href: "https://x.ru" });
});
it("{code} {code} в одной строке — пустой блок, не pre из пробелов (T1.2)", () => {
expect(parseWiki("{code} {code}")).toEqual([{ t: "pre", text: "" }]);
});
it("| внутри [текст|адрес] ячейку по-прежнему не делит", () => {
const [table] = parseWiki("|[док|https://x.ru]|b|");
expect((table as { rows: unknown[][] }).rows[0]).toHaveLength(2);
});
}); });

View File

@ -14,7 +14,7 @@ export type Block =
| { t: "quote"; children: Block[] } | { t: "quote"; children: Block[] }
| { t: "hr" }; | { t: "hr" };
const MACRO = /^\s*\{(code(?::[^}]*)?|noformat|quote)\}/; const MACRO = /^\s*\{(code|noformat|quote)(?::[^}]*)?\}/;
const HEADING = /^\s*h([1-6])\.\s+(.*)$/; const HEADING = /^\s*h([1-6])\.\s+(.*)$/;
const BQ = /^\s*bq\.\s+(.*)$/; const BQ = /^\s*bq\.\s+(.*)$/;
const HR = /^\s*----\s*$/; const HR = /^\s*----\s*$/;
@ -101,15 +101,16 @@ function blocks(src: string[], opts: WikiOptions): Block[] {
// закрывающий тег макроса в той же строке после открывающего или в последующих; null — не закрыт // закрывающий тег макроса в той же строке после открывающего или в последующих; null — не закрыт
function closeMacro(lines: string[], start: number, open: RegExpExecArray): { name: string; body: string[]; end: number; after: string } | null { function closeMacro(lines: string[], start: number, open: RegExpExecArray): { name: string; body: string[]; end: number; after: string } | null {
const name = open[1].split(":")[0]; const name = open[1];
const tag = `{${name}}`; const tag = `{${name}}`;
const rest = lines[start].slice(open[0].length); const rest = lines[start].slice(open[0].length);
const body: string[] = []; const body: string[] = [];
for (let k = start; k < lines.length; k++) { for (let k = start; k < lines.length; k++) {
const text = k === start ? rest : lines[k]; const text = k === start ? rest : lines[k];
const at = text.indexOf(tag); const at = text.indexOf(tag);
if (at < 0) { if (k !== start || text) body.push(text); continue; } if (at < 0) { if (k !== start || text.trim()) body.push(text); continue; }
if (text.slice(0, at)) body.push(text.slice(0, at)); const head = text.slice(0, at);
if (head && (k !== start || head.trim())) body.push(head);
return { name, body, end: k, after: text.slice(at + tag.length) }; return { name, body, end: k, after: text.slice(at + tag.length) };
} }
return null; return null;
@ -126,7 +127,7 @@ function row(line: string, opts: WikiOptions): Cell[] {
let depth = 0; let depth = 0;
// `|` внутри [ссылки|адрес] ячейку не режет // `|` внутри [ссылки|адрес] ячейку не режет
while (j < s.length && (s[j] !== "|" || depth > 0)) { while (j < s.length && (s[j] !== "|" || depth > 0)) {
if (s[j] === "[") depth++; if (s[j] === "[") { if (/^[^[\]]*\]/.test(s.slice(j + 1))) depth++; }
else if (s[j] === "]" && depth > 0) depth--; else if (s[j] === "]" && depth > 0) depth--;
j++; j++;
} }

View File

@ -0,0 +1,32 @@
import { describe, expect, it } from "vitest";
import { wikiPlain } from "./plain.ts";
const norm = (s: string) => s.replace(/\s+/g, " ").trim();
describe("wikiPlain", () => {
it("выделение, ссылки, упоминание, заголовок", () => {
expect(norm(wikiPlain("h3. Итог\n*Готово*, см. [протокол|https://x.ru/p] и [~ivanov]"))).toBe("Итог Готово, см. протокол и @ivanov");
});
it("[адрес] — сам адрес, {{моно}} и {code} — содержимое, ---- — пусто", () => {
expect(norm(wikiPlain("[https://x.ru] {{a_b}}\n----\n{code}\nx = 1\n{code}"))).toBe("https://x.ru a_b x = 1");
});
it("ключ задачи и вложение — текстом", () => {
const opts = { projectKey: "AURORA", attachments: [{ id: "1", filename: "скрин.png", created: null }] };
expect(wikiPlain("см. AURORA-1 и !скрин.png!", opts).trim()).toBe("см. AURORA-1 и скрин.png");
});
it("вложение, которого нет в списке, — именем (I-1)", () => {
expect(wikiPlain("!скрин.png|width=50! [текст|^отчёт.pdf] [^а.txt]").trim()).toBe("скрин.png текст а.txt");
});
it("«!» в обычном тексте без списка вложений остаются (N-1)", () => {
expect(wikiPlain("Срочно! Проверьте! Ок").trim()).toBe("Срочно! Проверьте! Ок");
expect(wikiPlain("Готово! см. лог.txt!").trim()).toBe("Готово! см. лог.txt!");
expect(wikiPlain("Ура!Сделано!").trim()).toBe("Ура!Сделано!");
});
it("незакрытая и неизвестная разметка — как есть, пустой текст — пусто (Review Focus 3)", () => {
expect(wikiPlain("{color:red}важно *без конца").trim()).toBe("{color:red}важно *без конца");
expect(wikiPlain("")).toBe("");
});
it("списки, таблицы, цитаты — содержимое через пробел", () => {
expect(norm(wikiPlain("* a\n** b\n# c\n||h1||h2||\n|x|y|\nbq. цит"))).toBe("a b c h1 h2 x y цит");
});
});

View File

@ -0,0 +1,35 @@
// Текст вики-разметки Jira без разметки — как читается отрендеренным (лента изменений, С6).
// Блоки соединены пробелом; «одну строку» и обрезку делает вызывающий.
import type { Inline, WikiOptions } from "./inline.ts";
import { type Block, type ListBlock, parseWiki } from "./parse.ts";
function inline(nodes: Inline[]): string {
return nodes.map((n): string => {
switch (n.t) {
case "text": case "code": return n.text;
case "mention": return "@" + n.login;
case "br": return " ";
case "issue": return n.children.length ? inline(n.children) : n.key;
default: return inline(n.children);
}
}).join("");
}
function list(l: ListBlock): string[] {
return l.items.flatMap((it) => [inline(it.children), ...it.lists.flatMap(list)]);
}
function block(b: Block): string[] {
switch (b.t) {
case "heading": case "p": return [inline(b.children)];
case "list": return list(b);
case "table": return b.rows.flatMap((r) => r.map((c) => inline(c.children)));
case "pre": return [b.text];
case "quote": return b.children.flatMap(block);
case "hr": return [];
}
}
export function wikiPlain(text: string, opts: WikiOptions = {}): string {
return parseWiki(text, { ...opts, anyAttachment: true }).flatMap(block).join(" ");
}

View File

@ -1,15 +1,18 @@
import type { ReactNode } from "react"; import type { ReactNode } from "react";
import type { Project } from "../api/types.ts"; import type { Project } from "../api/types.ts";
import { Button, Notice } from "../ui/index.ts"; import { Button, Notice } from "../ui/index.ts";
import { useProjects } from "../api/queries.ts";
import { useProjectContext } from "./ProjectContext.ts"; import { useProjectContext } from "./ProjectContext.ts";
import "./projects.css"; import "./projects.css";
// страница доски/списка показывается, только когда текущий проект определён; иначе — причина и что делать // страница доски/списка показывается, только когда текущий проект определён; иначе — причина и что делать
export function ProjectGate({ children }: { children: (p: Project) => ReactNode }) { export function ProjectGate({ children }: { children: (p: Project) => ReactNode }) {
const { state, openNew } = useProjectContext(); const { state, openNew } = useProjectContext();
const { refetch } = useProjects();
const retry = () => { void refetch(); };
switch (state.kind) { switch (state.kind) {
case "loading": return <div className="project-gate"><Notice tone="info">Загрузка проектов…</Notice></div>; case "loading": return <div className="project-gate"><Notice tone="info">Загрузка проектов…</Notice></div>;
case "error": return <div className="project-gate"><Notice tone="error">Проекты не загрузились: {state.error.message}</Notice></div>; case "error": return <div className="project-gate"><Notice tone="error" action={<Button onClick={retry}>Повторить</Button>}>Проекты не загрузились: {state.error.message}</Notice></div>;
case "unknown": case "unknown":
return <div className="project-gate" data-testid="project-unknown"> return <div className="project-gate" data-testid="project-unknown">
<Notice tone="warning" title={`проект ${state.label} не найден`}>выберите проект в списке «Проект» боковой панели (на узком экране — в «Меню»)</Notice></div>; <Notice tone="warning" title={`проект ${state.label} не найден`}>выберите проект в списке «Проект» боковой панели (на узком экране — в «Меню»)</Notice></div>;

View File

@ -263,5 +263,15 @@ describe("адреса модулей и заголовок вкладки", ()
void router.navigate("/?project=AWPCA"); void router.navigate("/?project=AWPCA");
await waitFor(() => expect(document.title).toBe("Обзор · AWPCA · SDLC Hub")); await waitFor(() => expect(document.title).toBe("Обзор · AWPCA · SDLC Hub"));
}); });
it("ошибка загрузки проектов: «Повторить» перезапрашивает список", async () => {
let n = 0;
const h = hub([A, S], { "GET /projects": () => (++n === 1 ? json(500, { error: "boom" }) : json(200, { projects: [A, S] })) });
open("/app/jira/links?project=AWPCA");
expect(await screen.findByText(/Проекты не загрузились/)).toBeInTheDocument();
await userEvent.click(screen.getByRole("button", { name: "Повторить" }));
await waitFor(() => expect(screen.queryByText(/Проекты не загрузились/)).not.toBeInTheDocument());
await waitFor(() => expect(h.get("/board").length).toBeGreaterThan(0));
});
}); });

View File

@ -124,7 +124,8 @@ function useTracker(m: (typeof TRACKED)[number], { qc, project, pollMs, agents }
}, [qc, status, setActiveNow]); }, [qc, status, setActiveNow]);
useEffect(() => { useEffect(() => {
if (!active) return; // #116: ответ обрабатывается, только если отслеживаемый пакет — тот же объект, что в activeRef (эффект смены проекта объявлен выше и в том же коммите обнуляет activeRef)
if (!active || activeRef.current !== active) return;
const b = batch.data, e = batch.error; const b = batch.data, e = batch.error;
if (b && b.id === active.id && !batch.isError) { if (b && b.id === active.id && !batch.isError) {
if (terminal(b)) { if (terminal(b)) {

View File

@ -337,6 +337,41 @@ describe("ActivityProvider", () => {
expect(m.now().jira.event).toMatchObject({ kind: "batch", id: 5 }); expect(m.now().jira.event).toMatchObject({ kind: "batch", id: 5 });
}); });
it("#116: ответ опроса прежнего проекта, пришедший в коммите смены проекта, не возвращает событие", async () => {
const late = deferred<void>();
let polls = 0, delivered = false;
hub({
...listing((mod, project) => (mod === "wiki" && project === "AWPCA" ? [item(9, "wiki")] : [])),
"GET /batches/:id": async () => {
if (++polls > 1) { await late.promise; delivered = true; } // первый ответ показан сразу, следующий держится до смены проекта
return batch("running", { type: "wiki_sync" }, { id: 9, module: "wiki" });
},
});
const m = mount({ pollMs: 5 });
await waitFor(() => expect(m.now().wiki.event).toMatchObject({ kind: "batch", id: 9 }));
await waitFor(() => expect(polls).toBeGreaterThanOrEqual(2)); // повторный опрос ушёл и висит
// смена проекта и ответ опроса — одним коммитом: оба обновления копятся в act и рендерятся вместе на выходе из него
await act(async () => { m.setProject("STOCKS_DRRK"); late.resolve(); await sleep(20); });
expect(delivered).toBe(true); // отложенный ответ действительно ушёл в обработчик
await waitFor(() => expect(m.now().wiki.event).toBeNull());
await sleep(50);
expect(m.now().wiki.event).toBeNull();
expect(m.now().wiki.busy).toBe(false);
});
it("#116: A → B → A — после возврата на AWPCA незавершённый пакет 9 снова подхватывается", async () => {
hub({
...listing((mod, project) => (mod === "wiki" && project === "AWPCA" ? [item(9, "wiki")] : [])),
...byId({ 9: "wiki_sync" }, () => "running"),
});
const m = mount();
await waitFor(() => expect(m.now().wiki.event).toMatchObject({ kind: "batch", id: 9 }));
m.setProject("STOCKS_DRRK");
await waitFor(() => expect(m.now().wiki.event).toBeNull());
m.setProject("AWPCA");
await waitFor(() => expect(m.now().wiki.event).toMatchObject({ kind: "batch", id: 9 }));
});
it("M-2: пакет, отданный track для нового проекта, переживает смену проекта на него; чужой — сбрасывается", async () => { it("M-2: пакет, отданный track для нового проекта, переживает смену проекта на него; чужой — сбрасывается", async () => {
hub({ ...byId({ 9: "wiki_sync" }, () => "running") }); hub({ ...byId({ 9: "wiki_sync" }, () => "running") });
const m = mount(); const m = mount();

View File

@ -16,6 +16,18 @@ describe("поля", () => {
expect(screen.getByLabelText("Текст").tagName).toBe("TEXTAREA"); expect(screen.getByLabelText("Текст").tagName).toBe("TEXTAREA");
expect(screen.getByRole("checkbox", { name: "Скрыть" })).toBeInTheDocument(); expect(screen.getByRole("checkbox", { name: "Скрыть" })).toBeInTheDocument();
}); });
it("SearchInput: «Очистить» возвращает фокус в поле", async () => {
const onChange = vi.fn();
function Wrap() {
const [v, setV] = useState("abc");
return <SearchInput value={v} onChange={(x) => { onChange(x); setV(x); }} label="Поиск" />;
}
render(<Wrap />);
await userEvent.click(screen.getByRole("button", { name: "Очистить" }));
expect(onChange).toHaveBeenLastCalledWith("");
expect(document.activeElement).toBe(screen.getByRole("searchbox"));
});
it("SearchInput: ввод и «Очистить» только при значении", async () => { it("SearchInput: ввод и «Очистить» только при значении", async () => {
const onChange = vi.fn(); const onChange = vi.fn();
const { rerender } = render(<SearchInput value="" onChange={onChange} label="Поиск" />); const { rerender } = render(<SearchInput value="" onChange={onChange} label="Поиск" />);

View File

@ -1,3 +1,4 @@
import { useRef } from "react";
import type { InputHTMLAttributes, ReactNode, SelectHTMLAttributes, TextareaHTMLAttributes } from "react"; import type { InputHTMLAttributes, ReactNode, SelectHTMLAttributes, TextareaHTMLAttributes } from "react";
import { IconButton } from "./Button.tsx"; import { IconButton } from "./Button.tsx";
import { IconChevronDown, IconClose, IconSearch } from "./icons.tsx"; import { IconChevronDown, IconClose, IconSearch } from "./icons.tsx";
@ -14,12 +15,13 @@ export function Textarea({ size = "md", className, ...rest }: Size & Omit<Textar
} }
export function SearchInput({ value, onChange, label, placeholder, autoFocus }: { value: string; onChange(v: string): void; label: string; placeholder?: string; autoFocus?: boolean }) { export function SearchInput({ value, onChange, label, placeholder, autoFocus }: { value: string; onChange(v: string): void; label: string; placeholder?: string; autoFocus?: boolean }) {
const ref = useRef<HTMLInputElement>(null);
return ( return (
<span className="ui-search"> <span className="ui-search">
<span className="ui-search__icon" aria-hidden="true"><IconSearch /></span> <span className="ui-search__icon" aria-hidden="true"><IconSearch /></span>
<input type="search" className="ui-input ui-input--md ui-search__input" aria-label={label} placeholder={placeholder} autoFocus={autoFocus} <input ref={ref} type="search" className="ui-input ui-input--md ui-search__input" aria-label={label} placeholder={placeholder} autoFocus={autoFocus}
value={value} onChange={(e) => onChange(e.target.value)} /> value={value} onChange={(e) => onChange(e.target.value)} />
{value !== "" && <IconButton className="ui-search__clear" label="Очистить" icon={<IconClose />} onClick={() => onChange("")} />} {value !== "" && <IconButton className="ui-search__clear" label="Очистить" icon={<IconClose />} onClick={() => { onChange(""); ref.current?.focus(); }} />}
</span> </span>
); );
} }