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.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)));