diff --git a/docs/superpowers/plans/2026-10-25-web-bugfixes.md b/docs/superpowers/plans/2026-10-25-web-bugfixes.md new file mode 100644 index 0000000..d119609 --- /dev/null +++ b/docs/superpowers/plans/2026-10-25-web-bugfixes.md @@ -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 `. +- Зелёным считается только весь набор: финально `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 | 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` ключ — `` со значком `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(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={}`, где `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)" +``` diff --git a/openspec/changes/web-bugfixes/.openspec.yaml b/openspec/changes/web-bugfixes/.openspec.yaml new file mode 100644 index 0000000..7ccf7c3 --- /dev/null +++ b/openspec/changes/web-bugfixes/.openspec.yaml @@ -0,0 +1,2 @@ +schema: spec-driven +created: 2026-10-25 diff --git a/openspec/changes/web-bugfixes/design.md b/openspec/changes/web-bugfixes/design.md new file mode 100644 index 0000000..1277593 --- /dev/null +++ b/openspec/changes/web-bugfixes/design.md @@ -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`); `[^имя]` — явная ссылка на вложение, проверка к ней не применяется. Обоснование: ветка + сама внесла искажение «!» в строке ленты, владелец не хочет таких ошибок. Цена ошибки — `!слово.слово!` в обычном + тексте ленты («Ура!Сделано.ру!») прочитается как имя вложения без «!»; данные не страдают. diff --git a/openspec/changes/web-bugfixes/proposal.md b/openspec/changes/web-bugfixes/proposal.md new file mode 100644 index 0000000..5871e3b --- /dev/null +++ b/openspec/changes/web-bugfixes/proposal.md @@ -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 + закрывается. diff --git a/openspec/changes/web-bugfixes/specs/web-app/spec.md b/openspec/changes/web-bugfixes/specs/web-app/spec.md new file mode 100644 index 0000000..667a9e6 --- /dev/null +++ b/openspec/changes/web-bugfixes/specs/web-app/spec.md @@ -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//<имя в 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** строка «Вики» без события пакета; опроса этого пакета больше нет diff --git a/web/e2e/issues.spec.ts b/web/e2e/issues.spec.ts index 999ca30..6dbf3b5 100644 --- a/web/e2e/issues.spec.ts +++ b/web/e2e/issues.spec.ts @@ -207,3 +207,16 @@ test("закрытые скрыты по умолчанию, «Все» пока await expect(page.getByRole("link", { name: "AURORA-9006", exact: true })).toBeVisible(); 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=/); +}); diff --git a/web/src/modules/jira/api/queries.ts b/web/src/modules/jira/api/queries.ts index 31a20b7..87eb87e 100644 --- a/web/src/modules/jira/api/queries.ts +++ b/web/src/modules/jira/api/queries.ts @@ -41,8 +41,9 @@ export const useIssue = (key: string) => useQuery({ queryKey: qk.issue(key), queryFn: () => api.get(`/issues/${encodeURIComponent(key)}`) }); // список порциями по ISSUE_PAGE: следующая порция есть, пока последняя полная -export const useIssues = (filters: IssueFilters) => +export const useIssues = (filters: IssueFilters, opts?: { enabled?: boolean }) => useInfiniteQuery({ + enabled: opts?.enabled ?? true, queryKey: qk.issues(filters), initialPageParam: 0, queryFn: ({ pageParam }) => diff --git a/web/src/modules/jira/board/model/fromHub.ts b/web/src/modules/jira/board/model/fromHub.ts index 6adee88..7310d86 100644 --- a/web/src/modules/jira/board/model/fromHub.ts +++ b/web/src/modules/jira/board/model/fromHub.ts @@ -27,6 +27,6 @@ export function fromHub(snap: BoardSnapshot, dicts: Dicts): BoardModel { const missing = addStubs(nodes, links, NOT_IN_HUB); 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 }; } diff --git a/web/src/modules/jira/board/model/model.test.ts b/web/src/modules/jira/board/model/model.test.ts index 3ff53c9..661ee2b 100644 --- a/web/src/modules/jira/board/model/model.test.ts +++ b/web/src/modules/jira/board/model/model.test.ts @@ -32,6 +32,11 @@ describe("модель доски = links.html@3931a88", () => { expect(fromHub(neighborOnly, dicts).hasSprint).toBe(false); 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, кавычки, повторные колонки, даты разных локалей", () => { const text = fx("jira-export.csv"); const g = golden("jira-export.expected.json"); diff --git a/web/src/modules/jira/board/useBoardPrefs.test.tsx b/web/src/modules/jira/board/useBoardPrefs.test.tsx new file mode 100644 index 0000000..dd7f3e7 --- /dev/null +++ b/web/src/modules/jira/board/useBoardPrefs.test.tsx @@ -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"); + }); +}); diff --git a/web/src/modules/jira/board/useBoardPrefs.ts b/web/src/modules/jira/board/useBoardPrefs.ts index 13ccc73..a8c1f6a 100644 --- a/web/src/modules/jira/board/useBoardPrefs.ts +++ b/web/src/modules/jira/board/useBoardPrefs.ts @@ -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 type { LayoutStore } from "./components/BoardCanvas.tsx"; 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(); } return st.readPrefs(); }); + const prefsRef = useRef(prefs); // последнее значение: два вызова в одном такте не теряют первый const [store, setStore] = useState(() => readStore(0)); const [sel, setSel] = useState(() => pick(model, prefs.filters)); 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(() => ({ ...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 p = prefsRef.current; 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); - setPrefs((p) => (isView ? { ...p, [name]: on } : { ...p, filters: { ...p.filters, [name]: on } })); - }, [prefs]); + commit(isView ? { ...p, [name]: on } : { ...p, filters: { ...p.filters, [name]: on } }); + }, [commit]); const setSprints = useCallback((s: Set) => { st.writeSprints(s); setSel((x) => ({ ...x, sprints: new Set(s) })); }, []); const setWho = useCallback((s: Set) => { st.writeWho(s); setSel((x) => ({ ...x, who: new Set(s) })); }, []); const toggleHidden = useCallback((what: "kind" | "edge", value: string) => { + const p = prefsRef.current; const key = what === "kind" ? "hideKind" : "hideEdge"; - const next = new Set(prefs.filters[key]); - if (next.has(value)) next.delete(value); else next.add(value); - const filters = { ...prefs.filters, [key]: next }; + const set = new Set(p.filters[key]); + if (set.has(value)) set.delete(value); else set.add(value); + const filters = { ...p.filters, [key]: set }; st.writeHidden(filters.hideKind, filters.hideEdge); - setPrefs((p) => ({ ...p, filters: { ...p.filters, [key]: next } })); - }, [prefs]); + commit({ ...p, filters }); + }, [commit]); 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 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 applyJson = useCallback((r: JsonImport) => { const patch = st.applyJsonSettings(r); - setPrefs((p) => ({ ...p, filters: { ...p.filters, ...patch } })); - setSel(pick(model, { ...prefs.filters, ...patch })); + commit({ ...prefsRef.current, filters: { ...prefsRef.current.filters, ...patch } }); + setSel(pick(model, { ...prefsRef.current.filters, ...patch })); bump(); - }, [model, prefs.filters, bump]); + }, [model, commit, bump]); return { filters, snap: prefs.snap, table: prefs.table, problems: prefs.problems, store, toggle, setSprints, setWho, toggleHidden, toggleLane, moveLane, setColWidth, resetLayout, applyJson }; diff --git a/web/src/modules/jira/feed/events.test.ts b/web/src/modules/jira/feed/events.test.ts index eee4757..bfd61e6 100644 --- a/web/src/modules/jira/feed/events.test.ts +++ b/web/src/modules/jira/feed/events.test.ts @@ -55,6 +55,20 @@ describe("текст события", () => { 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("прочие поля — подпись из словаря или имя поля", () => { expect(eventText(ev({ kind: "other", field: "assignee", from: null, to: "ivanov" }))).toBe("исполнитель: «—» → «ivanov»"); expect(eventText(ev({ kind: "other", field: "Компания", from: "А", to: "Б" }))).toBe("Компания: «А» → «Б»"); diff --git a/web/src/modules/jira/feed/events.ts b/web/src/modules/jira/feed/events.ts index 14a42d0..72587dd 100644 --- a/web/src/modules/jira/feed/events.ts +++ b/web/src/modules/jira/feed/events.ts @@ -1,4 +1,5 @@ import type { ChangeEvent, ChangeKind } from "../api/types.ts"; +import { wikiPlain } from "../issues/wiki/plain.ts"; // период ленты: границы по местному времени браузера; «Сегодня» — по умолчанию и в адрес не пишется (spec web-app «Лента изменений») export type Period = "2h" | "today" | "yesterday" | "7d"; @@ -70,7 +71,7 @@ export function eventText(e: ChangeEvent): string { 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 "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) + "…"); } default: { diff --git a/web/src/modules/jira/issues/IssueCard.test.tsx b/web/src/modules/jira/issues/IssueCard.test.tsx index 7fa547d..a342178 100644 --- a/web/src/modules/jira/issues/IssueCard.test.tsx +++ b/web/src/modules/jira/issues/IssueCard.test.tsx @@ -73,6 +73,34 @@ describe("карточка задачи", () => { 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 () => { mockFetch((_m, url) => url.pathname === "/settings" ? json(200, settings) : json(200, issue("AURORA-9001", { links_out: [], links_in: [] }))); 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 })))); } - it("раздел «Вложения (2)» после «Описание», новые сверху; размер, автор · дата", async () => { + it("раздел «Вложения (2)» после «Описание», порядок хаба; размер, автор · дата", async () => { hub({}); open("/jira/issues/AURORA-1650"); const sec = within(await screen.findByTestId("attachments")); @@ -504,12 +532,39 @@ describe("вложения в карточке", () => { const heads = screen.getAllByRole("heading", { level: 3 }).map((h) => h.textContent); expect(heads.indexOf("Вложения (2)")).toBe(heads.indexOf("Описание") + 1); const rows = sec.getAllByRole("listitem"); - expect(rows[0]).toHaveTextContent("ОСВ 60.xlsx"); - expect(rows[0]).toHaveTextContent("1,0 КБ"); - expect(rows[1]).toHaveTextContent("скрин 1.png"); - expect(rows[1]).toHaveTextContent("234,4 КБ"); - expect(rows[1]).toHaveTextContent("Иванов Иван · 15.10"); - expect(within(rows[1]).getByRole("button", { name: "Скачать" })).toBeInTheDocument(); + expect(rows[1]).toHaveTextContent("ОСВ 60.xlsx"); + expect(rows[1]).toHaveTextContent("1,0 КБ"); + expect(rows[0]).toHaveTextContent("скрин 1.png"); + expect(rows[0]).toHaveTextContent("234,4 КБ"); + expect(rows[0]).toHaveTextContent("Иванов Иван · 15.10"); + 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 () => { @@ -568,7 +623,7 @@ describe("вложения в карточке", () => { ? json(409, { error: "agent_too_old", detail: "агент v13 не умеет скачивать вложения — нужен v14" }) : undefined); open("/jira/issues/AURORA-1650"); 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(); await userEvent.click(sec.getByRole("button", { name: "Повторить" })); expect(f.mock.calls.filter(([u]) => String(u).endsWith("/fetch"))).toHaveLength(2); diff --git a/web/src/modules/jira/issues/IssueCard.tsx b/web/src/modules/jira/issues/IssueCard.tsx index 6d51e79..7bd142a 100644 --- a/web/src/modules/jira/issues/IssueCard.tsx +++ b/web/src/modules/jira/issues/IssueCard.tsx @@ -7,7 +7,7 @@ import type { ChangeEvent, IssueAttachment, IssueComment, IssueDetail } from ".. import { JIRA_URL } from "../board/constants.ts"; import { fmtDate, fmtStamp } from "../../../ui/format.ts"; 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 { IssueLink } from "./IssueLink.tsx"; import { Badge, Button, IconButton, IconClose, IconExternal, Notice, Panel } from "../../../ui/index.ts"; @@ -22,26 +22,27 @@ import "./issues.css"; 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 settings = useSettings(); const closed = useClosedStatuses(); const dl = useAttachmentDownload(key); // одно состояние на вложение для всех кнопок карточки const close = } onClick={onClose} />; + const back = onBack && ; if (issue.isPending) { return
{key}{close}

