feat(web-modules): боковая панель по модулям, блок состояния, узкий экран, адреса модулей

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
Andrey Epifancev 2026-10-24 13:56:33 +04:00
parent 4dbda116c0
commit bc367111a7
33 changed files with 563 additions and 257 deletions

View File

@ -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`-строку |

View File

@ -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(<App router={at("/app")} />);
// страница сначала показывает заглушку «Загрузка настроек», затем её заменяет другой элемент с тем же 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(<App router={router} />);
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(<App router={at("/app/nope")} />);
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(<App router={at("/app/board")} />);
render(<App router={at("/app/jira/board")} />);
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(<App router={at("/app/board")} />);
render(<App router={at("/app/jira/board")} />);
expect(screen.queryByRole("link", { name: "Доска связей" })).not.toBeInTheDocument();
});
});

View File

@ -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)}

View File

@ -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();
});

View File

@ -15,7 +15,7 @@ export function BoardHeader(p: BoardHeaderProps) {
const [confirm, setConfirm] = useState(false);
const fileRef = useRef<HTMLInputElement>(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) {
<Badge tone="info">из файла: {p.fileName}</Badge>
<IconButton label="Вернуться к данным хаба" icon={<IconRefresh size={16} />} onClick={p.onReload} />
</>
) : null} {/* данные хаба пометки не имеют: хаб показывает верхняя панель (design.md, решение 13) */}
) : null} {/* данные хаба пометки не имеют: состояние хаба показывает блок состояния модулей боковой панели */}
{st && <span className={"board-event" + (st.warn ? " board-event--warn" : "")} role="status">{st.text}</span>}
</div>
</PageHeader>

View File

@ -36,7 +36,7 @@ export const Card = memo(function Card({ node: n, box, rel, multiSprint, sprintS
onPointerDown={onPointerDown ? (e) => onPointerDown(e, n.key) : undefined}>
<div className="head">
<a className="key" href={`${JIRA_URL}/browse/${n.key}`} target="_blank" rel="noopener">{n.key}</a>
{!n.stub && <Link className="open-card" to={`/issues/${n.key}`} state={BOARD_CARD_STATE} title="Открыть карточку задачи"
{!n.stub && <Link className="open-card" to={`/jira/issues/${n.key}`} state={BOARD_CARD_STATE} title="Открыть карточку задачи"
aria-label={`Открыть карточку ${n.key}`}><IconOpen size={16} /></Link>}
{showTag && (n.sprint
? <span className={"sprint-tag" + (sprintSel.has(n.sprint) ? " sel" : "")} title={n.sprint}>{n.sprint.replace(/^AWPCA_/, "")}</span>

View File

@ -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";

View File

@ -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");
});
});

View File

@ -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 <Navigate to={{ pathname: key ? `${to}/${encodeURIComponent(key)}` : to, search }} replace />;
}
export const jiraModule: HubModule = {
name: "jira",
title: "Jira",
pages: [
{ path: "/", element: <Navigate to="/board" replace /> },
{ path: "/board", element: <ProjectGate>{(p) => <BoardPage key={p.label} project={p.label} />}</ProjectGate> },
{ path: "/issues", element: <ProjectGate>{(p) => <IssuesPage key={p.label} project={p.label} />}</ProjectGate> },
{ path: "/feed", element: <ProjectGate>{(p) => <FeedPage key={p.label} project={p.label} />}</ProjectGate> },
{ path: "/jira/board", element: <ProjectGate>{(p) => <BoardPage key={p.label} project={p.label} />}</ProjectGate> },
{ path: "/jira/issues", element: <ProjectGate>{(p) => <IssuesPage key={p.label} project={p.label} />}</ProjectGate> },
{ path: "/jira/feed", element: <ProjectGate>{(p) => <FeedPage key={p.label} project={p.label} />}</ProjectGate> },
// прежние адреса (закладки): перенаправление с сохранением строки запроса, без новой записи истории
{ path: "/board", element: <LegacyRedirect to="/jira/board" /> },
{ path: "/issues", element: <LegacyRedirect to="/jira/issues" /> },
{ path: "/issues/:key", element: <LegacyRedirect to="/jira/issues" keyParam="key" /> },
{ path: "/feed", element: <LegacyRedirect to="/jira/feed" /> },
],
nav: [
{ to: "/board", label: "Доска связей", icon: <IconBoard /> },
{ to: "/issues", label: "Задачи", icon: <IconList />, padded: true },
{ to: "/feed", label: "Лента", icon: <IconHistory />, padded: true, keepSearch: true },
{ to: "/jira/board", label: "Доска связей", icon: <IconBoard /> },
{ to: "/jira/issues", label: "Задачи", icon: <IconList />, padded: true },
{ to: "/jira/feed", label: "Лента", icon: <IconHistory />, padded: true, keepSearch: true },
],
status: jiraStatus,
useOverlay: useIssueOverlay,

View File

@ -24,7 +24,7 @@ function CardAt() {
function open(path: string, client = makeQueryClient()) {
const router = createMemoryRouter([
{ path: "/issues/:key", element: <CardAt /> },
{ path: "/jira/issues/:key", element: <CardAt /> },
{ path: "/board", element: <p>доска</p> },
], { initialEntries: [path] });
render(<QueryClientProvider client={client}><RouterProvider router={router} /></QueryClientProvider>);
@ -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();

View File

@ -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 {

View File

@ -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(<App router={router} />);
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(<App router={router} />);
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(<App router={router} />);
await userEvent.click(await screen.findByRole("link", { name: "AURORA-9001" }));
await within(dialog()).findByRole("heading", { name: /AURORA-9001/ });
await userEvent.click(dialog()); // цель — сам <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(<App router={router} />);
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 — на <dialog>
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(<App router={router} />);
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(<App router={router} />);
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(<App router={router} />);
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(<App router={router} />);
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(<App router={router} />);
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(<App router={at(["/app/issues/AURORA-1"])} />);
const r1 = render(<App router={at(["/app/jira/issues/AURORA-1"])} />);
expect(await screen.findByRole("dialog", { name: "AURORA-1" })).toBeInTheDocument();
r1.unmount();
render(<App router={at(["/app/issues/AURORA-2"])} />);
render(<App router={at(["/app/jira/issues/AURORA-2"])} />);
expect(await screen.findByRole("dialog", { name: "AURORA-2" })).toBeInTheDocument();
});
});

View File

@ -32,7 +32,7 @@ describe("список задач", () => {
it("фильтры из адреса уходят в GET /issues; ключ ведёт на карточку", async () => {
const fetch = hub(() => [row(1)]);
renderWithProviders(<IssuesPage project="AWPCA" />, { 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(<IssuesPage project="AWPCA" />, { 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 () => {

View File

@ -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);
});

View File

@ -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;

View File

@ -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; }, []);

View File

@ -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();

View File

@ -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(<MemoryRouter><NF /></MemoryRouter>);
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");

View File

@ -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; // при смене проекта строка запроса страницы сохраняется (параметры не проектные); иначе сбрасывается
};

View File

@ -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<void>((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"));
});
});

View File

@ -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<ProjectCtx>(() => ({ state, projects: projects.data ?? [], select, openNew: () => setNewOpen(true) }),
[state, projects.data, select]);
@ -57,7 +60,7 @@ export function Layout() {
<ActivityProvider project={okLabel}>
<div className="shell">
<Sidebar section={section} project={okLabel} />
<main className={"page" + (NAV.find((n) => n.to === section)?.padded ? "" : " page--board")}>
<main className={"page" + (navItem?.padded ? "" : " page--board")}>
<ErrorBoundary key={at}>{page}</ErrorBoundary>
</main>
</div>

View File

@ -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<string, unknown> = {}, 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(<App router={createMemoryRouter(routes, { initialEntries: ["/app/?project=AWPCA"], basename: "/app" })} />);
};
const bar = () => screen.getByRole("complementary", { name: "Панель приложения" });
const rowNow = (module: string) => bar().querySelector<HTMLElement>(`[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();
});
});

