Спринты и бэклог в модуле jira (#108) #109
@ -98,15 +98,15 @@
|
||||
его ядро: трекер пакетов и агентов — `web/src/shell/activity/` (`ActivityProvider`: подхват идущего пакета и `GET /agents` раз в 60 с,
|
||||
опрос пакета каждые 3 с, `useModuleActivity(module)`, тексты `events.ts`/`agentText.ts`), блок состояния панели — `ModuleStatusBlock`, а модуль
|
||||
даёт только `Summary` — карточку на «Обзоре» (`OverviewPage`: карточки модулей и «Последние пакеты проекта»). Адреса: «Обзор» — `/app/`,
|
||||
страницы модуля — `/app/<модуль>/…` (`/app/jira/board`, `/app/jira/issues[/{key}]`, `/app/jira/feed`, `/app/wiki`); прежние `/app/board`,
|
||||
`/app/issues`, `/app/issues/{key}`, `/app/feed` перенаправляются с сохранением `search` и `replace`.
|
||||
страницы модуля — `/app/<модуль>/…` (`/app/jira/links`, `/app/jira/issues[/{key}]`, `/app/jira/feed`, `/app/wiki`); прежние `/app/board`, `/app/jira/board`,
|
||||
`/app/issues`, `/app/issues/{key}`, `/app/feed` перенаправляются с сохранением `search` и `replace`. Пункты меню могут зависеть от проекта (`NavItem.when`, `visibleNav` в `shell/nav.tsx`): пока проект не определён, условный пункт скрыт; «не найдено» перечисляет только безусловные.
|
||||
|
||||
Модуль `wiki` (`web/src/modules/wiki/index.tsx`): страница «Вики» (`/app/wiki`): дерево (`Tree`) с поиском (`Search`), страница
|
||||
(`WikiPage`, `PageView` — `react-markdown` + `remark-gfm`, без `rehype-raw`; ссылки `page:` ведут на страницу вики, `attachment:`
|
||||
скачивают файл через `download.ts`, `urlTransform.ts` пропускает только эти схемы и http(s)), форма корня без привязки (`RootForm`),
|
||||
сводка для «Обзора» (`Summary`) и `status` для ядра; поле «Корень вики» в окне «Новый проект» — через `projectFields`.
|
||||
|
||||
Модуль `jira` (`web/src/modules/jira/index.tsx`): страницы «Доска связей» (`/app/jira/board`), «Задачи» (`/app/jira/issues`), «Лента»
|
||||
Модуль `jira` (`web/src/modules/jira/index.tsx`): страницы «Связи» (`/app/jira/links`, заголовок страницы — «Доска связей»), «Задачи» (`/app/jira/issues`), «Лента»
|
||||
(`/app/jira/feed`), карточка `/app/jira/issues/{key}` окном поверх страницы (`issues/overlay.tsx`, `IssueModal.tsx`; оболочка рисует
|
||||
страницу по `location.state.background`). Внутри: `board/` (чистые `model`, `analysis`, `layout`, `storage.ts` — ключи
|
||||
`localStorage` прежней страницы, у проекта AWPCA прежние, у остальных с суффиксом `@<метка>`; отрисовка `components/`;
|
||||
|
||||
@ -26,7 +26,7 @@ SDLC-хаб с модулями jira и wiki. Мост Jira для АРМ КА:
|
||||
- `uv sync` — установка окружения.
|
||||
- `uv run sdlc-hub init --config PATH [--force]` — настройки проекта из `config.example.json`, создание токенов.
|
||||
- `uv run sdlc-hub serve [--host] [--port]` — локальный запуск хаба.
|
||||
- Веб-приложение — `http://127.0.0.1:8790/app/` («Обзор» — `/app/?project=<метка>`, доска — `/app/jira/board?project=<метка>`, задачи — `/app/jira/issues?project=<метка>`, лента изменений — `/app/jira/feed?project=<метка>`, вики — `/app/wiki?project=<метка>`; вход по клиентскому токену); `/` и `/links` перенаправляют на «Обзор», прежние `/app/board`, `/app/issues`, `/app/feed` — на страницы модуля Jira.
|
||||
- Веб-приложение — `http://127.0.0.1:8790/app/` («Обзор» — `/app/?project=<метка>`, «Связи» (доска связей) — `/app/jira/links?project=<метка>`, задачи — `/app/jira/issues?project=<метка>`, лента изменений — `/app/jira/feed?project=<метка>`, вики — `/app/wiki?project=<метка>`; вход по клиентскому токену); `/` и `/links` перенаправляют на «Обзор», прежние `/app/board`, `/app/jira/board`, `/app/issues`, `/app/feed` — на страницы модуля Jira; пункты меню могут зависеть от проекта (`NavItem.when`).
|
||||
- `make web` — сборка приложения (после `git pull` на Mac mini, до перезапуска хаба); `make check` — pytest + lint и тесты фронта; `cd web && npm run dev` — разработка с прокси на тестовый хаб `bash web/scripts/test-hub.sh` (:8791); `cd web && npm run e2e` — Playwright; `cd web && npm run golden` — перегенерировать эталоны доски из `links.html@3931a88` (только при смене фикстур).
|
||||
- `uv run sdlc-hub import FOLDER` — импорт CSV-пакета `admin/jira-sync`; `uv run sdlc-hub mcp <модуль> [--project <метка>]` — MCP модуля по stdio (модуль обязателен: `jira` — сервер `sdlc-jira`, `wiki` — сервер `sdlc-wiki`; с привязкой сессии к проекту); `uv run sdlc-hub tokens` — показать токены (клиентский и по агенту на модуль).
|
||||
- `uv run sdlc-hub agent publish [--module jira|wiki] PATH` — опубликовать версию агента модуля для самообновления (только вверх; по умолчанию `jira`; публикация лежит в `release/<модуль>/`).
|
||||
|
||||
@ -246,8 +246,8 @@ curl -s -H "Authorization: Bearer $TOKEN" "http://127.0.0.1:8790/issues?sprint=
|
||||
|
||||
## Веб-приложение
|
||||
|
||||
Адреса: `http://127.0.0.1:8790/app/` — «Обзор» `/app/?project=<метка>` (карточки модулей Jira и «Вики» с состоянием агента, свежестью данных и обновлением, последние пакеты проекта), доска `/app/jira/board?project=<метка>`, задачи `/app/jira/issues?project=<метка>`, лента изменений `/app/jira/feed?project=<метка>` (период и виды событий, история — и в карточке задачи), вики `/app/wiki?project=<метка>`, карточка `/app/jira/issues/{key}` — окно поверх списка или доски (закрытие — ✕, Esc, щелчок по фону; прямой адрес открывает окно поверх списка задач); `/` и
|
||||
`/links` перенаправляют на «Обзор»; прежние адреса `/app/board`, `/app/issues`, `/app/feed` ведут на страницы модуля Jira. Вход по клиентскому токену (`uv run sdlc-hub tokens`), токен хранится в браузере;
|
||||
Адреса: `http://127.0.0.1:8790/app/` — «Обзор» `/app/?project=<метка>` (карточки модулей Jira и «Вики» с состоянием агента, свежестью данных и обновлением, последние пакеты проекта), «Связи» (доска связей) `/app/jira/links?project=<метка>`, задачи `/app/jira/issues?project=<метка>`, лента изменений `/app/jira/feed?project=<метка>` (период и виды событий, история — и в карточке задачи), вики `/app/wiki?project=<метка>`, карточка `/app/jira/issues/{key}` — окно поверх списка или доски (закрытие — ✕, Esc, щелчок по фону; прямой адрес открывает окно поверх списка задач); `/` и
|
||||
`/links` перенаправляют на «Обзор»; прежние адреса `/app/board`, `/app/jira/board`, `/app/issues`, `/app/feed` ведут на страницы модуля Jira; пункты меню могут зависеть от проекта (`NavItem.when`). Вход по клиентскому токену (`uv run sdlc-hub tokens`), токен хранится в браузере;
|
||||
`localhost` и `127.0.0.1` — разные сайты для браузера, токен и раскладка у каждого свои. Выкатка на Mac mini:
|
||||
|
||||
```
|
||||
|
||||
@ -59,7 +59,7 @@ test("ⓘ открывает карточку окном поверх доски
|
||||
await expect(page).toHaveURL(/\/app\/jira\/issues\/AURORA-9001$/);
|
||||
await expect(page.getByRole("dialog").getByRole("heading", { name: "Схема свода" })).toBeVisible();
|
||||
await expect(page.getByTestId("page-board")).toBeAttached();
|
||||
await expect(page.getByRole("link", { name: "Доска связей", exact: true })).toHaveAttribute("aria-current", "page");
|
||||
await expect(page.getByRole("link", { name: "Связи", exact: true })).toHaveAttribute("aria-current", "page");
|
||||
});
|
||||
|
||||
test("M-3: прокрутка и данные доски после окна карточки и после другого раздела", async ({ page }) => {
|
||||
@ -80,13 +80,13 @@ test("M-3: прокрутка и данные доски после окна к
|
||||
await page.locator(`.board .card[data-key="${key}"] a.open-card`).click();
|
||||
await expect(page.getByRole("dialog")).toBeVisible();
|
||||
await page.keyboard.press("Escape");
|
||||
await expect(page).toHaveURL(/\/app\/jira\/board\?project=AWPCA$/);
|
||||
await expect(page).toHaveURL(/\/app\/jira\/links\?project=AWPCA$/);
|
||||
expect(await page.evaluate(() => window.scrollY)).toBeGreaterThan(y - 2);
|
||||
expect(boardLoads).toBe(0); // доска не перезагружалась
|
||||
// боковая панель закреплена (#69): ссылки разделов видны и после прокрутки, щелчок не двигает доску
|
||||
await page.getByRole("link", { name: "Задачи", exact: true }).click();
|
||||
await expect(page).toHaveURL(/\/app\/jira\/issues\?project=AWPCA$/);
|
||||
await page.getByRole("link", { name: "Доска связей", exact: true }).click();
|
||||
await page.getByRole("link", { name: "Связи", exact: true }).click();
|
||||
await expect.poll(() => page.evaluate(() => window.scrollY)).toBeGreaterThan(y - 2);
|
||||
});
|
||||
|
||||
|
||||
@ -5,12 +5,12 @@ import { HUB_HOME, HUB_URL } from "../playwright.config.ts";
|
||||
export const tokens = () => JSON.parse(readFileSync(`${HUB_HOME}/tokens.json`, "utf8")) as { client: string; agents: Record<string, string> };
|
||||
const BASE = HUB_URL;
|
||||
|
||||
export async function login(page: Page, path = "/app/jira/board") {
|
||||
export async function login(page: Page, path = "/app/jira/links") {
|
||||
await page.goto(path);
|
||||
await page.getByLabel("Клиентский токен хаба").fill(tokens().client);
|
||||
await page.getByRole("button", { name: "Войти" }).click();
|
||||
// оболочка: на широком экране — ссылки разделов в боковой панели, на узком — кнопка «Меню» (#69)
|
||||
await expect(page.getByRole("complementary", { name: "Панель приложения" }).getByRole("link", { name: "Доска связей" }).or(page.getByRole("button", { name: "Меню" }))).toBeVisible();
|
||||
await expect(page.getByRole("complementary", { name: "Панель приложения" }).getByRole("link", { name: "Связи" }).or(page.getByRole("button", { name: "Меню" }))).toBeVisible();
|
||||
}
|
||||
|
||||
async function call(path: string, body: unknown) {
|
||||
|
||||
@ -76,7 +76,7 @@ test("переход по связи и «назад»; ✕ и щелчок по
|
||||
await page.mouse.click(5, 300); // фон слева от окна
|
||||
await expect(page.getByRole("dialog")).toHaveCount(0);
|
||||
await page.goBack();
|
||||
await expect(page).toHaveURL(/\/app\/jira\/board\?project=AWPCA$/);
|
||||
await expect(page).toHaveURL(/\/app\/jira\/links\?project=AWPCA$/);
|
||||
await expect(page.getByRole("dialog")).toHaveCount(0);
|
||||
});
|
||||
|
||||
|
||||
@ -49,7 +49,7 @@ test("C-1: на средних ширинах страница не шире о
|
||||
await expect(page.locator("table.issue-table tbody tr").first()).toBeVisible();
|
||||
expect(await page.evaluate(() => document.documentElement.scrollWidth)).toBeLessThanOrEqual(1280);
|
||||
await page.setViewportSize({ width: 1000, height: 800 });
|
||||
await page.getByRole("link", { name: "Доска связей" }).click();
|
||||
await page.getByRole("link", { name: "Связи" }).click();
|
||||
await expect(page.locator(".board .card[data-key]").first()).toBeVisible();
|
||||
await page.getByRole("button", { name: "По датам" }).click();
|
||||
await expect(page.locator(".dcol").first()).toBeVisible();
|
||||
@ -71,10 +71,16 @@ test("1440 px: «Задачи» не шире окна, h1 «Вики» — 20px
|
||||
expect(await h1.evaluate((el) => getComputedStyle(el).fontSize)).toBe("20px");
|
||||
});
|
||||
|
||||
test("/app/board → /app/jira/board с сохранением search", async ({ page }) => {
|
||||
test("/app/board → /app/jira/links с сохранением search", async ({ page }) => {
|
||||
await login(page);
|
||||
await page.goto("/app/board?project=AWPCA");
|
||||
await expect(page).toHaveURL(/\/app\/jira\/board\?project=AWPCA$/);
|
||||
await expect(page).toHaveURL(/\/app\/jira\/links\?project=AWPCA$/);
|
||||
});
|
||||
|
||||
test("/app/jira/board → /app/jira/links с сохранением search", async ({ page }) => {
|
||||
await login(page);
|
||||
await page.goto("/app/jira/board?project=AWPCA");
|
||||
await expect(page).toHaveURL(/\/app\/jira\/links\?project=AWPCA$/);
|
||||
});
|
||||
|
||||
test("390 px: метка проекта в верхней строке, «Меню» модально", async ({ page }) => {
|
||||
|
||||
@ -2,7 +2,7 @@ import { expect, test } from "@playwright/test";
|
||||
import { login } from "./hub.ts";
|
||||
|
||||
test("неверный токен — пояснение; верный — доска", async ({ page }) => {
|
||||
await page.goto("/app/jira/board");
|
||||
await page.goto("/app/jira/links");
|
||||
await page.getByLabel("Клиентский токен хаба").fill("wrong-token");
|
||||
await page.getByRole("button", { name: "Войти" }).click();
|
||||
await expect(page.getByText(/Токен не подошёл/)).toBeVisible();
|
||||
@ -16,7 +16,7 @@ test("прежние адреса ведут на страницы модуле
|
||||
await expect(page).toHaveURL(/\/app\/?\?project=AWPCA$/);
|
||||
await expect(page.getByTestId("page-overview")).toBeVisible();
|
||||
await page.goto("/app/board?project=AWPCA");
|
||||
await expect(page).toHaveURL(/\/app\/jira\/board\?project=AWPCA$/);
|
||||
await expect(page).toHaveURL(/\/app\/jira\/links\?project=AWPCA$/);
|
||||
await page.goto("/app/issues/AURORA-9001?project=AWPCA");
|
||||
await expect(page).toHaveURL(/\/app\/jira\/issues\/AURORA-9001\?project=AWPCA$/);
|
||||
});
|
||||
|
||||
@ -5,7 +5,7 @@ import { agentServeRefresh, login } from "./hub.ts";
|
||||
test.describe.serial("проекты", () => {
|
||||
test("первый вход: проект AWPCA в адресе, на доске задача проекта", async ({ page }) => {
|
||||
await login(page);
|
||||
await expect(page).toHaveURL(/\/app\/jira\/board\?project=AWPCA$/);
|
||||
await expect(page).toHaveURL(/\/app\/jira\/links\?project=AWPCA$/);
|
||||
await expect(page.locator('.board .card[data-key="AURORA-9001"]')).toBeVisible();
|
||||
});
|
||||
|
||||
@ -35,7 +35,7 @@ test.describe.serial("проекты", () => {
|
||||
});
|
||||
|
||||
test("переключение проекта и раздела", async ({ page }) => {
|
||||
await login(page, "/app/jira/board?project=STOCKS_DRRK");
|
||||
await login(page, "/app/jira/links?project=STOCKS_DRRK");
|
||||
await page.getByLabel("Проект", { exact: true }).selectOption({ label: "AWPCA — АРМ КА" });
|
||||
await expect(page.locator('.board .card[data-key="AURORA-9001"]')).toBeVisible();
|
||||
await page.getByRole("link", { name: "Задачи", exact: true }).click();
|
||||
|
||||
@ -26,7 +26,7 @@ for (const theme of ["light", "dark"] as const) {
|
||||
};
|
||||
|
||||
test("login", async ({ page }) => {
|
||||
await page.goto("/app/jira/board");
|
||||
await page.goto("/app/jira/links");
|
||||
await expect(page.getByLabel("Клиентский токен хаба")).toBeVisible();
|
||||
await settle(page);
|
||||
await shot(page, "login");
|
||||
|
||||
@ -20,29 +20,44 @@ describe("оболочка", () => {
|
||||
});
|
||||
|
||||
it("переход «Задачи» и обратно", async () => {
|
||||
const router = at("/app/jira/board");
|
||||
const router = at("/app/jira/links");
|
||||
render(<App router={router} />);
|
||||
await userEvent.click(screen.getByRole("link", { name: "Задачи" }));
|
||||
expect(router.state.location.pathname).toBe("/app/jira/issues");
|
||||
expect(router.state.location.search).toBe("?project=AWPCA");
|
||||
expect(await screen.findByTestId("page-issues")).toBeInTheDocument();
|
||||
await userEvent.click(screen.getByRole("link", { name: "Доска связей" }));
|
||||
expect(router.state.location.pathname).toBe("/app/jira/board");
|
||||
await userEvent.click(screen.getByRole("link", { name: "Связи" }));
|
||||
expect(router.state.location.pathname).toBe("/app/jira/links");
|
||||
expect(router.state.location.search).toBe("?project=AWPCA");
|
||||
expect(await screen.findByTestId("page-board")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("прежний адрес доски связей", async () => {
|
||||
const router = at("/app/jira/board?project=AWPCA");
|
||||
render(<App router={router} />);
|
||||
await waitFor(() => expect(router.state.location.pathname + router.state.location.search).toBe("/app/jira/links?project=AWPCA"));
|
||||
expect(await screen.findByRole("link", { name: "Связи" })).toHaveAttribute("aria-current", "page");
|
||||
await waitFor(() => expect(document.title).toBe("Связи · AWPCA · SDLC Hub"));
|
||||
});
|
||||
|
||||
it("/app/board → сразу /app/jira/links", async () => {
|
||||
const router = at("/app/board?project=AWPCA");
|
||||
render(<App router={router} />);
|
||||
await waitFor(() => expect(router.state.location.pathname).toBe("/app/jira/links"));
|
||||
expect(router.state.location.search).toBe("?project=AWPCA");
|
||||
});
|
||||
|
||||
it("неизвестный путь — «не найдено» со ссылками", async () => {
|
||||
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", expect.stringMatching(/^\/app\/?$/));
|
||||
expect(main.getByRole("link", { name: "Доска связей" })).toHaveAttribute("href", "/app/jira/board");
|
||||
expect(main.getByRole("link", { name: "Связи" })).toHaveAttribute("href", "/app/jira/links");
|
||||
expect(main.getByRole("link", { name: "Задачи" })).toHaveAttribute("href", "/app/jira/issues");
|
||||
});
|
||||
|
||||
it("«Сменить токен» показывает экран входа", async () => {
|
||||
render(<App router={at("/app/jira/board")} />);
|
||||
render(<App router={at("/app/jira/links")} />);
|
||||
await userEvent.click(within(await screen.findByRole("complementary", { name: "Панель приложения" })).getByRole("button", { name: "Ещё" })); // #68 M-2: на доске есть ещё и «Ещё — доска»
|
||||
await userEvent.click(screen.getByRole("button", { name: "Сменить токен" }));
|
||||
expect(getAuth().token).toBe("");
|
||||
@ -59,13 +74,13 @@ describe("оболочка", () => {
|
||||
expect(await screen.findByText(/сломалось/)).toBeInTheDocument();
|
||||
expect(screen.getByRole("button", { name: "Перезагрузить" })).toBeInTheDocument();
|
||||
expect(screen.getAllByRole("alert")).toHaveLength(1);
|
||||
expect(screen.getByRole("link", { name: "Доска связей" })).toBeInTheDocument();
|
||||
expect(screen.getByRole("link", { name: "Связи" })).toBeInTheDocument();
|
||||
} finally { PAGE_ROUTES.splice(PAGE_ROUTES.indexOf(boom), 1); }
|
||||
});
|
||||
|
||||
it("без токена — только экран входа", () => {
|
||||
clearToken();
|
||||
render(<App router={at("/app/jira/board")} />);
|
||||
expect(screen.queryByRole("link", { name: "Доска связей" })).not.toBeInTheDocument();
|
||||
render(<App router={at("/app/jira/links")} />);
|
||||
expect(screen.queryByRole("link", { name: "Связи" })).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
@ -24,7 +24,7 @@ export function Summary({ project }: { project: Project }) {
|
||||
{own.length} задач · {open.length} открытых · <span className={late ? "jira-summary__late" : undefined}>{late} просрочено</span>
|
||||
</p>)}
|
||||
<nav className="jira-summary__links" aria-label="Разделы Jira">
|
||||
<Link to={`/jira/board${q}`}>Доска связей</Link>
|
||||
<Link to={`/jira/links${q}`}>Связи</Link>
|
||||
<Link to={`/jira/issues${q}`}>Задачи</Link>
|
||||
<Link to={`/jira/feed${q}`}>Лента</Link>
|
||||
</nav>
|
||||
|
||||
@ -21,17 +21,18 @@ export const jiraModule: HubModule = {
|
||||
name: "jira",
|
||||
title: "Jira",
|
||||
pages: [
|
||||
{ path: "/jira/board", element: <ProjectGate>{(p) => <BoardPage key={p.label} project={p.label} />}</ProjectGate> },
|
||||
{ path: "/jira/links", 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: "/jira/board", element: <LegacyRedirect to="/jira/links" /> },
|
||||
{ path: "/board", element: <LegacyRedirect to="/jira/links" /> },
|
||||
{ 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: "/jira/board", label: "Доска связей", icon: <IconBoard /> },
|
||||
{ to: "/jira/links", label: "Связи", icon: <IconBoard /> },
|
||||
{ to: "/jira/issues", label: "Задачи", icon: <IconList />, padded: true },
|
||||
{ to: "/jira/feed", label: "Лента", icon: <IconHistory />, padded: true, keepSearch: true },
|
||||
],
|
||||
|
||||
@ -33,7 +33,7 @@ describe("карточка окном", () => {
|
||||
|
||||
it("из списка: окно поверх списка, фон не пересоздаётся, ✕ возвращает со старым фильтром, «назад» не открывает карточку", async () => {
|
||||
sessionStorage.setItem("sdlc-hub-issue-filters", "1"); // панель «Фильтры» раскрыта
|
||||
const router = at(["/app/jira/board", "/app/jira/issues?sprint=AWPCA_Спринт 2"]);
|
||||
const router = at(["/app/jira/links", "/app/jira/issues?sprint=AWPCA_Спринт 2"]);
|
||||
render(<App router={router} />);
|
||||
const table = await screen.findByRole("table");
|
||||
expect(screen.getByLabelText("Спринт")).toHaveValue("AWPCA_Спринт 2");
|
||||
@ -49,7 +49,7 @@ describe("карточка окном", () => {
|
||||
expect(new URLSearchParams(router.state.location.search).get("sprint")).toBe("AWPCA_Спринт 2");
|
||||
expect(within(screen.getByRole("main")).getByRole("table")).toBe(table); // таблица команд карточки — в окне, не в main
|
||||
await act(async () => { await router.navigate(-1); });
|
||||
expect(router.state.location.pathname).toBe("/app/jira/board");
|
||||
expect(router.state.location.pathname).toBe("/app/jira/links");
|
||||
});
|
||||
|
||||
it("переход по связи меняет окно, «назад» возвращает прежнюю карточку, Esc закрывает всё", async () => {
|
||||
@ -89,22 +89,22 @@ describe("карточка окном", () => {
|
||||
expect(router.state.location.pathname).toBe("/app/jira/issues/AURORA-9001");
|
||||
});
|
||||
|
||||
it("с доски: окно поверх доски, раздел «Доска связей» активен, после закрытия доска та же", async () => {
|
||||
const router = at(["/app/jira/board"]);
|
||||
it("с доски: окно поверх доски, раздел «Связи» активен, после закрытия доска та же", async () => {
|
||||
const router = at(["/app/jira/links"]);
|
||||
render(<App router={router} />);
|
||||
const ilink = await screen.findByRole("link", { name: "Открыть карточку AURORA-9001" });
|
||||
const boardEl = screen.getByTestId("page-board");
|
||||
await userEvent.click(ilink);
|
||||
expect(await within(dialog()).findByRole("heading", { name: /AURORA-9001/ })).toBeInTheDocument();
|
||||
expect(screen.getByTestId("page-board")).toBe(boardEl);
|
||||
expect(screen.getByRole("link", { name: "Доска связей" })).toHaveAttribute("aria-current", "page");
|
||||
expect(screen.getByRole("link", { name: "Связи" })).toHaveAttribute("aria-current", "page");
|
||||
await userEvent.click(within(dialog()).getByRole("button", { name: "Закрыть" }));
|
||||
await waitFor(() => expect(router.state.location.pathname).toBe("/app/jira/board"));
|
||||
await waitFor(() => expect(router.state.location.pathname).toBe("/app/jira/links"));
|
||||
expect(screen.getByTestId("page-board")).toBe(boardEl);
|
||||
});
|
||||
|
||||
it("прямой адрес: под окном список задач, ✕ ведёт в список заменой записи", async () => {
|
||||
const router = at(["/app/jira/board", "/app/jira/issues/AURORA-9001"]);
|
||||
const router = at(["/app/jira/links", "/app/jira/issues/AURORA-9001"]);
|
||||
render(<App router={router} />);
|
||||
expect(await within(await screen.findByRole("dialog")).findByRole("heading", { name: /AURORA-9001/ })).toBeInTheDocument();
|
||||
expect(await screen.findByTestId("page-issues")).toBeInTheDocument();
|
||||
@ -112,7 +112,7 @@ describe("карточка окном", () => {
|
||||
await userEvent.click(within(dialog()).getByRole("button", { name: "Закрыть" }));
|
||||
await waitFor(() => expect(router.state.location.pathname).toBe("/app/jira/issues"));
|
||||
await act(async () => { await router.navigate(-1); });
|
||||
expect(router.state.location.pathname).toBe("/app/jira/board"); // не карточка
|
||||
expect(router.state.location.pathname).toBe("/app/jira/links"); // не карточка
|
||||
});
|
||||
|
||||
it("прямой адрес и переход по связи: закрытие возвращает в список, а не на первую карточку", async () => {
|
||||
|
||||
@ -15,7 +15,7 @@ describe("состояние окна в истории", () => {
|
||||
const s = { background: LIST_BACKGROUND, depth: 0, direct: true };
|
||||
expect(openCardState({ pathname: "/jira/issues/A-1", search: "", state: s })).toEqual({ background: LIST_BACKGROUND, depth: 1, direct: true });
|
||||
expect(openCardState({ pathname: "/jira/issues/A-2", search: "", state: BOARD_CARD_STATE }))
|
||||
.toEqual({ background: { pathname: "/jira/board", search: "" }, depth: 2, direct: false });
|
||||
.toEqual({ background: { pathname: "/jira/links", search: "" }, depth: 2, direct: false });
|
||||
});
|
||||
it("чужое или битое state — не состояние окна", () => {
|
||||
expect(cardState(null)).toBeNull();
|
||||
|
||||
@ -9,8 +9,8 @@ export function listBackground(cardSearch: string): Background {
|
||||
const project = new URLSearchParams(cardSearch).get("project");
|
||||
return { pathname: ISSUES_PATH, search: project ? "?" + new URLSearchParams({ project }).toString() : "" };
|
||||
}
|
||||
// доска не хранит состояние в адресе: «ⓘ» всегда открывает окно поверх /jira/board
|
||||
export const BOARD_CARD_STATE: CardState = { background: { pathname: "/jira/board", search: "" }, depth: 1, direct: false };
|
||||
// доска не хранит состояние в адресе: «ⓘ» всегда открывает окно поверх /jira/links
|
||||
export const BOARD_CARD_STATE: CardState = { background: { pathname: "/jira/links", search: "" }, depth: 1, direct: false };
|
||||
|
||||
export function cardState(s: unknown): CardState | null {
|
||||
const c = s as { background?: { pathname?: unknown; search?: unknown }; depth?: unknown; direct?: unknown } | null;
|
||||
|
||||
@ -6,12 +6,12 @@ import { MODULES } from "./index.ts";
|
||||
|
||||
describe("сборка оболочки из модулей", () => {
|
||||
it("страницы: «Обзор», адреса jira, перенаправления, вики, NotFound последним", () => {
|
||||
expect(PAGE_ROUTES.map((r) => r.path)).toEqual(["/", "/jira/board", "/jira/issues", "/jira/feed", "/board", "/issues", "/issues/:key", "/feed", "/wiki", "*"]);
|
||||
expect(PAGE_ROUTES.map((r) => r.path)).toEqual(["/", "/jira/links", "/jira/issues", "/jira/feed", "/jira/board", "/board", "/issues", "/issues/:key", "/feed", "/wiki", "*"]);
|
||||
});
|
||||
|
||||
it("пункты панели: подписи прежние, адреса jira с префиксом, плюс вики", () => {
|
||||
expect(MODULES.flatMap((m) => m.nav).map((n) => [n.label, n.to])).toEqual([
|
||||
["Доска связей", "/jira/board"], ["Задачи", "/jira/issues"], ["Лента", "/jira/feed"], ["Вики", "/wiki"],
|
||||
["Связи", "/jira/links"], ["Задачи", "/jira/issues"], ["Лента", "/jira/feed"], ["Вики", "/wiki"],
|
||||
]);
|
||||
});
|
||||
|
||||
|
||||
@ -7,6 +7,7 @@ export type NavItem = {
|
||||
to: string; label: string; icon: ReactNode;
|
||||
title?: string; // название страницы для заголовка вкладки; по умолчанию label
|
||||
padded?: boolean; // страница с полями (не во всю ширину): оболочка не ставит ей класс page--board; для адресов вне панели класс ставится
|
||||
when?: (p: Project) => boolean; // пункт только для проектов, где условие верно; пока проект не определён (проекты не загружены) — скрыт
|
||||
keepSearch?: boolean; // при смене проекта строка запроса страницы сохраняется (параметры не проектные); иначе сбрасывается
|
||||
};
|
||||
// окно поверх страницы (карточка задачи): pageLocation — адрес страницы под окном, null — окна нет
|
||||
|
||||
@ -47,7 +47,7 @@ describe("проект в приложении", () => {
|
||||
|
||||
it("первый вход после выкатки: проект подставляется в адрес и запоминается", async () => {
|
||||
const h = hub();
|
||||
const router = open("/app/jira/board");
|
||||
const router = open("/app/jira/links");
|
||||
await waitFor(() => expect(router.state.location.search).toBe("?project=AWPCA"));
|
||||
expect(router.state.historyAction).toBe("REPLACE");
|
||||
await waitFor(() => expect(h.get("/board").length).toBeGreaterThan(0));
|
||||
@ -57,7 +57,7 @@ describe("проект в приложении", () => {
|
||||
|
||||
it("переключение проекта: доска, ссылка «Задачи», список и варианты фильтров", async () => {
|
||||
const h = hub();
|
||||
const router = open("/app/jira/board?project=AWPCA");
|
||||
const router = open("/app/jira/links?project=AWPCA");
|
||||
await screen.findByTestId("page-board");
|
||||
await userEvent.selectOptions(screen.getByRole("combobox", { name: "Проект" }), "STOCKS_DRRK");
|
||||
expect(router.state.historyAction).toBe("PUSH");
|
||||
@ -90,7 +90,7 @@ describe("проект в приложении", () => {
|
||||
|
||||
it("проектов нет: приглашение создать, без GET /board; щелчок открывает окно", async () => {
|
||||
const h = hub([]);
|
||||
open("/app/jira/board");
|
||||
open("/app/jira/links");
|
||||
await userEvent.click(await screen.findByRole("button", { name: "+ Новый проект…" }));
|
||||
expect(screen.getByRole("dialog", { name: "Новый проект" })).toHaveAttribute("open");
|
||||
expect(h.get("/board")).toHaveLength(0);
|
||||
@ -103,7 +103,7 @@ describe("проект в приложении", () => {
|
||||
"GET /projects": () => json(200, { projects: created ? [A, S] : [A] }), // блок состояния берёт проект из списка
|
||||
"GET /batches/7": () => json(200, { id: 7, author: "viewer", status: "queued", remaining_seconds: 5, counts: {}, commands: [{ type: "refresh", status: "queued" }] }),
|
||||
});
|
||||
const router = open("/app/jira/board?project=AWPCA");
|
||||
const router = open("/app/jira/links?project=AWPCA");
|
||||
await screen.findByTestId("page-board");
|
||||
const listed = h.get("/batches").length;
|
||||
await userEvent.selectOptions(screen.getByRole("combobox", { name: "Проект" }), "+ Новый проект…");
|
||||
@ -122,7 +122,7 @@ describe("проект в приложении", () => {
|
||||
|
||||
it("с номером доски board_id уходит числом", async () => {
|
||||
const h = hub([A], { "POST /projects": () => json(201, { project: S, batch_id: 7, batches: [{ module: "jira", id: 7 }] }) });
|
||||
open("/app/jira/board?project=AWPCA");
|
||||
open("/app/jira/links?project=AWPCA");
|
||||
await screen.findByTestId("page-board");
|
||||
await userEvent.selectOptions(screen.getByRole("combobox", { name: "Проект" }), "+ Новый проект…");
|
||||
const dlg = screen.getByRole("dialog", { name: "Новый проект" });
|
||||
@ -137,7 +137,7 @@ describe("проект в приложении", () => {
|
||||
|
||||
it("метка занята: окно открыто, текст ошибки хаба рядом с «Создать»", async () => {
|
||||
hub([A], { "POST /projects": () => json(409, { error: "project_exists", detail: "label: проект с такой меткой уже есть: AWPCA" }) });
|
||||
open("/app/jira/board?project=AWPCA");
|
||||
open("/app/jira/links?project=AWPCA");
|
||||
await screen.findByTestId("page-board");
|
||||
await userEvent.selectOptions(screen.getByRole("combobox", { name: "Проект" }), "+ Новый проект…");
|
||||
const dlg = screen.getByRole("dialog", { name: "Новый проект" });
|
||||
@ -155,7 +155,7 @@ describe("проект в приложении", () => {
|
||||
"POST /projects": () => json(201, { project: S, batch_id: 7, batches: [{ module: "jira", id: 7 }] }),
|
||||
"GET /batches/7": () => json(200, { id: 7, author: "viewer", status: "queued", remaining_seconds: 5, counts: {}, commands: [{ type: "refresh", status: "queued" }] }),
|
||||
});
|
||||
const router = open("/app/jira/board?project=AWPCA");
|
||||
const router = open("/app/jira/links?project=AWPCA");
|
||||
await screen.findByTestId("page-board");
|
||||
await userEvent.selectOptions(screen.getByRole("combobox", { name: "Проект" }), "+ Новый проект…");
|
||||
const dlg = screen.getByRole("dialog", { name: "Новый проект" });
|
||||
@ -172,7 +172,7 @@ describe("проект в приложении", () => {
|
||||
let release!: () => void;
|
||||
const gate = new Promise<void>((r) => { release = r; });
|
||||
hub([A], { "POST /projects": async () => { await gate; return json(201, { project: S, batch_id: 7, batches: [{ module: "jira", id: 7 }] }); } });
|
||||
open("/app/jira/board?project=AWPCA");
|
||||
open("/app/jira/links?project=AWPCA");
|
||||
await screen.findByTestId("page-board");
|
||||
await userEvent.selectOptions(screen.getByRole("combobox", { name: "Проект" }), "+ Новый проект…");
|
||||
const dlg = screen.getByRole("dialog", { name: "Новый проект" });
|
||||
@ -226,11 +226,18 @@ describe("адреса модулей и заголовок вкладки", ()
|
||||
it("прежние /board, /issues, /feed перенаправляют с сохранением search, replace", async () => {
|
||||
hub();
|
||||
const router = open("/app/board?project=STOCKS_DRRK&x=1");
|
||||
await waitFor(() => expect(router.state.location.pathname).toBe("/app/jira/board"));
|
||||
await waitFor(() => expect(router.state.location.pathname).toBe("/app/jira/links"));
|
||||
expect(router.state.location.search).toBe("?project=STOCKS_DRRK&x=1");
|
||||
expect(router.state.historyAction).toBe("REPLACE");
|
||||
});
|
||||
|
||||
it("прежний /jira/board перенаправляет на /jira/links с search, replace", async () => {
|
||||
hub();
|
||||
const router = open("/app/jira/board?project=AWPCA");
|
||||
await waitFor(() => expect(router.state.location.pathname + router.state.location.search).toBe("/app/jira/links?project=AWPCA"));
|
||||
expect(router.state.historyAction).toBe("REPLACE");
|
||||
});
|
||||
|
||||
it("смена проекта на «Вики» у проекта без вики остаётся на «Вики»", async () => {
|
||||
hub([W, S], { "GET /wiki/status": () => json(409, { error: "no_root", detail: "нет корня" }) });
|
||||
const router = open("/app/wiki?project=AWPCA");
|
||||
|
||||
@ -7,7 +7,7 @@ export function NotFound() {
|
||||
return (
|
||||
<div className="crash">
|
||||
<Notice tone="warning" title={<h2>Страница не найдена</h2>}>
|
||||
Разделы приложения: {NAV.map((n, i) => <Fragment key={`${n.to}#${i}`}>{i > 0 && ", "}<Link to={n.to}>{n.label}</Link></Fragment>)}.
|
||||
Разделы приложения: {NAV.filter((n) => !n.when).map((n, i) => <Fragment key={`${n.to}#${i}`}>{i > 0 && ", "}<Link to={n.to}>{n.label}</Link></Fragment>)}.
|
||||
</Notice>
|
||||
</div>
|
||||
);
|
||||
|
||||
@ -73,7 +73,7 @@ describe("страница «Обзор»", () => {
|
||||
open();
|
||||
const jira = within(await card("Jira"));
|
||||
expect(await jira.findByText((_, el) => el?.classList.contains("jira-summary__counts") === true && el.textContent === "4 задач · 3 открытых · 1 просрочено")).toBeInTheDocument();
|
||||
expect(jira.getByRole("link", { name: "Доска связей" })).toHaveAttribute("href", "/app/jira/board?project=AWPCA");
|
||||
expect(jira.getByRole("link", { name: "Связи" })).toHaveAttribute("href", "/app/jira/links?project=AWPCA");
|
||||
expect(jira.getByRole("link", { name: "Задачи" })).toHaveAttribute("href", "/app/jira/issues?project=AWPCA");
|
||||
expect(jira.getByRole("link", { name: "Лента" })).toHaveAttribute("href", "/app/jira/feed?project=AWPCA");
|
||||
});
|
||||
|
||||
@ -43,13 +43,13 @@ const bar = () => screen.getByRole("complementary", { name: "Панель при
|
||||
describe("боковая панель", () => {
|
||||
beforeEach(() => { setToken("tok"); localStorage.clear(); });
|
||||
|
||||
it("разделы — ссылки «Доска связей» и «Задачи», активная с aria-current", async () => {
|
||||
it("разделы — ссылки «Связи» и «Задачи», активная с aria-current", async () => {
|
||||
hub();
|
||||
open("/app/jira/issues?project=AWPCA");
|
||||
await screen.findByRole("link", { name: "Доска связей" });
|
||||
await waitFor(() => expect(screen.getByRole("link", { name: "Доска связей" })).toHaveAttribute("href", "/app/jira/board?project=AWPCA"));
|
||||
await screen.findByRole("link", { name: "Связи" });
|
||||
await waitFor(() => expect(screen.getByRole("link", { name: "Связи" })).toHaveAttribute("href", "/app/jira/links?project=AWPCA"));
|
||||
expect(screen.getByRole("link", { name: "Задачи" })).toHaveAttribute("aria-current", "page");
|
||||
expect(screen.getByRole("link", { name: "Доска связей" })).not.toHaveAttribute("aria-current");
|
||||
expect(screen.getByRole("link", { name: "Связи" })).not.toHaveAttribute("aria-current");
|
||||
});
|
||||
|
||||
it("раздел «Лента» ведёт на /feed с проектом и отмечается как текущий", async () => {
|
||||
@ -67,10 +67,10 @@ describe("боковая панель", () => {
|
||||
open("/app/nowhere?project=AWPCA");
|
||||
expect(await screen.findByRole("heading", { name: "Страница не найдена" })).toBeInTheDocument();
|
||||
const nav = within(bar()).getByRole("navigation", { name: "Разделы" });
|
||||
expect(within(nav).getByRole("link", { name: "Доска связей" })).not.toHaveAttribute("aria-current");
|
||||
expect(within(nav).getByRole("link", { name: "Связи" })).not.toHaveAttribute("aria-current");
|
||||
expect(within(nav).getByRole("link", { name: "Задачи" })).not.toHaveAttribute("aria-current");
|
||||
const main = within(screen.getByRole("main"));
|
||||
expect(main.getByRole("link", { name: "Доска связей" })).toHaveAttribute("href", "/app/jira/board");
|
||||
expect(main.getByRole("link", { name: "Связи" })).toHaveAttribute("href", "/app/jira/links");
|
||||
expect(main.getByRole("link", { name: "Задачи" })).toHaveAttribute("href", "/app/jira/issues");
|
||||
expect(main.getByRole("link", { name: "Лента" })).toHaveAttribute("href", "/app/jira/feed"); // M-2
|
||||
expect(main.getByRole("link", { name: "Обзор" })).toHaveAttribute("href", expect.stringMatching(/^\/app\/?$/));
|
||||
@ -98,13 +98,13 @@ describe("боковая панель", () => {
|
||||
|
||||
it("агент офлайн — «агент офлайн»; на связи — «агент на связи»", async () => {
|
||||
hub({ "GET /agents": () => json(200, { agents: [{ module: "jira", online: false, last_seen_at: "2026-10-14T09:30:00+00:00", warning: null, update_hint: null }] }) });
|
||||
open("/app/jira/board?project=AWPCA");
|
||||
open("/app/jira/links?project=AWPCA");
|
||||
expect(await within(bar()).findByText("агент офлайн")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("агент на связи", async () => {
|
||||
hub();
|
||||
open("/app/jira/board?project=AWPCA");
|
||||
open("/app/jira/links?project=AWPCA");
|
||||
expect(await within(bar()).findByText("агент на связи")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
@ -122,7 +122,7 @@ describe("боковая панель", () => {
|
||||
|
||||
it("блок состояния не грузит GET /wiki/status; у проекта без корня вики строки «Вики» нет", async () => {
|
||||
const h = hub();
|
||||
open("/app/jira/board?project=AWPCA");
|
||||
open("/app/jira/links?project=AWPCA");
|
||||
await within(bar()).findByRole("button", { name: /^Обновить из Jira/ });
|
||||
expect(within(bar()).queryByRole("button", { name: /^Обновить вики/ })).toBeNull();
|
||||
expect(h.count("GET", "/wiki/status")).toBe(0);
|
||||
@ -130,12 +130,12 @@ describe("боковая панель", () => {
|
||||
|
||||
it("группы по модулям: «Обзор» первым, затем заголовки «Jira» и «Вики» со своими разделами", async () => {
|
||||
hub();
|
||||
open("/app/jira/board?project=AWPCA");
|
||||
open("/app/jira/links?project=AWPCA");
|
||||
const nav = within(bar()).getByRole("navigation", { name: "Разделы" });
|
||||
const links = (await within(nav).findAllByRole("link")).map((l) => l.textContent);
|
||||
expect(links).toEqual(["Обзор", "Доска связей", "Задачи", "Лента", "Вики"]);
|
||||
expect(links).toEqual(["Обзор", "Связи", "Задачи", "Лента", "Вики"]);
|
||||
const jira = within(nav).getByRole("group", { name: "Jira" });
|
||||
expect(within(jira).getAllByRole("link").map((l) => l.textContent)).toEqual(["Доска связей", "Задачи", "Лента"]);
|
||||
expect(within(jira).getAllByRole("link").map((l) => l.textContent)).toEqual(["Связи", "Задачи", "Лента"]);
|
||||
expect(within(nav).getByRole("group", { name: "Вики" })).toBeInTheDocument();
|
||||
await waitFor(() => expect(within(nav).getByRole("link", { name: "Обзор" })).toHaveAttribute("href", expect.stringMatching(/^\/app\/?\?project=AWPCA$/)));
|
||||
});
|
||||
@ -143,7 +143,7 @@ describe("боковая панель", () => {
|
||||
it("свёрнутая: метка проекта целиком в title (M-4), у групп скрытые названия", async () => {
|
||||
localStorage.setItem("sdlc-hub-sidebar", "collapsed");
|
||||
hub({ "GET /projects": () => json(200, { projects: [{ ...A, label: "STOCKS_DRRK_LONG" }] }) });
|
||||
open("/app/jira/board?project=STOCKS_DRRK_LONG");
|
||||
open("/app/jira/links?project=STOCKS_DRRK_LONG");
|
||||
const tag = await within(bar()).findByRole("button", { name: "Проект STOCKS_DRRK_LONG — развернуть панель" });
|
||||
expect(tag).toHaveAttribute("title", "Проект STOCKS_DRRK_LONG");
|
||||
expect(within(bar()).getByRole("group", { name: "Jira" })).toBeInTheDocument();
|
||||
@ -151,7 +151,7 @@ describe("боковая панель", () => {
|
||||
|
||||
it("меню «Ещё» → «Сменить токен» показывает экран входа", async () => {
|
||||
hub();
|
||||
open("/app/jira/board?project=AWPCA");
|
||||
open("/app/jira/links?project=AWPCA");
|
||||
await userEvent.click(await screen.findByRole("button", { name: "Ещё" }));
|
||||
await userEvent.click(screen.getByRole("button", { name: "Сменить токен" }));
|
||||
expect(getAuth().token).toBe("");
|
||||
@ -160,13 +160,13 @@ describe("боковая панель", () => {
|
||||
|
||||
it("переключатель темы — в панели", async () => {
|
||||
hub();
|
||||
open("/app/jira/board?project=AWPCA");
|
||||
open("/app/jira/links?project=AWPCA");
|
||||
expect(await within(bar()).findByRole("switch", { name: "Тёмная тема" })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("кнопка «Обновить из Jira» в шапке доски — вторичная, рядом со значком панели «— все проекты»", async () => {
|
||||
hub();
|
||||
open("/app/jira/board?project=AWPCA");
|
||||
open("/app/jira/links?project=AWPCA");
|
||||
await screen.findByTestId("page-board");
|
||||
await screen.findByText(/задач из/);
|
||||
expect(screen.getAllByRole("button", { name: "Обновить из Jira" })).toHaveLength(1);
|
||||
@ -175,21 +175,21 @@ describe("боковая панель", () => {
|
||||
|
||||
it("разделы — в навигации «Разделы» боковой панели", async () => {
|
||||
hub();
|
||||
open("/app/jira/board?project=AWPCA");
|
||||
open("/app/jira/links?project=AWPCA");
|
||||
const nav = within(bar()).getByRole("navigation", { name: "Разделы" });
|
||||
expect(await within(nav).findByRole("link", { name: "Доска связей" })).toHaveAttribute("aria-current", "page");
|
||||
expect(await within(nav).findByRole("link", { name: "Связи" })).toHaveAttribute("aria-current", "page");
|
||||
});
|
||||
|
||||
it("«Свернуть панель» сворачивает и запоминает в sdlc-hub-sidebar; после перезапуска свёрнута, разделы доступны", async () => {
|
||||
hub();
|
||||
open("/app/jira/board?project=AWPCA");
|
||||
open("/app/jira/links?project=AWPCA");
|
||||
const btn = await within(bar()).findByRole("button", { name: "Свернуть панель" });
|
||||
expect(btn).toHaveAttribute("aria-expanded", "true");
|
||||
await userEvent.click(btn);
|
||||
expect(localStorage.getItem("sdlc-hub-sidebar")).toBe("collapsed");
|
||||
expect(bar()).toHaveClass("is-collapsed");
|
||||
cleanup();
|
||||
open("/app/jira/board?project=AWPCA");
|
||||
open("/app/jira/links?project=AWPCA");
|
||||
expect(bar()).toHaveClass("is-collapsed");
|
||||
expect(within(bar()).getByRole("button", { name: "Развернуть панель" })).toHaveAttribute("aria-expanded", "false");
|
||||
expect(within(bar()).getByRole("link", { name: "Задачи" })).toHaveAttribute("title", "Задачи");
|
||||
@ -213,7 +213,7 @@ describe("боковая панель", () => {
|
||||
it("свёрнутая: метка проекта видна, щелчок по ней разворачивает панель", async () => {
|
||||
localStorage.setItem("sdlc-hub-sidebar", "collapsed");
|
||||
hub();
|
||||
open("/app/jira/board?project=AWPCA");
|
||||
open("/app/jira/links?project=AWPCA");
|
||||
const tag = await within(bar()).findByRole("button", { name: "Проект AWPCA — развернуть панель" });
|
||||
await userEvent.click(tag);
|
||||
expect(bar()).not.toHaveClass("is-collapsed");
|
||||
@ -229,7 +229,7 @@ describe("боковая панель на узком экране", () => {
|
||||
|
||||
it("сверху строка с «Меню», меткой проекта и сводной точкой; кнопки обновления и панели нет", async () => {
|
||||
hub();
|
||||
open("/app/jira/board?project=AWPCA");
|
||||
open("/app/jira/links?project=AWPCA");
|
||||
const top = await screen.findByRole("banner", { name: "Строка приложения" });
|
||||
expect(within(top).getByRole("button", { name: "Меню" })).toHaveAttribute("aria-expanded", "false");
|
||||
expect(await within(top).findByText("AWPCA")).toBeInTheDocument();
|
||||
@ -240,7 +240,7 @@ describe("боковая панель на узком экране", () => {
|
||||
|
||||
it("«Меню» открывает панель с фокусом на первом разделе; Escape закрывает и возвращает фокус", async () => {
|
||||
hub();
|
||||
open("/app/jira/board?project=AWPCA");
|
||||
open("/app/jira/links?project=AWPCA");
|
||||
const menu = await screen.findByRole("button", { name: "Меню" });
|
||||
await userEvent.click(menu);
|
||||
expect(menu).toHaveAttribute("aria-expanded", "true");
|
||||
@ -254,7 +254,7 @@ describe("боковая панель на узком экране", () => {
|
||||
|
||||
it("выбор раздела закрывает панель и открывает раздел", async () => {
|
||||
hub();
|
||||
const router = open("/app/jira/board?project=AWPCA");
|
||||
const router = open("/app/jira/links?project=AWPCA");
|
||||
await userEvent.click(await screen.findByRole("button", { name: "Меню" }));
|
||||
await userEvent.click(within(bar()).getByRole("link", { name: "Задачи" }));
|
||||
expect(router.state.location.pathname).toBe("/app/jira/issues");
|
||||
@ -263,7 +263,7 @@ describe("боковая панель на узком экране", () => {
|
||||
|
||||
it("щелчок по подложке и «Закрыть меню» закрывают панель", async () => {
|
||||
hub();
|
||||
open("/app/jira/board?project=AWPCA");
|
||||
open("/app/jira/links?project=AWPCA");
|
||||
await userEvent.click(await screen.findByRole("button", { name: "Меню" }));
|
||||
await userEvent.click(screen.getByTestId("sidebar-backdrop"));
|
||||
expect(screen.queryByRole("complementary", { name: "Панель приложения" })).toBeNull();
|
||||
@ -274,7 +274,7 @@ describe("боковая панель на узком экране", () => {
|
||||
|
||||
it("I-1: событие пакета видно строкой под верхней строкой в постоянном живом регионе", async () => {
|
||||
hub();
|
||||
open("/app/jira/board?project=AWPCA");
|
||||
open("/app/jira/links?project=AWPCA");
|
||||
await screen.findByRole("banner", { name: "Строка приложения" });
|
||||
const strip = screen.getByTestId("topbar-events");
|
||||
expect(strip).toHaveAttribute("role", "status"); // T10.1: контейнер на месте до появления текста
|
||||
@ -287,7 +287,7 @@ describe("боковая панель на узком экране", () => {
|
||||
|
||||
it("drawer is modal: фокус в панели, страница инертна; Escape во вложенном меню закрывает только меню (M-1)", async () => {
|
||||
hub();
|
||||
open("/app/jira/board?project=AWPCA");
|
||||
open("/app/jira/links?project=AWPCA");
|
||||
const menu = await screen.findByRole("button", { name: "Меню" });
|
||||
await userEvent.click(menu);
|
||||
expect(within(bar()).getByRole("link", { name: "Обзор" })).toHaveFocus();
|
||||
@ -306,7 +306,7 @@ describe("боковая панель на узком экране", () => {
|
||||
|
||||
it("#73 M-2: панель — модальное окно «Меню» (role=dialog, aria-modal), прокрутка страницы под ней заблокирована до закрытия", async () => {
|
||||
hub();
|
||||
open("/app/jira/board?project=AWPCA");
|
||||
open("/app/jira/links?project=AWPCA");
|
||||
await userEvent.click(await screen.findByRole("button", { name: "Меню" }));
|
||||
const dlg = screen.getByRole("dialog", { name: "Меню" });
|
||||
expect(dlg).toHaveAttribute("aria-modal", "true");
|
||||
|
||||
@ -6,7 +6,8 @@ import type { Ref } from "react";
|
||||
import { clearToken } from "../auth/token.ts";
|
||||
import { useActivitySummary } from "./activity/activity.ts";
|
||||
import { ModuleEventStrip, ModuleStatusBlock } from "./ModuleStatusBlock.tsx";
|
||||
import { GROUPS, OVERVIEW } from "./nav.tsx";
|
||||
import { GROUPS, OVERVIEW, visibleNav } from "./nav.tsx";
|
||||
import { useProjectContext } from "../projects/ProjectContext.ts";
|
||||
import { withProject } from "../projects/current.ts";
|
||||
import { ProjectPicker } from "../projects/ProjectPicker.tsx";
|
||||
import { IconButton, IconClose, IconMenu, IconSidebar, Menu, MenuItem, ThemeSwitch } from "../ui/index.ts";
|
||||
@ -75,6 +76,8 @@ function NarrowShell(p: Props) {
|
||||
// модулей, тема, меню «Ещё»; прокручивается середина, низ с меню «Ещё» остаётся на месте (M-5 #73)
|
||||
function Panel({ section, project, drawer }: Props & { drawer?: { close(): void; navigated(): void } }) {
|
||||
const qc = useQueryClient();
|
||||
const ctx = useProjectContext().state;
|
||||
const current = ctx.kind === "ok" ? ctx.project : null;
|
||||
const [stored, setCollapsed] = useState(readSidebar);
|
||||
const collapsed = !drawer && stored;
|
||||
const fold = (c: boolean) => { setCollapsed(c); saveSidebar(c); };
|
||||
@ -122,7 +125,7 @@ function Panel({ section, project, drawer }: Props & { drawer?: { close(): void;
|
||||
{GROUPS.map((g) => (
|
||||
<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))}
|
||||
{visibleNav(g.nav, current).map((n) => link(n))}
|
||||
</div>
|
||||
))}
|
||||
</nav>
|
||||
|
||||
16
web/src/shell/nav.test.ts
Normal file
16
web/src/shell/nav.test.ts
Normal file
@ -0,0 +1,16 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import type { Project } from "../api/types.ts";
|
||||
import { visibleNav } from "./nav.tsx";
|
||||
|
||||
const A = { label: "AWPCA", name: "", created_at: "", board_type: "scrum" } as unknown as Project;
|
||||
const items = [
|
||||
{ to: "/a", label: "A", icon: null },
|
||||
{ to: "/b", label: "B", icon: null, when: (p: Project) => p.board_type === "scrum" },
|
||||
];
|
||||
describe("visibleNav", () => {
|
||||
it("условный пункт скрыт без проекта и при ложном условии", () => {
|
||||
expect(visibleNav(items, null).map((n) => n.to)).toEqual(["/a"]);
|
||||
expect(visibleNav(items, { ...A, board_type: null }).map((n) => n.to)).toEqual(["/a"]);
|
||||
expect(visibleNav(items, A).map((n) => n.to)).toEqual(["/a", "/b"]);
|
||||
});
|
||||
});
|
||||
@ -1,4 +1,5 @@
|
||||
import { MODULES } from "../modules/index.ts";
|
||||
import type { Project } from "../api/types.ts";
|
||||
import type { NavItem } from "../modules/types.ts";
|
||||
import { IconHome } from "../ui/index.ts";
|
||||
|
||||
@ -6,3 +7,7 @@ import { IconHome } from "../ui/index.ts";
|
||||
export const OVERVIEW: NavItem = { to: "/", label: "Обзор", icon: <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)];
|
||||
|
||||
// пункты, видимые для проекта: безусловные — всегда, условные (`when`) — когда проект известен и условие верно
|
||||
export const visibleNav = (items: NavItem[], project: Project | null): NavItem[] =>
|
||||
items.filter((n) => !n.when || (project !== null && n.when(project)));
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user