From f8610abc81773dd35c0bb4586b8f10fa891c364b Mon Sep 17 00:00:00 2001 From: Andrey Epifancev Date: Thu, 22 Oct 2026 08:51:23 +0400 Subject: [PATCH] =?UTF-8?q?=D0=91=D0=BE=D0=BA=D0=BE=D0=B2=D0=B0=D1=8F=20?= =?UTF-8?q?=D0=BF=D0=B0=D0=BD=D0=B5=D0=BB=D1=8C=20=D0=B2=D0=BC=D0=B5=D1=81?= =?UTF-8?q?=D1=82=D0=BE=20=D0=B2=D0=B5=D1=80=D1=85=D0=BD=D0=B5=D0=B9:=20?= =?UTF-8?q?=D1=80=D0=B0=D0=B7=D0=B4=D0=B5=D0=BB=D1=8B,=20=D1=81=D0=B2?= =?UTF-8?q?=D1=91=D1=80=D1=82=D0=BA=D0=B0,=20=D0=B4=D0=B0=D0=BD=D0=BD?= =?UTF-8?q?=D1=8B=D0=B5=20Jira=20=D0=B2=D0=BD=D0=B8=D0=B7=D1=83=20(#69)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5.5 --- web/src/projects/projects.css | 2 +- web/src/projects/projects.test.tsx | 2 +- web/src/shell/AppBar.tsx | 28 -------- web/src/shell/DataStatus.tsx | 33 ++++++--- web/src/shell/Layout.tsx | 12 ++-- .../{AppBar.test.tsx => Sidebar.test.tsx} | 54 ++++++++++++-- web/src/shell/Sidebar.tsx | 55 +++++++++++++++ web/src/shell/shell.css | 70 +++++++++++-------- web/src/shell/sidebarPref.ts | 13 ++++ web/src/ui/Button.tsx | 4 +- web/src/ui/Menu.tsx | 6 +- web/src/ui/icons.tsx | 4 ++ web/src/ui/ui.css | 1 + 13 files changed, 202 insertions(+), 82 deletions(-) delete mode 100644 web/src/shell/AppBar.tsx rename web/src/shell/{AppBar.test.tsx => Sidebar.test.tsx} (71%) create mode 100644 web/src/shell/Sidebar.tsx create mode 100644 web/src/shell/sidebarPref.ts diff --git a/web/src/projects/projects.css b/web/src/projects/projects.css index 8870ab4..b666fba 100644 --- a/web/src/projects/projects.css +++ b/web/src/projects/projects.css @@ -1,5 +1,5 @@ /* выбор проекта, служебные состояния выбора и окно «Новый проект» */ -.appbar .project-picker { max-width: 280px; min-width: 160px; } +.sidebar .project-picker { width: 100%; min-width: 0; } .project-gate { max-width: 640px; margin: var(--space-6) 0; } .project-gate .ui-notice { align-items: center; } .project-form { display: flex; flex-direction: column; gap: var(--space-4); } diff --git a/web/src/projects/projects.test.tsx b/web/src/projects/projects.test.tsx index 081d404..66a7966 100644 --- a/web/src/projects/projects.test.tsx +++ b/web/src/projects/projects.test.tsx @@ -111,7 +111,7 @@ describe("проект в приложении", () => { 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(await within(screen.getByRole("complementary", { name: "Панель приложения" })).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" }); diff --git a/web/src/shell/AppBar.tsx b/web/src/shell/AppBar.tsx deleted file mode 100644 index e624093..0000000 --- a/web/src/shell/AppBar.tsx +++ /dev/null @@ -1,28 +0,0 @@ -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); section = null на путях вне разделов — активной ссылки нет (T5.1): название, проект, разделы, состояние данных, тема, меню «Ещё» -export function AppBar({ section, project }: { section: Section | null; project: string | null }) { - const qc = useQueryClient(); - const href = (to: Section) => (project ? to + withProject("", project) : to); - return ( -
- Хаб Jira - - - - - - - {(close) => { close(); clearToken(); qc.clear(); }}>Сменить токен} - -
- ); -} diff --git a/web/src/shell/DataStatus.tsx b/web/src/shell/DataStatus.tsx index dcc4457..ac5a0ba 100644 --- a/web/src/shell/DataStatus.tsx +++ b/web/src/shell/DataStatus.tsx @@ -1,31 +1,48 @@ import { useBoard } from "../api/queries.ts"; import { fmtStamp } from "../board/model/util.ts"; -import { Button, IconRefresh } from "../ui/index.ts"; +import { Button, IconButton, IconRefresh } from "../ui/index.ts"; import { useJiraRefresh } from "./refresh.ts"; import { refreshText } from "./refreshStatus.ts"; -// состояние данных: агент, время последнего полного обновления, кнопка «Обновить из Jira» и ход пакета -export function DataStatus({ project }: { project: string | null }) { +const HINT = "Поставить в хаб пакет «обновление по базовой JQL» и дождаться его: агент перечитает задачи из Jira"; + +// состояние данных: агент, время последнего полного обновления, кнопка «Обновить из Jira» и ход пакета; +// compact — свёрнутая боковая панель: кнопка-значок с точкой агента, тексты в подсказке (#69, design 3) +export function DataStatus({ project, compact = false }: { project: string | null; compact?: boolean }) { 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)}` : "выхода на связь ещё не было"; + const agentText = agent ? (agent.online ? "агент на связи" : "агент офлайн") : null; + const syncText = board.data ? (ls ? `данные на ${fmtStamp(ls.finished_at)}` : "полного обновления не было") : null; + const event = {rt?.text}; + if (compact) { + const title = ["Обновить из Jira", agentText, syncText, rt?.text].filter(Boolean).join(" · "); + return ( +
+
+ ); + } return (
{agent && ( )} - {board.data && {ls ? `данные на ${fmtStamp(ls.finished_at)}` : "полного обновления не было"}} - - {rt?.text} + {event}
); } diff --git a/web/src/shell/Layout.tsx b/web/src/shell/Layout.tsx index 02e0219..0468854 100644 --- a/web/src/shell/Layout.tsx +++ b/web/src/shell/Layout.tsx @@ -7,10 +7,10 @@ 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 { AppBar } from "./AppBar.tsx"; import { ErrorBoundary } from "./ErrorBoundary.tsx"; import { PAGE_ROUTES } from "./pages.tsx"; import { useJiraRefresh } from "./refresh.ts"; +import { Sidebar } from "./Sidebar.tsx"; import "./shell.css"; export function Layout() { @@ -78,10 +78,12 @@ export function Layout() { const refresh = useJiraRefresh(); return ( - -
- {page} -
+
+ +
+ {page} +
+
{issueKey && } {newOpen && setNewOpen(false)} onCreated={(p, batchId) => { setNewOpen(false); refresh.track(batchId); select(p.label); }} />}
diff --git a/web/src/shell/AppBar.test.tsx b/web/src/shell/Sidebar.test.tsx similarity index 71% rename from web/src/shell/AppBar.test.tsx rename to web/src/shell/Sidebar.test.tsx index 65a1ae8..bc9cab9 100644 --- a/web/src/shell/AppBar.test.tsx +++ b/web/src/shell/Sidebar.test.tsx @@ -1,4 +1,4 @@ -import { render, screen, waitFor, within } from "@testing-library/react"; +import { cleanup, 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"; @@ -38,9 +38,9 @@ const open = (path: string) => { render(); return router; }; -const bar = () => screen.getAllByRole("banner")[0]; // верхняя панель — первая; PageHeader страницы без
тоже даёт banner +const bar = () => screen.getByRole("complementary", { name: "Панель приложения" }); -describe("верхняя панель", () => { +describe("боковая панель", () => { beforeEach(() => { setToken("tok"); localStorage.clear(); }); it("разделы — ссылки «Доска связей» и «Задачи», активная с aria-current", async () => { @@ -56,7 +56,7 @@ describe("верхняя панель", () => { hub(); open("/app/nowhere?project=AWPCA"); expect(await screen.findByRole("heading", { name: "Страница не найдена" })).toBeInTheDocument(); - const nav = within(bar()).getByRole("navigation", { name: "Раздел" }); + const nav = within(bar()).getByRole("navigation", { name: "Разделы" }); expect(within(nav).getByRole("link", { name: "Доска связей" })).not.toHaveAttribute("aria-current"); expect(within(nav).getByRole("link", { name: "Задачи" })).not.toHaveAttribute("aria-current"); const main = within(screen.getByRole("main")); @@ -130,4 +130,50 @@ describe("верхняя панель", () => { await screen.findByText(/задач из/); expect(screen.getAllByRole("button", { name: "Обновить из Jira" })).toHaveLength(1); }); + + it("разделы — в навигации «Разделы» боковой панели", async () => { + hub(); + open("/app/board?project=AWPCA"); + const nav = within(bar()).getByRole("navigation", { name: "Разделы" }); + expect(await within(nav).findByRole("link", { name: "Доска связей" })).toHaveAttribute("aria-current", "page"); + }); + + it("«Свернуть панель» сворачивает и запоминает в jira-hub-sidebar; после перезапуска свёрнута, разделы доступны", async () => { + hub(); + open("/app/board?project=AWPCA"); + const btn = await within(bar()).findByRole("button", { name: "Свернуть панель" }); + expect(btn).toHaveAttribute("aria-expanded", "true"); + await userEvent.click(btn); + expect(localStorage.getItem("jira-hub-sidebar")).toBe("collapsed"); + expect(bar()).toHaveClass("is-collapsed"); + cleanup(); + open("/app/board?project=AWPCA"); + expect(bar()).toHaveClass("is-collapsed"); + expect(within(bar()).getByRole("button", { name: "Развернуть панель" })).toHaveAttribute("aria-expanded", "false"); + expect(within(bar()).getByRole("link", { name: "Задачи" })).toHaveAttribute("title", "Задачи"); + await userEvent.click(within(bar()).getByRole("button", { name: "Развернуть панель" })); + expect(localStorage.getItem("jira-hub-sidebar")).toBeNull(); + expect(bar()).not.toHaveClass("is-collapsed"); + }); + + it("свёрнутая: «Обновить из Jira» значком ставит пакет, событие — в role=status и подсказке", async () => { + localStorage.setItem("jira-hub-sidebar", "collapsed"); + const h = hub(); + open("/app/issues?project=AWPCA"); + const btn = await within(bar()).findByRole("button", { name: "Обновить из Jira" }); + await userEvent.click(btn); + await waitFor(() => expect(btn).toBeDisabled()); + expect(h.count("POST", "/refresh")).toBe(1); + expect(await within(bar()).findByRole("status")).toHaveTextContent(/пакет #7 выполняется/); + expect(btn.getAttribute("title")).toMatch(/пакет #7 выполняется/); + }); + + it("свёрнутая: метка проекта видна, щелчок по ней разворачивает панель", async () => { + localStorage.setItem("jira-hub-sidebar", "collapsed"); + hub(); + open("/app/board?project=AWPCA"); + const tag = await within(bar()).findByRole("button", { name: "Проект AWPCA — развернуть панель" }); + await userEvent.click(tag); + expect(bar()).not.toHaveClass("is-collapsed"); + }); }); diff --git a/web/src/shell/Sidebar.tsx b/web/src/shell/Sidebar.tsx new file mode 100644 index 0000000..27d7930 --- /dev/null +++ b/web/src/shell/Sidebar.tsx @@ -0,0 +1,55 @@ +import { useQueryClient } from "@tanstack/react-query"; +import { useState } from "react"; +import { Link } from "react-router"; +import { clearToken } from "../auth/token.ts"; +import { withProject } from "../projects/current.ts"; +import { ProjectPicker } from "../projects/ProjectPicker.tsx"; +import { IconBoard, IconButton, IconList, IconSidebar, Menu, MenuItem, ThemeSwitch } from "../ui/index.ts"; +import { DataStatus } from "./DataStatus.tsx"; +import { readSidebar, saveSidebar } from "./sidebarPref.ts"; + +export type Section = "/board" | "/issues"; + +const SECTIONS: { to: Section; label: string; icon: React.ReactNode }[] = [ + { to: "/board", label: "Доска связей", icon: }, + { to: "/issues", label: "Задачи", icon: }, +]; + +// боковая панель (#69, design 1–3); section = null на путях вне разделов — активной ссылки нет: +// название и свёртка, проект, разделы, внизу состояние данных, тема, меню «Ещё» +export function Sidebar({ section, project }: { section: Section | null; project: string | null }) { + const qc = useQueryClient(); + const [collapsed, setCollapsed] = useState(readSidebar); + const fold = (c: boolean) => { setCollapsed(c); saveSidebar(c); }; + const href = (to: Section) => (project ? to + withProject("", project) : to); + return ( + + ); +} diff --git a/web/src/shell/shell.css b/web/src/shell/shell.css index 52b4e30..4d4979c 100644 --- a/web/src/shell/shell.css +++ b/web/src/shell/shell.css @@ -1,38 +1,48 @@ -/* оболочка: верхняя панель разделов (design.md, решение 9) */ -.appbar{ - position:relative; - 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); +/* оболочка: боковая панель и страница (#69, design 1–3); прокрутка — у окна, панель липнет к его верху */ +.shell{display:grid;grid-template-columns:auto minmax(0,1fr);min-height:100vh} +.sidebar{ + position:sticky;top:0;height:100vh;height:100dvh;width:240px;box-sizing:border-box;z-index:20; + display:flex;flex-direction:column;gap:var(--space-4);padding:var(--space-4); + border-right: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-warning)} +.sidebar.is-collapsed{width:64px;padding-inline:var(--space-2);align-items:center} +.sidebar__head{display:flex;align-items:center;justify-content:space-between;gap:var(--space-2);min-height:var(--control-h)} +.sidebar__brand{font-size:16px;line-height:24px;font-weight:700;color:var(--color-text-primary);white-space:nowrap;padding-left:var(--space-2)} +.sidebar__project-tag{ + max-width:48px;padding:2px var(--space-1);border:1px solid var(--color-border-default);border-radius:var(--radius-xs); + background:var(--color-bg-subtle);color:var(--color-text-secondary);font:inherit;font-size:var(--fs-caption);line-height:var(--lh-caption); + font-weight:600;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;cursor:pointer; +} +.sidebar__project-tag:hover{color:var(--color-text-primary);border-color:var(--color-border-strong)} +.sidebar__nav{display:flex;flex-direction:column;gap:var(--space-1);align-self:stretch} +.sidebar__link{ + display:flex;align-items:center;gap:var(--space-3);height:var(--control-h-lg);padding:0 var(--space-3);box-sizing:border-box; + border-radius:var(--control-radius);color:var(--color-text-secondary);text-decoration:none;font-weight:600;white-space:nowrap; +} +.sidebar__link:hover{background:var(--color-bg-hover);color:var(--color-text-primary)} +.sidebar__link[aria-current="page"]{background:var(--color-bg-selected);color:var(--color-action-primary)} +.sidebar__link:focus-visible,.sidebar__project-tag:focus-visible{outline:2px solid var(--color-focus-ring);outline-offset:2px} +.is-collapsed .sidebar__nav{align-items:center} +.is-collapsed .sidebar__link{width:var(--control-h-lg);padding:0;justify-content:center} +.sidebar__spacer{flex:1 1 auto} +.sidebar__foot{ + display:flex;align-items:center;justify-content:space-between;gap:var(--space-2);align-self:stretch; + padding-top:var(--space-3);border-top:1px solid var(--color-border-default); +} +.is-collapsed .sidebar__foot{flex-direction:column} +/* блок данных Jira: в развёрнутой панели — колонкой, событие пакета под кнопкой с переносом */ +.data-status{display:flex;flex-direction:column;align-items:stretch;gap:var(--space-2);align-self:stretch;font-size:var(--fs-caption);line-height:var(--lh-caption);color:var(--color-text-secondary)} +.data-status .ui-btn{width:100%;justify-content:center} +.data-status__agent{display:inline-flex;align-items:center;gap:6px} +.data-status__dot{width:8px;height:8px;border-radius:50%;background:var(--color-status-warning);flex:0 0 auto} .data-status__dot.is-online{background:var(--color-status-success)} -.data-status__sync{white-space:nowrap} -/* I-3: живой регион всегда в DOM; ≥1100 текст события — отдельной строкой у нижней границы панели (кнопка не двигается, высота панели не меняется) */ -.data-status__event{font-weight:600;min-width:0} -.data-status__event:empty{position:absolute} +.data-status__event{font-weight:600;overflow-wrap:anywhere} +.data-status__event:empty{position:absolute} /* живой регион всегда в DOM; пустой не занимает места */ .data-status__event--info{color:var(--color-text-secondary)} .data-status__event--success{color:var(--color-status-success)} .data-status__event--warning{color:color-mix(in srgb, var(--color-status-warning) 55%, var(--color-text-primary))} .data-status__event--error{color:var(--color-status-error)} +.data-status--compact{position:relative;align-items:center;align-self:center} +.data-status--compact .data-status__dot{position:absolute;top:2px;right:2px;pointer-events:none;box-shadow:0 0 0 2px var(--color-bg-surface)} .crash{max-width:640px;margin:48px auto} .crash h2{margin:0;font:inherit} -@media (min-width:1100px){ - .data-status__event{position:absolute;right:24px;bottom:1px;max-width:min(560px,45vw);overflow:hidden;text-overflow:ellipsis;white-space:nowrap;line-height:14px} -} -@media (max-width:1099px){ - .appbar .data-status{order:10;flex:1 0 100%} - .appbar .ui-theme,.appbar .more{order:4} /* T10.1: тема и «…» остаются в первой строке, статус данных — второй */ - .appbar .appbar__spacer{flex:1 1 0} -} -@media (max-width:639px){ - .appbar{padding-inline:16px} - .appbar__brand{display:none} - .appbar .appbar__spacer{display:none} - .appbar .project-picker{flex:1 1 0;width:auto;max-width:none;min-width:0} /* T10.1: picker, тема и «…» — в одной строке */ - .appbar .ui-seg{flex:1 1 100%;order:5} - .appbar .ui-seg__item{flex:1 1 0;justify-content:center} -} diff --git a/web/src/shell/sidebarPref.ts b/web/src/shell/sidebarPref.ts new file mode 100644 index 0000000..5467885 --- /dev/null +++ b/web/src/shell/sidebarPref.ts @@ -0,0 +1,13 @@ +// свёртка боковой панели: "collapsed" — свёрнута, без записи — развёрнута; ошибки хранилища глотаем +const KEY = "jira-hub-sidebar"; + +export function readSidebar(): boolean { + try { return localStorage.getItem(KEY) === "collapsed"; } catch { return false; } +} + +export function saveSidebar(collapsed: boolean): void { + try { + if (collapsed) localStorage.setItem(KEY, "collapsed"); + else localStorage.removeItem(KEY); + } catch { /* хранилище недоступно — состояние только в памяти */ } +} diff --git a/web/src/ui/Button.tsx b/web/src/ui/Button.tsx index db23e20..435dfc4 100644 --- a/web/src/ui/Button.tsx +++ b/web/src/ui/Button.tsx @@ -17,9 +17,9 @@ export function Button({ variant = "secondary", size = "md", icon, loading, clas ); } -export function IconButton({ label, icon, className, type = "button", ...rest }: { label: string; icon: ReactNode } & ButtonHTMLAttributes) { +export function IconButton({ label, icon, className, title, type = "button", ...rest }: { label: string; icon: ReactNode } & ButtonHTMLAttributes) { return ( - ); diff --git a/web/src/ui/Menu.tsx b/web/src/ui/Menu.tsx index c888a3b..a46b67a 100644 --- a/web/src/ui/Menu.tsx +++ b/web/src/ui/Menu.tsx @@ -25,11 +25,11 @@ export function Dropdown({ button, className = "", menu = false, children, onOpe } // меню «Ещё»: Dropdown с кнопкой-иконкой; пункты — кнопки (aria-pressed для переключателей) -export function Menu({ label, children, align = "end" }: { - label: string; children: (close: () => void) => ReactNode; align?: "start" | "end"; +export function Menu({ label, children, align = "end", up = false }: { + label: string; children: (close: () => void) => ReactNode; align?: "start" | "end"; up?: boolean; // up — открывается вверх (низ боковой панели) }) { return ( - } aria-haspopup="menu" aria-expanded={open} onClick={toggle} />}> {children} diff --git a/web/src/ui/icons.tsx b/web/src/ui/icons.tsx index eb25186..9471e52 100644 --- a/web/src/ui/icons.tsx +++ b/web/src/ui/icons.tsx @@ -32,3 +32,7 @@ export const IconNeighbors = make(<>); export const IconLegend = make(<>); export const IconOpen = make(<>); +export const IconBoard = make(<>); +export const IconList = make(<>); +export const IconMenu = make(<>); +export const IconSidebar = make(<>); diff --git a/web/src/ui/ui.css b/web/src/ui/ui.css index 1dce05b..aafea3c 100644 --- a/web/src/ui/ui.css +++ b/web/src/ui/ui.css @@ -203,6 +203,7 @@ .dd-empty { padding: var(--space-2); font-size: var(--fs-caption); line-height: var(--lh-caption); color: var(--color-text-secondary); } .dd-foot { display: flex; gap: var(--space-2); justify-content: space-between; border-top: 1px solid var(--color-border-default); padding-top: var(--space-2); } .dd--start > .dd-menu.menu { left: 0; right: auto; } +.dd--up > .dd-menu { top: auto; bottom: calc(100% + var(--space-1)); } .menu button { display: flex; align-items: center; justify-content: space-between; gap: var(--space-3); width: 100%; min-height: var(--control-h); padding: var(--space-1) var(--space-3); border: 0; border-radius: var(--radius-sm);