View File

@ -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<Pick<HubModule, "status">> => !!m.status);
type WithStatus = HubModule & Required<Pick<HubModule, "status">>;
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 = <span className={`data-status__event${ev ? ` data-status__event--${ev.tone}` : ""}${compact ? " visually-hidden" : ""}`}
const collapsed = variant === "collapsed";
const eventEl = <span className={`data-status__event${ev ? ` data-status__event--${ev.tone}` : ""}${collapsed ? " visually-hidden" : ""}`}
role="status" title={ev?.text}>{ev?.text}</span>;
const button = action && (
<IconButton label={action} title={compact ? [m.title, agentText, freshText, ev?.text].filter(Boolean).join(" · ") : action}
<IconButton label={action} title={collapsed ? [m.title, agentText, freshText, ev?.text].filter(Boolean).join(" · ") : action}
disabled={act.busy} aria-busy={act.busy ? "true" : undefined}
icon={act.busy ? <span className="ui-spinner" aria-hidden="true" /> : <IconRefresh />} onClick={() => void act.start()} />);
if (compact) {
if (collapsed) {
return (
<div className="data-status data-status--compact" data-module={m.name}>
{button}
@ -50,25 +52,25 @@ function ModuleRow({ m, project, compact }: { m: (typeof WITH_STATUS)[number]; p
</span>)}
{button}
</div>
<span className="data-status__sync">{freshText}</span>
<span className={"data-status__sync" + (fresh.error ? " data-status__sync--error" : "")}>{freshText}</span>
{eventEl}
</div>
);
}
// строки модулей, у которых есть данные для проекта
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) => <ModuleRow key={m.name} m={m} project={p} compact={compact} />)}</>;
return <>{WITH_STATUS.filter((m) => m.status.freshness(p).available).map((m) => <ModuleRow key={m.name} m={m} project={p} variant={variant} />)}</>;
}
// 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 ? <div className={`topbar-event data-status__event--${ev.tone}`} data-testid="topbar-event" aria-hidden="true">{ev.text}</div> : null;
return ev ? <div className={`topbar-event data-status__event--${ev.tone}`}>{ev.text}</div> : null;
}
export function TopbarNotice() {
return <>{WITH_STATUS.map((m) => <ModuleNotice key={m.name} m={m} />)}</>;
export function ModuleEventStrip() {
return <div className="topbar-events" role="status" data-testid="topbar-events">{WITH_STATUS.map((m) => <ModuleEvent key={m.name} m={m} />)}</div>;
}

View File

@ -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 (

View File

@ -0,0 +1,10 @@
import { PageHeader } from "../ui/index.ts";
// ВРЕМЕННАЯ заглушка «Обзора» (Task 4): Task 5 заменяет её страницей со сводками модулей
export function OverviewPage() {
return (
<div data-testid="page-overview">
<PageHeader title="Обзор" />
</div>
);
}

View File

@ -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");
});
});

