From 8062dc76683521f9a3b1a72e75769e2c1038e503 Mon Sep 17 00:00:00 2001 From: Andrey Epifancev Date: Sat, 24 Oct 2026 19:41:32 +0400 Subject: [PATCH] =?UTF-8?q?=D0=92=D0=B5=D0=B1:=20=C2=AB=D0=A1=D0=B2=D1=8F?= =?UTF-8?q?=D0=B7=D0=B8=C2=BB=20=D0=BD=D0=B0=20/app/jira/links,=20=D1=83?= =?UTF-8?q?=D1=81=D0=BB=D0=BE=D0=B2=D0=BD=D1=8B=D0=B5=20=D0=BF=D1=83=D0=BD?= =?UTF-8?q?=D0=BA=D1=82=D1=8B=20=D0=BC=D0=B5=D0=BD=D1=8E=20(#108)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Sonnet 5.5 --- ARCHITECTURE.md | 6 +- CLAUDE.md | 2 +- README.md | 4 +- web/e2e/board.spec.ts | 6 +- web/e2e/hub.ts | 4 +- web/e2e/issues.spec.ts | 2 +- web/e2e/layout.spec.ts | 12 +++- web/e2e/login.spec.ts | 4 +- web/e2e/projects.spec.ts | 4 +- web/e2e/screens.spec.ts | 2 +- web/src/App.test.tsx | 31 +++++++--- web/src/modules/jira/Summary.tsx | 2 +- web/src/modules/jira/index.tsx | 7 ++- .../modules/jira/issues/IssueModal.test.tsx | 16 +++--- web/src/modules/jira/issues/modal.test.ts | 2 +- web/src/modules/jira/issues/modal.ts | 4 +- web/src/modules/modules.test.tsx | 4 +- web/src/modules/types.ts | 1 + web/src/projects/projects.test.tsx | 25 ++++++--- web/src/shell/NotFound.tsx | 2 +- web/src/shell/OverviewPage.test.tsx | 2 +- web/src/shell/Sidebar.test.tsx | 56 +++++++++---------- web/src/shell/Sidebar.tsx | 7 ++- web/src/shell/nav.test.ts | 16 ++++++ web/src/shell/nav.tsx | 5 ++ 25 files changed, 140 insertions(+), 86 deletions(-) create mode 100644 web/src/shell/nav.test.ts diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index 854bb6f..f4c788d 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -98,15 +98,15 @@ его ядро: трекер пакетов и агентов — `web/src/shell/activity/` (`ActivityProvider`: подхват идущего пакета и `GET /agents` раз в 60 с, опрос пакета каждые 3 с, `useModuleActivity(module)`, тексты `events.ts`/`agentText.ts`), блок состояния панели — `ModuleStatusBlock`, а модуль даёт только `Summary` — карточку на «Обзоре» (`OverviewPage`: карточки модулей и «Последние пакеты проекта»). Адреса: «Обзор» — `/app/`, -страницы модуля — `/app/<модуль>/…` (`/app/jira/board`, `/app/jira/issues[/{key}]`, `/app/jira/feed`, `/app/wiki`); прежние `/app/board`, -`/app/issues`, `/app/issues/{key}`, `/app/feed` перенаправляются с сохранением `search` и `replace`. +страницы модуля — `/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`): пока проект не определён, условный пункт скрыт; «не найдено» перечисляет только безусловные. Модуль `wiki` (`web/src/modules/wiki/index.tsx`): страница «Вики» (`/app/wiki`): дерево (`Tree`) с поиском (`Search`), страница (`WikiPage`, `PageView` — `react-markdown` + `remark-gfm`, без `rehype-raw`; ссылки `page:` ведут на страницу вики, `attachment:` скачивают файл через `download.ts`, `urlTransform.ts` пропускает только эти схемы и http(s)), форма корня без привязки (`RootForm`), сводка для «Обзора» (`Summary`) и `status` для ядра; поле «Корень вики» в окне «Новый проект» — через `projectFields`. -Модуль `jira` (`web/src/modules/jira/index.tsx`): страницы «Доска связей» (`/app/jira/board`), «Задачи» (`/app/jira/issues`), «Лента» +Модуль `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`; оболочка рисует страницу по `location.state.background`). Внутри: `board/` (чистые `model`, `analysis`, `layout`, `storage.ts` — ключи `localStorage` прежней страницы, у проекта AWPCA прежние, у остальных с суффиксом `@<метка>`; отрисовка `components/`; diff --git a/CLAUDE.md b/CLAUDE.md index 8c66522..244cdce 100644 --- a/CLAUDE.md +++ b/CLAUDE.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/board?project=<метка>`, задачи — `/app/jira/issues?project=<метка>`, лента изменений — `/app/jira/feed?project=<метка>`, вики — `/app/wiki?project=<метка>`; вход по клиентскому токену); `/` и `/links` перенаправляют на «Обзор», прежние `/app/board`, `/app/issues`, `/app/feed` — на страницы модуля Jira. +- Веб-приложение — `http://127.0.0.1:8790/app/` («Обзор» — `/app/?project=<метка>`, «Связи» (доска связей) — `/app/jira/links?project=<метка>`, задачи — `/app/jira/issues?project=<метка>`, лента изменений — `/app/jira/feed?project=<метка>`, вики — `/app/wiki?project=<метка>`; вход по клиентскому токену); `/` и `/links` перенаправляют на «Обзор», прежние `/app/board`, `/app/jira/board`, `/app/issues`, `/app/feed` — на страницы модуля Jira; пункты меню могут зависеть от проекта (`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 4dc19b4..a5c71b6 100644 --- a/README.md +++ b/README.md @@ -246,8 +246,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/board?project=<метка>`, задачи `/app/jira/issues?project=<метка>`, лента изменений `/app/jira/feed?project=<метка>` (период и виды событий, история — и в карточке задачи), вики `/app/wiki?project=<метка>`, карточка `/app/jira/issues/{key}` — окно поверх списка или доски (закрытие — ✕, Esc, щелчок по фону; прямой адрес открывает окно поверх списка задач); `/` и -`/links` перенаправляют на «Обзор»; прежние адреса `/app/board`, `/app/issues`, `/app/feed` ведут на страницы модуля Jira. Вход по клиентскому токену (`uv run sdlc-hub tokens`), токен хранится в браузере; +Адреса: `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`), токен хранится в браузере; `localhost` и `127.0.0.1` — разные сайты для браузера, токен и раскладка у каждого свои. Выкатка на Mac mini: ``` diff --git a/web/e2e/board.spec.ts b/web/e2e/board.spec.ts index 6ccfce5..74cb14c 100644 --- a/web/e2e/board.spec.ts +++ b/web/e2e/board.spec.ts @@ -59,7 +59,7 @@ test("ⓘ открывает карточку окном поверх доски await expect(page).toHaveURL(/\/app\/jira\/issues\/AURORA-9001$/); await expect(page.getByRole("dialog").getByRole("heading", { name: "Схема свода" })).toBeVisible(); await expect(page.getByTestId("page-board")).toBeAttached(); - await expect(page.getByRole("link", { name: "Доска связей", exact: true })).toHaveAttribute("aria-current", "page"); + await expect(page.getByRole("link", { name: "Связи", exact: true })).toHaveAttribute("aria-current", "page"); }); test("M-3: прокрутка и данные доски после окна карточки и после другого раздела", async ({ page }) => { @@ -80,13 +80,13 @@ test("M-3: прокрутка и данные доски после окна к await page.locator(`.board .card[data-key="${key}"] a.open-card`).click(); await expect(page.getByRole("dialog")).toBeVisible(); await page.keyboard.press("Escape"); - await expect(page).toHaveURL(/\/app\/jira\/board\?project=AWPCA$/); + await expect(page).toHaveURL(/\/app\/jira\/links\?project=AWPCA$/); expect(await page.evaluate(() => window.scrollY)).toBeGreaterThan(y - 2); expect(boardLoads).toBe(0); // доска не перезагружалась // боковая панель закреплена (#69): ссылки разделов видны и после прокрутки, щелчок не двигает доску await page.getByRole("link", { name: "Задачи", exact: true }).click(); await expect(page).toHaveURL(/\/app\/jira\/issues\?project=AWPCA$/); - await page.getByRole("link", { name: "Доска связей", exact: true }).click(); + await page.getByRole("link", { name: "Связи", exact: true }).click(); await expect.poll(() => page.evaluate(() => window.scrollY)).toBeGreaterThan(y - 2); }); diff --git a/web/e2e/hub.ts b/web/e2e/hub.ts index 9641d4e..bb177da 100644 --- a/web/e2e/hub.ts +++ b/web/e2e/hub.ts @@ -5,12 +5,12 @@ import { HUB_HOME, HUB_URL } from "../playwright.config.ts"; export const tokens = () => JSON.parse(readFileSync(`${HUB_HOME}/tokens.json`, "utf8")) as { client: string; agents: Record }; const BASE = HUB_URL; -export async function login(page: Page, path = "/app/jira/board") { +export async function login(page: Page, path = "/app/jira/links") { await page.goto(path); await page.getByLabel("Клиентский токен хаба").fill(tokens().client); await page.getByRole("button", { name: "Войти" }).click(); // оболочка: на широком экране — ссылки разделов в боковой панели, на узком — кнопка «Меню» (#69) - await expect(page.getByRole("complementary", { name: "Панель приложения" }).getByRole("link", { name: "Доска связей" }).or(page.getByRole("button", { name: "Меню" }))).toBeVisible(); + await expect(page.getByRole("complementary", { name: "Панель приложения" }).getByRole("link", { name: "Связи" }).or(page.getByRole("button", { name: "Меню" }))).toBeVisible(); } async function call(path: string, body: unknown) { diff --git a/web/e2e/issues.spec.ts b/web/e2e/issues.spec.ts index a369251..999ca30 100644 --- a/web/e2e/issues.spec.ts +++ b/web/e2e/issues.spec.ts @@ -76,7 +76,7 @@ test("переход по связи и «назад»; ✕ и щелчок по await page.mouse.click(5, 300); // фон слева от окна await expect(page.getByRole("dialog")).toHaveCount(0); await page.goBack(); - await expect(page).toHaveURL(/\/app\/jira\/board\?project=AWPCA$/); + await expect(page).toHaveURL(/\/app\/jira\/links\?project=AWPCA$/); await expect(page.getByRole("dialog")).toHaveCount(0); }); diff --git a/web/e2e/layout.spec.ts b/web/e2e/layout.spec.ts index 2b9623a..e0fce73 100644 --- a/web/e2e/layout.spec.ts +++ b/web/e2e/layout.spec.ts @@ -49,7 +49,7 @@ test("C-1: на средних ширинах страница не шире о await expect(page.locator("table.issue-table tbody tr").first()).toBeVisible(); expect(await page.evaluate(() => document.documentElement.scrollWidth)).toBeLessThanOrEqual(1280); await page.setViewportSize({ width: 1000, height: 800 }); - await page.getByRole("link", { name: "Доска связей" }).click(); + await page.getByRole("link", { name: "Связи" }).click(); await expect(page.locator(".board .card[data-key]").first()).toBeVisible(); await page.getByRole("button", { name: "По датам" }).click(); await expect(page.locator(".dcol").first()).toBeVisible(); @@ -71,10 +71,16 @@ test("1440 px: «Задачи» не шире окна, h1 «Вики» — 20px expect(await h1.evaluate((el) => getComputedStyle(el).fontSize)).toBe("20px"); }); -test("/app/board → /app/jira/board с сохранением search", async ({ page }) => { +test("/app/board → /app/jira/links с сохранением search", async ({ page }) => { await login(page); await page.goto("/app/board?project=AWPCA"); - await expect(page).toHaveURL(/\/app\/jira\/board\?project=AWPCA$/); + await expect(page).toHaveURL(/\/app\/jira\/links\?project=AWPCA$/); +}); + +test("/app/jira/board → /app/jira/links с сохранением search", async ({ page }) => { + await login(page); + await page.goto("/app/jira/board?project=AWPCA"); + await expect(page).toHaveURL(/\/app\/jira\/links\?project=AWPCA$/); }); test("390 px: метка проекта в верхней строке, «Меню» модально", async ({ page }) => { diff --git a/web/e2e/login.spec.ts b/web/e2e/login.spec.ts index 9e6399e..c27418e 100644 --- a/web/e2e/login.spec.ts +++ b/web/e2e/login.spec.ts @@ -2,7 +2,7 @@ import { expect, test } from "@playwright/test"; import { login } from "./hub.ts"; test("неверный токен — пояснение; верный — доска", async ({ page }) => { - await page.goto("/app/jira/board"); + await page.goto("/app/jira/links"); await page.getByLabel("Клиентский токен хаба").fill("wrong-token"); await page.getByRole("button", { name: "Войти" }).click(); await expect(page.getByText(/Токен не подошёл/)).toBeVisible(); @@ -16,7 +16,7 @@ test("прежние адреса ведут на страницы модуле await expect(page).toHaveURL(/\/app\/?\?project=AWPCA$/); await expect(page.getByTestId("page-overview")).toBeVisible(); await page.goto("/app/board?project=AWPCA"); - await expect(page).toHaveURL(/\/app\/jira\/board\?project=AWPCA$/); + await expect(page).toHaveURL(/\/app\/jira\/links\?project=AWPCA$/); await page.goto("/app/issues/AURORA-9001?project=AWPCA"); await expect(page).toHaveURL(/\/app\/jira\/issues\/AURORA-9001\?project=AWPCA$/); }); diff --git a/web/e2e/projects.spec.ts b/web/e2e/projects.spec.ts index 3566d85..22270b6 100644 --- a/web/e2e/projects.spec.ts +++ b/web/e2e/projects.spec.ts @@ -5,7 +5,7 @@ import { agentServeRefresh, login } from "./hub.ts"; test.describe.serial("проекты", () => { test("первый вход: проект AWPCA в адресе, на доске задача проекта", async ({ page }) => { await login(page); - await expect(page).toHaveURL(/\/app\/jira\/board\?project=AWPCA$/); + await expect(page).toHaveURL(/\/app\/jira\/links\?project=AWPCA$/); await expect(page.locator('.board .card[data-key="AURORA-9001"]')).toBeVisible(); }); @@ -35,7 +35,7 @@ test.describe.serial("проекты", () => { }); test("переключение проекта и раздела", async ({ page }) => { - await login(page, "/app/jira/board?project=STOCKS_DRRK"); + await login(page, "/app/jira/links?project=STOCKS_DRRK"); await page.getByLabel("Проект", { exact: true }).selectOption({ label: "AWPCA — АРМ КА" }); await expect(page.locator('.board .card[data-key="AURORA-9001"]')).toBeVisible(); await page.getByRole("link", { name: "Задачи", exact: true }).click(); diff --git a/web/e2e/screens.spec.ts b/web/e2e/screens.spec.ts index 7959b5c..b4fdb4b 100644 --- a/web/e2e/screens.spec.ts +++ b/web/e2e/screens.spec.ts @@ -26,7 +26,7 @@ for (const theme of ["light", "dark"] as const) { }; test("login", async ({ page }) => { - await page.goto("/app/jira/board"); + await page.goto("/app/jira/links"); await expect(page.getByLabel("Клиентский токен хаба")).toBeVisible(); await settle(page); await shot(page, "login"); diff --git a/web/src/App.test.tsx b/web/src/App.test.tsx index 1de4031..792206e 100644 --- a/web/src/App.test.tsx +++ b/web/src/App.test.tsx @@ -20,29 +20,44 @@ describe("оболочка", () => { }); it("переход «Задачи» и обратно", async () => { - const router = at("/app/jira/board"); + const router = at("/app/jira/links"); render(); await userEvent.click(screen.getByRole("link", { name: "Задачи" })); expect(router.state.location.pathname).toBe("/app/jira/issues"); expect(router.state.location.search).toBe("?project=AWPCA"); expect(await screen.findByTestId("page-issues")).toBeInTheDocument(); - await userEvent.click(screen.getByRole("link", { name: "Доска связей" })); - expect(router.state.location.pathname).toBe("/app/jira/board"); + await userEvent.click(screen.getByRole("link", { name: "Связи" })); + expect(router.state.location.pathname).toBe("/app/jira/links"); expect(router.state.location.search).toBe("?project=AWPCA"); expect(await screen.findByTestId("page-board")).toBeInTheDocument(); }); + it("прежний адрес доски связей", async () => { + const router = at("/app/jira/board?project=AWPCA"); + render(); + await waitFor(() => expect(router.state.location.pathname + router.state.location.search).toBe("/app/jira/links?project=AWPCA")); + expect(await screen.findByRole("link", { name: "Связи" })).toHaveAttribute("aria-current", "page"); + await waitFor(() => expect(document.title).toBe("Связи · AWPCA · SDLC Hub")); + }); + + it("/app/board → сразу /app/jira/links", async () => { + const router = at("/app/board?project=AWPCA"); + render(); + await waitFor(() => expect(router.state.location.pathname).toBe("/app/jira/links")); + expect(router.state.location.search).toBe("?project=AWPCA"); + }); + it("неизвестный путь — «не найдено» со ссылками", async () => { render(); expect(await screen.findByRole("heading", { name: "Страница не найдена" })).toBeInTheDocument(); const main = within(screen.getByRole("main")); expect(main.getByRole("link", { name: "Обзор" })).toHaveAttribute("href", expect.stringMatching(/^\/app\/?$/)); - expect(main.getByRole("link", { name: "Доска связей" })).toHaveAttribute("href", "/app/jira/board"); + expect(main.getByRole("link", { name: "Связи" })).toHaveAttribute("href", "/app/jira/links"); expect(main.getByRole("link", { name: "Задачи" })).toHaveAttribute("href", "/app/jira/issues"); }); it("«Сменить токен» показывает экран входа", async () => { - render(); + render(); await userEvent.click(within(await screen.findByRole("complementary", { name: "Панель приложения" })).getByRole("button", { name: "Ещё" })); // #68 M-2: на доске есть ещё и «Ещё — доска» await userEvent.click(screen.getByRole("button", { name: "Сменить токен" })); expect(getAuth().token).toBe(""); @@ -59,13 +74,13 @@ describe("оболочка", () => { expect(await screen.findByText(/сломалось/)).toBeInTheDocument(); expect(screen.getByRole("button", { name: "Перезагрузить" })).toBeInTheDocument(); expect(screen.getAllByRole("alert")).toHaveLength(1); - expect(screen.getByRole("link", { name: "Доска связей" })).toBeInTheDocument(); + expect(screen.getByRole("link", { name: "Связи" })).toBeInTheDocument(); } finally { PAGE_ROUTES.splice(PAGE_ROUTES.indexOf(boom), 1); } }); it("без токена — только экран входа", () => { clearToken(); - render(); - expect(screen.queryByRole("link", { name: "Доска связей" })).not.toBeInTheDocument(); + render(); + expect(screen.queryByRole("link", { name: "Связи" })).not.toBeInTheDocument(); }); }); diff --git a/web/src/modules/jira/Summary.tsx b/web/src/modules/jira/Summary.tsx index 18a249a..467e255 100644 --- a/web/src/modules/jira/Summary.tsx +++ b/web/src/modules/jira/Summary.tsx @@ -24,7 +24,7 @@ export function Summary({ project }: { project: Project }) { {own.length} задач · {open.length} открытых · {late} просрочено

)} diff --git a/web/src/modules/jira/index.tsx b/web/src/modules/jira/index.tsx index 2fc7d5d..d00e39e 100644 --- a/web/src/modules/jira/index.tsx +++ b/web/src/modules/jira/index.tsx @@ -21,17 +21,18 @@ export const jiraModule: HubModule = { name: "jira", title: "Jira", pages: [ - { path: "/jira/board", element: {(p) => } }, + { path: "/jira/links", element: {(p) => } }, { path: "/jira/issues", element: {(p) => } }, { path: "/jira/feed", element: {(p) => } }, // прежние адреса (закладки): перенаправление с сохранением строки запроса, без новой записи истории - { path: "/board", element: }, + { path: "/jira/board", element: }, + { path: "/board", element: }, { path: "/issues", element: }, { path: "/issues/:key", element: }, { path: "/feed", element: }, ], nav: [ - { to: "/jira/board", label: "Доска связей", icon: }, + { to: "/jira/links", label: "Связи", icon: }, { to: "/jira/issues", label: "Задачи", icon: , padded: true }, { to: "/jira/feed", label: "Лента", icon: , padded: true, keepSearch: true }, ], diff --git a/web/src/modules/jira/issues/IssueModal.test.tsx b/web/src/modules/jira/issues/IssueModal.test.tsx index c58f40e..c646a4a 100644 --- a/web/src/modules/jira/issues/IssueModal.test.tsx +++ b/web/src/modules/jira/issues/IssueModal.test.tsx @@ -33,7 +33,7 @@ describe("карточка окном", () => { it("из списка: окно поверх списка, фон не пересоздаётся, ✕ возвращает со старым фильтром, «назад» не открывает карточку", async () => { sessionStorage.setItem("sdlc-hub-issue-filters", "1"); // панель «Фильтры» раскрыта - const router = at(["/app/jira/board", "/app/jira/issues?sprint=AWPCA_Спринт 2"]); + const router = at(["/app/jira/links", "/app/jira/issues?sprint=AWPCA_Спринт 2"]); render(); const table = await screen.findByRole("table"); expect(screen.getByLabelText("Спринт")).toHaveValue("AWPCA_Спринт 2"); @@ -49,7 +49,7 @@ describe("карточка окном", () => { expect(new URLSearchParams(router.state.location.search).get("sprint")).toBe("AWPCA_Спринт 2"); expect(within(screen.getByRole("main")).getByRole("table")).toBe(table); // таблица команд карточки — в окне, не в main await act(async () => { await router.navigate(-1); }); - expect(router.state.location.pathname).toBe("/app/jira/board"); + expect(router.state.location.pathname).toBe("/app/jira/links"); }); it("переход по связи меняет окно, «назад» возвращает прежнюю карточку, Esc закрывает всё", async () => { @@ -89,22 +89,22 @@ describe("карточка окном", () => { expect(router.state.location.pathname).toBe("/app/jira/issues/AURORA-9001"); }); - it("с доски: окно поверх доски, раздел «Доска связей» активен, после закрытия доска та же", async () => { - const router = at(["/app/jira/board"]); + it("с доски: окно поверх доски, раздел «Связи» активен, после закрытия доска та же", async () => { + const router = at(["/app/jira/links"]); render(); const ilink = await screen.findByRole("link", { name: "Открыть карточку AURORA-9001" }); const boardEl = screen.getByTestId("page-board"); await userEvent.click(ilink); expect(await within(dialog()).findByRole("heading", { name: /AURORA-9001/ })).toBeInTheDocument(); expect(screen.getByTestId("page-board")).toBe(boardEl); - expect(screen.getByRole("link", { name: "Доска связей" })).toHaveAttribute("aria-current", "page"); + expect(screen.getByRole("link", { name: "Связи" })).toHaveAttribute("aria-current", "page"); await userEvent.click(within(dialog()).getByRole("button", { name: "Закрыть" })); - await waitFor(() => expect(router.state.location.pathname).toBe("/app/jira/board")); + await waitFor(() => expect(router.state.location.pathname).toBe("/app/jira/links")); expect(screen.getByTestId("page-board")).toBe(boardEl); }); it("прямой адрес: под окном список задач, ✕ ведёт в список заменой записи", async () => { - const router = at(["/app/jira/board", "/app/jira/issues/AURORA-9001"]); + const router = at(["/app/jira/links", "/app/jira/issues/AURORA-9001"]); render(); expect(await within(await screen.findByRole("dialog")).findByRole("heading", { name: /AURORA-9001/ })).toBeInTheDocument(); expect(await screen.findByTestId("page-issues")).toBeInTheDocument(); @@ -112,7 +112,7 @@ describe("карточка окном", () => { await userEvent.click(within(dialog()).getByRole("button", { name: "Закрыть" })); await waitFor(() => expect(router.state.location.pathname).toBe("/app/jira/issues")); await act(async () => { await router.navigate(-1); }); - expect(router.state.location.pathname).toBe("/app/jira/board"); // не карточка + expect(router.state.location.pathname).toBe("/app/jira/links"); // не карточка }); it("прямой адрес и переход по связи: закрытие возвращает в список, а не на первую карточку", async () => { diff --git a/web/src/modules/jira/issues/modal.test.ts b/web/src/modules/jira/issues/modal.test.ts index b7e18db..d832581 100644 --- a/web/src/modules/jira/issues/modal.test.ts +++ b/web/src/modules/jira/issues/modal.test.ts @@ -15,7 +15,7 @@ describe("состояние окна в истории", () => { const s = { background: LIST_BACKGROUND, depth: 0, direct: true }; expect(openCardState({ pathname: "/jira/issues/A-1", search: "", state: s })).toEqual({ background: LIST_BACKGROUND, depth: 1, direct: true }); expect(openCardState({ pathname: "/jira/issues/A-2", search: "", state: BOARD_CARD_STATE })) - .toEqual({ background: { pathname: "/jira/board", search: "" }, depth: 2, direct: false }); + .toEqual({ background: { pathname: "/jira/links", search: "" }, depth: 2, direct: false }); }); it("чужое или битое state — не состояние окна", () => { expect(cardState(null)).toBeNull(); diff --git a/web/src/modules/jira/issues/modal.ts b/web/src/modules/jira/issues/modal.ts index ecd846c..df8beca 100644 --- a/web/src/modules/jira/issues/modal.ts +++ b/web/src/modules/jira/issues/modal.ts @@ -9,8 +9,8 @@ export function listBackground(cardSearch: string): Background { const project = new URLSearchParams(cardSearch).get("project"); return { pathname: ISSUES_PATH, search: project ? "?" + new URLSearchParams({ project }).toString() : "" }; } -// доска не хранит состояние в адресе: «ⓘ» всегда открывает окно поверх /jira/board -export const BOARD_CARD_STATE: CardState = { background: { pathname: "/jira/board", search: "" }, depth: 1, direct: false }; +// доска не хранит состояние в адресе: «ⓘ» всегда открывает окно поверх /jira/links +export const BOARD_CARD_STATE: CardState = { background: { pathname: "/jira/links", search: "" }, depth: 1, direct: false }; export function cardState(s: unknown): CardState | null { const c = s as { background?: { pathname?: unknown; search?: unknown }; depth?: unknown; direct?: unknown } | null; diff --git a/web/src/modules/modules.test.tsx b/web/src/modules/modules.test.tsx index a801327..39b0e5a 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/board", "/jira/issues", "/jira/feed", "/board", "/issues", "/issues/:key", "/feed", "/wiki", "*"]); + expect(PAGE_ROUTES.map((r) => r.path)).toEqual(["/", "/jira/links", "/jira/issues", "/jira/feed", "/jira/board", "/board", "/issues", "/issues/:key", "/feed", "/wiki", "*"]); }); it("пункты панели: подписи прежние, адреса jira с префиксом, плюс вики", () => { expect(MODULES.flatMap((m) => m.nav).map((n) => [n.label, n.to])).toEqual([ - ["Доска связей", "/jira/board"], ["Задачи", "/jira/issues"], ["Лента", "/jira/feed"], ["Вики", "/wiki"], + ["Связи", "/jira/links"], ["Задачи", "/jira/issues"], ["Лента", "/jira/feed"], ["Вики", "/wiki"], ]); }); diff --git a/web/src/modules/types.ts b/web/src/modules/types.ts index 379b94a..75cd887 100644 --- a/web/src/modules/types.ts +++ b/web/src/modules/types.ts @@ -7,6 +7,7 @@ export type NavItem = { to: string; label: string; icon: ReactNode; title?: string; // название страницы для заголовка вкладки; по умолчанию label padded?: boolean; // страница с полями (не во всю ширину): оболочка не ставит ей класс page--board; для адресов вне панели класс ставится + when?: (p: Project) => boolean; // пункт только для проектов, где условие верно; пока проект не определён (проекты не загружены) — скрыт keepSearch?: boolean; // при смене проекта строка запроса страницы сохраняется (параметры не проектные); иначе сбрасывается }; // окно поверх страницы (карточка задачи): pageLocation — адрес страницы под окном, null — окна нет diff --git a/web/src/projects/projects.test.tsx b/web/src/projects/projects.test.tsx index 406f9c6..84e683e 100644 --- a/web/src/projects/projects.test.tsx +++ b/web/src/projects/projects.test.tsx @@ -47,7 +47,7 @@ describe("проект в приложении", () => { it("первый вход после выкатки: проект подставляется в адрес и запоминается", async () => { const h = hub(); - const router = open("/app/jira/board"); + const router = open("/app/jira/links"); await waitFor(() => expect(router.state.location.search).toBe("?project=AWPCA")); expect(router.state.historyAction).toBe("REPLACE"); await waitFor(() => expect(h.get("/board").length).toBeGreaterThan(0)); @@ -57,7 +57,7 @@ describe("проект в приложении", () => { it("переключение проекта: доска, ссылка «Задачи», список и варианты фильтров", async () => { const h = hub(); - const router = open("/app/jira/board?project=AWPCA"); + const router = open("/app/jira/links?project=AWPCA"); await screen.findByTestId("page-board"); await userEvent.selectOptions(screen.getByRole("combobox", { name: "Проект" }), "STOCKS_DRRK"); expect(router.state.historyAction).toBe("PUSH"); @@ -90,7 +90,7 @@ describe("проект в приложении", () => { it("проектов нет: приглашение создать, без GET /board; щелчок открывает окно", async () => { const h = hub([]); - open("/app/jira/board"); + open("/app/jira/links"); await userEvent.click(await screen.findByRole("button", { name: "+ Новый проект…" })); expect(screen.getByRole("dialog", { name: "Новый проект" })).toHaveAttribute("open"); expect(h.get("/board")).toHaveLength(0); @@ -103,7 +103,7 @@ describe("проект в приложении", () => { "GET /projects": () => json(200, { projects: created ? [A, S] : [A] }), // блок состояния берёт проект из списка "GET /batches/7": () => json(200, { id: 7, author: "viewer", status: "queued", remaining_seconds: 5, counts: {}, commands: [{ type: "refresh", status: "queued" }] }), }); - const router = open("/app/jira/board?project=AWPCA"); + const router = open("/app/jira/links?project=AWPCA"); await screen.findByTestId("page-board"); const listed = h.get("/batches").length; await userEvent.selectOptions(screen.getByRole("combobox", { name: "Проект" }), "+ Новый проект…"); @@ -122,7 +122,7 @@ describe("проект в приложении", () => { it("с номером доски board_id уходит числом", async () => { const h = hub([A], { "POST /projects": () => json(201, { project: S, batch_id: 7, batches: [{ module: "jira", id: 7 }] }) }); - open("/app/jira/board?project=AWPCA"); + open("/app/jira/links?project=AWPCA"); await screen.findByTestId("page-board"); await userEvent.selectOptions(screen.getByRole("combobox", { name: "Проект" }), "+ Новый проект…"); const dlg = screen.getByRole("dialog", { name: "Новый проект" }); @@ -137,7 +137,7 @@ describe("проект в приложении", () => { it("метка занята: окно открыто, текст ошибки хаба рядом с «Создать»", async () => { hub([A], { "POST /projects": () => json(409, { error: "project_exists", detail: "label: проект с такой меткой уже есть: AWPCA" }) }); - open("/app/jira/board?project=AWPCA"); + open("/app/jira/links?project=AWPCA"); await screen.findByTestId("page-board"); await userEvent.selectOptions(screen.getByRole("combobox", { name: "Проект" }), "+ Новый проект…"); const dlg = screen.getByRole("dialog", { name: "Новый проект" }); @@ -155,7 +155,7 @@ describe("проект в приложении", () => { "POST /projects": () => json(201, { project: S, batch_id: 7, batches: [{ module: "jira", id: 7 }] }), "GET /batches/7": () => json(200, { id: 7, author: "viewer", status: "queued", remaining_seconds: 5, counts: {}, commands: [{ type: "refresh", status: "queued" }] }), }); - const router = open("/app/jira/board?project=AWPCA"); + const router = open("/app/jira/links?project=AWPCA"); await screen.findByTestId("page-board"); await userEvent.selectOptions(screen.getByRole("combobox", { name: "Проект" }), "+ Новый проект…"); const dlg = screen.getByRole("dialog", { name: "Новый проект" }); @@ -172,7 +172,7 @@ describe("проект в приложении", () => { let release!: () => void; const gate = new Promise((r) => { release = r; }); hub([A], { "POST /projects": async () => { await gate; return json(201, { project: S, batch_id: 7, batches: [{ module: "jira", id: 7 }] }); } }); - open("/app/jira/board?project=AWPCA"); + open("/app/jira/links?project=AWPCA"); await screen.findByTestId("page-board"); await userEvent.selectOptions(screen.getByRole("combobox", { name: "Проект" }), "+ Новый проект…"); const dlg = screen.getByRole("dialog", { name: "Новый проект" }); @@ -226,11 +226,18 @@ describe("адреса модулей и заголовок вкладки", () it("прежние /board, /issues, /feed перенаправляют с сохранением search, replace", async () => { hub(); const router = open("/app/board?project=STOCKS_DRRK&x=1"); - await waitFor(() => expect(router.state.location.pathname).toBe("/app/jira/board")); + await waitFor(() => expect(router.state.location.pathname).toBe("/app/jira/links")); expect(router.state.location.search).toBe("?project=STOCKS_DRRK&x=1"); expect(router.state.historyAction).toBe("REPLACE"); }); + it("прежний /jira/board перенаправляет на /jira/links с search, replace", async () => { + hub(); + const router = open("/app/jira/board?project=AWPCA"); + await waitFor(() => expect(router.state.location.pathname + router.state.location.search).toBe("/app/jira/links?project=AWPCA")); + expect(router.state.historyAction).toBe("REPLACE"); + }); + it("смена проекта на «Вики» у проекта без вики остаётся на «Вики»", async () => { hub([W, S], { "GET /wiki/status": () => json(409, { error: "no_root", detail: "нет корня" }) }); const router = open("/app/wiki?project=AWPCA"); diff --git a/web/src/shell/NotFound.tsx b/web/src/shell/NotFound.tsx index 6d56a88..ff5c865 100644 --- a/web/src/shell/NotFound.tsx +++ b/web/src/shell/NotFound.tsx @@ -7,7 +7,7 @@ export function NotFound() { return (
Страница не найдена}> - Разделы приложения: {NAV.map((n, i) => {i > 0 && ", "}{n.label})}. + Разделы приложения: {NAV.filter((n) => !n.when).map((n, i) => {i > 0 && ", "}{n.label})}.
); diff --git a/web/src/shell/OverviewPage.test.tsx b/web/src/shell/OverviewPage.test.tsx index 1bf34b7..e3f8900 100644 --- a/web/src/shell/OverviewPage.test.tsx +++ b/web/src/shell/OverviewPage.test.tsx @@ -73,7 +73,7 @@ describe("страница «Обзор»", () => { open(); const jira = within(await card("Jira")); 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/board?project=AWPCA"); + 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"); }); diff --git a/web/src/shell/Sidebar.test.tsx b/web/src/shell/Sidebar.test.tsx index d4e07ff..a74b505 100644 --- a/web/src/shell/Sidebar.test.tsx +++ b/web/src/shell/Sidebar.test.tsx @@ -43,13 +43,13 @@ const bar = () => screen.getByRole("complementary", { name: "Панель при describe("боковая панель", () => { beforeEach(() => { setToken("tok"); localStorage.clear(); }); - it("разделы — ссылки «Доска связей» и «Задачи», активная с aria-current", async () => { + it("разделы — ссылки «Связи» и «Задачи», активная с aria-current", async () => { hub(); open("/app/jira/issues?project=AWPCA"); - await screen.findByRole("link", { name: "Доска связей" }); - await waitFor(() => expect(screen.getByRole("link", { name: "Доска связей" })).toHaveAttribute("href", "/app/jira/board?project=AWPCA")); + await screen.findByRole("link", { name: "Связи" }); + await waitFor(() => expect(screen.getByRole("link", { name: "Связи" })).toHaveAttribute("href", "/app/jira/links?project=AWPCA")); expect(screen.getByRole("link", { name: "Задачи" })).toHaveAttribute("aria-current", "page"); - expect(screen.getByRole("link", { name: "Доска связей" })).not.toHaveAttribute("aria-current"); + expect(screen.getByRole("link", { name: "Связи" })).not.toHaveAttribute("aria-current"); }); it("раздел «Лента» ведёт на /feed с проектом и отмечается как текущий", async () => { @@ -67,10 +67,10 @@ describe("боковая панель", () => { open("/app/nowhere?project=AWPCA"); expect(await screen.findByRole("heading", { name: "Страница не найдена" })).toBeInTheDocument(); const nav = within(bar()).getByRole("navigation", { name: "Разделы" }); - expect(within(nav).getByRole("link", { name: "Доска связей" })).not.toHaveAttribute("aria-current"); + expect(within(nav).getByRole("link", { name: "Связи" })).not.toHaveAttribute("aria-current"); expect(within(nav).getByRole("link", { name: "Задачи" })).not.toHaveAttribute("aria-current"); const main = within(screen.getByRole("main")); - expect(main.getByRole("link", { name: "Доска связей" })).toHaveAttribute("href", "/app/jira/board"); + 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", expect.stringMatching(/^\/app\/?$/)); @@ -98,13 +98,13 @@ describe("боковая панель", () => { it("агент офлайн — «агент офлайн»; на связи — «агент на связи»", async () => { hub({ "GET /agents": () => json(200, { agents: [{ module: "jira", online: false, last_seen_at: "2026-10-14T09:30:00+00:00", warning: null, update_hint: null }] }) }); - open("/app/jira/board?project=AWPCA"); + open("/app/jira/links?project=AWPCA"); expect(await within(bar()).findByText("агент офлайн")).toBeInTheDocument(); }); it("агент на связи", async () => { hub(); - open("/app/jira/board?project=AWPCA"); + open("/app/jira/links?project=AWPCA"); expect(await within(bar()).findByText("агент на связи")).toBeInTheDocument(); }); @@ -122,7 +122,7 @@ describe("боковая панель", () => { it("блок состояния не грузит GET /wiki/status; у проекта без корня вики строки «Вики» нет", async () => { const h = hub(); - open("/app/jira/board?project=AWPCA"); + open("/app/jira/links?project=AWPCA"); await within(bar()).findByRole("button", { name: /^Обновить из Jira/ }); expect(within(bar()).queryByRole("button", { name: /^Обновить вики/ })).toBeNull(); expect(h.count("GET", "/wiki/status")).toBe(0); @@ -130,12 +130,12 @@ describe("боковая панель", () => { it("группы по модулям: «Обзор» первым, затем заголовки «Jira» и «Вики» со своими разделами", async () => { hub(); - open("/app/jira/board?project=AWPCA"); + 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$/))); }); @@ -143,7 +143,7 @@ describe("боковая панель", () => { it("свёрнутая: метка проекта целиком в title (M-4), у групп скрытые названия", async () => { localStorage.setItem("sdlc-hub-sidebar", "collapsed"); hub({ "GET /projects": () => json(200, { projects: [{ ...A, label: "STOCKS_DRRK_LONG" }] }) }); - open("/app/jira/board?project=STOCKS_DRRK_LONG"); + open("/app/jira/links?project=STOCKS_DRRK_LONG"); const tag = await within(bar()).findByRole("button", { name: "Проект STOCKS_DRRK_LONG — развернуть панель" }); expect(tag).toHaveAttribute("title", "Проект STOCKS_DRRK_LONG"); expect(within(bar()).getByRole("group", { name: "Jira" })).toBeInTheDocument(); @@ -151,7 +151,7 @@ describe("боковая панель", () => { it("меню «Ещё» → «Сменить токен» показывает экран входа", async () => { hub(); - open("/app/jira/board?project=AWPCA"); + open("/app/jira/links?project=AWPCA"); await userEvent.click(await screen.findByRole("button", { name: "Ещё" })); await userEvent.click(screen.getByRole("button", { name: "Сменить токен" })); expect(getAuth().token).toBe(""); @@ -160,13 +160,13 @@ describe("боковая панель", () => { it("переключатель темы — в панели", async () => { hub(); - open("/app/jira/board?project=AWPCA"); + open("/app/jira/links?project=AWPCA"); expect(await within(bar()).findByRole("switch", { name: "Тёмная тема" })).toBeInTheDocument(); }); it("кнопка «Обновить из Jira» в шапке доски — вторичная, рядом со значком панели «— все проекты»", async () => { hub(); - open("/app/jira/board?project=AWPCA"); + open("/app/jira/links?project=AWPCA"); await screen.findByTestId("page-board"); await screen.findByText(/задач из/); expect(screen.getAllByRole("button", { name: "Обновить из Jira" })).toHaveLength(1); @@ -175,21 +175,21 @@ describe("боковая панель", () => { it("разделы — в навигации «Разделы» боковой панели", async () => { hub(); - open("/app/jira/board?project=AWPCA"); + open("/app/jira/links?project=AWPCA"); const nav = within(bar()).getByRole("navigation", { name: "Разделы" }); - expect(await within(nav).findByRole("link", { name: "Доска связей" })).toHaveAttribute("aria-current", "page"); + expect(await within(nav).findByRole("link", { name: "Связи" })).toHaveAttribute("aria-current", "page"); }); it("«Свернуть панель» сворачивает и запоминает в sdlc-hub-sidebar; после перезапуска свёрнута, разделы доступны", async () => { hub(); - open("/app/jira/board?project=AWPCA"); + open("/app/jira/links?project=AWPCA"); const btn = await within(bar()).findByRole("button", { name: "Свернуть панель" }); expect(btn).toHaveAttribute("aria-expanded", "true"); await userEvent.click(btn); expect(localStorage.getItem("sdlc-hub-sidebar")).toBe("collapsed"); expect(bar()).toHaveClass("is-collapsed"); cleanup(); - open("/app/jira/board?project=AWPCA"); + open("/app/jira/links?project=AWPCA"); expect(bar()).toHaveClass("is-collapsed"); expect(within(bar()).getByRole("button", { name: "Развернуть панель" })).toHaveAttribute("aria-expanded", "false"); expect(within(bar()).getByRole("link", { name: "Задачи" })).toHaveAttribute("title", "Задачи"); @@ -213,7 +213,7 @@ describe("боковая панель", () => { it("свёрнутая: метка проекта видна, щелчок по ней разворачивает панель", async () => { localStorage.setItem("sdlc-hub-sidebar", "collapsed"); hub(); - open("/app/jira/board?project=AWPCA"); + open("/app/jira/links?project=AWPCA"); const tag = await within(bar()).findByRole("button", { name: "Проект AWPCA — развернуть панель" }); await userEvent.click(tag); expect(bar()).not.toHaveClass("is-collapsed"); @@ -229,7 +229,7 @@ describe("боковая панель на узком экране", () => { it("сверху строка с «Меню», меткой проекта и сводной точкой; кнопки обновления и панели нет", async () => { hub(); - open("/app/jira/board?project=AWPCA"); + open("/app/jira/links?project=AWPCA"); const top = await screen.findByRole("banner", { name: "Строка приложения" }); expect(within(top).getByRole("button", { name: "Меню" })).toHaveAttribute("aria-expanded", "false"); expect(await within(top).findByText("AWPCA")).toBeInTheDocument(); @@ -240,7 +240,7 @@ describe("боковая панель на узком экране", () => { it("«Меню» открывает панель с фокусом на первом разделе; Escape закрывает и возвращает фокус", async () => { hub(); - open("/app/jira/board?project=AWPCA"); + open("/app/jira/links?project=AWPCA"); const menu = await screen.findByRole("button", { name: "Меню" }); await userEvent.click(menu); expect(menu).toHaveAttribute("aria-expanded", "true"); @@ -254,7 +254,7 @@ describe("боковая панель на узком экране", () => { it("выбор раздела закрывает панель и открывает раздел", async () => { hub(); - const router = open("/app/jira/board?project=AWPCA"); + const router = open("/app/jira/links?project=AWPCA"); await userEvent.click(await screen.findByRole("button", { name: "Меню" })); await userEvent.click(within(bar()).getByRole("link", { name: "Задачи" })); expect(router.state.location.pathname).toBe("/app/jira/issues"); @@ -263,7 +263,7 @@ describe("боковая панель на узком экране", () => { it("щелчок по подложке и «Закрыть меню» закрывают панель", async () => { hub(); - open("/app/jira/board?project=AWPCA"); + open("/app/jira/links?project=AWPCA"); await userEvent.click(await screen.findByRole("button", { name: "Меню" })); await userEvent.click(screen.getByTestId("sidebar-backdrop")); expect(screen.queryByRole("complementary", { name: "Панель приложения" })).toBeNull(); @@ -274,7 +274,7 @@ describe("боковая панель на узком экране", () => { it("I-1: событие пакета видно строкой под верхней строкой в постоянном живом регионе", async () => { hub(); - open("/app/jira/board?project=AWPCA"); + open("/app/jira/links?project=AWPCA"); await screen.findByRole("banner", { name: "Строка приложения" }); const strip = screen.getByTestId("topbar-events"); expect(strip).toHaveAttribute("role", "status"); // T10.1: контейнер на месте до появления текста @@ -287,7 +287,7 @@ describe("боковая панель на узком экране", () => { it("drawer is modal: фокус в панели, страница инертна; Escape во вложенном меню закрывает только меню (M-1)", async () => { hub(); - open("/app/jira/board?project=AWPCA"); + open("/app/jira/links?project=AWPCA"); const menu = await screen.findByRole("button", { name: "Меню" }); await userEvent.click(menu); expect(within(bar()).getByRole("link", { name: "Обзор" })).toHaveFocus(); @@ -306,7 +306,7 @@ describe("боковая панель на узком экране", () => { it("#73 M-2: панель — модальное окно «Меню» (role=dialog, aria-modal), прокрутка страницы под ней заблокирована до закрытия", async () => { hub(); - open("/app/jira/board?project=AWPCA"); + open("/app/jira/links?project=AWPCA"); await userEvent.click(await screen.findByRole("button", { name: "Меню" })); const dlg = screen.getByRole("dialog", { name: "Меню" }); expect(dlg).toHaveAttribute("aria-modal", "true"); diff --git a/web/src/shell/Sidebar.tsx b/web/src/shell/Sidebar.tsx index 3e70d57..f8a013d 100644 --- a/web/src/shell/Sidebar.tsx +++ b/web/src/shell/Sidebar.tsx @@ -6,7 +6,8 @@ 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 } from "./nav.tsx"; +import { GROUPS, OVERVIEW, visibleNav } from "./nav.tsx"; +import { useProjectContext } from "../projects/ProjectContext.ts"; import { withProject } from "../projects/current.ts"; import { ProjectPicker } from "../projects/ProjectPicker.tsx"; import { IconButton, IconClose, IconMenu, IconSidebar, Menu, MenuItem, ThemeSwitch } from "../ui/index.ts"; @@ -75,6 +76,8 @@ function NarrowShell(p: Props) { // модулей, тема, меню «Ещё»; прокручивается середина, низ с меню «Ещё» остаётся на месте (M-5 #73) function Panel({ section, project, drawer }: Props & { drawer?: { close(): void; navigated(): void } }) { const qc = useQueryClient(); + const ctx = useProjectContext().state; + const current = ctx.kind === "ok" ? ctx.project : null; const [stored, setCollapsed] = useState(readSidebar); const collapsed = !drawer && stored; const fold = (c: boolean) => { setCollapsed(c); saveSidebar(c); }; @@ -122,7 +125,7 @@ function Panel({ section, project, drawer }: Props & { drawer?: { close(): void; {GROUPS.map((g) => (
{g.title} - {g.nav.map((n) => link(n))} + {visibleNav(g.nav, current).map((n) => link(n))}
))} diff --git a/web/src/shell/nav.test.ts b/web/src/shell/nav.test.ts new file mode 100644 index 0000000..c654dfa --- /dev/null +++ b/web/src/shell/nav.test.ts @@ -0,0 +1,16 @@ +import { describe, expect, it } from "vitest"; +import type { Project } from "../api/types.ts"; +import { visibleNav } from "./nav.tsx"; + +const A = { label: "AWPCA", name: "", created_at: "", board_type: "scrum" } as unknown as Project; +const items = [ + { to: "/a", label: "A", icon: null }, + { to: "/b", label: "B", icon: null, when: (p: Project) => p.board_type === "scrum" }, +]; +describe("visibleNav", () => { + it("условный пункт скрыт без проекта и при ложном условии", () => { + expect(visibleNav(items, null).map((n) => n.to)).toEqual(["/a"]); + expect(visibleNav(items, { ...A, board_type: null }).map((n) => n.to)).toEqual(["/a"]); + expect(visibleNav(items, A).map((n) => n.to)).toEqual(["/a", "/b"]); + }); +}); diff --git a/web/src/shell/nav.tsx b/web/src/shell/nav.tsx index b9fca92..7355641 100644 --- a/web/src/shell/nav.tsx +++ b/web/src/shell/nav.tsx @@ -1,4 +1,5 @@ import { MODULES } from "../modules/index.ts"; +import type { Project } from "../api/types.ts"; import type { NavItem } from "../modules/types.ts"; import { IconHome } from "../ui/index.ts"; @@ -6,3 +7,7 @@ import { IconHome } from "../ui/index.ts"; export const OVERVIEW: NavItem = { to: "/", label: "Обзор", icon: , padded: true }; export const GROUPS = MODULES.filter((m) => m.nav.length > 0).map((m) => ({ name: m.name, title: m.title, nav: m.nav })); export const NAV: NavItem[] = [OVERVIEW, ...GROUPS.flatMap((g) => g.nav)]; + +// пункты, видимые для проекта: безусловные — всегда, условные (`when`) — когда проект известен и условие верно +export const visibleNav = (items: NavItem[], project: Project | null): NavItem[] => + items.filter((n) => !n.when || (project !== null && n.when(project)));