From bc367111a7a8e4174cea452d6ed9ba15fa75b796 Mon Sep 17 00:00:00 2001 From: Andrey Epifancev Date: Sat, 24 Oct 2026 13:56:33 +0400 Subject: [PATCH] =?UTF-8?q?feat(web-modules):=20=D0=B1=D0=BE=D0=BA=D0=BE?= =?UTF-8?q?=D0=B2=D0=B0=D1=8F=20=D0=BF=D0=B0=D0=BD=D0=B5=D0=BB=D1=8C=20?= =?UTF-8?q?=D0=BF=D0=BE=20=D0=BC=D0=BE=D0=B4=D1=83=D0=BB=D1=8F=D0=BC,=20?= =?UTF-8?q?=D0=B1=D0=BB=D0=BE=D0=BA=20=D1=81=D0=BE=D1=81=D1=82=D0=BE=D1=8F?= =?UTF-8?q?=D0=BD=D0=B8=D1=8F,=20=D1=83=D0=B7=D0=BA=D0=B8=D0=B9=20=D1=8D?= =?UTF-8?q?=D0=BA=D1=80=D0=B0=D0=BD,=20=D0=B0=D0=B4=D1=80=D0=B5=D1=81?= =?UTF-8?q?=D0=B0=20=D0=BC=D0=BE=D0=B4=D1=83=D0=BB=D0=B5=D0=B9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Sonnet 5.5 --- openspec/changes/web-modules/design.md | 7 ++ web/src/App.test.tsx | 20 +-- web/src/modules/jira/board/board.css | 2 +- .../board/components/BoardCanvas.test.tsx | 2 +- .../jira/board/components/BoardHeader.tsx | 4 +- .../modules/jira/board/components/Card.tsx | 2 +- web/src/modules/jira/board/useBoardSource.ts | 2 +- web/src/modules/jira/feed/FeedPage.test.tsx | 28 ++--- web/src/modules/jira/index.tsx | 28 +++-- .../modules/jira/issues/IssueCard.test.tsx | 102 +++++++-------- web/src/modules/jira/issues/IssueLink.tsx | 2 +- .../modules/jira/issues/IssueModal.test.tsx | 46 +++---- .../modules/jira/issues/IssuesPage.test.tsx | 4 +- web/src/modules/jira/issues/modal.test.ts | 27 ++-- web/src/modules/jira/issues/modal.ts | 11 +- web/src/modules/jira/issues/overlay.tsx | 21 ++-- .../jira/issues/wiki/WikiText.test.tsx | 2 +- web/src/modules/modules.test.tsx | 10 +- web/src/modules/types.ts | 1 + web/src/projects/projects.test.tsx | 79 +++++++++--- web/src/shell/Layout.tsx | 13 +- web/src/shell/ModuleStatusBlock.test.tsx | 99 +++++++++++++++ .../StatusBlock.tsx => ModuleStatusBlock.tsx} | 50 ++++---- web/src/shell/NotFound.tsx | 4 +- web/src/shell/OverviewPage.tsx | 10 ++ web/src/shell/Sidebar.test.tsx | 116 ++++++++++++------ web/src/shell/Sidebar.tsx | 71 +++++++---- web/src/shell/nav.tsx | 8 ++ web/src/shell/pages.tsx | 10 +- web/src/shell/shell.css | 27 +++- web/src/shell/useDocumentTitle.ts | 8 ++ web/src/ui/Menu.tsx | 3 +- web/src/ui/icons.tsx | 1 + 33 files changed, 563 insertions(+), 257 deletions(-) create mode 100644 web/src/shell/ModuleStatusBlock.test.tsx rename web/src/shell/{activity/StatusBlock.tsx => ModuleStatusBlock.tsx} (51%) create mode 100644 web/src/shell/OverviewPage.tsx create mode 100644 web/src/shell/nav.tsx create mode 100644 web/src/shell/useDocumentTitle.ts diff --git a/openspec/changes/web-modules/design.md b/openspec/changes/web-modules/design.md index 37e53d6..20efa5d 100644 --- a/openspec/changes/web-modules/design.md +++ b/openspec/changes/web-modules/design.md @@ -133,3 +133,10 @@ type HubModule = { | 29 | 24.10.2026 | Task 3: сводка `useActivitySummary`: error > offline > busy > online | ошибка и офлайн агента важнее, чем «идёт пакет» | поменять порядок | | 30 | 24.10.2026 | Task 3: временный `shell/activity/StatusBlock.tsx` (строки модулей и строка событий узкого экрана) вместо `SidebarStatus`/`TopbarNotice` модулей; Task 4 заменяет его; страница вики берёт действие из трекера, уведомление об ошибке постановки (`syncError`) убрано — ошибка видна событием модуля | оболочка должна работать и набор быть зелёным до Task 4 | вернуть Notice на странице вики | | 31 | 24.10.2026 | Task 3, fix T3.I1: подхват после 409 (`pickup(true)`) заменяет отслеживаемый чужой пакет первым (самым старым) другим незавершённым пакетом модуля; если другого нет — чужой остаётся | детали 409 (`batch_id`) клиент не разбирает, а список без команд не различает типы | брать `batch_id` из `ApiError.detail` | +| 32 | 24.10.2026 | Task 4: «Обзор» (`/`) пока рисует `shell/OverviewPage.tsx` — заглушка с заголовком под `ProjectGate`; Task 5 заменяет её страницей со сводками | пункт «Обзор» и адрес `/` нужны панели и перенаправлениям уже сейчас | оставить `/` → `/jira/board` до Task 5 | +| 33 | 24.10.2026 | Task 4: пункты панели ядра — `shell/nav.tsx` (`OVERVIEW`, `GROUPS`, `NAV`); NotFound, Layout и Sidebar берут их оттуда; группа — `role="group"` с видимым названием модуля (в свёрнутой панели название скрыто) | один источник списка разделов для панели, «не найдено» и заголовка вкладки | свести в `MODULES.flatMap` по месту | +| 34 | 24.10.2026 | Task 4: список под окном карточки по прямому/прежнему адресу берёт `project` из адреса карточки (`listBackground(search)`), закрытие — `/jira/issues?project=…` | брифом: «закрытие окна — с текущим проектом»; константа `LIST_BACKGROUND` без проекта теряла выбор | вернуть константу, проект подставит Layout | +| 35 | 24.10.2026 | Task 4: модальность выезжающей панели — атрибут `inert` на соседях `aside` внутри `.shell` (страница и верхняя строка), фокус на «Меню» возвращается эффектом после закрытия; `Dropdown` гасит Escape (`preventDefault`) только когда сам открыт, а панель закрывается по Escape лишь при `!defaultPrevented` | ловушка Tab на списке фокусируемых хрупка; `inert` закрывает и чтение с экрана | ловушка фокуса вручную | +| 36 | 24.10.2026 | Task 4: `--topbar-h` удалён из `shell.css` и из `board.css` (`.drawer` на ширине страницы < 852 px статичен, а строка приложения бывает только на < 640 px) | M-6 #73: переменная мертва | вернуть переменную | +| 37 | 24.10.2026 | Task 4: прокручивается середина панели (`.sidebar__scroll`: разделы и блок состояния), низ с темой и меню «Ещё» остаётся вне прокрутки; поправка R-1 решения #69 (меню «Ещё» свёрнутой панели выходит за 64 px, при `overflow` на всей панели обрезалось бы) | M-5 #73: на низком окне панель не должна обрезаться | прокрутка всей панели и обрезанное меню | +| 38 | 24.10.2026 | Task 4: сводная точка узкого экрана — `role="img"` с `aria-label` «Состояние модулей: ошибка / идёт обновление / агент на связи / агент офлайн»; «идёт обновление» — нейтральный цвет (токена «info» нет); событие пакета узкого экрана — постоянный `role="status"` под строкой (`data-testid="topbar-events"`) | цвет не единственный признак; T10.1 #94 | вернуть `aria-hidden`-строку | diff --git a/web/src/App.test.tsx b/web/src/App.test.tsx index 6e08d5b..6f666c4 100644 --- a/web/src/App.test.tsx +++ b/web/src/App.test.tsx @@ -14,21 +14,20 @@ const A = { label: "AWPCA", name: "АРМ КА", board_id: null, board_type: nul describe("оболочка", () => { beforeEach(() => { setToken("tok"); mockFetch((_m, url) => json(200, url.pathname === "/projects" ? { projects: [A] } : {})); }); - it("/app ведёт на доску", async () => { + it("/app открывает «Обзор»", async () => { render(); - // страница сначала показывает заглушку «Загрузка настроек», затем её заменяет другой элемент с тем же testid - await waitFor(() => expect(screen.getByTestId("page-board")).toBeInTheDocument()); + await waitFor(() => expect(screen.getByTestId("page-overview")).toBeInTheDocument()); }); it("переход «Задачи» и обратно", async () => { - const router = at("/app/board"); + const router = at("/app/jira/board"); render(); await userEvent.click(screen.getByRole("link", { name: "Задачи" })); - expect(router.state.location.pathname).toBe("/app/issues"); + 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/board"); + expect(router.state.location.pathname).toBe("/app/jira/board"); expect(router.state.location.search).toBe("?project=AWPCA"); expect(await screen.findByTestId("page-board")).toBeInTheDocument(); }); @@ -37,12 +36,13 @@ describe("оболочка", () => { render(); expect(await screen.findByRole("heading", { name: "Страница не найдена" })).toBeInTheDocument(); const main = within(screen.getByRole("main")); - expect(main.getByRole("link", { name: "Доска связей" })).toHaveAttribute("href", "/app/board"); - expect(main.getByRole("link", { name: "Задачи" })).toHaveAttribute("href", "/app/issues"); + 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/issues"); }); it("«Сменить токен» показывает экран входа", async () => { - render(); + render(); await userEvent.click(await screen.findByRole("button", { name: "Ещё" })); await userEvent.click(screen.getByRole("button", { name: "Сменить токен" })); expect(getAuth().token).toBe(""); @@ -65,7 +65,7 @@ describe("оболочка", () => { it("без токена — только экран входа", () => { clearToken(); - render(); + render(); expect(screen.queryByRole("link", { name: "Доска связей" })).not.toBeInTheDocument(); }); }); diff --git a/web/src/modules/jira/board/board.css b/web/src/modules/jira/board/board.css index 1138a1f..93a296e 100644 --- a/web/src/modules/jira/board/board.css +++ b/web/src/modules/jira/board/board.css @@ -139,7 +139,7 @@ svg.guides line{stroke:var(--guide);stroke-width:1;stroke-dasharray:4 3} .who.none::before{background:transparent;border:1px solid var(--color-text-secondary)} /* 7. Панель проблем справа от доски, липнет под шапкой; прокручивается содержимое внутри рамки Panel */ -.drawer{flex:0 0 340px;max-width:40%;box-sizing:border-box;align-self:flex-start;position:sticky;top:calc(var(--topbar-h,0px) + var(--head-h,0px) + 8px);max-height:calc(100vh - var(--topbar-h,0px) - var(--head-h,0px) - 24px);display:flex;font-size:var(--fs-body)} +.drawer{flex:0 0 340px;max-width:40%;box-sizing:border-box;align-self:flex-start;position:sticky;top:calc(var(--head-h,0px) + 8px);max-height:calc(100vh - var(--head-h,0px) - 24px);display:flex;font-size:var(--fs-body)} .drawer>.ui-panel{display:flex;flex-direction:column;gap:var(--space-3);min-height:0;min-width:0;flex:1 1 auto;padding:var(--space-4) 0 var(--space-4) var(--space-4)} .drawer-head{display:flex;justify-content:space-between;align-items:center;gap:var(--space-2);padding-right:var(--space-4)} .drawer-body{overflow-y:auto;min-height:0;padding-right:var(--space-4)} diff --git a/web/src/modules/jira/board/components/BoardCanvas.test.tsx b/web/src/modules/jira/board/components/BoardCanvas.test.tsx index 8caab97..a56a83f 100644 --- a/web/src/modules/jira/board/components/BoardCanvas.test.tsx +++ b/web/src/modules/jira/board/components/BoardCanvas.test.tsx @@ -85,7 +85,7 @@ describe("BoardCanvas", () => { draw({ vis: computeVisible(model, { ...noFilters(), who: new Set(["Иванов Иван Иванович"]), neighbors: true }, [], "2026-10-14") }); const card = board().querySelector('.card[data-key="AURORA-9001"]') as HTMLElement; expect(within(card).getByRole("link", { name: "AURORA-9001" })).toHaveAttribute("href", "https://sdlc.go.rshbank.ru/jira/browse/AURORA-9001"); - expect(within(card).getByRole("link", { name: "Открыть карточку AURORA-9001" })).toHaveAttribute("href", "/issues/AURORA-9001"); + expect(within(card).getByRole("link", { name: "Открыть карточку AURORA-9001" })).toHaveAttribute("href", "/jira/issues/AURORA-9001"); const stub = board().querySelector('.card[data-key="AURORA-9500"]') as HTMLElement; expect(within(stub).queryByRole("link", { name: /Открыть карточку/ })).toBeNull(); }); diff --git a/web/src/modules/jira/board/components/BoardHeader.tsx b/web/src/modules/jira/board/components/BoardHeader.tsx index 8032ff5..189f88a 100644 --- a/web/src/modules/jira/board/components/BoardHeader.tsx +++ b/web/src/modules/jira/board/components/BoardHeader.tsx @@ -15,7 +15,7 @@ export function BoardHeader(p: BoardHeaderProps) { const [confirm, setConfirm] = useState(false); const fileRef = useRef(null); useEffect(() => { if (!confirm) return; const t = setTimeout(() => setConfirm(false), 8000); return () => clearTimeout(t); }, [confirm]); - const st = p.status ? statusText(p.status) : null; // только события доски; ход пакета «Обновить из Jira» — в верхней панели + const st = p.status ? statusText(p.status) : null; // только события доски; ход пакета «Обновить из Jira» — в блоке состояния модулей боковой панели const m = p.model, v = p.vis, r = v.removed; const coreShown = v.nodes.filter((n) => !n.dim).length, ctxShown = v.nodes.length - coreShown; const stats = ( @@ -68,7 +68,7 @@ export function BoardHeader(p: BoardHeaderProps) { из файла: {p.fileName} } onClick={p.onReload} /> - ) : null} {/* данные хаба пометки не имеют: хаб показывает верхняя панель (design.md, решение 13) */} + ) : null} {/* данные хаба пометки не имеют: состояние хаба показывает блок состояния модулей боковой панели */} {st && {st.text}} diff --git a/web/src/modules/jira/board/components/Card.tsx b/web/src/modules/jira/board/components/Card.tsx index 1290534..a8e5dd5 100644 --- a/web/src/modules/jira/board/components/Card.tsx +++ b/web/src/modules/jira/board/components/Card.tsx @@ -36,7 +36,7 @@ export const Card = memo(function Card({ node: n, box, rel, multiSprint, sprintS onPointerDown={onPointerDown ? (e) => onPointerDown(e, n.key) : undefined}>
{n.key} - {!n.stub && } {showTag && (n.sprint ? {n.sprint.replace(/^AWPCA_/, "")} diff --git a/web/src/modules/jira/board/useBoardSource.ts b/web/src/modules/jira/board/useBoardSource.ts index bcac7fc..e8ed36e 100644 --- a/web/src/modules/jira/board/useBoardSource.ts +++ b/web/src/modules/jira/board/useBoardSource.ts @@ -1,5 +1,5 @@ // источник данных доски: снимок хаба (GET /board) или файл (CSV/JSON). Обновление из Jira живёт в оболочке -// (refresh/RefreshProvider.tsx); здесь — явный источник (M-1) и одно событие состояния доски (M-5) +// (трекер пакетов ядра, shell/activity/); здесь — явный источник (M-1) и одно событие состояния доски (M-5) import { useQueryClient } from "@tanstack/react-query"; import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { AuthError, dropTokenOn, HubDownError } from "../../../api/client.ts"; diff --git a/web/src/modules/jira/feed/FeedPage.test.tsx b/web/src/modules/jira/feed/FeedPage.test.tsx index 3ac3009..a6e5810 100644 --- a/web/src/modules/jira/feed/FeedPage.test.tsx +++ b/web/src/modules/jira/feed/FeedPage.test.tsx @@ -59,7 +59,7 @@ describe("лента изменений", () => { vi.useFakeTimers({ toFake: ["Date"] }); vi.setSystemTime(new Date(2026, 9, 22, 10, 0)); const calls = hub(); - open("/app/feed?project=AWPCA"); + open("/app/jira/feed?project=AWPCA"); await page(); await waitFor(() => expect(calls.length).toBe(1)); expect(new Date(calls[0].searchParams.get("until")!).getTime()).toBe(new Date(2026, 9, 22, 10, 0).getTime()); @@ -75,7 +75,7 @@ describe("лента изменений", () => { ev({}), ev({ key: "AURORA-9001", summary: "Схема", status: "В работе", kind: "comment", field: "comment", from: null, to: "Готово к проверке" }), ev({ at: minutesAgo(40), kind: "description", field: "description" }), ] })); - open("/app/feed?project=AWPCA"); + open("/app/jira/feed?project=AWPCA"); await page(); const groups = await screen.findAllByTestId("feed-group"); expect(groups.map((g) => within(g).getByRole("link").textContent)).toEqual(["AURORA-9002", "AURORA-9001"]); @@ -95,12 +95,12 @@ describe("лента изменений", () => { it("«Что закрылось за два часа»: период и виды в адресе и в запросе", async () => { const calls = hub(); - const router = open("/app/feed?project=AWPCA"); + const router = open("/app/jira/feed?project=AWPCA"); await page(); await userEvent.click(screen.getByRole("radio", { name: "2 часа" })); for (const name of ["Описание", "Сроки", "Комментарии"]) await userEvent.click(screen.getByRole("checkbox", { name })); await waitFor(() => expect(router.state.location.search).toBe("?project=AWPCA&period=2h&kinds=status")); - expect(router.state.location.pathname).toBe("/app/feed"); + expect(router.state.location.pathname).toBe("/app/jira/feed"); await waitFor(() => expect(calls.at(-1)!.searchParams.get("kinds")).toBe("status")); const since = new Date(calls.at(-1)!.searchParams.get("since")!).getTime(); expect(Math.abs(Date.now() - 2 * 3600_000 - since)).toBeLessThan(60_000); @@ -108,7 +108,7 @@ describe("лента изменений", () => { it("карточка из ленты — окно поверх, закрытие возвращает ленту с тем же периодом", async () => { hub(() => ({ changes: [ev({})] })); - const router = open("/app/feed?project=AWPCA&period=7d"); + const router = open("/app/jira/feed?project=AWPCA&period=7d"); await page(); await userEvent.click(await screen.findByRole("link", { name: "AURORA-9002" })); const d = await screen.findByRole("dialog"); @@ -116,19 +116,19 @@ describe("лента изменений", () => { expect(screen.getByTestId("page-feed")).toBeInTheDocument(); await userEvent.click(within(d).getAllByRole("button", { name: "Закрыть" })[0]); await waitFor(() => expect(screen.queryByRole("dialog")).toBeNull()); - expect(router.state.location.pathname + router.state.location.search).toBe("/app/feed?project=AWPCA&period=7d"); + expect(router.state.location.pathname + router.state.location.search).toBe("/app/jira/feed?project=AWPCA&period=7d"); }); - it("M-1: /app/feed без project — проект дописывается в адрес", async () => { + it("M-1: /app/jira/feed без project — проект дописывается в адрес", async () => { hub(); - const router = open("/app/feed"); + const router = open("/app/jira/feed"); await page(); await waitFor(() => expect(router.state.location.search).toBe("?project=AWPCA")); }); it("M-3/M-6/M-7: подсказка truncated над группами, автор без имени — «—», группы не регионы", async () => { hub(() => ({ truncated: true, changes: [ev({ author: null })] })); - open("/app/feed?project=AWPCA"); + open("/app/jira/feed?project=AWPCA"); const notice = await screen.findByText("показаны последние 1 событий — выберите период короче"); const group = screen.getByTestId("feed-group"); expect(notice.compareDocumentPosition(group) & Node.DOCUMENT_POSITION_FOLLOWING).toBeTruthy(); @@ -140,7 +140,7 @@ describe("лента изменений", () => { let mode: "empty" | "truncated" | "error" = "empty"; hub(() => mode === "error" ? json(500, { error: "boom", detail: "сбой" }) : mode === "truncated" ? { truncated: true, changes: [ev({})] } : { changes: [] }); - open("/app/feed?project=AWPCA"); + open("/app/jira/feed?project=AWPCA"); expect(await screen.findByText("за период изменений нет")).toBeInTheDocument(); mode = "truncated"; await userEvent.click(screen.getByRole("radio", { name: "Вчера" })); @@ -155,7 +155,7 @@ describe("лента изменений", () => { it("все флажки сняты — подсказка, запроса нет", async () => { const calls = hub(); - open("/app/feed?project=AWPCA&kinds=status"); + open("/app/jira/feed?project=AWPCA&kinds=status"); await page(); await waitFor(() => expect(calls.length).toBe(1)); await userEvent.click(screen.getByRole("checkbox", { name: "Статусы" })); @@ -165,7 +165,7 @@ describe("лента изменений", () => { it("мусор в адресе: период по умолчанию, неизвестные виды отброшены", async () => { const calls = hub(); - open("/app/feed?project=AWPCA&period=foo&kinds=status,foo"); + open("/app/jira/feed?project=AWPCA&period=foo&kinds=status,foo"); await page(); await waitFor(() => expect(calls.length).toBeGreaterThan(0)); expect(screen.getByRole("radio", { name: "Сегодня" })).toHaveAttribute("aria-checked", "true"); @@ -176,12 +176,12 @@ describe("лента изменений", () => { it("смена проекта: запрос по новому проекту, период и виды сохраняются", async () => { const calls = hub(); - const router = open("/app/feed?project=AWPCA&period=2h&kinds=status"); + const router = open("/app/jira/feed?project=AWPCA&period=2h&kinds=status"); await page(); await userEvent.selectOptions(screen.getByRole("combobox", { name: "Проект" }), "STOCKS"); await waitFor(() => expect(calls.at(-1)!.searchParams.get("project")).toBe("STOCKS")); expect(calls.at(-1)!.searchParams.get("kinds")).toBe("status"); - expect(router.state.location.pathname).toBe("/app/feed"); + expect(router.state.location.pathname).toBe("/app/jira/feed"); expect(router.state.location.search).toBe("?project=STOCKS&period=2h&kinds=status"); }); }); diff --git a/web/src/modules/jira/index.tsx b/web/src/modules/jira/index.tsx index b8dd4b3..94322b3 100644 --- a/web/src/modules/jira/index.tsx +++ b/web/src/modules/jira/index.tsx @@ -1,4 +1,4 @@ -import { Navigate } from "react-router"; +import { Navigate, useLocation, useParams } from "react-router"; import { ProjectGate } from "../../projects/ProjectGate.tsx"; import { IconBoard, IconHistory, IconList } from "../../ui/index.ts"; import type { HubModule } from "../types.ts"; @@ -8,19 +8,31 @@ import { useIssueOverlay } from "./issues/overlay.tsx"; import { IssuesPage } from "./issues/IssuesPage.tsx"; import { jiraStatus } from "./status.ts"; +// прежний адрес → новый; keyParam — параметр пути (ключ задачи), который дописывается к новому адресу +function LegacyRedirect({ to, keyParam }: { to: string; keyParam?: string }) { + const { search } = useLocation(); + const params = useParams(); + const key = keyParam ? params[keyParam] : undefined; + return ; +} + export const jiraModule: HubModule = { name: "jira", title: "Jira", pages: [ - { path: "/", element: }, - { path: "/board", element: {(p) => } }, - { path: "/issues", element: {(p) => } }, - { path: "/feed", element: {(p) => } }, + { path: "/jira/board", element: {(p) => } }, + { path: "/jira/issues", element: {(p) => } }, + { path: "/jira/feed", element: {(p) => } }, + // прежние адреса (закладки): перенаправление с сохранением строки запроса, без новой записи истории + { path: "/board", element: }, + { path: "/issues", element: }, + { path: "/issues/:key", element: }, + { path: "/feed", element: }, ], nav: [ - { to: "/board", label: "Доска связей", icon: }, - { to: "/issues", label: "Задачи", icon: , padded: true }, - { to: "/feed", label: "Лента", icon: , padded: true, keepSearch: true }, + { to: "/jira/board", label: "Доска связей", icon: }, + { to: "/jira/issues", label: "Задачи", icon: , padded: true }, + { to: "/jira/feed", label: "Лента", icon: , padded: true, keepSearch: true }, ], status: jiraStatus, useOverlay: useIssueOverlay, diff --git a/web/src/modules/jira/issues/IssueCard.test.tsx b/web/src/modules/jira/issues/IssueCard.test.tsx index f1ba65f..4dc6284 100644 --- a/web/src/modules/jira/issues/IssueCard.test.tsx +++ b/web/src/modules/jira/issues/IssueCard.test.tsx @@ -24,7 +24,7 @@ function CardAt() { function open(path: string, client = makeQueryClient()) { const router = createMemoryRouter([ - { path: "/issues/:key", element: }, + { path: "/jira/issues/:key", element: }, { path: "/board", element:

доска

}, ], { initialEntries: [path] }); render(); @@ -43,7 +43,7 @@ describe("карточка задачи", () => { it("шапка: ключ, статус Jira, тема, исполнитель · срок · спринт · эпик по имени; ссылка в Jira", async () => { mockFetch((_m, url) => url.pathname === "/settings" ? json(200, settings) : json(200, issue("AURORA-9001", { planned_end: "2020-01-10" }))); - open("/issues/AURORA-9001"); + open("/jira/issues/AURORA-9001"); expect(await screen.findByRole("heading", { level: 2, name: "Тема AURORA-9001" })).toBeInTheDocument(); const head = screen.getByTestId("issue-head"); expect(within(head).getByText("AURORA-9001")).toBeInTheDocument(); @@ -62,11 +62,11 @@ describe("карточка задачи", () => { links_in: [{ source_key: "AURORA-9003", link_type: "ASUPR_Блокер_Завершения", summary: "Макет экрана", status: "Готово" }, { source_key: "AURORA-9008", link_type: "Duplicate", summary: "Актуализация ТЗ", status: "В работе" }], }))); - open("/issues/AURORA-9001"); + open("/jira/issues/AURORA-9001"); const depends = await screen.findByRole("group", { name: "Зависит от" }); expect(within(depends).getByRole("listitem")).toHaveTextContent("завершение AURORA-9003 Макет экрана Готово"); const blocks = screen.getByRole("group", { name: "Блокирует" }); - expect(within(blocks).getByRole("link", { name: "AURORA-9002" })).toHaveAttribute("href", "/issues/AURORA-9002"); + expect(within(blocks).getByRole("link", { name: "AURORA-9002" })).toHaveAttribute("href", "/jira/issues/AURORA-9002"); expect(within(blocks).getByRole("listitem")).toHaveTextContent("начало AURORA-9002 API свода Новая"); expect(within(screen.getByRole("group", { name: "Связана с" })).getByRole("listitem")).toHaveTextContent("lower-1 нет в зеркале"); expect(within(screen.getByRole("group", { name: "Прочие" })).getByRole("listitem")).toHaveTextContent("← Duplicate AURORA-9008"); @@ -75,7 +75,7 @@ describe("карточка задачи", () => { it("без связей — «связей нет»", async () => { mockFetch((_m, url) => url.pathname === "/settings" ? json(200, settings) : json(200, issue("AURORA-9001", { links_out: [], links_in: [] }))); - open("/issues/AURORA-9001"); + open("/jira/issues/AURORA-9001"); expect(await screen.findByText("связей нет")).toBeInTheDocument(); }); @@ -83,7 +83,7 @@ describe("карточка задачи", () => { const restore = overflow(500, 180); const long = Array.from({ length: 20 }, (_, n) => `абзац ${n + 1}`).join("\n\n"); mockFetch((_m, url) => url.pathname === "/settings" ? json(200, settings) : json(200, issue("AURORA-9001", { description: long }))); - open("/issues/AURORA-9001"); + open("/jira/issues/AURORA-9001"); const body = (await screen.findByText("абзац 1")).closest(".description")!; expect(body.tagName).toBe("DIV"); expect(body).toHaveClass("clamped"); @@ -104,7 +104,7 @@ describe("карточка задачи", () => { let restore = overflow(0, 0); const long = Array.from({ length: 20 }, (_, n) => `абзац ${n + 1}`).join("\n\n"); mockFetch((_m, url) => url.pathname === "/settings" ? json(200, settings) : json(200, issue("AURORA-9001", { description: long }))); - open("/issues/AURORA-9001"); + open("/jira/issues/AURORA-9001"); await screen.findByText("абзац 1"); expect(screen.queryByRole("button", { name: "развернуть" })).toBeNull(); restore(); @@ -118,7 +118,7 @@ describe("карточка задачи", () => { it("исходник длиннее 8 строк свёрнут построчно", async () => { const long = Array.from({ length: 10 }, (_, n) => `строка ${n + 1}`).join("\n"); mockFetch((_m, url) => url.pathname === "/settings" ? json(200, settings) : json(200, issue("AURORA-9001", { description: long }))); - open("/issues/AURORA-9001"); + open("/jira/issues/AURORA-9001"); await userEvent.click(await screen.findByRole("button", { name: "исходник" })); const pre = screen.getByText(long, { normalizer: (s) => s }); expect(pre).toHaveClass("description", "clamped"); @@ -129,7 +129,7 @@ describe("карточка задачи", () => { const restore = overflow(120, 180); const d = " * один\n\n * два\n\n * три\n\n * четыре\n\n * пять"; mockFetch((_m, url) => url.pathname === "/settings" ? json(200, settings) : json(200, issue("AURORA-9001", { description: d }))); - open("/issues/AURORA-9001"); + open("/jira/issues/AURORA-9001"); expect(await screen.findByText("пять")).toBeInTheDocument(); expect(screen.queryByRole("button", { name: "развернуть" })).toBeNull(); restore(); @@ -139,7 +139,7 @@ describe("карточка задачи", () => { const d = "h3. Контекст\n# Загрузить ОСВ\n# Открыть документ"; const comments = [{ comment_id: "1", author: "Иванов Иван", created: null, updated: null, body: "*важно*" }]; mockFetch((_m, url) => url.pathname === "/settings" ? json(200, settings) : json(200, issue("AURORA-9001", { description: d, comments }))); - open("/issues/AURORA-9001"); + open("/jira/issues/AURORA-9001"); expect(await screen.findByRole("heading", { level: 3, name: "Контекст" })).toBeInTheDocument(); const section = screen.getByRole("heading", { name: "Описание" }).closest("section")!; await userEvent.click(within(section).getByRole("button", { name: "исходник" })); @@ -155,13 +155,13 @@ describe("карточка задачи", () => { { comment_id: "2", author: "Петров", created: null, updated: null, body: "*b*" }, { comment_id: "1", author: "Иванов", created: null, updated: null, body: "*a*" }]; mockFetch((_m, url) => url.pathname === "/settings" ? json(200, settings) : json(200, issue(url.pathname.split("/").pop()!, { comments }))); - const router = open("/issues/AURORA-9001"); + const router = open("/jira/issues/AURORA-9001"); await screen.findByRole("heading", { name: "Комментарии (2)" }); const items = () => within(screen.getByTestId("comments")).getAllByRole("listitem"); await userEvent.click(within(items()[1]).getByRole("button", { name: "исходник" })); expect(items()[1].querySelector("pre.body")).toHaveTextContent("*a*"); expect(within(items()[0]).getByText("b").tagName).toBe("STRONG"); - await act(() => router.navigate("/issues/AURORA-9002")); + await act(() => router.navigate("/jira/issues/AURORA-9002")); await screen.findByRole("heading", { level: 2, name: "Тема AURORA-9002" }); await act(() => router.navigate(-1)); await screen.findByRole("heading", { level: 2, name: "Тема AURORA-9001" }); @@ -172,24 +172,24 @@ describe("карточка задачи", () => { it("ключ задачи проекта в комментарии — ссылка на карточку", async () => { const comments = [{ comment_id: "1", author: "Иванов", created: null, updated: null, body: "П. 1 ведётся в AURORA-9002, не UTF-8" }]; mockFetch((_m, url) => url.pathname === "/settings" ? json(200, settings) : json(200, issue(url.pathname.split("/").pop()!, { comments }))); - const router = open("/issues/AURORA-9001"); + const router = open("/jira/issues/AURORA-9001"); const comment = within(await screen.findByTestId("comments")); expect(comment.queryByRole("link", { name: "UTF-8" })).toBeNull(); await userEvent.click(comment.getByRole("link", { name: "AURORA-9002" })); - expect(router.state.location.pathname).toBe("/issues/AURORA-9002"); + expect(router.state.location.pathname).toBe("/jira/issues/AURORA-9002"); }); it("короткое описание — без кнопки; нет описания — нет раздела", async () => { let d: string | null = "строка 1\nстрока 2"; mockFetch((_m, url) => url.pathname === "/settings" ? json(200, settings) : json(200, issue("AURORA-9001", { description: d }))); - open("/issues/AURORA-9001"); + open("/jira/issues/AURORA-9001"); const body = (await screen.findByText(/строка 1/)).closest(".description")!; expect(body).toHaveTextContent("строка 1строка 2"); expect(body.querySelector("br")).not.toBeNull(); expect(screen.queryByRole("button", { name: "развернуть" })).toBeNull(); d = null; cleanup(); - open("/issues/AURORA-9001"); + open("/jira/issues/AURORA-9001"); await screen.findByRole("heading", { level: 2 }); expect(screen.queryByRole("heading", { name: "Описание" })).toBeNull(); }); @@ -199,7 +199,7 @@ describe("карточка задачи", () => { { comment_id: "2", author: "Петров Пётр", created: "2026-10-15T12:00:00.000+0300", updated: null, body: "новый\nвторая строка" }, { comment_id: "1", author: "Иванов Иван", created: "2026-10-14T12:00:00.000+0300", updated: null, body: "старый" }]; mockFetch((_m, url) => url.pathname === "/settings" ? json(200, settings) : json(200, issue("AURORA-9001", { comments }))); - open("/issues/AURORA-9001"); + open("/jira/issues/AURORA-9001"); expect(await screen.findByRole("heading", { name: "Комментарии (2)" })).toBeInTheDocument(); const items = within(screen.getByTestId("comments")).getAllByRole("listitem"); expect(items[0]).toHaveTextContent("Петров Пётр"); @@ -209,7 +209,7 @@ describe("карточка задачи", () => { expect(screen.queryByText("старый")).toBeNull(); expect(localStorage.getItem("sdlc-hub-comments-hidden")).toBe("1"); cleanup(); - open("/issues/AURORA-9001"); + open("/jira/issues/AURORA-9001"); expect(await screen.findByRole("button", { name: "Показать" })).toBeInTheDocument(); expect(screen.queryByText("старый")).toBeNull(); }); @@ -217,19 +217,19 @@ describe("карточка задачи", () => { it("комментарий без автора и текста не ломает карточку; без комментариев — «комментариев нет»", async () => { let comments = [{ comment_id: "1", author: null, created: null, updated: null, body: "" }]; mockFetch((_m, url) => url.pathname === "/settings" ? json(200, settings) : json(200, issue("AURORA-9001", { comments }))); - open("/issues/AURORA-9001"); + open("/jira/issues/AURORA-9001"); expect(await screen.findByRole("heading", { name: "Комментарии (1)" })).toBeInTheDocument(); expect(within(screen.getByTestId("comments")).getByRole("listitem")).toHaveTextContent("—"); comments = []; cleanup(); - open("/issues/AURORA-9001"); + open("/jira/issues/AURORA-9001"); expect(await screen.findByText("комментариев нет")).toBeInTheDocument(); expect(screen.queryByRole("button", { name: "Скрыть" })).toBeNull(); }); it("прочие поля и команды моста свёрнуты", async () => { mockFetch((_m, url) => url.pathname === "/settings" ? json(200, settings) : json(200, issue("AURORA-9001"))); - open("/issues/AURORA-9001"); + open("/jira/issues/AURORA-9001"); const other = await screen.findByRole("region", { name: "Прочие поля" }); expect(other).toHaveTextContent("Приоритет"); expect(other).toHaveTextContent("Высокий"); @@ -240,13 +240,13 @@ describe("карточка задачи", () => { it("заголовок карточки — h2 с id для aria-labelledby окна", async () => { mockFetch((_m, url) => url.pathname === "/settings" ? json(200, settings) : json(200, issue("AURORA-9001"))); - open("/issues/AURORA-9001"); + open("/jira/issues/AURORA-9001"); expect(await screen.findByRole("heading", { level: 2, name: /AURORA-9001/ })).toHaveAttribute("id", "issue-modal-title"); }); it("задачи нет в зеркале — сообщение и «Закрыть»", async () => { mockFetch((_m, url) => url.pathname === "/settings" ? json(200, settings) : json(404, { error: "not_found", detail: "issue" })); - const router = open("/issues/AURORA-1"); + const router = open("/jira/issues/AURORA-1"); expect(await screen.findByText("Задачи AURORA-1 нет в зеркале хаба.")).toBeInTheDocument(); await userEvent.click(screen.getByRole("button", { name: "Закрыть" })); expect(router.state.location.pathname).toBe("/board"); @@ -259,7 +259,7 @@ describe("карточка задачи", () => { if (m === "PUT") { put = body; return json(200, note({ rest_text: "тесты", done_text: null, updated_by: "web" })); } return json(200, issue("AURORA-9001")); }); - open("/issues/AURORA-9001"); + open("/jira/issues/AURORA-9001"); await userEvent.click(await screen.findByRole("button", { name: "Править" })); const rest = await screen.findByLabelText("Осталось"); await userEvent.clear(rest); await userEvent.type(rest, "тесты"); @@ -277,7 +277,7 @@ describe("карточка задачи", () => { if (m === "PUT") return json(422, { error: "bad_note", detail: "неизвестные поля заметки: x" }); return json(200, issue("AURORA-9001")); }); - open("/issues/AURORA-9001"); + open("/jira/issues/AURORA-9001"); await userEvent.click(await screen.findByRole("button", { name: "Править" })); const owner = await screen.findByLabelText("Заметка владельца"); await userEvent.type(owner, "проверить"); @@ -288,13 +288,13 @@ describe("карточка задачи", () => { it("уход с несохранёнными правками требует подтверждения", async () => { mockFetch((_m, url) => url.pathname === "/settings" ? json(200, settings) : json(200, issue("AURORA-9001"))); - const router = open("/issues/AURORA-9001"); + const router = open("/jira/issues/AURORA-9001"); await userEvent.click(await screen.findByRole("button", { name: "Править" })); await userEvent.type(await screen.findByLabelText("Заметка владельца"), "черновик"); await act(async () => { await router.navigate("/board"); }); expect(await screen.findByText("Есть несохранённые правки заметки. Уйти без сохранения?")).toBeInTheDocument(); await userEvent.click(screen.getByRole("button", { name: "Остаться" })); - expect(router.state.location.pathname).toBe("/issues/AURORA-9001"); + expect(router.state.location.pathname).toBe("/jira/issues/AURORA-9001"); expect(screen.getByLabelText("Заметка владельца")).toHaveValue("черновик"); }); @@ -306,8 +306,8 @@ describe("карточка задачи", () => { if (url.pathname === "/issues/AURORA-9001") return slow.promise; return json(200, issue("AURORA-9002")); }); - const router = open("/issues/AURORA-9001", client); - await act(async () => { await router.navigate("/issues/AURORA-9002"); }); + const router = open("/jira/issues/AURORA-9001", client); + await act(async () => { await router.navigate("/jira/issues/AURORA-9002"); }); expect(await screen.findByRole("heading", { name: /AURORA-9002/ })).toBeInTheDocument(); slow.resolve(json(200, issue("AURORA-9001"))); await waitFor(() => expect(client.getQueryData(qk.issue("AURORA-9001"))).toBeDefined()); @@ -326,11 +326,11 @@ describe("карточка задачи", () => { // 9002 уже в кэше: форма не размонтируется при переходе, её состояние переживает навигацию const client = makeQueryClient(); client.setQueryData(qk.issue("AURORA-9002"), issue("AURORA-9002")); - const router = open("/issues/AURORA-9001", client); + const router = open("/jira/issues/AURORA-9001", client); await userEvent.click(await screen.findByRole("button", { name: "Править" })); await userEvent.type(await screen.findByLabelText("Заметка владельца"), "черновик"); await userEvent.click(screen.getByRole("button", { name: "Сохранить" })); - await act(async () => { await router.navigate("/issues/AURORA-9002"); }); + await act(async () => { await router.navigate("/jira/issues/AURORA-9002"); }); await userEvent.click(await screen.findByRole("button", { name: "Уйти" })); expect(await screen.findByRole("heading", { name: /AURORA-9002/ })).toBeInTheDocument(); slowPut.resolve(json(200, note({ owner_note: "черновик", updated_by: "web" }))); @@ -347,7 +347,7 @@ describe("карточка задачи", () => { if (m === "PUT") return slowPut.promise; return json(200, issue("AURORA-9001")); }); - open("/issues/AURORA-9001"); + open("/jira/issues/AURORA-9001"); await userEvent.click(await screen.findByRole("button", { name: "Править" })); await userEvent.type(await screen.findByLabelText("Заметка владельца"), "раз"); await userEvent.click(screen.getByRole("button", { name: "Сохранить" })); @@ -368,7 +368,7 @@ describe("карточка задачи", () => { if (down) throw new TypeError("Failed to fetch"); return json(200, issue("AURORA-9001")); }); - open("/issues/AURORA-9001", client); + open("/jira/issues/AURORA-9001", client); expect(await screen.findByRole("heading", { name: /AURORA-9001/ })).toBeInTheDocument(); await act(async () => { await client.invalidateQueries({ queryKey: qk.issue("AURORA-9001") }); }); // повторная загрузка при данных в кэше expect(await screen.findByRole("alert")).toHaveTextContent("хаб не отвечает"); @@ -380,14 +380,14 @@ describe("карточка задачи", () => { it("O-11: задача без команд — текст вместо пустой таблицы", async () => { mockFetch((_m, url) => url.pathname === "/settings" ? json(200, settings) : json(200, issue("AURORA-9001", { commands: [] }))); - open("/issues/AURORA-9001"); + open("/jira/issues/AURORA-9001"); expect(await screen.findByText("команд по задаче не было")).toBeInTheDocument(); expect(screen.queryByRole("columnheader", { name: "Команда" })).toBeNull(); }); it("закрытая задача со сроком в прошлом — без пометки «просрочено»", async () => { mockFetch((_m, url) => url.pathname === "/settings" ? json(200, settings) : json(200, issue("AURORA-9001", { planned_end: "2020-01-10", status: "Готово" }))); - open("/issues/AURORA-9001"); + open("/jira/issues/AURORA-9001"); const facts = await screen.findByTestId("issue-facts"); expect(facts).toHaveTextContent("срок 10.01.2020"); expect(within(facts).queryByText("просрочено")).toBeNull(); @@ -395,7 +395,7 @@ describe("карточка задачи", () => { it("статуса по факту нет ни в чтении заметок, ни в форме", async () => { mockFetch((_m, url) => url.pathname === "/settings" ? json(200, settings) : json(200, issue("AURORA-9001"))); - open("/issues/AURORA-9001"); + open("/jira/issues/AURORA-9001"); const view = await screen.findByRole("region", { name: "Служебные заметки" }); expect(view).not.toHaveTextContent("Статус по факту"); expect(view).toHaveTextContent("Вид работы"); @@ -407,7 +407,7 @@ describe("карточка задачи", () => { it("заметки в режиме чтения: пустые — «—», время и автор; «Отмена» без правок — сразу", async () => { mockFetch((_m, url) => url.pathname === "/settings" ? json(200, settings) : json(200, issue("AURORA-9001"))); - open("/issues/AURORA-9001"); + open("/jira/issues/AURORA-9001"); const view = await screen.findByRole("region", { name: "Служебные заметки" }); expect(view).toHaveTextContent("Сделано"); expect(view).toHaveTextContent("схема"); @@ -420,7 +420,7 @@ describe("карточка задачи", () => { it("«Отмена» с правками спрашивает; «Отменить правки» возвращает чтение без изменений", async () => { mockFetch((_m, url) => url.pathname === "/settings" ? json(200, settings) : json(200, issue("AURORA-9001"))); - open("/issues/AURORA-9001"); + open("/jira/issues/AURORA-9001"); await userEvent.click(await screen.findByRole("button", { name: "Править" })); await userEvent.type(screen.getByLabelText("Заметка владельца"), "черновик"); await userEvent.click(screen.getByRole("button", { name: "Отмена" })); @@ -435,7 +435,7 @@ describe("карточка задачи", () => { it("заметки ещё нет — режим чтения с «—» и «Править»", async () => { mockFetch((_m, url) => url.pathname === "/settings" ? json(200, settings) : json(200, issue("AURORA-9001", { note: null }))); - open("/issues/AURORA-9001"); + open("/jira/issues/AURORA-9001"); const view = await screen.findByRole("region", { name: "Служебные заметки" }); expect(within(view).getAllByText("—")).toHaveLength(4); // вид работы, сделано, осталось, заметка владельца expect(within(view).getByRole("button", { name: "Править" })).toBeInTheDocument(); @@ -445,7 +445,7 @@ describe("карточка задачи", () => { const err = vi.spyOn(console, "error").mockImplementation(() => {}); const dup = { target_key: "AURORA-9002", link_type: "ASUPR_Блокер_Начала", summary: "API", status: "Новая" }; mockFetch((_m, url) => url.pathname === "/settings" ? json(200, settings) : json(200, issue("AURORA-9001", { links_out: [dup, dup] }))); - open("/issues/AURORA-9001"); + open("/jira/issues/AURORA-9001"); await screen.findAllByRole("link", { name: "AURORA-9002" }); expect(err.mock.calls.flat().join(" ")).not.toContain("same key"); err.mockRestore(); @@ -461,7 +461,7 @@ describe("история в карточке", () => { it("12 событий: видны 10 последних и «Показать все (12)», после нажатия — все", async () => { mockFetch((_m, url) => url.pathname === "/settings" ? json(200, settings) : json(200, issue("AURORA-9001", { changes: hist(12) }))); - open("/issues/AURORA-9001"); + open("/jira/issues/AURORA-9001"); const sec = await screen.findByTestId("history"); expect(within(sec).getByRole("heading", { name: "История (12)" })).toBeInTheDocument(); expect(within(sec).getAllByRole("listitem")).toHaveLength(10); @@ -473,12 +473,12 @@ describe("история в карточке", () => { it("пустая история — «изменений нет»; без поля changes (старый хаб) — блока нет", async () => { let body = issue("AURORA-9001", { changes: [] }); mockFetch((_m, url) => url.pathname === "/settings" ? json(200, settings) : json(200, body)); - open("/issues/AURORA-9001"); + open("/jira/issues/AURORA-9001"); const sec = await screen.findByTestId("history"); expect(within(sec).getByText("изменений нет")).toBeInTheDocument(); cleanup(); body = issue("AURORA-9002"); - open("/issues/AURORA-9002"); + open("/jira/issues/AURORA-9002"); await screen.findByRole("heading", { name: "Тема AURORA-9002" }); expect(screen.queryByTestId("history")).toBeNull(); }); @@ -498,7 +498,7 @@ describe("вложения в карточке", () => { it("раздел «Вложения (2)» после «Описание», новые сверху; размер, автор · дата", async () => { hub({}); - open("/issues/AURORA-1650"); + open("/jira/issues/AURORA-1650"); const sec = within(await screen.findByTestId("attachments")); expect(sec.getByRole("heading", { level: 3, name: "Вложения (2)" })).toBeInTheDocument(); const heads = screen.getAllByRole("heading", { level: 3 }).map((h) => h.textContent); @@ -514,20 +514,20 @@ describe("вложения в карточке", () => { it("без поля attachments (старый хаб) раздела нет", async () => { hub({ attachments: undefined, comments: [] }); - open("/issues/AURORA-1650"); + open("/jira/issues/AURORA-1650"); await screen.findByText("Описание"); expect(screen.queryByTestId("attachments")).toBeNull(); }); it("состояние ready — отметка «у хаба»", async () => { hub({ attachments: [att("31337", "скрин 1.png", { state: "ready" })], comments: [] }); - open("/issues/AURORA-1650"); + open("/jira/issues/AURORA-1650"); expect(within(await screen.findByTestId("attachments")).getByText("у хаба")).toBeInTheDocument(); }); it("25 МБ — ссылка «Открыть в Jira» с подсказкой вместо кнопки", async () => { hub({ attachments: [att("77", "запись.mp4", { size: 25 * 1024 ** 2, mime_type: "video/mp4" })], comments: [] }); - open("/issues/AURORA-1650"); + open("/jira/issues/AURORA-1650"); const sec = within(await screen.findByTestId("attachments")); const a = sec.getByRole("link", { name: "Открыть в Jira" }); expect(a).toHaveAttribute("href", "https://sdlc.go.rshbank.ru/jira/secure/attachment/77/%D0%B7%D0%B0%D0%BF%D0%B8%D1%81%D1%8C.mp4"); @@ -538,7 +538,7 @@ describe("вложения в карточке", () => { it("размер неизвестен — подпись и обычная кнопка", async () => { hub({ attachments: [att("5", "x.bin", { size: null })], comments: [] }); - open("/issues/AURORA-1650"); + open("/jira/issues/AURORA-1650"); const sec = within(await screen.findByTestId("attachments")); expect(sec.getByText(/размер неизвестен/)).toBeInTheDocument(); expect(sec.getByRole("button", { name: "Скачать" })).toBeInTheDocument(); @@ -546,7 +546,7 @@ describe("вложения в карточке", () => { it("плашка под комментарием и кнопка в тексте", async () => { hub({}); - open("/issues/AURORA-1650"); + open("/jira/issues/AURORA-1650"); const c = within(await screen.findByTestId("comments")); expect(c.getByRole("button", { name: "скрин 1.png · 234,4 КБ" })).toBeInTheDocument(); expect(c.getByRole("button", { name: "скрин 1.png" })).toBeInTheDocument(); @@ -556,7 +556,7 @@ describe("вложения в карточке", () => { it("«Загружается…» на всех кнопках одного вложения; чужие не затронуты", async () => { hub({}, (_m, url) => url.pathname === "/attachments/31337/fetch" || url.pathname === "/attachments/31337" ? json(200, { attachment_id: "31337", state: "fetching", error: null, command_id: 1, batch_id: 1 }) : undefined); - open("/issues/AURORA-1650"); + open("/jira/issues/AURORA-1650"); await userEvent.click(within(await screen.findByTestId("comments")).getByRole("button", { name: "скрин 1.png · 234,4 КБ" })); expect(await screen.findAllByText("Загружается…")).toHaveLength(3); for (const b of screen.getAllByText("Загружается…")) expect(b.closest("button")).toBeDisabled(); @@ -566,7 +566,7 @@ describe("вложения в карточке", () => { it("ошибка хаба — текст и «Повторить» под вложением", async () => { const f = hub({}, (_m, url) => url.pathname === "/attachments/31337/fetch" ? json(409, { error: "agent_too_old", detail: "агент v13 не умеет скачивать вложения — нужен v14" }) : undefined); - open("/issues/AURORA-1650"); + open("/jira/issues/AURORA-1650"); const sec = within(await screen.findByTestId("attachments")); await userEvent.click(sec.getAllByRole("button", { name: "Скачать" })[1]); expect(await sec.findByText("агент v13 не умеет скачивать вложения — нужен v14")).toBeInTheDocument(); diff --git a/web/src/modules/jira/issues/IssueLink.tsx b/web/src/modules/jira/issues/IssueLink.tsx index fd484ac..b214b55 100644 --- a/web/src/modules/jira/issues/IssueLink.tsx +++ b/web/src/modules/jira/issues/IssueLink.tsx @@ -2,7 +2,7 @@ import type { ReactNode } from "react"; import { Link, useLocation, useNavigate } from "react-router"; import { openCardState } from "./modal.ts"; -const cardPath = (key: string) => `/issues/${encodeURIComponent(key)}`; +const cardPath = (key: string) => `/jira/issues/${encodeURIComponent(key)}`; // переход к карточке задачи: окно поверх текущей страницы (или смена содержимого открытого окна) — как у ссылки-ключа export function useOpenIssue(): (key: string) => void { diff --git a/web/src/modules/jira/issues/IssueModal.test.tsx b/web/src/modules/jira/issues/IssueModal.test.tsx index 02f9f15..c58f40e 100644 --- a/web/src/modules/jira/issues/IssueModal.test.tsx +++ b/web/src/modules/jira/issues/IssueModal.test.tsx @@ -33,27 +33,27 @@ describe("карточка окном", () => { it("из списка: окно поверх списка, фон не пересоздаётся, ✕ возвращает со старым фильтром, «назад» не открывает карточку", async () => { sessionStorage.setItem("sdlc-hub-issue-filters", "1"); // панель «Фильтры» раскрыта - const router = at(["/app/board", "/app/issues?sprint=AWPCA_Спринт 2"]); + const router = at(["/app/jira/board", "/app/jira/issues?sprint=AWPCA_Спринт 2"]); render(); const table = await screen.findByRole("table"); expect(screen.getByLabelText("Спринт")).toHaveValue("AWPCA_Спринт 2"); await userEvent.click(await within(table).findByRole("link", { name: "AURORA-9002" })); - expect(router.state.location.pathname).toBe("/app/issues/AURORA-9002"); + expect(router.state.location.pathname).toBe("/app/jira/issues/AURORA-9002"); expect(await within(dialog()).findByRole("heading", { name: /AURORA-9002/ })).toBeInTheDocument(); expect(within(screen.getByRole("main")).getByRole("table")).toBe(table); // таблица команд карточки — в окне, не в main // фон не пересоздаётся expect(screen.getByLabelText("Спринт")).toHaveValue("AWPCA_Спринт 2"); // фильтр фона — из его адреса expect(screen.getByRole("link", { name: "Задачи" })).toHaveAttribute("aria-current", "page"); await userEvent.click(within(dialog()).getByRole("button", { name: "Закрыть" })); await waitFor(() => expect(screen.queryByRole("dialog")).toBeNull()); - expect(router.state.location.pathname).toBe("/app/issues"); + expect(router.state.location.pathname).toBe("/app/jira/issues"); 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/board"); + expect(router.state.location.pathname).toBe("/app/jira/board"); }); it("переход по связи меняет окно, «назад» возвращает прежнюю карточку, Esc закрывает всё", async () => { - const router = at(["/app/issues"]); + const router = at(["/app/jira/issues"]); render(); await userEvent.click(await screen.findByRole("link", { name: "AURORA-9001" })); await userEvent.click(await within(dialog()).findByRole("link", { name: "AURORA-9002" })); @@ -64,21 +64,21 @@ describe("карточка окном", () => { await within(dialog()).findByRole("heading", { name: /AURORA-9002/ }); fireEvent(dialog(), new Event("cancel", { cancelable: true })); await waitFor(() => expect(screen.queryByRole("dialog")).toBeNull()); - expect(router.state.location.pathname).toBe("/app/issues"); + expect(router.state.location.pathname).toBe("/app/jira/issues"); }); it("щелчок по фону окна закрывает его", async () => { - const router = at(["/app/issues"]); + const router = at(["/app/jira/issues"]); render(); await userEvent.click(await screen.findByRole("link", { name: "AURORA-9001" })); await within(dialog()).findByRole("heading", { name: /AURORA-9001/ }); await userEvent.click(dialog()); // цель — сам , не содержимое await waitFor(() => expect(screen.queryByRole("dialog")).toBeNull()); - expect(router.state.location.pathname).toBe("/app/issues"); + expect(router.state.location.pathname).toBe("/app/jira/issues"); }); it("I-2: выделение текста внутри окна, отпущенное над фоном, окно не закрывает", async () => { - const router = at(["/app/issues"]); + const router = at(["/app/jira/issues"]); render(); await userEvent.click(await screen.findByRole("link", { name: "AURORA-9001" })); const title = await within(dialog()).findByRole("heading", { name: /AURORA-9001/ }); @@ -86,11 +86,11 @@ describe("карточка окном", () => { fireEvent.pointerUp(dialog()); fireEvent.click(dialog()); // …отпустили над фоном: click — на expect(screen.getByRole("dialog")).toBeInTheDocument(); - expect(router.state.location.pathname).toBe("/app/issues/AURORA-9001"); + expect(router.state.location.pathname).toBe("/app/jira/issues/AURORA-9001"); }); it("с доски: окно поверх доски, раздел «Доска связей» активен, после закрытия доска та же", async () => { - const router = at(["/app/board"]); + const router = at(["/app/jira/board"]); render(); const ilink = await screen.findByRole("link", { name: "Открыть карточку AURORA-9001" }); const boardEl = screen.getByTestId("page-board"); @@ -99,34 +99,34 @@ describe("карточка окном", () => { expect(screen.getByTestId("page-board")).toBe(boardEl); 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/board")); + await waitFor(() => expect(router.state.location.pathname).toBe("/app/jira/board")); expect(screen.getByTestId("page-board")).toBe(boardEl); }); it("прямой адрес: под окном список задач, ✕ ведёт в список заменой записи", async () => { - const router = at(["/app/board", "/app/issues/AURORA-9001"]); + const router = at(["/app/jira/board", "/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(); await waitFor(() => expect(router.state.location.state).toMatchObject({ depth: 0, direct: true })); await userEvent.click(within(dialog()).getByRole("button", { name: "Закрыть" })); - await waitFor(() => expect(router.state.location.pathname).toBe("/app/issues")); + 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/board"); // не карточка + expect(router.state.location.pathname).toBe("/app/jira/board"); // не карточка }); it("прямой адрес и переход по связи: закрытие возвращает в список, а не на первую карточку", async () => { - const router = at(["/app/issues/AURORA-9001"]); + const router = at(["/app/jira/issues/AURORA-9001"]); render(); await userEvent.click(await within(await screen.findByRole("dialog")).findByRole("link", { name: "AURORA-9002" })); await within(dialog()).findByRole("heading", { name: /AURORA-9002/ }); fireEvent(dialog(), new Event("cancel", { cancelable: true })); - await waitFor(() => expect(router.state.location.pathname).toBe("/app/issues")); + await waitFor(() => expect(router.state.location.pathname).toBe("/app/jira/issues")); await waitFor(() => expect(screen.queryByRole("dialog")).toBeNull()); }); it("Esc с несохранённой заметкой спрашивает; «Остаться» оставляет окно с текстом", async () => { - const router = at(["/app/issues"]); + const router = at(["/app/jira/issues"]); render(); await userEvent.click(await screen.findByRole("link", { name: "AURORA-9001" })); await userEvent.click(await within(dialog()).findByRole("button", { name: "Править" })); @@ -135,12 +135,12 @@ describe("карточка окном", () => { fireEvent(dialog(), new Event("cancel", { cancelable: true })); expect(await within(dialog()).findByText("Есть несохранённые правки заметки. Уйти без сохранения?")).toBeInTheDocument(); await userEvent.click(within(dialog()).getByRole("button", { name: "Остаться" })); - expect(router.state.location.pathname).toBe("/app/issues/AURORA-9001"); + expect(router.state.location.pathname).toBe("/app/jira/issues/AURORA-9001"); expect(within(dialog()).getByLabelText("Заметка владельца")).toHaveValue("черновик"); }); it("T4.1: отказ от закрытия («Остаться») сбрасывает ожидание закрытия: «назад» на запись прямого адреса не уводит в список", async () => { - const router = at(["/app/issues/AURORA-9001"]); + const router = at(["/app/jira/issues/AURORA-9001"]); render(); await userEvent.click(await within(await screen.findByRole("dialog")).findByRole("link", { name: "AURORA-9002" })); await within(dialog()).findByRole("heading", { name: /AURORA-9002/ }); @@ -152,7 +152,7 @@ describe("карточка окном", () => { await userEvent.click(await within(dialog()).findByRole("button", { name: "Отменить правки" })); await act(async () => { await router.navigate(-1); }); expect(await within(dialog()).findByRole("heading", { name: /AURORA-9001/ })).toBeInTheDocument(); - expect(router.state.location.pathname).toBe("/app/issues/AURORA-9001"); + expect(router.state.location.pathname).toBe("/app/jira/issues/AURORA-9001"); }); it("T4.4: у окна есть имя в состоянии загрузки и 404", async () => { @@ -162,10 +162,10 @@ describe("карточка окном", () => { if (url.pathname === "/issues/AURORA-2") return json(404, { detail: "нет" }); return json(200, {}); }); - const r1 = render(); + const r1 = render(); expect(await screen.findByRole("dialog", { name: "AURORA-1" })).toBeInTheDocument(); r1.unmount(); - render(); + render(); expect(await screen.findByRole("dialog", { name: "AURORA-2" })).toBeInTheDocument(); }); }); diff --git a/web/src/modules/jira/issues/IssuesPage.test.tsx b/web/src/modules/jira/issues/IssuesPage.test.tsx index c9a3451..989c9f9 100644 --- a/web/src/modules/jira/issues/IssuesPage.test.tsx +++ b/web/src/modules/jira/issues/IssuesPage.test.tsx @@ -32,7 +32,7 @@ describe("список задач", () => { it("фильтры из адреса уходят в GET /issues; ключ ведёт на карточку", async () => { const fetch = hub(() => [row(1)]); renderWithProviders(, { route: "/issues?sprint=AWPCA_Спринт 2&neighbor=1", path: "/issues" }); - expect(await screen.findByRole("link", { name: "AURORA-9001" })).toHaveAttribute("href", "/issues/AURORA-9001"); + expect(await screen.findByRole("link", { name: "AURORA-9001" })).toHaveAttribute("href", "/jira/issues/AURORA-9001"); const q = issuesCalls(fetch)[0].searchParams; expect(q.get("project")).toBe("AWPCA"); expect(q.get("sprint")).toBe("AWPCA_Спринт 2"); @@ -325,7 +325,7 @@ describe("список задач", () => { hub(() => [row(1)]); const { router } = renderWithProviders(, { route: "/issues", path: "/issues" }); await userEvent.click(await screen.findByRole("cell", { name: "Задача 1" })); - await waitFor(() => expect(router.state.location.pathname).toBe("/issues/AURORA-9001")); + await waitFor(() => expect(router.state.location.pathname).toBe("/jira/issues/AURORA-9001")); }); it("выделенный текст в строке — без перехода", async () => { diff --git a/web/src/modules/jira/issues/modal.test.ts b/web/src/modules/jira/issues/modal.test.ts index 255902b..b7e18db 100644 --- a/web/src/modules/jira/issues/modal.test.ts +++ b/web/src/modules/jira/issues/modal.test.ts @@ -1,26 +1,31 @@ import { describe, expect, it } from "vitest"; -import { BOARD_CARD_STATE, cardState, LIST_BACKGROUND, openCardState } from "./modal.ts"; +import { BOARD_CARD_STATE, cardState, listBackground, openCardState } from "./modal.ts"; describe("состояние окна в истории", () => { + it("список под прямым адресом карточки берёт проект из её адреса", () => { + expect(listBackground("?project=AWPCA")).toEqual({ pathname: "/jira/issues", search: "?project=AWPCA" }); + expect(listBackground("")).toEqual({ pathname: "/jira/issues", search: "" }); + }); it("со страницы — фон = адрес страницы, глубина 1", () => { - expect(openCardState({ pathname: "/issues", search: "?sprint=S", state: null })) - .toEqual({ background: { pathname: "/issues", search: "?sprint=S" }, depth: 1, direct: false }); + expect(openCardState({ pathname: "/jira/issues", search: "?sprint=S", state: null })) + .toEqual({ background: { pathname: "/jira/issues", search: "?sprint=S" }, depth: 1, direct: false }); }); it("из окна — тот же фон, глубина + 1, признак прямого адреса сохраняется", () => { + const LIST_BACKGROUND = listBackground(""); const s = { background: LIST_BACKGROUND, depth: 0, direct: true }; - expect(openCardState({ pathname: "/issues/A-1", search: "", state: s })).toEqual({ background: LIST_BACKGROUND, depth: 1, direct: true }); - expect(openCardState({ pathname: "/issues/A-2", search: "", state: BOARD_CARD_STATE })) - .toEqual({ background: { pathname: "/board", search: "" }, depth: 2, direct: false }); + 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 }); }); it("чужое или битое state — не состояние окна", () => { expect(cardState(null)).toBeNull(); - expect(cardState({ background: "/issues", depth: 1 })).toBeNull(); - expect(cardState({ background: { pathname: "/issues", search: "" }, depth: "1" })).toBeNull(); - expect(cardState({ background: { pathname: "/issues", search: "" }, depth: 1 })) - .toEqual({ background: { pathname: "/issues", search: "" }, depth: 1, direct: false }); + expect(cardState({ background: "/jira/issues", depth: 1 })).toBeNull(); + expect(cardState({ background: { pathname: "/jira/issues", search: "" }, depth: "1" })).toBeNull(); + expect(cardState({ background: { pathname: "/jira/issues", search: "" }, depth: 1 })) + .toEqual({ background: { pathname: "/jira/issues", search: "" }, depth: 1, direct: false }); }); it("глубина — только целое неотрицательное, иначе 0", () => { - const bg = { pathname: "/issues", search: "" }; + const bg = { pathname: "/jira/issues", search: "" }; for (const depth of [-1, 1.5, NaN, Infinity]) expect(cardState({ background: bg, depth })?.depth).toBe(0); expect(cardState({ background: bg, depth: 3 })?.depth).toBe(3); }); diff --git a/web/src/modules/jira/issues/modal.ts b/web/src/modules/jira/issues/modal.ts index 6c66b9c..ecd846c 100644 --- a/web/src/modules/jira/issues/modal.ts +++ b/web/src/modules/jira/issues/modal.ts @@ -3,9 +3,14 @@ export interface Background { pathname: string; search: string } export interface CardState { background: Background; depth: number; direct: boolean } -export const LIST_BACKGROUND: Background = { pathname: "/issues", search: "" }; -// доска не хранит состояние в адресе: «ⓘ» всегда открывает окно поверх /board -export const BOARD_CARD_STATE: CardState = { background: { pathname: "/board", search: "" }, depth: 1, direct: false }; +export const ISSUES_PATH = "/jira/issues"; +// список под окном карточки по прямому адресу: проект берётся из адреса карточки, чтобы список и закрытие остались в нём +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 }; 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/jira/issues/overlay.tsx b/web/src/modules/jira/issues/overlay.tsx index 47a1834..ad3b173 100644 --- a/web/src/modules/jira/issues/overlay.tsx +++ b/web/src/modules/jira/issues/overlay.tsx @@ -3,16 +3,17 @@ import { matchPath, useLocation, useNavigate } from "react-router"; import type { Overlay } from "../../types.ts"; import { CloseAbortContext } from "./closeAbort.ts"; import { IssueModal } from "./IssueModal.tsx"; -import { cardState, LIST_BACKGROUND } from "./modal.ts"; +import { cardState, ISSUES_PATH, listBackground } from "./modal.ts"; -// окно карточки задачи /issues/{key} поверх страницы под ним (design 10–12 веб-приложения) +// окно карточки задачи /jira/issues/{key} поверх страницы под ним (design 10–12 веб-приложения) export function useIssueOverlay(): Overlay { const location = useLocation(); const navigate = useNavigate(); - const issueKey = matchPath("/issues/:key", location.pathname)?.params.key ?? null; + const issueKey = matchPath(`${ISSUES_PATH}/:key`, location.pathname)?.params.key ?? null; const card = issueKey ? cardState(location.state) : null; - const bgPath = issueKey ? (card?.background ?? LIST_BACKGROUND).pathname : null; - const bgSearch = issueKey ? (card?.background ?? LIST_BACKGROUND).search : ""; + const direct = listBackground(location.search); + const bgPath = issueKey ? (card?.background ?? direct).pathname : null; + const bgSearch = issueKey ? (card?.background ?? direct).search : ""; // адрес страницы под окном; оболочка передаёт его в useRoutes всегда (и без окна), чтобы дерево страницы не менялось при // открытии окна и useSearchParams внутри неё видел её собственный адрес (design 10) const pageLocation = useMemo(() => bgPath === null ? null : { pathname: bgPath, search: bgSearch, hash: "", state: null, key: "card-background" }, @@ -21,7 +22,7 @@ export function useIssueOverlay(): Overlay { // прямой адрес карточки: запись истории получает state окна поверх списка задач (design 11) useEffect(() => { if (issueKey && !card) { - void navigate(location.pathname + location.search, { replace: true, state: { background: LIST_BACKGROUND, depth: 0, direct: true } }); + void navigate(location.pathname + location.search, { replace: true, state: { background: listBackground(location.search), depth: 0, direct: true } }); } }, [issueKey, card, location.pathname, location.search, navigate]); @@ -30,14 +31,14 @@ export function useIssueOverlay(): Overlay { useEffect(() => { if (closingDirect.current && card?.direct && card.depth === 0) { closingDirect.current = false; - void navigate("/issues", { replace: true }); + void navigate(ISSUES_PATH + direct.search, { replace: true }); } - }, [card, navigate]); + }, [card, navigate, direct.search]); const close = useCallback(() => { - if (!card || card.depth === 0) { void navigate("/issues", { replace: true }); return; } + if (!card || card.depth === 0) { void navigate(ISSUES_PATH + direct.search, { replace: true }); return; } closingDirect.current = card.direct; void navigate(-card.depth); - }, [card, navigate]); + }, [card, navigate, direct.search]); const abortClose = useCallback(() => { closingDirect.current = false; }, []); diff --git a/web/src/modules/jira/issues/wiki/WikiText.test.tsx b/web/src/modules/jira/issues/wiki/WikiText.test.tsx index 7abd1e0..f525d9f 100644 --- a/web/src/modules/jira/issues/wiki/WikiText.test.tsx +++ b/web/src/modules/jira/issues/wiki/WikiText.test.tsx @@ -38,7 +38,7 @@ describe("WikiText", () => { it("ключ задачи проекта — ссылка на карточку; без projectKey — текст", () => { const { unmount } = show("см. AURORA-1445", "AURORA"); - expect(screen.getByRole("link", { name: "AURORA-1445" })).toHaveAttribute("href", "/issues/AURORA-1445"); + expect(screen.getByRole("link", { name: "AURORA-1445" })).toHaveAttribute("href", "/jira/issues/AURORA-1445"); unmount(); show("см. AURORA-1445"); expect(screen.queryByRole("link")).toBeNull(); diff --git a/web/src/modules/modules.test.tsx b/web/src/modules/modules.test.tsx index d90428d..a801327 100644 --- a/web/src/modules/modules.test.tsx +++ b/web/src/modules/modules.test.tsx @@ -5,13 +5,13 @@ import { PAGE_ROUTES } from "../shell/pages.tsx"; import { MODULES } from "./index.ts"; describe("сборка оболочки из модулей", () => { - it("страницы: адреса прежние плюс вики, NotFound последним", () => { - expect(PAGE_ROUTES.map((r) => r.path)).toEqual(["/", "/board", "/issues", "/feed", "/wiki", "*"]); + it("страницы: «Обзор», адреса jira, перенаправления, вики, NotFound последним", () => { + expect(PAGE_ROUTES.map((r) => r.path)).toEqual(["/", "/jira/board", "/jira/issues", "/jira/feed", "/board", "/issues", "/issues/:key", "/feed", "/wiki", "*"]); }); - it("пункты панели: подписи и адреса прежние плюс вики", () => { + it("пункты панели: подписи прежние, адреса jira с префиксом, плюс вики", () => { expect(MODULES.flatMap((m) => m.nav).map((n) => [n.label, n.to])).toEqual([ - ["Доска связей", "/board"], ["Задачи", "/issues"], ["Лента", "/feed"], ["Вики", "/wiki"], + ["Доска связей", "/jira/board"], ["Задачи", "/jira/issues"], ["Лента", "/jira/feed"], ["Вики", "/wiki"], ]); }); @@ -42,7 +42,7 @@ describe("NotFound при повторе адреса", () => { const { NotFound: NF } = await import("../shell/NotFound.tsx"); const err = vi.spyOn(console, "error").mockImplementation(() => {}); render(); - expect(screen.getAllByRole("link")).toHaveLength(2); + expect(screen.getAllByRole("link")).toHaveLength(3); // «Обзор» + два раздела expect(err.mock.calls.flat().join(" ")).not.toMatch(/same key/); err.mockRestore(); vi.doUnmock("../modules/index.ts"); diff --git a/web/src/modules/types.ts b/web/src/modules/types.ts index 315fcfc..379b94a 100644 --- a/web/src/modules/types.ts +++ b/web/src/modules/types.ts @@ -5,6 +5,7 @@ import type { Batch, Project } from "../api/types.ts"; export type NavItem = { to: string; label: string; icon: ReactNode; + title?: string; // название страницы для заголовка вкладки; по умолчанию label padded?: boolean; // страница с полями (не во всю ширину): оболочка не ставит ей класс page--board; для адресов вне панели класс ставится keepSearch?: boolean; // при смене проекта строка запроса страницы сохраняется (параметры не проектные); иначе сбрасывается }; diff --git a/web/src/projects/projects.test.tsx b/web/src/projects/projects.test.tsx index 4cf292b..a014e39 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/board"); + const router = open("/app/jira/board"); 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,15 +57,15 @@ describe("проект в приложении", () => { it("переключение проекта: доска, ссылка «Задачи», список и варианты фильтров", async () => { const h = hub(); - const router = open("/app/board?project=AWPCA"); + const router = open("/app/jira/board?project=AWPCA"); await screen.findByTestId("page-board"); await userEvent.selectOptions(screen.getByRole("combobox", { name: "Проект" }), "STOCKS_DRRK"); expect(router.state.historyAction).toBe("PUSH"); await waitFor(() => expect(h.get("/board").some((c) => c.url.searchParams.get("project") === "STOCKS_DRRK")).toBe(true)); const link = screen.getByRole("link", { name: "Задачи" }); - expect(link).toHaveAttribute("href", "/app/issues?project=STOCKS_DRRK"); + expect(link).toHaveAttribute("href", "/app/jira/issues?project=STOCKS_DRRK"); await userEvent.click(link); - expect(router.state.location.pathname + router.state.location.search).toBe("/app/issues?project=STOCKS_DRRK"); + expect(router.state.location.pathname + router.state.location.search).toBe("/app/jira/issues?project=STOCKS_DRRK"); await screen.findByTestId("page-issues"); await waitFor(() => expect(h.get("/issues").some((c) => c.url.searchParams.get("project") === "STOCKS_DRRK")).toBe(true)); expect(h.get("/board").filter((c) => c.url.searchParams.get("project") === "STOCKS_DRRK").length).toBeGreaterThan(0); @@ -74,14 +74,14 @@ describe("проект в приложении", () => { it("запомненный проект подставляется в адрес", async () => { localStorage.setItem(REMEMBER_KEY, "STOCKS_DRRK"); hub(); - const router = open("/app/issues"); + const router = open("/app/jira/issues"); await waitFor(() => expect(router.state.location.search).toBe("?project=STOCKS_DRRK")); expect(router.state.historyAction).toBe("REPLACE"); }); it("неизвестный проект в адресе: сообщение и список проектов, без GET /issues", async () => { const h = hub(); - open("/app/issues?project=NOPE"); + open("/app/jira/issues?project=NOPE"); expect(await screen.findByText(/проект NOPE не найден/)).toBeInTheDocument(); expect(screen.getByTestId("project-unknown")).toHaveTextContent("Выберите проект в списке «Проект» вверху."); expect(screen.getByRole("combobox", { name: "Проект" })).toBeInTheDocument(); @@ -90,7 +90,7 @@ describe("проект в приложении", () => { it("проектов нет: приглашение создать, без GET /board; щелчок открывает окно", async () => { const h = hub([]); - open("/app/board"); + open("/app/jira/board"); 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/board?project=AWPCA"); + const router = open("/app/jira/board?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 }) }); - open("/app/board?project=AWPCA"); + open("/app/jira/board?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/board?project=AWPCA"); + open("/app/jira/board?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 }), "GET /batches/7": () => json(200, { id: 7, author: "viewer", status: "queued", remaining_seconds: 5, counts: {}, commands: [{ type: "refresh", status: "queued" }] }), }); - const router = open("/app/board?project=AWPCA"); + const router = open("/app/jira/board?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 }); } }); - open("/app/board?project=AWPCA"); + open("/app/jira/board?project=AWPCA"); await screen.findByTestId("page-board"); await userEvent.selectOptions(screen.getByRole("combobox", { name: "Проект" }), "+ Новый проект…"); const dlg = screen.getByRole("dialog", { name: "Новый проект" }); @@ -189,11 +189,62 @@ describe("проект в приложении", () => { it("карточка по прямому адресу: выбор проекта не переписывает адрес и не закрывает окно", async () => { const h = hub(); - const router = open("/app/issues/AURORA-9001"); + const router = open("/app/jira/issues/AURORA-9001"); expect(await screen.findByTestId("issue-card")).toBeInTheDocument(); await waitFor(() => expect(h.get("/issues").some((c) => c.url.searchParams.get("project") === "AWPCA")).toBe(true)); - expect(router.state.location.pathname).toBe("/app/issues/AURORA-9001"); + expect(router.state.location.pathname).toBe("/app/jira/issues/AURORA-9001"); expect(router.state.location.search).toBe(""); expect(screen.getByTestId("issue-card")).toBeInTheDocument(); }); }); + +describe("адреса модулей и заголовок вкладки", () => { + beforeEach(() => { setToken("tok"); localStorage.clear(); }); + const W = { ...A, wiki_root: null }; + const WITH_WIKI = { ...A, wiki_root: "DOC-1", wiki_synced_at: null, wiki_sync_error: null }; + + it("legacy card address: /issues/KEY?project=… → /jira/issues/KEY, окно поверх списка, закрытие — список с проектом", async () => { + hub(); + const router = open("/app/issues/AURORA-9001?project=AWPCA"); + expect(await screen.findByTestId("issue-card")).toBeInTheDocument(); + expect(router.state.location.pathname + router.state.location.search).toBe("/app/jira/issues/AURORA-9001?project=AWPCA"); + expect(await screen.findByTestId("page-issues")).toBeInTheDocument(); + await userEvent.click(within(screen.getByRole("dialog")).getByRole("button", { name: /Закрыть/ })); + await waitFor(() => expect(router.state.location.pathname + router.state.location.search).toBe("/app/jira/issues?project=AWPCA")); + }); + + 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")); + expect(router.state.location.search).toBe("?project=STOCKS_DRRK&x=1"); + 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"); + await screen.findByLabelText("Корень вики (DOC-… или ссылка)"); + await screen.findByRole("option", { name: /STOCKS_DRRK/ }); + await userEvent.selectOptions(screen.getByRole("combobox", { name: "Проект" }), "STOCKS_DRRK"); + await waitFor(() => expect(router.state.location.pathname + router.state.location.search).toBe("/app/wiki?project=STOCKS_DRRK")); + }); + + it("смена проекта на неизвестном адресе ведёт на «Обзор»", async () => { + hub(); + const router = open("/app/nowhere?project=AWPCA"); + await screen.findByRole("heading", { name: "Страница не найдена" }); + await screen.findByRole("option", { name: /STOCKS_DRRK/ }); + await userEvent.selectOptions(screen.getByRole("combobox", { name: "Проект" }), "STOCKS_DRRK"); + await waitFor(() => expect(router.state.location.pathname + router.state.location.search).toMatch(/^\/app\/?\?project=STOCKS_DRRK$/)); + }); + + it("заголовок вкладки: «Вики · AWPCA · SDLC Hub» и «Обзор · AWPCA · SDLC Hub»", async () => { + hub([WITH_WIKI], { "GET /wiki/tree": () => json(200, { nodes: [] }), "GET /wiki/status": () => json(200, {}) }); + const router = open("/app/wiki?project=AWPCA"); + await waitFor(() => expect(document.title).toBe("Вики · AWPCA · SDLC Hub")); + void router.navigate("/?project=AWPCA"); + await waitFor(() => expect(document.title).toBe("Обзор · AWPCA · SDLC Hub")); + }); +}); + diff --git a/web/src/shell/Layout.tsx b/web/src/shell/Layout.tsx index 6b7a806..7faff22 100644 --- a/web/src/shell/Layout.tsx +++ b/web/src/shell/Layout.tsx @@ -1,18 +1,19 @@ import { useCallback, useEffect, useMemo, useState } from "react"; import { useLocation, useNavigate, useRoutes } from "react-router"; import { useProjects } from "../api/queries.ts"; -import { MODULES, useOverlays } from "../modules/index.ts"; +import { useOverlays } from "../modules/index.ts"; import { rememberedProject, rememberProject, resolveProject, withProject } from "../projects/current.ts"; import { NewProjectDialog } from "../projects/NewProjectDialog.tsx"; import { ProjectContext, type ProjectCtx, type ProjectState } from "../projects/ProjectContext.ts"; import { ActivityProvider } from "./activity/ActivityProvider.tsx"; import { useTrackBatch } from "./activity/activity.ts"; import { ErrorBoundary } from "./ErrorBoundary.tsx"; +import { NAV } from "./nav.tsx"; import { PAGE_ROUTES } from "./pages.tsx"; import { Sidebar } from "./Sidebar.tsx"; +import { useDocumentTitle } from "./useDocumentTitle.ts"; import "./shell.css"; -const NAV = MODULES.flatMap((m) => m.nav); const NAV_PATHS: readonly string[] = NAV.map((n) => n.to); export function Layout() { @@ -36,6 +37,8 @@ export function Layout() { const fromUrl = state.kind === "ok" && state.fromUrl; const section = NAV_PATHS.includes(at) ? at : null; const onPage = section !== null; + const navItem = NAV.find((n) => n.to === section); + useDocumentTitle(navItem ? navItem.title ?? navItem.label : "Страница не найдена", okLabel); useEffect(() => { if (okLabel) rememberProject(okLabel); }, [okLabel]); // выбранный проект записывается в адрес без новой записи истории; с открытой карточкой адрес не трогаем (design 10) useEffect(() => { @@ -47,8 +50,8 @@ export function Layout() { const [newOpen, setNewOpen] = useState(false); // смена проекта: фильтры доски и списка — проектные и сбрасываются; период и виды ленты от проекта не зависят (R-1) const select = useCallback((label: string) => { - void navigate({ pathname: section ?? NAV_PATHS[0], search: withProject(NAV.find((n) => n.to === section)?.keepSearch ? pageLocation.search : "", label) }); - }, [navigate, section, pageLocation.search]); + void navigate({ pathname: section ?? "/", search: withProject(navItem?.keepSearch ? pageLocation.search : "", label) }); + }, [navigate, section, navItem?.keepSearch, pageLocation.search]); const ctx = useMemo(() => ({ state, projects: projects.data ?? [], select, openNew: () => setNewOpen(true) }), [state, projects.data, select]); @@ -57,7 +60,7 @@ export function Layout() {
-
n.to === section)?.padded ? "" : " page--board")}> +
{page}
diff --git a/web/src/shell/ModuleStatusBlock.test.tsx b/web/src/shell/ModuleStatusBlock.test.tsx new file mode 100644 index 0000000..1e3063e --- /dev/null +++ b/web/src/shell/ModuleStatusBlock.test.tsx @@ -0,0 +1,99 @@ +import { cleanup, render, screen, waitFor, within } from "@testing-library/react"; +import { createMemoryRouter } from "react-router"; +import { beforeEach, describe, expect, it } from "vitest"; +import { App } from "../App.tsx"; +import { setToken } from "../auth/token.ts"; +import { routes } from "../routes.tsx"; +import { json, mockFetch } from "../test/fetchMock.ts"; + +const BASE = { label: "AWPCA", name: "АРМ КА", board_id: null, board_type: null, created_at: "", issues_count: 9, + jira_synced_at: "2026-10-24T11:07:00", wiki_root: "DOC-1", wiki_synced_at: "2026-10-24T11:05:00", wiki_sync_error: null, wiki_pages: 28 }; +const AGENT = { online: true, last_seen_at: "2026-10-24T09:30:00", agent_version: 1, warning: null, update_hint: null, current_command_id: null, release: null }; + +function hub(project: Record = {}, agents: unknown[] = [{ module: "jira", ...AGENT }, { module: "wiki", ...AGENT }]) { + const paths: string[] = []; + mockFetch((method, url) => { + paths.push(`${method} ${url.pathname}`); + const k = `${method} ${url.pathname}`; + if (k === "GET /projects") return json(200, { projects: [{ ...BASE, ...project }] }); + if (k === "GET /agents") return json(200, { agents }); + if (k === "GET /batches") return json(200, { batches: [] }); + return json(404, { error: "not_found" }); + }); + return paths; +} +const open = (collapsed = false) => { + if (collapsed) localStorage.setItem("sdlc-hub-sidebar", "collapsed"); + render(); +}; +const bar = () => screen.getByRole("complementary", { name: "Панель приложения" }); +const rowNow = (module: string) => bar().querySelector(`[data-module="${module}"]`); +const row = (module: string) => { const r = rowNow(module); if (!r) throw new Error(`нет строки ${module}`); return r; }; + +describe("блок состояния модулей", () => { + beforeEach(() => { setToken("tok"); localStorage.clear(); cleanup(); }); + + it("две строки: название, точка с текстом агента, свежесть с охватом, значки обновления", async () => { + hub(); + open(); + await within(bar()).findByRole("button", { name: "Обновить из Jira — все проекты" }); + expect(within(row("jira")).getByText("Jira")).toBeInTheDocument(); + expect(within(row("wiki")).getByText("Вики")).toBeInTheDocument(); + expect(await within(row("jira")).findByText("агент на связи")).toBeInTheDocument(); + expect(await within(row("wiki")).findByText("агент на связи")).toBeInTheDocument(); + expect(within(row("jira")).getByText("данные на 24.10 11:07 · все проекты")).toBeInTheDocument(); + expect(within(row("wiki")).getByText("данные на 24.10 11:05 · проект")).toBeInTheDocument(); + expect(within(bar()).getByRole("button", { name: "Обновить вики — проект AWPCA" })).toBeInTheDocument(); + }); + + it("агент офлайн и «обновите агента» — текстом, предупреждение в подсказке", async () => { + hub({}, [{ module: "jira", ...AGENT, online: false }, { module: "wiki", ...AGENT, warning: "версия устарела" }]); + open(); + await waitFor(() => row("wiki")); + expect(await within(row("jira")).findByText("агент офлайн")).toBeInTheDocument(); + const upd = await within(row("wiki")).findByText("обновите агента"); + expect(upd.closest("[title]")).toHaveAttribute("title", expect.stringContaining("версия устарела")); + }); + + it("обновления ещё не было", async () => { + hub({ jira_synced_at: null }); + open(); + await waitFor(() => row("jira")); + expect(await within(row("jira")).findByText("обновления ещё не было")).toBeInTheDocument(); + }); + + it("ошибка синхронизации вики — текстом в строке «Вики», тоном ошибки", async () => { + hub({ wiki_sync_error: "403 Forbidden" }); + open(); + await waitFor(() => row("wiki")); + const err = await within(row("wiki")).findByText(/403 Forbidden/); + expect(err).toHaveClass("data-status__sync--error"); + }); + + it("no wiki row without root: одна строка, запросов /wiki/status и /board нет", async () => { + const paths = hub({ wiki_root: null, wiki_synced_at: null }); + open(); + await within(bar()).findByRole("button", { name: "Обновить из Jira — все проекты" }); + expect(rowNow("wiki")).toBeNull(); + expect(within(bar()).queryByRole("button", { name: /^Обновить вики/ })).toBeNull(); + expect(paths.filter((p) => p === "GET /wiki/status" || p === "GET /board")).toEqual([]); + }); + + it("живой регион role=status стоит в строке модуля постоянно, до появления события", async () => { + hub(); + open(); + await within(bar()).findByRole("button", { name: "Обновить из Jira — все проекты" }); + const st = within(row("jira")).getByRole("status"); + expect(st).toBeEmptyDOMElement(); + }); + + it("collapsed: по значку на модуль, подробности в title", async () => { + hub(); + open(true); + const jira = await within(bar()).findByRole("button", { name: "Обновить из Jira — все проекты" }); + await waitFor(() => expect(jira.getAttribute("title")).toMatch(/Jira · агент на связи · данные на 24\.10 11:07 · все проекты/)); + const wiki = within(bar()).getByRole("button", { name: "Обновить вики — проект AWPCA" }); + expect(wiki.getAttribute("title")).toMatch(/Вики · .*данные на 24\.10 11:05 · проект/); + expect(within(bar()).queryByText("данные на 24.10 11:07 · все проекты")).toBeNull(); + }); +}); diff --git a/web/src/shell/activity/StatusBlock.tsx b/web/src/shell/ModuleStatusBlock.tsx similarity index 51% rename from web/src/shell/activity/StatusBlock.tsx rename to web/src/shell/ModuleStatusBlock.tsx index b7e733c..f05558f 100644 --- a/web/src/shell/activity/StatusBlock.tsx +++ b/web/src/shell/ModuleStatusBlock.tsx @@ -1,17 +1,18 @@ -// ВРЕМЕННЫЙ блок состояния модулей (Task 3): по строке на модуль с данными для проекта. Task 4 заменяет его -// полноценным блоком панели («Состояние и обновление модулей»); здесь — данные контракта и прежние классы -import { useProjects } from "../../api/queries.ts"; -import type { Project } from "../../api/types.ts"; -import { MODULES } from "../../modules/index.ts"; -import type { HubModule } from "../../modules/types.ts"; -import { fmtStamp } from "../../ui/format.ts"; -import { IconButton, IconRefresh } from "../../ui/index.ts"; -import { useModuleActivity } from "./activity.ts"; -import { eventText } from "./events.ts"; +import { useProjects } from "../api/queries.ts"; +import type { Project } from "../api/types.ts"; +import { MODULES } from "../modules/index.ts"; +import type { HubModule } from "../modules/types.ts"; +import { fmtStamp } from "../ui/format.ts"; +import { IconButton, IconRefresh } from "../ui/index.ts"; +import { useModuleActivity } from "./activity/activity.ts"; +import { eventText } from "./activity/events.ts"; -const WITH_STATUS = MODULES.filter((m): m is HubModule & Required> => !!m.status); +type WithStatus = HubModule & Required>; +const WITH_STATUS = MODULES.filter((m): m is WithStatus => !!m.status); -function ModuleRow({ m, project, compact }: { m: (typeof WITH_STATUS)[number]; project: Project; compact: boolean }) { +// строка модуля: название, агент (точка + текст), значок обновления, свежесть, событие пакета. +// Живой регион role="status" стоит в строке постоянно и меняет только текст (T10.1 #94): вставка вместе с текстом не озвучивается +function ModuleRow({ m, project, variant }: { m: WithStatus; project: Project; variant: "expanded" | "collapsed" }) { const act = useModuleActivity(m.name); const { status } = m; const fresh = status.freshness(project); @@ -25,13 +26,14 @@ function ModuleRow({ m, project, compact }: { m: (typeof WITH_STATUS)[number]; p : fresh.at ? `данные на ${fmtStamp(fresh.at)} · ${scope}` : "обновления ещё не было"; const ev = act.event ? eventText(act.event, label ?? "") : null; const online = !!a && a.online && !a.warning && !a.update_hint; - const eventEl = {ev?.text}; const button = action && ( -
); } -// строки модулей, у которых есть данные для проекта -export function StatusBlock({ project, compact }: { project: string | null; compact: boolean }) { +// блок состояния модулей панели: по строке на модуль, у которого есть данные для проекта (у вики без корня строки нет) +export function ModuleStatusBlock({ project, variant }: { project: string | null; variant: "expanded" | "collapsed" }) { const p = useProjects().data?.find((x) => x.label === project); if (!p) return null; - return <>{WITH_STATUS.filter((m) => m.status.freshness(p).available).map((m) => )}; + return <>{WITH_STATUS.filter((m) => m.status.freshness(p).available).map((m) => )}; } -// I-1: на телефоне подсказок нет — событие пакета строкой под верхней панелью -function ModuleNotice({ m }: { m: (typeof WITH_STATUS)[number] }) { +// узкий экран: события пакетов модулей — строкой под верхней строкой; контейнер role="status" в DOM всегда (T10.1 #94) +function ModuleEvent({ m }: { m: WithStatus }) { const act = useModuleActivity(m.name); const ev = act.event ? eventText(act.event, m.status.refresh?.label ?? "") : null; - return ev ? : null; + return ev ?
{ev.text}
: null; } -export function TopbarNotice() { - return <>{WITH_STATUS.map((m) => )}; +export function ModuleEventStrip() { + return
{WITH_STATUS.map((m) => )}
; } diff --git a/web/src/shell/NotFound.tsx b/web/src/shell/NotFound.tsx index 7a98bc6..6d56a88 100644 --- a/web/src/shell/NotFound.tsx +++ b/web/src/shell/NotFound.tsx @@ -1,9 +1,7 @@ import { Fragment } from "react"; import { Link } from "react-router"; -import { MODULES } from "../modules/index.ts"; import { Notice } from "../ui/index.ts"; - -const NAV = MODULES.flatMap((m) => m.nav); +import { NAV } from "./nav.tsx"; export function NotFound() { return ( diff --git a/web/src/shell/OverviewPage.tsx b/web/src/shell/OverviewPage.tsx new file mode 100644 index 0000000..b21db96 --- /dev/null +++ b/web/src/shell/OverviewPage.tsx @@ -0,0 +1,10 @@ +import { PageHeader } from "../ui/index.ts"; + +// ВРЕМЕННАЯ заглушка «Обзора» (Task 4): Task 5 заменяет её страницей со сводками модулей +export function OverviewPage() { + return ( +
+ +
+ ); +} diff --git a/web/src/shell/Sidebar.test.tsx b/web/src/shell/Sidebar.test.tsx index c3ab326..865728d 100644 --- a/web/src/shell/Sidebar.test.tsx +++ b/web/src/shell/Sidebar.test.tsx @@ -45,19 +45,19 @@ describe("боковая панель", () => { it("разделы — ссылки «Доска связей» и «Задачи», активная с aria-current", async () => { hub(); - open("/app/issues?project=AWPCA"); + open("/app/jira/issues?project=AWPCA"); await screen.findByRole("link", { name: "Доска связей" }); - await waitFor(() => expect(screen.getByRole("link", { name: "Доска связей" })).toHaveAttribute("href", "/app/board?project=AWPCA")); + await waitFor(() => expect(screen.getByRole("link", { name: "Доска связей" })).toHaveAttribute("href", "/app/jira/board?project=AWPCA")); expect(screen.getByRole("link", { name: "Задачи" })).toHaveAttribute("aria-current", "page"); expect(screen.getByRole("link", { name: "Доска связей" })).not.toHaveAttribute("aria-current"); }); it("раздел «Лента» ведёт на /feed с проектом и отмечается как текущий", async () => { hub({ "GET /changes": () => json(200, { since: "", until: "", truncated: false, changes: [] }) }); - open("/app/feed?project=AWPCA"); + open("/app/jira/feed?project=AWPCA"); expect(await screen.findByTestId("page-feed")).toBeInTheDocument(); const nav = within(bar()).getByRole("navigation", { name: "Разделы" }); - await waitFor(() => expect(within(nav).getByRole("link", { name: "Лента" })).toHaveAttribute("href", "/app/feed?project=AWPCA")); + await waitFor(() => expect(within(nav).getByRole("link", { name: "Лента" })).toHaveAttribute("href", "/app/jira/feed?project=AWPCA")); expect(within(nav).getByRole("link", { name: "Лента" })).toHaveAttribute("aria-current", "page"); expect(within(nav).getByRole("link", { name: "Задачи" })).not.toHaveAttribute("aria-current"); }); @@ -70,16 +70,17 @@ describe("боковая панель", () => { 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/board"); - expect(main.getByRole("link", { name: "Задачи" })).toHaveAttribute("href", "/app/issues"); - expect(main.getByRole("link", { name: "Лента" })).toHaveAttribute("href", "/app/feed"); // M-2 + expect(main.getByRole("link", { name: "Доска связей" })).toHaveAttribute("href", "/app/jira/board"); + 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\/?$/)); expect(screen.getByRole("alert")).toHaveTextContent("Страница не найдена"); }); it("«Обновить из Jira» на /issues ставит пакет, недоступна, по done — «пакет #7 выполнен» и GET /issues повторно", async () => { let done = false; const h = hub({ "GET /batches/7": () => json(200, { id: 7, author: "viewer", status: done ? "done" : "running", remaining_seconds: 3, counts: {}, commands: [{ type: "refresh", status: done ? "done" : "running" }] }) }); - open("/app/issues?project=AWPCA"); + open("/app/jira/issues?project=AWPCA"); await screen.findByTestId("page-issues"); await waitFor(() => expect(h.count("GET", "/issues")).toBeGreaterThan(0)); const issuesBefore = h.count("GET", "/issues"); @@ -97,39 +98,60 @@ 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/board?project=AWPCA"); + open("/app/jira/board?project=AWPCA"); expect(await within(bar()).findByText("агент офлайн")).toBeInTheDocument(); }); it("агент на связи", async () => { hub(); - open("/app/board?project=AWPCA"); + open("/app/jira/board?project=AWPCA"); expect(await within(bar()).findByText("агент на связи")).toBeInTheDocument(); }); it("«данные на ДД.ММ ЧЧ:ММ · все проекты» берутся из jira_synced_at проекта", async () => { hub(); - open("/app/issues?project=AWPCA"); + open("/app/jira/issues?project=AWPCA"); expect(await within(bar()).findByText(/^данные на \d\d\.\d\d \d\d:\d\d · все проекты$/)).toBeInTheDocument(); }); it("обновления ещё не было", async () => { hub({ "GET /projects": () => json(200, { projects: [{ ...A, jira_synced_at: null }] }) }); - open("/app/issues?project=AWPCA"); + open("/app/jira/issues?project=AWPCA"); expect(await within(bar()).findByText("обновления ещё не было")).toBeInTheDocument(); }); it("блок состояния не грузит GET /wiki/status; у проекта без корня вики строки «Вики» нет", async () => { const h = hub(); - open("/app/board?project=AWPCA"); + open("/app/jira/board?project=AWPCA"); await within(bar()).findByRole("button", { name: /^Обновить из Jira/ }); expect(within(bar()).queryByRole("button", { name: /^Обновить вики/ })).toBeNull(); expect(h.count("GET", "/wiki/status")).toBe(0); }); + it("группы по модулям: «Обзор» первым, затем заголовки «Jira» и «Вики» со своими разделами", async () => { + hub(); + open("/app/jira/board?project=AWPCA"); + const nav = within(bar()).getByRole("navigation", { name: "Разделы" }); + const links = (await within(nav).findAllByRole("link")).map((l) => l.textContent); + expect(links).toEqual(["Обзор", "Доска связей", "Задачи", "Лента", "Вики"]); + const jira = within(nav).getByRole("group", { name: "Jira" }); + 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$/))); + }); + + 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"); + 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(); + }); + it("меню «Ещё» → «Сменить токен» показывает экран входа", async () => { hub(); - open("/app/board?project=AWPCA"); + open("/app/jira/board?project=AWPCA"); await userEvent.click(await screen.findByRole("button", { name: "Ещё" })); await userEvent.click(screen.getByRole("button", { name: "Сменить токен" })); expect(getAuth().token).toBe(""); @@ -138,13 +160,13 @@ describe("боковая панель", () => { it("переключатель темы — в панели", async () => { hub(); - open("/app/board?project=AWPCA"); + open("/app/jira/board?project=AWPCA"); expect(await within(bar()).findByRole("switch", { name: "Тёмная тема" })).toBeInTheDocument(); }); it("доска не рисует свою кнопку «Обновить из Jira»: в приложении она одна", async () => { hub(); - open("/app/board?project=AWPCA"); + open("/app/jira/board?project=AWPCA"); await screen.findByTestId("page-board"); await screen.findByText(/задач из/); expect(screen.getAllByRole("button", { name: /^Обновить из Jira/ })).toHaveLength(1); @@ -152,21 +174,21 @@ describe("боковая панель", () => { it("разделы — в навигации «Разделы» боковой панели", async () => { hub(); - open("/app/board?project=AWPCA"); + open("/app/jira/board?project=AWPCA"); const nav = within(bar()).getByRole("navigation", { name: "Разделы" }); expect(await within(nav).findByRole("link", { name: "Доска связей" })).toHaveAttribute("aria-current", "page"); }); it("«Свернуть панель» сворачивает и запоминает в sdlc-hub-sidebar; после перезапуска свёрнута, разделы доступны", async () => { hub(); - open("/app/board?project=AWPCA"); + open("/app/jira/board?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/board?project=AWPCA"); + open("/app/jira/board?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", "Задачи"); @@ -178,7 +200,7 @@ describe("боковая панель", () => { it("свёрнутая: «Обновить из Jira» значком ставит пакет, событие — в role=status и подсказке", async () => { localStorage.setItem("sdlc-hub-sidebar", "collapsed"); const h = hub(); - open("/app/issues?project=AWPCA"); + open("/app/jira/issues?project=AWPCA"); const btn = await within(bar()).findByRole("button", { name: /^Обновить из Jira/ }); await userEvent.click(btn); await waitFor(() => expect(btn).toBeDisabled()); @@ -190,7 +212,7 @@ describe("боковая панель", () => { it("свёрнутая: метка проекта видна, щелчок по ней разворачивает панель", async () => { localStorage.setItem("sdlc-hub-sidebar", "collapsed"); hub(); - open("/app/board?project=AWPCA"); + open("/app/jira/board?project=AWPCA"); const tag = await within(bar()).findByRole("button", { name: "Проект AWPCA — развернуть панель" }); await userEvent.click(tag); expect(bar()).not.toHaveClass("is-collapsed"); @@ -204,24 +226,25 @@ describe("боковая панель на узком экране", () => { }); afterEach(() => { vi.unstubAllGlobals(); }); - it("сверху строка с «Меню», названием и «Обновить из Jira»; панели нет", async () => { + it("сверху строка с «Меню», меткой проекта и сводной точкой; кнопки обновления и панели нет", async () => { hub(); - open("/app/board?project=AWPCA"); + open("/app/jira/board?project=AWPCA"); const top = await screen.findByRole("banner", { name: "Строка приложения" }); expect(within(top).getByRole("button", { name: "Меню" })).toHaveAttribute("aria-expanded", "false"); - expect(within(top).getByText("SDLC Hub")).toBeInTheDocument(); - expect(await within(top).findByRole("button", { name: /^Обновить из Jira/ })).toBeInTheDocument(); + expect(await within(top).findByText("AWPCA")).toBeInTheDocument(); + expect(await within(top).findByRole("img", { name: /агент на связи/ })).toBeInTheDocument(); + expect(within(top).queryByRole("button", { name: /^Обновить/ })).toBeNull(); expect(screen.queryByRole("complementary", { name: "Панель приложения" })).toBeNull(); }); it("«Меню» открывает панель с фокусом на первом разделе; Escape закрывает и возвращает фокус", async () => { hub(); - open("/app/board?project=AWPCA"); + open("/app/jira/board?project=AWPCA"); const menu = await screen.findByRole("button", { name: "Меню" }); await userEvent.click(menu); expect(menu).toHaveAttribute("aria-expanded", "true"); const nav = within(bar()).getByRole("navigation", { name: "Разделы" }); - expect(within(nav).getByRole("link", { name: "Доска связей" })).toHaveFocus(); + expect(within(nav).getByRole("link", { name: "Обзор" })).toHaveFocus(); expect(within(bar()).queryByRole("button", { name: "Свернуть панель" })).toBeNull(); await userEvent.keyboard("{Escape}"); expect(screen.queryByRole("complementary", { name: "Панель приложения" })).toBeNull(); @@ -230,16 +253,16 @@ describe("боковая панель на узком экране", () => { it("выбор раздела закрывает панель и открывает раздел", async () => { hub(); - const router = open("/app/board?project=AWPCA"); + const router = open("/app/jira/board?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/issues"); + expect(router.state.location.pathname).toBe("/app/jira/issues"); expect(screen.queryByRole("complementary", { name: "Панель приложения" })).toBeNull(); }); it("щелчок по подложке и «Закрыть меню» закрывают панель", async () => { hub(); - open("/app/board?project=AWPCA"); + open("/app/jira/board?project=AWPCA"); await userEvent.click(await screen.findByRole("button", { name: "Меню" })); await userEvent.click(screen.getByTestId("sidebar-backdrop")); expect(screen.queryByRole("complementary", { name: "Панель приложения" })).toBeNull(); @@ -248,14 +271,35 @@ describe("боковая панель на узком экране", () => { expect(screen.queryByRole("complementary", { name: "Панель приложения" })).toBeNull(); }); - it("I-1: событие пакета видно под строкой, не только в подсказке", async () => { + it("I-1: событие пакета видно строкой под верхней строкой в постоянном живом регионе", async () => { hub(); - open("/app/board?project=AWPCA"); - const top = await screen.findByRole("banner", { name: "Строка приложения" }); - await userEvent.click(await within(top).findByRole("button", { name: /^Обновить из Jira/ })); - const strip = await screen.findByTestId("topbar-event"); + open("/app/jira/board?project=AWPCA"); + await screen.findByRole("banner", { name: "Строка приложения" }); + const strip = screen.getByTestId("topbar-events"); + expect(strip).toHaveAttribute("role", "status"); // T10.1: контейнер на месте до появления текста + expect(strip).toBeEmptyDOMElement(); + await userEvent.click(screen.getByRole("button", { name: "Меню" })); + await userEvent.click(await within(bar()).findByRole("button", { name: /^Обновить из Jira/ })); await waitFor(() => expect(strip).toHaveTextContent(/пакет #7 выполняется/)); expect(strip).not.toHaveClass("visually-hidden"); - expect(strip).toHaveAttribute("aria-hidden", "true"); // озвучивает role=status кнопки, второй раз не нужно + }); + + it("drawer is modal: фокус в панели, страница инертна; Escape во вложенном меню закрывает только меню (M-1)", async () => { + hub(); + open("/app/jira/board?project=AWPCA"); + const menu = await screen.findByRole("button", { name: "Меню" }); + await userEvent.click(menu); + expect(within(bar()).getByRole("link", { name: "Обзор" })).toHaveFocus(); + expect(screen.getByRole("main")).toHaveAttribute("inert"); + expect(screen.getByRole("banner", { name: "Строка приложения" })).toHaveAttribute("inert"); + await userEvent.click(within(bar()).getByRole("button", { name: "Ещё" })); + expect(within(bar()).getByRole("button", { name: "Сменить токен" })).toBeInTheDocument(); + await userEvent.keyboard("{Escape}"); + expect(within(bar()).queryByRole("button", { name: "Сменить токен" })).toBeNull(); + expect(bar()).toBeInTheDocument(); // панель осталась + await userEvent.keyboard("{Escape}"); + expect(screen.queryByRole("complementary", { name: "Панель приложения" })).toBeNull(); + await waitFor(() => expect(menu).toHaveFocus()); + expect(screen.getByRole("main")).not.toHaveAttribute("inert"); }); }); diff --git a/web/src/shell/Sidebar.tsx b/web/src/shell/Sidebar.tsx index 3e0440b..562ca66 100644 --- a/web/src/shell/Sidebar.tsx +++ b/web/src/shell/Sidebar.tsx @@ -1,9 +1,12 @@ import { useQueryClient } from "@tanstack/react-query"; import { useEffect, useMemo, useRef, useState, useSyncExternalStore } from "react"; +import type { NavItem } from "../modules/types.ts"; import { Link } from "react-router"; +import type { Ref } from "react"; import { clearToken } from "../auth/token.ts"; -import { MODULES } from "../modules/index.ts"; -import { StatusBlock, TopbarNotice } from "./activity/StatusBlock.tsx"; +import { useActivitySummary } from "./activity/activity.ts"; +import { ModuleEventStrip, ModuleStatusBlock } from "./ModuleStatusBlock.tsx"; +import { GROUPS, OVERVIEW } from "./nav.tsx"; import { withProject } from "../projects/current.ts"; import { ProjectPicker } from "../projects/ProjectPicker.tsx"; import { IconButton, IconClose, IconMenu, IconSidebar, Menu, MenuItem, ThemeSwitch } from "../ui/index.ts"; @@ -11,8 +14,6 @@ import { readSidebar, saveSidebar } from "./sidebarPref.ts"; export type Section = string; -const SECTIONS = MODULES.flatMap((m) => m.nav); - // узкий экран (<640 px): панель не в потоке, а выезжает по кнопке «Меню» из закреплённой строки (design 4) const NARROW = "(max-width: 639px)"; const subscribe = (cb: () => void) => { @@ -31,21 +32,36 @@ export function Sidebar(p: Props) { return narrow ? : ; } +const SUMMARY_TEXT = { error: "ошибка", busy: "идёт обновление", online: "агент на связи", offline: "агент офлайн" } as const; + +// сводная точка состояния модулей: цвет не единственный признак — состояние в aria-label и title +function SummaryDot() { + const summary = useActivitySummary(); + const text = `Состояние модулей: ${SUMMARY_TEXT[summary]}`; + return ; +} + function NarrowShell(p: Props) { const [open, setOpen] = useState(false); const menuBtn = useRef(null); // объект стабилен, пока панель открыта: эффект фокуса и Escape в Panel не перезапускается на каждой отрисовке - const drawer = useMemo(() => ({ close: () => { setOpen(false); menuBtn.current?.focus(); }, navigated: () => setOpen(false) }), []); + const drawer = useMemo(() => ({ close: () => setOpen(false), navigated: () => setOpen(false) }), []); + // фокус возвращается на «Меню» после закрытия: пока панель открыта, строка инертна и фокус на неё не встал бы + const wasOpen = useRef(false); + useEffect(() => { + if (wasOpen.current && !open) menuBtn.current?.focus(); + wasOpen.current = open; + }, [open]); return ( <>
- SDLC Hub + {p.project && {p.project}} - +
- + {open && <>
@@ -54,21 +70,31 @@ function NarrowShell(p: Props) { ); } -// содержимое панели: название и свёртка (в выезжающей — «Закрыть меню»), проект, разделы, внизу состояние данных, тема, меню «Ещё» +// содержимое панели: название и свёртка (в выезжающей — «Закрыть меню»), проект, «Обзор» и разделы по модулям, внизу состояние +// модулей, тема, меню «Ещё»; прокручивается середина, низ с меню «Ещё» остаётся на месте (M-5 #73) function Panel({ section, project, drawer }: Props & { drawer?: { close(): void; navigated(): void } }) { const qc = useQueryClient(); const [stored, setCollapsed] = useState(readSidebar); const collapsed = !drawer && stored; const fold = (c: boolean) => { setCollapsed(c); saveSidebar(c); }; - const href = (to: Section) => (project ? to + withProject("", project) : to); + const href = (to: string) => (project ? to + withProject("", project) : to); const first = useRef(null); useEffect(() => { if (!drawer) return; first.current?.focus(); const esc = (e: KeyboardEvent) => { if (e.key === "Escape" && !e.defaultPrevented) drawer.close(); }; document.addEventListener("keydown", esc); - return () => document.removeEventListener("keydown", esc); + // выезжающая панель модальна: страница и строка приложения недоступны с клавиатуры и для чтения с экрана + const behind = [...document.querySelectorAll(".shell > :not(.sidebar):not(.sidebar-backdrop)")]; + behind.forEach((el) => el.setAttribute("inert", "")); + return () => { document.removeEventListener("keydown", esc); behind.forEach((el) => el.removeAttribute("inert")); }; }, [drawer]); + const link = (s: NavItem, ref?: Ref) => ( + + {s.icon}{s.label} + + ); return (