View File

@ -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 ? <NarrowShell {...p} /> : <Panel {...p} />;
}
const SUMMARY_TEXT = { error: "ошибка", busy: "идёт обновление", online: "агент на связи", offline: "агент офлайн" } as const;
// сводная точка состояния модулей: цвет не единственный признак — состояние в aria-label и title
function SummaryDot() {
const summary = useActivitySummary();
const text = `Состояние модулей: ${SUMMARY_TEXT[summary]}`;
return <span role="img" aria-label={text} title={text} className={`topbar__dot topbar__dot--${summary}`} />;
}
function NarrowShell(p: Props) {
const [open, setOpen] = useState(false);
const menuBtn = useRef<HTMLButtonElement>(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 (
<>
<header className="topbar" aria-label="Строка приложения">
<button ref={menuBtn} type="button" className="ui-icon-btn" aria-label="Меню" title="Меню" aria-expanded={open}
onClick={() => setOpen(true)}><IconMenu /></button>
<span className="sidebar__brand">SDLC Hub</span>
{p.project && <span className="topbar__project" title={`Проект ${p.project}`}>{p.project}</span>}
<span className="sidebar__spacer" />
<StatusBlock project={p.project} compact />
<SummaryDot />
</header>
<TopbarNotice />
<ModuleEventStrip />
{open && <>
<div className="sidebar-backdrop" data-testid="sidebar-backdrop" onClick={drawer.close} />
<Panel {...p} drawer={drawer} />
@ -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<HTMLAnchorElement>(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<HTMLAnchorElement>) => (
<Link key={s.to} ref={ref} to={href(s.to)} className="sidebar__link"
aria-current={section === s.to ? "page" : undefined} title={collapsed ? s.label : undefined} onClick={drawer?.navigated}>
{s.icon}<span className={collapsed ? "visually-hidden" : undefined}>{s.label}</span>
</Link>
);
return (
<aside className={"sidebar" + (collapsed ? " is-collapsed" : "") + (drawer ? " is-drawer" : "")} aria-label="Панель приложения">
<div className="sidebar__head">
@ -83,16 +109,19 @@ function Panel({ section, project, drawer }: Props & { drawer?: { close(): void;
<button type="button" className="sidebar__project-tag" aria-label={`Проект ${project} — развернуть панель`}
title={`Проект ${project}`} onClick={() => fold(false)}>{project.slice(0, 6)}</button>)
: <ProjectPicker />}
<nav className="sidebar__nav" aria-label="Разделы">
{SECTIONS.map((s, i) => (
<Link key={s.to} ref={i === 0 ? first : undefined} to={href(s.to)} className="sidebar__link"
aria-current={section === s.to ? "page" : undefined} title={collapsed ? s.label : undefined} onClick={drawer?.navigated}>
{s.icon}<span className={collapsed ? "visually-hidden" : undefined}>{s.label}</span>
</Link>
))}
</nav>
<span className="sidebar__spacer" />
<StatusBlock project={project} compact={collapsed} />
<div className="sidebar__scroll">
<nav className="sidebar__nav" aria-label="Разделы">
{link(OVERVIEW, first)}
{GROUPS.map((g) => (
<div key={g.name} role="group" aria-labelledby={`sidebar-group-${g.name}`} className="sidebar__group">
<span id={`sidebar-group-${g.name}`} className={"sidebar__group-title" + (collapsed ? " visually-hidden" : "")}>{g.title}</span>
{g.nav.map((n) => link(n))}
</div>
))}
</nav>
<span className="sidebar__spacer" />
<ModuleStatusBlock project={project} variant={collapsed ? "collapsed" : "expanded"} />
</div>
<div className="sidebar__foot">
<ThemeSwitch />
<Menu label="Ещё" up align={collapsed ? "start" : "end"}>

8
web/src/shell/nav.tsx Normal file
View File

@ -0,0 +1,8 @@
import { MODULES } from "../modules/index.ts";
import type { NavItem } from "../modules/types.ts";
import { IconHome } from "../ui/index.ts";
// «Обзор» — главная страница ядра; разделы модулей — по группам
export const OVERVIEW: NavItem = { to: "/", label: "Обзор", icon: <IconHome />, padded: true };
export const GROUPS = MODULES.filter((m) => m.nav.length > 0).map((m) => ({ name: m.name, title: m.title, nav: m.nav }));
export const NAV: NavItem[] = [OVERVIEW, ...GROUPS.flatMap((g) => g.nav)];

View File

@ -1,6 +1,12 @@
import type { RouteObject } from "react-router";
import { MODULES } from "../modules/index.ts";
import { ProjectGate } from "../projects/ProjectGate.tsx";
import { NotFound } from "./NotFound.tsx";
import { OverviewPage } from "./OverviewPage.tsx";
// страницы модулей; Layout рисует их по адресу страницы под окном карточки (design 10); NotFound — последним
export const PAGE_ROUTES: RouteObject[] = [...MODULES.flatMap((m) => m.pages), { path: "*", element: <NotFound /> }];
// «Обзор» ядра, страницы модулей; Layout рисует их по адресу страницы под окном карточки (design 10); NotFound — последним
export const PAGE_ROUTES: RouteObject[] = [
{ path: "/", element: <ProjectGate>{() => <OverviewPage />}</ProjectGate> },
...MODULES.flatMap((m) => m.pages),
{ path: "*", element: <NotFound /> },
];

View File

@ -1,4 +1,5 @@
/* оболочка: боковая панель и страница (#69, design 1–3); прокрутка — у окна, панель липнет к его верху */
/* оболочка: боковая панель и страница (#69, design 1–3); прокрутка — у окна, панель липнет к её верху; если содержимое не
помещается по высоте, прокручивается середина панели (.sidebar__scroll), низ с меню «Ещё» остаётся на месте */
.shell{display:grid;grid-template-columns:auto minmax(0,1fr);min-height:100vh}
/* C-1: у элемента сетки с margin-inline:auto ширина — по содержимому; явная 100% держит страницу в своей дорожке */
.shell>.page{width:100%;box-sizing:border-box}
@ -16,7 +17,15 @@
font-weight:600;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;cursor:pointer;
}
.sidebar__project-tag:hover{color:var(--color-text-primary);border-color:var(--color-border-strong)}
.sidebar__scroll{
flex:1 1 auto;min-height:0;overflow-y:auto;display:flex;flex-direction:column;gap:var(--space-4);
margin-inline:calc(-1 * var(--space-1));padding:2px var(--space-1);
}
.is-collapsed .sidebar__scroll{align-items:center;align-self:stretch}
.sidebar__nav{display:flex;flex-direction:column;gap:var(--space-1);align-self:stretch}
.sidebar__group{display:flex;flex-direction:column;gap:var(--space-1);margin-top:var(--space-2)}
.sidebar__group-title{padding:var(--space-1) var(--space-3);font-size:var(--fs-caption);line-height:var(--lh-caption);font-weight:700;color:var(--color-text-secondary)}
.is-collapsed .sidebar__group{padding-top:var(--space-2);border-top:1px solid var(--color-border-default)}
.sidebar__link{
display:flex;align-items:center;gap:var(--space-3);height:var(--control-h-lg);padding:0 var(--space-3);box-sizing:border-box;
border-radius:var(--control-radius);color:var(--color-text-secondary);text-decoration:none;font-weight:600;white-space:nowrap;
@ -32,7 +41,7 @@
padding-top:var(--space-3);border-top:1px solid var(--color-border-default);
}
.is-collapsed .sidebar__foot{flex-direction:column}
/* блок данных Jira: в развёрнутой панели — колонкой, событие пакета под кнопкой с переносом */
/* блок состояния модулей: строка модуля — колонкой, событие пакета под кнопкой с переносом */
.data-status{display:flex;flex-direction:column;align-items:stretch;gap:var(--space-2);align-self:stretch;font-size:var(--fs-caption);line-height:var(--lh-caption);color:var(--color-text-secondary)}
.data-status .ui-btn{width:100%;justify-content:center}
.data-status__head{display:flex;align-items:center;gap:var(--space-2);flex-wrap:wrap}
@ -41,6 +50,7 @@
.data-status__agent{display:inline-flex;align-items:center;gap:6px}
.data-status__dot{width:8px;height:8px;border-radius:50%;background:var(--color-status-warning);flex:0 0 auto}
.data-status__dot.is-online{background:var(--color-status-success)}
.data-status__sync--error{color:var(--color-status-error);font-weight:600;overflow-wrap:anywhere}
.data-status__event{font-weight:600;overflow-wrap:anywhere}
.data-status__event:empty{position:absolute} /* живой регион всегда в DOM; пустой не занимает места */
.data-status__event--info{color:var(--color-text-secondary)}
@ -51,19 +61,24 @@
.data-status--compact .data-status__dot{position:absolute;top:2px;right:2px;pointer-events:none;box-shadow:0 0 0 2px var(--color-bg-surface)}
.crash{max-width:640px;margin:48px auto}
.crash h2{margin:0;font:inherit}
/* узкий экран (<640 px): закреплённая строка с «Меню» и выезжающая панель поверх страницы (design 4) */
/* узкий экран (<640 px): закреплённая строка с «Меню», меткой проекта и сводной точкой; выезжающая модальная панель (design 4) */
.topbar{
position:sticky;top:0;z-index:20;display:flex;align-items:center;gap:var(--space-2);height:56px;box-sizing:border-box;
padding:0 var(--space-4) 0 var(--space-2);border-bottom:1px solid var(--color-border-default);background:var(--color-bg-surface);
}
.topbar .sidebar__brand{padding-left:0}
.topbar__project{min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;font-weight:700;color:var(--color-text-primary)}
.topbar__dot{width:12px;height:12px;border-radius:50%;flex:0 0 auto;background:var(--color-status-warning)}
.topbar__dot--online{background:var(--color-status-success)}
.topbar__dot--busy{background:var(--color-text-secondary)}
.topbar__dot--error{background:var(--color-status-error)}
.sidebar-backdrop{position:fixed;inset:0;z-index:30;background:var(--color-overlay)}
.sidebar.is-drawer{position:fixed;left:0;top:0;bottom:0;z-index:31;width:min(300px,85vw);height:auto;box-shadow:var(--shadow-popover)}
@media (max-width:639px){
.shell{display:block}
:root{--topbar-h:56px}
}
.topbar-events{position:sticky;top:56px;z-index:19}
.topbar-events:empty{position:absolute;width:1px;height:1px;overflow:hidden;clip-path:inset(50%)} /* живой регион всегда в DOM; пустой не занимает места */
.topbar-event{
position:sticky;top:56px;z-index:19;padding:var(--space-2) var(--space-4);border-bottom:1px solid var(--color-border-default);
padding:var(--space-2) var(--space-4);border-bottom:1px solid var(--color-border-default);
background:var(--color-bg-subtle);font-size:var(--fs-caption);line-height:var(--lh-caption);font-weight:600;overflow-wrap:anywhere;
}

View File

@ -0,0 +1,8 @@
import { useEffect } from "react";
// заголовок вкладки: «<страница> · <метка проекта> · SDLC Hub»; без страницы или проекта части опускаются
export function useDocumentTitle(page: string | null, project: string | null): void {
useEffect(() => {
document.title = [page, project, "SDLC Hub"].filter(Boolean).join(" · ");
}, [page, project]);
}

View File

@ -15,9 +15,10 @@ export function Dropdown({ button, className = "", menu = false, children, onOpe
document.addEventListener("pointerdown", away);
return () => document.removeEventListener("pointerdown", away);
}, [open]);
// preventDefault на Escape: открытое меню закрывается само, объемлющая панель (выезжающая) Escape не получает (M-1 #73)
const toggle = () => { if (!open) onOpen?.(); setOpen(!open); }; // T12.1: побочное действие вне updater
return (
<div className={"dd " + className} ref={ref} onKeyDown={(e) => { if (e.key === "Escape") setOpen(false); }}>
<div className={"dd " + className} ref={ref} onKeyDown={(e) => { if (e.key === "Escape" && open) { setOpen(false); e.preventDefault(); } }}>
{button({ open, toggle })}
{open && <div className={"dd-menu" + (menu ? " menu" : "")}>{children(() => setOpen(false))}</div>}
</div>

View File

@ -42,4 +42,5 @@ export const IconDownload = make(<><path d="M12 4v12"/><path d="M7 11l5 5 5-5"/>
export const IconFile = make(<><path d="M14 3H7a1 1 0 0 0-1 1v16a1 1 0 0 0 1 1h10a1 1 0 0 0 1-1V7z"/><path d="M14 3v4h4"/></>);
export const IconImage = make(<><rect x="3.5" y="5" width="17" height="14" rx="2"/><circle cx="9" cy="10" r="1.5"/><path d="M4 17l5-4.5 3.5 3 3-2.5 4.5 4"/></>);
export const IconSheet = make(<><rect x="4" y="4" width="16" height="16" rx="2"/><path d="M4 10h16M4 15h16M10 4v16"/></>);
export const IconHome = make(<><path d="M4 11l8-7 8 7"/><path d="M6 10v9a1 1 0 0 0 1 1h3.5v-5h3v5H17a1 1 0 0 0 1-1v-9"/></>);
export const IconBook = make(<><path d="M5 4.5A1.5 1.5 0 0 1 6.5 3H19v15H6.5A1.5 1.5 0 0 0 5 19.5z"/><path d="M5 19.5A1.5 1.5 0 0 0 6.5 21H19v-3"/><path d="M9 7.5h6M9 11h6"/></>);