From 3e2f252aac31ff19eb7af251cb4f7e2cef117d8a Mon Sep 17 00:00:00 2001 From: Andrey Epifancev Date: Thu, 22 Oct 2026 00:23:39 +0400 Subject: [PATCH] =?UTF-8?q?feat(web):=20=D0=B2=D0=B5=D1=80=D1=85=D0=BD?= =?UTF-8?q?=D1=8F=D1=8F=20=D0=BF=D0=B0=D0=BD=D0=B5=D0=BB=D1=8C=20=E2=80=94?= =?UTF-8?q?=20=D1=80=D0=B0=D0=B7=D0=B4=D0=B5=D0=BB=D1=8B,=20=D0=B4=D0=B0?= =?UTF-8?q?=D0=BD=D0=BD=D1=8B=D0=B5=20Jira,=20=D0=BE=D0=B1=D0=BD=D0=BE?= =?UTF-8?q?=D0=B2=D0=BB=D0=B5=D0=BD=D0=B8=D0=B5,=20=D1=82=D0=B5=D0=BC?= =?UTF-8?q?=D0=B0=20(#65)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5.5 --- web/e2e/board.spec.ts | 4 +- web/e2e/hub.ts | 2 +- web/src/App.test.tsx | 7 +- web/src/api/queries.ts | 5 +- web/src/board/BoardPage.test.tsx | 25 ++---- web/src/board/components/Toolbar.tsx | 12 +-- web/src/issues/IssueModal.test.tsx | 6 +- web/src/projects/NewProjectDialog.tsx | 4 +- web/src/projects/ProjectPicker.tsx | 5 +- web/src/projects/projects.css | 2 +- web/src/projects/projects.test.tsx | 9 +- web/src/shell/AppBar.test.tsx | 120 ++++++++++++++++++++++++++ web/src/shell/AppBar.tsx | 28 ++++++ web/src/shell/DataStatus.tsx | 31 +++++++ web/src/shell/Layout.tsx | 26 ++---- web/src/shell/RefreshProvider.tsx | 4 +- web/src/shell/refresh.test.tsx | 23 ++++- web/src/shell/shell.css | 34 ++++++-- web/src/shell/theme.ts | 1 - web/src/styles/base.css | 4 +- 20 files changed, 275 insertions(+), 77 deletions(-) create mode 100644 web/src/shell/AppBar.test.tsx create mode 100644 web/src/shell/AppBar.tsx create mode 100644 web/src/shell/DataStatus.tsx diff --git a/web/e2e/board.spec.ts b/web/e2e/board.spec.ts index fea09f1..644d640 100644 --- a/web/e2e/board.spec.ts +++ b/web/e2e/board.spec.ts @@ -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); }); diff --git a/web/e2e/hub.ts b/web/e2e/hub.ts index 621bc7d..86eff72 100644 --- a/web/e2e/hub.ts +++ b/web/e2e/hub.ts @@ -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) { diff --git a/web/src/App.test.tsx b/web/src/App.test.tsx index 070faf4..611e12c 100644 --- a/web/src/App.test.tsx +++ b/web/src/App.test.tsx @@ -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(); + 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(); 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(); - expect(screen.queryByRole("link", { name: "Доска" })).not.toBeInTheDocument(); + expect(screen.queryByRole("link", { name: "Доска связей" })).not.toBeInTheDocument(); }); }); diff --git a/web/src/api/queries.ts b/web/src/api/queries.ts index d44bb2b..0ec0b20 100644 --- a/web/src/api/queries.ts +++ b/web/src/api/queries.ts @@ -19,8 +19,9 @@ export const qk = { export const useSettings = () => useQuery({ queryKey: qk.settings, queryFn: () => api.get("/settings"), staleTime: Infinity }); -export const useBoard = (project: string) => - useQuery({ queryKey: qk.board(project), queryFn: () => api.get("/board", { project }) }); +// project === null — проект ещё не выбран: запроса нет +export const useBoard = (project: string | null) => + useQuery({ queryKey: qk.board(project ?? ""), enabled: project !== null, queryFn: () => api.get("/board", { project: project! }) }); export const useProjects = () => useQuery({ queryKey: qk.projects, queryFn: () => api.get<{ projects?: Project[] }>("/projects").then((r) => r.projects ?? []) }); diff --git a/web/src/board/BoardPage.test.tsx b/web/src/board/BoardPage.test.tsx index afa9e82..5e70356 100644 --- a/web/src/board/BoardPage.test.tsx +++ b/web/src/board/BoardPage.test.tsx @@ -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 () => { diff --git a/web/src/board/components/Toolbar.tsx b/web/src/board/components/Toolbar.tsx index f367126..8701d00 100644 --- a/web/src/board/components/Toolbar.tsx +++ b/web/src/board/components/Toolbar.tsx @@ -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(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 (

Доска связей

{p.label} -