fix(web-modules): страницы модулей — шапки, «Вики» с корнем, просрочка, прокрутка «Задач»
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
parent
8eecc567dc
commit
fbfbd555e6
@ -146,3 +146,8 @@ type HubModule = {
|
||||
| 42 | 24.10.2026 | Task 5: поле поиска вики на «Обзоре» не ведёт на пустой запрос (Enter при пустой строке ничего не делает); `q` в «Вики» — только начальное значение поля, выбор страницы убирает `q` из адреса | пустой поиск = страница «Вики» без `q`, для неё есть ссылка «Вики»; `hrefOf` страницы по контракту Task 4 не несёт поиск | пользователь, вставший на `?q=` и открывший страницу, теряет в адресе строку поиска (в поле она остаётся) |
|
||||
| 43 | 24.10.2026 | Task 6: номер доски проверяется в окне сам (`/^\d+$/`, ≥ 1, текст «нужно целое число»), поле — `type="text"` с `inputMode="numeric"`, форма `noValidate` | `type="number"` даёт браузерную подсказку вместо нашего текста и пропускает `51.5`/`1e3` по-разному в браузерах; T4.1 | поле без стрелок счётчика; невалидное значение ловится только по «Создать» |
|
||||
| 44 | 24.10.2026 | Task 6: в ядре `useTrackModuleBatch()` (activity.ts) — отслеживание пакета по имени модуля; окно создания вынесено в `shell/NewProject.tsx`; `useTrackBatch` остаётся для пакетов без известного модуля | пакеты нового проекта привязываются каждому своему модулю по `batches[].module` (M-14, T5.3 #68); Layout не обязан знать модули | пакет неизвестного модуля молча не отслеживается |
|
||||
| 45 | 24.10.2026 | Task 7: узкий экран «Вики» (страница выше дерева) — через `matchMedia("(max-width: 759.98px)")` и порядок в разметке (`WikiPage`), а не CSS `order`; дерево при поиске скрыто (`hidden`), а не размонтировано | порядок Tab совпадает с видимым; раскрытые вручную узлы переживают поиск (T10.2, T10.6); порог окна — приближение порога контейнера страницы | CSS `order` и размонтирование дерева |
|
||||
| 46 | 24.10.2026 | Task 7: заголовки текста страницы вики сдвинуты ниже заголовка страницы: `#`/`##` → h3, `###` → h4 … не глубже h6 | порядок h1 → h2 → h3 без скачков и без второго h1 (T10.3) | оставить h1–h4 как есть |
|
||||
| 47 | 24.10.2026 | Task 7: `.issue-table-wrap{overflow-x:auto}` на любой ширине (раньше — только `@container page < 1052px`) | на 1440 px девять столбцов шире панели; цена — липкая шапка таблицы липнет к панели, а не к окну | вернуть условие и сжать столбцы |
|
||||
| 48 | 24.10.2026 | Task 7: проверка T5.2 «ядро не грузит /board и /wiki/status» идёт на странице вне модулей (`/app/nowhere`), а не на `/wiki?project=…` | страница «Вики» сама запрашивает `/wiki/status` (это модуль, не ядро) | проверять на странице вики с исключением статуса |
|
||||
| 49 | 24.10.2026 | Task 7: `scrollbar-gutter: stable` — на `html` (прокручивается окно, отдельной области страницы нет); «Обновить из Jira» в шапках — общий `modules/jira/RefreshButton.tsx` | страницы разной высоты не двигаются по ширине; одна реализация кнопки на три страницы | прокручиваемый контейнер страницы; кнопка в каждой странице |
|
||||
|
||||
9
web/src/modules/jira/RefreshButton.tsx
Normal file
9
web/src/modules/jira/RefreshButton.tsx
Normal file
@ -0,0 +1,9 @@
|
||||
import { Button, IconRefresh } from "../../ui/index.ts";
|
||||
import { useModuleActivity } from "../../shell/activity/activity.ts";
|
||||
import { jiraStatus } from "./status.ts";
|
||||
|
||||
// «Обновить из Jira» в шапке страницы модуля: вторичная кнопка; ход пакета показывает блок состояния боковой панели
|
||||
export function RefreshButton() {
|
||||
const act = useModuleActivity("jira");
|
||||
return <Button variant="secondary" icon={<IconRefresh />} loading={act.busy} onClick={() => void act.start()}>{jiraStatus.refresh!.label}</Button>;
|
||||
}
|
||||
@ -36,6 +36,15 @@ const NO_SPRINT_TEXT = "колонки «Спринт» нет — показа
|
||||
describe("страница доски", () => {
|
||||
beforeEach(() => setToken("tok"));
|
||||
|
||||
it("шапка: вторичная кнопка «Обновить из Jira», primary нет; пустого блока .board-src под шапкой нет", async () => {
|
||||
hub();
|
||||
const { container } = page();
|
||||
await screen.findByText(/задач из/);
|
||||
expect(screen.getByRole("button", { name: "Обновить из Jira" })).toHaveClass("ui-btn--secondary");
|
||||
expect(container.querySelector(".ui-btn--primary")).toBeNull();
|
||||
expect(container.querySelector(".board-src")).toBeNull();
|
||||
});
|
||||
|
||||
it("строит доску из хаба: источник, статистика, выбор самого наполненного спринта", async () => {
|
||||
hub();
|
||||
page();
|
||||
@ -156,11 +165,11 @@ describe("страница доски", () => {
|
||||
expect(screen.getByText("скрыто: 1")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("доска не рисует «Обновить из Jira» и ход пакета: они в верхней панели", async () => {
|
||||
it("кнопка «Обновить из Jira» в шапке есть, хода пакета и «Тема» на доске нет", async () => {
|
||||
hub();
|
||||
page();
|
||||
await screen.findByText(/задач из 9/);
|
||||
expect(screen.queryByRole("button", { name: "Обновить из Jira" })).not.toBeInTheDocument();
|
||||
expect(screen.getByRole("button", { name: "Обновить из Jira" })).toBeInTheDocument();
|
||||
await userEvent.click(screen.getByRole("button", { name: "Ещё" }));
|
||||
expect(screen.getByRole("button", { name: "Ещё" })).toHaveAttribute("aria-expanded", "true");
|
||||
expect(screen.getByRole("button", { name: "Перечитать" })).toBeInTheDocument(); // меню действительно открыто
|
||||
|
||||
@ -70,7 +70,7 @@ export function BoardPage({ project }: { project: string }) {
|
||||
return (
|
||||
<div data-testid="page-board">
|
||||
<div className="sticky" ref={headRef}>
|
||||
<BoardHeader label={src.label} title={src.title} source={src.source} fileName={src.model.fileName} status={src.status} byDate={prefs.filters.byDate}
|
||||
<BoardHeader source={src.source} fileName={src.model.fileName} status={src.status} byDate={prefs.filters.byDate}
|
||||
snap={prefs.snap} model={src.model} vis={vis} problemCount={problemCount(vis.problems)} problemsOpen={prefs.problems}
|
||||
tableOpen={prefs.table} onReload={src.reload} onFile={onFile}
|
||||
onProblems={() => prefs.toggle("problems")} onExport={onExport} onTable={() => prefs.toggle("table")}
|
||||
|
||||
@ -4,8 +4,6 @@
|
||||
.stats{font-variant-numeric:tabular-nums}
|
||||
.stats b{color:var(--color-text-primary);font-weight:600}
|
||||
.board-src{display:flex;align-items:center;flex-wrap:wrap;gap:var(--space-2) var(--space-3);min-height:var(--lh-body)}
|
||||
.board-src:empty{display:none}
|
||||
.board-src__label{font-size:var(--fs-caption);line-height:var(--lh-caption);color:var(--color-text-secondary);max-width:420px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
|
||||
.board-event{font-size:var(--fs-body);line-height:var(--lh-body);color:var(--color-text-secondary)}
|
||||
.board-event--warn{color:color-mix(in srgb, var(--color-status-warning) 55%, var(--color-text-primary))}
|
||||
.menu__label{display:inline-flex;align-items:center;gap:var(--space-2)}
|
||||
|
||||
@ -2,10 +2,11 @@ import { useEffect, useRef, useState } from "react";
|
||||
import { statusText, type BoardStatus } from "../status.ts";
|
||||
import type { BoardModel, Visible } from "../types.ts";
|
||||
import { NOT_IN_HUB } from "../constants.ts";
|
||||
import { RefreshButton } from "../../RefreshButton.tsx";
|
||||
import { Badge, Button, IconButton, IconRefresh, IconUpload, IconWarning, Menu, MenuItem, MenuSeparator, Notice, PageHeader } from "../../../../ui/index.ts";
|
||||
|
||||
interface BoardHeaderProps {
|
||||
label: string; title: string; source: "hub" | "file"; fileName: string; status: BoardStatus | null; byDate: boolean; snap: boolean;
|
||||
source: "hub" | "file"; fileName: string; status: BoardStatus | null; byDate: boolean; snap: boolean;
|
||||
model: BoardModel; vis: Visible; problemCount: number; problemsOpen: boolean; tableOpen: boolean;
|
||||
onReload(): void; onFile(name: string, text: string): void; onProblems(): void;
|
||||
onExport(): void; onTable(): void; onSnap(): void; onReset(): void;
|
||||
@ -29,6 +30,7 @@ export function BoardHeader(p: BoardHeaderProps) {
|
||||
return (
|
||||
<PageHeader title="Доска связей" subtitle={stats} actions={
|
||||
<>
|
||||
<RefreshButton />
|
||||
<Button variant="secondary" icon={<IconWarning />} aria-pressed={p.problemsOpen} onClick={p.onProblems}
|
||||
title="Сроки блокеров, задачи без исполнителя, передачи между людьми">
|
||||
Проблемы <Badge tone={p.problemCount ? "error" : "neutral"}>{p.problemCount}</Badge>
|
||||
@ -62,15 +64,17 @@ export function BoardHeader(p: BoardHeaderProps) {
|
||||
}} />
|
||||
</>
|
||||
}>
|
||||
<div className="board-src">
|
||||
{p.source === "file" ? (
|
||||
<>
|
||||
<Badge tone="info">из файла: {p.fileName}</Badge>
|
||||
<IconButton label="Вернуться к данным хаба" icon={<IconRefresh size={16} />} onClick={p.onReload} />
|
||||
</>
|
||||
) : null} {/* данные хаба пометки не имеют: состояние хаба показывает блок состояния модулей боковой панели */}
|
||||
{st && <span className={"board-event" + (st.warn ? " board-event--warn" : "")} role="status">{st.text}</span>}
|
||||
</div>
|
||||
{(p.source === "file" || st) && (
|
||||
<div className="board-src">
|
||||
{p.source === "file" && (
|
||||
<>
|
||||
<Badge tone="info">из файла: {p.fileName}</Badge>
|
||||
<IconButton label="Вернуться к данным хаба" icon={<IconRefresh size={16} />} onClick={p.onReload} />
|
||||
</>
|
||||
)} {/* данные хаба пометки не имеют: состояние хаба показывает блок состояния модулей боковой панели */}
|
||||
{st && <span className={"board-event" + (st.warn ? " board-event--warn" : "")} role="status">{st.text}</span>}
|
||||
</div>
|
||||
)}
|
||||
</PageHeader>
|
||||
);
|
||||
}
|
||||
|
||||
@ -55,6 +55,14 @@ describe("лента изменений", () => {
|
||||
beforeEach(() => { setToken("tok"); localStorage.clear(); });
|
||||
afterEach(() => { vi.useRealTimers(); focusManager.setFocused(undefined); });
|
||||
|
||||
it("шапка: вторичная кнопка «Обновить из Jira», primary нет", async () => {
|
||||
hub();
|
||||
open("/app/jira/feed?project=AWPCA");
|
||||
await page();
|
||||
expect(screen.getByRole("button", { name: "Обновить из Jira" })).toHaveClass("ui-btn--secondary");
|
||||
expect(document.querySelector(".ui-btn--primary")).toBeNull();
|
||||
});
|
||||
|
||||
it("C-1/C-2: возврат на вкладку перечитывает ленту с границей «до» на момент запроса", async () => {
|
||||
vi.useFakeTimers({ toFake: ["Date"] });
|
||||
vi.setSystemTime(new Date(2026, 9, 22, 10, 0));
|
||||
|
||||
@ -4,6 +4,7 @@ import { useChanges } from "../api/queries.ts";
|
||||
import type { ChangeKind } from "../api/types.ts";
|
||||
import { IssueLink } from "../issues/IssueLink.tsx";
|
||||
import { Badge, Button, Checkbox, Notice, PageHeader, Panel, Segmented } from "../../../ui/index.ts";
|
||||
import { RefreshButton } from "../RefreshButton.tsx";
|
||||
import { EventList } from "./EventList.tsx";
|
||||
import { groupByIssue, KINDS, kindsParam, parseKinds, parsePeriod, PERIODS, periodRange, type Period } from "./events.ts";
|
||||
import "./feed.css";
|
||||
@ -46,7 +47,7 @@ export function FeedPage({ project }: { project: string }) {
|
||||
|
||||
return (
|
||||
<div data-testid="page-feed" className="feed">
|
||||
<PageHeader title="Лента" subtitle={subtitle} />
|
||||
<PageHeader title="Лента" subtitle={subtitle} actions={<RefreshButton />} />
|
||||
<div className="feed__controls">
|
||||
<Segmented label="Период" value={period} options={PERIODS} onChange={choosePeriod} />
|
||||
<div className="feed__kinds" role="group" aria-label="Виды событий">
|
||||
|
||||
@ -227,13 +227,13 @@ describe("карточка задачи", () => {
|
||||
expect(screen.queryByRole("button", { name: "Скрыть" })).toBeNull();
|
||||
});
|
||||
|
||||
it("прочие поля и команды моста свёрнуты", async () => {
|
||||
it("прочие поля и команды хаба свёрнуты", async () => {
|
||||
mockFetch((_m, url) => url.pathname === "/settings" ? json(200, settings) : json(200, issue("AURORA-9001")));
|
||||
open("/jira/issues/AURORA-9001");
|
||||
const other = await screen.findByRole("region", { name: "Прочие поля" });
|
||||
expect(other).toHaveTextContent("Приоритет");
|
||||
expect(other).toHaveTextContent("Высокий");
|
||||
const details = screen.getByText("Команды моста (1)").closest("details")!;
|
||||
const details = screen.getByText("Команды хаба (1)").closest("details")!;
|
||||
expect(details).not.toHaveAttribute("open");
|
||||
expect(within(details).getByRole("cell", { name: "ensure_fields" })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
@ -238,12 +238,12 @@ function OtherFields({ issue: i }: { issue: IssueDetail }) {
|
||||
|
||||
function Commands({ issue: i }: { issue: IssueDetail }) {
|
||||
if (!i.commands.length) {
|
||||
return <section className="commands"><h3>Команды моста</h3><p className="hint">команд по задаче не было</p></section>;
|
||||
return <section className="commands"><h3>Команды хаба</h3><p className="hint">команд по задаче не было</p></section>;
|
||||
}
|
||||
return (
|
||||
<section className="commands">
|
||||
<details>
|
||||
<summary>Команды моста ({i.commands.length})</summary>
|
||||
<summary>Команды хаба ({i.commands.length})</summary>
|
||||
<div className="cmd-wrap"><table className="grid">
|
||||
<thead><tr><th>Команда</th><th>Пакет</th><th>Тип</th><th>Статус</th><th>Завершена</th><th>Ошибка</th></tr></thead>
|
||||
<tbody>{i.commands.map((c) => (
|
||||
|
||||
@ -13,6 +13,15 @@ function rowClick(e: MouseEvent<HTMLTableRowElement>, key: string, onOpen: (key:
|
||||
onOpen(key);
|
||||
}
|
||||
|
||||
// срок; просроченный — красным и с текстом «просрочено» для экранного чтения и подсказки (цвет не единственный признак)
|
||||
function DueCell({ due, late }: { due: string | null; late: boolean }) {
|
||||
return (
|
||||
<td className={late ? "overdue" : undefined} title={late ? "просрочено" : undefined}>
|
||||
{due ? fmtDate(due) : ""}{late && <span className="visually-hidden">просрочено</span>}
|
||||
</td>
|
||||
);
|
||||
}
|
||||
|
||||
export function IssueTable({ rows, epicNames, closed, onOpen }: {
|
||||
rows: IssueRow[]; epicNames: Record<string, string>; closed: Set<string>; onOpen(key: string): void;
|
||||
}) {
|
||||
@ -30,7 +39,7 @@ export function IssueTable({ rows, epicNames, closed, onOpen }: {
|
||||
<td className="issue-table__sum">{i.summary}</td>
|
||||
<td>{i.status && <Badge tone="info">{i.status}</Badge>}</td>
|
||||
<td>{i.assignee}</td>
|
||||
<td className={isOverdue(due, i.status, closed, today) ? "overdue" : undefined}>{due ? fmtDate(due) : ""}</td>
|
||||
<DueCell due={due} late={isOverdue(due, i.status, closed, today)} />
|
||||
<td>{i.sprint}</td>
|
||||
<td>{i.epic_key ? epicNames[i.epic_key] ?? i.epic_key : ""}</td>
|
||||
<td>{i.work_kind}</td>
|
||||
|
||||
@ -351,4 +351,26 @@ describe("список задач", () => {
|
||||
expect(dueCell(2)).not.toHaveClass("overdue");
|
||||
expect(dueCell(3)).not.toHaveClass("overdue");
|
||||
});
|
||||
it("шапка: вторичная кнопка «Обновить из Jira», primary нет", async () => {
|
||||
hub(() => [row(1)]);
|
||||
renderWithProviders(<IssuesPage project="AWPCA" />, { route: "/issues", path: "/issues" });
|
||||
await screen.findByRole("link", { name: "AURORA-9001" });
|
||||
const btn = screen.getByRole("button", { name: "Обновить из Jira" });
|
||||
expect(btn).toHaveClass("ui-btn--secondary");
|
||||
expect(document.querySelector(".ui-btn--primary")).toBeNull();
|
||||
});
|
||||
|
||||
it("просроченный срок: «просрочено» для экранного чтения и title, срок через fmtDate", async () => {
|
||||
hub(() => [{ ...row(1), planned_end: "2000-01-01", status: "В работе" }]);
|
||||
renderWithProviders(<IssuesPage project="AWPCA" />, { route: "/issues?closed=1", path: "/issues" });
|
||||
await screen.findByRole("link", { name: "AURORA-9001" });
|
||||
const cell = await waitFor(() => {
|
||||
const c = within(screen.getByRole("row", { name: /AURORA-9001/ })).getByText("просрочено").closest("td")!;
|
||||
expect(c).toHaveClass("overdue");
|
||||
return c;
|
||||
});
|
||||
expect(within(cell).getByText("просрочено")).toHaveClass("visually-hidden");
|
||||
expect(cell).toHaveAttribute("title", "просрочено");
|
||||
expect(cell).toHaveTextContent("01.01.2000");
|
||||
});
|
||||
});
|
||||
|
||||
@ -6,6 +6,7 @@ import { ISSUE_FILTER_NAMES, useBoard, useIssues, type IssueFilters as Filters }
|
||||
import { Button, Notice, PageHeader } from "../../../ui/index.ts";
|
||||
import { IssueFilters, SELECTS } from "./IssueFilters.tsx";
|
||||
import { useOpenIssue } from "./IssueLink.tsx";
|
||||
import { RefreshButton } from "../RefreshButton.tsx";
|
||||
import { IssueTable } from "./IssueTable.tsx";
|
||||
import { epicNames, filterOptions } from "./options.ts";
|
||||
import "./issues.css";
|
||||
@ -50,7 +51,7 @@ export function IssuesPage({ project }: { project: string }) {
|
||||
|
||||
return (
|
||||
<div data-testid="page-issues" className="issues">
|
||||
<PageHeader title="Задачи" subtitle={list.isPending ? undefined : `показано ${rows.length}${list.hasNextPage ? " · есть ещё" : ""}`} />
|
||||
<PageHeader title="Задачи" subtitle={list.isPending ? undefined : `показано ${rows.length}${list.hasNextPage ? " · есть ещё" : ""}`} actions={<RefreshButton />} />
|
||||
<IssueFilters filters={filters} set={set} onReset={reset} q={q} setQ={setQ} showClosed={showClosed} opts={opts} hasBoard={!!board.data} />
|
||||
{list.isError && (
|
||||
<Notice tone="error" action={<Button size="md" onClick={() => void list.refetch()}>Повторить</Button>}>{(list.error as Error).message}</Notice>
|
||||
|
||||
@ -9,8 +9,8 @@
|
||||
.issue-filters__grid{display:grid;grid-template-columns:repeat(3,minmax(0,1fr));gap:var(--space-3) var(--space-4)}
|
||||
@container page (max-width:851.98px){.issue-filters__grid{grid-template-columns:repeat(2,minmax(0,1fr))}}
|
||||
@container page (max-width:607.98px){.issue-filters__grid{grid-template-columns:minmax(0,1fr)}.issue-filters__search{max-width:none}}
|
||||
/* прокрутка внутри панели — только при узкой странице: overflow на предке отключает липкую шапку страницы; пороги контейнера = прежние пороги окна минус поля страницы */
|
||||
@container page (max-width:1051.98px){.issue-table-wrap{overflow-x:auto}}
|
||||
/* таблица прокручивается внутри своей панели на любой ширине (девять столбцов шире контейнера и на 1440 px); обёртка не шире контейнера страницы. Цена: липкая шапка таблицы липнет к панели, а не к окну (R-5 Task 7) */
|
||||
.issue-table-wrap{overflow-x:auto;max-width:100%;min-width:0}
|
||||
.issue-table th:first-child{border-top-left-radius:calc(var(--radius-xl) - 1px)}
|
||||
.issue-table th:last-child{border-top-right-radius:calc(var(--radius-xl) - 1px)}
|
||||
.issue-table tbody tr:last-child td:first-child{border-bottom-left-radius:calc(var(--radius-xl) - 1px)}
|
||||
|
||||
@ -15,6 +15,12 @@ function withoutTitle(md: string, title: string | null): string {
|
||||
const m = /^# ([^\n]*)\n+/.exec(md);
|
||||
return m && title !== null && m[1].trim() === title.split(/\s+/).filter(Boolean).join(" ") ? md.slice(m[0].length) : md;
|
||||
}
|
||||
// заголовки текста страницы идут ниже заголовка страницы (h2): # → h3, ## → h3, ### → h4 … не глубже h6
|
||||
const heading = (n: number) => {
|
||||
const Tag = `h${Math.min(6, Math.max(3, n + 1))}` as "h3";
|
||||
return function Heading({ children }: ComponentProps<"h1">) { return <Tag className={`wiki-md__h wiki-md__h--${n}`}>{children}</Tag>; };
|
||||
};
|
||||
const HEADINGS = { h1: heading(1), h2: heading(2), h3: heading(3), h4: heading(4), h5: heading(5), h6: heading(6) };
|
||||
const decode = (s: string) => { try { return decodeURIComponent(s); } catch { return s; } };
|
||||
|
||||
// страница вики: заголовок, дата синхронизации, «открыть в Eva» и Markdown (таблицы GFM, без сырого HTML)
|
||||
@ -25,6 +31,7 @@ export function PageView({ project, code, hrefOf }: { project: string; code: str
|
||||
const failed = useMemo(() => new Map((atts ?? []).filter((a) => a.state === "failed" && a.error).map((a) => [String(a.id), a.error!])), [atts]);
|
||||
|
||||
const components = useMemo<Components>(() => ({
|
||||
...HEADINGS,
|
||||
a({ href, children }: ComponentProps<"a">) {
|
||||
if (!href) return <>{children}</>;
|
||||
if (/^page:/i.test(href)) return <Link to={hrefOf(decode(href.slice(5)))}>{children}</Link>;
|
||||
|
||||
20
web/src/modules/wiki/Summary.test.tsx
Normal file
20
web/src/modules/wiki/Summary.test.tsx
Normal file
@ -0,0 +1,20 @@
|
||||
import { screen } from "@testing-library/react";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import type { Project } from "../../api/types.ts";
|
||||
import { renderWithProviders } from "../../test/render.tsx";
|
||||
import { Summary } from "./Summary.tsx";
|
||||
|
||||
const P = { label: "AWPCA", name: "АРМ КА", board_id: null, board_type: null, created_at: "", issues_count: 0, wiki_root: "DOC-1", wiki_pages: 28 } as unknown as Project;
|
||||
|
||||
describe("сводка вики", () => {
|
||||
it("число страниц", () => {
|
||||
renderWithProviders(<Summary project={P} />);
|
||||
expect(screen.getByText("28 страниц")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("T5.4: корень есть, синхронизации не было (wiki_pages = null) — «ещё не синхронизирована», а не «0 страниц»", () => {
|
||||
renderWithProviders(<Summary project={{ ...P, wiki_pages: null } as unknown as Project} />);
|
||||
expect(screen.getByText("ещё не синхронизирована")).toBeInTheDocument();
|
||||
expect(screen.queryByText(/0 страниц/)).toBeNull();
|
||||
});
|
||||
});
|
||||
@ -16,14 +16,14 @@ export function Summary({ project }: { project: Project }) {
|
||||
</div>
|
||||
);
|
||||
}
|
||||
const pages = typeof project.wiki_pages === "number" ? project.wiki_pages : 0;
|
||||
const pages = typeof project.wiki_pages === "number" ? project.wiki_pages : null; // null — синхронизации ещё не было
|
||||
const submit = (e: FormEvent) => {
|
||||
e.preventDefault();
|
||||
if (q.trim() !== "") void navigate(`/wiki${base}&q=${encodeURIComponent(q.trim())}`);
|
||||
};
|
||||
return (
|
||||
<div className="wiki-summary">
|
||||
<p className="wiki-summary__text">{pages} страниц</p>
|
||||
<p className="wiki-summary__text">{pages === null ? "ещё не синхронизирована" : `${pages} страниц`}</p>
|
||||
<form onSubmit={submit} role="search" className="wiki-summary__search"><SearchBox value={q} onChange={setQ} /></form>
|
||||
<Link to={`/wiki${base}`} className="wiki-summary__link">Вики</Link>
|
||||
</div>
|
||||
|
||||
@ -67,4 +67,13 @@ describe("Tree", () => {
|
||||
expect(item("B")).toHaveAttribute("aria-expanded", "true");
|
||||
expect(item("C")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("T10.2: aria-setsize и aria-posinset — среди братьев одного родителя", () => {
|
||||
render(<Tree nodes={NODES} selected="C" onOpen={() => {}} />);
|
||||
expect(item("A")).toHaveAttribute("aria-setsize", "1");
|
||||
expect(item("B")).toHaveAttribute("aria-setsize", "2");
|
||||
expect(item("B")).toHaveAttribute("aria-posinset", "1");
|
||||
expect(item("D")).toHaveAttribute("aria-posinset", "2");
|
||||
expect(item("C")).toHaveAttribute("aria-setsize", "1");
|
||||
});
|
||||
});
|
||||
|
||||
@ -8,6 +8,13 @@ import type { WikiNode } from "./api/queries.ts";
|
||||
export function Tree({ nodes, selected, onOpen }: { nodes: WikiNode[]; selected: string | null; onOpen(code: string): void }) {
|
||||
const byCode = useMemo(() => new Map(nodes.map((n) => [n.code, n])), [nodes]);
|
||||
const hasKids = useMemo(() => new Set(nodes.flatMap((n) => (n.parent_code ? [n.parent_code] : []))), [nodes]);
|
||||
// место среди братьев одного родителя: aria-setsize / aria-posinset (корни — «братья» без родителя)
|
||||
const place = useMemo(() => {
|
||||
const kids = new Map<string | null, string[]>(), at = new Map<string, { size: number; pos: number }>();
|
||||
for (const n of nodes) kids.set(n.parent_code, [...(kids.get(n.parent_code) ?? []), n.code]);
|
||||
for (const list of kids.values()) list.forEach((c, i) => at.set(c, { size: list.length, pos: i + 1 }));
|
||||
return at;
|
||||
}, [nodes]);
|
||||
const [open, setOpen] = useState<Set<string>>(() => new Set(nodes.filter((n) => n.depth === 0).map((n) => n.code)));
|
||||
const [focus, setFocus] = useState<string | null>(null);
|
||||
const refs = useRef(new Map<string, HTMLDivElement>());
|
||||
@ -59,7 +66,7 @@ export function Tree({ nodes, selected, onOpen }: { nodes: WikiNode[]; selected:
|
||||
const kids = hasKids.has(n.code);
|
||||
const sel = n.code === selected;
|
||||
return (
|
||||
<div key={n.code} role="treeitem" aria-level={n.depth + 1} aria-selected={sel} aria-expanded={kids ? open.has(n.code) : undefined}
|
||||
<div key={n.code} role="treeitem" aria-level={n.depth + 1} aria-setsize={place.get(n.code)?.size} aria-posinset={place.get(n.code)?.pos} aria-selected={sel} aria-expanded={kids ? open.has(n.code) : undefined}
|
||||
tabIndex={n.code === tabbable ? 0 : -1} data-code={n.code} className={"wiki-tree__item" + (sel ? " is-selected" : "")}
|
||||
style={{ paddingInlineStart: `calc(var(--space-2) + ${n.depth} * var(--space-4))` }}
|
||||
ref={(el) => { if (el) refs.current.set(n.code, el); else refs.current.delete(n.code); }}
|
||||
|
||||
@ -1,9 +1,10 @@
|
||||
import { screen, waitFor, within } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { beforeEach, describe, expect, it } from "vitest";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { setToken } from "../../auth/token.ts";
|
||||
import { json, mockFetch } from "../../test/fetchMock.ts";
|
||||
import { renderWithProviders } from "../../test/render.tsx";
|
||||
import { fmtStamp } from "../../ui/format.ts";
|
||||
import { WikiPage } from "./WikiPage.tsx";
|
||||
|
||||
const STATUS = { root: "DOC-1", root_title: "Корень", synced_at: "2026-10-20T10:00:00+00:00", pages: 3, error: null, batch: null };
|
||||
@ -41,6 +42,101 @@ const open = (route: string) => renderWithProviders(<WikiPage project="AWPCA" />
|
||||
describe("страница «Вики»", () => {
|
||||
beforeEach(() => setToken("tok"));
|
||||
|
||||
|
||||
it("шапка: h1 «Вики», подзаголовок с корнем, числом страниц и временем, вторичная кнопка «Обновить вики»", async () => {
|
||||
hub();
|
||||
open("/wiki?project=AWPCA");
|
||||
expect(await screen.findByRole("heading", { level: 1, name: "Вики" })).toBeInTheDocument();
|
||||
expect(screen.getByText(`Корень · 3 страниц · обновлено ${fmtStamp(STATUS.synced_at)}`)).toBeInTheDocument();
|
||||
const btn = screen.getByRole("button", { name: "Обновить вики" });
|
||||
expect(btn).toHaveClass("ui-btn--secondary");
|
||||
expect(btn).not.toHaveClass("ui-btn--primary");
|
||||
});
|
||||
|
||||
it("без page открыт корень и выделен в дереве", async () => {
|
||||
const calls = hub();
|
||||
open("/wiki?project=AWPCA");
|
||||
expect(await screen.findByRole("treeitem", { name: /Корень/ })).toHaveAttribute("aria-selected", "true");
|
||||
expect(await screen.findByRole("article")).toBeInTheDocument();
|
||||
expect(calls.some((c) => c.path === "/wiki/pages/DOC-1")).toBe(true);
|
||||
});
|
||||
|
||||
it("T10.2: предки открытой страницы раскрыты и остаются раскрытыми после очистки поиска", async () => {
|
||||
hub((_m, url) => url.pathname === "/wiki/search"
|
||||
? json(200, { results: [{ code: "DOC-3", title: "Регламент", path: "p", snippet: "порядок" }] }) : undefined);
|
||||
open("/wiki?project=AWPCA&page=DOC-2");
|
||||
expect(await screen.findByRole("treeitem", { name: /Модель данных/ })).toHaveAttribute("aria-selected", "true");
|
||||
await userEvent.click(screen.getByRole("treeitem", { name: /Корень/ }).querySelector(".wiki-tree__chevron")!); // свернуть корень вручную
|
||||
await userEvent.click(screen.getByRole("treeitem", { name: /Корень/ }).querySelector(".wiki-tree__chevron")!); // и раскрыть
|
||||
await userEvent.type(screen.getByRole("searchbox", { name: "Поиск по вики" }), "поряд");
|
||||
expect(await screen.findByText("порядок")).toBeInTheDocument();
|
||||
await userEvent.click(screen.getByRole("button", { name: "Очистить" }));
|
||||
expect(await screen.findByRole("treeitem", { name: /Модель данных/ })).toBeInTheDocument();
|
||||
expect(screen.getByRole("treeitem", { name: /Корень/ })).toHaveAttribute("aria-expanded", "true");
|
||||
});
|
||||
|
||||
it("T10.6: ручное сворачивание сохраняется после очистки поиска", async () => {
|
||||
hub((_m, url) => url.pathname === "/wiki/search" ? json(200, { results: [] }) : undefined);
|
||||
open("/wiki?project=AWPCA");
|
||||
await userEvent.click((await screen.findByRole("treeitem", { name: /Корень/ })).querySelector(".wiki-tree__chevron")!);
|
||||
expect(screen.queryByRole("treeitem", { name: /Регламент/ })).toBeNull();
|
||||
await userEvent.type(screen.getByRole("searchbox", { name: "Поиск по вики" }), "яяя");
|
||||
expect(await screen.findByText("Ничего не найдено")).toBeInTheDocument();
|
||||
await userEvent.click(screen.getByRole("button", { name: "Очистить" }));
|
||||
expect(await screen.findByRole("treeitem", { name: /Корень/ })).toHaveAttribute("aria-expanded", "false");
|
||||
expect(screen.queryByRole("treeitem", { name: /Регламент/ })).toBeNull();
|
||||
});
|
||||
|
||||
it("T10.2: у узлов дерева aria-setsize и aria-posinset", async () => {
|
||||
hub();
|
||||
open("/wiki?project=AWPCA");
|
||||
await screen.findByRole("tree");
|
||||
const root = screen.getByRole("treeitem", { name: /Корень/ });
|
||||
expect(root).toHaveAttribute("aria-setsize", "1");
|
||||
expect(root).toHaveAttribute("aria-posinset", "1");
|
||||
expect(screen.getByRole("treeitem", { name: /Модель данных/ })).toHaveAttribute("aria-setsize", "2");
|
||||
expect(screen.getByRole("treeitem", { name: /Регламент/ })).toHaveAttribute("aria-posinset", "2");
|
||||
});
|
||||
|
||||
it("T10.3: заголовки идут по порядку h1 → h2 без скачков, заголовки текста страницы ниже заголовка страницы", async () => {
|
||||
hub((_m, url) => url.pathname === "/wiki/pages/DOC-2" ? json(200, page("DOC-2", "# Верх\n\n## Раздел\n\n### Подраздел")) : undefined);
|
||||
open("/wiki?project=AWPCA&page=DOC-2");
|
||||
const article = await screen.findByRole("article");
|
||||
await within(article).findByText("Подраздел");
|
||||
const levels = screen.getAllByRole("heading").map((h) => Number(h.tagName[1]));
|
||||
expect(levels[0]).toBe(1);
|
||||
expect(levels[1]).toBe(2);
|
||||
levels.slice(1).forEach((l, i) => { expect(l).toBeGreaterThan(1); expect(l - (levels[i] ?? 0)).toBeLessThanOrEqual(1); });
|
||||
});
|
||||
|
||||
it("на узком экране страница выше дерева", async () => {
|
||||
vi.stubGlobal("matchMedia", (q: string) => ({ matches: q.includes("max-width"), media: q, addEventListener() {}, removeEventListener() {} }));
|
||||
try {
|
||||
hub();
|
||||
open("/wiki?project=AWPCA&page=DOC-2");
|
||||
const article = await screen.findByRole("article");
|
||||
const tree = await screen.findByRole("tree");
|
||||
expect(article.compareDocumentPosition(tree) & Node.DOCUMENT_POSITION_FOLLOWING).toBeTruthy();
|
||||
} finally { vi.unstubAllGlobals(); }
|
||||
});
|
||||
|
||||
it("на широком экране дерево выше страницы", async () => {
|
||||
hub();
|
||||
open("/wiki?project=AWPCA&page=DOC-2");
|
||||
const article = await screen.findByRole("article");
|
||||
const tree = await screen.findByRole("tree");
|
||||
expect(tree.compareDocumentPosition(article) & Node.DOCUMENT_POSITION_FOLLOWING).toBeTruthy();
|
||||
});
|
||||
|
||||
it("параметр q заполняет поиск и запускает его", async () => {
|
||||
const calls = hub((_m, url) => url.pathname === "/wiki/search"
|
||||
? json(200, { results: [{ code: "DOC-3", title: "Регламент", path: "p", snippet: "порядок согласования" }] }) : undefined);
|
||||
open("/wiki?project=AWPCA&q=согласов");
|
||||
expect(await screen.findByRole("searchbox", { name: "Поиск по вики" })).toHaveValue("согласов");
|
||||
expect(await screen.findByText("порядок согласования")).toBeInTheDocument();
|
||||
expect(new URLSearchParams(calls.find((c) => c.path === "/wiki/search")!.search).get("q")).toBe("согласов");
|
||||
});
|
||||
|
||||
it("M-15: ошибка вложения в состоянии failed видна сразу рядом со ссылкой", async () => {
|
||||
hub((_m, url) => url.pathname === "/wiki/pages/DOC-3"
|
||||
? json(200, { ...page("DOC-3"), attachments: [{ id: 7, name: "a.txt", state: "failed", size: null, error: "Eva: 404" }] })
|
||||
|
||||
@ -1,7 +1,8 @@
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
import { useCallback, useEffect, useState, useSyncExternalStore } from "react";
|
||||
import { useNavigate, useSearchParams } from "react-router";
|
||||
import { ApiError } from "../../api/client.ts";
|
||||
import { Button, IconRefresh, Notice, PageHeader, Panel } from "../../ui/index.ts";
|
||||
import { fmtStamp } from "../../ui/format.ts";
|
||||
import { useModuleActivity } from "../../shell/activity/activity.ts";
|
||||
import { useWikiSearch, useWikiStatus, useWikiTree } from "./api/queries.ts";
|
||||
import { PageView } from "./PageView.tsx";
|
||||
@ -12,10 +13,19 @@ import "./wiki.css";
|
||||
|
||||
const Q_DELAY = 300; // поиск — после паузы ввода
|
||||
|
||||
// узкий экран: страница выше дерева (порядок в разметке, чтобы совпадал с порядком обхода Tab); порог = порог одной колонки в wiki.css
|
||||
const NARROW = "(max-width: 759.98px)";
|
||||
const subscribeNarrow = (cb: () => void) => {
|
||||
if (typeof matchMedia !== "function") return () => {};
|
||||
const m = matchMedia(NARROW);
|
||||
m.addEventListener?.("change", cb);
|
||||
return () => m.removeEventListener?.("change", cb);
|
||||
};
|
||||
const isNarrow = () => typeof matchMedia === "function" && matchMedia(NARROW).matches;
|
||||
|
||||
export function WikiPage({ project }: { project: string }) {
|
||||
const [params] = useSearchParams();
|
||||
const navigate = useNavigate();
|
||||
const code = params.get("page");
|
||||
// адрес страницы вики: проект и страница; поиск и прочие параметры в адрес не входят
|
||||
const hrefOf = useCallback((c: string) => "?" + new URLSearchParams({ project, page: c }).toString(), [project]);
|
||||
|
||||
@ -34,6 +44,7 @@ export function WikiPage({ project }: { project: string }) {
|
||||
}, [q]);
|
||||
const search = useWikiSearch(project, dq);
|
||||
const searching = q.trim() !== "";
|
||||
const narrow = useSyncExternalStore(subscribeNarrow, isNarrow, () => false);
|
||||
|
||||
if (status.isPending) return <div className="wiki"><p className="wiki-hint">Загрузка…</p></div>;
|
||||
if (noRoot) return <div className="wiki"><PageHeader title="Вики" /><RootForm project={project} /></div>;
|
||||
@ -41,26 +52,35 @@ export function WikiPage({ project }: { project: string }) {
|
||||
|
||||
const s = status.data;
|
||||
const running = !!s.batch && s.batch.status !== "done" && s.batch.status !== "cancelled";
|
||||
const code = params.get("page") ?? (synced ? s.root : null); // без page открыт корень вики
|
||||
const subtitle = s.root_title ? `${s.root_title} · ${s.pages} страниц${s.synced_at ? ` · обновлено ${fmtStamp(s.synced_at)}` : ""}` : undefined;
|
||||
// дерево остаётся в разметке (скрыто) на время поиска: раскрытые вручную узлы не теряются
|
||||
const nav = (
|
||||
<Panel key="nav" as="section" padding="md" className="wiki__nav" aria-label="Навигация по вики">
|
||||
<SearchBox value={q} onChange={setQ} />
|
||||
{searching && <SearchResults hits={dq === "" ? undefined : search.data} pending={dq === "" || search.isPending} error={search.error} hrefOf={hrefOf} selected={code} />}
|
||||
<div hidden={searching}>
|
||||
{tree.isPending ? <p className="wiki-hint">Загрузка дерева…</p>
|
||||
: tree.isError ? <Notice tone="error">{tree.error.message}</Notice>
|
||||
: <Tree nodes={tree.data} selected={code} onOpen={(c) => void navigate(hrefOf(c))} />}
|
||||
</div>
|
||||
</Panel>
|
||||
);
|
||||
const content = (
|
||||
<Panel key="content" as="section" padding="lg" className="wiki__content" aria-label="Страница вики">
|
||||
{code ? <PageView key={code} project={project} code={code} hrefOf={hrefOf} /> : <p className="wiki-hint">Выберите страницу в дереве или найдите её поиском.</p>}
|
||||
</Panel>
|
||||
);
|
||||
return (
|
||||
<div className="wiki" data-testid="page-wiki">
|
||||
<PageHeader title="Вики" subtitle={s.root_title ? `${s.root_title} · страниц: ${s.pages}` : undefined}
|
||||
actions={<Button variant="primary" icon={<IconRefresh />} loading={sync.busy || running} onClick={() => void sync.start()}>Обновить вики</Button>} />
|
||||
<PageHeader title="Вики" subtitle={subtitle}
|
||||
actions={<Button variant="secondary" icon={<IconRefresh />} loading={sync.busy || running} onClick={() => void sync.start()}>Обновить вики</Button>} />
|
||||
{s.error && <Notice tone="error" title="Последняя синхронизация не удалась">{s.error}</Notice>}
|
||||
{!synced ? (
|
||||
<Notice tone="info">{running ? "Вики синхронизируется…" : "Вики ещё не синхронизирована. Нажмите «Обновить вики»."}</Notice>
|
||||
) : (
|
||||
<div className="wiki__body">
|
||||
<Panel as="section" padding="md" className="wiki__nav" aria-label="Навигация по вики">
|
||||
<SearchBox value={q} onChange={setQ} />
|
||||
{searching
|
||||
? <SearchResults hits={dq === "" ? undefined : search.data} pending={dq === "" || search.isPending} error={search.error} hrefOf={hrefOf} selected={code} />
|
||||
: tree.isPending ? <p className="wiki-hint">Загрузка дерева…</p>
|
||||
: tree.isError ? <Notice tone="error">{tree.error.message}</Notice>
|
||||
: <Tree nodes={tree.data} selected={code} onOpen={(c) => void navigate(hrefOf(c))} />}
|
||||
</Panel>
|
||||
<Panel as="section" padding="lg" className="wiki__content" aria-label="Страница вики">
|
||||
{code ? <PageView key={code} project={project} code={code} hrefOf={hrefOf} /> : <p className="wiki-hint">Выберите страницу в дереве или найдите её поиском.</p>}
|
||||
</Panel>
|
||||
{narrow ? <>{content}{nav}</> : <>{nav}{content}</>}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@ -39,9 +39,8 @@
|
||||
.wiki-page__eva:hover{text-decoration:underline}
|
||||
.wiki-md{font-size:var(--fs-body);line-height:var(--lh-body);overflow-wrap:anywhere}
|
||||
.wiki-md>:first-child{margin-top:0}
|
||||
.wiki-md h1,.wiki-md h2,.wiki-md h3,.wiki-md h4{margin:var(--space-5) 0 var(--space-2);font-weight:700}
|
||||
.wiki-md h1{font-size:var(--fs-section);line-height:var(--lh-section)}
|
||||
.wiki-md h2,.wiki-md h3,.wiki-md h4{font-size:var(--fs-lg);line-height:var(--lh-lg)}
|
||||
.wiki-md__h{margin:var(--space-5) 0 var(--space-2);font-weight:700;font-size:var(--fs-lg);line-height:var(--lh-lg)}
|
||||
.wiki-md__h--1{font-size:var(--fs-section);line-height:var(--lh-section)}
|
||||
.wiki-md p,.wiki-md ul,.wiki-md ol,.wiki-md blockquote,.wiki-md pre{margin:0 0 var(--space-3)}
|
||||
.wiki-md a,.wiki-att{color:var(--color-action-primary)}
|
||||
.wiki-md blockquote{padding-inline-start:var(--space-3);border-inline-start:3px solid var(--color-border-strong);color:var(--color-text-secondary)}
|
||||
|
||||
@ -12,7 +12,7 @@ export function ProjectGate({ children }: { children: (p: Project) => ReactNode
|
||||
case "error": return <div className="project-gate"><Notice tone="error">Проекты не загрузились: {state.error.message}</Notice></div>;
|
||||
case "unknown":
|
||||
return <div className="project-gate" data-testid="project-unknown">
|
||||
<Notice tone="warning" title={`проект ${state.label} не найден`}>Выберите проект в списке «Проект» вверху.</Notice></div>;
|
||||
<Notice tone="warning" title={`проект ${state.label} не найден`}>выберите проект в списке «Проект» боковой панели (на узком экране — в «Меню»)</Notice></div>;
|
||||
case "none":
|
||||
return <div className="project-gate">
|
||||
<Notice tone="info" title="Проектов нет." action={<Button variant="primary" onClick={openNew}>+ Новый проект…</Button>} /></div>;
|
||||
|
||||
@ -7,5 +7,3 @@
|
||||
.project-form__actions { display: flex; flex-wrap: wrap; justify-content: flex-end; gap: var(--space-2); }
|
||||
.project-form__group { display: flex; flex-direction: column; gap: var(--space-3); margin: 0; padding: var(--space-3) var(--space-4) var(--space-4); border: 1px solid var(--color-border-default); border-radius: var(--radius-md); min-width: 0; }
|
||||
.project-form__legend { padding: 0 var(--space-1); font-size: var(--fs-caption); line-height: var(--lh-caption); font-weight: 700; color: var(--color-text-secondary); }
|
||||
.project-form__group { display: flex; flex-direction: column; gap: var(--space-3); margin: 0; padding: var(--space-3) var(--space-4) var(--space-4); border: 1px solid var(--color-border-default); border-radius: var(--radius-md); min-width: 0; }
|
||||
.project-form__legend { padding: 0 var(--space-1); font-size: var(--fs-caption); line-height: var(--lh-caption); font-weight: 700; color: var(--color-text-secondary); }
|
||||
|
||||
@ -83,7 +83,7 @@ describe("проект в приложении", () => {
|
||||
const h = hub();
|
||||
open("/app/jira/issues?project=NOPE");
|
||||
expect(await screen.findByText(/проект NOPE не найден/)).toBeInTheDocument();
|
||||
expect(screen.getByTestId("project-unknown")).toHaveTextContent("Выберите проект в списке «Проект» вверху.");
|
||||
expect(screen.getByTestId("project-unknown")).toHaveTextContent("выберите проект в списке «Проект» боковой панели (на узком экране — в «Меню»)");
|
||||
expect(screen.getByRole("combobox", { name: "Проект" })).toBeInTheDocument();
|
||||
expect(h.get("/issues")).toHaveLength(0);
|
||||
});
|
||||
|
||||
@ -79,6 +79,14 @@ describe("блок состояния модулей", () => {
|
||||
expect(paths.filter((p) => p === "GET /wiki/status")).toEqual([]);
|
||||
});
|
||||
|
||||
it("ядро само не грузит /board и /wiki/status на странице вне модулей без корня вики", async () => {
|
||||
const paths = hub({ wiki_root: null, wiki_synced_at: null });
|
||||
render(<App router={createMemoryRouter(routes, { initialEntries: ["/app/nowhere?project=AWPCA"], basename: "/app" })} />); // страница вне модулей: сами они ничего не грузят
|
||||
await within(bar()).findByRole("button", { name: "Обновить из Jira — все проекты" });
|
||||
await waitFor(() => expect(paths).toContain("GET /agents"));
|
||||
expect(paths.filter((p) => p === "GET /board" || p === "GET /wiki/status")).toEqual([]);
|
||||
});
|
||||
|
||||
it("живой регион role=status стоит в строке модуля постоянно, до появления события", async () => {
|
||||
hub();
|
||||
open();
|
||||
|
||||
@ -79,13 +79,14 @@ describe("страница «Обзор»", () => {
|
||||
});
|
||||
|
||||
it("overview card without root: объяснение и «Привязать корень вики»", async () => {
|
||||
hub();
|
||||
const calls = hub();
|
||||
open("sfm_kedr");
|
||||
const wiki = within(await card("Вики"));
|
||||
const link = await wiki.findByRole("link", { name: "Привязать корень вики" });
|
||||
expect(link).toHaveAttribute("href", "/app/wiki?project=sfm_kedr");
|
||||
expect(wiki.queryByRole("button", { name: "Обновить вики" })).toBeNull();
|
||||
expect(wiki.queryByRole("searchbox")).toBeNull();
|
||||
expect(calls.some((c) => c.startsWith("GET /wiki/status"))).toBe(false);
|
||||
});
|
||||
|
||||
it("«Последние пакеты проекта»: номер, модуль, название, автор, статус, время", async () => {
|
||||
|
||||
@ -164,12 +164,13 @@ describe("боковая панель", () => {
|
||||
expect(await within(bar()).findByRole("switch", { name: "Тёмная тема" })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("доска не рисует свою кнопку «Обновить из Jira»: в приложении она одна", async () => {
|
||||
it("кнопка «Обновить из Jira» в шапке доски — вторичная, рядом со значком панели «— все проекты»", async () => {
|
||||
hub();
|
||||
open("/app/jira/board?project=AWPCA");
|
||||
await screen.findByTestId("page-board");
|
||||
await screen.findByText(/задач из/);
|
||||
expect(screen.getAllByRole("button", { name: /^Обновить из Jira/ })).toHaveLength(1);
|
||||
expect(screen.getAllByRole("button", { name: "Обновить из Jira" })).toHaveLength(1);
|
||||
expect(screen.getAllByRole("button", { name: /^Обновить из Jira/ })).toHaveLength(2);
|
||||
});
|
||||
|
||||
it("разделы — в навигации «Разделы» боковой панели", async () => {
|
||||
|
||||
@ -40,4 +40,3 @@ export function useTrackModuleBatch(): (module: string, batchId: number) => void
|
||||
}
|
||||
export const useAgents = (): AgentView[] => useActivity().agents;
|
||||
export const useActivitySummary = (): ActivityValue["summary"] => useActivity().summary;
|
||||
export const useTrackBatch = (): ActivityValue["trackBatch"] => useActivity().trackBatch;
|
||||
|
||||
@ -1,5 +1,7 @@
|
||||
/* 2. Основа */
|
||||
html,body{min-height:100%}
|
||||
/* место под полосу прокрутки резервируется: страницы разной высоты не сдвигаются по ширине */
|
||||
html{scrollbar-gutter:stable}
|
||||
body{font:var(--fs-body)/var(--lh-body) var(--font-ui);background:var(--color-bg-canvas);color:var(--color-text-primary);margin:0}
|
||||
h1{font-size:var(--fs-section);line-height:var(--lh-section);font-weight:700;margin:0}
|
||||
[hidden]{display:none!important}
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user