загрузка {key}…

; } if (issue.error instanceof ApiError && issue.error.status === 404) { return (
- Закрыть}>Задачи {key} нет в зеркале хаба. + {back}}>Задачи {key} нет в зеркале хаба.
); } if (issue.isError && !issue.data) { return (
- }> + {back}}> {issue.error.message}
@@ -76,14 +77,14 @@ export function IssueCard({ issueKey: key, onClose }: { issueKey: string; onClos
} /> - {i.description && } + {i.description?.trim() && } {atts && atts.length > 0 && } {i.changes && }
- + | undefined} />
@@ -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 }) { const groups = groupLinks(issue, linkTypes); return ( @@ -120,8 +123,10 @@ function Links({ issue, linkTypes }: { issue: IssueDetail; linkTypes: Record (
  • {l.tag && <>{l.tag}{" "}} - {" "} - {l.summary === null && l.status === null + {absent(l) + ? {l.key} + : }{" "} + {absent(l) ? нет в зеркале : <>{l.summary}{l.status && <>{" "}{l.status}}}
  • @@ -222,9 +227,11 @@ function History({ items }: { items: ChangeEvent[] }) { ); } -function OtherFields({ issue: i }: { issue: IssueDetail }) { +function OtherFields({ issue: i, kindLabels }: { issue: IssueDetail; kindLabels?: Record }) { + const { kinds, rest } = splitKindLabels(i.labels, kindLabels); 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.neighbor ? "да" : null], ]; diff --git a/web/src/modules/jira/issues/IssueLink.tsx b/web/src/modules/jira/issues/IssueLink.tsx index b214b55..ce43e3c 100644 --- a/web/src/modules/jira/issues/IssueLink.tsx +++ b/web/src/modules/jira/issues/IssueLink.tsx @@ -1,8 +1,8 @@ -import type { ReactNode } from "react"; +import type { MouseEvent, ReactNode } from "react"; import { Link, useLocation, useNavigate } from "react-router"; 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 { @@ -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(); - return {children ?? issueKey}; + const click = onOpen && ((e: MouseEvent) => { + if (e.defaultPrevented || e.button !== 0 || e.ctrlKey || e.metaKey || e.shiftKey || e.altKey) return; + e.preventDefault(); + onOpen(issueKey); + }); + return {children ?? issueKey}; } diff --git a/web/src/modules/jira/issues/IssueModal.test.tsx b/web/src/modules/jira/issues/IssueModal.test.tsx index c646a4a..3cbe92f 100644 --- a/web/src/modules/jira/issues/IssueModal.test.tsx +++ b/web/src/modules/jira/issues/IssueModal.test.tsx @@ -137,6 +137,8 @@ describe("карточка окном", () => { await userEvent.click(within(dialog()).getByRole("button", { name: "Остаться" })); expect(router.state.location.pathname).toBe("/app/jira/issues/AURORA-9001"); 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 () => { @@ -155,6 +157,59 @@ describe("карточка окном", () => { 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(); + 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(); + 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(); + 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(); + 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 () => { mockFetch((_m, url) => { if (url.pathname === "/settings") return json(200, settings); @@ -168,4 +223,111 @@ describe("карточка окном", () => { render(); 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(); + 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(); + 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(); + 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(); + 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(); + 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(); + 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(); + 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(); + }); + }); }); diff --git a/web/src/modules/jira/issues/IssueModal.tsx b/web/src/modules/jira/issues/IssueModal.tsx index df19501..87d86db 100644 --- a/web/src/modules/jira/issues/IssueModal.tsx +++ b/web/src/modules/jira/issues/IssueModal.tsx @@ -7,7 +7,7 @@ import { IssueCard } from "./IssueCard.tsx"; // окно карточки (design 13): держит фокус и делает фон недоступным; Esc и щелчок по фону — onClose. // Сами окно не закрываем: оно исчезает вместе с адресом карточки. Если браузер закрыл его сам (Esc без активации // пользователя — cancel не отменяемый), открываем снова и просим закрытия по общему пути (блокировщик заметок). -export function IssueModal({ issueKey, onClose }: { issueKey: string; onClose: () => void }) { +export function IssueModal({ issueKey, onClose, onBack }: { issueKey: string; onClose: () => void; onBack?: () => void }) { // I-2: закрывать щелчком по фону, только если и нажатие было на фоне — выделение текста, отпущенное за краем окна, // даёт click на самом , но нажатие было внутри содержимого const downOnBackdrop = useRef(false); @@ -26,7 +26,7 @@ export function IssueModal({ issueKey, onClose }: { issueKey: string; onClose: ( downOnBackdrop.current = false; if (e.target === e.currentTarget && fromBackdrop) onClose(); }}> - + ); } diff --git a/web/src/modules/jira/issues/IssueTable.tsx b/web/src/modules/jira/issues/IssueTable.tsx index ecacc71..13f8cab 100644 --- a/web/src/modules/jira/issues/IssueTable.tsx +++ b/web/src/modules/jira/issues/IssueTable.tsx @@ -35,7 +35,7 @@ export function IssueTable({ rows, epicNames, closed, onOpen }: { const due = dueOf(i); return ( rowClick(e, i.key, onOpen)}> - {i.neighbor && сосед} + {i.neighbor && сосед} {i.summary} {i.status && {i.status}} {i.assignee} diff --git a/web/src/modules/jira/issues/IssuesPage.test.tsx b/web/src/modules/jira/issues/IssuesPage.test.tsx index 86ffda9..b4388ef 100644 --- a/web/src/modules/jira/issues/IssuesPage.test.tsx +++ b/web/src/modules/jira/issues/IssuesPage.test.tsx @@ -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 { readFileSync } from "node:fs"; import { join } from "node:path"; @@ -41,6 +41,16 @@ describe("список задач", () => { expect(q.get("offset")).toBe("0"); }); + it("ключ строки: href прежний, щелчок с Ctrl не открывает карточку внутри приложения (Review Focus 1)", async () => { + hub(() => [row(1)]); + const { router } = renderWithProviders(, { 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 () => { expanded(); const fetch = hub(() => [row(1)]); @@ -236,6 +246,37 @@ describe("список задач", () => { await waitFor(() => expect(calls().at(-1)!.searchParams.has("hide_closed")).toBe(false)); }); + it("закрытый статус в адресе до настроек: запроса нет, «Загрузка…», после настроек — один запрос без hide_closed (#60 M-3)", async () => { + const st = deferred(); + 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(, { 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(, { 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 () => { const fetch = hub(() => [row(1)]); renderWithProviders(, { route: "/issues?fact_status=В работе", path: "/issues" }); diff --git a/web/src/modules/jira/issues/IssuesPage.tsx b/web/src/modules/jira/issues/IssuesPage.tsx index a3f97e9..c356be3 100644 --- a/web/src/modules/jira/issues/IssuesPage.tsx +++ b/web/src/modules/jira/issues/IssuesPage.tsx @@ -1,11 +1,12 @@ 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 { useSettings } 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 { 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 { IssueTable } from "./IssueTable.tsx"; import { epicNames, filterOptions } from "./options.ts"; @@ -28,14 +29,32 @@ export function IssuesPage({ project }: { project: string }) { written.current = fromUrl; setQ(fromUrl); }, [filters.q]); + const timer = useRef | undefined>(undefined); useEffect(() => { if (q === (filters.q ?? "")) return; - const t = setTimeout(() => { + timer.current = setTimeout(() => { written.current = q; setParams((p) => { const n = new URLSearchParams(p); if (q) n.set("q", q); else n.delete("q"); return n; }, { replace: true }); }, Q_DELAY); - return () => clearTimeout(t); + return () => clearTimeout(timer.current); }, [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 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 query = useMemo(() => (showClosed || isClosed(filters.status, closed) ? { ...filters, project } : { ...filters, project, hide_closed: "1" }), [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 open = useOpenIssue(); + const loading = list.isPending || settings.isPending; return (
    - } /> + } /> {list.isError && ( void list.refetch()}>Повторить}>{(list.error as Error).message} )} {rows.length > 0 && } - {list.isPending &&

    Загрузка…

    } - {!list.isPending && !rows.length && !list.isError && По этим фильтрам задач нет} + {loading &&

    Загрузка…

    } + {!loading && !rows.length && !list.isError && По этим фильтрам задач нет} {list.hasNextPage && (
    diff --git a/web/src/modules/jira/issues/NoteForm.tsx b/web/src/modules/jira/issues/NoteForm.tsx index 1a66171..f43a866 100644 --- a/web/src/modules/jira/issues/NoteForm.tsx +++ b/web/src/modules/jira/issues/NoteForm.tsx @@ -39,7 +39,11 @@ export function NoteForm({ issue, onDone }: { issue: IssueDetail; onDone: () => return () => window.removeEventListener("beforeunload", warn); }, [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("[data-stay]")?.focus(); + }, [blocker.state]); const kinds = Object.keys(settings.data?.kind_labels ?? {}); const kindOptions = values.work_kind && !kinds.includes(values.work_kind) ? [values.work_kind, ...kinds] : kinds; @@ -79,11 +83,11 @@ export function NoteForm({ issue, onDone }: { issue: IssueDetail; onDone: () =>
    )} {blocker.state === "blocked" && ( -
    +
    Есть несохранённые правки заметки. Уйти без сохранения?
    - +
    )} diff --git a/web/src/modules/jira/issues/attachments/AttachmentList.tsx b/web/src/modules/jira/issues/attachments/AttachmentList.tsx index 845c9ad..066b091 100644 --- a/web/src/modules/jira/issues/attachments/AttachmentList.tsx +++ b/web/src/modules/jira/issues/attachments/AttachmentList.tsx @@ -10,19 +10,13 @@ export type Download = ReturnType; const KIND_ICON = { image: IconImage, sheet: IconSheet, pdf: IconFile, other: IconFile }; 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)»: значок по типу, имя, размер, «автор · дата», отметка «у хаба», кнопка скачивания export function AttachmentList({ items, dl }: { items: IssueAttachment[]; dl: Download }) { return (

    Вложения ({items.length})

      - {newestFirst(items).map((a) => )} + {items.map((a) => )}
    ); @@ -31,9 +25,9 @@ export function AttachmentList({ items, dl }: { items: IssueAttachment[]; dl: Do function Row({ a, dl }: { a: IssueAttachment; dl: Download }) { const kind = fileKind(a.mime_type, a.filename); 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 до открытия карточки - 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(" · "); return (
  • diff --git a/web/src/modules/jira/issues/attachments/useAttachmentDownload.test.tsx b/web/src/modules/jira/issues/attachments/useAttachmentDownload.test.tsx index 9c31694..17a3312 100644 --- a/web/src/modules/jira/issues/attachments/useAttachmentDownload.test.tsx +++ b/web/src/modules/jira/issues/attachments/useAttachmentDownload.test.tsx @@ -4,7 +4,7 @@ import type { ReactNode } from "react"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { makeQueryClient } from "../../../../api/queryClient.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 { useAttachmentDownload } from "./useAttachmentDownload.ts"; @@ -38,7 +38,7 @@ describe("useAttachmentDownload", () => { const { result } = renderHook(() => useAttachmentDownload("AURORA-1650"), { wrapper }); act(() => result.current.start(att)); 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" }); await act(async () => { await vi.advanceTimersByTimeAsync(2000); }); expect(polls).toBe(1); @@ -48,7 +48,7 @@ describe("useAttachmentDownload", () => { expect(URL.revokeObjectURL).not.toHaveBeenCalled(); // I-2: отзыв отложен await act(async () => { await vi.advanceTimersByTimeAsync(60_000); }); 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); }); expect(polls).toBe(2); expect(f.mock.calls.filter(([u]) => String(u).endsWith("/content"))).toHaveLength(1); @@ -84,7 +84,7 @@ describe("useAttachmentDownload", () => { const n = f.mock.calls.length; await act(async () => { await vi.advanceTimersByTimeAsync(10000); }); 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 () => { @@ -92,10 +92,29 @@ describe("useAttachmentDownload", () => { const { result } = renderHook(() => useAttachmentDownload("AURORA-1650"), { wrapper }); act(() => result.current.start(att)); 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); }); + 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 () => { let n = 0; 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)); await flush(); 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)); await flush(); - expect(result.current.status("31337")).toEqual({ busy: true, error: null }); + expect(result.current.status("31337")).toEqual({ busy: true, error: null, ok: false }); }); }); diff --git a/web/src/modules/jira/issues/attachments/useAttachmentDownload.ts b/web/src/modules/jira/issues/attachments/useAttachmentDownload.ts index e56b230..508d3e0 100644 --- a/web/src/modules/jira/issues/attachments/useAttachmentDownload.ts +++ b/web/src/modules/jira/issues/attachments/useAttachmentDownload.ts @@ -1,13 +1,13 @@ import { useQueryClient } from "@tanstack/react-query"; 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 { qk } from "../../api/queries.ts"; import type { AttachmentState, IssueAttachment } from "../../api/types.ts"; export const POLL_MS = 2000; -export interface DownloadStatus { busy: boolean; error: string | null } -const IDLE: DownloadStatus = { busy: false, error: null }; +export interface DownloadStatus { busy: boolean; error: string | null; ok: boolean } // ok — успех в этой карточке +const IDLE: DownloadStatus = { busy: false, error: null, ok: false }; // браузер сохраняет файл под своим именем: Blob → временная ссылка download (токен в адресе не появляется) function save(blob: Blob, filename: string) { @@ -56,7 +56,12 @@ export function useAttachmentDownload(issueKey: string) { if (mapRef.current[id]?.busy) return; const my = gen.current; 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 — подождать и спросить снова, иначе — ошибка const handle = async (st: AttachmentState): Promise => { @@ -65,13 +70,13 @@ export function useAttachmentDownload(issueKey: string) { const blob = await api.blob(`/attachments/${encodeURIComponent(id)}/content`); if (!alive()) return; save(blob, a.filename); - put(id, IDLE); + put(id, { busy: false, error: null, ok: true }); void qc.invalidateQueries({ queryKey: qk.issue(issueKey) }); // отметка «у хаба» } else if (st.state === "fetching") { const t = setTimeout(() => { timers.current.delete(t); if (!alive()) return; - api.get(`/attachments/${encodeURIComponent(id)}`).then(handle).catch((e) => fail(errorText(e))); + api.get(`/attachments/${encodeURIComponent(id)}`).then(handle).catch(onError); }, POLL_MS); timers.current.add(t); } 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(`/attachments/${encodeURIComponent(id)}/fetch`, { author: "web" }) - .then(handle).catch((e) => fail(errorText(e))); + .then(handle).catch(onError); }, [issueKey, put, qc]); const status = useCallback((id: string): DownloadStatus => map[id] ?? IDLE, [map]); diff --git a/web/src/modules/jira/issues/card.test.ts b/web/src/modules/jira/issues/card.test.ts index 493e712..6bcc24d 100644 --- a/web/src/modules/jira/issues/card.test.ts +++ b/web/src/modules/jira/issues/card.test.ts @@ -1,6 +1,6 @@ import { describe, expect, it } from "vitest"; 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; const closed = new Set(["Готово", "Снято"]); @@ -50,3 +50,13 @@ describe("просрочка", () => { 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"] }); + }); +}); diff --git a/web/src/modules/jira/issues/card.ts b/web/src/modules/jira/issues/card.ts index 2b5f1ad..437c987 100644 --- a/web/src/modules/jira/issues/card.ts +++ b/web/src/modules/jira/issues/card.ts @@ -31,3 +31,14 @@ export function isOverdue(due: string, status: string | null | undefined, closed export const todayISO = (d = new Date()): string => `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, "0")}-${String(d.getDate()).padStart(2, "0")}`; + +// метки задачи: виды работ (метка из kind_labels → название вида) отдельно от прочих меток +export function splitKindLabels(labels: string[], kindLabels: Record | 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 }; +} diff --git a/web/src/modules/jira/issues/overlay.tsx b/web/src/modules/jira/issues/overlay.tsx index ad3b173..f028ad0 100644 --- a/web/src/modules/jira/issues/overlay.tsx +++ b/web/src/modules/jira/issues/overlay.tsx @@ -34,16 +34,31 @@ export function useIssueOverlay(): Overlay { void navigate(ISSUES_PATH + direct.search, { replace: true }); } }, [card, navigate, direct.search]); + // повторное закрытие (двойной щелчок по ✕) на той же записи истории игнорируется (#42 M-2) + const closingKey = useRef(null); + useEffect(() => { closingKey.current = null; }, [location.key]); 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; } closingDirect.current = card.direct; 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 - ? + ? : null; return { pageLocation, element }; } diff --git a/web/src/modules/jira/issues/wiki/inline.test.ts b/web/src/modules/jira/issues/wiki/inline.test.ts index 1073755..e3080bd 100644 --- a/web/src/modules/jira/issues/wiki/inline.test.ts +++ b/web/src/modules/jira/issues/wiki/inline.test.ts @@ -89,13 +89,17 @@ describe("parseInline: ссылки на вложения", () => { expect(parseInline("см. !скрин 1.png|thumbnail!", A)).toEqual([t("см. "), 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("[^имя] и [текст|^имя]", () => { expect(parseInline("[^a.pdf]", A)).toEqual([att("31338", t("a.pdf"))]); expect(parseInline("[отчёт|^a.pdf]", A)).toEqual([att("31338", t("отчёт"))]); }); - it("имя точно (с регистром); несколько с одним именем — самое новое", () => { + it("имя точно (с регистром); несколько с одним именем — первое в порядке хаба", () => { 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("имени нет среди вложений — исходник как есть", () => { expect(parseInline("!нет.png!", A)).toEqual([t("!нет.png!")]); @@ -116,4 +120,10 @@ describe("parseInline: ссылки на вложения", () => { it("внутри подписи ссылки вложения нет (кнопка в ссылке недопустима)", () => { 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" }]); + }); }); diff --git a/web/src/modules/jira/issues/wiki/inline.ts b/web/src/modules/jira/issues/wiki/inline.ts index abfb5a8..25988f4 100644 --- a/web/src/modules/jira/issues/wiki/inline.ts +++ b/web/src/modules/jira/issues/wiki/inline.ts @@ -1,9 +1,10 @@ // Строчная вики-разметка Jira (design wiki-render #4, #5): выделение, {{моно}}, ссылки, упоминания, ключи задач. // Вход — одна строка без переводов строк; разбор не бросает исключений, нераспознанное остаётся текстом как есть. -// вложения задачи для разбора ссылок `!имя!`, `[^имя]` (как у хаба: имя точно, при повторе имени — самое новое) +// вложения задачи для разбора ссылок `!имя!`, `[^имя]` (как у хаба: имя точно, при повторе имени — первое в порядке хаба, новые сверху) 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"; @@ -18,13 +19,15 @@ export type Inline = | { t: "br" }; const MARKS: Record = { "*": "strong", _: "em", "-": "del", "+": "ins", "^": "sup", "~": "sub" }; -const ESCAPABLE = new Set([..."*_-+^~{}[]|!\\"]); +const ESCAPABLE = new Set([..."*_-+^~{}[]|!#\\"]); const WORD = /[\p{L}\p{N}]/u; const SPACE = /\s/; const URL = /https?:\/\/[^\s\]|<>"]+/y; const URL_TAIL = /[.,;:!?)]+$/; const IMAGE = /!([^!|\n]+)(?:\|[^!\n]*)?!/y; const ATT_LINK = /^(?:([^|]*)\|)?\^(.+)$/; +// имя файла с расширением, без пробелов по краям: только такое `!имя!` без списка вложений — вложение (N-1) +const FILE_NAME = /^\S(?:.*\S)?\.[\p{L}\p{N}]{1,10}$/u; // схема ссылки — только http, https, mailto (спека «Разметка Jira в карточке»); иначе ссылки нет 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; } -// вложение по имени: несколько с одним именем — самое новое по created, затем по числовому id -function findAttachment(opts: WikiOptions, name: string): string | null { - const same = (opts.attachments ?? []).filter((a) => a.filename === name); - if (!same.length) return null; - const rank = (a: WikiAttachment): [number, number] => [a.created ? Date.parse(a.created) || 0 : 0, Number(a.id) || 0]; - 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; +// вложение по имени: несколько с одним именем — первое в порядке хаба; image — `!имя!`, без списка только имя файла, +// иначе «Срочно! Проверьте!» в ленте теряет «!» (N-1) +function findAttachment(opts: WikiOptions, name: string, image = false): string | null { + const found = (opts.attachments ?? []).find((a) => a.filename === name)?.id; + return found ?? (opts.anyAttachment && (!image || FILE_NAME.test(name)) ? "" : 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; } - if (c === "!" && !label && opts.attachments?.length) { + if (c === "!" && !label && (opts.attachments?.length || opts.anyAttachment)) { IMAGE.lastIndex = i; const m = IMAGE.exec(line); - const id = m && !m[1].includes("://") ? findAttachment(opts, m[1]) : null; - if (m && id) { out.push({ t: "attachment", id, children: [{ t: "text", text: m[1] }] }); i += m[0].length; continue; } + const id = m && !m[1].includes("://") ? findAttachment(opts, m[1], true) : null; + 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)) { @@ -147,13 +145,13 @@ function closing(line: string, i: number): number { // содержимое [...]: упоминание, ключ задачи, ссылка с проверенной схемой; иначе null — текст как есть function bracket(body: string, opts: WikiOptions, isKey: (s: string) => boolean): Inline | null { 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; } const ref = ATT_LINK.exec(body); if (ref) { 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 label = bar >= 0 ? body.slice(0, bar) : ""; diff --git a/web/src/modules/jira/issues/wiki/parse.test.ts b/web/src/modules/jira/issues/wiki/parse.test.ts index f6e57bf..3d85b2e 100644 --- a/web/src/modules/jira/issues/wiki/parse.test.ts +++ b/web/src/modules/jira/issues/wiki/parse.test.ts @@ -77,4 +77,34 @@ describe("parseWiki: блоки-макросы", () => { 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) => 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); + }); }); diff --git a/web/src/modules/jira/issues/wiki/parse.ts b/web/src/modules/jira/issues/wiki/parse.ts index f3d8c6b..a731a90 100644 --- a/web/src/modules/jira/issues/wiki/parse.ts +++ b/web/src/modules/jira/issues/wiki/parse.ts @@ -14,7 +14,7 @@ export type Block = | { t: "quote"; children: Block[] } | { t: "hr" }; -const MACRO = /^\s*\{(code(?::[^}]*)?|noformat|quote)\}/; +const MACRO = /^\s*\{(code|noformat|quote)(?::[^}]*)?\}/; const HEADING = /^\s*h([1-6])\.\s+(.*)$/; const BQ = /^\s*bq\.\s+(.*)$/; const HR = /^\s*----\s*$/; @@ -101,15 +101,16 @@ function blocks(src: string[], opts: WikiOptions): Block[] { // закрывающий тег макроса в той же строке после открывающего или в последующих; 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 rest = lines[start].slice(open[0].length); const body: string[] = []; for (let k = start; k < lines.length; k++) { const text = k === start ? rest : lines[k]; const at = text.indexOf(tag); - if (at < 0) { if (k !== start || text) body.push(text); continue; } - if (text.slice(0, at)) body.push(text.slice(0, at)); + if (at < 0) { if (k !== start || text.trim()) body.push(text); continue; } + 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 null; @@ -126,7 +127,7 @@ function row(line: string, opts: WikiOptions): Cell[] { let 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--; j++; } diff --git a/web/src/modules/jira/issues/wiki/plain.test.ts b/web/src/modules/jira/issues/wiki/plain.test.ts new file mode 100644 index 0000000..fe02ccc --- /dev/null +++ b/web/src/modules/jira/issues/wiki/plain.test.ts @@ -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 цит"); + }); +}); diff --git a/web/src/modules/jira/issues/wiki/plain.ts b/web/src/modules/jira/issues/wiki/plain.ts new file mode 100644 index 0000000..682a5f3 --- /dev/null +++ b/web/src/modules/jira/issues/wiki/plain.ts @@ -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(" "); +} diff --git a/web/src/projects/ProjectGate.tsx b/web/src/projects/ProjectGate.tsx index 5d1ddba..86f809d 100644 --- a/web/src/projects/ProjectGate.tsx +++ b/web/src/projects/ProjectGate.tsx @@ -1,15 +1,18 @@ import type { ReactNode } from "react"; import type { Project } from "../api/types.ts"; import { Button, Notice } from "../ui/index.ts"; +import { useProjects } from "../api/queries.ts"; import { useProjectContext } from "./ProjectContext.ts"; import "./projects.css"; // страница доски/списка показывается, только когда текущий проект определён; иначе — причина и что делать export function ProjectGate({ children }: { children: (p: Project) => ReactNode }) { const { state, openNew } = useProjectContext(); + const { refetch } = useProjects(); + const retry = () => { void refetch(); }; switch (state.kind) { case "loading": return
    Загрузка проектов…
    ; - case "error": return
    Проекты не загрузились: {state.error.message}
    ; + case "error": return
    Повторить}>Проекты не загрузились: {state.error.message}
    ; case "unknown": return
    выберите проект в списке «Проект» боковой панели (на узком экране — в «Меню»)
    ; diff --git a/web/src/projects/projects.test.tsx b/web/src/projects/projects.test.tsx index 84e683e..b6b10d9 100644 --- a/web/src/projects/projects.test.tsx +++ b/web/src/projects/projects.test.tsx @@ -263,5 +263,15 @@ describe("адреса модулей и заголовок вкладки", () void router.navigate("/?project=AWPCA"); 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)); + }); }); diff --git a/web/src/shell/activity/ActivityProvider.tsx b/web/src/shell/activity/ActivityProvider.tsx index bf64571..eeb0f0b 100644 --- a/web/src/shell/activity/ActivityProvider.tsx +++ b/web/src/shell/activity/ActivityProvider.tsx @@ -124,7 +124,8 @@ function useTracker(m: (typeof TRACKED)[number], { qc, project, pollMs, agents } }, [qc, status, setActiveNow]); useEffect(() => { - if (!active) return; + // #116: ответ обрабатывается, только если отслеживаемый пакет — тот же объект, что в activeRef (эффект смены проекта объявлен выше и в том же коммите обнуляет activeRef) + if (!active || activeRef.current !== active) return; const b = batch.data, e = batch.error; if (b && b.id === active.id && !batch.isError) { if (terminal(b)) { diff --git a/web/src/shell/activity/activity.test.tsx b/web/src/shell/activity/activity.test.tsx index 8e0e12a..0ed37c4 100644 --- a/web/src/shell/activity/activity.test.tsx +++ b/web/src/shell/activity/activity.test.tsx @@ -337,6 +337,41 @@ describe("ActivityProvider", () => { expect(m.now().jira.event).toMatchObject({ kind: "batch", id: 5 }); }); + it("#116: ответ опроса прежнего проекта, пришедший в коммите смены проекта, не возвращает событие", async () => { + const late = deferred(); + 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 () => { hub({ ...byId({ 9: "wiki_sync" }, () => "running") }); const m = mount(); diff --git a/web/src/ui/forms.test.tsx b/web/src/ui/forms.test.tsx index 8a48b7a..757bce6 100644 --- a/web/src/ui/forms.test.tsx +++ b/web/src/ui/forms.test.tsx @@ -16,6 +16,18 @@ describe("поля", () => { expect(screen.getByLabelText("Текст").tagName).toBe("TEXTAREA"); expect(screen.getByRole("checkbox", { name: "Скрыть" })).toBeInTheDocument(); }); + it("SearchInput: «Очистить» возвращает фокус в поле", async () => { + const onChange = vi.fn(); + function Wrap() { + const [v, setV] = useState("abc"); + return { onChange(x); setV(x); }} label="Поиск" />; + } + render(); + await userEvent.click(screen.getByRole("button", { name: "Очистить" })); + expect(onChange).toHaveBeenLastCalledWith(""); + expect(document.activeElement).toBe(screen.getByRole("searchbox")); + }); + it("SearchInput: ввод и «Очистить» только при значении", async () => { const onChange = vi.fn(); const { rerender } = render(); diff --git a/web/src/ui/inputs.tsx b/web/src/ui/inputs.tsx index af3f495..6bb0751 100644 --- a/web/src/ui/inputs.tsx +++ b/web/src/ui/inputs.tsx @@ -1,3 +1,4 @@ +import { useRef } from "react"; import type { InputHTMLAttributes, ReactNode, SelectHTMLAttributes, TextareaHTMLAttributes } from "react"; import { IconButton } from "./Button.tsx"; import { IconChevronDown, IconClose, IconSearch } from "./icons.tsx"; @@ -14,12 +15,13 @@ export function Textarea({ size = "md", className, ...rest }: Size & Omit(null); return ( - onChange(e.target.value)} /> - {value !== "" && } onClick={() => onChange("")} />} + {value !== "" && } onClick={() => { onChange(""); ref.current?.focus(); }} />} ); }