feat(web): верхняя панель — разделы, данные Jira, обновление, тема (#65)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
parent
e55a482182
commit
3e2f252aac
@ -58,7 +58,7 @@ test("ⓘ открывает карточку окном поверх доски
|
||||
await expect(page).toHaveURL(/\/app\/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 })).toHaveClass(/active/);
|
||||
await expect(page.getByRole("link", { name: "Доска связей", exact: true })).toHaveAttribute("aria-current", "page");
|
||||
});
|
||||
|
||||
test("M-3: прокрутка и данные доски после окна карточки и после другого раздела", async ({ page }) => {
|
||||
@ -85,7 +85,7 @@ test("M-3: прокрутка и данные доски после окна к
|
||||
// ссылки меню вверху страницы: обычный click прокрутил бы доску к ним до перехода и затёр запомненное, поэтому dispatchEvent
|
||||
await page.getByRole("link", { name: "Задачи", exact: true }).dispatchEvent("click");
|
||||
await expect(page).toHaveURL(/\/app\/issues\?project=AWPCA$/);
|
||||
await page.getByRole("link", { name: "Доска", exact: true }).dispatchEvent("click");
|
||||
await page.getByRole("link", { name: "Доска связей", exact: true }).dispatchEvent("click");
|
||||
await expect.poll(() => page.evaluate(() => window.scrollY)).toBeGreaterThan(y - 2);
|
||||
});
|
||||
|
||||
|
||||
@ -9,7 +9,7 @@ export async function login(page: Page, path = "/app/board") {
|
||||
await page.goto(path);
|
||||
await page.getByLabel("Клиентский токен хаба").fill(tokens().client);
|
||||
await page.getByRole("button", { name: "Войти" }).click();
|
||||
await expect(page.getByRole("link", { name: "Доска" })).toBeVisible();
|
||||
await expect(page.getByRole("link", { name: "Доска связей" })).toBeVisible();
|
||||
}
|
||||
|
||||
async function call(path: string, body: unknown) {
|
||||
|
||||
@ -27,7 +27,7 @@ describe("оболочка", () => {
|
||||
expect(router.state.location.pathname).toBe("/app/issues");
|
||||
expect(router.state.location.search).toBe("?project=AWPCA");
|
||||
expect(await screen.findByTestId("page-issues")).toBeInTheDocument();
|
||||
await userEvent.click(screen.getByRole("link", { name: "Доска" }));
|
||||
await userEvent.click(screen.getByRole("link", { name: "Доска связей" }));
|
||||
expect(router.state.location.pathname).toBe("/app/board");
|
||||
expect(router.state.location.search).toBe("?project=AWPCA");
|
||||
expect(await screen.findByTestId("page-board")).toBeInTheDocument();
|
||||
@ -42,6 +42,7 @@ describe("оболочка", () => {
|
||||
|
||||
it("«Сменить токен» показывает экран входа", async () => {
|
||||
render(<App router={at("/app/board")} />);
|
||||
await userEvent.click(await screen.findByRole("button", { name: "Ещё" }));
|
||||
await userEvent.click(screen.getByRole("button", { name: "Сменить токен" }));
|
||||
expect(getAuth().token).toBe("");
|
||||
expect(screen.getByRole("heading", { name: "Вход в хаб" })).toBeInTheDocument();
|
||||
@ -56,13 +57,13 @@ describe("оболочка", () => {
|
||||
render(<App router={router} />);
|
||||
expect(await screen.findByText(/сломалось/)).toBeInTheDocument();
|
||||
expect(screen.getByRole("button", { name: "Перезагрузить" })).toBeInTheDocument();
|
||||
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/board")} />);
|
||||
expect(screen.queryByRole("link", { name: "Доска" })).not.toBeInTheDocument();
|
||||
expect(screen.queryByRole("link", { name: "Доска связей" })).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
@ -19,8 +19,9 @@ export const qk = {
|
||||
export const useSettings = () =>
|
||||
useQuery({ queryKey: qk.settings, queryFn: () => api.get<HubSettings>("/settings"), staleTime: Infinity });
|
||||
|
||||
export const useBoard = (project: string) =>
|
||||
useQuery({ queryKey: qk.board(project), queryFn: () => api.get<BoardSnapshot>("/board", { project }) });
|
||||
// project === null — проект ещё не выбран: запроса нет
|
||||
export const useBoard = (project: string | null) =>
|
||||
useQuery({ queryKey: qk.board(project ?? ""), enabled: project !== null, queryFn: () => api.get<BoardSnapshot>("/board", { project: project! }) });
|
||||
|
||||
export const useProjects = () =>
|
||||
useQuery({ queryKey: qk.projects, queryFn: () => api.get<{ projects?: Project[] }>("/projects").then((r) => r.projects ?? []) });
|
||||
|
||||
@ -1,4 +1,4 @@
|
||||
import { fireEvent, screen, waitFor, within } from "@testing-library/react";
|
||||
import { fireEvent, screen, within } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { readFileSync } from "node:fs";
|
||||
import { join } from "node:path";
|
||||
@ -111,34 +111,21 @@ describe("страница доски", () => {
|
||||
expect(await screen.findByText("jira-export.csv (из файла)")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("тема переключается по кругу и применяется к документу", async () => {
|
||||
it("доска не рисует «Обновить из Jira» и ход пакета: они в верхней панели", async () => {
|
||||
hub();
|
||||
page();
|
||||
await screen.findByText(/задач из 9/);
|
||||
expect(screen.queryByRole("button", { name: "Обновить из Jira" })).not.toBeInTheDocument();
|
||||
await userEvent.click(screen.getByTitle("Ещё"));
|
||||
await userEvent.click(screen.getByRole("button", { name: "Тема: системная" }));
|
||||
expect(document.documentElement.getAttribute("data-theme")).toBe("light");
|
||||
expect(localStorage.getItem("graph-theme")).toBe("light");
|
||||
expect(screen.queryByRole("button", { name: /^Тема/ })).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("«Обновить из Jira» ставит пакет и недоступна, пока он идёт", async () => {
|
||||
const fetch = hub();
|
||||
page();
|
||||
await screen.findByText(/задач из 9/);
|
||||
const btn = screen.getByRole("button", { name: "Обновить из Jira" });
|
||||
await userEvent.click(btn);
|
||||
await waitFor(() => expect(btn).toBeDisabled());
|
||||
expect(fetch.mock.calls.filter(([u, i]) => u === "/refresh" && i?.method === "POST")).toHaveLength(1);
|
||||
expect(await screen.findByText(/пакет #7 выполняется/)).toBeInTheDocument();
|
||||
});
|
||||
it("заголовок страницы — h1, пока идёт пакет в шапке label и строка состояния", async () => {
|
||||
it("заголовок страницы — h1, в шапке label; строка состояния — только события доски", async () => {
|
||||
hub();
|
||||
page();
|
||||
await screen.findByText(/задач из 9/);
|
||||
expect(screen.getByRole("heading", { level: 1 })).toBeInTheDocument();
|
||||
await userEvent.click(screen.getByRole("button", { name: "Обновить из Jira" }));
|
||||
expect(await screen.findByRole("status")).toHaveTextContent(/пакет #7/);
|
||||
expect(screen.getByText(/^Хаб · данные на/)).toBeInTheDocument();
|
||||
expect(screen.queryByRole("status")).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("пустой link_types при заполненном kind_labels — называет только недостающий словарь", async () => {
|
||||
|
||||
@ -1,7 +1,4 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { useJiraRefresh } from "../../shell/refresh.ts";
|
||||
import { refreshText } from "../../shell/refreshStatus.ts";
|
||||
import { applyTheme, readTheme, saveTheme, THEME_NAMES, THEMES, type Theme } from "../../shell/theme.ts";
|
||||
import { statusText, type BoardStatus } from "../status.ts";
|
||||
import type { BoardModel, Visible } from "../types.ts";
|
||||
import { NOT_IN_HUB } from "../constants.ts";
|
||||
@ -15,23 +12,17 @@ interface ToolbarProps {
|
||||
}
|
||||
|
||||
export function Toolbar(p: ToolbarProps) {
|
||||
const [theme, setTheme] = useState<Theme>(readTheme);
|
||||
const [confirm, setConfirm] = useState(false);
|
||||
useEffect(() => { if (!confirm) return; const t = setTimeout(() => setConfirm(false), 8000); return () => clearTimeout(t); }, [confirm]);
|
||||
const refresh = useJiraRefresh(); // кнопка и ход пакета временно здесь; в верхней панели — с Task 5
|
||||
const rt = refresh.event ? refreshText(refresh.event) : null;
|
||||
const st = p.status ? statusText(p.status) : rt ? { text: rt.text, warn: rt.tone === "error" || rt.tone === "warning" } : null;
|
||||
const st = p.status ? statusText(p.status) : null; // только события доски; ход пакета «Обновить из Jira» — в верхней панели
|
||||
const m = p.model, v = p.vis, r = v.removed;
|
||||
const coreShown = v.nodes.filter((n) => !n.dim).length, ctxShown = v.nodes.length - coreShown;
|
||||
const nextTheme = () => { const t = THEMES[(THEMES.indexOf(theme) + 1) % THEMES.length]; setTheme(t); saveTheme(t); applyTheme(t); };
|
||||
return (
|
||||
<div className="top">
|
||||
<h1>Доска связей</h1>
|
||||
<span className="src">
|
||||
<span className="file-name" title={p.title}>{p.label}</span>
|
||||
<button type="button" className="link" title="Загрузить снимок зеркала хаба заново, без обращения к Jira" onClick={p.onReload}>Перечитать</button>
|
||||
<button type="button" className="link" disabled={refresh.busy} onClick={() => void refresh.start()}
|
||||
title="Поставить в хаб пакет «обновление по базовой JQL» и дождаться его: агент перечитает задачи из Jira">Обновить из Jira</button>
|
||||
<label className="link" title="CSV из фильтра Jira или JSON, выгруженный этой доской. JSON при работе с хабом переносит только расстановку карточек, дорожки и фильтры; CSV показывает снимок файла до «Перечитать»">
|
||||
файл…
|
||||
<input type="file" accept=".csv,.json,text/csv,application/json" hidden onChange={async (e) => {
|
||||
@ -61,7 +52,6 @@ export function Toolbar(p: ToolbarProps) {
|
||||
<button type="button" onClick={() => { close(); p.onExport(); }}>Экспорт JSON</button>
|
||||
<button type="button" aria-pressed={p.tableOpen} onClick={p.onTable}>Таблица связей</button>
|
||||
<button type="button" aria-pressed={p.snap} onClick={p.onSnap}>Привязка к сетке</button>
|
||||
<button type="button" onClick={nextTheme}>Тема: {THEME_NAMES[theme]}</button>
|
||||
<hr />
|
||||
{!confirm ? <button type="button" className="danger" onClick={() => setConfirm(true)}>Сбросить раскладку</button> : (
|
||||
<div className="confirm">
|
||||
|
||||
@ -41,7 +41,7 @@ describe("карточка окном", () => {
|
||||
expect(await within(dialog()).findByRole("heading", { name: /AURORA-9002/ })).toBeInTheDocument();
|
||||
expect(within(screen.getByRole("main")).getByRole("table")).toBe(table); // таблица команд карточки — в окне, не в main // фон не пересоздаётся
|
||||
expect(screen.getByLabelText("Спринт")).toHaveValue("AWPCA_Спринт 2"); // фильтр фона — из его адреса
|
||||
expect(screen.getByRole("link", { name: "Задачи" })).toHaveClass("active");
|
||||
expect(screen.getByRole("link", { name: "Задачи" })).toHaveAttribute("aria-current", "page");
|
||||
await userEvent.click(within(dialog()).getByRole("button", { name: "Закрыть" }));
|
||||
await waitFor(() => expect(screen.queryByRole("dialog")).toBeNull());
|
||||
expect(router.state.location.pathname).toBe("/app/issues");
|
||||
@ -88,7 +88,7 @@ describe("карточка окном", () => {
|
||||
expect(router.state.location.pathname).toBe("/app/issues/AURORA-9001");
|
||||
});
|
||||
|
||||
it("с доски: окно поверх доски, вкладка «Доска» активна, после закрытия доска та же", async () => {
|
||||
it("с доски: окно поверх доски, раздел «Доска связей» активен, после закрытия доска та же", async () => {
|
||||
const router = at(["/app/board"]);
|
||||
render(<App router={router} />);
|
||||
const ilink = await screen.findByRole("link", { name: "Открыть карточку AURORA-9001" });
|
||||
@ -96,7 +96,7 @@ describe("карточка окном", () => {
|
||||
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: "Доска" })).toHaveClass("active");
|
||||
expect(screen.getByRole("link", { name: "Доска связей" })).toHaveAttribute("aria-current", "page");
|
||||
await userEvent.click(within(dialog()).getByRole("button", { name: "Закрыть" }));
|
||||
await waitFor(() => expect(router.state.location.pathname).toBe("/app/board"));
|
||||
expect(screen.getByTestId("page-board")).toBe(boardEl);
|
||||
|
||||
@ -5,7 +5,7 @@ import type { Project } from "../api/types.ts";
|
||||
import "./projects.css";
|
||||
|
||||
// окно «Новый проект» (как IssueModal): <dialog> держит фокус; Esc и «Отмена» — onClose, ошибка хаба — рядом с «Создать»
|
||||
export function NewProjectDialog({ onClose, onCreated }: { onClose(): void; onCreated(p: Project): void }) {
|
||||
export function NewProjectDialog({ onClose, onCreated }: { onClose(): void; onCreated(p: Project, batchId: number): void }) {
|
||||
const ref = useRef<HTMLDialogElement>(null);
|
||||
const [label, setLabel] = useState(""), [name, setName] = useState(""), [board, setBoard] = useState("");
|
||||
const create = useCreateProject();
|
||||
@ -15,7 +15,7 @@ export function NewProjectDialog({ onClose, onCreated }: { onClose(): void; onCr
|
||||
e.preventDefault();
|
||||
const f: { label: string; name: string; board_id?: number } = { label: label.trim(), name: name.trim() };
|
||||
if (board.trim() !== "") f.board_id = Number(board);
|
||||
create.mutate(f, { onSuccess: (r) => onCreated(r.project) });
|
||||
create.mutate(f, { onSuccess: (r) => onCreated(r.project, r.batch_id) });
|
||||
};
|
||||
const err = create.error;
|
||||
const text = err ? (err instanceof ApiError ? err.detail ?? err.message : err.message) : null;
|
||||
|
||||
@ -1,3 +1,4 @@
|
||||
import { Select } from "../ui/index.ts";
|
||||
import { useProjectContext } from "./ProjectContext.ts";
|
||||
|
||||
const NEW = " new"; // служебное значение пункта «Новый проект…»: метка пробелов содержать не может
|
||||
@ -6,11 +7,11 @@ export function ProjectPicker() {
|
||||
const { state, projects, select, openNew } = useProjectContext();
|
||||
const current = state.kind === "ok" ? state.project.label : "";
|
||||
return (
|
||||
<select className="project-picker" aria-label="Проект" value={current}
|
||||
<Select className="project-picker" aria-label="Проект" value={current}
|
||||
onChange={(e) => { if (e.target.value === NEW) openNew(); else select(e.target.value); }}>
|
||||
{current === "" && <option value="" disabled>выберите проект</option>}
|
||||
{projects.map((p) => <option key={p.label} value={p.label}>{p.label} — {p.name}</option>)}
|
||||
<option value={NEW}>+ Новый проект…</option>
|
||||
</select>
|
||||
</Select>
|
||||
);
|
||||
}
|
||||
|
||||
@ -1,5 +1,5 @@
|
||||
/* выбор проекта и окно «Новый проект» */
|
||||
.appbar .project-picker{font:inherit;font-size:14px;height:30px;max-width:280px;border:1px solid var(--line);border-radius:6px;background:var(--card);color:var(--fg);padding:0 8px}
|
||||
.appbar .project-picker{max-width:280px;min-width:160px}
|
||||
.project-gate{display:flex;flex-direction:column;gap:12px;align-items:flex-start;margin:24px 0}
|
||||
.project-dialog{border:1px solid var(--line);border-radius:10px;background:var(--panel);color:var(--fg);padding:16px 20px;min-width:340px}
|
||||
.project-dialog::backdrop{background:var(--color-overlay)}
|
||||
|
||||
@ -96,15 +96,22 @@ describe("проект в приложении", () => {
|
||||
});
|
||||
|
||||
it("создание проекта из формы", async () => {
|
||||
const h = hub([A], { "POST /projects": () => json(201, { project: S, batch_id: 7 }) });
|
||||
const h = hub([A], {
|
||||
"POST /projects": () => json(201, { project: S, batch_id: 7 }),
|
||||
"GET /batches/7": () => json(200, { id: 7, author: "viewer", status: "queued", remaining_seconds: 5, counts: {}, commands: [{ type: "refresh", status: "queued" }] }),
|
||||
});
|
||||
const router = open("/app/board?project=AWPCA");
|
||||
await screen.findByTestId("page-board");
|
||||
const listed = h.get("/batches").length;
|
||||
await userEvent.selectOptions(screen.getByRole("combobox", { name: "Проект" }), "+ Новый проект…");
|
||||
const dlg = screen.getByRole("dialog", { name: "Новый проект" });
|
||||
await userEvent.type(within(dlg).getByLabelText("Метка"), "STOCKS_DRRK");
|
||||
await userEvent.type(within(dlg).getByLabelText("Название"), "Запасы ДРРК");
|
||||
await userEvent.click(within(dlg).getByRole("button", { name: "Создать" }));
|
||||
await waitFor(() => expect(router.state.location.search).toBe("?project=STOCKS_DRRK"));
|
||||
// пакет проекта отслеживается по batch_id (track): верхняя панель показывает ход, список пакетов не запрашивается
|
||||
expect(await within(screen.getByRole("banner")).findByRole("status")).toHaveTextContent(/пакет #7 в очереди/);
|
||||
expect(h.get("/batches").length).toBe(listed);
|
||||
const post = h.calls.find((c) => c.method === "POST" && c.url.pathname === "/projects")!;
|
||||
expect(post.body).toEqual({ label: "STOCKS_DRRK", name: "Запасы ДРРК", author: "viewer" });
|
||||
expect(screen.queryByRole("dialog", { name: "Новый проект" })).not.toBeInTheDocument();
|
||||
|
||||
120
web/src/shell/AppBar.test.tsx
Normal file
120
web/src/shell/AppBar.test.tsx
Normal file
@ -0,0 +1,120 @@
|
||||
import { render, screen, waitFor, within } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { readFileSync } from "node:fs";
|
||||
import { join } from "node:path";
|
||||
import { createMemoryRouter } from "react-router";
|
||||
import { beforeEach, describe, expect, it } from "vitest";
|
||||
import { App } from "../App.tsx";
|
||||
import { getAuth, setToken } from "../auth/token.ts";
|
||||
import settings from "../board/__fixtures__/settings.json";
|
||||
import { routes } from "../routes.tsx";
|
||||
import { json, mockFetch } from "../test/fetchMock.ts";
|
||||
|
||||
const SNAP = JSON.parse(readFileSync(join(import.meta.dirname, "..", "board", "__fixtures__", "hub-basic.board.json"), "utf8"));
|
||||
const A = { label: "AWPCA", name: "АРМ КА", board_id: null, board_type: null, created_at: "", issues_count: 9 };
|
||||
const WITH_SYNC = { ...SNAP, last_sync: { started_at: "2026-10-14T11:58:00+00:00", finished_at: "2026-10-14T12:00:00+00:00", issues_count: 9 } };
|
||||
|
||||
interface Call { method: string; url: URL }
|
||||
function hub(over: Record<string, () => Response> = {}) {
|
||||
const calls: Call[] = [];
|
||||
mockFetch((method, url) => {
|
||||
calls.push({ method, url });
|
||||
const k = `${method} ${url.pathname}`;
|
||||
if (over[k]) return over[k]();
|
||||
if (k === "GET /projects") return json(200, { projects: [A] });
|
||||
if (k === "GET /settings") return json(200, settings);
|
||||
if (k === "GET /board") return json(200, WITH_SYNC);
|
||||
if (k === "GET /issues") return json(200, { issues: [] });
|
||||
if (k === "GET /batches") return json(200, { batches: [] });
|
||||
if (k === "GET /agent") return json(200, { online: true, last_seen_at: "2026-10-14T09:30:00+00:00" });
|
||||
if (k === "POST /refresh") return json(201, { id: 7, estimate_seconds: 5, count: 1 });
|
||||
if (k === "GET /batches/7") return json(200, { id: 7, author: "viewer", status: "running", remaining_seconds: 3, counts: {}, commands: [{ type: "refresh", status: "running" }] });
|
||||
return json(404, { error: "not_found" });
|
||||
});
|
||||
return { count: (m: string, p: string) => calls.filter((c) => c.method === m && c.url.pathname === p).length };
|
||||
}
|
||||
const open = (path: string) => {
|
||||
const router = createMemoryRouter(routes, { initialEntries: [path], basename: "/app" });
|
||||
render(<App router={router} />);
|
||||
return router;
|
||||
};
|
||||
const bar = () => screen.getByRole("banner");
|
||||
|
||||
describe("верхняя панель", () => {
|
||||
beforeEach(() => { setToken("tok"); localStorage.clear(); });
|
||||
|
||||
it("разделы — ссылки «Доска связей» и «Задачи», активная с aria-current", async () => {
|
||||
hub();
|
||||
open("/app/issues?project=AWPCA");
|
||||
await screen.findByRole("link", { name: "Доска связей" });
|
||||
await waitFor(() => expect(screen.getByRole("link", { name: "Доска связей" })).toHaveAttribute("href", "/app/board?project=AWPCA"));
|
||||
expect(screen.getByRole("link", { name: "Задачи" })).toHaveAttribute("aria-current", "page");
|
||||
expect(screen.getByRole("link", { name: "Доска связей" })).not.toHaveAttribute("aria-current");
|
||||
});
|
||||
|
||||
it("«Обновить из Jira» на /issues ставит пакет, недоступна, по done — «пакет #7 выполнен» и GET /issues повторно", async () => {
|
||||
let done = false;
|
||||
const h = hub({ "GET /batches/7": () => json(200, { id: 7, author: "viewer", status: done ? "done" : "running", remaining_seconds: 3, counts: {}, commands: [{ type: "refresh", status: done ? "done" : "running" }] }) });
|
||||
open("/app/issues?project=AWPCA");
|
||||
await screen.findByTestId("page-issues");
|
||||
await waitFor(() => expect(h.count("GET", "/issues")).toBeGreaterThan(0));
|
||||
const issuesBefore = h.count("GET", "/issues");
|
||||
const btn = within(bar()).getByRole("button", { name: "Обновить из Jira" });
|
||||
await userEvent.click(btn);
|
||||
await waitFor(() => expect(btn).toBeDisabled());
|
||||
expect(btn).toHaveAttribute("aria-busy", "true");
|
||||
expect(h.count("POST", "/refresh")).toBe(1);
|
||||
expect(await within(bar()).findByRole("status")).toHaveTextContent(/пакет #7 выполняется/);
|
||||
done = true;
|
||||
await waitFor(() => expect(within(bar()).getByRole("status")).toHaveTextContent("пакет #7 выполнен"), { timeout: 6000 });
|
||||
await waitFor(() => expect(h.count("GET", "/issues")).toBeGreaterThan(issuesBefore));
|
||||
expect(btn).toBeEnabled();
|
||||
});
|
||||
|
||||
it("агент офлайн — «агент офлайн»; на связи — «агент на связи»", async () => {
|
||||
hub({ "GET /agent": () => json(200, { online: false, last_seen_at: "2026-10-14T09:30:00+00:00" }) });
|
||||
open("/app/board?project=AWPCA");
|
||||
expect(await within(bar()).findByText("агент офлайн")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("агент на связи", async () => {
|
||||
hub();
|
||||
open("/app/board?project=AWPCA");
|
||||
expect(await within(bar()).findByText("агент на связи")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("«данные на ДД.ММ ЧЧ:ММ» берутся из last_sync снимка", async () => {
|
||||
hub();
|
||||
open("/app/issues?project=AWPCA");
|
||||
expect(await within(bar()).findByText(/^данные на \d\d\.\d\d \d\d:\d\d$/)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("полного обновления не было", async () => {
|
||||
hub({ "GET /board": () => json(200, { ...SNAP, last_sync: null }) });
|
||||
open("/app/issues?project=AWPCA");
|
||||
expect(await within(bar()).findByText("полного обновления не было")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("меню «Ещё» → «Сменить токен» показывает экран входа", async () => {
|
||||
hub();
|
||||
open("/app/board?project=AWPCA");
|
||||
await userEvent.click(await screen.findByRole("button", { name: "Ещё" }));
|
||||
await userEvent.click(screen.getByRole("button", { name: "Сменить токен" }));
|
||||
expect(getAuth().token).toBe("");
|
||||
expect(screen.getByRole("heading", { name: "Вход в хаб" })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("переключатель темы — в панели", async () => {
|
||||
hub();
|
||||
open("/app/board?project=AWPCA");
|
||||
expect(await within(bar()).findByRole("switch", { name: "Тёмная тема" })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("доска не рисует свою кнопку «Обновить из Jira»: в приложении она одна", async () => {
|
||||
hub();
|
||||
open("/app/board?project=AWPCA");
|
||||
await screen.findByTestId("page-board");
|
||||
await screen.findByText(/задач из/);
|
||||
expect(screen.getAllByRole("button", { name: "Обновить из Jira" })).toHaveLength(1);
|
||||
});
|
||||
});
|
||||
28
web/src/shell/AppBar.tsx
Normal file
28
web/src/shell/AppBar.tsx
Normal file
@ -0,0 +1,28 @@
|
||||
import { useQueryClient } from "@tanstack/react-query";
|
||||
import { clearToken } from "../auth/token.ts";
|
||||
import { withProject } from "../projects/current.ts";
|
||||
import { ProjectPicker } from "../projects/ProjectPicker.tsx";
|
||||
import { Menu, MenuItem, Segmented, ThemeSwitch } from "../ui/index.ts";
|
||||
import { DataStatus } from "./DataStatus.tsx";
|
||||
|
||||
export type Section = "/board" | "/issues";
|
||||
|
||||
// верхняя панель (design.md, решение 9): название, проект, разделы, состояние данных, тема, меню «Ещё»
|
||||
export function AppBar({ section, project }: { section: Section; project: string | null }) {
|
||||
const qc = useQueryClient();
|
||||
const href = (to: Section) => (project ? to + withProject("", project) : to);
|
||||
return (
|
||||
<header className="appbar">
|
||||
<span className="appbar__brand">Хаб Jira</span>
|
||||
<ProjectPicker />
|
||||
<Segmented label="Раздел" value={section}
|
||||
options={[{ value: "/board", label: "Доска связей", href: href("/board") }, { value: "/issues", label: "Задачи", href: href("/issues") }]} />
|
||||
<span className="appbar__spacer" />
|
||||
<DataStatus project={project} />
|
||||
<ThemeSwitch />
|
||||
<Menu label="Ещё">
|
||||
{(close) => <MenuItem onSelect={() => { close(); clearToken(); qc.clear(); }}>Сменить токен</MenuItem>}
|
||||
</Menu>
|
||||
</header>
|
||||
);
|
||||
}
|
||||
31
web/src/shell/DataStatus.tsx
Normal file
31
web/src/shell/DataStatus.tsx
Normal file
@ -0,0 +1,31 @@
|
||||
import { useBoard } from "../api/queries.ts";
|
||||
import { fmtStamp } from "../board/model/util.ts";
|
||||
import { Button, IconRefresh } from "../ui/index.ts";
|
||||
import { useJiraRefresh } from "./refresh.ts";
|
||||
import { refreshText } from "./refreshStatus.ts";
|
||||
|
||||
// состояние данных: агент, время последнего полного обновления, кнопка «Обновить из Jira» и ход пакета
|
||||
export function DataStatus({ project }: { project: string | null }) {
|
||||
const refresh = useJiraRefresh();
|
||||
const board = useBoard(project);
|
||||
const ls = board.data?.last_sync ?? null;
|
||||
const agent = refresh.agent;
|
||||
const rt = refresh.event ? refreshText(refresh.event) : null;
|
||||
const seen = agent?.last_seen_at ? `последний выход на связь: ${fmtStamp(agent.last_seen_at)}` : "выхода на связь ещё не было";
|
||||
return (
|
||||
<div className="data-status">
|
||||
{agent && (
|
||||
<span className="data-status__agent" title={seen}>
|
||||
<span className={"data-status__dot" + (agent.online ? " is-online" : "")} aria-hidden="true" />
|
||||
{agent.online ? "агент на связи" : "агент офлайн"}
|
||||
</span>
|
||||
)}
|
||||
{board.data && <span className="data-status__sync">{ls ? `данные на ${fmtStamp(ls.finished_at)}` : "полного обновления не было"}</span>}
|
||||
<Button variant="primary" icon={<IconRefresh />} loading={refresh.busy} onClick={() => void refresh.start()}
|
||||
title="Поставить в хаб пакет «обновление по базовой JQL» и дождаться его: агент перечитает задачи из Jira">
|
||||
Обновить из Jira
|
||||
</Button>
|
||||
{rt && <span className={`data-status__event data-status__event--${rt.tone}`} role="status">{rt.text}</span>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@ -1,21 +1,19 @@
|
||||
import { useQueryClient } from "@tanstack/react-query";
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
import { Link, matchPath, useLocation, useNavigate, useRoutes } from "react-router";
|
||||
import { matchPath, useLocation, useNavigate, useRoutes } from "react-router";
|
||||
import { useProjects } from "../api/queries.ts";
|
||||
import { clearToken } from "../auth/token.ts";
|
||||
import { CloseAbortContext } from "../issues/closeAbort.ts";
|
||||
import { IssueModal } from "../issues/IssueModal.tsx";
|
||||
import { cardState, LIST_BACKGROUND } from "../issues/modal.ts";
|
||||
import { rememberedProject, rememberProject, resolveProject, withProject } from "../projects/current.ts";
|
||||
import { NewProjectDialog } from "../projects/NewProjectDialog.tsx";
|
||||
import { ProjectContext, type ProjectCtx, type ProjectState } from "../projects/ProjectContext.ts";
|
||||
import { ProjectPicker } from "../projects/ProjectPicker.tsx";
|
||||
import { AppBar } from "./AppBar.tsx";
|
||||
import { ErrorBoundary } from "./ErrorBoundary.tsx";
|
||||
import { PAGE_ROUTES } from "./pages.tsx";
|
||||
import { useJiraRefresh } from "./refresh.ts";
|
||||
import "./shell.css";
|
||||
|
||||
export function Layout() {
|
||||
const qc = useQueryClient();
|
||||
const location = useLocation();
|
||||
const navigate = useNavigate();
|
||||
const issueKey = matchPath("/issues/:key", location.pathname)?.params.key ?? null;
|
||||
@ -77,25 +75,15 @@ export function Layout() {
|
||||
const ctx = useMemo<ProjectCtx>(() => ({ state, projects: projects.data ?? [], select, openNew: () => setNewOpen(true) }),
|
||||
[state, projects.data, select]);
|
||||
|
||||
const tab = (to: string, label: string) => {
|
||||
const active = at === to || at.startsWith(to + "/");
|
||||
return <Link to={okLabel ? { pathname: to, search: withProject("", okLabel) } : to} className={active ? "active" : undefined}
|
||||
aria-current={active ? "page" : undefined}>{label}</Link>;
|
||||
};
|
||||
const refresh = useJiraRefresh();
|
||||
return (
|
||||
<ProjectContext value={ctx}>
|
||||
<header className="appbar">
|
||||
<span className="brand">Хаб Jira</span>
|
||||
<ProjectPicker />
|
||||
<nav>{tab("/board", "Доска")}{tab("/issues", "Задачи")}</nav>
|
||||
<span className="spacer" />
|
||||
<button type="button" className="link" onClick={() => { clearToken(); qc.clear(); }}>Сменить токен</button>
|
||||
</header>
|
||||
<main className="page">
|
||||
<AppBar section={section} project={okLabel} />
|
||||
<main className={"page" + (section === "/board" ? " page--board" : "")}>
|
||||
<ErrorBoundary key={at}>{page}</ErrorBoundary>
|
||||
</main>
|
||||
{issueKey && <CloseAbortContext value={abortClose}><IssueModal issueKey={issueKey} onClose={close} /></CloseAbortContext>}
|
||||
{newOpen && <NewProjectDialog onClose={() => setNewOpen(false)} onCreated={(p) => { setNewOpen(false); select(p.label); }} />}
|
||||
{newOpen && <NewProjectDialog onClose={() => setNewOpen(false)} onCreated={(p, batchId) => { setNewOpen(false); refresh.track(batchId); select(p.label); }} />}
|
||||
</ProjectContext>
|
||||
);
|
||||
}
|
||||
|
||||
@ -93,7 +93,9 @@ export function RefreshProvider({ children, pollMs = POLL_MS }: { children: Reac
|
||||
qc.removeQueries({ queryKey: ["batch", id] });
|
||||
// перечитывание активных снимков досок — после своего пакета, подхват не нужен; показанный файл не заменяется (M-1)
|
||||
skipPickup.current = qc.getQueryCache().findAll({ queryKey: qk.boardAll, type: "active" }).length;
|
||||
for (const key of [qk.boardAll, ["issues"], ["issue"], qk.projects]) void qc.invalidateQueries({ queryKey: key });
|
||||
// T4.1: перечитывание могло упасть (успеха нет, счётчик не уменьшился) — по его завершению счётчик гасится в любом случае
|
||||
void qc.invalidateQueries({ queryKey: qk.boardAll }).finally(() => { skipPickup.current = 0; });
|
||||
for (const key of [["issues"], ["issue"], qk.projects]) void qc.invalidateQueries({ queryKey: key });
|
||||
}, [qc]);
|
||||
|
||||
useEffect(() => {
|
||||
|
||||
@ -6,7 +6,7 @@ import { useBoard, useIssues } from "../api/queries.ts";
|
||||
import { makeQueryClient } from "../api/queryClient.ts";
|
||||
import { getToken, setToken } from "../auth/token.ts";
|
||||
import { json } from "../test/fetchMock.ts";
|
||||
import { batch, deferred, hub, sleep } from "../test/batchHub.ts";
|
||||
import { batch, deferred, hub, SNAP, sleep } from "../test/batchHub.ts";
|
||||
import { useJiraRefresh, type JiraRefresh } from "./refresh.ts";
|
||||
import { RefreshProvider } from "./RefreshProvider.tsx";
|
||||
|
||||
@ -147,6 +147,27 @@ describe("RefreshProvider", () => {
|
||||
expect(getToken()).toBe("new-tok");
|
||||
});
|
||||
|
||||
it("T4.1: упавшее перечитывание снимка после своего пакета не гасит подхват следующей независимой загрузки", async () => {
|
||||
let boardFails = false, listed: unknown[] = [];
|
||||
const h = hub({
|
||||
"GET /board": () => (boardFails ? json(500, { error: "boom" }) : json(200, SNAP)),
|
||||
"GET /batches": () => json(200, { batches: listed }),
|
||||
"GET /batches/:id": (url) => (url.pathname.endsWith("/8") ? json(200, { id: 8, author: "viewer", status: "running", remaining_seconds: 3, counts: {}, commands: [{ type: "refresh", status: "running" }] }) : batch("done")),
|
||||
});
|
||||
const { result } = renderHook(() => ({ r: useJiraRefresh(), b: useBoard("AWPCA") }), { wrapper: wrap() });
|
||||
await waitFor(() => expect(result.current.b.data).toBeDefined());
|
||||
await waitFor(() => expect(h.count("GET /batches")).toBeGreaterThanOrEqual(1));
|
||||
boardFails = true;
|
||||
await act(() => result.current.r.start());
|
||||
await waitFor(() => expect(result.current.r.event).toEqual({ kind: "batch-done", id: 7 }));
|
||||
await waitFor(() => expect(h.count("GET /board")).toBe(2)); // перечитывание после пакета упало
|
||||
await sleep(30);
|
||||
boardFails = false;
|
||||
listed = [{ id: 8, author: "viewer", status: "running" }];
|
||||
await act(async () => { await result.current.b.refetch(); });
|
||||
await waitFor(() => expect(result.current.r.event).toMatchObject({ kind: "batch", id: 8 }));
|
||||
});
|
||||
|
||||
it("по завершении инвалидируются board, issues, issue, projects", async () => {
|
||||
let polls = 0;
|
||||
const h = hub({ "GET /batches/:id": () => (++polls < 2 ? batch("running") : batch("done")) });
|
||||
|
||||
@ -1,8 +1,28 @@
|
||||
/* оболочка: верхняя панель разделов */
|
||||
.appbar{display:flex;align-items:center;gap:16px;padding:10px 16px;border-bottom:1px solid var(--line);background:var(--panel)}
|
||||
.appbar .brand{font-weight:600}
|
||||
.appbar nav{display:flex;gap:4px}
|
||||
.appbar nav a{color:var(--fg);text-decoration:none;padding:6px 10px;border-radius:6px}
|
||||
.appbar nav a.active{background:var(--accent);color:var(--accent-fg)}
|
||||
.appbar .spacer{flex:1 1 auto}
|
||||
/* оболочка: верхняя панель разделов (design.md, решение 9) */
|
||||
.appbar{
|
||||
display:flex;align-items:center;flex-wrap:wrap;gap:8px 16px;min-height:64px;padding:8px 24px;box-sizing:border-box;
|
||||
border-bottom:1px solid var(--color-border-default);background:var(--color-bg-surface);
|
||||
}
|
||||
.appbar__brand{font-size:16px;line-height:24px;font-weight:700;color:var(--color-text-primary);white-space:nowrap}
|
||||
.appbar__spacer{flex:1 1 auto}
|
||||
.data-status{display:flex;align-items:center;flex-wrap:wrap;gap:4px 12px;font-size:var(--fs-caption);line-height:var(--lh-caption);color:var(--color-text-secondary)}
|
||||
.data-status__agent{display:inline-flex;align-items:center;gap:6px;white-space:nowrap}
|
||||
.data-status__dot{width:8px;height:8px;border-radius:50%;background:var(--color-status-error)}
|
||||
.data-status__dot.is-online{background:var(--color-status-success)}
|
||||
.data-status__sync{white-space:nowrap}
|
||||
.data-status__event{font-weight:600}
|
||||
.data-status__event--info{color:var(--color-text-secondary)}
|
||||
.data-status__event--success{color:var(--color-status-success)}
|
||||
.data-status__event--warning{color:var(--color-status-warning)}
|
||||
.data-status__event--error{color:var(--color-status-error)}
|
||||
.crash{max-width:640px;margin:48px auto}
|
||||
@media (max-width:1099px){
|
||||
.appbar .data-status{order:10;flex:1 0 100%}
|
||||
.appbar .appbar__spacer{flex:1 1 0}
|
||||
}
|
||||
@media (max-width:639px){
|
||||
.appbar{padding-inline:16px}
|
||||
.appbar__brand{display:none}
|
||||
.appbar .ui-seg{flex:1 1 100%;order:5}
|
||||
.appbar .ui-seg__item{flex:1 1 0;justify-content:center}
|
||||
}
|
||||
|
||||
@ -1,7 +1,6 @@
|
||||
// тема интерфейса: "" — системная, light, dark; ключ graph-theme, как у прежней доски (links.html, строки 1000–1003)
|
||||
export const THEMES = ["", "light", "dark"] as const;
|
||||
export type Theme = (typeof THEMES)[number];
|
||||
export const THEME_NAMES: Record<Theme, string> = { "": "системная", light: "светлая", dark: "тёмная" };
|
||||
const KEY = "graph-theme";
|
||||
|
||||
export function readTheme(): Theme {
|
||||
|
||||
@ -7,7 +7,9 @@ h1{font-size:var(--fs-section);line-height:var(--lh-section);font-weight:700;mar
|
||||
.spacer{flex:1 1 auto}
|
||||
.hint{font-size:13px;color:var(--muted)}
|
||||
.warn{color:var(--p-high)}
|
||||
.page{padding-inline:16px;padding-block:0 32px}
|
||||
.page{padding-inline:24px;padding-block:0 32px;max-width:1440px;margin-inline:auto}
|
||||
.page--board{max-width:none}
|
||||
@media (max-width:639px){.page{padding-inline:16px}}
|
||||
.login{max-width:560px;margin:48px auto}.login form{display:flex;gap:8px}.login input{flex:1;font:inherit;height:34px;padding:0 10px;border:1px solid var(--line);border-radius:6px;background:var(--card);color:var(--fg)}.login .err{color:var(--p-crit)}
|
||||
:focus-visible{outline:2px solid var(--color-focus-ring);outline-offset:2px}
|
||||
@media (prefers-reduced-motion: reduce){*{transition:none!important;animation:none!important}}
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user