From 66bcb7e293782fb46631d28234e250bfaecb9b6b Mon Sep 17 00:00:00 2001 From: Andrey Epifancev Date: Wed, 14 Oct 2026 23:14:13 +0400 Subject: [PATCH] =?UTF-8?q?feat(web):=20=D1=81=D1=82=D1=80=D0=B0=D0=BD?= =?UTF-8?q?=D0=B8=D1=86=D0=B0=20=D0=B4=D0=BE=D1=81=D0=BA=D0=B8=20=E2=80=94?= =?UTF-8?q?=20=D0=BF=D0=B0=D0=BD=D0=B5=D0=BB=D1=8C,=20=D1=84=D0=B8=D0=BB?= =?UTF-8?q?=D1=8C=D1=82=D1=80=D1=8B,=20=D0=BB=D0=B5=D0=B3=D0=B5=D0=BD?= =?UTF-8?q?=D0=B4=D0=B0,=20=D0=BF=D1=80=D0=BE=D0=B1=D0=BB=D0=B5=D0=BC?= =?UTF-8?q?=D1=8B,=20=D1=82=D0=B0=D0=B1=D0=BB=D0=B8=D1=86=D0=B0,=20=D1=8D?= =?UTF-8?q?=D0=BA=D1=81=D0=BF=D0=BE=D1=80=D1=82=20(#21)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Заглушка модели в useBoardSource стабильна между рендерами, readTheme не возвращает null. Co-Authored-By: Claude Opus 5.5 --- web/src/board/BoardPage.test.tsx | 135 ++++++++++++++++++++ web/src/board/BoardPage.tsx | 92 ++++++++++++- web/src/board/board.css | 1 + web/src/board/components/BoardCanvas.tsx | 9 +- web/src/board/components/Dropdown.tsx | 23 ++++ web/src/board/components/ExportBox.tsx | 20 +++ web/src/board/components/FilterBar.tsx | 27 ++++ web/src/board/components/Legend.tsx | 25 ++++ web/src/board/components/LinkTable.tsx | 13 ++ web/src/board/components/MultiSelect.tsx | 40 ++++++ web/src/board/components/ProblemsDrawer.tsx | 30 +++++ web/src/board/components/Toolbar.tsx | 84 ++++++++++++ web/src/board/model/util.ts | 1 + web/src/board/useBoardPrefs.ts | 67 ++++++++++ web/src/board/useBoardSource.ts | 7 +- web/src/shell/theme.test.ts | 3 + web/src/shell/theme.ts | 2 +- 17 files changed, 573 insertions(+), 6 deletions(-) create mode 100644 web/src/board/BoardPage.test.tsx create mode 100644 web/src/board/components/Dropdown.tsx create mode 100644 web/src/board/components/ExportBox.tsx create mode 100644 web/src/board/components/FilterBar.tsx create mode 100644 web/src/board/components/Legend.tsx create mode 100644 web/src/board/components/LinkTable.tsx create mode 100644 web/src/board/components/MultiSelect.tsx create mode 100644 web/src/board/components/ProblemsDrawer.tsx create mode 100644 web/src/board/components/Toolbar.tsx create mode 100644 web/src/board/useBoardPrefs.ts diff --git a/web/src/board/BoardPage.test.tsx b/web/src/board/BoardPage.test.tsx new file mode 100644 index 0000000..19fc5ed --- /dev/null +++ b/web/src/board/BoardPage.test.tsx @@ -0,0 +1,135 @@ +import { fireEvent, 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 { beforeEach, describe, expect, it } from "vitest"; +import { setToken } from "../auth/token.ts"; +import { json, mockFetch } from "../test/fetchMock.ts"; +import { renderWithProviders } from "../test/render.tsx"; +import settings from "./__fixtures__/settings.json"; +import { BoardPage } from "./BoardPage.tsx"; + +const fx = (name: string) => readFileSync(join(import.meta.dirname, "__fixtures__", name), "utf8"); +const SNAP = JSON.parse(fx("hub-basic.board.json")); + +function hub(over: Record Response> = {}) { + return mockFetch((method, url) => { + const k = `${method} ${url.pathname}`; + if (over[k]) return over[k](); + if (k === "GET /settings") return json(200, settings); + if (k === "GET /board") return json(200, SNAP); + if (k === "GET /batches") return json(200, { batches: [] }); + if (k === "GET /agent") return json(200, { online: true }); + 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: {} }); + return json(404, { error: "not_found" }); + }); +} +const page = () => renderWithProviders(, { route: "/board", path: "/board" }); + +describe("страница доски", () => { + beforeEach(() => setToken("tok")); + + it("строит доску из хаба: источник, статистика, выбор самого наполненного спринта", async () => { + hub(); + page(); + expect(await screen.findByText(/^Хаб · данные на/)).toBeInTheDocument(); + expect(screen.getByText(/задач из 9/)).toBeInTheDocument(); + expect(screen.getByRole("button", { name: /AWPCA_Спринт 2/ })).toBeInTheDocument(); + }); + + it("пустые словари в настройках — сообщение, а не пустая доска", async () => { + hub({ "GET /settings": () => json(200, { kind_labels: {}, link_types: {} }) }); + page(); + expect(await screen.findByText(/В настройках хаба нет kind_labels и link_types/)).toBeInTheDocument(); + expect(document.querySelector(".board")).toBeNull(); + }); + + it("переключатели и легенда сохраняются под прежними ключами", async () => { + hub(); + page(); + await screen.findByText(/задач из 9/); + const nb = screen.getByRole("button", { name: "+ соседи по связям" }); + await userEvent.click(nb); + expect(nb).toHaveAttribute("aria-pressed", "true"); + expect(localStorage.getItem("graph-neighbors")).toBe("1"); + await userEvent.click(within(document.querySelector(".legend") as HTMLElement).getByRole("button", { name: /разработка/ })); + expect(localStorage.getItem("graph-hidekind")).toBe('["dev"]'); + }); + + it("«Проблемы» открывают ящик, счётчик — сроки, исполнители, статусы", async () => { + hub(); + page(); + await screen.findByText(/задач из 9/); + await userEvent.click(screen.getByRole("button", { name: /Проблемы/ })); + expect(screen.getByText("Проблемы на доске")).toBeInTheDocument(); + expect(localStorage.getItem("graph-prob")).toBe("1"); + }); + + it("экспорт JSON показывает выгрузку arm-ka-links/1 с раскладкой", async () => { + hub(); + page(); + await screen.findByText(/задач из 9/); + await userEvent.click(screen.getByTitle("Ещё")); + await userEvent.click(screen.getByRole("button", { name: "Экспорт JSON" })); + const text = (screen.getByRole("textbox") as HTMLTextAreaElement).value; + expect(JSON.parse(text)).toMatchObject({ format: "arm-ka-links/1", source: "Хаб" }); + expect(JSON.parse(text).nodes.find((n: { key: string }) => n.key === "AURORA-9001").layout).not.toBeNull(); + }); + + it("сброс раскладки — с подтверждением в меню", async () => { + hub(); + localStorage.setItem("graph-pos:board", '{"AURORA-9001":{"x":1,"ry":40}}'); + page(); + await screen.findByText(/задач из 9/); + await userEvent.click(screen.getByTitle("Ещё")); + await userEvent.click(screen.getByRole("button", { name: "Сбросить раскладку" })); + await userEvent.click(screen.getByRole("button", { name: "Да, сбросить" })); + expect(localStorage.getItem("graph-pos:board")).toBeNull(); + }); + + it("загрузка CSV — источник «из файла»", async () => { + hub(); + page(); + await screen.findByText(/задач из 9/); + const input = document.querySelector('input[type="file"]') as HTMLInputElement; + fireEvent.change(input, { target: { files: [new File([fx("jira-export.csv")], "jira-export.csv", { type: "text/csv" })] } }); + expect(await screen.findByText("jira-export.csv (из файла)")).toBeInTheDocument(); + }); + + it("тема переключается по кругу и применяется к документу", async () => { + hub(); + page(); + await screen.findByText(/задач из 9/); + 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"); + }); + + 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 () => { + 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(); + }); + + it("пустой link_types при заполненном kind_labels — называет только недостающий словарь", async () => { + hub({ "GET /settings": () => json(200, { kind_labels: { dev: "разработка" }, link_types: {} }) }); + page(); + expect(await screen.findByText(/В настройках хаба нет link_types —/)).toBeInTheDocument(); + }); +}); diff --git a/web/src/board/BoardPage.tsx b/web/src/board/BoardPage.tsx index 202112f..03ae483 100644 --- a/web/src/board/BoardPage.tsx +++ b/web/src/board/BoardPage.tsx @@ -1,2 +1,90 @@ -// временная страница (заменяется в Task 11) -export function BoardPage() { return
; } +import { useLayoutEffect, useMemo, useRef, useState } from "react"; +import { useSettings } from "../api/queries.ts"; +import { computeVisible, emptyReason } from "./analysis/visible.ts"; +import { BoardCanvas } from "./components/BoardCanvas.tsx"; +import { ExportBox } from "./components/ExportBox.tsx"; +import { FilterBar } from "./components/FilterBar.tsx"; +import { Legend } from "./components/Legend.tsx"; +import { LinkTable } from "./components/LinkTable.tsx"; +import { problemCount, ProblemsDrawer } from "./components/ProblemsDrawer.tsx"; +import { Notice, Toolbar } from "./components/Toolbar.tsx"; +import { dictsFromSettings } from "./dicts.ts"; +import { exportFileName, exportJson } from "./exportJson.ts"; +import { todayIso } from "./model/util.ts"; +import { useBoardPrefs } from "./useBoardPrefs.ts"; +import { useBoardSource } from "./useBoardSource.ts"; +import "./board.css"; + +export function BoardPage() { + const settings = useSettings(); + const d = useMemo(() => (settings.data ? dictsFromSettings(settings.data) : null), [settings.data]); + const src = useBoardSource(d && !d.missing.length ? d.dicts : null); + const prefs = useBoardPrefs(src.model); + const today = todayIso(); + const vis = useMemo(() => computeVisible(src.model, prefs.filters, prefs.store.laneOrder, today), + [src.model, prefs.filters, prefs.store.laneOrder, today]); + const [exportText, setExportText] = useState(null); + const saveRef = useRef<(() => void) | null>(null); + const headRef = useRef(null); + + // высота закреплённой шапки — ящик «Проблемы» и полоса дат липнут под ней (строки 1016–1019) + useLayoutEffect(() => { + const el = headRef.current; + if (!el) return; + const set = () => document.documentElement.style.setProperty("--head-h", (getComputedStyle(el).position === "sticky" ? el.offsetHeight : 0) + "px"); + set(); + if (typeof ResizeObserver === "undefined") return; + const ro = new ResizeObserver(set); + ro.observe(el); + return () => ro.disconnect(); + }); + + if (settings.isPending) return

Загрузка настроек хаба…

; + if (settings.error) return

Настройки хаба не загрузились: {settings.error.message}

; + if (d && d.missing.length) { + return ( +
+ В настройках хаба нет {d.missing.join(" и ")} — доска не может перевести виды работ и типы связей Jira. Задайте + словари в config.example.json и выполните uv run jira-hub init --config … или запишите их через PUT /settings. +
+ ); + } + + const onExport = () => { + saveRef.current?.(); + const now = new Date().toISOString(); + const text = JSON.stringify(exportJson(src.model, vis, prefs.filters, now), null, 2); + setExportText(text); + try { // в обычном браузере файл скачается; если нет — остаются текст и «Копировать» + const a = document.createElement("a"); + a.href = URL.createObjectURL(new Blob([text], { type: "application/json" })); + a.download = exportFileName(src.model, now); + document.body.appendChild(a); a.click(); + setTimeout(() => { URL.revokeObjectURL(a.href); a.remove(); }, 1000); + } catch { /* остаётся текст */ } + }; + const onFile = (name: string, text: string) => { const r = src.loadFile(name, text); if (r) prefs.applyJson(r); }; + + return ( +
+
+ void src.refreshFromJira()} onFile={onFile} + onProblems={() => prefs.toggle("problems")} onExport={onExport} onTable={() => prefs.toggle("table")} + onSnap={() => prefs.toggle("snap")} onReset={prefs.resetLayout} /> + + + +
+ {exportText !== null && setExportText(null)} />} + prefs.moveLane(vis.lanes.map((l) => l.name), name, to)} + onColWidth={prefs.setColWidth} saveRef={saveRef}> + {prefs.problems && prefs.toggle("problems")} />} + + {prefs.table && } +
+ ); +} diff --git a/web/src/board/board.css b/web/src/board/board.css index 53faf54..491ec2e 100644 --- a/web/src/board/board.css +++ b/web/src/board/board.css @@ -159,3 +159,4 @@ td.k{font-family:var(--font-mono);font-size:12px;white-space:nowrap} .measure{position:absolute;left:-100000px;top:0;visibility:hidden;pointer-events:none} .measure .card{position:static;height:auto} .measure .card .title{-webkit-line-clamp:unset} +.top h1{font-size:15px;font-weight:600;margin:0} diff --git a/web/src/board/components/BoardCanvas.tsx b/web/src/board/components/BoardCanvas.tsx index f213dda..e82b765 100644 --- a/web/src/board/components/BoardCanvas.tsx +++ b/web/src/board/components/BoardCanvas.tsx @@ -14,13 +14,14 @@ export interface LayoutStore { laneOrder: string[]; folded: Set; colWidt export interface BoardCanvasProps { vis: Visible; byDate: boolean; snap: boolean; today: string; store: LayoutStore; sprintSel: Set; emptyText: string; + saveRef?: { current: (() => void) | null }; onToggleLane(name: string): void; onMoveLane(name: string, to: number): void; onColWidth(date: string, w: number | null): void; children?: ReactNode; } -export function BoardCanvas({ vis, byDate, snap, today, store, sprintSel, emptyText, onToggleLane, onMoveLane, onColWidth, children }: BoardCanvasProps) { +export function BoardCanvas({ vis, byDate, snap, today, store, sprintSel, emptyText, onToggleLane, onMoveLane, onColWidth, saveRef, children }: BoardCanvasProps) { const boardRef = useRef(null); const measureRef = useRef(null); const wrapRef = useRef(null); @@ -49,6 +50,12 @@ export function BoardCanvas({ vis, byDate, snap, today, store, sprintSel, emptyT const placed = useMemo(() => (layout ? place(layout, rel, store.folded) : null), [layout, rel, store.folded]); const live = useRef({ placed, rel, layout }); useLayoutEffect(() => { live.current = { placed, rel, layout }; }); + // экспорт JSON сохраняет текущую раскладку (в том числе автоматическую) перед выгрузкой + useLayoutEffect(() => { + if (!saveRef) return; + saveRef.current = () => { const { rel: r, layout: lay } = live.current; if (lay) saveLayout(r, lay.colBounds, byDate); }; + return () => { saveRef.current = null; }; + }, [saveRef, byDate]); // перетаскивание за рамку и растяжка за уголок (строки 753–778): направляющие, сетка, не выше своей дорожки const onCardDown = useCallback((ev: PointerEvent, key: string) => { diff --git a/web/src/board/components/Dropdown.tsx b/web/src/board/components/Dropdown.tsx new file mode 100644 index 0000000..6a87228 --- /dev/null +++ b/web/src/board/components/Dropdown.tsx @@ -0,0 +1,23 @@ +import { useEffect, useRef, useState, type ReactNode } from "react"; + +// выпадающий список: кнопка открывает, щелчок вне или Escape закрывает (строки 897–904) +export function Dropdown({ button, className = "", children, onOpen }: { + button: (p: { open: boolean; toggle(): void }) => ReactNode; className?: string; + children: (close: () => void) => ReactNode; onOpen?: () => void; +}) { + const [open, setOpen] = useState(false); + const ref = useRef(null); + useEffect(() => { + if (!open) return; + const away = (e: PointerEvent) => { if (!ref.current?.contains(e.target as Node)) setOpen(false); }; + document.addEventListener("pointerdown", away); + return () => document.removeEventListener("pointerdown", away); + }, [open]); + const toggle = () => setOpen((o) => { if (!o) onOpen?.(); return !o; }); + return ( +
{ if (e.key === "Escape") setOpen(false); }}> + {button({ open, toggle })} + {open &&
{children(() => setOpen(false))}
} +
+ ); +} diff --git a/web/src/board/components/ExportBox.tsx b/web/src/board/components/ExportBox.tsx new file mode 100644 index 0000000..0fa491c --- /dev/null +++ b/web/src/board/components/ExportBox.tsx @@ -0,0 +1,20 @@ +import { useState } from "react"; + +export function ExportBox({ text, onClose }: { text: string; onClose(): void }) { + const [copied, setCopied] = useState(false); + const copy = async (ta: HTMLTextAreaElement | null) => { + try { await navigator.clipboard.writeText(text); setCopied(true); setTimeout(() => setCopied(false), 1500); } + catch { ta?.focus(); ta?.select(); } + }; + let ta: HTMLTextAreaElement | null = null; + return ( +
+
+ JSON доски: все задачи, связи, раскладка, дорожки и фильтры. Загружается обратно ссылкой «файл…». Если файл не скачался, скопируйте текст. + + +
+