From cec8beecfbad8d450521fe755fda99a078918b55 Mon Sep 17 00:00:00 2001 From: Andrey Epifancev Date: Thu, 22 Oct 2026 00:33:32 +0400 Subject: [PATCH] =?UTF-8?q?feat(web):=20=D1=88=D0=B0=D0=BF=D0=BA=D0=B0=20?= =?UTF-8?q?=D0=B4=D0=BE=D1=81=D0=BA=D0=B8,=20=D0=BF=D0=B0=D0=BD=D0=B5?= =?UTF-8?q?=D0=BB=D1=8C=20=D1=83=D0=BF=D1=80=D0=B0=D0=B2=D0=BB=D0=B5=D0=BD?= =?UTF-8?q?=D0=B8=D1=8F=20=D0=BF=D0=BE=20=D0=B3=D1=80=D1=83=D0=BF=D0=BF?= =?UTF-8?q?=D0=B0=D0=BC,=20=D1=81=D0=B2=D0=BE=D1=80=D0=B0=D1=87=D0=B8?= =?UTF-8?q?=D0=B2=D0=B0=D0=B5=D0=BC=D0=B0=D1=8F=20=D0=BB=D0=B5=D0=B3=D0=B5?= =?UTF-8?q?=D0=BD=D0=B4=D0=B0=20(#65)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit BoardHeader вместо Toolbar, панель управления в Panel, легенда сворачивается (jira-hub-legend), ящик проблем, экспорт и таблица связей в Panel. Dropdown: явный проп menu; тест фонового обновления проектов мокает GET /batches/7. Co-Authored-By: Claude Opus 5.5 --- web/e2e/board.spec.ts | 2 +- web/e2e/geometry.spec.ts | 10 +-- web/src/board/BoardPage.test.tsx | 59 +++++++++++++-- web/src/board/BoardPage.tsx | 15 ++-- web/src/board/board.css | 72 +++++++++++------- web/src/board/components/BoardHeader.tsx | 84 +++++++++++++++++++++ web/src/board/components/ExportBox.tsx | 11 +-- web/src/board/components/FilterBar.tsx | 46 +++++++---- web/src/board/components/Legend.tsx | 28 ++++--- web/src/board/components/LinkTable.tsx | 7 +- web/src/board/components/MultiSelect.tsx | 14 ++-- web/src/board/components/ProblemsDrawer.tsx | 7 +- web/src/board/components/Toolbar.tsx | 78 ------------------- web/src/board/legendPref.ts | 10 +++ web/src/projects/projects.test.tsx | 1 + web/src/styles/controls.css | 2 - web/src/ui/Menu.tsx | 16 ++-- web/src/ui/inputs.tsx | 4 +- 18 files changed, 288 insertions(+), 178 deletions(-) create mode 100644 web/src/board/components/BoardHeader.tsx delete mode 100644 web/src/board/components/Toolbar.tsx create mode 100644 web/src/board/legendPref.ts diff --git a/web/e2e/board.spec.ts b/web/e2e/board.spec.ts index 644d640..61c3c35 100644 --- a/web/e2e/board.spec.ts +++ b/web/e2e/board.spec.ts @@ -35,7 +35,7 @@ test("растяжка за уголок и возврат размера дво }); test("«по датам» — колонки дат, подписи и стрелки", async ({ page }) => { - await page.getByRole("button", { name: "▥ по датам" }).click(); + await page.getByRole("button", { name: "По датам" }).click(); await expect(page.locator(".dcol").first()).toBeVisible(); await expect(page.locator(".dates-track .dlabel").first()).toBeVisible(); await expect(page.locator("svg.edges path")).not.toHaveCount(0); diff --git a/web/e2e/geometry.spec.ts b/web/e2e/geometry.spec.ts index 87c09f4..2be7084 100644 --- a/web/e2e/geometry.spec.ts +++ b/web/e2e/geometry.spec.ts @@ -28,7 +28,7 @@ test("высота карточек берётся по содержимому test("при открытой панели «Проблемы» полоса дат по ширине равна обёртке холста", async ({ page }) => { await login(page); - await page.getByRole("button", { name: "▥ по датам" }).click(); + await page.getByRole("button", { name: "По датам" }).click(); await expect(page.locator(".dates-in")).toBeVisible(); for (const open of [false, true]) { if (open) { @@ -47,22 +47,22 @@ test("смена спринта и исполнителя, убирающая к await login(page); await expect(page.locator(".board .card[data-key]").first()).toBeVisible(); const total = await page.locator(".board .card[data-key]").count(); - const dd = (i: number) => page.locator(".bar .dd-btn").nth(i); // 0 — спринты, 1 — исполнители + const dd = (i: number) => page.locator(".board-controls .multi-btn").nth(i); // 0 — спринты, 1 — исполнители for (const [i, name] of [[0, "Спринт 3"], [1, "Сидорова"]] as const) { await dd(i).click(); await page.getByRole("button", { name: "Снять выбор" }).click(); await page.locator(".dd-item", { hasText: name }).first().locator("input").check(); await page.keyboard.press("Escape"); await expect(page.locator("h1")).toHaveText("Доска связей"); - await expect(page.locator(".top")).toBeVisible(); + await expect(page.locator(".sticky .ui-page-header")).toBeVisible(); await expect(page.locator(".board")).toBeVisible(); await expect.poll(() => page.locator(".board .card[data-key]:not(.dim)").count(), `фильтр «${name}»`).toBeLessThan(total); await dd(i).click(); await page.getByRole("button", { name: "Снять выбор" }).click(); await page.keyboard.press("Escape"); } - await page.getByRole("button", { name: "▥ по датам" }).click(); - await expect(page.locator(".top")).toBeVisible(); + await page.getByRole("button", { name: "По датам" }).click(); + await expect(page.locator(".sticky .ui-page-header")).toBeVisible(); expect(errs).toEqual([]); }); diff --git a/web/src/board/BoardPage.test.tsx b/web/src/board/BoardPage.test.tsx index 5e70356..bdce9f7 100644 --- a/web/src/board/BoardPage.test.tsx +++ b/web/src/board/BoardPage.test.tsx @@ -63,7 +63,7 @@ describe("страница доски", () => { hub(); page(); await screen.findByText(/задач из 9/); - const nb = screen.getByRole("button", { name: "+ соседи по связям" }); + const nb = screen.getByRole("button", { name: "Соседи по связям" }); await userEvent.click(nb); expect(nb).toHaveAttribute("aria-pressed", "true"); expect(localStorage.getItem("graph-neighbors")).toBe("1"); @@ -78,13 +78,23 @@ describe("страница доски", () => { await userEvent.click(screen.getByRole("button", { name: /Проблемы/ })); expect(screen.getByText("Проблемы на доске")).toBeInTheDocument(); expect(localStorage.getItem("graph-prob")).toBe("1"); + expect(screen.getByRole("button", { name: /^Проблемы/ })).toHaveAttribute("aria-pressed", "true"); + }); + + it("кнопка «Проблемы» несёт счётчик и aria-pressed", async () => { + hub(); + page(); + await screen.findByText(/задач из 9/); + const b = screen.getByRole("button", { name: /^Проблемы/ }); + expect(b).toHaveAttribute("aria-pressed", "false"); + expect(within(b).getByText(/^\d+$/)).toBeInTheDocument(); }); 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: "Ещё" })); 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: "Хаб" }); @@ -96,7 +106,7 @@ describe("страница доски", () => { 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: "Сбросить раскладку" })); await userEvent.click(screen.getByRole("button", { name: "Да, сбросить" })); expect(localStorage.getItem("graph-pos:board")).toBeNull(); @@ -108,7 +118,42 @@ describe("страница доски", () => { 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(); + expect(await screen.findByText("из файла: jira-export.csv")).toBeInTheDocument(); + await userEvent.click(screen.getByRole("button", { name: "Вернуться к данным хаба" })); + expect(await screen.findByText(/^Хаб · данные на/)).toBeInTheDocument(); + expect(screen.queryByText(/^из файла:/)).not.toBeInTheDocument(); + }); + + it("«Загрузить файл…» в меню «Ещё» открывает выбор файла", async () => { + hub(); + page(); + await screen.findByText(/задач из 9/); + const input = document.querySelector('input[type="file"]') as HTMLInputElement; + const click = vi.spyOn(input, "click"); + await userEvent.click(screen.getByRole("button", { name: "Ещё" })); + await userEvent.click(screen.getByRole("button", { name: "Загрузить файл…" })); + expect(click).toHaveBeenCalled(); + }); + + it("свёрнутая легенда: скрыт «тестирование», «Легенда» свёрнута, после перемонтирования свёрнута, «скрыто: 1»", async () => { + hub(); + const r = page(); + await screen.findByText(/задач из 9/); + const legend = () => document.querySelector(".legend"); + await userEvent.click(within(legend() as HTMLElement).getByRole("button", { name: /тестирование/ })); + expect(screen.getByText("скрыто: 1")).toBeInTheDocument(); + const toggle = screen.getByRole("button", { name: /^Легенда/ }); + expect(toggle).toHaveAttribute("aria-expanded", "true"); + await userEvent.click(toggle); + expect(toggle).toHaveAttribute("aria-expanded", "false"); + expect(legend()).toBeNull(); + expect(localStorage.getItem("jira-hub-legend")).toBe("0"); + r.unmount(); + page(); + await screen.findByText(/задач из 9/); + expect(screen.getByRole("button", { name: /^Легенда/ })).toHaveAttribute("aria-expanded", "false"); + expect(legend()).toBeNull(); + expect(screen.getByText("скрыто: 1")).toBeInTheDocument(); }); it("доска не рисует «Обновить из Jira» и ход пакета: они в верхней панели", async () => { @@ -116,7 +161,9 @@ describe("страница доски", () => { 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(screen.getByRole("button", { name: "Ещё" })).toHaveAttribute("aria-expanded", "true"); + expect(screen.getByRole("button", { name: "Перечитать" })).toBeInTheDocument(); // меню действительно открыто expect(screen.queryByRole("button", { name: /^Тема/ })).not.toBeInTheDocument(); }); it("заголовок страницы — h1, в шапке label; строка состояния — только события доски", async () => { @@ -147,7 +194,7 @@ describe("страница доски", () => { renderWithProviders(, { route: "/board", path: "/board" }); await screen.findByText(/^Хаб · данные на/); const set = vi.spyOn(Storage.prototype, "setItem"); - fireEvent.click(screen.getByRole("button", { name: /критический путь/ })); + fireEvent.click(screen.getByRole("button", { name: "Критический путь" })); expect(set.mock.calls.filter(([k]) => k === "graph-crit")).toEqual([["graph-crit", "1"]]); }); diff --git a/web/src/board/BoardPage.tsx b/web/src/board/BoardPage.tsx index a075816..ee9f1e6 100644 --- a/web/src/board/BoardPage.tsx +++ b/web/src/board/BoardPage.tsx @@ -8,10 +8,12 @@ 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 { Notice } from "../ui/index.ts"; +import { BoardHeader, BoardNotes } from "./components/BoardHeader.tsx"; import { dictsFromSettings } from "./dicts.ts"; import { exportFileName, exportJson } from "./exportJson.ts"; import { todayIso } from "./model/util.ts"; +import { readLegendOpen, saveLegendOpen } from "./legendPref.ts"; import { useBoardPrefs } from "./useBoardPrefs.ts"; import { useBoardSource } from "./useBoardSource.ts"; import "./board.css"; @@ -26,6 +28,7 @@ export function BoardPage({ project }: { project: string }) { 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 [legendOpen, setLegendOpen] = useState(readLegendOpen); const saveRef = useRef<(() => void) | null>(null); // высота закреплённой шапки — ящик «Проблемы» и полоса дат липнут под ней (строки 1016–1019); при уходе — снимаем const headRef = useCallback((el: HTMLDivElement | null) => { @@ -42,10 +45,10 @@ export function BoardPage({ project }: { project: string }) { 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. -
+
); } @@ -67,14 +70,14 @@ export function BoardPage({ project }: { project: string }) { return (
- prefs.toggle("problems")} onExport={onExport} onTable={() => prefs.toggle("table")} onSnap={() => prefs.toggle("snap")} onReset={prefs.resetLayout} /> - - + + { const n = !legendOpen; setLegendOpen(n); saveLegendOpen(n); }} />
{exportText !== null && setExportText(null)} />} span:empty{display:none} #hubState{white-space:normal;min-width:0} .login{max-width:560px;margin:48px auto;padding:20px 24px;background:var(--panel);border:1px solid var(--line);border-radius:8px} .login h2{margin:0 0 8px;font-size:17px;font-weight:600} @@ -128,32 +144,31 @@ svg.guides line{stroke:var(--guide);stroke-width:1;stroke-dasharray:4 3} .who.none::before{background:transparent;border:1px solid var(--muted)} /* 7. Панель проблем справа от доски, липнет под шапкой и прокручивается внутри себя */ -.drawer{flex:0 0 340px;max-width:40%;background:var(--panel);border:1px solid var(--line);border-radius:8px;padding:10px 12px;overflow:auto;font-size:14px;box-sizing:border-box;align-self:flex-start;position:sticky;top:calc(var(--head-h,0px) + 8px);max-height:calc(100vh - var(--head-h,0px) - 24px)} -.drawer-head{display:flex;justify-content:space-between;align-items:center;gap:8px} -.drawer-head b{font-weight:600} -.drawer h4{font-size:12px;letter-spacing:.06em;text-transform:uppercase;color:var(--muted);font-weight:500;margin:12px 0 4px} +.drawer{flex:0 0 340px;max-width:40%;overflow:auto;box-sizing:border-box;align-self:flex-start;position:sticky;top:calc(var(--head-h,0px) + 8px);max-height:calc(100vh - var(--head-h,0px) - 24px);font-size:var(--fs-body)} +.drawer-head{display:flex;justify-content:space-between;align-items:center;gap:var(--space-2)} +.board-h2{margin:0;font-size:var(--fs-lg);line-height:var(--lh-lg);font-weight:600;color:var(--color-text-primary)} +.board-h3{margin:var(--space-3) 0 var(--space-1);font-size:var(--fs-caption);line-height:var(--lh-caption);font-weight:600;color:var(--color-text-secondary)} .drawer ul{margin:0;padding-left:18px} .drawer li{margin:3px 0} -.drawer .k{font-family:var(--font-mono);font-size:13px;color:var(--accent);text-decoration:none} -.drawer .ok{color:var(--k-test);margin:8px 0 0} +.drawer .k{font-family:var(--font-mono);font-size:13px;color:var(--color-action-primary);text-decoration:none} +.drawer .ok{color:var(--color-status-success);margin:var(--space-2) 0 0} .drawer .why{color:var(--muted)} -/* 8. Экспорт JSON и таблица связей (включаются из меню «⋯») */ -.export{background:var(--panel);border:1px solid var(--line);border-radius:8px;padding:10px 12px;margin:0 0 8px;display:flex;flex-direction:column;gap:8px} -.export textarea{font-family:var(--font-mono);font-size:11.5px;width:100%;box-sizing:border-box;height:180px;border:1px solid var(--line);border-radius:6px;background:var(--card);color:var(--fg);padding:8px;resize:vertical} -details{margin-top:12px} -summary{cursor:pointer;color:var(--muted);font-size:14px} -.tbl{max-width:100%;overflow-x:auto;margin-top:8px} -table{border-collapse:collapse;font-size:14px;min-width:520px} +/* 8. Экспорт JSON и таблица связей (включаются из меню «Ещё») */ +.export{display:flex;flex-direction:column;gap:var(--space-2);margin:0 0 var(--space-2)} +.export textarea{font-family:var(--font-mono);font-size:11.5px;width:100%;box-sizing:border-box;height:180px;border:1px solid var(--line);border-radius:var(--radius-sm);background:var(--card);color:var(--fg);padding:8px;resize:vertical} +.link-table{margin-top:var(--space-3)} +.tbl{max-width:100%;overflow-x:auto;margin-top:var(--space-2)} +table{border-collapse:collapse;font-size:var(--fs-body);min-width:520px} th,td{text-align:left;padding:6px 10px;border-bottom:1px solid var(--line);vertical-align:top} -th{font-size:11px;letter-spacing:.06em;text-transform:uppercase;color:var(--muted);font-weight:500} -td.k{font-family:var(--font-mono);font-size:12px;white-space:nowrap} +th{font-size:var(--fs-caption);font-weight:600;color:var(--color-text-secondary)} +td.k{font-family:var(--font-mono);font-size:12px;white-space:nowrap;color:var(--color-action-primary)} /* 9. Движение и узкие экраны */ @media (prefers-reduced-motion:no-preference){.card{transition:border-color .15s}} @media (prefers-reduced-motion:reduce){.card .grip,.fold::before{transition:none}} @media (max-width:900px){.stage{flex-direction:column}.drawer{flex:none;max-width:100%;max-height:none;position:static}} -@media (max-width:640px){.sticky{position:static}.dates-bar{position:static}.file-name{max-width:100%}} +@media (max-width:640px){.sticky{position:static}.dates-bar{position:static}} /* «Открыть карточку» в заголовке карточки (R-1) */ .card .open-card{margin-left:6px;color:var(--muted);text-decoration:none;font-size:13px} @@ -162,4 +177,3 @@ 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/BoardHeader.tsx b/web/src/board/components/BoardHeader.tsx new file mode 100644 index 0000000..c2d3fb6 --- /dev/null +++ b/web/src/board/components/BoardHeader.tsx @@ -0,0 +1,84 @@ +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 { 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; + 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; +} + +export function BoardHeader(p: BoardHeaderProps) { + const [confirm, setConfirm] = useState(false); + const fileRef = useRef(null); + useEffect(() => { if (!confirm) return; const t = setTimeout(() => setConfirm(false), 8000); return () => clearTimeout(t); }, [confirm]); + 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 stats = ( + + {coreShown} задач из {m.nodes.filter((n) => !n.stub).length} + {ctxShown > 0 && <> · +{ctxShown} соседи по связям} + {" · "}{v.links.length} связей из {m.links.length} + {r.loose > 0 && <> · скрыто без связей: {r.loose}} + + ); + return ( + + + + {(close) => ( + <> + { close(); p.onReload(); }}>Перечитать + } onSelect={() => { close(); fileRef.current?.click(); }} + title="CSV из фильтра Jira или JSON, выгруженный этой доской. JSON при работе с хабом переносит только расстановку карточек, дорожки и фильтры; CSV показывает снимок файла до «Перечитать»">Загрузить файл… + { close(); p.onExport(); }}>Экспорт JSON + Таблица связей + Привязка к сетке + + {!confirm ? setConfirm(true)}>Сбросить раскладку : ( +
+ Вернуть автоматическую раскладку {p.byDate ? "режима «по датам»" : "(обычную; раскладка «по датам» останется)"}, порядок и раскрытие дорожек? +
+ + +
+
+ )} + + )} +
+ { + const f = e.target.files?.[0]; + e.target.value = ""; + if (f) p.onFile(f.name, await f.text()); + }} /> + + }> +
+ {p.source === "file" ? ( + <> + из файла: {p.fileName} + } onClick={p.onReload} /> + + ) : {p.label}} + {st && {st.text}} +
+
+ ); +} + +// предупреждения о данных под шапкой (строки 878–880) +export function BoardNotes({ model }: { model: BoardModel }) { + const notes = [...model.problems]; + if (model.missing) notes.push(`упомянуты в связях, но в ${model.stubLane === NOT_IN_HUB ? "зеркале хаба" : "файле"} отсутствуют: ${model.missing}`); + if (!notes.length) return null; + return {notes.map((n) =>
{n}
)}
; +} diff --git a/web/src/board/components/ExportBox.tsx b/web/src/board/components/ExportBox.tsx index 0fa491c..9e9a731 100644 --- a/web/src/board/components/ExportBox.tsx +++ b/web/src/board/components/ExportBox.tsx @@ -1,4 +1,5 @@ import { useState } from "react"; +import { Button, Panel } from "../../ui/index.ts"; export function ExportBox({ text, onClose }: { text: string; onClose(): void }) { const [copied, setCopied] = useState(false); @@ -8,13 +9,13 @@ export function ExportBox({ text, onClose }: { text: string; onClose(): void }) }; let ta: HTMLTextAreaElement | null = null; return ( -
+
- JSON доски: все задачи, связи, раскладка, дорожки и фильтры. Загружается обратно ссылкой «файл…». Если файл не скачался, скопируйте текст. - - + JSON доски: все задачи, связи, раскладка, дорожки и фильтры. Загружается обратно пунктом «Загрузить файл…» в меню «Ещё». Если файл не скачался, скопируйте текст. + +