Пакет веб-багов: карточка, лента, вики-разметка, состояние модулей (#124) #125
505
docs/superpowers/plans/2026-10-25-web-bugfixes.md
Normal file
505
docs/superpowers/plans/2026-10-25-web-bugfixes.md
Normal file
@ -0,0 +1,505 @@
|
|||||||
|
# Пакет веб-багов (web-bugfixes) Implementation Plan
|
||||||
|
|
||||||
|
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||||||
|
|
||||||
|
**Goal:** убрать видимые пользователю ошибки веба хаба из пакета A сверки (sdlc-hub#124), каждую закрепив тестом, который до правки падает.
|
||||||
|
|
||||||
|
**Architecture:** только клиент (`web/src/`): точечные правки окна карточки, разборщика вики-разметки, трекера
|
||||||
|
состояния модулей, вложений, доски и UI-кита. Одна новая чистая функция `wikiPlain` для текста ленты. API, схема, MCP и
|
||||||
|
агенты не меняются.
|
||||||
|
|
||||||
|
**Tech Stack:** React 19 + TypeScript + Vite, React Router (data router), TanStack Query, Vitest + Testing Library, Playwright.
|
||||||
|
|
||||||
|
**Spec:** `openspec/changes/web-bugfixes/` — `design.md` (решения 1–21, ссылки на строки main 1405ccd),
|
||||||
|
`specs/web-app/spec.md` (дельта требований и сценарии), `proposal.md`.
|
||||||
|
|
||||||
|
## Global Constraints
|
||||||
|
|
||||||
|
- Меняется только `web/src/` (+ `web/e2e/issues.spec.ts`). Сервер, API, MCP, агенты — без изменений.
|
||||||
|
- Язык комментариев и сообщений коммитов — русский; идентификаторы — английские. Плотность комментариев — как в окружающем коде.
|
||||||
|
- Разметка MUST NOT превращаться в HTML строкой (`innerHTML`, `dangerouslySetInnerHTML`).
|
||||||
|
- Тесты — рядом с кодом (`*.test.ts(x)`), запросы к хабу мокаются `mockFetch`/`json` из `web/src/test/fetchMock.ts`.
|
||||||
|
- Каждая задача начинается с теста, который падает до правки (RED подтверждается запуском и фиксируется в отчёте).
|
||||||
|
- Тексты интерфейса — дословно из спеки: «Вид работы в Jira», «Назад», «Закрыть», «Остаться», «Уйти без сохранения
|
||||||
|
заметки?», «Повторить», «Загрузка…», «хаб не отвечает», «нет в зеркале», «Задачи {key} нет в зеркале хаба.».
|
||||||
|
- Коммит в конце каждой задачи; сообщение заканчивается строкой `Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>`.
|
||||||
|
- Зелёным считается только весь набор: финально `make check` из корня репозитория и `cd web && npm run e2e`.
|
||||||
|
|
||||||
|
## Review Focus
|
||||||
|
|
||||||
|
1. Ctrl/Cmd/средний щелчок по ключу в списке «Задачи» по-прежнему открывает карточку в новой вкладке (перехват только
|
||||||
|
обычного щелчка) — тест в Task 4.
|
||||||
|
2. Окно, открытое прямым адресом (`depth = 0`), и двойной ✕: адрес — список, дальше по истории не уходит — тест в Task 4.
|
||||||
|
3. `wikiPlain` на незакрытой разметке, неизвестных макросах и пустом тексте не бросает и отдаёт текст как есть; пустой
|
||||||
|
комментарий в ленте — по-прежнему «комментарий: —» — тест в Task 2.
|
||||||
|
4. Настройки ещё не пришли или без `kind_labels`: все метки — в «Метках», строки «Вид работы в Jira» нет — тест в Task 3.
|
||||||
|
5. Смена проекта A → B → A во время опроса: пакет A снова подхватывается при возврате (сброс не «запирает» модуль) — тест в Task 5.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 1: Вики-разметка — краевые случаи (#56 M-5, M-6)
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `web/src/modules/jira/issues/wiki/inline.ts` (`ESCAPABLE` :21, `bracket` :148-152)
|
||||||
|
- Modify: `web/src/modules/jira/issues/wiki/parse.ts` (`MACRO` :17, `closeMacro` :103-116, `row` :118-139)
|
||||||
|
- Test: `web/src/modules/jira/issues/wiki/inline.test.ts`, `web/src/modules/jira/issues/wiki/parse.test.ts`
|
||||||
|
|
||||||
|
**Interfaces:**
|
||||||
|
- Consumes: —
|
||||||
|
- Produces: прежние `parseWiki(text, opts): Block[]`, `parseInline(line, opts, label?): Inline[]` с исправленным разбором.
|
||||||
|
|
||||||
|
- [ ] **Step 1: Write the failing tests**
|
||||||
|
|
||||||
|
В `inline.test.ts`:
|
||||||
|
```ts
|
||||||
|
it("\\# — символ решётки (M-5)", () => {
|
||||||
|
expect(parseInline("\\# не пункт")).toEqual([{ t: "text", text: "# не пункт" }]);
|
||||||
|
});
|
||||||
|
it("[~логин|текст] — упоминание логина без подписи (M-6)", () => {
|
||||||
|
expect(parseInline("[~ivanov|Иванов]")).toEqual([{ t: "mention", login: "ivanov" }]);
|
||||||
|
});
|
||||||
|
```
|
||||||
|
В `parse.test.ts`:
|
||||||
|
```ts
|
||||||
|
it("\\# в начале строки — абзац, не список", () => {
|
||||||
|
expect(parseWiki("\\# не пункт")).toEqual([{ t: "p", children: [{ t: "text", text: "# не пункт" }] }]);
|
||||||
|
});
|
||||||
|
it("quote и noformat с параметрами (M-5)", () => {
|
||||||
|
expect(parseWiki("{quote:title=Итог}\nсогласовано\n{quote}")).toEqual([{ t: "quote", children: [{ t: "p", children: [{ t: "text", text: "согласовано" }] }] }]);
|
||||||
|
expect(parseWiki("{noformat:borderStyle=solid}\n*x*\n{noformat}")).toEqual([{ t: "pre", text: "*x*" }]);
|
||||||
|
});
|
||||||
|
it("пробелы после открывающего {code} не дают пустой строки (M-6)", () => {
|
||||||
|
expect(parseWiki("{code} \nx = 1\n{code}")).toEqual([{ t: "pre", text: "x = 1" }]);
|
||||||
|
});
|
||||||
|
it("непарная [ в ячейке не съедает разделители (M-6)", () => {
|
||||||
|
const [table] = parseWiki("|[draft|итог|готово|");
|
||||||
|
expect(table).toMatchObject({ t: "table" });
|
||||||
|
expect((table as { rows: { children: { text: string }[] }[][] }).rows[0].map((c) => c.children[0].text)).toEqual(["[draft", "итог", "готово"]);
|
||||||
|
});
|
||||||
|
it("| внутри [текст|адрес] ячейку по-прежнему не делит", () => {
|
||||||
|
const [table] = parseWiki("|[док|https://x.ru]|b|");
|
||||||
|
expect((table as { rows: unknown[][] }).rows[0]).toHaveLength(2);
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 2: Run tests to verify they fail**
|
||||||
|
|
||||||
|
Run: `cd web && npx vitest run src/modules/jira/issues/wiki/`
|
||||||
|
Expected: FAIL — новые тесты (кроме последнего, он фиксирует текущее поведение и должен быть зелёным).
|
||||||
|
|
||||||
|
- [ ] **Step 3: Implement**
|
||||||
|
|
||||||
|
- `ESCAPABLE` — добавить `#`.
|
||||||
|
- `MACRO = /^\s*\{(code|noformat|quote)(?::[^}]*)?\}/`; имя тега в `closeMacro` — первая группа (без параметров), закрывающий тег — `{имя}`.
|
||||||
|
- `closeMacro`: остаток строки открывающего тега, состоящий из одних пробелов, в `body` не попадает (`text.trim()` вместо `text` в условии для `k === start`).
|
||||||
|
- `bracket`: при `body.startsWith("~")` логин — `body.slice(1).split("|")[0].trim()`; пустой или с пробелом — `null`, как сейчас.
|
||||||
|
- `row`: `[` увеличивает `depth`, только если в `s` правее позиции есть `]` (`s.indexOf("]", j + 1) >= 0`).
|
||||||
|
|
||||||
|
- [ ] **Step 4: Run tests to verify they pass**
|
||||||
|
|
||||||
|
Run: `cd web && npx vitest run src/modules/jira/issues/wiki/`
|
||||||
|
Expected: PASS (все тесты каталога, включая прежние).
|
||||||
|
|
||||||
|
- [ ] **Step 5: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add web/src/modules/jira/issues/wiki/
|
||||||
|
git commit -m "Вики-разметка: \\#, макросы с параметрами, [~логин|текст], пробелы после {code}, непарная [ в таблице (#124, #56 M-5, M-6)"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 2: Текст комментария в ленте без разметки (#103 С6)
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Create: `web/src/modules/jira/issues/wiki/plain.ts`
|
||||||
|
- Modify: `web/src/modules/jira/feed/events.ts` (`eventText`, ветка `comment` :72-75)
|
||||||
|
- Test: `web/src/modules/jira/issues/wiki/plain.test.ts` (новый), `web/src/modules/jira/feed/events.test.ts`
|
||||||
|
|
||||||
|
**Interfaces:**
|
||||||
|
- Consumes: `parseWiki`, `Block`, `Inline`, `WikiOptions` из Task 1 (`parse.ts`, `inline.ts`).
|
||||||
|
- Produces: `export function wikiPlain(text: string, opts?: WikiOptions): string` — текст как читается отрендеренным,
|
||||||
|
блоки через пробел (не одна строка — «одна строка» и обрезку делает `eventText`).
|
||||||
|
|
||||||
|
- [ ] **Step 1: Write the failing tests**
|
||||||
|
|
||||||
|
`plain.test.ts`:
|
||||||
|
```ts
|
||||||
|
import { wikiPlain } from "./plain.ts";
|
||||||
|
it("выделение, ссылки, упоминание, заголовок", () => {
|
||||||
|
expect(wikiPlain("h3. Итог\n*Готово*, см. [протокол|https://x.ru/p] и [~ivanov]").replace(/\s+/g, " ").trim())
|
||||||
|
.toBe("Итог Готово, см. протокол и @ivanov");
|
||||||
|
});
|
||||||
|
it("[адрес] — сам адрес, {{моно}} и {code} — содержимое, ---- — пусто", () => {
|
||||||
|
expect(wikiPlain("[https://x.ru] {{a_b}}\n----\n{code}\nx = 1\n{code}").replace(/\s+/g, " ").trim()).toBe("https://x.ru a_b x = 1");
|
||||||
|
});
|
||||||
|
it("ключ задачи и вложение — текстом", () => {
|
||||||
|
const opts = { projectKey: "AURORA", attachments: [{ id: "1", filename: "скрин.png", created: null }] };
|
||||||
|
expect(wikiPlain("см. AURORA-1 и !скрин.png!", opts).trim()).toBe("см. AURORA-1 и скрин.png");
|
||||||
|
});
|
||||||
|
it("незакрытая и неизвестная разметка — как есть, пустой текст — пусто (Review Focus 3)", () => {
|
||||||
|
expect(wikiPlain("{color:red}важно *без конца").trim()).toBe("{color:red}важно *без конца");
|
||||||
|
expect(wikiPlain("")).toBe("");
|
||||||
|
});
|
||||||
|
```
|
||||||
|
В `events.test.ts`:
|
||||||
|
```ts
|
||||||
|
it("комментарий в ленте — без вики-разметки (С6)", () => {
|
||||||
|
expect(eventText({ ...commentEvent, to: "h3. Итог\n*Готово*, см. [протокол|https://x.ru/p] и [~ivanov]" }))
|
||||||
|
.toBe("комментарий: Итог Готово, см. протокол и @ivanov");
|
||||||
|
});
|
||||||
|
it("пустой комментарий — по-прежнему «—»", () => {
|
||||||
|
expect(eventText({ ...commentEvent, to: " " })).toBe("комментарий: —");
|
||||||
|
});
|
||||||
|
```
|
||||||
|
(`commentEvent` — событие `kind: "comment"` по образцу уже имеющихся в `events.test.ts`.)
|
||||||
|
|
||||||
|
- [ ] **Step 2: Run tests to verify they fail**
|
||||||
|
|
||||||
|
Run: `cd web && npx vitest run src/modules/jira/issues/wiki/plain.test.ts src/modules/jira/feed/events.test.ts`
|
||||||
|
Expected: FAIL — нет модуля `plain.ts`; тест С6 видит сырую разметку.
|
||||||
|
|
||||||
|
- [ ] **Step 3: Implement**
|
||||||
|
|
||||||
|
`wikiPlain` обходит дерево `parseWiki`: `text` — текст; метки выделения — дети; `code` — текст; `link` — дети
|
||||||
|
(у `[адрес]` дети уже содержат адрес); `issue` — `key`, если детей нет, иначе дети; `attachment` — дети; `mention` —
|
||||||
|
`"@" + login`; `br` — пробел; блоки (`heading`, `p`, `list` с пунктами и вложенными списками, `table` по ячейкам,
|
||||||
|
`pre` — `text`, `quote` — вложенные блоки, `hr` — пусто) соединяются пробелом. В `eventText` ветка `comment`:
|
||||||
|
`oneLine(wikiPlain(e.to ?? ""))`, остальное (обрезка 300 и «…», «—» для пустого) — как сейчас.
|
||||||
|
|
||||||
|
- [ ] **Step 4: Run tests to verify they pass**
|
||||||
|
|
||||||
|
Run: `cd web && npx vitest run src/modules/jira/issues/wiki/ src/modules/jira/feed/`
|
||||||
|
Expected: PASS.
|
||||||
|
|
||||||
|
- [ ] **Step 5: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add web/src/modules/jira/issues/wiki/plain.ts web/src/modules/jira/issues/wiki/plain.test.ts web/src/modules/jira/feed/
|
||||||
|
git commit -m "Лента: текст комментария без вики-разметки Jira (#124, #103 С6)"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 3: Карточка — связи «нет в зеркале», окно 404, метки видов, пустое описание (#42 M-5, #103, #56 M-4)
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `web/src/modules/jira/issues/card.ts` (новая функция)
|
||||||
|
- Modify: `web/src/modules/jira/issues/IssueCard.tsx` (404/ошибка :34-49, `Links` :110-134, описание :79, `OtherFields` :225-237)
|
||||||
|
- Modify: `web/src/modules/jira/issues/overlay.tsx` (передать в карточку `onBack`, если `card.depth >= 1` … см. Step 3)
|
||||||
|
- Test: `web/src/modules/jira/issues/card.test.ts` (если нет — создать), `web/src/modules/jira/issues/IssueCard.test.tsx`, `web/src/modules/jira/issues/IssueModal.test.tsx`
|
||||||
|
|
||||||
|
**Interfaces:**
|
||||||
|
- Consumes: `JIRA_URL` (`../board/constants.ts`), `settings.data.kind_labels` (вид → метка).
|
||||||
|
- Produces:
|
||||||
|
- `export function splitKindLabels(labels: string[], kindLabels: Record<string, string> | undefined): { kinds: string[]; rest: string[] }` в `card.ts` — `kinds` — названия видов в порядке меток задачи, `rest` — прочие метки;
|
||||||
|
- `IssueCard` получает необязательный проп `onBack?: () => void`; `IssueModal` прокидывает его из `useIssueOverlay`.
|
||||||
|
|
||||||
|
- [ ] **Step 1: Write the failing tests**
|
||||||
|
|
||||||
|
`card.test.ts`:
|
||||||
|
```ts
|
||||||
|
it("метки видов работ отдельно от прочих", () => {
|
||||||
|
expect(splitKindLabels(["AWPCA", "kind-dev", "kind-ui"], { разработка: "kind-dev", UI: "kind-ui" }))
|
||||||
|
.toEqual({ kinds: ["разработка", "UI"], rest: ["AWPCA"] });
|
||||||
|
});
|
||||||
|
it("без kind_labels — все метки прочие (Review Focus 4)", () => {
|
||||||
|
expect(splitKindLabels(["AWPCA", "kind-dev"], undefined)).toEqual({ kinds: [], rest: ["AWPCA", "kind-dev"] });
|
||||||
|
});
|
||||||
|
```
|
||||||
|
`IssueCard.test.tsx`:
|
||||||
|
- «Прочие поля»: задача с метками `["AWPCA", "kind-dev"]`, настройки фикстуры `settings.json` → видны пары «Вид работы в Jira» / «разработка» и «Метки» / «AWPCA», текста `kind-dev` в карточке нет.
|
||||||
|
- описание `" \n\n"` → нет заголовка «Описание».
|
||||||
|
- связь на ключ вне зеркала (`summary: null, status: null`) → в «Связях» `getByRole("link", { name: /AURORA-1999/ })` имеет `href` = `${JIRA_URL}/browse/AURORA-1999` и `target="_blank"`; рядом текст «нет в зеркале».
|
||||||
|
|
||||||
|
`IssueModal.test.tsx` (сценарий спеки «Задача не в зеркале после перехода»): из списка открыта AURORA-9002, в окне
|
||||||
|
переход по ссылке на ключ, для которого хаб отвечает 404 (`json(404, {})` в `hub()` для этого ключа) → в окне «Задачи
|
||||||
|
AURORA-1999 нет в зеркале хаба.» и кнопки «Назад» и «Закрыть»; «Назад» — заголовок окна снова AURORA-9002; при новом
|
||||||
|
переходе и «Закрыть» — окна нет, адрес — список. Прямой адрес `/app/jira/issues/AURORA-1999` (404) → только «Закрыть».
|
||||||
|
|
||||||
|
- [ ] **Step 2: Run tests to verify they fail**
|
||||||
|
|
||||||
|
Run: `cd web && npx vitest run src/modules/jira/issues/card.test.ts src/modules/jira/issues/IssueCard.test.tsx src/modules/jira/issues/IssueModal.test.tsx`
|
||||||
|
Expected: FAIL — нет `splitKindLabels`, метка `kind-dev` видна в «Метках», рамка описания есть, ссылка ведёт на `/jira/issues/…`, кнопки «Назад» нет.
|
||||||
|
|
||||||
|
- [ ] **Step 3: Implement**
|
||||||
|
|
||||||
|
- `OtherFields`: строки «Вид работы в Jira» (`kinds.join(", ")`) и «Метки» (`rest.join(", ")`) вместо прежней «Метки»;
|
||||||
|
`kind_labels` — из `useSettings()` (передать пропом из `IssueCard`). Положение «Вид работы в Jira» — сразу перед «Метками».
|
||||||
|
- Описание: условие `i.description?.trim()`.
|
||||||
|
- `Links`: при `l.summary === null && l.status === null` ключ — `<a href={`${JIRA_URL}/browse/${l.key}`} target="_blank" rel="noopener">` со значком `IconExternal` (как «Открыть в Jira»), иначе — `IssueLink`, как сейчас.
|
||||||
|
- `useIssueOverlay`: «Назад» — только если окно открыто переходом из другой карточки. По `openCardState`
|
||||||
|
(`modal.ts:25-28`): из списка `depth = 1, direct = false`; прямой адрес — `depth = 0, direct = true`; каждый переход
|
||||||
|
внутри окна — `depth + 1`. Отсюда `onBack = card && (card.depth >= 2 || (card.direct && card.depth >= 1)) ? () => void
|
||||||
|
navigate(-1) : undefined`; прокинуть через `IssueModal` в `IssueCard`. Тесты: «из списка — без Назад», «после перехода —
|
||||||
|
с Назад», «прямой адрес — без Назад», «переход из прямого адреса — с Назад».
|
||||||
|
- 404 и ошибка загрузки: `action` — «Назад» (если `onBack`) и «Закрыть» / «Повторить», «Назад» (если есть), «Закрыть».
|
||||||
|
|
||||||
|
- [ ] **Step 4: Run tests to verify they pass**
|
||||||
|
|
||||||
|
Run: `cd web && npx vitest run src/modules/jira/issues/`
|
||||||
|
Expected: PASS.
|
||||||
|
|
||||||
|
- [ ] **Step 5: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add web/src/modules/jira/issues/
|
||||||
|
git commit -m "Карточка: связь вне зеркала ведёт в Jira, «Назад» у окна 404, метки видов работ отдельно, пустое описание (#124, #42 M-5, #103, #56 M-4)"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 4: Окно карточки — поиск, двойное закрытие, подтверждение ухода (#42 M-1, M-2, M-4)
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `web/src/modules/jira/issues/IssuesPage.tsx` (:14-38, `open` :50)
|
||||||
|
- Modify: `web/src/modules/jira/issues/IssueTable.tsx` (ключ :38 — обычный щелчок через `onOpen`)
|
||||||
|
- Modify: `web/src/modules/jira/issues/overlay.tsx` (`close` :37-41)
|
||||||
|
- Modify: `web/src/modules/jira/issues/NoteForm.tsx` (подтверждение ухода :82-89, эффект :42)
|
||||||
|
- Test: `web/src/modules/jira/issues/IssueModal.test.tsx`, `web/src/modules/jira/issues/IssuesPage.test.tsx`, `web/e2e/issues.spec.ts`
|
||||||
|
|
||||||
|
**Interfaces:**
|
||||||
|
- Consumes: `openCardState`, `cardPath` (`modal.ts`, `IssueLink.tsx`).
|
||||||
|
- Produces: `IssueTable` сохраняет проп `onOpen(key: string): void` — теперь его зовут и строка, и обычный щелчок по ключу.
|
||||||
|
|
||||||
|
- [ ] **Step 1: Write the failing tests**
|
||||||
|
|
||||||
|
`IssueModal.test.tsx`:
|
||||||
|
- «поиск перед открытием не закрывает окно» (сценарий спеки): список, в поле поиска (`getByRole("searchbox")`)
|
||||||
|
набрано «свод», сразу щёлкнут ключ AURORA-9002 (без ожидания 300 мс) → окно открыто; после `await new Promise(r => setTimeout(r, 400))`
|
||||||
|
окно всё ещё открыто (`screen.getByRole("dialog")`), `router.state.location.pathname` — `/app/jira/issues/AURORA-9002`;
|
||||||
|
после ✕ адрес — `/app/jira/issues` с `q=свод`. То же для щелчка по ячейке темы строки.
|
||||||
|
- «двойной щелчок по ✕ не уводит дальше назад»: история `["/app/jira/links", "/app/jira/issues"]`, открыта AURORA-9002,
|
||||||
|
переход по связи на AURORA-9001, `await userEvent.dblClick(✕)` → адрес — `/app/jira/issues`, не `/app/jira/links`.
|
||||||
|
- то же для прямого адреса (Review Focus 2): история `["/app/jira/issues/AURORA-9002"]`, двойной ✕ → `/app/jira/issues`.
|
||||||
|
- подтверждение ухода: в форме заметки правка, нажата Esc → `getByRole("alertdialog", { name: "Уйти без сохранения заметки?" })`,
|
||||||
|
`document.activeElement` — кнопка «Остаться».
|
||||||
|
|
||||||
|
`IssuesPage.test.tsx` (Review Focus 1): у ключа строки `href` прежний (`/jira/issues/AURORA-9001`), щелчок с `ctrlKey`
|
||||||
|
(`fireEvent.click(link, { ctrlKey: true })`) не вызывает навигацию внутри приложения (адрес страницы не меняется).
|
||||||
|
|
||||||
|
`web/e2e/issues.spec.ts`: сценарий «поиск перед открытием карточки» — ввести текст в поиск и сразу щёлкнуть первую
|
||||||
|
строку; через 600 мс окно открыто; Escape — адрес списка содержит `q=`.
|
||||||
|
|
||||||
|
- [ ] **Step 2: Run tests to verify they fail**
|
||||||
|
|
||||||
|
Run: `cd web && npx vitest run src/modules/jira/issues/IssueModal.test.tsx src/modules/jira/issues/IssuesPage.test.tsx`
|
||||||
|
Expected: FAIL — окно закрывается через 300 мс; двойной ✕ уходит на `/app/jira/links`; у подтверждения нет имени и фокуса.
|
||||||
|
|
||||||
|
- [ ] **Step 3: Implement**
|
||||||
|
|
||||||
|
- `IssuesPage`: функция `openCard(key)` — снять таймер поиска; если `q` отличается от `filters.q`, записать `q` в адрес
|
||||||
|
(`replace`) и открыть карточку с фоном, у которого `search` уже содержит `q` (`navigate(cardPath(key), { state:
|
||||||
|
openCardState({ ...loc, search: <поиск с q> }) })`, `written.current = q`). Её получает `IssueTable` как `onOpen`.
|
||||||
|
Таймер не перезаводится, пока страница под окном (адрес `filters.q` совпадает с `q` после записи — эффект ничего не делает).
|
||||||
|
- `IssueTable`: ключ — та же ссылка `IssueLink` (адрес и состояние для новой вкладки), но обычный щелчок (левая кнопка
|
||||||
|
без `ctrl`/`meta`/`shift`/`alt`) — `preventDefault()` и `onOpen(key)`.
|
||||||
|
- `overlay.tsx`: `closingKey = useRef<string | null>(null)`; `close` выходит, если `closingKey.current === location.key`,
|
||||||
|
иначе запоминает `location.key` и закрывает как сейчас. Сброс — эффект по смене `location.key`.
|
||||||
|
- `NoteForm`: у блока подтверждения ухода `aria-label="Уйти без сохранения заметки?"`; ref на кнопку «Остаться»; при
|
||||||
|
`blocker.state === "blocked"` — `scrollIntoView` (как сейчас) и `focus()` этой кнопки.
|
||||||
|
|
||||||
|
- [ ] **Step 4: Run tests to verify they pass**
|
||||||
|
|
||||||
|
Run: `cd web && npx vitest run src/modules/jira/issues/ && npm run e2e -- issues.spec.ts`
|
||||||
|
Expected: PASS.
|
||||||
|
|
||||||
|
- [ ] **Step 5: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add web/src/modules/jira/issues/ web/e2e/issues.spec.ts
|
||||||
|
git commit -m "Окно карточки: поиск не закрывает окно, повторное закрытие игнорируется, фокус подтверждения ухода (#124, #42 M-1, M-2, M-4)"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 5: Строка состояния — ответ опроса прежнего проекта (#116)
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `web/src/shell/activity/ActivityProvider.tsx` (эффект опроса :126-154)
|
||||||
|
- Test: `web/src/shell/activity/activity.test.tsx`
|
||||||
|
|
||||||
|
**Interfaces:**
|
||||||
|
- Consumes: —
|
||||||
|
- Produces: поведение `ModuleActivity` без изменений интерфейса.
|
||||||
|
|
||||||
|
- [ ] **Step 1: Write the failing test**
|
||||||
|
|
||||||
|
В `activity.test.tsx` — детерминированный тест «ответ опроса после смены проекта не возвращает событие»: ответ
|
||||||
|
`GET /batches/9` (пакет `wiki_sync` проекта AWPCA) задерживается промисом-заглушкой (`deferred` из `web/src/test/batchHub.ts`);
|
||||||
|
когда первый ответ показан (`event.kind === "batch"`), следующий ответ держится; в одном `act` вызвать
|
||||||
|
`m.setProject("STOCKS_DRRK")` и разрешить задержанный ответ → после `await waitFor(...)` и `sleep(50)` `m.now().wiki.event`
|
||||||
|
равно `null`. Исполнитель подбирает порядок так, чтобы тест падал на текущем коде (RED обязателен, см. Risks в design.md);
|
||||||
|
если воспроизвести в одном такте не удаётся — описать в отчёте, как получен RED (например, `pollMs` 0 и разрешение
|
||||||
|
ответа синхронно перед `setProject`).
|
||||||
|
|
||||||
|
Второй тест (Review Focus 5): A → B → A — после возврата на AWPCA незавершённый пакет 9 снова подхватывается
|
||||||
|
(`event` `{ kind: "batch", id: 9 }`).
|
||||||
|
|
||||||
|
- [ ] **Step 2: Run tests to verify they fail**
|
||||||
|
|
||||||
|
Run: `cd web && npx vitest run src/shell/activity/activity.test.tsx`
|
||||||
|
Expected: FAIL — первый тест: событие `batch` id 9 после смены проекта.
|
||||||
|
|
||||||
|
- [ ] **Step 3: Implement**
|
||||||
|
|
||||||
|
В начале эффекта опроса: `if (!active || activeRef.current !== active) return;` — ответ обрабатывается, только если
|
||||||
|
отслеживаемый пакет тот же объект, что в `activeRef` (смена проекта синхронно обнуляет `activeRef` в `setActiveNow`).
|
||||||
|
Проверить, что `setActiveNow({ ...active, own: true })` (:138) и `track`/`start` по-прежнему проходят: они сами
|
||||||
|
обновляют `activeRef`.
|
||||||
|
|
||||||
|
- [ ] **Step 4: Run tests to verify they pass**
|
||||||
|
|
||||||
|
Run: `cd web && npx vitest run src/shell/activity/activity.test.tsx` и прогон в цикле:
|
||||||
|
`for i in $(seq 20); do npx vitest run src/shell/activity/activity.test.tsx --silent || break; done`
|
||||||
|
Expected: PASS все 20 прогонов.
|
||||||
|
|
||||||
|
- [ ] **Step 5: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add web/src/shell/activity/
|
||||||
|
git commit -m "Состояние модулей: ответ опроса прежнего проекта не возвращает событие (#124, #116)"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 6: Список задач ждёт настройки (#60 M-3)
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `web/src/modules/jira/issues/IssuesPage.tsx` (запрос :45-48, «Загрузка…» :60-61)
|
||||||
|
- Modify: `web/src/modules/jira/api/queries.ts` (`useIssues` — необязательный параметр `enabled`)
|
||||||
|
- Test: `web/src/modules/jira/issues/IssuesPage.test.tsx`
|
||||||
|
|
||||||
|
**Interfaces:**
|
||||||
|
- Consumes: `useSettings()` (`isPending`).
|
||||||
|
- Produces: `useIssues(filters: IssueFilters, opts?: { enabled?: boolean })`.
|
||||||
|
|
||||||
|
- [ ] **Step 1: Write the failing test**
|
||||||
|
|
||||||
|
«закрытый статус в адресе до настроек» (сценарий спеки): ответ `/settings` задержан; маршрут `/issues?status=Готово`;
|
||||||
|
пока настройки не пришли — `issuesCalls(fetch)` пуст, видно «Загрузка…», нет «По этим фильтрам задач нет»; после
|
||||||
|
ответа `/settings` (с «Готово» в `closed_statuses`) — ровно один вызов `/issues` с `status=Готово` и без `hide_closed`.
|
||||||
|
Второй тест (R-11): `/settings` отвечает 500 → один вызов `/issues` с `hide_closed=1`.
|
||||||
|
|
||||||
|
- [ ] **Step 2: Run test to verify it fails**
|
||||||
|
|
||||||
|
Run: `cd web && npx vitest run src/modules/jira/issues/IssuesPage.test.tsx`
|
||||||
|
Expected: FAIL — первый вызов `/issues` уходит до настроек с `hide_closed=1`.
|
||||||
|
|
||||||
|
- [ ] **Step 3: Implement**
|
||||||
|
|
||||||
|
`useIssues(query, { enabled: !settings.isPending })`; пока настройки грузятся, «Загрузка…» (условие подсказки — по
|
||||||
|
`list.isPending || settings.isPending`), сообщение «По этим фильтрам задач нет» — только после ответа списка.
|
||||||
|
|
||||||
|
- [ ] **Step 4: Run tests to verify they pass**
|
||||||
|
|
||||||
|
Run: `cd web && npx vitest run src/modules/jira/issues/`
|
||||||
|
Expected: PASS.
|
||||||
|
|
||||||
|
- [ ] **Step 5: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add web/src/modules/jira/
|
||||||
|
git commit -m "Список задач: запрос после настроек, без мелькания «задач нет» (#124, #60 M-3)"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 7: Вложения — прежняя ошибка, 401 при опросе, порядок хаба (#86 T6.2, T6.3, M-4)
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `web/src/modules/jira/issues/attachments/useAttachmentDownload.ts`
|
||||||
|
- Modify: `web/src/modules/jira/issues/attachments/AttachmentList.tsx` (`newestFirst` :14-17, ошибка :36)
|
||||||
|
- Modify: `web/src/modules/jira/issues/wiki/inline.ts` (`findAttachment` :36-45)
|
||||||
|
- Test: `web/src/modules/jira/issues/attachments/useAttachmentDownload.test.tsx`, `web/src/modules/jira/issues/IssueCard.test.tsx`, `web/src/modules/jira/issues/wiki/inline.test.ts`
|
||||||
|
|
||||||
|
**Interfaces:**
|
||||||
|
- Consumes: `dropTokenOn`, `AuthError`, `HubDownError` (`web/src/api/client.ts`).
|
||||||
|
- Produces: `DownloadStatus` дополняется полем `ok: boolean` (успех в этой карточке); `IDLE.ok = false`.
|
||||||
|
|
||||||
|
- [ ] **Step 1: Write the failing tests**
|
||||||
|
|
||||||
|
- `useAttachmentDownload.test.tsx`: опрос `GET /attachments/{id}` отвечает 401 → вызван сброс токена (токен в хранилище
|
||||||
|
пуст / экран входа по образцу других тестов `AuthError`), текста ошибки нет; ответ — сеть недоступна (`HubDownError`)
|
||||||
|
→ `status(id).error === "хаб не отвечает"`.
|
||||||
|
- `IssueCard.test.tsx` (сценарий «Повтор после ошибки»): вложение с `state: "failed", error: "сбой"`, «Повторить», хаб
|
||||||
|
отдаёт `ready` и файл, перечитанная карточка всё ещё отдаёт `failed` (задержать) → текста «сбой» нет.
|
||||||
|
- `IssueCard.test.tsx`: вложения в ответе хаба в порядке `[b (created null), a (created 2026-10-20)]` → в разделе в том же порядке.
|
||||||
|
- `inline.test.ts`: два вложения «скрин.png» — `[{id: "5", created: null}, {id: "9", created: "2026-10-20T10:00:00.000+0300"}]` →
|
||||||
|
`!скрин.png!` даёт `attachment` с `id: "5"` (первое в порядке хаба).
|
||||||
|
|
||||||
|
- [ ] **Step 2: Run tests to verify they fail**
|
||||||
|
|
||||||
|
Run: `cd web && npx vitest run src/modules/jira/issues/attachments/ src/modules/jira/issues/IssueCard.test.tsx src/modules/jira/issues/wiki/inline.test.ts`
|
||||||
|
Expected: FAIL.
|
||||||
|
|
||||||
|
- [ ] **Step 3: Implement**
|
||||||
|
|
||||||
|
- В `useAttachmentDownload`: общая функция разбора ошибки — `AuthError` → `dropTokenOn(e)` без текста; `HubDownError` →
|
||||||
|
«хаб не отвечает»; прочие — `errorText`. Успех — `put(id, { busy: false, error: null, ok: true })`; `start` сбрасывает `ok`.
|
||||||
|
- `AttachmentList`: порядок — как в `items`; серверный `failed` показывается, только если `!ok`. Убрать `newestFirst`
|
||||||
|
(и его экспорт, если других потребителей нет).
|
||||||
|
- `findAttachment`: первое вложение с этим именем в порядке `opts.attachments`.
|
||||||
|
|
||||||
|
- [ ] **Step 4: Run tests to verify they pass**
|
||||||
|
|
||||||
|
Run: `cd web && npx vitest run src/modules/jira/issues/`
|
||||||
|
Expected: PASS.
|
||||||
|
|
||||||
|
- [ ] **Step 5: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add web/src/modules/jira/issues/
|
||||||
|
git commit -m "Вложения: без прежней ошибки после повтора, 401 — экран входа, порядок хаба (#124, #86 T6.2, T6.3, M-4)"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 8: Доска, проекты, поиск UI-кита (#35 T4.1/M-15, #64 T9.1, M-6, #68 T3.3) и итоговая проверка
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `web/src/modules/jira/board/useBoardPrefs.ts` (`toggle`, `toggleHidden` :39-54)
|
||||||
|
- Modify: `web/src/modules/jira/board/model/fromHub.ts` (`hasSprint` :30)
|
||||||
|
- Modify: `web/src/projects/ProjectGate.tsx` (:11)
|
||||||
|
- Modify: `web/src/ui/inputs.tsx` (`SearchInput` :15-24)
|
||||||
|
- Test: `web/src/modules/jira/board/BoardPage.test.tsx` или новый `useBoardPrefs.test.tsx`, тест модели доски рядом с `fromHub.ts`, `web/src/projects/projects.test.tsx`, `web/src/ui/forms.test.tsx`
|
||||||
|
|
||||||
|
**Interfaces:**
|
||||||
|
- Consumes: `useProjects()` (`refetch`) из `web/src/api/queries.ts`.
|
||||||
|
- Produces: —
|
||||||
|
|
||||||
|
- [ ] **Step 1: Write the failing tests**
|
||||||
|
|
||||||
|
- `useBoardPrefs`: `renderHook`; в одном `act` два вызова `toggle(<флаг A>)` и `toggle(<флаг B>)` → оба флага включены
|
||||||
|
и в состоянии, и в `localStorage`; в одном `act` `toggleHidden("kind", "x")` и `toggleHidden("kind", "y")` → скрыты оба.
|
||||||
|
- `fromHub`: снимок, где спринт только у эпика (тип «Эпик», `neighbor: false`), у остальных своих задач спринта нет →
|
||||||
|
`hasSprint === false`.
|
||||||
|
- `projects.test.tsx`: `GET /projects` → 500, затем 200 → видно «Проекты не загрузились: …» и кнопка «Повторить»;
|
||||||
|
после нажатия — содержимое страницы проекта.
|
||||||
|
- `forms.test.tsx`: `SearchInput` со значением, щелчок «Очистить» → `onChange("")` и фокус в поле поиска
|
||||||
|
(`document.activeElement === getByRole("searchbox")`) — с обёрткой-состоянием, чтобы кнопка исчезла.
|
||||||
|
|
||||||
|
- [ ] **Step 2: Run tests to verify they fail**
|
||||||
|
|
||||||
|
Run: `cd web && npx vitest run src/modules/jira/board/ src/projects/ src/ui/`
|
||||||
|
Expected: FAIL.
|
||||||
|
|
||||||
|
- [ ] **Step 3: Implement**
|
||||||
|
|
||||||
|
- `useBoardPrefs`: `prefsRef` с последним значением; `toggle`/`toggleHidden` считают новое значение из `prefsRef.current`,
|
||||||
|
синхронно присваивают `prefsRef.current = next`, пишут `st.writeFlag`/`st.writeHidden` и `setPrefs(next)`.
|
||||||
|
- `fromHub`: `hasSprint = snap.issues.some((it) => !isEpic(it) && !it.neighbor && !!it.sprint)`.
|
||||||
|
- `ProjectGate`: `action={<Button onClick={retry}>Повторить</Button>}`, где `retry` перезапрашивает `GET /projects`.
|
||||||
|
- `SearchInput`: `ref` на `input`; «Очистить» — `onChange("")`, затем `ref.current?.focus()`.
|
||||||
|
|
||||||
|
- [ ] **Step 4: Run the whole suite**
|
||||||
|
|
||||||
|
Run (из корня репозитория): `make check`, затем `cd web && npm run e2e`
|
||||||
|
Expected: всё зелёное (pytest, lint, vitest, e2e).
|
||||||
|
|
||||||
|
- [ ] **Step 5: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add web/src/
|
||||||
|
git commit -m "Доска, проекты, поиск: два переключения не теряются, спринт эпика, «Повторить», фокус после «Очистить» (#124, #35 T4.1, #64 T9.1, M-6, #68 T3.3)"
|
||||||
|
```
|
||||||
2
openspec/changes/web-bugfixes/.openspec.yaml
Normal file
2
openspec/changes/web-bugfixes/.openspec.yaml
Normal file
@ -0,0 +1,2 @@
|
|||||||
|
schema: spec-driven
|
||||||
|
created: 2026-10-25
|
||||||
218
openspec/changes/web-bugfixes/design.md
Normal file
218
openspec/changes/web-bugfixes/design.md
Normal file
@ -0,0 +1,218 @@
|
|||||||
|
## Context
|
||||||
|
|
||||||
|
25.10.2026 пять сабагентов сверили открытые follow-up issue хаба и агентов с main (hub 1405ccd). Каждый маркер получил
|
||||||
|
статус с доказательством `file:line`. Итоги записаны комментариями в issue. Владелец выбрал первым пакет A — видимые
|
||||||
|
пользователю ошибки веба (sdlc-hub#124). Brainstorming-вопросы владелец пропустил («делай сам… не хочу с ними
|
||||||
|
сталкиваться в будущем»), поэтому все решения ниже приняты агентом. Они перечислены в «Decisions made without
|
||||||
|
discussion».
|
||||||
|
|
||||||
|
Состав пакета: #42 M-1, M-2, M-4, M-5; #116; #103 С6 и метки `kind-*`; #56 M-4, M-5, M-6; #35 T4.1/M-15; #60 M-3;
|
||||||
|
#64 M-6, T9.1; #86 T6.2, T6.3, M-4; #68 T3.3. Ссылки на строки ниже — по main 1405ccd.
|
||||||
|
|
||||||
|
## Goals / Non-Goals
|
||||||
|
|
||||||
|
Цель — убрать перечисленные ошибки. Каждую закрепить тестом, который падает до правки: юнит или поведение компонента в
|
||||||
|
vitest, для окна карточки дополнительно e2e. Сервер, API, MCP и агенты не меняются.
|
||||||
|
|
||||||
|
Не цели:
|
||||||
|
- прочие маркеры исходных issue: тесты, стиль, доступность UI-кита (#68), квадратичный разбор (#56 M-2), фокус окна
|
||||||
|
(#42 M-3), `100vh` окна (#42 M-6);
|
||||||
|
- новые возможности.
|
||||||
|
|
||||||
|
## Decisions
|
||||||
|
|
||||||
|
### Окно карточки
|
||||||
|
|
||||||
|
1. **Поиск списка не закрывает окно (#42 M-1).** Сейчас `IssuesPage.tsx:31-38` через 300 мс после ввода делает
|
||||||
|
`setParams(..., {replace: true})`. Если за это время открыто окно карточки, запись заменяет адрес окна адресом списка,
|
||||||
|
и окно закрывается.
|
||||||
|
|
||||||
|
Решение — перед открытием карточки со страницы «Задачи» (строка, ключ) несохранённый поиск записывается в адрес сразу,
|
||||||
|
таймер снимается. Окно открывается поверх списка уже с этим поиском. Отложенная запись MUST NOT срабатывать, пока
|
||||||
|
открыто окно.
|
||||||
|
|
||||||
|
Реализация на усмотрение исполнителя, например:
|
||||||
|
- `IssuesPage` даёт таблице свою обёртку `open`, которая сначала делает flush;
|
||||||
|
- или эффект проверяет, что текущий адрес браузера — сам список.
|
||||||
|
|
||||||
|
2. **Повторное закрытие не уводит назад дальше (#42 M-2).** `overlay.tsx:37-41`: `close()` при каждом вызове делает
|
||||||
|
`navigate(-depth)`. Двойной щелчок по ✕ или фону, Esc сразу после щелчка — два шага назад.
|
||||||
|
|
||||||
|
Решение — закрытие выполняется один раз для одной записи истории окна. Повторные вызовы игнорируются, пока адрес не
|
||||||
|
сменился. Признак — `location.key` записи, с которой начато закрытие. Отметка снимается также при «Остаться» (`abortClose`): навигация заблокирована, ключ записи не меняется, и без сброса следующий ✕ молча игнорировался бы (R-15).
|
||||||
|
|
||||||
|
3. **Подтверждение ухода с несохранённой заметкой (#42 M-4).** `NoteForm.tsx:82`: блок `role="alertdialog"` без имени,
|
||||||
|
фокус в него не переводится. Решение:
|
||||||
|
- блоку — `aria-label` «Уйти без сохранения заметки?»;
|
||||||
|
- при появлении фокус переходит на «Остаться» (безопасное действие по умолчанию);
|
||||||
|
- прокрутка к блоку остаётся.
|
||||||
|
|
||||||
|
4. **Связанная задача «нет в зеркале» и окно 404 (#42 M-5).**
|
||||||
|
- В «Связях» (`IssueCard.tsx:123-125`) ключ задачи вне зеркала открывает задачу в Jira в новой вкладке (как «Открыть
|
||||||
|
в Jira» в шапке) со значком внешней ссылки, а не окно хаба. Пометка «нет в зеркале» остаётся.
|
||||||
|
- Окно 404 (прямой адрес, ключ из текста, гонка) показывает «Закрыть» (закрывает окно целиком, как ✕). Если окно
|
||||||
|
открыто переходом из другой карточки (`depth ≥ 1`), добавляется «Назад» — `navigate(-1)` к прежней карточке.
|
||||||
|
- То же «Назад» — у ошибки загрузки с «Повторить».
|
||||||
|
|
||||||
|
### Строка состояния
|
||||||
|
|
||||||
|
5. **Ответ опроса прежнего проекта отбрасывается (#116).** `ActivityProvider.tsx`:
|
||||||
|
- эффект смены проекта (`:75-90`) сбрасывает `activeRef`/`active` и событие;
|
||||||
|
- эффект опроса (`:126-154`) в том же коммите видит `active` из замыкания старого рендера и снова ставит событие
|
||||||
|
`batch` (`:139`);
|
||||||
|
- после этого `active = null`, событие больше никто не снимает.
|
||||||
|
|
||||||
|
Решение — эффект опроса обрабатывает ответ, только если отслеживаемый пакет на момент обработки тот же:
|
||||||
|
`activeRef.current` совпадает с `active` из замыкания (по `id` и `project`). Иначе ответ игнорируется. `finish` по
|
||||||
|
устаревшему ответу тоже не вызывается.
|
||||||
|
|
||||||
|
Тест — детерминированный, без нагрузки: ответ `/batches/{id}` приходит в том же такте, что и смена проекта; после
|
||||||
|
этого событие модуля `null`. Существующий тест `activity.test.tsx:331` при этом стабилен (прогон в цикле ≥ 20 раз
|
||||||
|
зелёный).
|
||||||
|
|
||||||
|
### Лента и карточка
|
||||||
|
|
||||||
|
6. **Текст комментария в ленте без разметки (#103 С6).** Новая чистая функция `wikiPlain(text, opts)` рядом с
|
||||||
|
разборщиком (`issues/wiki/`) переводит вики-разметку в обычный текст тем же разбором `parseWiki`:
|
||||||
|
- выделение и `{{моно}}` — содержимое;
|
||||||
|
- ссылка — её текст (у `[адрес]` — адрес);
|
||||||
|
- ключ задачи — ключ, `[~логин]` — «@логин», вложение — имя;
|
||||||
|
- блоки — их текст через пробел, заголовок — его текст, `----` — пусто.
|
||||||
|
|
||||||
|
`eventText` для `comment` берёт `wikiPlain`, затем прежние «одна строка» и обрезку до 300 символов с «…». Модуль ленты
|
||||||
|
импортирует функцию из модуля задач того же модуля jira — граница модулей веба не нарушается. Та же функция
|
||||||
|
применяется к «Истории» карточки: она рисуется тем же `EventList`.
|
||||||
|
|
||||||
|
7. **Метки видов работ отдельно (#103).** В «Прочих полях» (`IssueCard.tsx:227`):
|
||||||
|
- строка «Вид работы в Jira» — названия видов из `kind_labels`, чьи метки есть у задачи, через запятую, в порядке
|
||||||
|
меток задачи;
|
||||||
|
- строка «Метки» — остальные метки.
|
||||||
|
|
||||||
|
Строка с пустым значением, как и сейчас, не показывается. Сравнение меток — точное, как у доски (`dicts.ts`).
|
||||||
|
Служебное поле заметки «Вид работы» (`work_kind`) не трогается. Это заметка хаба, а не метка Jira, и они могут
|
||||||
|
расходиться — отсюда подпись «в Jira».
|
||||||
|
|
||||||
|
8. **Описание из пробелов (#56 M-4).** `IssueCard.tsx:79`: раздел «Описание» показывается, только если
|
||||||
|
`description.trim()` не пуст.
|
||||||
|
|
||||||
|
### Вики-разметка (#56 M-5, M-6)
|
||||||
|
|
||||||
|
9. **`\#`.** `#` добавляется в `ESCAPABLE` (`inline.ts:21`). Строка `\# текст` не список (уже так — `LIST` требует `#`
|
||||||
|
первым символом), а `\#` показывается как `#`.
|
||||||
|
|
||||||
|
10. **Макросы с параметрами.** `MACRO` (`parse.ts:17`) принимает параметры у всех трёх тегов:
|
||||||
|
`\{(code|noformat|quote)(?::[^}]*)?\}`. Закрывающий тег — по имени без параметров, как сейчас.
|
||||||
|
|
||||||
|
11. **Пробелы после открывающего тега.** В `closeMacro` (`parse.ts:111`) остаток строки открывающего тега из одних
|
||||||
|
пробелов в тело блока не попадает. Ведущие пробелы строк внутри блока сохраняются.
|
||||||
|
|
||||||
|
12. **`[~логин|текст]`.** В `bracket` (`inline.ts:149-152`) логин — часть до `|`, обрезанная. Получается «@логин»,
|
||||||
|
подпись не показывается. Пустой логин или логин с пробелом — текст как есть, как сейчас.
|
||||||
|
|
||||||
|
13. **Непарная `[` в ячейке таблицы.** В `row` (`parse.ts:127-132`) `[` увеличивает глубину, только если дальше в
|
||||||
|
строке есть парная `]`. Иначе `[` — обычный символ, и `|` делит ячейки.
|
||||||
|
|
||||||
|
### Список задач, доска, проекты
|
||||||
|
|
||||||
|
14. **Список ждёт настройки (#60 M-3).** Запрос `GET /issues` (`useIssues`) включается, когда запрос `/settings`
|
||||||
|
завершён — успехом или ошибкой. Пока нет — «Загрузка…», без «По этим фильтрам задач нет». При ошибке настроек
|
||||||
|
закрытых статусов нет, запрос уходит с `hide_closed=1`, как сейчас.
|
||||||
|
|
||||||
|
15. **Два быстрых переключения фильтра доски (#35 T4.1/M-15).** `useBoardPrefs.ts:39-54`: `toggle` и `toggleHidden`
|
||||||
|
считают новое значение из `prefs` замыкания, и второй клик в том же батче теряет первый. Решение — последнее значение
|
||||||
|
держится в `ref`, обновляемом синхронно вместе с `setPrefs`. Новое значение считается из `ref` и тем же значением
|
||||||
|
пишется в `localStorage`. Чистота updater-функции сохраняется: StrictMode не пишет дважды.
|
||||||
|
|
||||||
|
16. **Фильтр спринтов не из-за эпика (#64 T9.1).** `fromHub.ts:30`: `hasSprint` — только по своим задачам, которые
|
||||||
|
не эпики (`!isEpic(it) && !it.neighbor && !!it.sprint`), то есть по узлам доски.
|
||||||
|
|
||||||
|
17. **«Повторить» при ошибке загрузки проектов (#64 M-6).** `ProjectGate.tsx:11`: у сообщения «Проекты не загрузились»
|
||||||
|
есть кнопка «Повторить», которая перезапрашивает `GET /projects` (`refetch` запроса проектов). Через контекст
|
||||||
|
проекта или прямо — на усмотрение исполнителя.
|
||||||
|
|
||||||
|
### Вложения
|
||||||
|
|
||||||
|
18. **Прежняя ошибка не мигает после успешного повтора (#86 T6.2).** После успешного скачивания хук помнит «успех в
|
||||||
|
этой карточке» для вложения. Строка (`AttachmentList.tsx:36`) не показывает серверный `state = failed`, пока
|
||||||
|
данные карточки не перечитаны. Отметку снимает приход данных, где `state` вложения уже не `failed`, или новый
|
||||||
|
`start`.
|
||||||
|
|
||||||
|
19. **401 при опросе скачивания — экран входа (#86 T6.3).** В `useAttachmentDownload` (`:74`, `:84`) `AuthError` идёт
|
||||||
|
в `dropTokenOn(e)`, как в остальном приложении, а не текстом под вложением. `HubDownError` — текст «хаб не отвечает»
|
||||||
|
под вложением с «Повторить» (требование «Ошибки хаба»).
|
||||||
|
|
||||||
|
20. **Порядок вложений — как у хаба (#86 M-4).** Хаб уже отдаёт вложения новыми сверху (`mirror.py:300-301`: по
|
||||||
|
`created_at` UTC, затем `attachment_id`). Веб не пересортирует (`newestFirst` убирается из `AttachmentList`).
|
||||||
|
`findAttachment` (`inline.ts:36-45`) при одинаковом имени берёт первое вложение в порядке хаба. Так выбор совпадает
|
||||||
|
с `attachment_ids` хаба и MCP и не зависит от `Date.parse` строк Jira.
|
||||||
|
|
||||||
|
### UI-кит
|
||||||
|
|
||||||
|
21. **Фокус после «Очистить» (#68 T3.3).** `SearchInput` (`ui/inputs.tsx:22`): после нажатия «Очистить» фокус
|
||||||
|
переводится в поле ввода (ref). Иначе кнопка исчезает вместе с фокусом.
|
||||||
|
|
||||||
|
## Risks / Trade-offs
|
||||||
|
|
||||||
|
- **#116 — гонка в порядке эффектов.** Решение опирается на то, что эффект смены проекта стоит раньше эффекта опроса и
|
||||||
|
синхронно меняет `activeRef`. Тест должен воспроизводить именно один коммит со сменой проекта и ответом опроса, иначе
|
||||||
|
он проходит и без правки. Исполнитель проверяет RED.
|
||||||
|
- **`wikiPlain` и разбор.** Плоский текст строится разбором карточки. Ошибка разбора в ленте даёт исходник — так же
|
||||||
|
безопасно, как в карточке (рендер блока с ошибкой — исходник).
|
||||||
|
- **Ссылка «нет в зеркале» в Jira.** Адрес Jira — тот же `JIRA_URL`, что у «Открыть в Jira». Задача может быть и вне
|
||||||
|
Jira (опечатка в связи) — тогда Jira покажет свою 404, это приемлемо.
|
||||||
|
- **Окно 404 и «Назад».** `depth` берётся из state окна. При прямом адресе `depth = 0` — только «Закрыть».
|
||||||
|
|
||||||
|
## Decisions made without discussion
|
||||||
|
|
||||||
|
Все — 25.10.2026: владелец пропустил вопросы brainstorming («делай сам»). Рулинги R-n — с R-1 в пределах фичи.
|
||||||
|
|
||||||
|
- R-1 (design #1): при открытии карточки отложенный поиск записывается сразу, окно открывается поверх списка с ним.
|
||||||
|
Обоснование: пользователь ввёл поиск и видит отфильтрованную строку, на которую щёлкнул, — потерять поиск хуже.
|
||||||
|
Цена ошибки — вместо этого отбрасывать поиск: правка одной функции.
|
||||||
|
- R-2 (design #2): повторное закрытие окна игнорируется до смены адреса. Цена ошибки — нет.
|
||||||
|
- R-3 (design #3): фокус подтверждения ухода — на «Остаться». Обоснование: безопасное действие по умолчанию, данные не
|
||||||
|
теряются от лишнего Enter. Цена ошибки — перенести фокус на другую кнопку.
|
||||||
|
- R-4 (design #4): ключ связи «нет в зеркале» открывает Jira в новой вкладке, а не окно хаба с 404. Обоснование: в хабе
|
||||||
|
про задачу ничего нет, полезное действие — посмотреть её в Jira. Цена ошибки — вернуть окно: одна ветка в `Links`.
|
||||||
|
- R-5 (design #4): у окна 404 после перехода по связи — «Назад» (на шаг) и «Закрыть» (всё окно). Цена ошибки — убрать
|
||||||
|
кнопку.
|
||||||
|
- R-6 (design #5): #116 чинится в провайдере (ответ сверяется с текущим `activeRef`), тест не ослабляется. Цена ошибки
|
||||||
|
— нет: гипотеза подтверждена чтением кода при сверке.
|
||||||
|
- R-7 (design #6): лента показывает текст комментария без разметки по разбору карточки; «История» карточки — тоже.
|
||||||
|
Альтернатива — показывать начало комментария отрендеренным — отвергнута: строка ленты однострочная. Цена ошибки —
|
||||||
|
правка `eventText`.
|
||||||
|
- R-8 (design #7): метки видов работ — отдельной строкой «Вид работы в Jira» с названиями видов, из «Меток» убраны.
|
||||||
|
Альтернатива — просто скрыть. Отвергнута: метка вида — единственный след вида работы в Jira, и он может расходиться
|
||||||
|
с заметкой. Цена ошибки — скрыть строку.
|
||||||
|
- R-9 (design #12): `[~логин|текст]` показывается «@логин» без подписи. Jira такой формы не документирует, логин важнее
|
||||||
|
подписи. Цена ошибки — показывать подпись.
|
||||||
|
- R-10 (design #13): непарная `[` в строке таблицы — обычный символ. Цена ошибки — нет.
|
||||||
|
- R-11 (design #14): при ошибке `/settings` список всё равно запрашивается (с `hide_closed=1`, закрытых статусов нет),
|
||||||
|
а не блокируется. Обоснование: список полезнее пустого экрана, ошибку настроек покажет страница. Цена ошибки — нет.
|
||||||
|
- R-12 (design #19): `HubDownError` при опросе скачивания остаётся текстом под вложением с «Повторить», отдельного
|
||||||
|
экрана нет — данные карточки остаются на экране (требование «Ошибки хаба»). Цена ошибки — нет.
|
||||||
|
- R-13 (design #20): веб берёт порядок вложений у хаба, а при одинаковых именах — первое в этом порядке. Обоснование:
|
||||||
|
совпадение с `attachment_ids` хаба и MCP. Цена ошибки — вернуть сортировку на клиенте.
|
||||||
|
- R-14 (proposal): в пакет не взяты #42 M-3 (возврат фокуса после закрытия окна), M-6 (`100vh`), доступность
|
||||||
|
UI-кита #68 и производительность разбора #56 M-2. Это не ошибки поведения, они остаются в своих issue. Цена ошибки —
|
||||||
|
следующий пакет.
|
||||||
|
- R-15 (design #2, 26.10.2026): `closingKey` в `overlay.tsx` сбрасывается и в `abortClose` («Остаться»), а не только при смене
|
||||||
|
`location.key`. Обоснование: при заблокированной навигации ключ записи не меняется, и без сброса второй Esc/✕ после
|
||||||
|
«Остаться» молча игнорировался бы (дефект плана, пойман e2e Task 4). Цена ошибки — повторный ✕ при блокере станет
|
||||||
|
повторно вызывать navigate; безвредно.
|
||||||
|
- R-16 (design #6): в ленте вложение читается именем без списка вложений карточки — флаг `WikiOptions.anyAttachment`,
|
||||||
|
выставляется только `wikiPlain`; карточка его не передаёт. Обоснование: финальное ревью I-1, `!скрин.png|thumbnail!` и
|
||||||
|
`[^отчёт.pdf]` иначе остаются сырой разметкой. Цена ошибки — правка в одном месте.
|
||||||
|
- R-17: #18 выполнен частично (отметка «успех» не снимается приходом данных) и разбор всего комментария ради 300 символов
|
||||||
|
в ленте — отложены (M-2, M-6 финального ревью). Обоснование: редкий случай и общая тема #56 M-2. Цена ошибки — редкая
|
||||||
|
невидимая ошибка вложения в открытой карточке; задержка ленты на очень длинных комментариях.
|
||||||
|
- R-18 (design #2): ссылка R-15 указывает на решение 2, а не на несуществующее #22; к решению 2 дописан сброс в
|
||||||
|
`abortClose`. Цена ошибки — нет (документация).
|
||||||
|
- R-19: N-1 (пере-ревью финальной волны) — в режиме `anyAttachment` ветка `!имя!` принимает любую пару «!», поэтому в ленте
|
||||||
|
«Срочно! Проверьте! Ок» теряет восклицательные знаки — отложено в follow-up (требовать расширение файла в имени);
|
||||||
|
второй волны правок нет. Цена ошибки — искажённые «!» в тексте строки ленты, данные не страдают.
|
||||||
|
- R-20 (design #6, 26.10.2026, решение владельца по R-19): N-1 исправлена до PR, а не отложена. В режиме `anyAttachment`
|
||||||
|
`!имя!` без списка вложений считается вложением, только если имя — имя файла с расширением без пробелов по краям
|
||||||
|
(`FILE_NAME` в `inline.ts`); `[^имя]` — явная ссылка на вложение, проверка к ней не применяется. Обоснование: ветка
|
||||||
|
сама внесла искажение «!» в строке ленты, владелец не хочет таких ошибок. Цена ошибки — `!слово.слово!` в обычном
|
||||||
|
тексте ленты («Ура!Сделано.ру!») прочитается как имя вложения без «!»; данные не страдают.
|
||||||
47
openspec/changes/web-bugfixes/proposal.md
Normal file
47
openspec/changes/web-bugfixes/proposal.md
Normal file
@ -0,0 +1,47 @@
|
|||||||
|
## Why
|
||||||
|
|
||||||
|
25.10.2026 владелец попросил сверить все открытые follow-up issue хаба и агентов с текущим кодом. В веб-приложении
|
||||||
|
хаба подтвердились ошибки, которые пользователь может увидеть: окно карточки закрывается само или уводит назад по
|
||||||
|
истории дальше нужного, лента показывает сырую разметку Jira, после смены проекта в строке состояния остаётся чужое
|
||||||
|
событие пакета, вики-разметка в отдельных случаях ломает таблицу или остаётся исходником. Владелец с ними пока не
|
||||||
|
сталкивался, но не хочет столкнуться в будущем. Это пакет A сверки (sdlc-hub#124).
|
||||||
|
|
||||||
|
## What Changes
|
||||||
|
|
||||||
|
- **Окно карточки** (#42): отложенный поиск списка больше не закрывает только что открытое окно (M-1); повторное
|
||||||
|
закрытие не уводит дальше назад по истории (M-2); подтверждение «Уйти без сохранения?» получает имя и фокус (M-4);
|
||||||
|
связанная задача «нет в зеркале» ведёт в Jira, а не в окно 404, у окна 404 после перехода по связи есть «Назад»
|
||||||
|
(M-5).
|
||||||
|
- **Строка состояния** (#116): ответ опроса пакета прежнего проекта после смены проекта отбрасывается — событие не
|
||||||
|
«залипает», тест `activity.test.tsx` перестаёт флейкать.
|
||||||
|
- **Лента** (#103 С6): текст комментария — без вики-разметки Jira.
|
||||||
|
- **Карточка** (#103, #56 M-4): метки видов работ (`kind_labels`) не смешиваются с прочими метками — отдельная строка
|
||||||
|
«Вид работы в Jira»; описание из одних пробелов — раздела нет.
|
||||||
|
- **Вики-разметка** (#56 M-5, M-6): `\#`, `{quote:…}`/`{noformat:…}` с параметрами, `[~логин|текст]`, пробелы после
|
||||||
|
`{code}`, непарная `[` в ячейке таблицы.
|
||||||
|
- **Список задач** (#60 M-3): запрос не уходит, пока не пришли настройки (`closed_statuses`) — нет лишнего запроса и
|
||||||
|
мелькания «По этим фильтрам задач нет».
|
||||||
|
- **Доска связей** (#35 T4.1/M-15, #64 T9.1): два быстрых переключения фильтра не теряют первое; фильтр спринтов не
|
||||||
|
появляется из-за спринта у эпика.
|
||||||
|
- **Проекты** (#64 M-6): «Повторить» при ошибке загрузки проектов.
|
||||||
|
- **Вложения** (#86 T6.2, T6.3, M-4): после успешного повтора не мигает прежняя ошибка; 401 при опросе скачивания —
|
||||||
|
экран входа; порядок вложений и выбор при одинаковых именах — как у хаба.
|
||||||
|
- **UI-кит** (#68 T3.3): после «Очистить» в поиске фокус остаётся в поле.
|
||||||
|
|
||||||
|
## Non-Goals
|
||||||
|
|
||||||
|
- Изменения API, схемы, MCP, агентов: всё чинится на клиенте, хаб уже отдаёт нужные данные.
|
||||||
|
- Прочие маркеры исходных issue (тесты, стиль, доступность UI-кита из #68, производительность разбора вики-разметки
|
||||||
|
#56 M-2, фокус окна #42 M-3) — остаются в своих issue.
|
||||||
|
- Правки Jira из веба — веб по-прежнему только для просмотра (служебные заметки — данные хаба).
|
||||||
|
|
||||||
|
## Impact
|
||||||
|
|
||||||
|
- Спека `web-app`: требования «Список задач», «Карточка задачи», «Служебные заметки», «Разметка Jira в карточке»,
|
||||||
|
«Проект в приложении», «Лента изменений», «Скачивание вложений в карточке», «Состояние и обновление модулей».
|
||||||
|
- Код (только `web/src/`): `modules/jira/issues/` (страница, окно, карточка, заметки, вложения, вики-разметка),
|
||||||
|
`modules/jira/feed/`, `modules/jira/board/`, `shell/activity/ActivityProvider.tsx`, `projects/ProjectGate.tsx`,
|
||||||
|
`ui/inputs.tsx`.
|
||||||
|
- Тесты: vitest рядом с кодом; e2e — сценарии окна карточки (`web/e2e/issues.spec.ts`).
|
||||||
|
- После merge — комментарии в исходных issue (#35, #42, #56, #60, #64, #68, #86, #103) о закрытых маркерах; #116
|
||||||
|
закрывается.
|
||||||
519
openspec/changes/web-bugfixes/specs/web-app/spec.md
Normal file
519
openspec/changes/web-bugfixes/specs/web-app/spec.md
Normal file
@ -0,0 +1,519 @@
|
|||||||
|
## MODIFIED Requirements
|
||||||
|
|
||||||
|
### Requirement: Список задач
|
||||||
|
Страница `/app/issues` SHALL показывать задачи текущего проекта из `GET /issues?project=<текущий>` таблицей: ключ
|
||||||
|
(открывает карточку задачи окном поверх списка) с пометкой «сосед» (относительно проекта), тема, статус Jira,
|
||||||
|
исполнитель, срок (`planned_end`, иначе `duedate`; просроченный срок незакрытой задачи выделен), спринт, эпик (имя
|
||||||
|
эпика, иначе ключ), вид работы, тип; статуса по факту в списке нет. Щелчок по строке (кроме ссылок и выделения текста)
|
||||||
|
SHALL открывать карточку задачи так же, как ключ. Шапка страницы SHALL показывать число показанных задач.
|
||||||
|
|
||||||
|
Фильтры SHALL соответствовать параметрам `GET /issues` (спринт, исполнитель, вид работы, статус Jira, метка, эпик,
|
||||||
|
только соседи, поиск по ключу или теме, скрыть закрытые), храниться в query-строке адреса вместе с `project` и
|
||||||
|
применяться сервером. На виду SHALL быть поиск, переключатель «Открытые | Все», переключатель «Только соседи» и
|
||||||
|
кнопка «Фильтры» с числом выбранных выпадающих списков; шесть выпадающих списков (спринт, исполнитель, вид работы,
|
||||||
|
статус Jira, метка, эпик) — в панели, которая раскрывается этой кнопкой; каждый выбранный список SHALL показываться
|
||||||
|
снимаемой плашкой «<подпись>: <значение>» и под строкой фильтров должно быть действие «Сбросить», снимающее все шесть
|
||||||
|
списков. Варианты выпадающих списков SHALL браться из снимка `GET /board?project=<текущий>`; значение фильтра из
|
||||||
|
адреса, которого нет среди вариантов, SHALL показываться в списке с пометкой «(нет на доске)». Положение «Открытые»
|
||||||
|
SHALL быть по умолчанию (в адресе ничего), «Все» — `closed=1` в адресе; «Открытые» — запрос с `hide_closed=1`,
|
||||||
|
кроме случая, когда в фильтре «Статус Jira» выбран статус из `closed_statuses`. Параметр `fact_status` в адресе
|
||||||
|
игнорируется. Задачи SHALL загружаться порциями по 50 строк; «Показать ещё» SHALL быть доступна, пока последняя
|
||||||
|
порция полная. Запрос `GET /issues` MUST NOT уходить, пока не завершён запрос настроек хаба (`closed_statuses`): до этого страница показывает «Загрузка…», а не «По этим фильтрам задач нет»; настройки не загрузились — запрос уходит как без закрытых статусов. Поиск SHALL записываться в адрес после паузы ввода; если до записи открыта карточка задачи, поиск SHALL записываться в адрес списка сразу, до открытия окна, и окно поверх списка с этим поиском MUST NOT закрываться отложенной записью.
|
||||||
|
|
||||||
|
#### Scenario: Фильтр в адресе
|
||||||
|
- **WHEN** выбран спринт «AWPCA_Спринт 2» и исполнитель
|
||||||
|
- **THEN** адрес содержит `project=`, `sprint=` и `assignee=`, таблица — результат `GET /issues` с этими параметрами; открытие этого
|
||||||
|
адреса в новой вкладке показывает ту же выборку, кнопка «Фильтры» показывает 2, видны плашки спринта и исполнителя
|
||||||
|
|
||||||
|
#### Scenario: Снять фильтр плашкой
|
||||||
|
- **WHEN** выбраны спринт и исполнитель, нажат ✕ на плашке спринта
|
||||||
|
- **THEN** в адресе нет `sprint=`, `assignee=` остался, таблица перезапрошена
|
||||||
|
|
||||||
|
#### Scenario: Конец списка
|
||||||
|
- **WHEN** вторая порция вернула 17 задач
|
||||||
|
- **THEN** в таблице 67 задач, «Показать ещё» не показана
|
||||||
|
|
||||||
|
#### Scenario: Фильтр по закрытому спринту
|
||||||
|
- **WHEN** открыт адрес `/app/issues?project=AWPCA&sprint=AWPCA_Спринт 0`, а в снимке доски такого спринта нет
|
||||||
|
- **THEN** выпадающий список «Спринт» показывает «AWPCA_Спринт 0 (нет на доске)» выбранным, таблица — по этому фильтру
|
||||||
|
|
||||||
|
#### Scenario: Карточка поверх списка
|
||||||
|
- **WHEN** список отфильтрован по спринту, прокручен и догружен «Показать ещё», открыта карточка задачи и окно закрыто
|
||||||
|
- **THEN** адрес списка с тем же фильтром, в таблице те же строки, прокрутка та же
|
||||||
|
|
||||||
|
#### Scenario: Щелчок по строке
|
||||||
|
- **WHEN** в таблице щёлкнута ячейка темы строки AURORA-1620
|
||||||
|
- **THEN** адрес `/app/issues/AURORA-1620`, карточка открыта окном поверх списка
|
||||||
|
|
||||||
|
#### Scenario: Закрытые скрыты по умолчанию
|
||||||
|
- **WHEN** открыт `/app/issues`, в зеркале задачи в статусах «В работе», «Готово», «Снято»
|
||||||
|
- **THEN** выбрано «Открытые», запрос `GET /issues` с `hide_closed=1`, в таблице только «В работе»; после выбора «Все»
|
||||||
|
адрес содержит `closed=1`, в таблице все три
|
||||||
|
|
||||||
|
#### Scenario: Выбран закрытый статус
|
||||||
|
- **WHEN** выбрано «Открытые» и в «Статус Jira» выбрано «Готово»
|
||||||
|
- **THEN** запрос `GET /issues?status=Готово` без `hide_closed`, в таблице задачи в статусе «Готово»
|
||||||
|
|
||||||
|
#### Scenario: Поиск перед открытием карточки
|
||||||
|
- **WHEN** в поиске списка введено «свод» и меньше чем через 300 мс щёлкнута строка AURORA-1620
|
||||||
|
- **THEN** окно карточки AURORA-1620 открыто и остаётся открытым; после его закрытия адрес списка содержит `q=свод`
|
||||||
|
|
||||||
|
#### Scenario: Закрытый статус в адресе до настроек
|
||||||
|
- **WHEN** открыт `/app/jira/issues?project=AWPCA&status=Готово`, ответ `/settings` ещё не пришёл
|
||||||
|
- **THEN** запросов `GET /issues` нет, показано «Загрузка…»; после ответа `/settings` (где «Готово» в `closed_statuses`) —
|
||||||
|
один запрос `GET /issues?status=Готово` без `hide_closed`; сообщение «По этим фильтрам задач нет» не появлялось
|
||||||
|
|
||||||
|
### Requirement: Карточка задачи
|
||||||
|
Адрес `/app/issues/{key}` SHALL открывать карточку задачи из `GET /issues/{key}` модальным окном поверх страницы, с
|
||||||
|
которой её открыли (список «Задачи» или доска); страница под окном SHALL оставаться смонтированной и сохранять фильтры,
|
||||||
|
загруженные строки, прокрутку и раскладку. Перезагрузка страницы с открытым окном SHALL сохранять страницу под окном. Открытый напрямую
|
||||||
|
адрес (новая вкладка, закладка) SHALL
|
||||||
|
показывать окно поверх списка задач без фильтров. Окно SHALL закрываться кнопкой ✕, клавишей Esc и щелчком по фону (нажатие и отпускание — на фоне; выделение текста,
|
||||||
|
отпущенное за краем окна, окно не закрывает) и
|
||||||
|
возвращать на страницу, с которой его открыли, даже после переходов по связям внутри окна; «назад» браузера после
|
||||||
|
закрытия MUST NOT открывать карточку снова. Переход по связанной задаче SHALL менять содержимое окна и адрес, «назад»
|
||||||
|
браузера — возвращать к прежней карточке. Пока окно открыто, фокус SHALL оставаться в окне, страница под ним не
|
||||||
|
прокручивается. Повторное закрытие, пока окно закрывается (двойной щелчок по ✕ или фону, Esc сразу после щелчка), MUST NOT уводить дальше назад по истории браузера.
|
||||||
|
|
||||||
|
Содержимое окна:
|
||||||
|
- шапка: ключ, статус Jira, «Открыть в Jira», ✕, тема; строка
|
||||||
|
«исполнитель · срок · спринт · эпик» без пустых частей, срок — `planned_end`, иначе `duedate`, с пометкой «просрочено»,
|
||||||
|
если он раньше сегодняшней даты и статус Jira не входит в `closed_statuses`; эпик — темой
|
||||||
|
(`epic_summary`), иначе ключом;
|
||||||
|
- «Связи» по группам в порядке: «Зависит от» (входящие связи логических типов `блокирует начало` и `блокирует
|
||||||
|
завершение` из `link_types`, с меткой «начало» / «завершение»), «Блокирует» (исходящие тех же типов, с теми же метками),
|
||||||
|
«Связана с» (тип `связанные` в обе стороны), «Прочие» (тип Jira вне `link_types`: имя типа и направление «→» / «←»);
|
||||||
|
в строке — ключ (открывает связанную задачу в окне), тема и статус Jira, а если задачи нет в зеркале — ключ ссылкой на задачу в Jira (новая вкладка, значок внешней ссылки) и «нет в
|
||||||
|
зеркале»; окно хаба такая ссылка не открывает; пустые группы не показываются, без связей — «связей нет»;
|
||||||
|
- «Описание» — текст, отрендеренный по требованию «Разметка Jira в карточке», с кнопкой «исходник» / «текст» в
|
||||||
|
заголовке раздела; длиннее 8 строк — свёрнуто с кнопкой «развернуть» / «свернуть» в обоих режимах; без описания
|
||||||
|
(пустое или из одних пробелов и переводов строк) раздела нет;
|
||||||
|
- «Вложения (N)» — новые сверху: значок по типу файла (картинка, таблица, PDF, прочее), имя, размер, «автор · дата» и
|
||||||
|
кнопка скачивания (требование «Скачивание вложений в карточке»); без вложений раздела нет;
|
||||||
|
- «Комментарии (N)» — новые сверху, автор, время, кнопка «исходник» / «текст» у каждого комментария, текст,
|
||||||
|
отрендеренный по требованию «Разметка Jira в карточке», под текстом — вложения, на которые ссылается комментарий
|
||||||
|
(`attachment_ids`): строка «плашек» с именем и размером, щелчок — скачивание; кнопка «Скрыть» / «Показать» сворачивает раздел, выбор SHALL
|
||||||
|
запоминаться в браузере для всех карточек; без комментариев — «комментариев нет»;
|
||||||
|
- «Служебные заметки» (требование «Служебные заметки»);
|
||||||
|
- «Прочие поля» — непустые из: тип, приоритет, автор, «Вид работы в Jira» (названия видов из `kind_labels`, метки которых
|
||||||
|
есть у задачи, через запятую), метки (кроме меток видов из `kind_labels`), плановое начало, обновлена в Jira, сосед
|
||||||
|
по связям;
|
||||||
|
- «Команды моста (N)» — 10 последних команд по задаче (id, пакет, тип, статус, время завершения, ошибка), раздел свёрнут
|
||||||
|
при открытии; если команд не было — текст «команд по задаче не было».
|
||||||
|
На ширине от 900 px «Связи», «Описание», «Вложения», «Комментарии» — в левой колонке, заметки, прочие поля и команды — в правой;
|
||||||
|
уже — одна колонка: заметки, связи, описание, вложения, комментарии, прочие поля, команды. Если задачи нет в зеркале (404), окно
|
||||||
|
SHALL сообщать об этом с кнопкой «Закрыть» (закрывает окно целиком, как ✕); если окно открыто переходом из другой карточки — ещё и с кнопкой «Назад», возвращающей к ней (так же у ошибки загрузки); хаб не отвечает — сообщение и «Повторить», ранее загруженные данные
|
||||||
|
остаются.
|
||||||
|
|
||||||
|
#### Scenario: Связанная задача
|
||||||
|
- **WHEN** у AURORA-1620 исходящая связь `ASUPR_Блокер_Начала` на AURORA-1621 «Расчёт свода» в статусе «В работе»
|
||||||
|
- **THEN** в карточке в группе «Блокирует» строка «начало · AURORA-1621 · Расчёт свода · В работе»; щелчок по ключу
|
||||||
|
меняет содержимое окна на AURORA-1621, адрес — `/app/issues/AURORA-1621`; «назад» браузера возвращает AURORA-1620
|
||||||
|
|
||||||
|
#### Scenario: Входящий блокер
|
||||||
|
- **WHEN** у AURORA-1620 входящая связь `ASUPR_Блокер_Завершения` от AURORA-1500
|
||||||
|
- **THEN** строка с AURORA-1500 и меткой «завершение» — в группе «Зависит от»
|
||||||
|
|
||||||
|
#### Scenario: Задача без команд
|
||||||
|
- **WHEN** по AURORA-1620 не было команд
|
||||||
|
- **THEN** в разделе «Команды моста» — текст «команд по задаче не было», таблицы нет
|
||||||
|
|
||||||
|
#### Scenario: Закрытие после переходов
|
||||||
|
- **WHEN** со списка «Задачи» открыта AURORA-1620, в окне — переход по связи на AURORA-1621, нажата Esc
|
||||||
|
- **THEN** окно закрыто, адрес — адрес списка с прежними фильтрами; «назад» браузера не открывает карточку
|
||||||
|
|
||||||
|
#### Scenario: Прямой адрес
|
||||||
|
- **WHEN** в новой вкладке открыт `/app/issues/AURORA-1620`, затем нажат ✕
|
||||||
|
- **THEN** под окном был список задач без фильтров; после закрытия адрес `/app/issues`, «назад» не открывает карточку
|
||||||
|
|
||||||
|
#### Scenario: Скрытые комментарии
|
||||||
|
- **WHEN** в карточке нажато «Скрыть» у комментариев, затем страница перезагружена и открыта другая карточка
|
||||||
|
- **THEN** комментарии в ней скрыты, видна кнопка «Показать» и число комментариев
|
||||||
|
|
||||||
|
#### Scenario: Просрочка закрытой задачи
|
||||||
|
- **WHEN** у AURORA-1620 срок 2026-10-01, сегодня 2026-10-17, статус Jira «Готово»
|
||||||
|
- **THEN** в строке под темой «срок 01.10» без пометки «просрочено»; при статусе «В работе» — с пометкой
|
||||||
|
|
||||||
|
#### Scenario: Двойной щелчок по закрытию
|
||||||
|
- **WHEN** со списка «Задачи» открыта AURORA-1620, в окне — переход по связи на AURORA-1621, по ✕ щёлкнули дважды подряд
|
||||||
|
- **THEN** окно закрыто, адрес — адрес списка; запись истории перед списком не открыта
|
||||||
|
|
||||||
|
#### Scenario: Связанная задача не в зеркале
|
||||||
|
- **WHEN** у AURORA-1620 связь на AURORA-1999, которой нет в зеркале
|
||||||
|
- **THEN** в «Связях» строка «AURORA-1999 · нет в зеркале»; ключ — ссылка `<адрес Jira>/browse/AURORA-1999` в новой
|
||||||
|
вкладке, окно карточки остаётся на AURORA-1620
|
||||||
|
|
||||||
|
#### Scenario: Задача не в зеркале после перехода
|
||||||
|
- **WHEN** в окне AURORA-1620 щёлкнут ключ AURORA-1999 в тексте комментария, хаб ответил 404
|
||||||
|
- **THEN** окно сообщает «Задачи AURORA-1999 нет в зеркале хаба» с кнопками «Назад» и «Закрыть»; «Назад» возвращает
|
||||||
|
окно к AURORA-1620, «Закрыть» закрывает окно и возвращает на страницу под ним
|
||||||
|
|
||||||
|
#### Scenario: Метки вида работы
|
||||||
|
- **WHEN** у задачи метки `AWPCA` и `kind-dev`, в `kind_labels` есть «разработка» → `kind-dev`
|
||||||
|
- **THEN** в «Прочих полях» строки «Вид работы в Jira: разработка» и «Метки: AWPCA»
|
||||||
|
|
||||||
|
#### Scenario: Описание из пробелов
|
||||||
|
- **WHEN** описание задачи — три пробела и два перевода строки
|
||||||
|
- **THEN** раздела «Описание» нет
|
||||||
|
|
||||||
|
### Requirement: Служебные заметки
|
||||||
|
Карточка задачи SHALL показывать служебные заметки в режиме чтения: «Вид работы», «Сделано»,
|
||||||
|
«Осталось», «Заметка владельца» (пустые — «—») и время и автор последнего изменения; кнопка «Править» SHALL открывать
|
||||||
|
форму: «вид работы» — выбор из значений `kind_labels` настроек (и текущего значения, если его там нет, и пустого),
|
||||||
|
«сделано», «осталось», «заметка владельца» —
|
||||||
|
многострочный текст; «Отмена» SHALL возвращать режим чтения, при несохранённых правках — после подтверждения.
|
||||||
|
Сохранение SHALL отправлять `PUT /issues/{key}/note` только с изменёнными полями и `updated_by: "web"`; очищенное поле
|
||||||
|
отправляется как `null`. После успеха SHALL показываться режим чтения с сохранённой заметкой; список задач под окном SHALL
|
||||||
|
показывать новый «вид работы» без «Перечитать», доска под окном SHALL перечитать снимок
|
||||||
|
(`GET /board`) без потери раскладки и прокрутки; при 422 — текст ошибки у формы без
|
||||||
|
потери введённого. Закрытие окна, переход по связи и уход в другой раздел с несохранёнными правками SHALL требовать
|
||||||
|
подтверждения; блок подтверждения — с доступным именем «Уйти без сохранения заметки?», при появлении фокус SHALL переходить на «Остаться». Статуса по факту в заметках нет. Заметки пишутся только в хаб, в Jira приложение ничего не отправляет.
|
||||||
|
|
||||||
|
#### Scenario: Только изменённые поля
|
||||||
|
- **WHEN** в заметке нажато «Править», изменено только «осталось» и нажато «Сохранить»
|
||||||
|
- **THEN** тело `PUT /issues/{key}/note` — `{rest_text: "…", updated_by: "web"}`; карточка снова в режиме чтения с новым
|
||||||
|
«Осталось»
|
||||||
|
|
||||||
|
#### Scenario: Уход с несохранёнными правками
|
||||||
|
- **WHEN** в форме заметки есть несохранённая правка и нажата Esc
|
||||||
|
- **THEN** приложение спрашивает подтверждение, фокус — на «Остаться»; при отказе окно открыто, форма с введённым текстом
|
||||||
|
|
||||||
|
#### Scenario: Заметка видна на доске
|
||||||
|
- **WHEN** карточка AURORA-9002 открыта с доски, вид работы изменён на «тестирование» и сохранён
|
||||||
|
- **THEN** доска под окном остаётся на месте и перезапрашивается (`GET /board` отдаёт `work_kind` «тестирование»); окно закрывается без потери раскладки и прокрутки доски
|
||||||
|
|
||||||
|
### Requirement: Разметка Jira в карточке
|
||||||
|
Описание задачи и текст каждого комментария в карточке SHALL показываться отрендеренной вики-разметкой Jira (хаб
|
||||||
|
хранит их как есть, в wiki) так, как их показывает Jira. Разбираются:
|
||||||
|
- блоки: заголовки `h1.`–`h6.` в начале строки; списки — строки, начинающиеся (после необязательных пробелов) с
|
||||||
|
маркеров `*`, `#` и пробела (уровень вложенности — числом маркеров, `*` — маркированный, `#` — нумерованный,
|
||||||
|
смешанная вложенность `*#` — по последнему маркеру) или с одного `-` и пробела (маркированный, первый уровень);
|
||||||
|
`--`, `*-` и прочие сочетания с `-` — не список; таблицы — строки, начинающиеся с
|
||||||
|
`|`, ячейки `||заголовок||` и `|ячейка|` (`|` внутри `[текст|адрес]` ячейку не делит; `[` без парной `]` дальше в строке —
|
||||||
|
обычный символ и на деление ячеек не влияет); `{code}` / `{code:параметры}` … `{code}` и `{noformat}` / `{noformat:параметры}` … `{noformat}` —
|
||||||
|
моноширинный блок без разбора разметки внутри; `{quote}` / `{quote:параметры}` … `{quote}` и строка `bq. ` — цитата; открывающий тег
|
||||||
|
блока — в начале строки, закрывающий — в любом месте той же или последующей строки, текст после закрывающего тега
|
||||||
|
разбирается дальше как обычные строки; пробелы после открывающего тега в той же строке в блок не попадают; тег не в начале строки — текст как есть; строка `----` —
|
||||||
|
горизонтальная черта; прочие строки — абзацы, разделённые пустыми строками, одиночный перенос внутри абзаца —
|
||||||
|
перенос строки;
|
||||||
|
- внутри строки: `*жирный*`, `_курсив_`, `-зачёркнутый-`, `+подчёркнутый+`, `^верхний^`, `~нижний~`, `{{моно}}`
|
||||||
|
(`{{…}}` — без разбора внутри); маркер выделения действует, только если открывающий стоит в начале текста или после
|
||||||
|
пробела либо знака препинания и за ним не пробел и не тот же символ (`**x**` — текст как есть), а закрывающий стоит после не-пробела и за ним не буква и не
|
||||||
|
цифра (`data_type`, `2026-10-15`, `счёт-фактура`, `a*b*c` — обычный текст); `\\` — перенос строки; `\` перед
|
||||||
|
символом разметки (в том числе `#`: `\#` — символ `#`) — сам символ;
|
||||||
|
- ссылки: `[текст|адрес]`, `[адрес]` и голые адреса `http://`, `https://` — ссылка в новой вкладке, только для схем
|
||||||
|
`http`, `https`, `mailto`, иначе текст как есть; `[ключ задачи]`, `[текст|ключ задачи]` и ключ задачи в тексте вида
|
||||||
|
`ПРОЕКТ-123`, где `ПРОЕКТ` — `project_key` из настроек хаба, — ссылка, открывающая карточку этой задачи в окне
|
||||||
|
(`UTF-8`, `SHA-256` и ключи других проектов — текст);
|
||||||
|
`[~логин]` и `[~логин|текст]` — «@логин»;
|
||||||
|
- ссылки на вложения задачи — `!имя!`, `!имя|параметры!`, `[^имя]`, `[текст|^имя]` с тем же разбором имени, что у хаба
|
||||||
|
(`hub-api`, «Ссылки на вложения в тексте»), имя есть среди вложений задачи: ссылка-кнопка со значком скрепки и текстом
|
||||||
|
— именем файла (у `[текст|^имя]` — текстом), щелчок — скачивание; картинка в тексте не показывается; имени нет среди
|
||||||
|
вложений — исходник как есть.
|
||||||
|
Всё, что разборщик не распознал (`{panel}`, `{color}`, `!картинка!` и `[^имя]` без такого вложения у задачи, незакрытые маркеры и блоки и прочее), SHALL
|
||||||
|
показываться как текст исходника без потерь. Разметка MUST NOT превращаться в HTML строкой (`innerHTML`,
|
||||||
|
`dangerouslySetInnerHTML`): текст задачи не может добавить на страницу элементы, атрибуты или скрипты. Если рендер
|
||||||
|
блока завершился ошибкой, блок SHALL показываться исходником.
|
||||||
|
|
||||||
|
Кнопка «исходник» у описания и у каждого комментария SHALL переключать этот блок на исходник — текст с сохранёнными
|
||||||
|
переносами строк, как без разбора разметки, — и обратно кнопкой «текст»; остальные блоки не переключаются. При каждом
|
||||||
|
открытии карточки (в том числе при переходе по связи) все блоки показываются отрендеренными, выбор не запоминается.
|
||||||
|
|
||||||
|
#### Scenario: Описание со структурой
|
||||||
|
- **WHEN** описание AURORA-1620 — `h3. Контекст`, строка `Шаги:`, затем `# Загрузить ОСВ` и `# Открыть документ`,
|
||||||
|
затем пустая строка и `*Итог:* ошибка в {{data_type}}`
|
||||||
|
- **THEN** в «Описании» заголовок «Контекст», абзац «Шаги:», нумерованный список из двух пунктов и абзац, где «Итог:» —
|
||||||
|
жирным, «data_type» — моноширинным; символов `h3.`, `#`, `*`, `{{` не видно
|
||||||
|
|
||||||
|
#### Scenario: Переключение на исходник
|
||||||
|
- **WHEN** в карточке AURORA-1620 у описания нажато «исходник»
|
||||||
|
- **THEN** описание показано исходником с переносами строк (видны `h3. Контекст` и `# Загрузить ОСВ`), кнопка — «текст»;
|
||||||
|
комментарии остаются отрендеренными; после «текст» описание снова отрендерено
|
||||||
|
|
||||||
|
#### Scenario: Исходник у одного комментария
|
||||||
|
- **WHEN** у второго комментария нажато «исходник», затем открыта связанная задача и возврат «назад»
|
||||||
|
- **THEN** сразу после нажатия исходником показан только второй комментарий; после возврата все комментарии
|
||||||
|
отрендерены
|
||||||
|
|
||||||
|
#### Scenario: Ключ задачи в тексте
|
||||||
|
- **WHEN** комментарий — `П. 1 ведётся в AURORA-1445; AURORA-1645 снята как дубль.`
|
||||||
|
- **THEN** «AURORA-1445» и «AURORA-1645» — ссылки; щелчок по первой меняет содержимое окна на AURORA-1445
|
||||||
|
|
||||||
|
#### Scenario: Неизвестная разметка и опасный адрес
|
||||||
|
- **WHEN** описание — `{color:red}важно{color}`, `!схема.png!` и `[нажми|javascript:alert(1)]`
|
||||||
|
- **THEN** все три показаны текстом исходника как есть, ссылки и картинки нет, скрипт не выполняется
|
||||||
|
|
||||||
|
#### Scenario: Списки с отступом и пустыми строками
|
||||||
|
- **WHEN** описание — `Шаги воспроизведения:`, ` * Загрузить ОСВ.`, пустая строка, ` * Дождаться обработки.`
|
||||||
|
- **THEN** абзац «Шаги воспроизведения:» и маркированные пункты «Загрузить ОСВ.» и «Дождаться обработки.» без
|
||||||
|
звёздочек
|
||||||
|
|
||||||
|
#### Scenario: Обычный текст не портится
|
||||||
|
- **WHEN** описание — `Поле data_type = null, дата 2026-10-15, оборот 18 803 903,65 - 9 351 102,89, a*b*c, UTF-8,
|
||||||
|
**важно**`
|
||||||
|
- **THEN** текст показан как есть, без курсива, зачёркивания, жирного и ссылок
|
||||||
|
|
||||||
|
#### Scenario: Скриншот в тексте комментария
|
||||||
|
- **WHEN** комментарий — «см. !скрин 1.png|thumbnail!», у задачи есть вложение «скрин 1.png»
|
||||||
|
- **THEN** вместо `!скрин 1.png|thumbnail!` — кнопка со скрепкой «скрин 1.png»; под комментарием плашка «скрин 1.png ·
|
||||||
|
234,4 КБ»; щелчок по любой из них скачивает файл
|
||||||
|
|
||||||
|
#### Scenario: Экранированная решётка и цитата с параметрами
|
||||||
|
- **WHEN** описание — `\# не пункт`, затем `{quote:title=Итог}`, `согласовано`, `{quote}`
|
||||||
|
- **THEN** абзац «# не пункт» (не нумерованный список) и цитата «согласовано»; тегов `{quote` не видно
|
||||||
|
|
||||||
|
#### Scenario: Таблица с непарной скобкой
|
||||||
|
- **WHEN** описание — `|[draft|итог|готово|`
|
||||||
|
- **THEN** таблица из одной строки с ячейками «[draft», «итог», «готово»
|
||||||
|
|
||||||
|
#### Scenario: Упоминание с подписью и код с пробелами
|
||||||
|
- **WHEN** комментарий — `[~ivanov|Иванов]`, затем строка `{code} `, `x = 1`, `{code}`
|
||||||
|
- **THEN** «@ivanov» и моноширинный блок из одной строки `x = 1` без пустой строки перед ней
|
||||||
|
|
||||||
|
### Requirement: Проект в приложении
|
||||||
|
«Обзор», доска, список задач, лента и вики SHALL показывать один проект — текущий. Текущий проект SHALL задаваться
|
||||||
|
параметром `project` в адресе страниц; без параметра — последний выбранный в этом браузере (`localStorage`), если он
|
||||||
|
есть в `GET /projects`, иначе первый из `GET /projects`; выбранный проект записывается в адрес без новой записи истории.
|
||||||
|
Проекта из адреса нет в `GET /projects` — страница SHALL показывать «проект <метка> не найден» и подсказку выбрать
|
||||||
|
проект в списке «Проект» боковой панели (на узком экране — в «Меню») вместо данных. Проектов нет — страницы SHALL
|
||||||
|
показывать приглашение создать проект («+ Новый проект…»). Список проектов не загрузился — сообщение с ошибкой и кнопкой «Повторить», перезапрашивающей `GET /projects`. Переходы между страницами SHALL сохранять текущий проект;
|
||||||
|
смена проекта на странице, которой нет у нового проекта, открывает «Обзор»; исключение — «Бэклог», «Спринт», «Kanban»:
|
||||||
|
адрес остаётся, страница показывает «У проекта нет …-доски» (требование «Спринты и бэклог»). Карточка задачи
|
||||||
|
(`/app/jira/issues/{key}`) от проекта не зависит.
|
||||||
|
|
||||||
|
Доска SHALL загружать `GET /board?project=<текущий>`. Раскладка, дорожки и фильтры доски в `localStorage` SHALL
|
||||||
|
храниться отдельно для каждого проекта; у проекта `AWPCA` — на прежних ключах (раскладка, сделанная до проектов,
|
||||||
|
сохраняется). Фильтр спринтов SHALL показываться, только если хотя бы у одной своей задачи снимка (`neighbor = false`), не
|
||||||
|
эпика, есть спринт; иначе — «колонки «Спринт» нет — показаны все задачи».
|
||||||
|
|
||||||
|
Форма «Новый проект» (пункт «+ Новый проект…» выпадающего списка) SHALL открываться окном поверх страницы: метка (с
|
||||||
|
подсказкой «метка задач в Jira и имя проекта в хабе») и название, затем поля каждого модуля в своей группе с
|
||||||
|
заголовком — названием модуля: «Jira» — номер доски Jira (необязательно; подсказка «scrum или kanban определится
|
||||||
|
сам»), «Вики» — корень вики (необязательно; подсказка «DOC-… или ссылка на страницу Eva»). Подсказка — текстом под
|
||||||
|
полем, не в placeholder. Пустое необязательное поле в запрос не попадает; числовое поле с нецелым значением — ошибка у
|
||||||
|
поля, запрос не уходит. «Создать» — `POST /projects` с автором `viewer`; ошибка хаба 409/422 — текст `detail` рядом с
|
||||||
|
кнопкой, окно остаётся открытым; успех — окно закрывается, текущим становится новый проект, каждый пакет из ответа
|
||||||
|
(`batches`) отслеживается своим модулем (требование «Состояние и обновление модулей»).
|
||||||
|
|
||||||
|
#### Scenario: Первый вход после выкатки
|
||||||
|
- **WHEN** открыт `/app/jira/links` без `project`, в `localStorage` выбора нет, проекты `AWPCA` и `STOCKS_DRRK`
|
||||||
|
- **THEN** адрес — `/app/jira/links?project=AWPCA`, доска — `GET /board?project=AWPCA` с прежней раскладкой карточек
|
||||||
|
|
||||||
|
#### Scenario: Переключение проекта
|
||||||
|
- **WHEN** на доске `AWPCA` в выпадающем списке выбран `STOCKS_DRRK`, затем открыт раздел «Задачи»
|
||||||
|
- **THEN** доска — `GET /board?project=STOCKS_DRRK` со своей раскладкой; адрес «Задач» —
|
||||||
|
`/app/jira/issues?project=STOCKS_DRRK`
|
||||||
|
|
||||||
|
#### Scenario: Неизвестный проект в адресе
|
||||||
|
- **WHEN** открыт `/app/jira/issues?project=NOPE`
|
||||||
|
- **THEN** «проект NOPE не найден» и подсказка выбрать проект в боковой панели; запроса `GET /issues` нет
|
||||||
|
|
||||||
|
#### Scenario: Создание проекта из формы
|
||||||
|
- **WHEN** в форме введены `STOCKS_DRRK`, «Запасы ДРРК», доска и корень вики пусто; нажато «Создать»
|
||||||
|
- **THEN** `POST /projects {label: "STOCKS_DRRK", name: "Запасы ДРРК", author: "viewer"}`; текущий проект — `STOCKS_DRRK`;
|
||||||
|
строка «Jira» блока состояния показывает «пакет #N выполняется»
|
||||||
|
|
||||||
|
#### Scenario: Проект с корнем вики из формы
|
||||||
|
- **WHEN** в форме введены `STOCKS_DRRK`, «Запасы ДРРК», корень `DOC-001234`; нажато «Создать»
|
||||||
|
- **THEN** `POST /projects {label: "STOCKS_DRRK", name: "Запасы ДРРК", wiki_root: "DOC-001234", author: "viewer"}`; строка
|
||||||
|
«Jira» показывает пакет обновления, строка «Вики» — пакет синхронизации
|
||||||
|
|
||||||
|
#### Scenario: Метка занята
|
||||||
|
- **WHEN** в форме введена метка `awpca`, хаб ответил 409
|
||||||
|
- **THEN** окно открыто, рядом с «Создать» — текст ошибки хаба
|
||||||
|
|
||||||
|
#### Scenario: Канбан-проект
|
||||||
|
- **WHEN** текущий проект — `STOCKS_DRRK`, ни у одной своей задачи снимка нет спринта (у соседа из `AWPCA` спринт есть)
|
||||||
|
- **THEN** вместо фильтра спринтов — «колонки «Спринт» нет — показаны все задачи»
|
||||||
|
|
||||||
|
#### Scenario: Нецелый номер доски
|
||||||
|
- **WHEN** в поле «Номер доски Jira» введено `51.5`, нажато «Создать»
|
||||||
|
- **THEN** у поля — «нужно целое число», запроса `POST /projects` нет
|
||||||
|
|
||||||
|
#### Scenario: Спринт только у эпика
|
||||||
|
- **WHEN** у эпика AURORA-9100 проекта спринт «AWPCA_Спринт 2», у остальных своих задач спринта нет
|
||||||
|
- **THEN** вместо фильтра спринтов — «колонки «Спринт» нет — показаны все задачи»
|
||||||
|
|
||||||
|
#### Scenario: Проекты не загрузились
|
||||||
|
- **WHEN** `GET /projects` ответил 500, затем нажато «Повторить», хаб ответил списком проектов
|
||||||
|
- **THEN** сначала «Проекты не загрузились: …» с кнопкой «Повторить»; после нажатия — страница текущего проекта
|
||||||
|
|
||||||
|
### Requirement: Лента изменений
|
||||||
|
Страница `/app/feed?project=<метка>` SHALL показывать события журнала изменений своих задач текущего проекта за период
|
||||||
|
(`GET /changes` с `project`, `since`, `until`, `kinds`). Период SHALL выбираться переключателем «2 часа» / «Сегодня» /
|
||||||
|
«Вчера» / «7 дней» (по умолчанию «Сегодня»; границы — по местному времени браузера: «Сегодня» — с начала текущих суток
|
||||||
|
до сейчас, «Вчера» — с начала прошлых суток до начала текущих) и храниться в адресе (`period=2h|today|yesterday|7d`;
|
||||||
|
«Сегодня» в адрес не пишется). Виды событий SHALL выбираться флажками «Статусы», «Описание», «Сроки», «Комментарии»,
|
||||||
|
«Прочие поля» (по умолчанию все, кроме «Прочие поля») и храниться в адресе (`kinds=` через запятую; набор по умолчанию в
|
||||||
|
адрес не пишется; в адресе нет ни одного известного вида — набор по умолчанию); снятые все флажки — сообщение «выберите
|
||||||
|
хотя бы один вид», запроса нет.
|
||||||
|
|
||||||
|
События SHALL быть сгруппированы по задаче; задачи — по времени последнего события, новые сверху. У группы: ключ —
|
||||||
|
ссылка на карточку задачи (окно поверх ленты, требование «Карточка задачи»), тема и текущий статус; под ней события по
|
||||||
|
времени, новые сверху, каждое — время (`ЧЧ:ММ`, с датой `ДД.ММ`, если событие не в текущих сутках), автор и текст:
|
||||||
|
статус — «статус «было» → «стало»»; описание — «описание изменено»; срок, плановое начало и окончание — «срок ДД.ММ →
|
||||||
|
ДД.ММ» (дата по значению `from_value`/`to_value`, иначе строка журнала; пустое — «—»); комментарий — «комментарий:» и
|
||||||
|
первые 300 символов текста без вики-разметки Jira (так, как текст читается отрендеренным: без `*`, `{code}`, `h3.`; у `[текст|адрес]` — текст, `[~логин]` — «@логин») одной строкой с «…»; прочие поля — «<название поля>: «было» → «стало»» (значения до 80
|
||||||
|
символов с «…»), где название — подпись из словаря приложения (исполнитель, спринт, метки, приоритет, тема, эпик, связь,
|
||||||
|
вложение, резолюция), иначе имя поля Jira. Подзаголовок страницы — число событий и границы периода. Событий нет —
|
||||||
|
сообщение «за период изменений нет»; `truncated` — сообщение «показаны последние N событий — выберите период короче».
|
||||||
|
Ошибка хаба — сообщение с кнопкой «Повторить». Ленту SHALL перечитывать завершение «Обновить из Jira» (вместе со списками
|
||||||
|
задач) и возврат на вкладку браузера.
|
||||||
|
|
||||||
|
#### Scenario: Что закрылось за два часа
|
||||||
|
- **WHEN** открыта лента проекта AWPCA, выбран период «2 часа», сняты все флажки, кроме «Статусы»
|
||||||
|
- **THEN** адрес — `/app/feed?project=AWPCA&period=2h&kinds=status`; запрос `GET /changes?project=AWPCA&since=<сейчас − 2 ч>&kinds=status`;
|
||||||
|
задачи сгруппированы, у каждой — события «ЧЧ:ММ автор: статус «В работе» → «Готово»»
|
||||||
|
|
||||||
|
#### Scenario: Карточка из ленты
|
||||||
|
- **WHEN** в ленте нажат ключ AURORA-9002
|
||||||
|
- **THEN** поверх ленты открыто окно карточки AURORA-9002; закрытие окна возвращает ленту с тем же периодом
|
||||||
|
|
||||||
|
#### Scenario: Пустой период
|
||||||
|
- **WHEN** за выбранный период событий нет
|
||||||
|
- **THEN** показано «за период изменений нет»
|
||||||
|
|
||||||
|
#### Scenario: Вчера
|
||||||
|
- **WHEN** выбран период «Вчера», сейчас 22.10 14:05 местного времени
|
||||||
|
- **THEN** запрос с `since` = 21.10 00:00 и `until` = 22.10 00:00 местного времени
|
||||||
|
|
||||||
|
#### Scenario: Комментарий с разметкой
|
||||||
|
- **WHEN** в ленте событие комментария с текстом `h3. Итог` и `*Готово*, см. [протокол|https://x.ru/p] и [~ivanov]`
|
||||||
|
- **THEN** строка события — «комментарий: Итог Готово, см. протокол и @ivanov»
|
||||||
|
|
||||||
|
### Requirement: Скачивание вложений в карточке
|
||||||
|
Кнопка скачивания вложения (в разделе «Вложения», плашка под комментарием, ссылка в тексте) SHALL работать одинаково:
|
||||||
|
1. `POST /attachments/{id}/fetch {author: "web"}`;
|
||||||
|
2. `state = ready` — `GET /attachments/{id}/content` с клиентским токеном, браузер сохраняет файл под именем `filename`
|
||||||
|
(через `Blob` и временную ссылку `download`; адрес с токеном в строке браузера MUST NOT появляться);
|
||||||
|
3. `state = fetching` — у всех кнопок этого вложения в карточке вместо «Скачать» — «Загружается…» (неактивна), опрос
|
||||||
|
`GET /attachments/{id}` раз в 2 с, пока окно открыто на этой задаче; `ready` — шаг 2 сам, без второго нажатия; закрыли
|
||||||
|
окно или ушли на другую задачу — опрос прекращается, команда остаётся в очереди (файл ляжет в хаб);
|
||||||
|
4. `failed` — под вложением в разделе «Вложения» текст ошибки и кнопка «Повторить» (новый запрос); 409 `agent_too_old`
|
||||||
|
и прочие ошибки хаба — их текст там же; хаб не отвечает — «хаб не отвечает» там же; 401 на любом шаге — экран входа.
|
||||||
|
После успешного повтора прежняя ошибка `failed` из данных карточки MUST NOT показываться.
|
||||||
|
Вложения в разделе SHALL идти в порядке ответа хаба (новые сверху); ссылка в тексте на имя, которое носят несколько
|
||||||
|
вложений, SHALL вести к первому из них в этом порядке (как `attachment_ids` хаба).
|
||||||
|
Вложение с `state = ready` в разделе «Вложения» помечается «у хаба» и скачивается сразу. Размер неизвестен — «размер неизвестен» и обычная кнопка.
|
||||||
|
Вложение больше 10 МБ —
|
||||||
|
вместо кнопки ссылка «Открыть в Jira» (`<адрес Jira>/secure/attachment/<id>/<имя в URL-кодировке>`, новая вкладка) с
|
||||||
|
подсказкой «больше 10 МБ — только в Jira»; плашка и ссылка в тексте ведут туда же.
|
||||||
|
|
||||||
|
#### Scenario: Скачать скриншот
|
||||||
|
- **WHEN** в карточке AURORA-1650 нажато «Скачать» у «скрин 1.png», файла у хаба нет, агент онлайн
|
||||||
|
- **THEN** кнопка — «Загружается…», через несколько секунд браузер сохраняет «скрин 1.png», кнопка снова «Скачать», у
|
||||||
|
вложения отметка «у хаба»
|
||||||
|
|
||||||
|
#### Scenario: Окно закрыли во время скачивания
|
||||||
|
- **WHEN** нажато «Скачать», затем окно карточки закрыто
|
||||||
|
- **THEN** опросов `GET /attachments/{id}` больше нет; при следующем открытии карточки, если файл уже у хаба, вложение с
|
||||||
|
отметкой «у хаба»
|
||||||
|
|
||||||
|
#### Scenario: Большой файл
|
||||||
|
- **WHEN** у вложения «запись.mp4» размер 25 МБ
|
||||||
|
- **THEN** вместо «Скачать» — ссылка «Открыть в Jira» с подсказкой «больше 10 МБ — только в Jira»
|
||||||
|
|
||||||
|
#### Scenario: Агент старой версии
|
||||||
|
- **WHEN** нажато «Скачать», хаб ответил 409 «агент v13 не умеет скачивать вложения — нужен v14»
|
||||||
|
- **THEN** под вложением этот текст и «Повторить»
|
||||||
|
|
||||||
|
#### Scenario: Повтор после ошибки
|
||||||
|
- **WHEN** у вложения «скрин 1.png» в данных карточки `state = failed`, нажато «Повторить», файл скачан
|
||||||
|
- **THEN** браузер сохранил файл, текста ошибки под вложением нет — и до, и после перечитывания карточки
|
||||||
|
|
||||||
|
#### Scenario: Токен отозван во время скачивания
|
||||||
|
- **WHEN** нажато «Скачать», во время опроса `GET /attachments/{id}` хаб ответил 401
|
||||||
|
- **THEN** приложение показывает экран входа
|
||||||
|
|
||||||
|
### Requirement: Состояние и обновление модулей
|
||||||
|
Каждый веб-модуль SHALL описывать своё состояние данными, а рисовать их SHALL ядро. Описание модуля: название для
|
||||||
|
показа («Jira», «Вики»); агент (имя модуля в `GET /agents`) или его отсутствие; свежесть данных для текущего проекта —
|
||||||
|
время, охват («все проекты» или «проект») и ошибка последнего обновления; действие обновления (название, охват,
|
||||||
|
постановка пакета) или его отсутствие; правило, какие пакеты принадлежат модулю; данные, которые надо перечитать после
|
||||||
|
пакета модуля; есть ли у модуля данные для проекта (у вики без корня — нет).
|
||||||
|
|
||||||
|
Ядро SHALL:
|
||||||
|
- опрашивать `GET /agents` при входе и раз в 60 с — один опрос на приложение; агент модуля: «на связи», «офлайн» или
|
||||||
|
«обновите агента» (есть `warning` или `update_hint`, текст — в подсказке);
|
||||||
|
- вести один трекер пакетов на приложение: на модуль — не больше одного отслеживаемого пакета; пакет подхватывается при
|
||||||
|
входе, раз в 60 с и после действия обновления, если он не завершён, принадлежит модулю (поле `module` в `GET
|
||||||
|
/batches`) и текущему проекту (для модулей с охватом «проект») — любого автора, в том числе автообновления хаба и
|
||||||
|
MCP; опрос `GET /batches/{id}` каждые 3 с до терминального статуса; 4xx (404 — «пакета нет в хабе») и 5 ошибок «хаб
|
||||||
|
не отвечает» подряд — опрос остановлен с сообщением; по завершении — перечитать данные модуля и список проектов; при смене проекта отслеживаемый пакет модуля с
|
||||||
|
охватом «проект» сбрасывается вместе с событием, и ответ опроса этого пакета, пришедший после смены, MUST NOT
|
||||||
|
возвращать событие;
|
||||||
|
- ответ 409 на постановку пакета — отслеживать уже идущий пакет модуля, без сообщения об ошибке;
|
||||||
|
- агент модуля офлайн при обновлении — пакет всё равно ставится, событие «агент офлайн — пакет выполнится, когда он
|
||||||
|
выйдет на связь; пакет #N в очереди…».
|
||||||
|
|
||||||
|
Блок состояния в развёрнутой боковой панели — по строке на модуль, у которого есть данные для проекта: название модуля,
|
||||||
|
точка агента (цвет и текст «агент на связи» / «агент офлайн» / «обновите агента» — не только цвет), кнопка-значок
|
||||||
|
обновления с подписью действия в подсказке и `aria-label` («Обновить из Jira — все проекты», «Обновить вики — проект
|
||||||
|
AWPCA»); под ними — «данные на ДД.ММ ЧЧ:ММ» с охватом («все проекты» / «проект»), «обновления ещё не было» или ошибка
|
||||||
|
последнего обновления; под ними — одно текущее событие пакета модуля: в очереди / выполняется (с оставшимся временем,
|
||||||
|
если хаб его дал, и пометками «агент офлайн», «хаб не отвечает, повтор»), выполнен (исчезает через 10 с), не выполнен
|
||||||
|
с текстом ошибки, отменён, опрос остановлен с причиной. Кнопка обновления модуля недоступна (с признаком загрузки), пока
|
||||||
|
запрос постановки в пути или выполняется пакет действия обновления модуля (у jira — пакет с первой командой `refresh`, у
|
||||||
|
wiki — `wiki_sync`); прочие пакеты модуля показываются, но обновление не блокируют: нажатие ставит пакет обновления, и
|
||||||
|
трекер переключается на него. Свёрнутая панель — по значку модуля с точкой агента, подробности в
|
||||||
|
подсказке, нажатие — обновление. На узком экране — сводная точка в верхней строке (требование «Оболочка и адреса»),
|
||||||
|
событие пакета — строкой под верхней строкой.
|
||||||
|
|
||||||
|
Модуль jira: агент `jira`; свежесть — `jira_synced_at` из `GET /projects`, охват «все проекты»; действие «Обновить из
|
||||||
|
Jira» — `POST /refresh {full: true, author: "viewer"}`; после пакета перечитываются снимки досок, списки задач, лента и
|
||||||
|
открытая карточка; доска, показывающая снимок из файла, его не заменяет. Модуль wiki: агент `wiki`; свежесть —
|
||||||
|
`wiki_synced_at`, `wiki_sync_error` из `GET /projects`, охват «проект»; данные для проекта есть, только если есть
|
||||||
|
`wiki_root`; действие «Обновить вики» — `POST /wiki/sync {project, author: "viewer"}`; после пакета перечитываются дерево,
|
||||||
|
страницы и поиск. Блок состояния MUST NOT загружать снимок доски или `GET /wiki/status`.
|
||||||
|
|
||||||
|
Страница модуля с действием обновления SHALL дублировать его вторичной кнопкой в шапке страницы; основной (primary)
|
||||||
|
кнопки обновления в приложении нет.
|
||||||
|
|
||||||
|
#### Scenario: Обновление со списка задач
|
||||||
|
- **WHEN** открыт `/app/jira/issues`, нажато «Обновить из Jira» в строке «Jira», пакет выполнен
|
||||||
|
- **THEN** в хабе один пакет `refresh` автора `viewer`; список задач перезапрошен; строка «Jira» показывала «пакет #N
|
||||||
|
выполняется», затем «пакет #N выполнен»; строка «Вики» не изменилась
|
||||||
|
|
||||||
|
#### Scenario: Один прогон на модуль
|
||||||
|
- **WHEN** нажата «Обновить из Jira» и, пока пакет выполняется, выполнен переход между разделами и нажата «Обновить из
|
||||||
|
Jira» в шапке доски
|
||||||
|
- **THEN** кнопки «Обновить из Jira» недоступны, в хабе один пакет, его опрашивает один опрос
|
||||||
|
|
||||||
|
#### Scenario: Опрос получил 404
|
||||||
|
- **WHEN** опрос пакета вики получил 404
|
||||||
|
- **THEN** опрос остановлен, строка «Вики» показывает «пакета нет в хабе», «Обновить вики» снова доступна
|
||||||
|
|
||||||
|
#### Scenario: Автообновление хаба
|
||||||
|
- **WHEN** хаб поставил пакет «Автообновление» (автор `hub`, модуль `jira`), приложение открыто на странице «Вики»
|
||||||
|
- **THEN** не позже чем через 60 с строка «Jira» показывает «пакет #N выполняется», по завершении — новое «данные на …»
|
||||||
|
|
||||||
|
#### Scenario: Пакет другого проекта
|
||||||
|
- **WHEN** идёт пакет `wiki_sync` проекта `STOCKS_DRRK`, текущий проект — `AWPCA`
|
||||||
|
- **THEN** строка «Вики» его не показывает, «Обновить вики» доступна
|
||||||
|
|
||||||
|
#### Scenario: Пакет с другими командами
|
||||||
|
- **WHEN** незавершённый пакет модуля jira автора `viewer` состоит из команд `ensure_fields`
|
||||||
|
- **THEN** строка «Jira» показывает «пакет #N выполняется», кнопка «Обновить из Jira» доступна; её нажатие ставит пакет
|
||||||
|
`refresh`, и строка показывает его
|
||||||
|
|
||||||
|
#### Scenario: Агент офлайн
|
||||||
|
- **WHEN** агент `wiki` офлайн и нажато «Обновить вики»
|
||||||
|
- **THEN** пакет поставлен; строка «Вики» — точка с текстом «агент офлайн» и событие «агент офлайн — пакет выполнится,
|
||||||
|
когда он выйдет на связь; пакет #N в очереди…»
|
||||||
|
|
||||||
|
#### Scenario: Синхронизация уже идёт
|
||||||
|
- **WHEN** нажато «Обновить вики», хаб ответил 409
|
||||||
|
- **THEN** строка «Вики» показывает идущий пакет, сообщения об ошибке нет
|
||||||
|
|
||||||
|
#### Scenario: Проект без корня вики
|
||||||
|
- **WHEN** текущий проект `sfm_kedr` без корня вики
|
||||||
|
- **THEN** в блоке состояния только строка «Jira»; на свёрнутой панели — один значок состояния
|
||||||
|
|
||||||
|
#### Scenario: Агенту нужно обновление
|
||||||
|
- **WHEN** `GET /agents` отдаёт агента `jira` с `warning`
|
||||||
|
- **THEN** строка «Jira» — точка с текстом «обновите агента», текст `warning` в подсказке
|
||||||
|
|
||||||
|
#### Scenario: Смена проекта во время опроса
|
||||||
|
- **WHEN** строка «Вики» показывает пакет `wiki_sync` проекта `AWPCA`, текущий проект сменён на `STOCKS_DRRK`, и в тот же
|
||||||
|
момент пришёл ответ опроса этого пакета
|
||||||
|
- **THEN** строка «Вики» без события пакета; опроса этого пакета больше нет
|
||||||
@ -207,3 +207,16 @@ test("закрытые скрыты по умолчанию, «Все» пока
|
|||||||
await expect(page.getByRole("link", { name: "AURORA-9006", exact: true })).toBeVisible();
|
await expect(page.getByRole("link", { name: "AURORA-9006", exact: true })).toBeVisible();
|
||||||
await expect(page.locator("table.issue-table tbody tr")).toHaveCount(10);
|
await expect(page.locator("table.issue-table tbody tr")).toHaveCount(10);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test("поиск перед открытием карточки: окно не закрывается, адрес списка сохраняет q", async ({ page }) => {
|
||||||
|
await login(page, "/app/jira/issues");
|
||||||
|
await expect(page.locator("table.issue-table tbody tr")).toHaveCount(9);
|
||||||
|
await page.getByRole("searchbox").fill("свод");
|
||||||
|
await page.locator("table.issue-table tbody tr").first().getByRole("link").click();
|
||||||
|
await expect(page.getByRole("dialog")).toBeVisible();
|
||||||
|
await page.waitForTimeout(600);
|
||||||
|
await expect(page.getByRole("dialog")).toBeVisible();
|
||||||
|
await page.keyboard.press("Escape");
|
||||||
|
await expect(page.getByRole("dialog")).toHaveCount(0);
|
||||||
|
await expect(page).toHaveURL(/\/app\/jira\/issues\?.*q=/);
|
||||||
|
});
|
||||||
|
|||||||
@ -41,8 +41,9 @@ export const useIssue = (key: string) =>
|
|||||||
useQuery({ queryKey: qk.issue(key), queryFn: () => api.get<IssueDetail>(`/issues/${encodeURIComponent(key)}`) });
|
useQuery({ queryKey: qk.issue(key), queryFn: () => api.get<IssueDetail>(`/issues/${encodeURIComponent(key)}`) });
|
||||||
|
|
||||||
// список порциями по ISSUE_PAGE: следующая порция есть, пока последняя полная
|
// список порциями по ISSUE_PAGE: следующая порция есть, пока последняя полная
|
||||||
export const useIssues = (filters: IssueFilters) =>
|
export const useIssues = (filters: IssueFilters, opts?: { enabled?: boolean }) =>
|
||||||
useInfiniteQuery({
|
useInfiniteQuery({
|
||||||
|
enabled: opts?.enabled ?? true,
|
||||||
queryKey: qk.issues(filters),
|
queryKey: qk.issues(filters),
|
||||||
initialPageParam: 0,
|
initialPageParam: 0,
|
||||||
queryFn: ({ pageParam }) =>
|
queryFn: ({ pageParam }) =>
|
||||||
|
|||||||
@ -27,6 +27,6 @@ export function fromHub(snap: BoardSnapshot, dicts: Dicts): BoardModel {
|
|||||||
const missing = addStubs(nodes, links, NOT_IN_HUB);
|
const missing = addStubs(nodes, links, NOT_IN_HUB);
|
||||||
const problems = snap.last_sync ? [] : ["в хабе ещё не было полного обновления из Jira — нажмите «Обновить из Jira»"];
|
const problems = snap.last_sync ? [] : ["в хабе ещё не было полного обновления из Jira — нажмите «Обновить из Jira»"];
|
||||||
// фильтр спринтов — только по своим задачам: спринт соседа из другого проекта не делает канбан-проект «спринтовым»
|
// фильтр спринтов — только по своим задачам: спринт соседа из другого проекта не делает канбан-проект «спринтовым»
|
||||||
const hasSprint = snap.issues.some((it) => !it.neighbor && !!it.sprint);
|
const hasSprint = snap.issues.some((it) => !isEpic(it) && !it.neighbor && !!it.sprint);
|
||||||
return { nodes, links, sprints, fileName: "Хаб", problems, hasSprint, missing, stubLane: NOT_IN_HUB };
|
return { nodes, links, sprints, fileName: "Хаб", problems, hasSprint, missing, stubLane: NOT_IN_HUB };
|
||||||
}
|
}
|
||||||
|
|||||||
@ -32,6 +32,11 @@ describe("модель доски = links.html@3931a88", () => {
|
|||||||
expect(fromHub(neighborOnly, dicts).hasSprint).toBe(false);
|
expect(fromHub(neighborOnly, dicts).hasSprint).toBe(false);
|
||||||
expect(fromHub(snap, dicts).hasSprint).toBe(true);
|
expect(fromHub(snap, dicts).hasSprint).toBe(true);
|
||||||
});
|
});
|
||||||
|
it("hasSprint: спринт только у эпика → false", () => {
|
||||||
|
const snap = JSON.parse(fx("hub-basic.board.json")) as BoardSnapshot;
|
||||||
|
const onlyEpic = { ...snap, issues: snap.issues.map((i) => ({ ...i, sprint: i.key === "AURORA-9100" ? "AWPCA_Спринт 1" : null })) };
|
||||||
|
expect(fromHub(onlyEpic, dicts).hasSprint).toBe(false);
|
||||||
|
});
|
||||||
it("parseCsv и fromCsv — BOM, CRLF, кавычки, повторные колонки, даты разных локалей", () => {
|
it("parseCsv и fromCsv — BOM, CRLF, кавычки, повторные колонки, даты разных локалей", () => {
|
||||||
const text = fx("jira-export.csv");
|
const text = fx("jira-export.csv");
|
||||||
const g = golden("jira-export.expected.json");
|
const g = golden("jira-export.expected.json");
|
||||||
|
|||||||
36
web/src/modules/jira/board/useBoardPrefs.test.tsx
Normal file
36
web/src/modules/jira/board/useBoardPrefs.test.tsx
Normal file
@ -0,0 +1,36 @@
|
|||||||
|
import { act, renderHook } from "@testing-library/react";
|
||||||
|
import { beforeEach, describe, expect, it } from "vitest";
|
||||||
|
import { readPrefs } from "./storage.ts";
|
||||||
|
import type { BoardModel } from "./types.ts";
|
||||||
|
import { useBoardPrefs } from "./useBoardPrefs.ts";
|
||||||
|
|
||||||
|
const model = { nodes: [], links: [], sprints: {}, fileName: "x", problems: [], hasSprint: false, missing: [], stubLane: "" } as unknown as BoardModel;
|
||||||
|
|
||||||
|
describe("useBoardPrefs: два переключения в одном такте", () => {
|
||||||
|
beforeEach(() => localStorage.clear());
|
||||||
|
|
||||||
|
it("toggle двух флагов не теряет первый", () => {
|
||||||
|
const { result } = renderHook(() => useBoardPrefs(model, "P"));
|
||||||
|
const a = result.current.snap, b = result.current.table;
|
||||||
|
act(() => { result.current.toggle("snap"); result.current.toggle("table"); });
|
||||||
|
expect(result.current.snap).toBe(!a);
|
||||||
|
expect(result.current.table).toBe(!b);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("два toggle одного флага в одном такте возвращают исходное значение, в хранилище то же", () => {
|
||||||
|
const { result } = renderHook(() => useBoardPrefs(model, "P"));
|
||||||
|
const before = result.current.snap;
|
||||||
|
act(() => { result.current.toggle("snap"); result.current.toggle("snap"); });
|
||||||
|
expect(result.current.snap).toBe(before);
|
||||||
|
expect(readPrefs().snap).toBe(before);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("toggleHidden двух значений скрывает оба", () => {
|
||||||
|
const { result } = renderHook(() => useBoardPrefs(model, "P"));
|
||||||
|
act(() => { result.current.toggleHidden("kind", "x"); result.current.toggleHidden("kind", "y"); });
|
||||||
|
expect([...result.current.filters.hideKind].sort()).toEqual(["x", "y"]);
|
||||||
|
const saved = Object.values(localStorage).join("|");
|
||||||
|
expect(saved).toContain("x");
|
||||||
|
expect(saved).toContain("y");
|
||||||
|
});
|
||||||
|
});
|
||||||
@ -1,4 +1,4 @@
|
|||||||
import { useCallback, useMemo, useState } from "react";
|
import { useCallback, useMemo, useRef, useState } from "react";
|
||||||
import { initialSprints, initialWho } from "./analysis/facets.ts";
|
import { initialSprints, initialWho } from "./analysis/facets.ts";
|
||||||
import type { LayoutStore } from "./components/BoardCanvas.tsx";
|
import type { LayoutStore } from "./components/BoardCanvas.tsx";
|
||||||
import * as st from "./storage.ts";
|
import * as st from "./storage.ts";
|
||||||
@ -29,6 +29,7 @@ export function useBoardPrefs(model: BoardModel, project: string): BoardPrefsApi
|
|||||||
if (st.isLegacyStore()) { st.migrateMode(); st.migrateStore(); }
|
if (st.isLegacyStore()) { st.migrateMode(); st.migrateStore(); }
|
||||||
return st.readPrefs();
|
return st.readPrefs();
|
||||||
});
|
});
|
||||||
|
const prefsRef = useRef(prefs); // последнее значение: два вызова в одном такте не теряют первый
|
||||||
const [store, setStore] = useState<LayoutStore>(() => readStore(0));
|
const [store, setStore] = useState<LayoutStore>(() => readStore(0));
|
||||||
const [sel, setSel] = useState(() => pick(model, prefs.filters));
|
const [sel, setSel] = useState(() => pick(model, prefs.filters));
|
||||||
if (sel.model !== model) setSel(pick(model, prefs.filters)); // новая модель — выбор заново, как fillSprints/fillWho
|
if (sel.model !== model) setSel(pick(model, prefs.filters)); // новая модель — выбор заново, как fillSprints/fillWho
|
||||||
@ -36,32 +37,35 @@ export function useBoardPrefs(model: BoardModel, project: string): BoardPrefsApi
|
|||||||
|
|
||||||
const filters = useMemo<Filters>(() => ({ ...prefs.filters, sprints: sel.sprints, who: sel.who }), [prefs.filters, sel]);
|
const filters = useMemo<Filters>(() => ({ ...prefs.filters, sprints: sel.sprints, who: sel.who }), [prefs.filters, sel]);
|
||||||
|
|
||||||
|
const commit = useCallback((next: st.BoardPrefs) => { prefsRef.current = next; setPrefs(next); }, []);
|
||||||
const toggle = useCallback((name: st.FlagName | "snap" | "table" | "problems") => {
|
const toggle = useCallback((name: st.FlagName | "snap" | "table" | "problems") => {
|
||||||
|
const p = prefsRef.current;
|
||||||
const isView = name === "snap" || name === "table" || name === "problems";
|
const isView = name === "snap" || name === "table" || name === "problems";
|
||||||
const on = isView ? !prefs[name] : !prefs.filters[name];
|
const on = isView ? !p[name] : !p.filters[name];
|
||||||
st.writeFlag(name, on);
|
st.writeFlag(name, on);
|
||||||
setPrefs((p) => (isView ? { ...p, [name]: on } : { ...p, filters: { ...p.filters, [name]: on } }));
|
commit(isView ? { ...p, [name]: on } : { ...p, filters: { ...p.filters, [name]: on } });
|
||||||
}, [prefs]);
|
}, [commit]);
|
||||||
const setSprints = useCallback((s: Set<string>) => { st.writeSprints(s); setSel((x) => ({ ...x, sprints: new Set(s) })); }, []);
|
const setSprints = useCallback((s: Set<string>) => { st.writeSprints(s); setSel((x) => ({ ...x, sprints: new Set(s) })); }, []);
|
||||||
const setWho = useCallback((s: Set<string>) => { st.writeWho(s); setSel((x) => ({ ...x, who: new Set(s) })); }, []);
|
const setWho = useCallback((s: Set<string>) => { st.writeWho(s); setSel((x) => ({ ...x, who: new Set(s) })); }, []);
|
||||||
const toggleHidden = useCallback((what: "kind" | "edge", value: string) => {
|
const toggleHidden = useCallback((what: "kind" | "edge", value: string) => {
|
||||||
|
const p = prefsRef.current;
|
||||||
const key = what === "kind" ? "hideKind" : "hideEdge";
|
const key = what === "kind" ? "hideKind" : "hideEdge";
|
||||||
const next = new Set(prefs.filters[key]);
|
const set = new Set(p.filters[key]);
|
||||||
if (next.has(value)) next.delete(value); else next.add(value);
|
if (set.has(value)) set.delete(value); else set.add(value);
|
||||||
const filters = { ...prefs.filters, [key]: next };
|
const filters = { ...p.filters, [key]: set };
|
||||||
st.writeHidden(filters.hideKind, filters.hideEdge);
|
st.writeHidden(filters.hideKind, filters.hideEdge);
|
||||||
setPrefs((p) => ({ ...p, filters: { ...p.filters, [key]: next } }));
|
commit({ ...p, filters });
|
||||||
}, [prefs]);
|
}, [commit]);
|
||||||
const toggleLane = useCallback((name: string) => { st.toggleLane(name); bump(); }, [bump]);
|
const toggleLane = useCallback((name: string) => { st.toggleLane(name); bump(); }, [bump]);
|
||||||
const moveLane = useCallback((current: string[], name: string, to: number) => { if (st.moveLane(current, name, to)) bump(); }, [bump]);
|
const moveLane = useCallback((current: string[], name: string, to: number) => { if (st.moveLane(current, name, to)) bump(); }, [bump]);
|
||||||
const setColWidth = useCallback((date: string, w: number | null) => { st.setColWidth(date, w); bump(); }, [bump]);
|
const setColWidth = useCallback((date: string, w: number | null) => { st.setColWidth(date, w); bump(); }, [bump]);
|
||||||
const resetLayout = useCallback(() => { st.resetLayout(prefs.filters.byDate); bump(); }, [prefs.filters.byDate, bump]);
|
const resetLayout = useCallback(() => { st.resetLayout(prefs.filters.byDate); bump(); }, [prefs.filters.byDate, bump]);
|
||||||
const applyJson = useCallback((r: JsonImport) => {
|
const applyJson = useCallback((r: JsonImport) => {
|
||||||
const patch = st.applyJsonSettings(r);
|
const patch = st.applyJsonSettings(r);
|
||||||
setPrefs((p) => ({ ...p, filters: { ...p.filters, ...patch } }));
|
commit({ ...prefsRef.current, filters: { ...prefsRef.current.filters, ...patch } });
|
||||||
setSel(pick(model, { ...prefs.filters, ...patch }));
|
setSel(pick(model, { ...prefsRef.current.filters, ...patch }));
|
||||||
bump();
|
bump();
|
||||||
}, [model, prefs.filters, bump]);
|
}, [model, commit, bump]);
|
||||||
|
|
||||||
return { filters, snap: prefs.snap, table: prefs.table, problems: prefs.problems, store,
|
return { filters, snap: prefs.snap, table: prefs.table, problems: prefs.problems, store,
|
||||||
toggle, setSprints, setWho, toggleHidden, toggleLane, moveLane, setColWidth, resetLayout, applyJson };
|
toggle, setSprints, setWho, toggleHidden, toggleLane, moveLane, setColWidth, resetLayout, applyJson };
|
||||||
|
|||||||
@ -55,6 +55,20 @@ describe("текст события", () => {
|
|||||||
expect(eventText(ev({ kind: "comment", field: "comment", from: null, to: null }))).toBe("комментарий: —"); // M-6
|
expect(eventText(ev({ kind: "comment", field: "comment", from: null, to: null }))).toBe("комментарий: —"); // M-6
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("комментарий в ленте — без вики-разметки (С6)", () => {
|
||||||
|
expect(eventText(ev({ kind: "comment", field: "comment", from: null, to: "h3. Итог\n*Готово*, см. [протокол|https://x.ru/p] и [~ivanov]" })))
|
||||||
|
.toBe("комментарий: Итог Готово, см. протокол и @ivanov");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("вложения в комментарии — именем, даже без списка вложений (I-1)", () => {
|
||||||
|
expect(eventText(ev({ kind: "comment", field: "comment", from: null, to: "см. !скрин.png|thumbnail! и [^отчёт.pdf]" })))
|
||||||
|
.toBe("комментарий: см. скрин.png и отчёт.pdf");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("пустой комментарий — по-прежнему «—»", () => {
|
||||||
|
expect(eventText(ev({ kind: "comment", field: "comment", from: null, to: " " }))).toBe("комментарий: —");
|
||||||
|
});
|
||||||
|
|
||||||
it("прочие поля — подпись из словаря или имя поля", () => {
|
it("прочие поля — подпись из словаря или имя поля", () => {
|
||||||
expect(eventText(ev({ kind: "other", field: "assignee", from: null, to: "ivanov" }))).toBe("исполнитель: «—» → «ivanov»");
|
expect(eventText(ev({ kind: "other", field: "assignee", from: null, to: "ivanov" }))).toBe("исполнитель: «—» → «ivanov»");
|
||||||
expect(eventText(ev({ kind: "other", field: "Компания", from: "А", to: "Б" }))).toBe("Компания: «А» → «Б»");
|
expect(eventText(ev({ kind: "other", field: "Компания", from: "А", to: "Б" }))).toBe("Компания: «А» → «Б»");
|
||||||
|
|||||||
@ -1,4 +1,5 @@
|
|||||||
import type { ChangeEvent, ChangeKind } from "../api/types.ts";
|
import type { ChangeEvent, ChangeKind } from "../api/types.ts";
|
||||||
|
import { wikiPlain } from "../issues/wiki/plain.ts";
|
||||||
|
|
||||||
// период ленты: границы по местному времени браузера; «Сегодня» — по умолчанию и в адрес не пишется (spec web-app «Лента изменений»)
|
// период ленты: границы по местному времени браузера; «Сегодня» — по умолчанию и в адрес не пишется (spec web-app «Лента изменений»)
|
||||||
export type Period = "2h" | "today" | "yesterday" | "7d";
|
export type Period = "2h" | "today" | "yesterday" | "7d";
|
||||||
@ -70,7 +71,7 @@ export function eventText(e: ChangeEvent): string {
|
|||||||
case "description": return "описание изменено";
|
case "description": return "описание изменено";
|
||||||
case "dates": return `${DATE_LABELS[e.field] ?? e.field} ${day(e.from_value, e.from)} → ${day(e.to_value, e.to)}`;
|
case "dates": return `${DATE_LABELS[e.field] ?? e.field} ${day(e.from_value, e.from)} → ${day(e.to_value, e.to)}`;
|
||||||
case "comment": { // первые 300 символов текста и «…» (M-4); без текста — «—» (M-6)
|
case "comment": { // первые 300 символов текста и «…» (M-4); без текста — «—» (M-6)
|
||||||
const s = oneLine(e.to);
|
const s = oneLine(wikiPlain(e.to ?? ""));
|
||||||
return "комментарий: " + (!s ? "—" : s.length <= 300 ? s : s.slice(0, 300) + "…");
|
return "комментарий: " + (!s ? "—" : s.length <= 300 ? s : s.slice(0, 300) + "…");
|
||||||
}
|
}
|
||||||
default: {
|
default: {
|
||||||
|
|||||||
@ -73,6 +73,34 @@ describe("карточка задачи", () => {
|
|||||||
expect(screen.getByRole("link", { name: "AURORA-9002" }).closest(".issue")).not.toBeNull(); // O-10: цвет темы
|
expect(screen.getByRole("link", { name: "AURORA-9002" }).closest(".issue")).not.toBeNull(); // O-10: цвет темы
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("связь на ключ вне зеркала ведёт в Jira, а не в карточку (#42 M-5)", async () => {
|
||||||
|
mockFetch((_m, url) => url.pathname === "/settings" ? json(200, settings) : json(200, issue("AURORA-9001", {
|
||||||
|
links_out: [{ target_key: "AURORA-1999", link_type: "ASUPR_Связанные", summary: null, status: null }], links_in: [],
|
||||||
|
})));
|
||||||
|
open("/jira/issues/AURORA-9001");
|
||||||
|
const link = await screen.findByRole("link", { name: /AURORA-1999/ });
|
||||||
|
expect(link).toHaveAttribute("href", "https://sdlc.go.rshbank.ru/jira/browse/AURORA-1999");
|
||||||
|
expect(link).toHaveAttribute("target", "_blank");
|
||||||
|
expect(link.closest("li")).toHaveTextContent("нет в зеркале");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("«Прочие поля»: вид работы отдельно от меток (#103)", async () => {
|
||||||
|
mockFetch((_m, url) => url.pathname === "/settings" ? json(200, settings) : json(200, issue("AURORA-9001", { labels: ["AWPCA", "kind-dev"] })));
|
||||||
|
open("/jira/issues/AURORA-9001");
|
||||||
|
await screen.findByRole("heading", { level: 2, name: "Тема AURORA-9001" });
|
||||||
|
const other = screen.getByRole("region", { name: "Прочие поля" });
|
||||||
|
expect(within(other).getByText("Вид работы в Jira").nextSibling).toHaveTextContent("разработка");
|
||||||
|
expect(within(other).getByText("Метки").nextSibling).toHaveTextContent("AWPCA");
|
||||||
|
expect(within(other).queryByText(/kind-dev/)).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("пустое описание (одни пробелы) — без раздела «Описание» (#56 M-4)", async () => {
|
||||||
|
mockFetch((_m, url) => url.pathname === "/settings" ? json(200, settings) : json(200, issue("AURORA-9001", { description: " \n\n" })));
|
||||||
|
open("/jira/issues/AURORA-9001");
|
||||||
|
await screen.findByRole("heading", { level: 2, name: "Тема AURORA-9001" });
|
||||||
|
expect(screen.queryByRole("heading", { name: "Описание" })).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
it("без связей — «связей нет»", async () => {
|
it("без связей — «связей нет»", async () => {
|
||||||
mockFetch((_m, url) => url.pathname === "/settings" ? json(200, settings) : json(200, issue("AURORA-9001", { links_out: [], links_in: [] })));
|
mockFetch((_m, url) => url.pathname === "/settings" ? json(200, settings) : json(200, issue("AURORA-9001", { links_out: [], links_in: [] })));
|
||||||
open("/jira/issues/AURORA-9001");
|
open("/jira/issues/AURORA-9001");
|
||||||
@ -496,7 +524,7 @@ describe("вложения в карточке", () => {
|
|||||||
return mockFetch((m, url) => extra?.(m, url) ?? (url.pathname === "/settings" ? json(200, settings) : json(200, issue("AURORA-1650", { ...base, ...over }))));
|
return mockFetch((m, url) => extra?.(m, url) ?? (url.pathname === "/settings" ? json(200, settings) : json(200, issue("AURORA-1650", { ...base, ...over }))));
|
||||||
}
|
}
|
||||||
|
|
||||||
it("раздел «Вложения (2)» после «Описание», новые сверху; размер, автор · дата", async () => {
|
it("раздел «Вложения (2)» после «Описание», порядок хаба; размер, автор · дата", async () => {
|
||||||
hub({});
|
hub({});
|
||||||
open("/jira/issues/AURORA-1650");
|
open("/jira/issues/AURORA-1650");
|
||||||
const sec = within(await screen.findByTestId("attachments"));
|
const sec = within(await screen.findByTestId("attachments"));
|
||||||
@ -504,12 +532,39 @@ describe("вложения в карточке", () => {
|
|||||||
const heads = screen.getAllByRole("heading", { level: 3 }).map((h) => h.textContent);
|
const heads = screen.getAllByRole("heading", { level: 3 }).map((h) => h.textContent);
|
||||||
expect(heads.indexOf("Вложения (2)")).toBe(heads.indexOf("Описание") + 1);
|
expect(heads.indexOf("Вложения (2)")).toBe(heads.indexOf("Описание") + 1);
|
||||||
const rows = sec.getAllByRole("listitem");
|
const rows = sec.getAllByRole("listitem");
|
||||||
expect(rows[0]).toHaveTextContent("ОСВ 60.xlsx");
|
expect(rows[1]).toHaveTextContent("ОСВ 60.xlsx");
|
||||||
expect(rows[0]).toHaveTextContent("1,0 КБ");
|
expect(rows[1]).toHaveTextContent("1,0 КБ");
|
||||||
expect(rows[1]).toHaveTextContent("скрин 1.png");
|
expect(rows[0]).toHaveTextContent("скрин 1.png");
|
||||||
expect(rows[1]).toHaveTextContent("234,4 КБ");
|
expect(rows[0]).toHaveTextContent("234,4 КБ");
|
||||||
expect(rows[1]).toHaveTextContent("Иванов Иван · 15.10");
|
expect(rows[0]).toHaveTextContent("Иванов Иван · 15.10");
|
||||||
expect(within(rows[1]).getByRole("button", { name: "Скачать" })).toBeInTheDocument();
|
expect(within(rows[0]).getByRole("button", { name: "Скачать" })).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("порядок вложений — как у хаба, без пересортировки по дате", async () => {
|
||||||
|
hub({ attachments: [att("1", "b.bin", { created: null }), att("2", "a.bin", { created: "2026-10-20T10:00:00.000+0300" })], comments: [] });
|
||||||
|
open("/jira/issues/AURORA-1650");
|
||||||
|
const rows = within(await screen.findByTestId("attachments")).getAllByRole("listitem");
|
||||||
|
expect(rows[0]).toHaveTextContent("b.bin");
|
||||||
|
expect(rows[1]).toHaveTextContent("a.bin");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("повтор после ошибки: успех в карточке скрывает прежний failed из несвежего ответа хаба", async () => {
|
||||||
|
let fetched = false;
|
||||||
|
hub({ attachments: [att("31337", "скрин 1.png", { state: "failed", error: "сбой" })], comments: [] }, (_m, url) => {
|
||||||
|
if (url.pathname === "/attachments/31337/fetch") { fetched = true; return json(200, { attachment_id: "31337", state: "ready", error: null, command_id: 1, batch_id: 1 }); }
|
||||||
|
if (url.pathname === "/attachments/31337/content") return new Response("PNG", { status: 200 });
|
||||||
|
return undefined;
|
||||||
|
});
|
||||||
|
URL.createObjectURL = vi.fn(() => "blob:fake");
|
||||||
|
URL.revokeObjectURL = vi.fn();
|
||||||
|
vi.spyOn(HTMLAnchorElement.prototype, "click").mockImplementation(() => {});
|
||||||
|
open("/jira/issues/AURORA-1650");
|
||||||
|
const sec = within(await screen.findByTestId("attachments"));
|
||||||
|
expect(await sec.findByText("сбой")).toBeInTheDocument();
|
||||||
|
await userEvent.click(sec.getByRole("button", { name: "Повторить" }));
|
||||||
|
await waitFor(() => expect(fetched).toBe(true));
|
||||||
|
await waitFor(() => expect(sec.queryByText("сбой")).toBeNull());
|
||||||
|
expect(sec.getByRole("button", { name: "Скачать" })).toBeInTheDocument();
|
||||||
});
|
});
|
||||||
|
|
||||||
it("без поля attachments (старый хаб) раздела нет", async () => {
|
it("без поля attachments (старый хаб) раздела нет", async () => {
|
||||||
@ -568,7 +623,7 @@ describe("вложения в карточке", () => {
|
|||||||
? json(409, { error: "agent_too_old", detail: "агент v13 не умеет скачивать вложения — нужен v14" }) : undefined);
|
? json(409, { error: "agent_too_old", detail: "агент v13 не умеет скачивать вложения — нужен v14" }) : undefined);
|
||||||
open("/jira/issues/AURORA-1650");
|
open("/jira/issues/AURORA-1650");
|
||||||
const sec = within(await screen.findByTestId("attachments"));
|
const sec = within(await screen.findByTestId("attachments"));
|
||||||
await userEvent.click(sec.getAllByRole("button", { name: "Скачать" })[1]);
|
await userEvent.click(sec.getAllByRole("button", { name: "Скачать" })[0]);
|
||||||
expect(await sec.findByText("агент v13 не умеет скачивать вложения — нужен v14")).toBeInTheDocument();
|
expect(await sec.findByText("агент v13 не умеет скачивать вложения — нужен v14")).toBeInTheDocument();
|
||||||
await userEvent.click(sec.getByRole("button", { name: "Повторить" }));
|
await userEvent.click(sec.getByRole("button", { name: "Повторить" }));
|
||||||
expect(f.mock.calls.filter(([u]) => String(u).endsWith("/fetch"))).toHaveLength(2);
|
expect(f.mock.calls.filter(([u]) => String(u).endsWith("/fetch"))).toHaveLength(2);
|
||||||
|
|||||||
@ -7,7 +7,7 @@ import type { ChangeEvent, IssueAttachment, IssueComment, IssueDetail } from "..
|
|||||||
import { JIRA_URL } from "../board/constants.ts";
|
import { JIRA_URL } from "../board/constants.ts";
|
||||||
import { fmtDate, fmtStamp } from "../../../ui/format.ts";
|
import { fmtDate, fmtStamp } from "../../../ui/format.ts";
|
||||||
import { EventList } from "../feed/EventList.tsx";
|
import { EventList } from "../feed/EventList.tsx";
|
||||||
import { groupLinks, isOverdue, todayISO } from "./card.ts";
|
import { groupLinks, isOverdue, splitKindLabels, todayISO } from "./card.ts";
|
||||||
import { useCommentsHidden } from "./commentsPref.ts";
|
import { useCommentsHidden } from "./commentsPref.ts";
|
||||||
import { IssueLink } from "./IssueLink.tsx";
|
import { IssueLink } from "./IssueLink.tsx";
|
||||||
import { Badge, Button, IconButton, IconClose, IconExternal, Notice, Panel } from "../../../ui/index.ts";
|
import { Badge, Button, IconButton, IconClose, IconExternal, Notice, Panel } from "../../../ui/index.ts";
|
||||||
@ -22,26 +22,27 @@ import "./issues.css";
|
|||||||
|
|
||||||
const DESC_LINES = 8;
|
const DESC_LINES = 8;
|
||||||
|
|
||||||
export function IssueCard({ issueKey: key, onClose }: { issueKey: string; onClose: () => void }) {
|
export function IssueCard({ issueKey: key, onClose, onBack }: { issueKey: string; onClose: () => void; onBack?: () => void }) {
|
||||||
const issue = useIssue(key);
|
const issue = useIssue(key);
|
||||||
const settings = useSettings();
|
const settings = useSettings();
|
||||||
const closed = useClosedStatuses();
|
const closed = useClosedStatuses();
|
||||||
const dl = useAttachmentDownload(key); // одно состояние на вложение для всех кнопок карточки
|
const dl = useAttachmentDownload(key); // одно состояние на вложение для всех кнопок карточки
|
||||||
const close = <IconButton label="Закрыть" icon={<IconClose />} onClick={onClose} />;
|
const close = <IconButton label="Закрыть" icon={<IconClose />} onClick={onClose} />;
|
||||||
|
const back = onBack && <Button onClick={onBack}>Назад</Button>;
|
||||||
if (issue.isPending) {
|
if (issue.isPending) {
|
||||||
return <div className="issue" data-testid="issue-card"><div className="issue-head"><div className="row1"><span className="key">{key}</span><span className="spacer" />{close}</div><p className="hint">загрузка {key}…</p></div></div>;
|
return <div className="issue" data-testid="issue-card"><div className="issue-head"><div className="row1"><span className="key">{key}</span><span className="spacer" />{close}</div><p className="hint">загрузка {key}…</p></div></div>;
|
||||||
}
|
}
|
||||||
if (issue.error instanceof ApiError && issue.error.status === 404) {
|
if (issue.error instanceof ApiError && issue.error.status === 404) {
|
||||||
return (
|
return (
|
||||||
<div className="issue issue--state" data-testid="issue-card">
|
<div className="issue issue--state" data-testid="issue-card">
|
||||||
<Notice tone="info" action={<Button onClick={onClose}>Закрыть</Button>}>Задачи {key} нет в зеркале хаба.</Notice>
|
<Notice tone="info" action={<>{back}<Button onClick={onClose}>Закрыть</Button></>}>Задачи {key} нет в зеркале хаба.</Notice>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
if (issue.isError && !issue.data) {
|
if (issue.isError && !issue.data) {
|
||||||
return (
|
return (
|
||||||
<div className="issue issue--state" data-testid="issue-card">
|
<div className="issue issue--state" data-testid="issue-card">
|
||||||
<Notice tone="error" action={<><Button onClick={() => void issue.refetch()}>Повторить</Button><Button onClick={onClose}>Закрыть</Button></>}>
|
<Notice tone="error" action={<><Button onClick={() => void issue.refetch()}>Повторить</Button>{back}<Button onClick={onClose}>Закрыть</Button></>}>
|
||||||
{issue.error.message}
|
{issue.error.message}
|
||||||
</Notice>
|
</Notice>
|
||||||
</div>
|
</div>
|
||||||
@ -76,14 +77,14 @@ export function IssueCard({ issueKey: key, onClose }: { issueKey: string; onClos
|
|||||||
<div className="cols">
|
<div className="cols">
|
||||||
<div className="main">
|
<div className="main">
|
||||||
<Links issue={i} linkTypes={(settings.data?.link_types ?? {}) as Record<string, string>} />
|
<Links issue={i} linkTypes={(settings.data?.link_types ?? {}) as Record<string, string>} />
|
||||||
{i.description && <Description text={i.description} projectKey={settings.data?.project_key} wiki={wiki} />}
|
{i.description?.trim() && <Description text={i.description} projectKey={settings.data?.project_key} wiki={wiki} />}
|
||||||
{atts && atts.length > 0 && <AttachmentList items={atts} dl={dl} />}
|
{atts && atts.length > 0 && <AttachmentList items={atts} dl={dl} />}
|
||||||
<Comments items={i.comments ?? []} projectKey={settings.data?.project_key} wiki={wiki} atts={atts ?? []} dl={dl} />
|
<Comments items={i.comments ?? []} projectKey={settings.data?.project_key} wiki={wiki} atts={atts ?? []} dl={dl} />
|
||||||
{i.changes && <History items={i.changes} />}
|
{i.changes && <History items={i.changes} />}
|
||||||
</div>
|
</div>
|
||||||
<Panel className="side">
|
<Panel className="side">
|
||||||
<Notes key={i.key} issue={i} />
|
<Notes key={i.key} issue={i} />
|
||||||
<OtherFields issue={i} />
|
<OtherFields issue={i} kindLabels={settings.data?.kind_labels as Record<string, string> | undefined} />
|
||||||
<Commands issue={i} />
|
<Commands issue={i} />
|
||||||
</Panel>
|
</Panel>
|
||||||
</div>
|
</div>
|
||||||
@ -107,6 +108,8 @@ function wikiAttachments(atts: IssueAttachment[] | undefined, dl: Download): Wik
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const absent = (l: { summary: string | null; status: string | null }) => l.summary === null && l.status === null;
|
||||||
|
|
||||||
function Links({ issue, linkTypes }: { issue: IssueDetail; linkTypes: Record<string, string> }) {
|
function Links({ issue, linkTypes }: { issue: IssueDetail; linkTypes: Record<string, string> }) {
|
||||||
const groups = groupLinks(issue, linkTypes);
|
const groups = groupLinks(issue, linkTypes);
|
||||||
return (
|
return (
|
||||||
@ -120,8 +123,10 @@ function Links({ issue, linkTypes }: { issue: IssueDetail; linkTypes: Record<str
|
|||||||
{links.map((l, n) => (
|
{links.map((l, n) => (
|
||||||
<li key={`${n}:${l.key}:${l.tag ?? ""}`}>
|
<li key={`${n}:${l.key}:${l.tag ?? ""}`}>
|
||||||
{l.tag && <><span className="tag">{l.tag}</span>{" "}</>}
|
{l.tag && <><span className="tag">{l.tag}</span>{" "}</>}
|
||||||
<IssueLink issueKey={l.key} />{" "}
|
{absent(l)
|
||||||
{l.summary === null && l.status === null
|
? <a href={`${JIRA_URL}/browse/${l.key}`} target="_blank" rel="noopener">{l.key}<IconExternal size={12} /></a>
|
||||||
|
: <IssueLink issueKey={l.key} />}{" "}
|
||||||
|
{absent(l)
|
||||||
? <span className="sum absent">нет в зеркале</span>
|
? <span className="sum absent">нет в зеркале</span>
|
||||||
: <><span className="sum">{l.summary}</span>{l.status && <>{" "}<Badge>{l.status}</Badge></>}</>}
|
: <><span className="sum">{l.summary}</span>{l.status && <>{" "}<Badge>{l.status}</Badge></>}</>}
|
||||||
</li>
|
</li>
|
||||||
@ -222,9 +227,11 @@ function History({ items }: { items: ChangeEvent[] }) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function OtherFields({ issue: i }: { issue: IssueDetail }) {
|
function OtherFields({ issue: i, kindLabels }: { issue: IssueDetail; kindLabels?: Record<string, string> }) {
|
||||||
|
const { kinds, rest } = splitKindLabels(i.labels, kindLabels);
|
||||||
const rows: [string, string | null][] = [
|
const rows: [string, string | null][] = [
|
||||||
["Тип", i.issue_type], ["Приоритет", i.priority], ["Автор", i.reporter], ["Метки", i.labels.join(", ") || null],
|
["Тип", i.issue_type], ["Приоритет", i.priority], ["Автор", i.reporter],
|
||||||
|
["Вид работы в Jira", kinds.join(", ") || null], ["Метки", rest.join(", ") || null],
|
||||||
["Плановое начало", i.planned_start], ["Обновлена в Jira", i.jira_updated ? fmtStamp(i.jira_updated) : null],
|
["Плановое начало", i.planned_start], ["Обновлена в Jira", i.jira_updated ? fmtStamp(i.jira_updated) : null],
|
||||||
["Сосед по связям", i.neighbor ? "да" : null],
|
["Сосед по связям", i.neighbor ? "да" : null],
|
||||||
];
|
];
|
||||||
|
|||||||
@ -1,8 +1,8 @@
|
|||||||
import type { ReactNode } from "react";
|
import type { MouseEvent, ReactNode } from "react";
|
||||||
import { Link, useLocation, useNavigate } from "react-router";
|
import { Link, useLocation, useNavigate } from "react-router";
|
||||||
import { openCardState } from "./modal.ts";
|
import { openCardState } from "./modal.ts";
|
||||||
|
|
||||||
const cardPath = (key: string) => `/jira/issues/${encodeURIComponent(key)}`;
|
export const cardPath = (key: string) => `/jira/issues/${encodeURIComponent(key)}`;
|
||||||
|
|
||||||
// переход к карточке задачи: окно поверх текущей страницы (или смена содержимого открытого окна) — как у ссылки-ключа
|
// переход к карточке задачи: окно поверх текущей страницы (или смена содержимого открытого окна) — как у ссылки-ключа
|
||||||
export function useOpenIssue(): (key: string) => void {
|
export function useOpenIssue(): (key: string) => void {
|
||||||
@ -12,7 +12,13 @@ export function useOpenIssue(): (key: string) => void {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// ссылка на карточку задачи: открывает окно поверх текущей страницы (или меняет содержимое открытого окна)
|
// ссылка на карточку задачи: открывает окно поверх текущей страницы (или меняет содержимое открытого окна)
|
||||||
export function IssueLink({ issueKey, className, children }: { issueKey: string; className?: string; children?: ReactNode }) {
|
// onOpen: обычный щелчок (левая кнопка без модификаторов) отдаётся вызывающему; Ctrl/Cmd/Shift/средняя кнопка — обычная ссылка
|
||||||
|
export function IssueLink({ issueKey, className, children, onOpen }: { issueKey: string; className?: string; children?: ReactNode; onOpen?(key: string): void }) {
|
||||||
const loc = useLocation();
|
const loc = useLocation();
|
||||||
return <Link className={className} to={cardPath(issueKey)} state={openCardState(loc)}>{children ?? issueKey}</Link>;
|
const click = onOpen && ((e: MouseEvent<HTMLAnchorElement>) => {
|
||||||
|
if (e.defaultPrevented || e.button !== 0 || e.ctrlKey || e.metaKey || e.shiftKey || e.altKey) return;
|
||||||
|
e.preventDefault();
|
||||||
|
onOpen(issueKey);
|
||||||
|
});
|
||||||
|
return <Link className={className} to={cardPath(issueKey)} state={openCardState(loc)} onClick={click}>{children ?? issueKey}</Link>;
|
||||||
}
|
}
|
||||||
|
|||||||
@ -137,6 +137,8 @@ describe("карточка окном", () => {
|
|||||||
await userEvent.click(within(dialog()).getByRole("button", { name: "Остаться" }));
|
await userEvent.click(within(dialog()).getByRole("button", { name: "Остаться" }));
|
||||||
expect(router.state.location.pathname).toBe("/app/jira/issues/AURORA-9001");
|
expect(router.state.location.pathname).toBe("/app/jira/issues/AURORA-9001");
|
||||||
expect(within(dialog()).getByLabelText("Заметка владельца")).toHaveValue("черновик");
|
expect(within(dialog()).getByLabelText("Заметка владельца")).toHaveValue("черновик");
|
||||||
|
fireEvent(dialog(), new Event("cancel", { cancelable: true })); // после «Остаться» повторный Esc снова спрашивает (closingKey сброшен)
|
||||||
|
expect(await within(dialog()).findByRole("button", { name: "Уйти" })).toBeInTheDocument();
|
||||||
});
|
});
|
||||||
|
|
||||||
it("T4.1: отказ от закрытия («Остаться») сбрасывает ожидание закрытия: «назад» на запись прямого адреса не уводит в список", async () => {
|
it("T4.1: отказ от закрытия («Остаться») сбрасывает ожидание закрытия: «назад» на запись прямого адреса не уводит в список", async () => {
|
||||||
@ -155,6 +157,59 @@ describe("карточка окном", () => {
|
|||||||
expect(router.state.location.pathname).toBe("/app/jira/issues/AURORA-9001");
|
expect(router.state.location.pathname).toBe("/app/jira/issues/AURORA-9001");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("поиск перед открытием не закрывает окно (#42 M-1): ключ и ячейка темы", async () => {
|
||||||
|
for (const open of ["key", "sum"] as const) {
|
||||||
|
const router = at(["/app/jira/issues"]);
|
||||||
|
const { unmount } = render(<App router={router} />);
|
||||||
|
await screen.findByRole("table");
|
||||||
|
await userEvent.type(screen.getByRole("searchbox"), "свод");
|
||||||
|
if (open === "key") await userEvent.click(screen.getByRole("link", { name: "AURORA-9002" }));
|
||||||
|
else await userEvent.click(screen.getByRole("cell", { name: "API свода" }));
|
||||||
|
await within(dialog()).findByRole("heading", { name: /AURORA-9002/ });
|
||||||
|
await new Promise((r) => setTimeout(r, 400));
|
||||||
|
expect(screen.getByRole("dialog")).toBeInTheDocument();
|
||||||
|
expect(router.state.location.pathname).toBe("/app/jira/issues/AURORA-9002");
|
||||||
|
await userEvent.click(within(dialog()).getByRole("button", { name: "Закрыть" }));
|
||||||
|
await waitFor(() => expect(screen.queryByRole("dialog")).toBeNull());
|
||||||
|
expect(router.state.location.pathname).toBe("/app/jira/issues");
|
||||||
|
expect(new URLSearchParams(router.state.location.search).get("q")).toBe("свод");
|
||||||
|
unmount();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it("двойной щелчок по ✕ не уводит дальше назад (#42 M-2)", async () => {
|
||||||
|
const router = at(["/app/jira/links", "/app/jira/issues"]);
|
||||||
|
render(<App router={router} />);
|
||||||
|
await userEvent.click(await screen.findByRole("link", { name: "AURORA-9001" }));
|
||||||
|
await userEvent.click(await within(dialog()).findByRole("link", { name: "AURORA-9002" }));
|
||||||
|
await within(dialog()).findByRole("heading", { name: /AURORA-9002/ });
|
||||||
|
const x = within(dialog()).getByRole("button", { name: "Закрыть" });
|
||||||
|
await act(async () => { fireEvent.click(x); fireEvent.click(x); }); // оба щелчка до перерисовки
|
||||||
|
await waitFor(() => expect(screen.queryByRole("dialog")).toBeNull());
|
||||||
|
expect(router.state.location.pathname).toBe("/app/jira/issues");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("двойной ✕ у прямого адреса: адрес списка, дальше по истории не уходит (Review Focus 2)", async () => {
|
||||||
|
const router = at(["/app/jira/issues/AURORA-9002"]);
|
||||||
|
render(<App router={router} />);
|
||||||
|
await within(await screen.findByRole("dialog")).findByRole("heading", { name: /AURORA-9002/ });
|
||||||
|
await waitFor(() => expect(router.state.location.state).toMatchObject({ depth: 0, direct: true }));
|
||||||
|
await userEvent.dblClick(within(dialog()).getByRole("button", { name: "Закрыть" }));
|
||||||
|
await waitFor(() => expect(screen.queryByRole("dialog")).toBeNull());
|
||||||
|
expect(router.state.location.pathname).toBe("/app/jira/issues");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("подтверждение ухода: имя и фокус на «Остаться» (#42 M-4)", async () => {
|
||||||
|
const router = at(["/app/jira/issues"]);
|
||||||
|
render(<App router={router} />);
|
||||||
|
await userEvent.click(await screen.findByRole("link", { name: "AURORA-9001" }));
|
||||||
|
await userEvent.click(await within(dialog()).findByRole("button", { name: "Править" }));
|
||||||
|
await userEvent.type(await within(dialog()).findByLabelText("Заметка владельца"), "черновик");
|
||||||
|
fireEvent(dialog(), new Event("cancel", { cancelable: true }));
|
||||||
|
const ask = await within(dialog()).findByRole("alertdialog", { name: "Уйти без сохранения заметки?" });
|
||||||
|
await waitFor(() => expect(document.activeElement).toBe(within(ask).getByRole("button", { name: "Остаться" })));
|
||||||
|
});
|
||||||
|
|
||||||
it("T4.4: у окна есть имя в состоянии загрузки и 404", async () => {
|
it("T4.4: у окна есть имя в состоянии загрузки и 404", async () => {
|
||||||
mockFetch((_m, url) => {
|
mockFetch((_m, url) => {
|
||||||
if (url.pathname === "/settings") return json(200, settings);
|
if (url.pathname === "/settings") return json(200, settings);
|
||||||
@ -168,4 +223,111 @@ describe("карточка окном", () => {
|
|||||||
render(<App router={at(["/app/jira/issues/AURORA-2"])} />);
|
render(<App router={at(["/app/jira/issues/AURORA-2"])} />);
|
||||||
expect(await screen.findByRole("dialog", { name: "AURORA-2" })).toBeInTheDocument();
|
expect(await screen.findByRole("dialog", { name: "AURORA-2" })).toBeInTheDocument();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe("задача не в зеркале после перехода (#42 M-5)", () => {
|
||||||
|
const mirror = () => mockFetch((_m, url) => {
|
||||||
|
const p = url.pathname;
|
||||||
|
if (p === "/issues/AURORA-1999") return json(404, {});
|
||||||
|
if (p === "/issues/AURORA-9002") return json(200, issue("AURORA-9002", {
|
||||||
|
links_out: [{ target_key: "AURORA-1999", link_type: "ASUPR_Связанные", summary: "Тема", status: "Новая" }], links_in: [],
|
||||||
|
}));
|
||||||
|
if (p === "/settings") return json(200, settings);
|
||||||
|
if (p === "/projects") return json(200, { projects: [{ label: "AWPCA", name: "АРМ КА", board_id: null, board_type: null, created_at: "", issues_count: 9 }] });
|
||||||
|
if (p === "/issues") return json(200, { issues: rows });
|
||||||
|
if (p === "/batches") return json(200, { batches: [] });
|
||||||
|
return json(200, {});
|
||||||
|
});
|
||||||
|
const msg = "Задачи AURORA-1999 нет в зеркале хаба.";
|
||||||
|
|
||||||
|
it("«Назад» возвращает прежнюю карточку; при новом переходе «Закрыть» закрывает окно", async () => {
|
||||||
|
mirror();
|
||||||
|
const router = at(["/app/jira/issues"]);
|
||||||
|
render(<App router={router} />);
|
||||||
|
await userEvent.click(await screen.findByRole("link", { name: "AURORA-9002" }));
|
||||||
|
await userEvent.click(await within(dialog()).findByRole("link", { name: "AURORA-1999" }));
|
||||||
|
expect(await within(dialog()).findByText(msg)).toBeInTheDocument();
|
||||||
|
await userEvent.click(within(dialog()).getByRole("button", { name: "Назад" }));
|
||||||
|
expect(await within(dialog()).findByRole("heading", { name: /AURORA-9002/ })).toBeInTheDocument();
|
||||||
|
await userEvent.click(within(dialog()).getByRole("link", { name: "AURORA-1999" }));
|
||||||
|
await within(dialog()).findByText(msg);
|
||||||
|
await userEvent.click(within(dialog()).getByRole("button", { name: "Закрыть" }));
|
||||||
|
await waitFor(() => expect(screen.queryByRole("dialog")).toBeNull());
|
||||||
|
expect(router.state.location.pathname).toBe("/app/jira/issues");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("прямой адрес 404 — только «Закрыть»", async () => {
|
||||||
|
mirror();
|
||||||
|
render(<App router={at(["/app/jira/issues/AURORA-1999"])} />);
|
||||||
|
expect(await within(await screen.findByRole("dialog")).findByText(msg)).toBeInTheDocument();
|
||||||
|
expect(within(dialog()).getByRole("button", { name: "Закрыть" })).toBeInTheDocument();
|
||||||
|
expect(within(dialog()).queryByRole("button", { name: "Назад" })).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("«Назад» есть только после перехода: из списка — нет, после перехода из списка — есть", async () => {
|
||||||
|
mirror();
|
||||||
|
render(<App router={at(["/app/jira/issues"])} />);
|
||||||
|
await userEvent.click(await screen.findByRole("link", { name: "AURORA-9002" }));
|
||||||
|
await within(dialog()).findByRole("heading", { name: /AURORA-9002/ });
|
||||||
|
expect(within(dialog()).queryByRole("button", { name: "Назад" })).toBeNull();
|
||||||
|
await userEvent.click(within(dialog()).getByRole("link", { name: "AURORA-1999" }));
|
||||||
|
await within(dialog()).findByText(msg);
|
||||||
|
expect(within(dialog()).getByRole("button", { name: "Назад" })).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("двойной щелчок по «Назад» — один шаг, окно остаётся (M-1)", async () => {
|
||||||
|
mirror();
|
||||||
|
const router = at(["/app/jira/issues"]);
|
||||||
|
render(<App router={router} />);
|
||||||
|
await userEvent.click(await screen.findByRole("link", { name: "AURORA-9002" }));
|
||||||
|
await userEvent.click(await within(dialog()).findByRole("link", { name: "AURORA-1999" }));
|
||||||
|
await within(dialog()).findByText(msg);
|
||||||
|
const back = within(dialog()).getByRole("button", { name: "Назад" });
|
||||||
|
await act(async () => { fireEvent.click(back); fireEvent.click(back); });
|
||||||
|
expect(await within(dialog()).findByRole("heading", { name: /AURORA-9002/ })).toBeInTheDocument();
|
||||||
|
expect(router.state.location.pathname).toBe("/app/jira/issues/AURORA-9002");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("двойной «Назад» из прямого адреса не уходит со страницы приложения (M-1)", async () => {
|
||||||
|
mirror();
|
||||||
|
const router = at(["/app/jira/issues/AURORA-9002"]);
|
||||||
|
render(<App router={router} />);
|
||||||
|
await userEvent.click(await within(await screen.findByRole("dialog")).findByRole("link", { name: "AURORA-1999" }));
|
||||||
|
await within(dialog()).findByText(msg);
|
||||||
|
const back = within(dialog()).getByRole("button", { name: "Назад" });
|
||||||
|
await act(async () => { fireEvent.click(back); fireEvent.click(back); });
|
||||||
|
expect(await within(dialog()).findByRole("heading", { name: /AURORA-9002/ })).toBeInTheDocument();
|
||||||
|
expect(router.state.location.pathname).toBe("/app/jira/issues/AURORA-9002");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("ошибка загрузки после перехода: «Повторить», «Назад», «Закрыть» (T3.2)", async () => {
|
||||||
|
mockFetch((_m, url) => {
|
||||||
|
const p = url.pathname;
|
||||||
|
if (p === "/issues/AURORA-1999") return json(500, { detail: "сбой" });
|
||||||
|
if (p === "/issues/AURORA-9002") return json(200, issue("AURORA-9002", {
|
||||||
|
links_out: [{ target_key: "AURORA-1999", link_type: "ASUPR_Связанные", summary: "Тема", status: "Новая" }], links_in: [],
|
||||||
|
}));
|
||||||
|
if (p === "/settings") return json(200, settings);
|
||||||
|
if (p === "/projects") return json(200, { projects: [{ label: "AWPCA", name: "АРМ КА", board_id: null, board_type: null, created_at: "", issues_count: 9 }] });
|
||||||
|
if (p === "/issues") return json(200, { issues: rows });
|
||||||
|
if (p === "/batches") return json(200, { batches: [] });
|
||||||
|
return json(200, {});
|
||||||
|
});
|
||||||
|
const router = at(["/app/jira/issues"]);
|
||||||
|
render(<App router={router} />);
|
||||||
|
await userEvent.click(await screen.findByRole("link", { name: "AURORA-9002" }));
|
||||||
|
await userEvent.click(await within(dialog()).findByRole("link", { name: "AURORA-1999" }));
|
||||||
|
expect(await within(dialog()).findByRole("button", { name: "Повторить" })).toBeInTheDocument();
|
||||||
|
expect(within(dialog()).getByRole("button", { name: "Закрыть" })).toBeInTheDocument();
|
||||||
|
await userEvent.click(within(dialog()).getByRole("button", { name: "Назад" }));
|
||||||
|
expect(await within(dialog()).findByRole("heading", { name: /AURORA-9002/ })).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("переход из прямого адреса — «Назад» есть", async () => {
|
||||||
|
mirror();
|
||||||
|
render(<App router={at(["/app/jira/issues/AURORA-9002"])} />);
|
||||||
|
await userEvent.click(await within(await screen.findByRole("dialog")).findByRole("link", { name: "AURORA-1999" }));
|
||||||
|
await within(dialog()).findByText(msg);
|
||||||
|
expect(within(dialog()).getByRole("button", { name: "Назад" })).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@ -7,7 +7,7 @@ import { IssueCard } from "./IssueCard.tsx";
|
|||||||
// окно карточки (design 13): <dialog> держит фокус и делает фон недоступным; Esc и щелчок по фону — onClose.
|
// окно карточки (design 13): <dialog> держит фокус и делает фон недоступным; Esc и щелчок по фону — onClose.
|
||||||
// Сами окно не закрываем: оно исчезает вместе с адресом карточки. Если браузер закрыл его сам (Esc без активации
|
// Сами окно не закрываем: оно исчезает вместе с адресом карточки. Если браузер закрыл его сам (Esc без активации
|
||||||
// пользователя — cancel не отменяемый), открываем снова и просим закрытия по общему пути (блокировщик заметок).
|
// пользователя — cancel не отменяемый), открываем снова и просим закрытия по общему пути (блокировщик заметок).
|
||||||
export function IssueModal({ issueKey, onClose }: { issueKey: string; onClose: () => void }) {
|
export function IssueModal({ issueKey, onClose, onBack }: { issueKey: string; onClose: () => void; onBack?: () => void }) {
|
||||||
// I-2: закрывать щелчком по фону, только если и нажатие было на фоне — выделение текста, отпущенное за краем окна,
|
// I-2: закрывать щелчком по фону, только если и нажатие было на фоне — выделение текста, отпущенное за краем окна,
|
||||||
// даёт click на самом <dialog>, но нажатие было внутри содержимого
|
// даёт click на самом <dialog>, но нажатие было внутри содержимого
|
||||||
const downOnBackdrop = useRef(false);
|
const downOnBackdrop = useRef(false);
|
||||||
@ -26,7 +26,7 @@ export function IssueModal({ issueKey, onClose }: { issueKey: string; onClose: (
|
|||||||
downOnBackdrop.current = false;
|
downOnBackdrop.current = false;
|
||||||
if (e.target === e.currentTarget && fromBackdrop) onClose();
|
if (e.target === e.currentTarget && fromBackdrop) onClose();
|
||||||
}}>
|
}}>
|
||||||
<ErrorBoundary key={issueKey}><IssueCard issueKey={issueKey} onClose={onClose} /></ErrorBoundary>
|
<ErrorBoundary key={issueKey}><IssueCard issueKey={issueKey} onClose={onClose} onBack={onBack} /></ErrorBoundary>
|
||||||
</Dialog>
|
</Dialog>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -35,7 +35,7 @@ export function IssueTable({ rows, epicNames, closed, onOpen }: {
|
|||||||
const due = dueOf(i);
|
const due = dueOf(i);
|
||||||
return (
|
return (
|
||||||
<tr key={i.key} onClick={(e) => rowClick(e, i.key, onOpen)}>
|
<tr key={i.key} onClick={(e) => rowClick(e, i.key, onOpen)}>
|
||||||
<td className="issue-table__key"><IssueLink issueKey={i.key} />{i.neighbor && <Badge tone="neutral">сосед</Badge>}</td>
|
<td className="issue-table__key"><IssueLink issueKey={i.key} onOpen={onOpen} />{i.neighbor && <Badge tone="neutral">сосед</Badge>}</td>
|
||||||
<td className="issue-table__sum">{i.summary}</td>
|
<td className="issue-table__sum">{i.summary}</td>
|
||||||
<td>{i.status && <Badge tone="info">{i.status}</Badge>}</td>
|
<td>{i.status && <Badge tone="info">{i.status}</Badge>}</td>
|
||||||
<td>{i.assignee}</td>
|
<td>{i.assignee}</td>
|
||||||
|
|||||||
@ -1,4 +1,4 @@
|
|||||||
import { act, screen, waitFor, within } from "@testing-library/react";
|
import { act, fireEvent, screen, waitFor, within } from "@testing-library/react";
|
||||||
import userEvent from "@testing-library/user-event";
|
import userEvent from "@testing-library/user-event";
|
||||||
import { readFileSync } from "node:fs";
|
import { readFileSync } from "node:fs";
|
||||||
import { join } from "node:path";
|
import { join } from "node:path";
|
||||||
@ -41,6 +41,16 @@ describe("список задач", () => {
|
|||||||
expect(q.get("offset")).toBe("0");
|
expect(q.get("offset")).toBe("0");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("ключ строки: href прежний, щелчок с Ctrl не открывает карточку внутри приложения (Review Focus 1)", async () => {
|
||||||
|
hub(() => [row(1)]);
|
||||||
|
const { router } = renderWithProviders(<IssuesPage project="AWPCA" />, { route: "/issues", path: "/issues" });
|
||||||
|
const link = await screen.findByRole("link", { name: "AURORA-9001" });
|
||||||
|
expect(link).toHaveAttribute("href", "/jira/issues/AURORA-9001");
|
||||||
|
fireEvent.click(link, { ctrlKey: true });
|
||||||
|
fireEvent.click(link, { metaKey: true });
|
||||||
|
expect(router.state.location.pathname).toBe("/issues");
|
||||||
|
});
|
||||||
|
|
||||||
it("выбор исполнителя пишет его в адрес и в запрос", async () => {
|
it("выбор исполнителя пишет его в адрес и в запрос", async () => {
|
||||||
expanded();
|
expanded();
|
||||||
const fetch = hub(() => [row(1)]);
|
const fetch = hub(() => [row(1)]);
|
||||||
@ -236,6 +246,37 @@ describe("список задач", () => {
|
|||||||
await waitFor(() => expect(calls().at(-1)!.searchParams.has("hide_closed")).toBe(false));
|
await waitFor(() => expect(calls().at(-1)!.searchParams.has("hide_closed")).toBe(false));
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("закрытый статус в адресе до настроек: запроса нет, «Загрузка…», после настроек — один запрос без hide_closed (#60 M-3)", async () => {
|
||||||
|
const st = deferred<Response>();
|
||||||
|
const fetch = mockFetch((_m, url) => {
|
||||||
|
if (url.pathname === "/settings") return st.promise;
|
||||||
|
if (url.pathname === "/board") return json(200, SNAP);
|
||||||
|
return json(200, { issues: [row(1)] });
|
||||||
|
});
|
||||||
|
renderWithProviders(<IssuesPage project="AWPCA" />, { route: "/issues?status=Готово", path: "/issues" });
|
||||||
|
await screen.findByText("Загрузка…");
|
||||||
|
expect(issuesCalls(fetch)).toHaveLength(0);
|
||||||
|
expect(screen.queryByText("По этим фильтрам задач нет")).toBeNull();
|
||||||
|
st.resolve(json(200, { ...settings, closed_statuses: ["Готово"] }));
|
||||||
|
await screen.findByRole("link", { name: "AURORA-9001" });
|
||||||
|
expect(issuesCalls(fetch)).toHaveLength(1);
|
||||||
|
const q = issuesCalls(fetch)[0].searchParams;
|
||||||
|
expect(q.get("status")).toBe("Готово");
|
||||||
|
expect(q.has("hide_closed")).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("R-11: /settings отвечает 500 — список всё равно запрашивается с hide_closed=1", async () => {
|
||||||
|
const fetch = mockFetch((_m, url) => {
|
||||||
|
if (url.pathname === "/settings") return json(500, { error: "boom" });
|
||||||
|
if (url.pathname === "/board") return json(200, SNAP);
|
||||||
|
return json(200, { issues: [row(1)] });
|
||||||
|
});
|
||||||
|
renderWithProviders(<IssuesPage project="AWPCA" />, { route: "/issues", path: "/issues" });
|
||||||
|
await screen.findByRole("link", { name: "AURORA-9001" });
|
||||||
|
expect(issuesCalls(fetch)).toHaveLength(1);
|
||||||
|
expect(issuesCalls(fetch)[0].searchParams.get("hide_closed")).toBe("1");
|
||||||
|
});
|
||||||
|
|
||||||
it("статуса по факту нет: ни фильтра, ни колонки; fact_status из адреса не уходит в запрос", async () => {
|
it("статуса по факту нет: ни фильтра, ни колонки; fact_status из адреса не уходит в запрос", async () => {
|
||||||
const fetch = hub(() => [row(1)]);
|
const fetch = hub(() => [row(1)]);
|
||||||
renderWithProviders(<IssuesPage project="AWPCA" />, { route: "/issues?fact_status=В работе", path: "/issues" });
|
renderWithProviders(<IssuesPage project="AWPCA" />, { route: "/issues?fact_status=В работе", path: "/issues" });
|
||||||
|
|||||||
@ -1,11 +1,12 @@
|
|||||||
import { useEffect, useMemo, useRef, useState } from "react";
|
import { useEffect, useMemo, useRef, useState } from "react";
|
||||||
import { useSearchParams } from "react-router";
|
import { useLocation, useNavigate, useSearchParams } from "react-router";
|
||||||
import { isClosed, useClosedStatuses } from "../api/closed.ts";
|
import { isClosed, useClosedStatuses } from "../api/closed.ts";
|
||||||
import { useSettings } from "../../../api/queries.ts";
|
import { useSettings } from "../../../api/queries.ts";
|
||||||
import { ISSUE_FILTER_NAMES, useBoard, useIssues, type IssueFilters as Filters } from "../api/queries.ts";
|
import { ISSUE_FILTER_NAMES, useBoard, useIssues, type IssueFilters as Filters } from "../api/queries.ts";
|
||||||
import { Button, Notice, PageHeader } from "../../../ui/index.ts";
|
import { Button, Notice, PageHeader } from "../../../ui/index.ts";
|
||||||
import { IssueFilters, SELECTS } from "./IssueFilters.tsx";
|
import { IssueFilters, SELECTS } from "./IssueFilters.tsx";
|
||||||
import { useOpenIssue } from "./IssueLink.tsx";
|
import { cardPath } from "./IssueLink.tsx";
|
||||||
|
import { openCardState } from "./modal.ts";
|
||||||
import { RefreshButton } from "../RefreshButton.tsx";
|
import { RefreshButton } from "../RefreshButton.tsx";
|
||||||
import { IssueTable } from "./IssueTable.tsx";
|
import { IssueTable } from "./IssueTable.tsx";
|
||||||
import { epicNames, filterOptions } from "./options.ts";
|
import { epicNames, filterOptions } from "./options.ts";
|
||||||
@ -28,14 +29,32 @@ export function IssuesPage({ project }: { project: string }) {
|
|||||||
written.current = fromUrl;
|
written.current = fromUrl;
|
||||||
setQ(fromUrl);
|
setQ(fromUrl);
|
||||||
}, [filters.q]);
|
}, [filters.q]);
|
||||||
|
const timer = useRef<ReturnType<typeof setTimeout> | undefined>(undefined);
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (q === (filters.q ?? "")) return;
|
if (q === (filters.q ?? "")) return;
|
||||||
const t = setTimeout(() => {
|
timer.current = setTimeout(() => {
|
||||||
written.current = q;
|
written.current = q;
|
||||||
setParams((p) => { const n = new URLSearchParams(p); if (q) n.set("q", q); else n.delete("q"); return n; }, { replace: true });
|
setParams((p) => { const n = new URLSearchParams(p); if (q) n.set("q", q); else n.delete("q"); return n; }, { replace: true });
|
||||||
}, Q_DELAY);
|
}, Q_DELAY);
|
||||||
return () => clearTimeout(t);
|
return () => clearTimeout(timer.current);
|
||||||
}, [q, filters.q, setParams]);
|
}, [q, filters.q, setParams]);
|
||||||
|
// открытие карточки до срабатывания таймера поиска: q сначала пишется в запись списка (replace), окно открывается поверх неё —
|
||||||
|
// иначе таймер переписал бы адрес карточки и закрыл окно (#42 M-1)
|
||||||
|
const loc = useLocation();
|
||||||
|
const navigate = useNavigate();
|
||||||
|
const open = (key: string) => {
|
||||||
|
clearTimeout(timer.current);
|
||||||
|
let bg = loc;
|
||||||
|
if (q !== (filters.q ?? "")) {
|
||||||
|
const n = new URLSearchParams(loc.search);
|
||||||
|
if (q) n.set("q", q); else n.delete("q");
|
||||||
|
const search = n.size ? "?" + n.toString() : "";
|
||||||
|
written.current = q;
|
||||||
|
void navigate({ pathname: loc.pathname, search }, { replace: true, state: loc.state });
|
||||||
|
bg = { ...loc, search };
|
||||||
|
}
|
||||||
|
void navigate(cardPath(key), { state: openCardState(bg) });
|
||||||
|
};
|
||||||
|
|
||||||
const board = useBoard(project), settings = useSettings();
|
const board = useBoard(project), settings = useSettings();
|
||||||
const opts = useMemo(() => filterOptions(board.data, settings.data), [board.data, settings.data]);
|
const opts = useMemo(() => filterOptions(board.data, settings.data), [board.data, settings.data]);
|
||||||
@ -45,20 +64,20 @@ export function IssuesPage({ project }: { project: string }) {
|
|||||||
const closed = useClosedStatuses();
|
const closed = useClosedStatuses();
|
||||||
const query = useMemo<Filters>(() => (showClosed || isClosed(filters.status, closed) ? { ...filters, project } : { ...filters, project, hide_closed: "1" }),
|
const query = useMemo<Filters>(() => (showClosed || isClosed(filters.status, closed) ? { ...filters, project } : { ...filters, project, hide_closed: "1" }),
|
||||||
[filters, project, showClosed, closed]);
|
[filters, project, showClosed, closed]);
|
||||||
const list = useIssues(query);
|
const list = useIssues(query, { enabled: !settings.isPending }); // закрытые статусы — из настроек: без них запрос ушёл бы с неверным hide_closed (#60 M-3); при ошибке настроек isPending=false (R-11)
|
||||||
const rows = list.data?.pages.flat() ?? [];
|
const rows = list.data?.pages.flat() ?? [];
|
||||||
const open = useOpenIssue();
|
const loading = list.isPending || settings.isPending;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div data-testid="page-issues" className="issues">
|
<div data-testid="page-issues" className="issues">
|
||||||
<PageHeader title="Задачи" subtitle={list.isPending ? undefined : `показано ${rows.length}${list.hasNextPage ? " · есть ещё" : ""}`} actions={<RefreshButton />} />
|
<PageHeader title="Задачи" subtitle={loading ? undefined : `показано ${rows.length}${list.hasNextPage ? " · есть ещё" : ""}`} actions={<RefreshButton />} />
|
||||||
<IssueFilters filters={filters} set={set} onReset={reset} q={q} setQ={setQ} showClosed={showClosed} opts={opts} hasBoard={!!board.data} />
|
<IssueFilters filters={filters} set={set} onReset={reset} q={q} setQ={setQ} showClosed={showClosed} opts={opts} hasBoard={!!board.data} />
|
||||||
{list.isError && (
|
{list.isError && (
|
||||||
<Notice tone="error" action={<Button size="md" onClick={() => void list.refetch()}>Повторить</Button>}>{(list.error as Error).message}</Notice>
|
<Notice tone="error" action={<Button size="md" onClick={() => void list.refetch()}>Повторить</Button>}>{(list.error as Error).message}</Notice>
|
||||||
)}
|
)}
|
||||||
{rows.length > 0 && <IssueTable rows={rows} epicNames={names} closed={closed} onOpen={open} />}
|
{rows.length > 0 && <IssueTable rows={rows} epicNames={names} closed={closed} onOpen={open} />}
|
||||||
{list.isPending && <p className="issues__hint">Загрузка…</p>}
|
{loading && <p className="issues__hint">Загрузка…</p>}
|
||||||
{!list.isPending && !rows.length && !list.isError && <Notice tone="info">По этим фильтрам задач нет</Notice>}
|
{!loading && !rows.length && !list.isError && <Notice tone="info">По этим фильтрам задач нет</Notice>}
|
||||||
{list.hasNextPage && (
|
{list.hasNextPage && (
|
||||||
<div className="issues__more">
|
<div className="issues__more">
|
||||||
<Button disabled={list.isFetchingNextPage} onClick={() => void list.fetchNextPage()}>Показать ещё</Button>
|
<Button disabled={list.isFetchingNextPage} onClick={() => void list.fetchNextPage()}>Показать ещё</Button>
|
||||||
|
|||||||
@ -39,7 +39,11 @@ export function NoteForm({ issue, onDone }: { issue: IssueDetail; onDone: () =>
|
|||||||
return () => window.removeEventListener("beforeunload", warn);
|
return () => window.removeEventListener("beforeunload", warn);
|
||||||
}, [dirty]);
|
}, [dirty]);
|
||||||
// подтверждение ухода может оказаться ниже видимой части окна — прокрутить к нему
|
// подтверждение ухода может оказаться ниже видимой части окна — прокрутить к нему
|
||||||
useEffect(() => { if (blocker.state === "blocked") confirmRef.current?.scrollIntoView?.({ block: "nearest" }); }, [blocker.state]);
|
useEffect(() => {
|
||||||
|
if (blocker.state !== "blocked") return;
|
||||||
|
confirmRef.current?.scrollIntoView?.({ block: "nearest" });
|
||||||
|
confirmRef.current?.querySelector<HTMLButtonElement>("[data-stay]")?.focus();
|
||||||
|
}, [blocker.state]);
|
||||||
|
|
||||||
const kinds = Object.keys(settings.data?.kind_labels ?? {});
|
const kinds = Object.keys(settings.data?.kind_labels ?? {});
|
||||||
const kindOptions = values.work_kind && !kinds.includes(values.work_kind) ? [values.work_kind, ...kinds] : kinds;
|
const kindOptions = values.work_kind && !kinds.includes(values.work_kind) ? [values.work_kind, ...kinds] : kinds;
|
||||||
@ -79,11 +83,11 @@ export function NoteForm({ issue, onDone }: { issue: IssueDetail; onDone: () =>
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
{blocker.state === "blocked" && (
|
{blocker.state === "blocked" && (
|
||||||
<div className="note-confirm" role="alertdialog" ref={confirmRef}>
|
<div className="note-confirm" role="alertdialog" aria-label="Уйти без сохранения заметки?" ref={confirmRef}>
|
||||||
<span>Есть несохранённые правки заметки. Уйти без сохранения?</span>
|
<span>Есть несохранённые правки заметки. Уйти без сохранения?</span>
|
||||||
<div className="row">
|
<div className="row">
|
||||||
<Button onClick={() => blocker.proceed()}>Уйти</Button>
|
<Button onClick={() => blocker.proceed()}>Уйти</Button>
|
||||||
<Button variant="primary" onClick={() => { blocker.reset(); abortClose(); }}>Остаться</Button>
|
<Button variant="primary" data-stay onClick={() => { blocker.reset(); abortClose(); }}>Остаться</Button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@ -10,19 +10,13 @@ export type Download = ReturnType<typeof useAttachmentDownload>;
|
|||||||
const KIND_ICON = { image: IconImage, sheet: IconSheet, pdf: IconFile, other: IconFile };
|
const KIND_ICON = { image: IconImage, sheet: IconSheet, pdf: IconFile, other: IconFile };
|
||||||
const KIND_LABEL = { image: "картинка", sheet: "таблица", pdf: "PDF", other: "файл" };
|
const KIND_LABEL = { image: "картинка", sheet: "таблица", pdf: "PDF", other: "файл" };
|
||||||
|
|
||||||
// новые сверху: по времени Jira, затем по числовому id
|
|
||||||
export function newestFirst(items: IssueAttachment[]): IssueAttachment[] {
|
|
||||||
const t = (a: IssueAttachment) => (a.created ? Date.parse(a.created) || 0 : 0);
|
|
||||||
return [...items].sort((a, b) => t(b) - t(a) || (Number(b.attachment_id) || 0) - (Number(a.attachment_id) || 0));
|
|
||||||
}
|
|
||||||
|
|
||||||
// раздел «Вложения (N)»: значок по типу, имя, размер, «автор · дата», отметка «у хаба», кнопка скачивания
|
// раздел «Вложения (N)»: значок по типу, имя, размер, «автор · дата», отметка «у хаба», кнопка скачивания
|
||||||
export function AttachmentList({ items, dl }: { items: IssueAttachment[]; dl: Download }) {
|
export function AttachmentList({ items, dl }: { items: IssueAttachment[]; dl: Download }) {
|
||||||
return (
|
return (
|
||||||
<section className="attachments" data-testid="attachments">
|
<section className="attachments" data-testid="attachments">
|
||||||
<div className="sec-head"><h3>Вложения ({items.length})</h3></div>
|
<div className="sec-head"><h3>Вложения ({items.length})</h3></div>
|
||||||
<ul className="att-list">
|
<ul className="att-list">
|
||||||
{newestFirst(items).map((a) => <Row key={a.attachment_id} a={a} dl={dl} />)}
|
{items.map((a) => <Row key={a.attachment_id} a={a} dl={dl} />)}
|
||||||
</ul>
|
</ul>
|
||||||
</section>
|
</section>
|
||||||
);
|
);
|
||||||
@ -31,9 +25,9 @@ export function AttachmentList({ items, dl }: { items: IssueAttachment[]; dl: Do
|
|||||||
function Row({ a, dl }: { a: IssueAttachment; dl: Download }) {
|
function Row({ a, dl }: { a: IssueAttachment; dl: Download }) {
|
||||||
const kind = fileKind(a.mime_type, a.filename);
|
const kind = fileKind(a.mime_type, a.filename);
|
||||||
const Icon = KIND_ICON[kind];
|
const Icon = KIND_ICON[kind];
|
||||||
const { busy, error: local } = dl.status(a.attachment_id);
|
const { busy, error: local, ok } = dl.status(a.attachment_id);
|
||||||
// ошибка: своя (последняя попытка в этой карточке) или команда, закончившаяся failed до открытия карточки
|
// ошибка: своя (последняя попытка в этой карточке) или команда, закончившаяся failed до открытия карточки
|
||||||
const error = busy ? null : local ?? (a.state === "failed" ? a.error ?? "не удалось скачать вложение" : null);
|
const error = busy ? null : local ?? (a.state === "failed" && !ok ? a.error ?? "не удалось скачать вложение" : null);
|
||||||
const who = [a.author, a.created ? fmtStamp(a.created) : null].filter(Boolean).join(" · ");
|
const who = [a.author, a.created ? fmtStamp(a.created) : null].filter(Boolean).join(" · ");
|
||||||
return (
|
return (
|
||||||
<li className="att-row">
|
<li className="att-row">
|
||||||
|
|||||||
@ -4,7 +4,7 @@ import type { ReactNode } from "react";
|
|||||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||||
import { makeQueryClient } from "../../../../api/queryClient.ts";
|
import { makeQueryClient } from "../../../../api/queryClient.ts";
|
||||||
import type { IssueAttachment } from "../../api/types.ts";
|
import type { IssueAttachment } from "../../api/types.ts";
|
||||||
import { setToken } from "../../../../auth/token.ts";
|
import { getToken, setToken } from "../../../../auth/token.ts";
|
||||||
import { json, mockFetch } from "../../../../test/fetchMock.ts";
|
import { json, mockFetch } from "../../../../test/fetchMock.ts";
|
||||||
import { useAttachmentDownload } from "./useAttachmentDownload.ts";
|
import { useAttachmentDownload } from "./useAttachmentDownload.ts";
|
||||||
|
|
||||||
@ -38,7 +38,7 @@ describe("useAttachmentDownload", () => {
|
|||||||
const { result } = renderHook(() => useAttachmentDownload("AURORA-1650"), { wrapper });
|
const { result } = renderHook(() => useAttachmentDownload("AURORA-1650"), { wrapper });
|
||||||
act(() => result.current.start(att));
|
act(() => result.current.start(att));
|
||||||
await flush();
|
await flush();
|
||||||
expect(result.current.status("31337")).toEqual({ busy: true, error: null });
|
expect(result.current.status("31337")).toEqual({ busy: true, error: null, ok: false });
|
||||||
expect(JSON.parse(String(f.mock.calls[0][1]?.body))).toEqual({ author: "web" });
|
expect(JSON.parse(String(f.mock.calls[0][1]?.body))).toEqual({ author: "web" });
|
||||||
await act(async () => { await vi.advanceTimersByTimeAsync(2000); });
|
await act(async () => { await vi.advanceTimersByTimeAsync(2000); });
|
||||||
expect(polls).toBe(1);
|
expect(polls).toBe(1);
|
||||||
@ -48,7 +48,7 @@ describe("useAttachmentDownload", () => {
|
|||||||
expect(URL.revokeObjectURL).not.toHaveBeenCalled(); // I-2: отзыв отложен
|
expect(URL.revokeObjectURL).not.toHaveBeenCalled(); // I-2: отзыв отложен
|
||||||
await act(async () => { await vi.advanceTimersByTimeAsync(60_000); });
|
await act(async () => { await vi.advanceTimersByTimeAsync(60_000); });
|
||||||
expect(URL.revokeObjectURL).toHaveBeenCalledWith("blob:fake");
|
expect(URL.revokeObjectURL).toHaveBeenCalledWith("blob:fake");
|
||||||
expect(result.current.status("31337")).toEqual({ busy: false, error: null });
|
expect(result.current.status("31337")).toEqual({ busy: false, error: null, ok: true });
|
||||||
await act(async () => { await vi.advanceTimersByTimeAsync(10000); });
|
await act(async () => { await vi.advanceTimersByTimeAsync(10000); });
|
||||||
expect(polls).toBe(2);
|
expect(polls).toBe(2);
|
||||||
expect(f.mock.calls.filter(([u]) => String(u).endsWith("/content"))).toHaveLength(1);
|
expect(f.mock.calls.filter(([u]) => String(u).endsWith("/content"))).toHaveLength(1);
|
||||||
@ -84,7 +84,7 @@ describe("useAttachmentDownload", () => {
|
|||||||
const n = f.mock.calls.length;
|
const n = f.mock.calls.length;
|
||||||
await act(async () => { await vi.advanceTimersByTimeAsync(10000); });
|
await act(async () => { await vi.advanceTimersByTimeAsync(10000); });
|
||||||
expect(f.mock.calls.length).toBe(n);
|
expect(f.mock.calls.length).toBe(n);
|
||||||
expect(result.current.status("31337")).toEqual({ busy: false, error: null });
|
expect(result.current.status("31337")).toEqual({ busy: false, error: null, ok: false });
|
||||||
});
|
});
|
||||||
|
|
||||||
it("409 — ошибка с текстом хаба", async () => {
|
it("409 — ошибка с текстом хаба", async () => {
|
||||||
@ -92,10 +92,29 @@ describe("useAttachmentDownload", () => {
|
|||||||
const { result } = renderHook(() => useAttachmentDownload("AURORA-1650"), { wrapper });
|
const { result } = renderHook(() => useAttachmentDownload("AURORA-1650"), { wrapper });
|
||||||
act(() => result.current.start(att));
|
act(() => result.current.start(att));
|
||||||
await flush();
|
await flush();
|
||||||
expect(result.current.status("31337")).toEqual({ busy: false, error: "агент v13 не умеет скачивать вложения — нужен v14" });
|
expect(result.current.status("31337")).toEqual({ busy: false, error: "агент v13 не умеет скачивать вложения — нужен v14", ok: false });
|
||||||
expect(clicked).toHaveLength(0);
|
expect(clicked).toHaveLength(0);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("401 при опросе — сброс токена, без текста ошибки", async () => {
|
||||||
|
mockFetch((m) => m === "POST" ? json(200, st("fetching")) : json(401, { error: "unauthorized" }));
|
||||||
|
const { result } = renderHook(() => useAttachmentDownload("AURORA-1650"), { wrapper });
|
||||||
|
act(() => result.current.start(att));
|
||||||
|
await flush();
|
||||||
|
await act(async () => { await vi.advanceTimersByTimeAsync(2000); });
|
||||||
|
expect(getToken()).toBe("");
|
||||||
|
expect(result.current.status("31337").error).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("сеть недоступна при опросе — «хаб не отвечает»", async () => {
|
||||||
|
mockFetch((m) => { if (m === "POST") return json(200, st("fetching")); throw new TypeError("fetch failed"); });
|
||||||
|
const { result } = renderHook(() => useAttachmentDownload("AURORA-1650"), { wrapper });
|
||||||
|
act(() => result.current.start(att));
|
||||||
|
await flush();
|
||||||
|
await act(async () => { await vi.advanceTimersByTimeAsync(2000); });
|
||||||
|
expect(result.current.status("31337").error).toBe("хаб не отвечает");
|
||||||
|
});
|
||||||
|
|
||||||
it("failed при опросе — ошибка команды; повторное start сбрасывает ошибку", async () => {
|
it("failed при опросе — ошибка команды; повторное start сбрасывает ошибку", async () => {
|
||||||
let n = 0;
|
let n = 0;
|
||||||
mockFetch((m) => m === "POST" ? json(200, st("fetching")) : json(200, ++n === 1 ? st("failed", { error: "Jira: 404" }) : st("fetching")));
|
mockFetch((m) => m === "POST" ? json(200, st("fetching")) : json(200, ++n === 1 ? st("failed", { error: "Jira: 404" }) : st("fetching")));
|
||||||
@ -103,9 +122,9 @@ describe("useAttachmentDownload", () => {
|
|||||||
act(() => result.current.start(att));
|
act(() => result.current.start(att));
|
||||||
await flush();
|
await flush();
|
||||||
await act(async () => { await vi.advanceTimersByTimeAsync(2000); });
|
await act(async () => { await vi.advanceTimersByTimeAsync(2000); });
|
||||||
expect(result.current.status("31337")).toEqual({ busy: false, error: "Jira: 404" });
|
expect(result.current.status("31337")).toEqual({ busy: false, error: "Jira: 404", ok: false });
|
||||||
act(() => result.current.start(att));
|
act(() => result.current.start(att));
|
||||||
await flush();
|
await flush();
|
||||||
expect(result.current.status("31337")).toEqual({ busy: true, error: null });
|
expect(result.current.status("31337")).toEqual({ busy: true, error: null, ok: false });
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@ -1,13 +1,13 @@
|
|||||||
import { useQueryClient } from "@tanstack/react-query";
|
import { useQueryClient } from "@tanstack/react-query";
|
||||||
import { useCallback, useEffect, useRef, useState } from "react";
|
import { useCallback, useEffect, useRef, useState } from "react";
|
||||||
import { ApiError } from "../../../../api/client.ts";
|
import { ApiError, AuthError, dropTokenOn, HubDownError } from "../../../../api/client.ts";
|
||||||
import { api } from "../../../../api/client.ts";
|
import { api } from "../../../../api/client.ts";
|
||||||
import { qk } from "../../api/queries.ts";
|
import { qk } from "../../api/queries.ts";
|
||||||
import type { AttachmentState, IssueAttachment } from "../../api/types.ts";
|
import type { AttachmentState, IssueAttachment } from "../../api/types.ts";
|
||||||
|
|
||||||
export const POLL_MS = 2000;
|
export const POLL_MS = 2000;
|
||||||
export interface DownloadStatus { busy: boolean; error: string | null }
|
export interface DownloadStatus { busy: boolean; error: string | null; ok: boolean } // ok — успех в этой карточке
|
||||||
const IDLE: DownloadStatus = { busy: false, error: null };
|
const IDLE: DownloadStatus = { busy: false, error: null, ok: false };
|
||||||
|
|
||||||
// браузер сохраняет файл под своим именем: Blob → временная ссылка download (токен в адресе не появляется)
|
// браузер сохраняет файл под своим именем: Blob → временная ссылка download (токен в адресе не появляется)
|
||||||
function save(blob: Blob, filename: string) {
|
function save(blob: Blob, filename: string) {
|
||||||
@ -56,7 +56,12 @@ export function useAttachmentDownload(issueKey: string) {
|
|||||||
if (mapRef.current[id]?.busy) return;
|
if (mapRef.current[id]?.busy) return;
|
||||||
const my = gen.current;
|
const my = gen.current;
|
||||||
const alive = () => gen.current === my;
|
const alive = () => gen.current === my;
|
||||||
const fail = (error: string) => { if (alive()) put(id, { busy: false, error }); };
|
const fail = (error: string) => { if (alive()) put(id, { busy: false, error, ok: false }); };
|
||||||
|
// 401 — экран входа без текста ошибки; сеть/5xx — «хаб не отвечает»; прочее — текст ошибки
|
||||||
|
const onError = (e: unknown) => {
|
||||||
|
if (e instanceof AuthError) { dropTokenOn(e); if (alive()) put(id, IDLE); }
|
||||||
|
else fail(e instanceof HubDownError ? "хаб не отвечает" : errorText(e));
|
||||||
|
};
|
||||||
|
|
||||||
// разбор состояния: ready — забрать файл, fetching — подождать и спросить снова, иначе — ошибка
|
// разбор состояния: ready — забрать файл, fetching — подождать и спросить снова, иначе — ошибка
|
||||||
const handle = async (st: AttachmentState): Promise<void> => {
|
const handle = async (st: AttachmentState): Promise<void> => {
|
||||||
@ -65,13 +70,13 @@ export function useAttachmentDownload(issueKey: string) {
|
|||||||
const blob = await api.blob(`/attachments/${encodeURIComponent(id)}/content`);
|
const blob = await api.blob(`/attachments/${encodeURIComponent(id)}/content`);
|
||||||
if (!alive()) return;
|
if (!alive()) return;
|
||||||
save(blob, a.filename);
|
save(blob, a.filename);
|
||||||
put(id, IDLE);
|
put(id, { busy: false, error: null, ok: true });
|
||||||
void qc.invalidateQueries({ queryKey: qk.issue(issueKey) }); // отметка «у хаба»
|
void qc.invalidateQueries({ queryKey: qk.issue(issueKey) }); // отметка «у хаба»
|
||||||
} else if (st.state === "fetching") {
|
} else if (st.state === "fetching") {
|
||||||
const t = setTimeout(() => {
|
const t = setTimeout(() => {
|
||||||
timers.current.delete(t);
|
timers.current.delete(t);
|
||||||
if (!alive()) return;
|
if (!alive()) return;
|
||||||
api.get<AttachmentState>(`/attachments/${encodeURIComponent(id)}`).then(handle).catch((e) => fail(errorText(e)));
|
api.get<AttachmentState>(`/attachments/${encodeURIComponent(id)}`).then(handle).catch(onError);
|
||||||
}, POLL_MS);
|
}, POLL_MS);
|
||||||
timers.current.add(t);
|
timers.current.add(t);
|
||||||
} else {
|
} else {
|
||||||
@ -79,9 +84,9 @@ export function useAttachmentDownload(issueKey: string) {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
put(id, { busy: true, error: null });
|
put(id, { busy: true, error: null, ok: false });
|
||||||
api.post<AttachmentState>(`/attachments/${encodeURIComponent(id)}/fetch`, { author: "web" })
|
api.post<AttachmentState>(`/attachments/${encodeURIComponent(id)}/fetch`, { author: "web" })
|
||||||
.then(handle).catch((e) => fail(errorText(e)));
|
.then(handle).catch(onError);
|
||||||
}, [issueKey, put, qc]);
|
}, [issueKey, put, qc]);
|
||||||
|
|
||||||
const status = useCallback((id: string): DownloadStatus => map[id] ?? IDLE, [map]);
|
const status = useCallback((id: string): DownloadStatus => map[id] ?? IDLE, [map]);
|
||||||
|
|||||||
@ -1,6 +1,6 @@
|
|||||||
import { describe, expect, it } from "vitest";
|
import { describe, expect, it } from "vitest";
|
||||||
import settings from "../board/__fixtures__/settings.json";
|
import settings from "../board/__fixtures__/settings.json";
|
||||||
import { groupLinks, isOverdue, todayISO } from "./card.ts";
|
import { groupLinks, isOverdue, splitKindLabels, todayISO } from "./card.ts";
|
||||||
|
|
||||||
const types = settings.link_types as Record<string, string>;
|
const types = settings.link_types as Record<string, string>;
|
||||||
const closed = new Set(["Готово", "Снято"]);
|
const closed = new Set(["Готово", "Снято"]);
|
||||||
@ -50,3 +50,13 @@ describe("просрочка", () => {
|
|||||||
expect(todayISO(new Date(2026, 0, 5, 23, 30))).toBe("2026-01-05");
|
expect(todayISO(new Date(2026, 0, 5, 23, 30))).toBe("2026-01-05");
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe("splitKindLabels", () => {
|
||||||
|
it("метки видов работ отдельно от прочих", () => {
|
||||||
|
expect(splitKindLabels(["AWPCA", "kind-dev", "kind-ui"], { разработка: "kind-dev", UI: "kind-ui" }))
|
||||||
|
.toEqual({ kinds: ["разработка", "UI"], rest: ["AWPCA"] });
|
||||||
|
});
|
||||||
|
it("без kind_labels — все метки прочие (Review Focus 4)", () => {
|
||||||
|
expect(splitKindLabels(["AWPCA", "kind-dev"], undefined)).toEqual({ kinds: [], rest: ["AWPCA", "kind-dev"] });
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@ -31,3 +31,14 @@ export function isOverdue(due: string, status: string | null | undefined, closed
|
|||||||
|
|
||||||
export const todayISO = (d = new Date()): string =>
|
export const todayISO = (d = new Date()): string =>
|
||||||
`${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, "0")}-${String(d.getDate()).padStart(2, "0")}`;
|
`${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, "0")}-${String(d.getDate()).padStart(2, "0")}`;
|
||||||
|
|
||||||
|
// метки задачи: виды работ (метка из kind_labels → название вида) отдельно от прочих меток
|
||||||
|
export function splitKindLabels(labels: string[], kindLabels: Record<string, string> | undefined): { kinds: string[]; rest: string[] } {
|
||||||
|
const byLabel = new Map(Object.entries(kindLabels ?? {}).map(([kind, label]) => [label, kind]));
|
||||||
|
const kinds: string[] = [], rest: string[] = [];
|
||||||
|
for (const l of labels) {
|
||||||
|
const k = byLabel.get(l);
|
||||||
|
if (k !== undefined) kinds.push(k); else rest.push(l);
|
||||||
|
}
|
||||||
|
return { kinds, rest };
|
||||||
|
}
|
||||||
|
|||||||
@ -34,16 +34,31 @@ export function useIssueOverlay(): Overlay {
|
|||||||
void navigate(ISSUES_PATH + direct.search, { replace: true });
|
void navigate(ISSUES_PATH + direct.search, { replace: true });
|
||||||
}
|
}
|
||||||
}, [card, navigate, direct.search]);
|
}, [card, navigate, direct.search]);
|
||||||
|
// повторное закрытие (двойной щелчок по ✕) на той же записи истории игнорируется (#42 M-2)
|
||||||
|
const closingKey = useRef<string | null>(null);
|
||||||
|
useEffect(() => { closingKey.current = null; }, [location.key]);
|
||||||
const close = useCallback(() => {
|
const close = useCallback(() => {
|
||||||
|
if (closingKey.current === location.key) return;
|
||||||
|
closingKey.current = location.key;
|
||||||
if (!card || card.depth === 0) { void navigate(ISSUES_PATH + direct.search, { replace: true }); return; }
|
if (!card || card.depth === 0) { void navigate(ISSUES_PATH + direct.search, { replace: true }); return; }
|
||||||
closingDirect.current = card.direct;
|
closingDirect.current = card.direct;
|
||||||
void navigate(-card.depth);
|
void navigate(-card.depth);
|
||||||
}, [card, navigate, direct.search]);
|
}, [card, navigate, direct.search, location.key]);
|
||||||
|
|
||||||
const abortClose = useCallback(() => { closingDirect.current = false; }, []);
|
const abortClose = useCallback(() => { closingDirect.current = false; closingKey.current = null; }, []);
|
||||||
|
|
||||||
|
// «Назад» в окне — только если оно открыто переходом из другой карточки (из списка depth = 1; прямой адрес — direct, depth 0)
|
||||||
|
// повторный щелчок на той же записи истории игнорируется тем же closingKey (M-1)
|
||||||
|
const onBack = card && (card.depth >= 2 || (card.direct && card.depth >= 1))
|
||||||
|
? () => {
|
||||||
|
if (closingKey.current === location.key) return;
|
||||||
|
closingKey.current = location.key;
|
||||||
|
void navigate(-1);
|
||||||
|
}
|
||||||
|
: undefined;
|
||||||
|
|
||||||
const element = issueKey
|
const element = issueKey
|
||||||
? <CloseAbortContext value={abortClose}><IssueModal issueKey={issueKey} onClose={close} /></CloseAbortContext>
|
? <CloseAbortContext value={abortClose}><IssueModal issueKey={issueKey} onClose={close} onBack={onBack} /></CloseAbortContext>
|
||||||
: null;
|
: null;
|
||||||
return { pageLocation, element };
|
return { pageLocation, element };
|
||||||
}
|
}
|
||||||
|
|||||||
@ -89,13 +89,17 @@ describe("parseInline: ссылки на вложения", () => {
|
|||||||
expect(parseInline("см. !скрин 1.png|thumbnail!", A)).toEqual([t("см. "), att("31337", t("скрин 1.png"))]);
|
expect(parseInline("см. !скрин 1.png|thumbnail!", A)).toEqual([t("см. "), att("31337", t("скрин 1.png"))]);
|
||||||
expect(parseInline("!скрин 1.png!", A)).toEqual([att("31337", t("скрин 1.png"))]);
|
expect(parseInline("!скрин 1.png!", A)).toEqual([att("31337", t("скрин 1.png"))]);
|
||||||
});
|
});
|
||||||
|
it("два вложения с одним именем — первое в порядке хаба, даже если created null", () => {
|
||||||
|
const o = { attachments: [{ id: "5", filename: "скрин.png", created: null }, { id: "9", filename: "скрин.png", created: "2026-10-20T10:00:00.000+0300" }] };
|
||||||
|
expect(parseInline("!скрин.png!", o)).toEqual([att("5", t("скрин.png"))]);
|
||||||
|
});
|
||||||
it("[^имя] и [текст|^имя]", () => {
|
it("[^имя] и [текст|^имя]", () => {
|
||||||
expect(parseInline("[^a.pdf]", A)).toEqual([att("31338", t("a.pdf"))]);
|
expect(parseInline("[^a.pdf]", A)).toEqual([att("31338", t("a.pdf"))]);
|
||||||
expect(parseInline("[отчёт|^a.pdf]", A)).toEqual([att("31338", t("отчёт"))]);
|
expect(parseInline("[отчёт|^a.pdf]", A)).toEqual([att("31338", t("отчёт"))]);
|
||||||
});
|
});
|
||||||
it("имя точно (с регистром); несколько с одним именем — самое новое", () => {
|
it("имя точно (с регистром); несколько с одним именем — первое в порядке хаба", () => {
|
||||||
expect(parseInline("!Скрин 1.png!", A)).toEqual([t("!Скрин 1.png!")]);
|
expect(parseInline("!Скрин 1.png!", A)).toEqual([t("!Скрин 1.png!")]);
|
||||||
expect(parseInline("!дубль.png!", A)).toEqual([att("20", t("дубль.png"))]);
|
expect(parseInline("!дубль.png!", A)).toEqual([att("10", t("дубль.png"))]); // первое в порядке хаба
|
||||||
});
|
});
|
||||||
it("имени нет среди вложений — исходник как есть", () => {
|
it("имени нет среди вложений — исходник как есть", () => {
|
||||||
expect(parseInline("!нет.png!", A)).toEqual([t("!нет.png!")]);
|
expect(parseInline("!нет.png!", A)).toEqual([t("!нет.png!")]);
|
||||||
@ -116,4 +120,10 @@ describe("parseInline: ссылки на вложения", () => {
|
|||||||
it("внутри подписи ссылки вложения нет (кнопка в ссылке недопустима)", () => {
|
it("внутри подписи ссылки вложения нет (кнопка в ссылке недопустима)", () => {
|
||||||
expect(parseInline("[!a.pdf!|https://x.y]", A)).toEqual([link("https://x.y", t("!a.pdf!"))]);
|
expect(parseInline("[!a.pdf!|https://x.y]", A)).toEqual([link("https://x.y", t("!a.pdf!"))]);
|
||||||
});
|
});
|
||||||
|
it("\\# — символ решётки (M-5)", () => {
|
||||||
|
expect(parseInline("\\# не пункт")).toEqual([{ t: "text", text: "# не пункт" }]);
|
||||||
|
});
|
||||||
|
it("[~логин|текст] — упоминание логина без подписи (M-6)", () => {
|
||||||
|
expect(parseInline("[~ivanov|Иванов]")).toEqual([{ t: "mention", login: "ivanov" }]);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@ -1,9 +1,10 @@
|
|||||||
// Строчная вики-разметка Jira (design wiki-render #4, #5): выделение, {{моно}}, ссылки, упоминания, ключи задач.
|
// Строчная вики-разметка Jira (design wiki-render #4, #5): выделение, {{моно}}, ссылки, упоминания, ключи задач.
|
||||||
// Вход — одна строка без переводов строк; разбор не бросает исключений, нераспознанное остаётся текстом как есть.
|
// Вход — одна строка без переводов строк; разбор не бросает исключений, нераспознанное остаётся текстом как есть.
|
||||||
|
|
||||||
// вложения задачи для разбора ссылок `!имя!`, `[^имя]` (как у хаба: имя точно, при повторе имени — самое новое)
|
// вложения задачи для разбора ссылок `!имя!`, `[^имя]` (как у хаба: имя точно, при повторе имени — первое в порядке хаба, новые сверху)
|
||||||
export interface WikiAttachment { id: string; filename: string; created: string | null }
|
export interface WikiAttachment { id: string; filename: string; created: string | null }
|
||||||
export interface WikiOptions { projectKey?: string; attachments?: WikiAttachment[] }
|
// anyAttachment — только для плоского текста (wikiPlain): `!имя!` и `[^имя]` — вложение, даже если имени нет в списке
|
||||||
|
export interface WikiOptions { projectKey?: string; attachments?: WikiAttachment[]; anyAttachment?: boolean }
|
||||||
|
|
||||||
type Mark = "strong" | "em" | "del" | "ins" | "sup" | "sub";
|
type Mark = "strong" | "em" | "del" | "ins" | "sup" | "sub";
|
||||||
|
|
||||||
@ -18,13 +19,15 @@ export type Inline =
|
|||||||
| { t: "br" };
|
| { t: "br" };
|
||||||
|
|
||||||
const MARKS: Record<string, Mark> = { "*": "strong", _: "em", "-": "del", "+": "ins", "^": "sup", "~": "sub" };
|
const MARKS: Record<string, Mark> = { "*": "strong", _: "em", "-": "del", "+": "ins", "^": "sup", "~": "sub" };
|
||||||
const ESCAPABLE = new Set([..."*_-+^~{}[]|!\\"]);
|
const ESCAPABLE = new Set([..."*_-+^~{}[]|!#\\"]);
|
||||||
const WORD = /[\p{L}\p{N}]/u;
|
const WORD = /[\p{L}\p{N}]/u;
|
||||||
const SPACE = /\s/;
|
const SPACE = /\s/;
|
||||||
const URL = /https?:\/\/[^\s\]|<>"]+/y;
|
const URL = /https?:\/\/[^\s\]|<>"]+/y;
|
||||||
const URL_TAIL = /[.,;:!?)]+$/;
|
const URL_TAIL = /[.,;:!?)]+$/;
|
||||||
const IMAGE = /!([^!|\n]+)(?:\|[^!\n]*)?!/y;
|
const IMAGE = /!([^!|\n]+)(?:\|[^!\n]*)?!/y;
|
||||||
const ATT_LINK = /^(?:([^|]*)\|)?\^(.+)$/;
|
const ATT_LINK = /^(?:([^|]*)\|)?\^(.+)$/;
|
||||||
|
// имя файла с расширением, без пробелов по краям: только такое `!имя!` без списка вложений — вложение (N-1)
|
||||||
|
const FILE_NAME = /^\S(?:.*\S)?\.[\p{L}\p{N}]{1,10}$/u;
|
||||||
|
|
||||||
// схема ссылки — только http, https, mailto (спека «Разметка Jira в карточке»); иначе ссылки нет
|
// схема ссылки — только http, https, mailto (спека «Разметка Jira в карточке»); иначе ссылки нет
|
||||||
function safeHref(raw: string): string | null {
|
function safeHref(raw: string): string | null {
|
||||||
@ -32,16 +35,11 @@ function safeHref(raw: string): string | null {
|
|||||||
return /^(https?:\/\/|mailto:)\S/i.test(href) ? href : null;
|
return /^(https?:\/\/|mailto:)\S/i.test(href) ? href : null;
|
||||||
}
|
}
|
||||||
|
|
||||||
// вложение по имени: несколько с одним именем — самое новое по created, затем по числовому id
|
// вложение по имени: несколько с одним именем — первое в порядке хаба; image — `!имя!`, без списка только имя файла,
|
||||||
function findAttachment(opts: WikiOptions, name: string): string | null {
|
// иначе «Срочно! Проверьте!» в ленте теряет «!» (N-1)
|
||||||
const same = (opts.attachments ?? []).filter((a) => a.filename === name);
|
function findAttachment(opts: WikiOptions, name: string, image = false): string | null {
|
||||||
if (!same.length) return null;
|
const found = (opts.attachments ?? []).find((a) => a.filename === name)?.id;
|
||||||
const rank = (a: WikiAttachment): [number, number] => [a.created ? Date.parse(a.created) || 0 : 0, Number(a.id) || 0];
|
return found ?? (opts.anyAttachment && (!image || FILE_NAME.test(name)) ? "" : null);
|
||||||
return same.reduce((best, a) => {
|
|
||||||
const [bt, bi] = rank(best);
|
|
||||||
const [at, ai] = rank(a);
|
|
||||||
return at > bt || (at === bt && ai > bi) ? a : best;
|
|
||||||
}).id;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function keyPattern(projectKey: string | undefined): RegExp | null {
|
function keyPattern(projectKey: string | undefined): RegExp | null {
|
||||||
@ -88,11 +86,11 @@ export function parseInline(line: string, opts: WikiOptions = {}, label = false)
|
|||||||
text(c); i += 1; continue;
|
text(c); i += 1; continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (c === "!" && !label && opts.attachments?.length) {
|
if (c === "!" && !label && (opts.attachments?.length || opts.anyAttachment)) {
|
||||||
IMAGE.lastIndex = i;
|
IMAGE.lastIndex = i;
|
||||||
const m = IMAGE.exec(line);
|
const m = IMAGE.exec(line);
|
||||||
const id = m && !m[1].includes("://") ? findAttachment(opts, m[1]) : null;
|
const id = m && !m[1].includes("://") ? findAttachment(opts, m[1], true) : null;
|
||||||
if (m && id) { out.push({ t: "attachment", id, children: [{ t: "text", text: m[1] }] }); i += m[0].length; continue; }
|
if (m && id !== null) { out.push({ t: "attachment", id, children: [{ t: "text", text: m[1] }] }); i += m[0].length; continue; }
|
||||||
}
|
}
|
||||||
|
|
||||||
if (c === "h" && !label && prevOk(i)) {
|
if (c === "h" && !label && prevOk(i)) {
|
||||||
@ -147,13 +145,13 @@ function closing(line: string, i: number): number {
|
|||||||
// содержимое [...]: упоминание, ключ задачи, ссылка с проверенной схемой; иначе null — текст как есть
|
// содержимое [...]: упоминание, ключ задачи, ссылка с проверенной схемой; иначе null — текст как есть
|
||||||
function bracket(body: string, opts: WikiOptions, isKey: (s: string) => boolean): Inline | null {
|
function bracket(body: string, opts: WikiOptions, isKey: (s: string) => boolean): Inline | null {
|
||||||
if (body.startsWith("~")) {
|
if (body.startsWith("~")) {
|
||||||
const login = body.slice(1).trim();
|
const login = body.slice(1).split("|")[0].trim();
|
||||||
return login && !SPACE.test(login) ? { t: "mention", login } : null;
|
return login && !SPACE.test(login) ? { t: "mention", login } : null;
|
||||||
}
|
}
|
||||||
const ref = ATT_LINK.exec(body);
|
const ref = ATT_LINK.exec(body);
|
||||||
if (ref) {
|
if (ref) {
|
||||||
const id = findAttachment(opts, ref[2]);
|
const id = findAttachment(opts, ref[2]);
|
||||||
return id ? { t: "attachment", id, children: ref[1] ? parseInline(ref[1], opts, true) : [{ t: "text", text: ref[2] }] } : null;
|
return id !== null ? { t: "attachment", id, children: ref[1] ? parseInline(ref[1], opts, true) : [{ t: "text", text: ref[2] }] } : null;
|
||||||
}
|
}
|
||||||
const bar = body.indexOf("|");
|
const bar = body.indexOf("|");
|
||||||
const label = bar >= 0 ? body.slice(0, bar) : "";
|
const label = bar >= 0 ? body.slice(0, bar) : "";
|
||||||
|
|||||||
@ -77,4 +77,34 @@ describe("parseWiki: блоки-макросы", () => {
|
|||||||
it("{quote} не в начале строки — текст (R-8)", () => expect(parseWiki("см. {quote}x{quote}")).toEqual([p(t("см. {quote}x{quote}"))]));
|
it("{quote} не в начале строки — текст (R-8)", () => expect(parseWiki("см. {quote}x{quote}")).toEqual([p(t("см. {quote}x{quote}"))]));
|
||||||
it.each(["{color:red}важно{color}", "!схема.png!", "{panel}x{panel}"])("неизвестное %s — текст как есть", (src) =>
|
it.each(["{color:red}важно{color}", "!схема.png!", "{panel}x{panel}"])("неизвестное %s — текст как есть", (src) =>
|
||||||
expect(parseWiki(src)).toEqual([p(t(src))]));
|
expect(parseWiki(src)).toEqual([p(t(src))]));
|
||||||
|
it("\\# в начале строки — абзац, не список", () => {
|
||||||
|
expect(parseWiki("\\# не пункт")).toEqual([{ t: "p", children: [{ t: "text", text: "# не пункт" }] }]);
|
||||||
|
});
|
||||||
|
it("quote и noformat с параметрами (M-5)", () => {
|
||||||
|
expect(parseWiki("{quote:title=Итог}\nсогласовано\n{quote}")).toEqual([{ t: "quote", children: [{ t: "p", children: [{ t: "text", text: "согласовано" }] }] }]);
|
||||||
|
expect(parseWiki("{noformat:borderStyle=solid}\n*x*\n{noformat}")).toEqual([{ t: "pre", text: "*x*" }]);
|
||||||
|
});
|
||||||
|
it("пробелы после открывающего {code} не дают пустой строки (M-6)", () => {
|
||||||
|
expect(parseWiki("{code} \nx = 1\n{code}")).toEqual([{ t: "pre", text: "x = 1" }]);
|
||||||
|
});
|
||||||
|
it("непарная [ в ячейке не съедает разделители (M-6)", () => {
|
||||||
|
const [table] = parseWiki("|[draft|итог|готово|");
|
||||||
|
expect(table).toMatchObject({ t: "table" });
|
||||||
|
expect((table as { rows: { children: { text: string }[] }[][] }).rows[0].map((c) => c.children[0].text)).toEqual(["[draft", "итог", "готово"]);
|
||||||
|
});
|
||||||
|
it("непарная [ не берёт чужую ] из ссылки в следующей ячейке (T1.1)", () => {
|
||||||
|
const [table] = parseWiki("|[draft|итог|[док|https://x.ru]|");
|
||||||
|
const cells = (table as { rows: { children: Inline[] }[][] }).rows[0];
|
||||||
|
expect(cells).toHaveLength(3);
|
||||||
|
expect(cells[0].children[0]).toEqual({ t: "text", text: "[draft" });
|
||||||
|
expect(cells[1].children[0]).toEqual({ t: "text", text: "итог" });
|
||||||
|
expect(cells[2].children[0]).toMatchObject({ t: "link", href: "https://x.ru" });
|
||||||
|
});
|
||||||
|
it("{code} {code} в одной строке — пустой блок, не pre из пробелов (T1.2)", () => {
|
||||||
|
expect(parseWiki("{code} {code}")).toEqual([{ t: "pre", text: "" }]);
|
||||||
|
});
|
||||||
|
it("| внутри [текст|адрес] ячейку по-прежнему не делит", () => {
|
||||||
|
const [table] = parseWiki("|[док|https://x.ru]|b|");
|
||||||
|
expect((table as { rows: unknown[][] }).rows[0]).toHaveLength(2);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@ -14,7 +14,7 @@ export type Block =
|
|||||||
| { t: "quote"; children: Block[] }
|
| { t: "quote"; children: Block[] }
|
||||||
| { t: "hr" };
|
| { t: "hr" };
|
||||||
|
|
||||||
const MACRO = /^\s*\{(code(?::[^}]*)?|noformat|quote)\}/;
|
const MACRO = /^\s*\{(code|noformat|quote)(?::[^}]*)?\}/;
|
||||||
const HEADING = /^\s*h([1-6])\.\s+(.*)$/;
|
const HEADING = /^\s*h([1-6])\.\s+(.*)$/;
|
||||||
const BQ = /^\s*bq\.\s+(.*)$/;
|
const BQ = /^\s*bq\.\s+(.*)$/;
|
||||||
const HR = /^\s*----\s*$/;
|
const HR = /^\s*----\s*$/;
|
||||||
@ -101,15 +101,16 @@ function blocks(src: string[], opts: WikiOptions): Block[] {
|
|||||||
|
|
||||||
// закрывающий тег макроса в той же строке после открывающего или в последующих; null — не закрыт
|
// закрывающий тег макроса в той же строке после открывающего или в последующих; null — не закрыт
|
||||||
function closeMacro(lines: string[], start: number, open: RegExpExecArray): { name: string; body: string[]; end: number; after: string } | null {
|
function closeMacro(lines: string[], start: number, open: RegExpExecArray): { name: string; body: string[]; end: number; after: string } | null {
|
||||||
const name = open[1].split(":")[0];
|
const name = open[1];
|
||||||
const tag = `{${name}}`;
|
const tag = `{${name}}`;
|
||||||
const rest = lines[start].slice(open[0].length);
|
const rest = lines[start].slice(open[0].length);
|
||||||
const body: string[] = [];
|
const body: string[] = [];
|
||||||
for (let k = start; k < lines.length; k++) {
|
for (let k = start; k < lines.length; k++) {
|
||||||
const text = k === start ? rest : lines[k];
|
const text = k === start ? rest : lines[k];
|
||||||
const at = text.indexOf(tag);
|
const at = text.indexOf(tag);
|
||||||
if (at < 0) { if (k !== start || text) body.push(text); continue; }
|
if (at < 0) { if (k !== start || text.trim()) body.push(text); continue; }
|
||||||
if (text.slice(0, at)) body.push(text.slice(0, at));
|
const head = text.slice(0, at);
|
||||||
|
if (head && (k !== start || head.trim())) body.push(head);
|
||||||
return { name, body, end: k, after: text.slice(at + tag.length) };
|
return { name, body, end: k, after: text.slice(at + tag.length) };
|
||||||
}
|
}
|
||||||
return null;
|
return null;
|
||||||
@ -126,7 +127,7 @@ function row(line: string, opts: WikiOptions): Cell[] {
|
|||||||
let depth = 0;
|
let depth = 0;
|
||||||
// `|` внутри [ссылки|адрес] ячейку не режет
|
// `|` внутри [ссылки|адрес] ячейку не режет
|
||||||
while (j < s.length && (s[j] !== "|" || depth > 0)) {
|
while (j < s.length && (s[j] !== "|" || depth > 0)) {
|
||||||
if (s[j] === "[") depth++;
|
if (s[j] === "[") { if (/^[^[\]]*\]/.test(s.slice(j + 1))) depth++; }
|
||||||
else if (s[j] === "]" && depth > 0) depth--;
|
else if (s[j] === "]" && depth > 0) depth--;
|
||||||
j++;
|
j++;
|
||||||
}
|
}
|
||||||
|
|||||||
32
web/src/modules/jira/issues/wiki/plain.test.ts
Normal file
32
web/src/modules/jira/issues/wiki/plain.test.ts
Normal file
@ -0,0 +1,32 @@
|
|||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
import { wikiPlain } from "./plain.ts";
|
||||||
|
|
||||||
|
const norm = (s: string) => s.replace(/\s+/g, " ").trim();
|
||||||
|
|
||||||
|
describe("wikiPlain", () => {
|
||||||
|
it("выделение, ссылки, упоминание, заголовок", () => {
|
||||||
|
expect(norm(wikiPlain("h3. Итог\n*Готово*, см. [протокол|https://x.ru/p] и [~ivanov]"))).toBe("Итог Готово, см. протокол и @ivanov");
|
||||||
|
});
|
||||||
|
it("[адрес] — сам адрес, {{моно}} и {code} — содержимое, ---- — пусто", () => {
|
||||||
|
expect(norm(wikiPlain("[https://x.ru] {{a_b}}\n----\n{code}\nx = 1\n{code}"))).toBe("https://x.ru a_b x = 1");
|
||||||
|
});
|
||||||
|
it("ключ задачи и вложение — текстом", () => {
|
||||||
|
const opts = { projectKey: "AURORA", attachments: [{ id: "1", filename: "скрин.png", created: null }] };
|
||||||
|
expect(wikiPlain("см. AURORA-1 и !скрин.png!", opts).trim()).toBe("см. AURORA-1 и скрин.png");
|
||||||
|
});
|
||||||
|
it("вложение, которого нет в списке, — именем (I-1)", () => {
|
||||||
|
expect(wikiPlain("!скрин.png|width=50! [текст|^отчёт.pdf] [^а.txt]").trim()).toBe("скрин.png текст а.txt");
|
||||||
|
});
|
||||||
|
it("«!» в обычном тексте без списка вложений остаются (N-1)", () => {
|
||||||
|
expect(wikiPlain("Срочно! Проверьте! Ок").trim()).toBe("Срочно! Проверьте! Ок");
|
||||||
|
expect(wikiPlain("Готово! см. лог.txt!").trim()).toBe("Готово! см. лог.txt!");
|
||||||
|
expect(wikiPlain("Ура!Сделано!").trim()).toBe("Ура!Сделано!");
|
||||||
|
});
|
||||||
|
it("незакрытая и неизвестная разметка — как есть, пустой текст — пусто (Review Focus 3)", () => {
|
||||||
|
expect(wikiPlain("{color:red}важно *без конца").trim()).toBe("{color:red}важно *без конца");
|
||||||
|
expect(wikiPlain("")).toBe("");
|
||||||
|
});
|
||||||
|
it("списки, таблицы, цитаты — содержимое через пробел", () => {
|
||||||
|
expect(norm(wikiPlain("* a\n** b\n# c\n||h1||h2||\n|x|y|\nbq. цит"))).toBe("a b c h1 h2 x y цит");
|
||||||
|
});
|
||||||
|
});
|
||||||
35
web/src/modules/jira/issues/wiki/plain.ts
Normal file
35
web/src/modules/jira/issues/wiki/plain.ts
Normal file
@ -0,0 +1,35 @@
|
|||||||
|
// Текст вики-разметки Jira без разметки — как читается отрендеренным (лента изменений, С6).
|
||||||
|
// Блоки соединены пробелом; «одну строку» и обрезку делает вызывающий.
|
||||||
|
import type { Inline, WikiOptions } from "./inline.ts";
|
||||||
|
import { type Block, type ListBlock, parseWiki } from "./parse.ts";
|
||||||
|
|
||||||
|
function inline(nodes: Inline[]): string {
|
||||||
|
return nodes.map((n): string => {
|
||||||
|
switch (n.t) {
|
||||||
|
case "text": case "code": return n.text;
|
||||||
|
case "mention": return "@" + n.login;
|
||||||
|
case "br": return " ";
|
||||||
|
case "issue": return n.children.length ? inline(n.children) : n.key;
|
||||||
|
default: return inline(n.children);
|
||||||
|
}
|
||||||
|
}).join("");
|
||||||
|
}
|
||||||
|
|
||||||
|
function list(l: ListBlock): string[] {
|
||||||
|
return l.items.flatMap((it) => [inline(it.children), ...it.lists.flatMap(list)]);
|
||||||
|
}
|
||||||
|
|
||||||
|
function block(b: Block): string[] {
|
||||||
|
switch (b.t) {
|
||||||
|
case "heading": case "p": return [inline(b.children)];
|
||||||
|
case "list": return list(b);
|
||||||
|
case "table": return b.rows.flatMap((r) => r.map((c) => inline(c.children)));
|
||||||
|
case "pre": return [b.text];
|
||||||
|
case "quote": return b.children.flatMap(block);
|
||||||
|
case "hr": return [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function wikiPlain(text: string, opts: WikiOptions = {}): string {
|
||||||
|
return parseWiki(text, { ...opts, anyAttachment: true }).flatMap(block).join(" ");
|
||||||
|
}
|
||||||
@ -1,15 +1,18 @@
|
|||||||
import type { ReactNode } from "react";
|
import type { ReactNode } from "react";
|
||||||
import type { Project } from "../api/types.ts";
|
import type { Project } from "../api/types.ts";
|
||||||
import { Button, Notice } from "../ui/index.ts";
|
import { Button, Notice } from "../ui/index.ts";
|
||||||
|
import { useProjects } from "../api/queries.ts";
|
||||||
import { useProjectContext } from "./ProjectContext.ts";
|
import { useProjectContext } from "./ProjectContext.ts";
|
||||||
import "./projects.css";
|
import "./projects.css";
|
||||||
|
|
||||||
// страница доски/списка показывается, только когда текущий проект определён; иначе — причина и что делать
|
// страница доски/списка показывается, только когда текущий проект определён; иначе — причина и что делать
|
||||||
export function ProjectGate({ children }: { children: (p: Project) => ReactNode }) {
|
export function ProjectGate({ children }: { children: (p: Project) => ReactNode }) {
|
||||||
const { state, openNew } = useProjectContext();
|
const { state, openNew } = useProjectContext();
|
||||||
|
const { refetch } = useProjects();
|
||||||
|
const retry = () => { void refetch(); };
|
||||||
switch (state.kind) {
|
switch (state.kind) {
|
||||||
case "loading": return <div className="project-gate"><Notice tone="info">Загрузка проектов…</Notice></div>;
|
case "loading": return <div className="project-gate"><Notice tone="info">Загрузка проектов…</Notice></div>;
|
||||||
case "error": return <div className="project-gate"><Notice tone="error">Проекты не загрузились: {state.error.message}</Notice></div>;
|
case "error": return <div className="project-gate"><Notice tone="error" action={<Button onClick={retry}>Повторить</Button>}>Проекты не загрузились: {state.error.message}</Notice></div>;
|
||||||
case "unknown":
|
case "unknown":
|
||||||
return <div className="project-gate" data-testid="project-unknown">
|
return <div className="project-gate" data-testid="project-unknown">
|
||||||
<Notice tone="warning" title={`проект ${state.label} не найден`}>выберите проект в списке «Проект» боковой панели (на узком экране — в «Меню»)</Notice></div>;
|
<Notice tone="warning" title={`проект ${state.label} не найден`}>выберите проект в списке «Проект» боковой панели (на узком экране — в «Меню»)</Notice></div>;
|
||||||
|
|||||||
@ -263,5 +263,15 @@ describe("адреса модулей и заголовок вкладки", ()
|
|||||||
void router.navigate("/?project=AWPCA");
|
void router.navigate("/?project=AWPCA");
|
||||||
await waitFor(() => expect(document.title).toBe("Обзор · AWPCA · SDLC Hub"));
|
await waitFor(() => expect(document.title).toBe("Обзор · AWPCA · SDLC Hub"));
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("ошибка загрузки проектов: «Повторить» перезапрашивает список", async () => {
|
||||||
|
let n = 0;
|
||||||
|
const h = hub([A, S], { "GET /projects": () => (++n === 1 ? json(500, { error: "boom" }) : json(200, { projects: [A, S] })) });
|
||||||
|
open("/app/jira/links?project=AWPCA");
|
||||||
|
expect(await screen.findByText(/Проекты не загрузились/)).toBeInTheDocument();
|
||||||
|
await userEvent.click(screen.getByRole("button", { name: "Повторить" }));
|
||||||
|
await waitFor(() => expect(screen.queryByText(/Проекты не загрузились/)).not.toBeInTheDocument());
|
||||||
|
await waitFor(() => expect(h.get("/board").length).toBeGreaterThan(0));
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@ -124,7 +124,8 @@ function useTracker(m: (typeof TRACKED)[number], { qc, project, pollMs, agents }
|
|||||||
}, [qc, status, setActiveNow]);
|
}, [qc, status, setActiveNow]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!active) return;
|
// #116: ответ обрабатывается, только если отслеживаемый пакет — тот же объект, что в activeRef (эффект смены проекта объявлен выше и в том же коммите обнуляет activeRef)
|
||||||
|
if (!active || activeRef.current !== active) return;
|
||||||
const b = batch.data, e = batch.error;
|
const b = batch.data, e = batch.error;
|
||||||
if (b && b.id === active.id && !batch.isError) {
|
if (b && b.id === active.id && !batch.isError) {
|
||||||
if (terminal(b)) {
|
if (terminal(b)) {
|
||||||
|
|||||||
@ -337,6 +337,41 @@ describe("ActivityProvider", () => {
|
|||||||
expect(m.now().jira.event).toMatchObject({ kind: "batch", id: 5 });
|
expect(m.now().jira.event).toMatchObject({ kind: "batch", id: 5 });
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("#116: ответ опроса прежнего проекта, пришедший в коммите смены проекта, не возвращает событие", async () => {
|
||||||
|
const late = deferred<void>();
|
||||||
|
let polls = 0, delivered = false;
|
||||||
|
hub({
|
||||||
|
...listing((mod, project) => (mod === "wiki" && project === "AWPCA" ? [item(9, "wiki")] : [])),
|
||||||
|
"GET /batches/:id": async () => {
|
||||||
|
if (++polls > 1) { await late.promise; delivered = true; } // первый ответ показан сразу, следующий держится до смены проекта
|
||||||
|
return batch("running", { type: "wiki_sync" }, { id: 9, module: "wiki" });
|
||||||
|
},
|
||||||
|
});
|
||||||
|
const m = mount({ pollMs: 5 });
|
||||||
|
await waitFor(() => expect(m.now().wiki.event).toMatchObject({ kind: "batch", id: 9 }));
|
||||||
|
await waitFor(() => expect(polls).toBeGreaterThanOrEqual(2)); // повторный опрос ушёл и висит
|
||||||
|
// смена проекта и ответ опроса — одним коммитом: оба обновления копятся в act и рендерятся вместе на выходе из него
|
||||||
|
await act(async () => { m.setProject("STOCKS_DRRK"); late.resolve(); await sleep(20); });
|
||||||
|
expect(delivered).toBe(true); // отложенный ответ действительно ушёл в обработчик
|
||||||
|
await waitFor(() => expect(m.now().wiki.event).toBeNull());
|
||||||
|
await sleep(50);
|
||||||
|
expect(m.now().wiki.event).toBeNull();
|
||||||
|
expect(m.now().wiki.busy).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("#116: A → B → A — после возврата на AWPCA незавершённый пакет 9 снова подхватывается", async () => {
|
||||||
|
hub({
|
||||||
|
...listing((mod, project) => (mod === "wiki" && project === "AWPCA" ? [item(9, "wiki")] : [])),
|
||||||
|
...byId({ 9: "wiki_sync" }, () => "running"),
|
||||||
|
});
|
||||||
|
const m = mount();
|
||||||
|
await waitFor(() => expect(m.now().wiki.event).toMatchObject({ kind: "batch", id: 9 }));
|
||||||
|
m.setProject("STOCKS_DRRK");
|
||||||
|
await waitFor(() => expect(m.now().wiki.event).toBeNull());
|
||||||
|
m.setProject("AWPCA");
|
||||||
|
await waitFor(() => expect(m.now().wiki.event).toMatchObject({ kind: "batch", id: 9 }));
|
||||||
|
});
|
||||||
|
|
||||||
it("M-2: пакет, отданный track для нового проекта, переживает смену проекта на него; чужой — сбрасывается", async () => {
|
it("M-2: пакет, отданный track для нового проекта, переживает смену проекта на него; чужой — сбрасывается", async () => {
|
||||||
hub({ ...byId({ 9: "wiki_sync" }, () => "running") });
|
hub({ ...byId({ 9: "wiki_sync" }, () => "running") });
|
||||||
const m = mount();
|
const m = mount();
|
||||||
|
|||||||
@ -16,6 +16,18 @@ describe("поля", () => {
|
|||||||
expect(screen.getByLabelText("Текст").tagName).toBe("TEXTAREA");
|
expect(screen.getByLabelText("Текст").tagName).toBe("TEXTAREA");
|
||||||
expect(screen.getByRole("checkbox", { name: "Скрыть" })).toBeInTheDocument();
|
expect(screen.getByRole("checkbox", { name: "Скрыть" })).toBeInTheDocument();
|
||||||
});
|
});
|
||||||
|
it("SearchInput: «Очистить» возвращает фокус в поле", async () => {
|
||||||
|
const onChange = vi.fn();
|
||||||
|
function Wrap() {
|
||||||
|
const [v, setV] = useState("abc");
|
||||||
|
return <SearchInput value={v} onChange={(x) => { onChange(x); setV(x); }} label="Поиск" />;
|
||||||
|
}
|
||||||
|
render(<Wrap />);
|
||||||
|
await userEvent.click(screen.getByRole("button", { name: "Очистить" }));
|
||||||
|
expect(onChange).toHaveBeenLastCalledWith("");
|
||||||
|
expect(document.activeElement).toBe(screen.getByRole("searchbox"));
|
||||||
|
});
|
||||||
|
|
||||||
it("SearchInput: ввод и «Очистить» только при значении", async () => {
|
it("SearchInput: ввод и «Очистить» только при значении", async () => {
|
||||||
const onChange = vi.fn();
|
const onChange = vi.fn();
|
||||||
const { rerender } = render(<SearchInput value="" onChange={onChange} label="Поиск" />);
|
const { rerender } = render(<SearchInput value="" onChange={onChange} label="Поиск" />);
|
||||||
|
|||||||
@ -1,3 +1,4 @@
|
|||||||
|
import { useRef } from "react";
|
||||||
import type { InputHTMLAttributes, ReactNode, SelectHTMLAttributes, TextareaHTMLAttributes } from "react";
|
import type { InputHTMLAttributes, ReactNode, SelectHTMLAttributes, TextareaHTMLAttributes } from "react";
|
||||||
import { IconButton } from "./Button.tsx";
|
import { IconButton } from "./Button.tsx";
|
||||||
import { IconChevronDown, IconClose, IconSearch } from "./icons.tsx";
|
import { IconChevronDown, IconClose, IconSearch } from "./icons.tsx";
|
||||||
@ -14,12 +15,13 @@ export function Textarea({ size = "md", className, ...rest }: Size & Omit<Textar
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function SearchInput({ value, onChange, label, placeholder, autoFocus }: { value: string; onChange(v: string): void; label: string; placeholder?: string; autoFocus?: boolean }) {
|
export function SearchInput({ value, onChange, label, placeholder, autoFocus }: { value: string; onChange(v: string): void; label: string; placeholder?: string; autoFocus?: boolean }) {
|
||||||
|
const ref = useRef<HTMLInputElement>(null);
|
||||||
return (
|
return (
|
||||||
<span className="ui-search">
|
<span className="ui-search">
|
||||||
<span className="ui-search__icon" aria-hidden="true"><IconSearch /></span>
|
<span className="ui-search__icon" aria-hidden="true"><IconSearch /></span>
|
||||||
<input type="search" className="ui-input ui-input--md ui-search__input" aria-label={label} placeholder={placeholder} autoFocus={autoFocus}
|
<input ref={ref} type="search" className="ui-input ui-input--md ui-search__input" aria-label={label} placeholder={placeholder} autoFocus={autoFocus}
|
||||||
value={value} onChange={(e) => onChange(e.target.value)} />
|
value={value} onChange={(e) => onChange(e.target.value)} />
|
||||||
{value !== "" && <IconButton className="ui-search__clear" label="Очистить" icon={<IconClose />} onClick={() => onChange("")} />}
|
{value !== "" && <IconButton className="ui-search__clear" label="Очистить" icon={<IconClose />} onClick={() => { onChange(""); ref.current?.focus(); }} />}
|
||||||
</span>
|
</span>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user