From 558fe7812bf52ae7ad38a8f051f6c05568f90206 Mon Sep 17 00:00:00 2001 From: Andrey Epifancev Date: Wed, 28 Oct 2026 12:48:40 +0400 Subject: [PATCH] =?UTF-8?q?=D0=92=D0=B5=D0=B1:=20=D0=BE=D0=B1=D1=89=D0=B0?= =?UTF-8?q?=D1=8F=20=C2=AB=D0=9B=D0=B5=D0=BD=D1=82=D0=B0=C2=BB=20=D1=8F?= =?UTF-8?q?=D0=B4=D1=80=D0=B0=20=D1=81=20=D0=B8=D1=81=D1=82=D0=BE=D1=87?= =?UTF-8?q?=D0=BD=D0=B8=D0=BA=D0=B0=D0=BC=D0=B8=20=D0=BC=D0=BE=D0=B4=D1=83?= =?UTF-8?q?=D0=BB=D0=B5=D0=B9,=20=D0=B8=D1=81=D1=82=D0=BE=D1=87=D0=BD?= =?UTF-8?q?=D0=B8=D0=BA=20Jira=20(#140)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5.5 --- ARCHITECTURE.md | 15 +-- CLAUDE.md | 4 +- README.md | 4 +- web/e2e/feed.spec.ts | 4 +- web/src/boundary.test.ts | 3 +- web/src/modules/index.ts | 11 ++- web/src/modules/jira/Summary.tsx | 2 +- .../modules/jira/agile/BacklogPage.test.tsx | 8 +- .../modules/jira/agile/KanbanPage.test.tsx | 4 +- .../modules/jira/agile/SprintPage.test.tsx | 4 +- web/src/modules/jira/feed/EventList.tsx | 3 +- web/src/modules/jira/feed/FeedGroup.tsx | 19 ++++ web/src/modules/jira/feed/FeedPage.tsx | 79 ---------------- web/src/modules/jira/feed/events.test.ts | 28 +----- web/src/modules/jira/feed/events.ts | 28 ------ web/src/modules/jira/feed/feed.css | 6 +- web/src/modules/jira/feed/source.tsx | 59 ++++++++++++ web/src/modules/jira/index.tsx | 9 +- web/src/modules/modules.test.tsx | 6 +- web/src/modules/types.ts | 26 ++++++ web/src/shell/OverviewPage.test.tsx | 2 +- web/src/shell/Sidebar.test.tsx | 10 +- web/src/shell/Sidebar.tsx | 3 +- .../jira => shell}/feed/FeedPage.test.tsx | 93 ++++++++++++------- web/src/shell/feed/FeedPage.tsx | 86 +++++++++++++++++ web/src/shell/feed/feed.css | 9 ++ web/src/shell/feed/period.test.ts | 19 ++++ web/src/shell/feed/period.ts | 16 ++++ web/src/shell/feed/sources.test.ts | 17 ++++ web/src/shell/feed/sources.ts | 14 +++ web/src/shell/nav.tsx | 6 +- web/src/shell/pages.tsx | 2 + web/src/ui/format.test.ts | 14 ++- web/src/ui/format.ts | 9 ++ 34 files changed, 410 insertions(+), 212 deletions(-) create mode 100644 web/src/modules/jira/feed/FeedGroup.tsx delete mode 100644 web/src/modules/jira/feed/FeedPage.tsx create mode 100644 web/src/modules/jira/feed/source.tsx rename web/src/{modules/jira => shell}/feed/FeedPage.test.tsx (70%) create mode 100644 web/src/shell/feed/FeedPage.tsx create mode 100644 web/src/shell/feed/feed.css create mode 100644 web/src/shell/feed/period.test.ts create mode 100644 web/src/shell/feed/period.ts create mode 100644 web/src/shell/feed/sources.test.ts create mode 100644 web/src/shell/feed/sources.ts diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index da541cb..d019734 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -98,9 +98,12 @@ (`freshness(project)`), действие обновления (`refresh`: подпись, `start`, `isOwn`) и перечитывание после пакета (`invalidate`). Рисует и ведёт его ядро: трекер пакетов и агентов — `web/src/shell/activity/` (`ActivityProvider`: подхват идущего пакета и `GET /agents` раз в 60 с, опрос пакета каждые 3 с, `useModuleActivity(module)`, тексты `events.ts`/`agentText.ts`), блок состояния панели — `ModuleStatusBlock`, а модуль -даёт только `Summary` — карточку на «Обзоре» (`OverviewPage`: карточки модулей и «Последние пакеты проекта»). Адреса: «Обзор» — `/app/`, -страницы модуля — `/app/<модуль>/…` (`/app/jira/links`, `/app/jira/issues[/{key}]`, `/app/jira/feed`, `/app/wiki`); прежние `/app/board`, `/app/jira/board`, -`/app/issues`, `/app/issues/{key}`, `/app/feed` перенаправляются с сохранением `search` и `replace`. Пункты меню могут зависеть от проекта (`NavItem.when`, `visibleNav` в `shell/nav.tsx`): пока проект не определён, условный пункт скрыт; «не найдено» перечисляет только безусловные. +даёт только `Summary` — карточку на «Обзоре» (`OverviewPage`: карточки модулей и «Последние пакеты проекта»). Контракт `HubModule` — плюс необязательное +`feed` (`FeedSource`: источник общей ленты — `available(project)`, хук `useFeed(input)` с группами/состоянием, `Controls` — свои настройки над лентой): +страница ядра «Лента» (`web/src/shell/feed/`: `FeedPage`, `period`, `sources`; `FEED_MODULES`/`useFeeds` в `modules/index.ts`) ведёт период, выбор +источников (`sources=`, id источника — имя модуля) и порядок групп по времени, модуль — запросы, группы и виды событий; ядро не знает видов событий модулей. Адреса: «Обзор» — `/app/`, +«Лента» — страница ядра `/app/feed`; страницы модуля — `/app/<модуль>/…` (`/app/jira/links`, `/app/jira/issues[/{key}]`, `/app/wiki`); прежние `/app/board`, `/app/jira/board`, +`/app/issues`, `/app/issues/{key}`, `/app/jira/feed` (на `/app/feed`) перенаправляются с сохранением `search` и `replace`. Пункты меню могут зависеть от проекта (`NavItem.when`, `visibleNav` в `shell/nav.tsx`): пока проект не определён, условный пункт скрыт; «не найдено» перечисляет только безусловные. Экраны доски модуля jira — `web/src/modules/jira/agile/`: `model` (даты Jira, «осталось N дн.», склонение, состояние данных `boardState`), `columns` (раскладка задач по колонкам доски, «Без колонки»), `AgileGate` (тип доски проекта, загрузка, ошибка, три @@ -117,13 +120,13 @@ скачивают файл через `download.ts`, `urlTransform.ts` пропускает только эти схемы и http(s)), форма корня без привязки (`RootForm`), сводка для «Обзора» (`Summary`) и `status` для ядра; поле «Корень вики» в окне «Новый проект» — через `projectFields`. -Модуль `jira` (`web/src/modules/jira/index.tsx`): страницы «Связи» (`/app/jira/links`, заголовок страницы — «Доска связей»), «Задачи» (`/app/jira/issues`), «Лента» -(`/app/jira/feed`), карточка `/app/jira/issues/{key}` окном поверх страницы (`issues/overlay.tsx`, `IssueModal.tsx`; оболочка рисует +Модуль `jira` (`web/src/modules/jira/index.tsx`): страницы «Связи» (`/app/jira/links`, заголовок страницы — «Доска связей»), «Задачи» (`/app/jira/issues`) (страницы «Лента» у модуля нет — она в ядре; `/app/jira/feed` +перенаправляется на `/app/feed`), карточка `/app/jira/issues/{key}` окном поверх страницы (`issues/overlay.tsx`, `IssueModal.tsx`; оболочка рисует страницу по `location.state.background`). Внутри: `board/` (чистые `model`, `analysis`, `layout`, `storage.ts` — ключи `localStorage` прежней страницы, у проекта AWPCA прежние, у остальных с суффиксом `@<метка>`; отрисовка `components/`; golden-эталоны `__fixtures__/`), `issues/` (список, карточка, `wiki/` — разбор вики-разметки, `attachments/` — список, плашки и скачивание: `POST /attachments/{id}/fetch`, опрос `GET /attachments/{id}`, затем `api.blob` на `/content`), -`feed/` (период, виды, группировка событий, `EventList`, общий с блоком «История» карточки), `RefreshButton.tsx` (кнопка «Обновить из Jira» в шапках страниц), `Summary`/`status` для ядра, `api/queries.ts`. +`feed/` (источник ленты `jiraFeed` — `source.tsx`: виды событий, группировка по задаче, `FeedGroup`; `EventList`, общий с блоком «История» карточки), `RefreshButton.tsx` (кнопка «Обновить из Jira» в шапках страниц), `Summary`/`status` для ядра, `api/queries.ts`. Собирается `make web` в `src/sdlc_hub/web/app/`; `core/api` отдаёт сборку без токена (`/app/assets/*` — файлы, прочие `/app…` — `index.html`, 503 без сборки), `/` и `/links` перенаправляет на `/app/` («Обзор»). Данные — только через HTTP API с diff --git a/CLAUDE.md b/CLAUDE.md index 91af62d..5d10bb2 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -18,7 +18,7 @@ SDLC-хаб с модулями jira и wiki. Мост Jira для АРМ КА: - `src/sdlc_hub/modules/wiki/` — модуль wiki: `schema`, `settings`, `paths`, `projects` (поле `wiki_root`), `commands` (`wiki_sync`, `wiki_fetch` и хуки очереди), `convert` (HTML → Markdown), `stage`, `sync`, `search` (FTS5), `attachments`, `tick`, `api`, `mcp`. Входная точка — `MODULE` в `modules/wiki/__init__.py`. - Граница: ядро не импортирует модули (кроме ленивого импорта в `registry.py` и шагах миграций `db.py`), типы команд и термины модулей (jira, wiki, eva) живут в модуле, новые имена в ядре — без названий модулей; проверяют `test_core_imports`, `test_core_has_no_jira_terms`, `test_core_has_no_module_terms` в `tests/test_registry.py`. Меток-исключений нет. - Цепочка ядра: `db` ← `settings`, `projects` ← `queue` ← `api` ← `cli`; `files` ← `queue`, `api`; реле `relay` — поток в `serve`; `backup` вызывается из `api` и из `db`. Модуль jira: `hooks → commands/mirror/history/refresh/attachments/fetch → core`. -- Веб: оболочка и общее — `web/src/{shell,ui,api,projects,auth,styles}`, модули — `web/src/modules/<имя>/` (контракт `HubModule` в `modules/types.ts`, список — `modules/index.ts`); состояние модулей (агент, свежесть, обновление) ведёт ядро в `web/src/shell/activity/`, модуль даёт `status` и `Summary` для «Обзора»; модуль jira — страницы доски, задач и ленты; модуль wiki (`web/src/modules/wiki/`) — страница «Вики» (`/app/wiki?project=<метка>`). +- Веб: оболочка и общее — `web/src/{shell,ui,api,projects,auth,styles}`, модули — `web/src/modules/<имя>/` (контракт `HubModule` в `modules/types.ts`, список — `modules/index.ts`); состояние модулей (агент, свежесть, обновление) ведёт ядро в `web/src/shell/activity/`, модуль даёт `status` и `Summary` для «Обзора»; модуль jira — страницы доски и задач; лента — страница ядра; модуль wiki (`web/src/modules/wiki/`) — страница «Вики» (`/app/wiki?project=<метка>`). - Подробная карта модулей и границ — `ARCHITECTURE.md`. ## Команды @@ -26,7 +26,7 @@ SDLC-хаб с модулями jira и wiki. Мост Jira для АРМ КА: - `uv sync` — установка окружения. - `uv run sdlc-hub init --config PATH [--force]` — настройки проекта из `config.example.json`, создание токенов. - `uv run sdlc-hub serve [--host] [--port]` — локальный запуск хаба. -- Веб-приложение — `http://127.0.0.1:8790/app/` («Обзор» — `/app/?project=<метка>`, «Связи» (доска связей) — `/app/jira/links?project=<метка>`, задачи — `/app/jira/issues?project=<метка>`, «Бэклог» — `/app/jira/backlog?project=<метка>`, «Спринт» — `/app/jira/sprint?project=<метка>`, «Kanban» — `/app/jira/kanban?project=<метка>`, лента изменений — `/app/jira/feed?project=<метка>`, вики — `/app/wiki?project=<метка>`; вход по клиентскому токену); `/` и `/links` перенаправляют на «Обзор», прежние `/app/board`, `/app/jira/board`, `/app/issues`, `/app/feed` — на страницы модуля Jira; пункты меню могут зависеть от проекта (`NavItem.when`). +- Веб-приложение — `http://127.0.0.1:8790/app/` («Обзор» — `/app/?project=<метка>`, «Связи» (доска связей) — `/app/jira/links?project=<метка>`, задачи — `/app/jira/issues?project=<метка>`, «Бэклог» — `/app/jira/backlog?project=<метка>`, «Спринт» — `/app/jira/sprint?project=<метка>`, «Kanban» — `/app/jira/kanban?project=<метка>`, лента изменений — `/app/feed?project=<метка>` (источники Jira и Вики), вики — `/app/wiki?project=<метка>`; вход по клиентскому токену); `/` и `/links` перенаправляют на «Обзор», прежние `/app/board`, `/app/jira/board`, `/app/issues` — на страницы модуля Jira, `/app/jira/feed` — на `/app/feed`; пункты меню могут зависеть от проекта (`NavItem.when`). - `make web` — сборка приложения (после `git pull` на Mac mini, до перезапуска хаба); `make check` — pytest + lint и тесты фронта; `cd web && npm run dev` — разработка с прокси на тестовый хаб `bash web/scripts/test-hub.sh` (:8791); `cd web && npm run e2e` — Playwright; `cd web && npm run golden` — перегенерировать эталоны доски из `links.html@3931a88` (только при смене фикстур). - `uv run sdlc-hub import FOLDER` — импорт CSV-пакета `admin/jira-sync`; `uv run sdlc-hub mcp <модуль> [--project <метка>]` — MCP модуля по stdio (модуль обязателен: `jira` — сервер `sdlc-jira`, `wiki` — сервер `sdlc-wiki`; с привязкой сессии к проекту); `uv run sdlc-hub tokens` — показать токены (клиентский и по агенту на модуль). - `uv run sdlc-hub agent publish [--module jira|wiki] PATH` — опубликовать версию агента модуля для самообновления (только вверх; по умолчанию `jira`; публикация лежит в `release/<модуль>/`). diff --git a/README.md b/README.md index 7d3aca3..94bbaaf 100644 --- a/README.md +++ b/README.md @@ -258,8 +258,8 @@ curl -s -H "Authorization: Bearer $TOKEN" "http://127.0.0.1:8790/issues?sprint= ## Веб-приложение -Адреса: `http://127.0.0.1:8790/app/` — «Обзор» `/app/?project=<метка>` (карточки модулей Jira и «Вики» с состоянием агента, свежестью данных и обновлением, последние пакеты проекта), «Связи» (доска связей) `/app/jira/links?project=<метка>`, задачи `/app/jira/issues?project=<метка>`, лента изменений `/app/jira/feed?project=<метка>` (период и виды событий, история — и в карточке задачи), вики `/app/wiki?project=<метка>`, карточка `/app/jira/issues/{key}` — окно поверх списка или доски (закрытие — ✕, Esc, щелчок по фону; прямой адрес открывает окно поверх списка задач); `/` и -`/links` перенаправляют на «Обзор»; прежние адреса `/app/board`, `/app/jira/board`, `/app/issues`, `/app/feed` ведут на страницы модуля Jira; пункты меню могут зависеть от проекта (`NavItem.when`). Вход по клиентскому токену (`uv run sdlc-hub tokens`), токен хранится в браузере; +Адреса: `http://127.0.0.1:8790/app/` — «Обзор» `/app/?project=<метка>` (карточки модулей Jira и «Вики» с состоянием агента, свежестью данных и обновлением, последние пакеты проекта), «Связи» (доска связей) `/app/jira/links?project=<метка>`, задачи `/app/jira/issues?project=<метка>`, лента изменений `/app/feed?project=<метка>` (источники Jira и Вики, период и виды событий Jira, история — и в карточке задачи), вики `/app/wiki?project=<метка>`, карточка `/app/jira/issues/{key}` — окно поверх списка или доски (закрытие — ✕, Esc, щелчок по фону; прямой адрес открывает окно поверх списка задач); `/` и +`/links` перенаправляют на «Обзор»; прежние адреса `/app/board`, `/app/jira/board`, `/app/issues` ведут на страницы модуля Jira, `/app/jira/feed` — на `/app/feed`; пункты меню могут зависеть от проекта (`NavItem.when`). Вход по клиентскому токену (`uv run sdlc-hub tokens`), токен хранится в браузере; `localhost` и `127.0.0.1` — разные сайты для браузера, токен и раскладка у каждого свои. Выкатка на Mac mini: ``` diff --git a/web/e2e/feed.spec.ts b/web/e2e/feed.spec.ts index c57eec4..ab57064 100644 --- a/web/e2e/feed.spec.ts +++ b/web/e2e/feed.spec.ts @@ -18,7 +18,7 @@ async function seedHistory() { test("лента: событие за сегодня, карточка окном поверх ленты, история в карточке", async ({ page }) => { await seedHistory(); - await login(page, "/app/jira/feed?project=AWPCA"); + await login(page, "/app/feed?project=AWPCA"); const group = page.getByTestId("feed-group").filter({ has: page.getByRole("link", { name: "AURORA-9002", exact: true }) }); await expect(group.getByText("статус «В работе» → «Готово»").first()).toBeVisible(); const other = page.getByRole("checkbox", { name: "Прочие поля" }); @@ -39,7 +39,7 @@ test("лента: событие за сегодня, карточка окно test("лента на 390 px: нет горизонтальной прокрутки при длинных значениях", async ({ page }) => { await seedHistory(); await page.setViewportSize({ width: 390, height: 844 }); - await login(page, "/app/jira/feed?project=AWPCA&kinds=status,other"); + await login(page, "/app/feed?project=AWPCA&kinds=status,other"); await expect(page.getByTestId("feed-group").first()).toBeVisible(); const overflow = await page.evaluate(() => document.documentElement.scrollWidth - window.innerWidth); expect(overflow).toBeLessThanOrEqual(0); diff --git a/web/src/boundary.test.ts b/web/src/boundary.test.ts index 4d94b8a..7c395d3 100644 --- a/web/src/boundary.test.ts +++ b/web/src/boundary.test.ts @@ -24,7 +24,7 @@ function resolve(from: string, spec: string): string { } // имена полей и типов модулей, которых в ядре быть не должно (типизирует сам модуль) -const MODULE_TERMS = /\b(fact_status|closed_statuses|kind_labels|link_types|BoardIssue|IssueRow|jira_synced_at|wiki_synced_at|wiki_sync_error|wiki_root|board_id|board_type|issues_count)\b/; +const MODULE_TERMS = /\b(fact_status|closed_statuses|kind_labels|link_types|BoardIssue|IssueRow|jira_synced_at|wiki_synced_at|wiki_sync_error|wiki_root|board_id|board_type|issues_count|ChangeKind|kinds|history_at|page_code|prev_version)\b/; // классы, которые «определяет» CSS-текст: только селекторы; значения свойств, атрибуты и :not(...) не считаются const classesOf = (css: string): Set => { @@ -106,6 +106,7 @@ describe("детекторы границы", () => { expect(MODULE_TERMS.test("p.wiki_root")).toBe(true); expect(MODULE_TERMS.test("board_type")).toBe(true); expect(MODULE_TERMS.test("fact_status")).toBe(true); + expect(MODULE_TERMS.test("kinds: string")).toBe(true); expect(MODULE_TERMS.test("метка задач в Jira")).toBe(false); }); }); diff --git a/web/src/modules/index.ts b/web/src/modules/index.ts index 71c941b..8ce3e58 100644 --- a/web/src/modules/index.ts +++ b/web/src/modules/index.ts @@ -1,6 +1,6 @@ import { jiraModule } from "./jira/index.tsx"; import { wikiModule } from "./wiki/index.tsx"; -import type { HubModule, Overlay } from "./types.ts"; +import type { FeedResult, FeedSourceInput, HubModule, Overlay } from "./types.ts"; // список модулей — константа: хуки модулей вызываются в постоянном порядке на каждой отрисовке export const MODULES: readonly HubModule[] = [jiraModule, wikiModule]; @@ -11,3 +11,12 @@ const OVERLAY_HOOKS = MODULES.flatMap((m) => m.useOverlay ?? []); export function useOverlays(): Overlay[] { return OVERLAY_HOOKS.map((use) => use()); } + +// модули с источником ленты — в порядке MODULES +export const FEED_MODULES: readonly HubModule[] = MODULES.filter((m) => m.feed); +const FEED_HOOKS = FEED_MODULES.map((m) => m.feed!.useFeed); + +// результаты источников ленты; inputs — по одному на модуль из FEED_MODULES, число и порядок не меняются +export function useFeeds(inputs: FeedSourceInput[]): FeedResult[] { + return FEED_HOOKS.map((use, i) => use(inputs[i])); +} diff --git a/web/src/modules/jira/Summary.tsx b/web/src/modules/jira/Summary.tsx index 467e255..c334fc8 100644 --- a/web/src/modules/jira/Summary.tsx +++ b/web/src/modules/jira/Summary.tsx @@ -26,7 +26,7 @@ export function Summary({ project }: { project: Project }) { ); diff --git a/web/src/modules/jira/agile/BacklogPage.test.tsx b/web/src/modules/jira/agile/BacklogPage.test.tsx index 96c7718..0ee640c 100644 --- a/web/src/modules/jira/agile/BacklogPage.test.tsx +++ b/web/src/modules/jira/agile/BacklogPage.test.tsx @@ -66,18 +66,18 @@ const links = () => within(jiraGroup()).getAllByRole("link").map((l) => l.textCo describe("«Бэклог»", () => { beforeEach(() => { setToken("tok"); localStorage.clear(); }); - it("меню scrum-проекта: «Связи», «Бэклог», «Спринт», «Задачи», «Лента»", async () => { + it("меню scrum-проекта: «Связи», «Бэклог», «Спринт», «Задачи»", async () => { hub(); open("/app/jira/links?project=AWPCA"); await within(jiraGroup()).findByRole("link", { name: "Бэклог" }); - expect(links()).toEqual(["Связи", "Бэклог", "Спринт", "Задачи", "Лента"]); + expect(links()).toEqual(["Связи", "Бэклог", "Спринт", "Задачи"]); }); it("проект без доски: пунктов нет, «У проекта нет scrum-доски» со ссылкой «Связи», запроса /agile нет", async () => { const calls = hub(); open("/app/jira/backlog?project=STOCKS"); expect(await screen.findByText("У проекта нет scrum-доски")).toBeInTheDocument(); - expect(links()).toEqual(["Связи", "Задачи", "Лента"]); + expect(links()).toEqual(["Связи", "Задачи"]); const inPage = within(screen.getByRole("main")); expect(inPage.getByRole("link", { name: "Связи" })).toHaveAttribute("href", "/app/jira/links?project=STOCKS"); expect(calls).toHaveLength(0); @@ -87,7 +87,7 @@ describe("«Бэклог»", () => { hub({ projects: "hang" }); open("/app/jira/links?project=AWPCA"); await screen.findByRole("group", { name: "Jira" }); - expect(links()).toEqual(["Связи", "Задачи", "Лента"]); + expect(links()).toEqual(["Связи", "Задачи"]); }); it("секции: активный, будущий, «Бэклог», «Прошлые спринты (1)»; заголовок спринта с суммами", async () => { diff --git a/web/src/modules/jira/agile/KanbanPage.test.tsx b/web/src/modules/jira/agile/KanbanPage.test.tsx index f60c958..9360fc9 100644 --- a/web/src/modules/jira/agile/KanbanPage.test.tsx +++ b/web/src/modules/jira/agile/KanbanPage.test.tsx @@ -54,12 +54,12 @@ const colNames = (p: HTMLElement) => Array.from(p.querySelectorAll(".agile-col__ describe("«Kanban»", () => { beforeEach(() => { setToken("tok"); localStorage.clear(); }); - it("меню kanban-проекта: «Связи», «Kanban», «Задачи», «Лента»", async () => { + it("меню kanban-проекта: «Связи», «Kanban», «Задачи»", async () => { hub(agile(false)); open("/app/jira/links?project=KAN"); const g = () => screen.getByRole("group", { name: "Jira" }); await within(g()).findByRole("link", { name: "Kanban" }); - expect(within(g()).getAllByRole("link").map((l) => l.textContent)).toEqual(["Связи", "Kanban", "Задачи", "Лента"]); + expect(within(g()).getAllByRole("link").map((l) => l.textContent)).toEqual(["Связи", "Kanban", "Задачи"]); }); it("колонки из board_issues, у последней — «за 14 дней», «Без колонки» при статусе вне доски", async () => { diff --git a/web/src/modules/jira/agile/SprintPage.test.tsx b/web/src/modules/jira/agile/SprintPage.test.tsx index aaf2e62..0107ded 100644 --- a/web/src/modules/jira/agile/SprintPage.test.tsx +++ b/web/src/modules/jira/agile/SprintPage.test.tsx @@ -61,11 +61,11 @@ const colNames = (p: HTMLElement) => Array.from(p.querySelectorAll(".agile-col__ describe("«Спринт»", () => { beforeEach(() => { setToken("tok"); localStorage.clear(); }); - it("меню scrum-проекта: «Связи», «Бэклог», «Спринт», «Задачи», «Лента»; «Kanban» нет", async () => { + it("меню scrum-проекта: «Связи», «Бэклог», «Спринт», «Задачи»; «Kanban» нет", async () => { hub(agile()); open("/app/jira/links?project=AWPCA"); await within(jiraGroup()).findByRole("link", { name: "Спринт" }); - expect(within(jiraGroup()).getAllByRole("link").map((l) => l.textContent)).toEqual(["Связи", "Бэклог", "Спринт", "Задачи", "Лента"]); + expect(within(jiraGroup()).getAllByRole("link").map((l) => l.textContent)).toEqual(["Связи", "Бэклог", "Спринт", "Задачи"]); }); it("колонки доски с числом задач и «Без колонки»", async () => { diff --git a/web/src/modules/jira/feed/EventList.tsx b/web/src/modules/jira/feed/EventList.tsx index 708f3a5..cb04667 100644 --- a/web/src/modules/jira/feed/EventList.tsx +++ b/web/src/modules/jira/feed/EventList.tsx @@ -1,5 +1,6 @@ import type { ChangeEvent } from "../api/types.ts"; -import { eventText, eventTime } from "./events.ts"; +import { eventTime } from "../../../ui/format.ts"; +import { eventText } from "./events.ts"; import "./feed.css"; // строки событий журнала: время, автор, что изменилось (лента и история в карточке; spec web-app «Лента изменений») diff --git a/web/src/modules/jira/feed/FeedGroup.tsx b/web/src/modules/jira/feed/FeedGroup.tsx new file mode 100644 index 0000000..6fc7a38 --- /dev/null +++ b/web/src/modules/jira/feed/FeedGroup.tsx @@ -0,0 +1,19 @@ +import { Badge, Panel } from "../../../ui/index.ts"; +import { IssueLink } from "../issues/IssueLink.tsx"; +import { EventList } from "./EventList.tsx"; +import type { IssueGroup } from "./events.ts"; +import "./feed.css"; + +// группа ленты — одна задача: ключ ссылкой на карточку, тема, статус и события по времени +export function FeedGroup({ group, now }: { group: IssueGroup; now: Date }) { + return ( + +
+ + {group.summary ?? "нет в зеркале"} + {group.status && {group.status}} +
+ +
+ ); +} diff --git a/web/src/modules/jira/feed/FeedPage.tsx b/web/src/modules/jira/feed/FeedPage.tsx deleted file mode 100644 index 51b97f3..0000000 --- a/web/src/modules/jira/feed/FeedPage.tsx +++ /dev/null @@ -1,79 +0,0 @@ -import { useMemo } from "react"; -import { useSearchParams } from "react-router"; -import { useChanges } from "../api/queries.ts"; -import type { ChangeKind } from "../api/types.ts"; -import { IssueLink } from "../issues/IssueLink.tsx"; -import { Badge, Button, Checkbox, Notice, PageHeader, Panel, Segmented } from "../../../ui/index.ts"; -import { RefreshButton } from "../RefreshButton.tsx"; -import { EventList } from "./EventList.tsx"; -import { groupByIssue, KINDS, kindsParam, parseKinds, parsePeriod, PERIODS, periodRange, type Period } from "./events.ts"; -import "./feed.css"; - -const pad = (n: number) => String(n).padStart(2, "0"); -const stamp = (d: Date) => isNaN(d.getTime()) ? "—" : `${pad(d.getDate())}.${pad(d.getMonth() + 1)} ${pad(d.getHours())}:${pad(d.getMinutes())}`; - -function plural(n: number): string { - const m10 = n % 10, m100 = n % 100; - if (m10 === 1 && m100 !== 11) return "событие"; - if (m10 >= 2 && m10 <= 4 && (m100 < 12 || m100 > 14)) return "события"; - return "событий"; -} - -// лента изменений проекта за период (spec web-app «Лента изменений», design web-feed 1–7) -export function FeedPage({ project }: { project: string }) { - const [params, setParams] = useSearchParams(); - const period = parsePeriod(params.get("period")); - const kinds = useMemo(() => parseKinds(params.get("kinds")), [params]); - // границы считает запрос в момент выполнения (C-1): ключ кэша — проект, период и виды - const changes = useChanges({ project, period, kinds: kinds.join(",") }, () => periodRange(period, new Date()), kinds.length > 0); - const now = new Date(changes.dataUpdatedAt || Date.now()); // «текущие сутки» для времени событий — на момент ответа - - const setParam = (name: string, value: string | null) => setParams((p) => { - const n = new URLSearchParams(p); - if (value) n.set(name, value); else n.delete(name); - return n; - }); - const choosePeriod = (p: Period) => setParam("period", p === "today" ? null : p); - const toggleKind = (k: ChangeKind, on: boolean) => { - const next = on ? [...kinds, k] : kinds.filter((x) => x !== k); - setParam("kinds", next.length ? kindsParam(next) : "none"); // пустой набор — явное «none», иначе адрес вернул бы умолчание - }; - - const data = changes.data; - const groups = useMemo(() => groupByIssue(data?.changes ?? []), [data]); - const count = data?.changes.length ?? 0; - // окно — из ответа хаба: подзаголовок совпадает с тем, что показано - const subtitle = kinds.length && data ? `${count} ${plural(count)} · ${stamp(new Date(data.since))} — ${stamp(new Date(data.until))}` : undefined; - - return ( -
- } /> -
- -
- {KINDS.map((k) => ( - toggleKind(k.value, e.currentTarget.checked)} /> - ))} -
-
- {!kinds.length && выберите хотя бы один вид} - {kinds.length > 0 && changes.isError && ( - void changes.refetch()}>Повторить}>{(changes.error as Error).message} - )} - {kinds.length > 0 && changes.isPending &&

Загрузка…

} - {data && !count && за период изменений нет} - {data?.truncated && {`показаны последние ${count} событий — выберите период короче`}} - {groups.map((g) => ( - -
- - {g.summary ?? "нет в зеркале"} - {g.status && {g.status}} -
- -
- ))} -
- ); -} diff --git a/web/src/modules/jira/feed/events.test.ts b/web/src/modules/jira/feed/events.test.ts index bfd61e6..fa6025d 100644 --- a/web/src/modules/jira/feed/events.test.ts +++ b/web/src/modules/jira/feed/events.test.ts @@ -1,28 +1,12 @@ import { describe, expect, it } from "vitest"; import type { ChangeEvent } from "../api/types.ts"; -import { DEFAULT_KINDS, eventText, eventTime, groupByIssue, kindsParam, parseKinds, parsePeriod, periodRange } from "./events.ts"; +import { DEFAULT_KINDS, eventText, groupByIssue, kindsParam, parseKinds } from "./events.ts"; -const NOW = new Date(2026, 9, 22, 14, 5); const ev = (over: Partial = {}): ChangeEvent => ({ at: "2026-10-22T08:15:00Z", key: "X", summary: "Тема", status: "Готово", kind: "status", field: "status", author: "Петров", from: "В работе", to: "Готово", from_value: null, to_value: null, ...over, }); -describe("период", () => { - it("границы по местному времени", () => { - expect(periodRange("today", NOW)).toEqual({ since: new Date(2026, 9, 22, 0, 0), until: NOW }); - expect(periodRange("yesterday", NOW)).toEqual({ since: new Date(2026, 9, 21, 0, 0), until: new Date(2026, 9, 22, 0, 0) }); - expect(periodRange("2h", NOW)).toEqual({ since: new Date(2026, 9, 22, 12, 5), until: NOW }); - expect(periodRange("7d", NOW)).toEqual({ since: new Date(2026, 9, 15, 14, 5), until: NOW }); - }); - - it("мусор в адресе — «Сегодня»", () => { - expect(parsePeriod("foo")).toBe("today"); - expect(parsePeriod(null)).toBe("today"); - expect(parsePeriod("7d")).toBe("7d"); - }); -}); - describe("виды", () => { it("неизвестные отброшены, пусто — по умолчанию", () => { expect(parseKinds("status,foo")).toEqual(["status"]); @@ -77,16 +61,6 @@ describe("текст события", () => { }); }); -describe("время события", () => { - it("сегодня — ЧЧ:ММ, иначе с датой, без времени — прочерк", () => { - expect(eventTime(new Date(2026, 9, 22, 11, 15).toISOString(), NOW)).toBe("11:15"); - expect(eventTime(new Date(2026, 9, 21, 9, 5).toISOString(), NOW)).toBe("21.10 09:05"); - expect(eventTime(new Date(2026, 9, 22, 11, 15).toISOString(), NOW, true)).toBe("22.10 11:15"); - expect(eventTime(null, NOW)).toBe("—"); - expect(eventTime("мусор", NOW)).toBe("—"); - }); -}); - describe("группировка", () => { it("группы в порядке первого появления, у группы тема и статус", () => { const g = groupByIssue([ev({ key: "Y", summary: "Вторая", status: null }), ev(), ev({ key: "Y", summary: "Вторая", status: null })]); diff --git a/web/src/modules/jira/feed/events.ts b/web/src/modules/jira/feed/events.ts index 72587dd..a02abfd 100644 --- a/web/src/modules/jira/feed/events.ts +++ b/web/src/modules/jira/feed/events.ts @@ -1,23 +1,6 @@ import type { ChangeEvent, ChangeKind } from "../api/types.ts"; import { wikiPlain } from "../issues/wiki/plain.ts"; -// период ленты: границы по местному времени браузера; «Сегодня» — по умолчанию и в адрес не пишется (spec web-app «Лента изменений») -export type Period = "2h" | "today" | "yesterday" | "7d"; -export const PERIODS: { value: Period; label: string }[] = [ - { value: "2h", label: "2 часа" }, { value: "today", label: "Сегодня" }, { value: "yesterday", label: "Вчера" }, { value: "7d", label: "7 дней" }, -]; -export const parsePeriod = (v: string | null): Period => (PERIODS.some((p) => p.value === v) ? (v as Period) : "today"); - -export function periodRange(p: Period, now: Date): { since: Date; until: Date } { - const midnight = new Date(now.getFullYear(), now.getMonth(), now.getDate()); - switch (p) { - case "2h": return { since: new Date(now.getTime() - 2 * 3600_000), until: now }; - case "yesterday": return { since: new Date(now.getFullYear(), now.getMonth(), now.getDate() - 1), until: midnight }; - case "7d": return { since: new Date(now.getFullYear(), now.getMonth(), now.getDate() - 7, now.getHours(), now.getMinutes(), now.getSeconds(), now.getMilliseconds()), until: now }; - default: return { since: midnight, until: now }; - } -} - export const KINDS: { value: ChangeKind; label: string }[] = [ { value: "status", label: "Статусы" }, { value: "description", label: "Описание" }, { value: "dates", label: "Сроки" }, { value: "comment", label: "Комментарии" }, { value: "other", label: "Прочие поля" }, @@ -38,17 +21,6 @@ export function kindsParam(kinds: ChangeKind[]): string | null { return ordered.join(",") === DEFAULT_KINDS.join(",") ? null : ordered.join(","); } -const pad = (n: number) => String(n).padStart(2, "0"); - -// «ЧЧ:ММ» для событий текущих суток, иначе «ДД.ММ ЧЧ:ММ»; без времени — «—» -export function eventTime(at: string | null, now: Date, alwaysDate = false): string { - const d = at ? new Date(at) : null; - if (!d || isNaN(d.getTime())) return "—"; - const hm = `${pad(d.getHours())}:${pad(d.getMinutes())}`; - const sameDay = d.getFullYear() === now.getFullYear() && d.getMonth() === now.getMonth() && d.getDate() === now.getDate(); - return sameDay && !alwaysDate ? hm : `${pad(d.getDate())}.${pad(d.getMonth() + 1)} ${hm}`; -} - const FIELD_LABELS: Record = { assignee: "исполнитель", Sprint: "спринт", labels: "метки", priority: "приоритет", summary: "тема", epic_link: "эпик", Link: "связь", Attachment: "вложение", resolution: "резолюция", diff --git a/web/src/modules/jira/feed/feed.css b/web/src/modules/jira/feed/feed.css index 9f948e4..3dd2176 100644 --- a/web/src/modules/jira/feed/feed.css +++ b/web/src/modules/jira/feed/feed.css @@ -1,9 +1,5 @@ -/* лента изменений (spec web-app «Лента изменений»): только токены дизайн-системы */ -.feed{display:flex;flex-direction:column;gap:var(--space-4);padding-top:var(--space-6)} -.feed .ui-page-header{margin-bottom:0} -.feed__controls{display:flex;flex-wrap:wrap;align-items:center;gap:var(--space-3) var(--space-5)} +/* источник Jira в ленте (spec web-app «Лента изменений»): только токены дизайн-системы */ .feed__kinds{display:flex;flex-wrap:wrap;align-items:center;gap:var(--space-2) var(--space-4)} -.feed__hint{margin:0;color:var(--color-text-secondary)} .feed-group{display:flex;flex-direction:column;gap:var(--space-3);min-width:0} .feed-group__head{display:flex;flex-wrap:wrap;align-items:center;gap:var(--space-2)} .feed-group__key{font-family:var(--font-mono);font-size:13px;color:var(--color-action-primary);text-decoration:none} diff --git a/web/src/modules/jira/feed/source.tsx b/web/src/modules/jira/feed/source.tsx new file mode 100644 index 0000000..183a0d0 --- /dev/null +++ b/web/src/modules/jira/feed/source.tsx @@ -0,0 +1,59 @@ +import { useMemo } from "react"; +import { useSearchParams } from "react-router"; +import { Checkbox } from "../../../ui/index.ts"; +import type { FeedResult, FeedSource, FeedSourceInput } from "../../types.ts"; +import { useChanges } from "../api/queries.ts"; +import type { ChangeKind } from "../api/types.ts"; +import { groupByIssue, KINDS, kindsParam, parseKinds } from "./events.ts"; +import { FeedGroup } from "./FeedGroup.tsx"; +import "./feed.css"; + +// виды событий — настройка источника, хранится в адресе (`kinds=`); пустой набор — явное «none», иначе адрес вернул бы умолчание +function Kinds() { + const [params, setParams] = useSearchParams(); + const kinds = useMemo(() => parseKinds(params.get("kinds")), [params]); + const toggle = (k: ChangeKind, on: boolean) => { + const next = on ? [...kinds, k] : kinds.filter((x) => x !== k); + const value = next.length ? kindsParam(next) : "none"; + setParams((p) => { + const n = new URLSearchParams(p); + if (value) n.set("kinds", value); else n.delete("kinds"); + return n; + }); + }; + return ( +
+ {KINDS.map((k) => ( + toggle(k.value, e.currentTarget.checked)} /> + ))} +
+ ); +} + +const EMPTY: FeedResult = { groups: [], count: 0, window: null, pending: false, error: null, truncated: false, notice: null }; + +function useJiraFeed(input: FeedSourceInput): FeedResult { + const [params] = useSearchParams(); + const kinds = useMemo(() => parseKinds(params.get("kinds")), [params]); + // границы считает запрос в момент выполнения (C-1): ключ кэша — проект, период и виды + const q = useChanges({ project: input.project.label, period: input.period, kinds: kinds.join(",") }, input.bounds, + input.enabled && kinds.length > 0); + const data = input.enabled && kinds.length > 0 ? q.data : undefined; + const updatedAt = q.dataUpdatedAt; + const groups = useMemo(() => { + const now = new Date(updatedAt || Date.now()); // «текущие сутки» для времени событий — на момент ответа + return groupByIssue(data?.changes ?? []).map((g) => ({ + key: g.key, at: g.events[0]?.at ?? "", node: , + })); + }, [data, updatedAt]); + if (!input.enabled) return EMPTY; + if (!kinds.length) return { ...EMPTY, notice: "выберите хотя бы один вид" }; + return { + groups, count: data?.changes.length ?? 0, window: data ? { since: data.since, until: data.until } : null, + pending: q.isPending, truncated: !!data?.truncated, + error: q.isError ? { message: (q.error as Error).message, retry: () => void q.refetch() } : null, notice: null, + }; +} + +export const jiraFeed: FeedSource = { available: () => true, useFeed: useJiraFeed, Controls: Kinds }; diff --git a/web/src/modules/jira/index.tsx b/web/src/modules/jira/index.tsx index 8d8191c..8ca8e21 100644 --- a/web/src/modules/jira/index.tsx +++ b/web/src/modules/jira/index.tsx @@ -1,12 +1,12 @@ import { Navigate, useLocation, useParams } from "react-router"; import { ProjectGate } from "../../projects/ProjectGate.tsx"; -import { IconBacklog, IconBoard, IconHistory, IconKanban, IconList, IconSprint } from "../../ui/index.ts"; +import { IconBacklog, IconBoard, IconKanban, IconList, IconSprint } from "../../ui/index.ts"; import type { HubModule } from "../types.ts"; import { BacklogPage } from "./agile/BacklogPage.tsx"; import { KanbanPage } from "./agile/KanbanPage.tsx"; import { SprintPage } from "./agile/SprintPage.tsx"; import { BoardPage } from "./board/BoardPage.tsx"; -import { FeedPage } from "./feed/FeedPage.tsx"; +import { jiraFeed } from "./feed/source.tsx"; import { useIssueOverlay } from "./issues/overlay.tsx"; import { IssuesPage } from "./issues/IssuesPage.tsx"; import { boardType } from "./projectFields.ts"; @@ -30,13 +30,12 @@ export const jiraModule: HubModule = { { path: "/jira/sprint", element: {(p) => } }, { path: "/jira/kanban", element: {(p) => } }, { path: "/jira/issues", element: {(p) => } }, - { path: "/jira/feed", element: {(p) => } }, // прежние адреса (закладки): перенаправление с сохранением строки запроса, без новой записи истории { path: "/jira/board", element: }, { path: "/board", element: }, { path: "/issues", element: }, { path: "/issues/:key", element: }, - { path: "/feed", element: }, + { path: "/jira/feed", element: }, // лента — страница ядра ], nav: [ { to: "/jira/links", label: "Связи", icon: }, @@ -44,10 +43,10 @@ export const jiraModule: HubModule = { { to: "/jira/sprint", label: "Спринт", icon: , when: (p) => boardType(p) === "scrum" }, { to: "/jira/kanban", label: "Kanban", icon: , when: (p) => boardType(p) === "kanban" }, { to: "/jira/issues", label: "Задачи", icon: , padded: true }, - { to: "/jira/feed", label: "Лента", icon: , padded: true, keepSearch: true }, ], status: jiraStatus, Summary, useOverlay: useIssueOverlay, + feed: jiraFeed, projectFields: [{ name: "board_id", label: "Номер доски Jira", hint: "scrum или kanban определится сам", kind: "int" }], }; diff --git a/web/src/modules/modules.test.tsx b/web/src/modules/modules.test.tsx index 3328300..ffb9cac 100644 --- a/web/src/modules/modules.test.tsx +++ b/web/src/modules/modules.test.tsx @@ -6,12 +6,12 @@ import { MODULES } from "./index.ts"; describe("сборка оболочки из модулей", () => { it("страницы: «Обзор», адреса jira, перенаправления, вики, NotFound последним", () => { - expect(PAGE_ROUTES.map((r) => r.path)).toEqual(["/", "/jira/links", "/jira/backlog", "/jira/sprint", "/jira/kanban", "/jira/issues", "/jira/feed", "/jira/board", "/board", "/issues", "/issues/:key", "/feed", "/wiki", "*"]); + expect(PAGE_ROUTES.map((r) => r.path)).toEqual(["/", "/feed", "/jira/links", "/jira/backlog", "/jira/sprint", "/jira/kanban", "/jira/issues", "/jira/board", "/board", "/issues", "/issues/:key", "/jira/feed", "/wiki", "*"]); }); it("пункты панели: подписи прежние, адреса jira с префиксом, плюс вики", () => { expect(MODULES.flatMap((m) => m.nav).map((n) => [n.label, n.to])).toEqual([ - ["Связи", "/jira/links"], ["Бэклог", "/jira/backlog"], ["Спринт", "/jira/sprint"], ["Kanban", "/jira/kanban"], ["Задачи", "/jira/issues"], ["Лента", "/jira/feed"], ["Вики", "/wiki"], + ["Связи", "/jira/links"], ["Бэклог", "/jira/backlog"], ["Спринт", "/jira/sprint"], ["Kanban", "/jira/kanban"], ["Задачи", "/jira/issues"], ["Вики", "/wiki"], ]); }); @@ -42,7 +42,7 @@ describe("NotFound при повторе адреса", () => { const { NotFound: NF } = await import("../shell/NotFound.tsx"); const err = vi.spyOn(console, "error").mockImplementation(() => {}); render(); - expect(screen.getAllByRole("link")).toHaveLength(3); // «Обзор» + два раздела + expect(screen.getAllByRole("link")).toHaveLength(4); // «Обзор», «Лента» + два раздела expect(err.mock.calls.flat().join(" ")).not.toMatch(/same key/); err.mockRestore(); vi.doUnmock("../modules/index.ts"); diff --git a/web/src/modules/types.ts b/web/src/modules/types.ts index 75cd887..18a96c7 100644 --- a/web/src/modules/types.ts +++ b/web/src/modules/types.ts @@ -29,6 +29,31 @@ export type ModuleStatus = { invalidate(qc: QueryClient, project: string | null): void; // что перечитать после пакета модуля }; +// источник общей ленты (страница ядра «Лента», design wiki-history): ядро ведёт период, выбор источников и порядок групп, +// модуль — запросы, группы и свои настройки над лентой +export type FeedBounds = { since: Date; until: Date }; +export type FeedSourceInput = { + project: Project; + period: string; // ключ кэша: "2h" | "today" | "yesterday" | "7d" + bounds: () => FeedBounds; // границы на момент запроса (перечитывание берёт «до сейчас») + enabled: boolean; // источник выбран и доступен; false — запросов нет +}; +export type FeedGroup = { key: string; at: string; node: ReactNode }; // at — ISO события группы, по которому группы упорядочены +export type FeedResult = { + groups: FeedGroup[]; + count: number; // событий в ответе — для подзаголовка + window: { since: string; until: string } | null; // границы из ответа хаба; null — данных нет + pending: boolean; + error: { message: string; retry(): void } | null; + truncated: boolean; + notice: string | null; // своё сообщение на месте событий источника (тогда groups пусты) +}; +export type FeedSource = { + available(p: Project): boolean; + useFeed(input: FeedSourceInput): FeedResult; // хук: ядро вызывает его у всех модулей с feed в постоянном порядке + Controls?: ComponentType; // свои настройки над лентой; видны, пока источник выбран +}; + // описание веб-модуля (design hub-modules 10): оболочка берёт из него страницы, пункты панели, состояние данных, окна export type HubModule = { name: string; @@ -39,5 +64,6 @@ export type HubModule = { status?: ModuleStatus; Summary?: ComponentType<{ project: Project }>; // карточка модуля на «Обзоре» useOverlay?: () => Overlay; // вызывается оболочкой в постоянном порядке модулей + feed?: FeedSource; // источник ленты; id в адресе (`sources=`) — name, подпись флажка — title projectFields?: ProjectFieldDef[]; // поля проекта модуля: окно создания собирает их у всех модулей }; diff --git a/web/src/shell/OverviewPage.test.tsx b/web/src/shell/OverviewPage.test.tsx index e3f8900..537caf6 100644 --- a/web/src/shell/OverviewPage.test.tsx +++ b/web/src/shell/OverviewPage.test.tsx @@ -75,7 +75,7 @@ describe("страница «Обзор»", () => { expect(await jira.findByText((_, el) => el?.classList.contains("jira-summary__counts") === true && el.textContent === "4 задач · 3 открытых · 1 просрочено")).toBeInTheDocument(); expect(jira.getByRole("link", { name: "Связи" })).toHaveAttribute("href", "/app/jira/links?project=AWPCA"); expect(jira.getByRole("link", { name: "Задачи" })).toHaveAttribute("href", "/app/jira/issues?project=AWPCA"); - expect(jira.getByRole("link", { name: "Лента" })).toHaveAttribute("href", "/app/jira/feed?project=AWPCA"); + expect(jira.getByRole("link", { name: "Лента" })).toHaveAttribute("href", "/app/feed?project=AWPCA&sources=jira"); }); it("overview card without root: объяснение и «Привязать корень вики»", async () => { diff --git a/web/src/shell/Sidebar.test.tsx b/web/src/shell/Sidebar.test.tsx index a74b505..3b9b35e 100644 --- a/web/src/shell/Sidebar.test.tsx +++ b/web/src/shell/Sidebar.test.tsx @@ -54,10 +54,10 @@ describe("боковая панель", () => { it("раздел «Лента» ведёт на /feed с проектом и отмечается как текущий", async () => { hub({ "GET /changes": () => json(200, { since: "", until: "", truncated: false, changes: [] }) }); - open("/app/jira/feed?project=AWPCA"); + open("/app/feed?project=AWPCA"); expect(await screen.findByTestId("page-feed")).toBeInTheDocument(); const nav = within(bar()).getByRole("navigation", { name: "Разделы" }); - await waitFor(() => expect(within(nav).getByRole("link", { name: "Лента" })).toHaveAttribute("href", "/app/jira/feed?project=AWPCA")); + await waitFor(() => expect(within(nav).getByRole("link", { name: "Лента" })).toHaveAttribute("href", "/app/feed?project=AWPCA")); expect(within(nav).getByRole("link", { name: "Лента" })).toHaveAttribute("aria-current", "page"); expect(within(nav).getByRole("link", { name: "Задачи" })).not.toHaveAttribute("aria-current"); }); @@ -72,7 +72,7 @@ describe("боковая панель", () => { const main = within(screen.getByRole("main")); expect(main.getByRole("link", { name: "Связи" })).toHaveAttribute("href", "/app/jira/links"); expect(main.getByRole("link", { name: "Задачи" })).toHaveAttribute("href", "/app/jira/issues"); - expect(main.getByRole("link", { name: "Лента" })).toHaveAttribute("href", "/app/jira/feed"); // M-2 + expect(main.getByRole("link", { name: "Лента" })).toHaveAttribute("href", "/app/feed"); // M-2 expect(main.getByRole("link", { name: "Обзор" })).toHaveAttribute("href", expect.stringMatching(/^\/app\/?$/)); expect(screen.getByRole("alert")).toHaveTextContent("Страница не найдена"); }); @@ -133,9 +133,9 @@ describe("боковая панель", () => { open("/app/jira/links?project=AWPCA"); const nav = within(bar()).getByRole("navigation", { name: "Разделы" }); const links = (await within(nav).findAllByRole("link")).map((l) => l.textContent); - expect(links).toEqual(["Обзор", "Связи", "Задачи", "Лента", "Вики"]); + expect(links).toEqual(["Обзор", "Лента", "Связи", "Задачи", "Вики"]); const jira = within(nav).getByRole("group", { name: "Jira" }); - expect(within(jira).getAllByRole("link").map((l) => l.textContent)).toEqual(["Связи", "Задачи", "Лента"]); + expect(within(jira).getAllByRole("link").map((l) => l.textContent)).toEqual(["Связи", "Задачи"]); expect(within(nav).getByRole("group", { name: "Вики" })).toBeInTheDocument(); await waitFor(() => expect(within(nav).getByRole("link", { name: "Обзор" })).toHaveAttribute("href", expect.stringMatching(/^\/app\/?\?project=AWPCA$/))); }); diff --git a/web/src/shell/Sidebar.tsx b/web/src/shell/Sidebar.tsx index f8a013d..652015c 100644 --- a/web/src/shell/Sidebar.tsx +++ b/web/src/shell/Sidebar.tsx @@ -6,7 +6,7 @@ import type { Ref } from "react"; import { clearToken } from "../auth/token.ts"; import { useActivitySummary } from "./activity/activity.ts"; import { ModuleEventStrip, ModuleStatusBlock } from "./ModuleStatusBlock.tsx"; -import { GROUPS, OVERVIEW, visibleNav } from "./nav.tsx"; +import { FEED, GROUPS, OVERVIEW, visibleNav } from "./nav.tsx"; import { useProjectContext } from "../projects/ProjectContext.ts"; import { withProject } from "../projects/current.ts"; import { ProjectPicker } from "../projects/ProjectPicker.tsx"; @@ -122,6 +122,7 @@ function Panel({ section, project, drawer }: Props & { drawer?: { close(): void;