feat(web): страница доски — панель, фильтры, легенда, проблемы, таблица, экспорт (#21)
Заглушка модели в useBoardSource стабильна между рендерами, readTheme не возвращает null. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
parent
2227b8885a
commit
66bcb7e293
135
web/src/board/BoardPage.test.tsx
Normal file
135
web/src/board/BoardPage.test.tsx
Normal file
@ -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<string, () => 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(<BoardPage />, { 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();
|
||||
});
|
||||
});
|
||||
@ -1,2 +1,90 @@
|
||||
// временная страница (заменяется в Task 11)
|
||||
export function BoardPage() { return <div data-testid="page-board" />; }
|
||||
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<string | null>(null);
|
||||
const saveRef = useRef<(() => void) | null>(null);
|
||||
const headRef = useRef<HTMLDivElement>(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 <p className="hint" data-testid="page-board">Загрузка настроек хаба…</p>;
|
||||
if (settings.error) return <p className="warn" data-testid="page-board">Настройки хаба не загрузились: {settings.error.message}</p>;
|
||||
if (d && d.missing.length) {
|
||||
return (
|
||||
<div className="notice" role="alert" data-testid="page-board">
|
||||
В настройках хаба нет {d.missing.join(" и ")} — доска не может перевести виды работ и типы связей Jira. Задайте
|
||||
словари в config.example.json и выполните <code>uv run jira-hub init --config …</code> или запишите их через PUT /settings.
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
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 (
|
||||
<div data-testid="page-board">
|
||||
<div className="sticky" ref={headRef}>
|
||||
<Toolbar label={src.label} title={src.title} status={src.status} refreshing={src.refreshing} byDate={prefs.filters.byDate}
|
||||
snap={prefs.snap} model={src.model} vis={vis} problemCount={problemCount(vis.problems)} problemsOpen={prefs.problems}
|
||||
tableOpen={prefs.table} onReload={src.reload} onRefresh={() => void src.refreshFromJira()} onFile={onFile}
|
||||
onProblems={() => prefs.toggle("problems")} onExport={onExport} onTable={() => prefs.toggle("table")}
|
||||
onSnap={() => prefs.toggle("snap")} onReset={prefs.resetLayout} />
|
||||
<FilterBar model={src.model} prefs={prefs} />
|
||||
<Notice model={src.model} />
|
||||
<Legend prefs={prefs} />
|
||||
</div>
|
||||
{exportText !== null && <ExportBox text={exportText} onClose={() => setExportText(null)} />}
|
||||
<BoardCanvas vis={vis} byDate={prefs.filters.byDate} snap={prefs.snap} today={today} store={prefs.store}
|
||||
sprintSel={prefs.filters.sprints} emptyText={vis.nodes.length ? "" : emptyReason(src.model, vis, prefs.filters)}
|
||||
onToggleLane={prefs.toggleLane} onMoveLane={(name, to) => prefs.moveLane(vis.lanes.map((l) => l.name), name, to)}
|
||||
onColWidth={prefs.setColWidth} saveRef={saveRef}>
|
||||
{prefs.problems && <ProblemsDrawer problems={vis.problems} onClose={() => prefs.toggle("problems")} />}
|
||||
</BoardCanvas>
|
||||
{prefs.table && <LinkTable links={vis.links} />}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@ -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}
|
||||
|
||||
@ -14,13 +14,14 @@ export interface LayoutStore { laneOrder: string[]; folded: Set<string>; colWidt
|
||||
export interface BoardCanvasProps {
|
||||
vis: Visible; byDate: boolean; snap: boolean; today: string; store: LayoutStore; sprintSel: Set<string>;
|
||||
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<HTMLDivElement>(null);
|
||||
const measureRef = useRef<HTMLDivElement>(null);
|
||||
const wrapRef = useRef<HTMLDivElement>(null);
|
||||
@ -49,6 +50,12 @@ export function BoardCanvas({ vis, byDate, snap, today, store, sprintSel, emptyT
|
||||
const placed = useMemo<Placed | null>(() => (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<HTMLDivElement>, key: string) => {
|
||||
|
||||
23
web/src/board/components/Dropdown.tsx
Normal file
23
web/src/board/components/Dropdown.tsx
Normal file
@ -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<HTMLDivElement>(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 (
|
||||
<div className={"dd " + className} ref={ref} onKeyDown={(e) => { if (e.key === "Escape") setOpen(false); }}>
|
||||
{button({ open, toggle })}
|
||||
{open && <div className={"dd-menu" + (className.includes("more") ? " menu" : "")}>{children(() => setOpen(false))}</div>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
20
web/src/board/components/ExportBox.tsx
Normal file
20
web/src/board/components/ExportBox.tsx
Normal file
@ -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 (
|
||||
<div className="export">
|
||||
<div className="row">
|
||||
<span className="hint">JSON доски: все задачи, связи, раскладка, дорожки и фильтры. Загружается обратно ссылкой «файл…». Если файл не скачался, скопируйте текст.</span>
|
||||
<button className="btn" type="button" onClick={() => void copy(ta)}>{copied ? "Скопировано" : "Копировать"}</button>
|
||||
<button className="btn" type="button" onClick={onClose}>Закрыть</button>
|
||||
</div>
|
||||
<textarea readOnly spellCheck={false} value={text} ref={(el) => { ta = el; }} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
27
web/src/board/components/FilterBar.tsx
Normal file
27
web/src/board/components/FilterBar.tsx
Normal file
@ -0,0 +1,27 @@
|
||||
import { sprintBox, sprintItems, whoBox, whoItems } from "../analysis/facets.ts";
|
||||
import type { BoardPrefsApi } from "../useBoardPrefs.ts";
|
||||
import type { BoardModel } from "../types.ts";
|
||||
import { MultiSelect } from "./MultiSelect.tsx";
|
||||
|
||||
const TOGGLES = [
|
||||
{ name: "neighbors", label: "+ соседи по связям", title: "Добавить к отобранным задачам их соседей по связям из любого спринта, любого исполнителя и вида работы. Соседи рисуются пунктиром и считаются отдельно" },
|
||||
{ name: "onlyHand", label: "⇄ передачи", title: "Оставить только стрелки, где задачу ждёт другой человек. Карточки не убирает — для этого «скрыть без связей»" },
|
||||
{ name: "onlyLinked", label: "скрыть без связей", title: "Убрать с доски карточки, у которых не осталось ни одной видимой стрелки" },
|
||||
{ name: "crit", label: "⚑ критический путь", title: "Самая длинная цепочка блокировок по плановым датам окончания" },
|
||||
{ name: "byDate", label: "▥ по датам", title: "Разложить карточки по колонкам плановых дат окончания. У этого режима своя расстановка: карточку можно двигать внутри её колонки; обычная расстановка не меняется и вернётся, когда режим выключен" },
|
||||
] as const;
|
||||
|
||||
export function FilterBar({ model, prefs }: { model: BoardModel; prefs: BoardPrefsApi }) {
|
||||
const f = prefs.filters, sb = sprintBox(model), wb = whoBox(model, f);
|
||||
return (
|
||||
<div className="bar">
|
||||
<span>{sb.kind === "text" ? <span className="file-name">{sb.text}</span>
|
||||
: sb.kind === "multi" && <MultiSelect items={sprintItems(model, f).items} selected={f.sprints} allLabel="Все спринты" onChange={prefs.setSprints} />}</span>
|
||||
<span>{wb.kind === "text" ? <span className="file-name">{wb.text}</span>
|
||||
: wb.kind === "multi" && <MultiSelect items={whoItems(model, f).items} selected={f.who} allLabel="Все исполнители" onChange={prefs.setWho} />}</span>
|
||||
{TOGGLES.map((t) => (
|
||||
<button key={t.name} className="btn" type="button" aria-pressed={f[t.name]} title={t.title} onClick={() => prefs.toggle(t.name)}>{t.label}</button>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
25
web/src/board/components/Legend.tsx
Normal file
25
web/src/board/components/Legend.tsx
Normal file
@ -0,0 +1,25 @@
|
||||
import type { ReactNode } from "react";
|
||||
import type { BoardPrefsApi } from "../useBoardPrefs.ts";
|
||||
|
||||
const KINDS = [["decision", "решение"], ["analytics", "аналитика / актуализация"], ["dev", "разработка"], ["ui", "UI"],
|
||||
["test", "тестирование"], ["org", "организационная"]] as const;
|
||||
const EDGES = [["block", "блокирует начало → заблокирована", "ln"], ["rel", "связанные", "ln dash"]] as const;
|
||||
|
||||
export function Legend({ prefs }: { prefs: BoardPrefsApi }) {
|
||||
const { hideKind, hideEdge } = prefs.filters;
|
||||
const btn = (what: "kind" | "edge", value: string, off: boolean, body: ReactNode) => (
|
||||
<button key={what + value} type="button" className={"lg" + (off ? " off" : "")} aria-pressed={!off}
|
||||
title={off ? "Показать снова" : "Скрыть на доске"} onClick={() => prefs.toggleHidden(what, value)}>{body}</button>
|
||||
);
|
||||
return (
|
||||
<div className="legend">
|
||||
{KINDS.map(([k, label]) => btn("kind", k, hideKind.has(k), <><span className="sw" style={{ background: `var(--k-${k})` }} />{label}</>))}
|
||||
<span className="sep" />
|
||||
{EDGES.map(([k, label, ln]) => btn("edge", k, hideEdge.has(k), <><span className={ln} />{label}</>))}
|
||||
<span><span className="ln hand" />передача другому исполнителю</span>
|
||||
<span><span className="ln crit" />критический путь</span>
|
||||
<span><span className="out-sw" />сосед по связям, сам под фильтры не попадает</span>
|
||||
<span className="hint">щелчок по виду работы или типу связи скрывает их</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
13
web/src/board/components/LinkTable.tsx
Normal file
13
web/src/board/components/LinkTable.tsx
Normal file
@ -0,0 +1,13 @@
|
||||
import type { VisLink } from "../types.ts";
|
||||
|
||||
export function LinkTable({ links }: { links: VisLink[] }) {
|
||||
return (
|
||||
<details open>
|
||||
<summary>Таблица связей</summary>
|
||||
<div className="tbl"><table>
|
||||
<thead><tr><th>Блокирует</th><th>Тип</th><th>Заблокирована</th></tr></thead>
|
||||
<tbody>{links.map((l) => <tr key={`${l.src}>${l.dst}${l.type}`}><td className="k">{l.src}</td><td>{l.type}</td><td className="k">{l.dst}</td></tr>)}</tbody>
|
||||
</table></div>
|
||||
</details>
|
||||
);
|
||||
}
|
||||
40
web/src/board/components/MultiSelect.tsx
Normal file
40
web/src/board/components/MultiSelect.tsx
Normal file
@ -0,0 +1,40 @@
|
||||
import { useState } from "react";
|
||||
import type { FacetItem } from "../analysis/facets.ts";
|
||||
import { Dropdown } from "./Dropdown.tsx";
|
||||
|
||||
// мультиселект с поиском: пустой выбор = без фильтра (строки 913–938)
|
||||
export function MultiSelect({ items, selected, allLabel, onChange }: {
|
||||
items: FacetItem[]; selected: Set<string>; allLabel: string; onChange(next: Set<string>): void;
|
||||
}) {
|
||||
const [q, setQ] = useState("");
|
||||
const sel = items.filter((i) => selected.has(i.value));
|
||||
const label = !sel.length ? allLabel : sel.length === 1 ? sel[0].label : `${sel[0].label} +${sel.length - 1}`;
|
||||
const shown = items.filter((i) => !q || i.label.toLowerCase().includes(q.toLowerCase()) || i.value.toLowerCase().includes(q.toLowerCase()));
|
||||
const flip = (v: string, on: boolean) => { const n = new Set(selected); if (on) n.add(v); else n.delete(v); onChange(n); };
|
||||
return (
|
||||
<Dropdown onOpen={() => setQ("")} button={({ open, toggle }) => (
|
||||
<button type="button" className="btn dd-btn" aria-haspopup="listbox" aria-expanded={open} title={sel.map((i) => i.label).join(", ")} onClick={toggle}>
|
||||
<span className="lbl">{label}</span><span className="car">▼</span>
|
||||
</button>
|
||||
)}>
|
||||
{() => (
|
||||
<>
|
||||
<input type="search" placeholder="Поиск…" aria-label="Поиск" autoFocus value={q} onChange={(e) => setQ(e.target.value)} />
|
||||
<div className="dd-list" role="listbox">
|
||||
{!shown.length && <div className="dd-empty">ничего не найдено</div>}
|
||||
{shown.map((i) => (
|
||||
<label key={i.value} className={"dd-item" + (i.cls ? " " + i.cls : "")} title={i.title || i.label}>
|
||||
<input type="checkbox" checked={selected.has(i.value)} onChange={(e) => flip(i.value, e.target.checked)} />
|
||||
<span>{i.label}</span><span className="n">{i.count}</span>
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
<div className="dd-foot">
|
||||
<button type="button" className="btn all" onClick={() => onChange(new Set(items.map((i) => i.value)))}>Выбрать все</button>
|
||||
<button type="button" className="btn clear" onClick={() => onChange(new Set())}>Снять выбор</button>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</Dropdown>
|
||||
);
|
||||
}
|
||||
30
web/src/board/components/ProblemsDrawer.tsx
Normal file
30
web/src/board/components/ProblemsDrawer.tsx
Normal file
@ -0,0 +1,30 @@
|
||||
import { JIRA_URL } from "../constants.ts";
|
||||
import type { ProblemItem, Problems } from "../types.ts";
|
||||
|
||||
export const problemCount = (p: Problems) => p.dates.length + p.unassigned.length + p.late.length;
|
||||
|
||||
const K = ({ k }: { k: string }) => <a className="k" href={`${JIRA_URL}/browse/${k}`} target="_blank" rel="noopener">{k}</a>;
|
||||
function Section({ title, list }: { title: string; list: ProblemItem[] }) {
|
||||
if (!list.length) return null;
|
||||
return (
|
||||
<>
|
||||
<h4>{title} ({list.length})</h4>
|
||||
<ul>{list.map((x, i) => <li key={i}><K k={x.a.key} /> → <K k={x.b.key} /> <span className="why">— {x.text}</span></li>)}</ul>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
export function ProblemsDrawer({ problems, onClose }: { problems: Problems; onClose(): void }) {
|
||||
return (
|
||||
<aside className="drawer">
|
||||
<div className="drawer-head"><b>Проблемы на доске</b><button type="button" className="btn sm" onClick={onClose}>Закрыть</button></div>
|
||||
<div>
|
||||
{!problemCount(problems) && <div className="ok">По датам, исполнителям и статусам блокеров на видимой части доски проблем нет.</div>}
|
||||
<Section title="Срок блокера не раньше срока зависимой задачи" list={problems.dates} />
|
||||
<Section title="Блокер без исполнителя" list={problems.unassigned} />
|
||||
<Section title="Блокер не начат, а срок зависимой задачи близко" list={problems.late} />
|
||||
<Section title="Передачи между исполнителями (точки ожидания)" list={problems.hand} />
|
||||
</div>
|
||||
</aside>
|
||||
);
|
||||
}
|
||||
84
web/src/board/components/Toolbar.tsx
Normal file
84
web/src/board/components/Toolbar.tsx
Normal file
@ -0,0 +1,84 @@
|
||||
import { useEffect, useState } from "react";
|
||||
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";
|
||||
import { Dropdown } from "./Dropdown.tsx";
|
||||
|
||||
interface ToolbarProps {
|
||||
label: string; title: string; status: BoardStatus | null; refreshing: boolean; byDate: boolean; snap: boolean;
|
||||
model: BoardModel; vis: Visible; problemCount: number; problemsOpen: boolean; tableOpen: boolean;
|
||||
onReload(): void; onRefresh(): void; onFile(name: string, text: string): void; onProblems(): void;
|
||||
onExport(): void; onTable(): void; onSnap(): void; onReset(): void;
|
||||
}
|
||||
|
||||
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 st = p.status ? statusText(p.status) : null;
|
||||
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={p.refreshing} onClick={p.onRefresh}
|
||||
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) => {
|
||||
const f = e.target.files?.[0];
|
||||
e.target.value = "";
|
||||
if (f) p.onFile(f.name, await f.text());
|
||||
}} />
|
||||
</label>
|
||||
</span>
|
||||
{st && <span className={"stats" + (st.warn ? " warn" : "")} role="status">{st.text}</span>}
|
||||
<span className="stats">
|
||||
<b>{coreShown}</b> задач из {m.nodes.filter((n) => !n.stub).length}
|
||||
{ctxShown > 0 && <> · <b>+{ctxShown}</b> соседи по связям</>}
|
||||
{" · "}<b>{v.links.length}</b> связей из {m.links.length}
|
||||
{r.loose > 0 && <> · скрыто без связей: {r.loose}</>}
|
||||
</span>
|
||||
<span className="spacer" />
|
||||
<button className="btn" type="button" aria-pressed={p.problemsOpen} onClick={p.onProblems}
|
||||
title="Сроки блокеров, задачи без исполнителя, передачи между людьми">
|
||||
Проблемы <span className={"n" + (p.problemCount ? "" : " zero")}>{p.problemCount}</span>
|
||||
</button>
|
||||
<Dropdown className="more" button={({ open, toggle }) => (
|
||||
<button type="button" className="btn more-btn" aria-haspopup="menu" aria-expanded={open} title="Ещё" onClick={toggle}>⋯</button>
|
||||
)}>
|
||||
{(close) => (
|
||||
<>
|
||||
<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">
|
||||
<span>Вернуть автоматическую раскладку {p.byDate ? "режима «по датам»" : "(обычную; раскладка «по датам» останется)"}, порядок и раскрытие дорожек?</span>
|
||||
<div className="row">
|
||||
<button type="button" className="btn yes" onClick={() => { p.onReset(); setConfirm(false); close(); }}>Да, сбросить</button>
|
||||
<button type="button" className="btn no" onClick={() => setConfirm(false)}>Нет</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</Dropdown>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// предупреждения о данных под шапкой (строки 878–880)
|
||||
export function Notice({ 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 <div className="notice">{notes.map((n) => <span key={n}>{n}</span>)}</div>;
|
||||
}
|
||||
@ -37,3 +37,4 @@ export const fmtStamp = (iso: string): string => {
|
||||
};
|
||||
export const emptyModel = (text: string): BoardModel =>
|
||||
({ nodes: [], links: [], sprints: {}, fileName: "", problems: [], hasSprint: false, missing: 0, emptyText: text });
|
||||
export const todayIso = (): string => new Date().toISOString().slice(0, 10);
|
||||
|
||||
67
web/src/board/useBoardPrefs.ts
Normal file
67
web/src/board/useBoardPrefs.ts
Normal file
@ -0,0 +1,67 @@
|
||||
import { useCallback, useMemo, useState } from "react";
|
||||
import { initialSprints, initialWho } from "./analysis/facets.ts";
|
||||
import type { LayoutStore } from "./components/BoardCanvas.tsx";
|
||||
import * as st from "./storage.ts";
|
||||
import type { BoardModel, Filters, JsonImport } from "./types.ts";
|
||||
|
||||
export interface BoardPrefsApi {
|
||||
filters: Filters; snap: boolean; table: boolean; problems: boolean; store: LayoutStore;
|
||||
toggle(name: st.FlagName | "snap" | "table" | "problems"): void;
|
||||
setSprints(s: Set<string>): void;
|
||||
setWho(s: Set<string>): void;
|
||||
toggleHidden(what: "kind" | "edge", value: string): void;
|
||||
toggleLane(name: string): void;
|
||||
moveLane(current: string[], name: string, to: number): void;
|
||||
setColWidth(date: string, w: number | null): void;
|
||||
resetLayout(): void;
|
||||
applyJson(r: JsonImport): void;
|
||||
}
|
||||
|
||||
const readStore = (version: number): LayoutStore =>
|
||||
({ laneOrder: st.laneOrder(), folded: st.foldedSet(), colWidths: st.colWidths(), version });
|
||||
const pick = (model: BoardModel, f: Filters) =>
|
||||
({ model, sprints: initialSprints(model, f, st.rememberedSprints()), who: initialWho(model, f, st.rememberedWho()) });
|
||||
|
||||
// настройки доски из хранилища links.html: флаги, скрытые виды и типы, выбор спринтов и исполнителей, раскладка
|
||||
export function useBoardPrefs(model: BoardModel): BoardPrefsApi {
|
||||
const [prefs, setPrefs] = useState<st.BoardPrefs>(() => { st.migrateMode(); st.migrateStore(); return st.readPrefs(); });
|
||||
const [store, setStore] = useState<LayoutStore>(() => readStore(0));
|
||||
const [sel, setSel] = useState(() => pick(model, prefs.filters));
|
||||
if (sel.model !== model) setSel(pick(model, prefs.filters)); // новая модель — выбор заново, как fillSprints/fillWho
|
||||
const bump = useCallback(() => setStore((s) => readStore(s.version + 1)), []);
|
||||
|
||||
const filters = useMemo<Filters>(() => ({ ...prefs.filters, sprints: sel.sprints, who: sel.who }), [prefs.filters, sel]);
|
||||
|
||||
const toggle = useCallback((name: st.FlagName | "snap" | "table" | "problems") => {
|
||||
setPrefs((p) => {
|
||||
if (name === "snap" || name === "table" || name === "problems") { st.writeFlag(name, !p[name]); return { ...p, [name]: !p[name] }; }
|
||||
st.writeFlag(name, !p.filters[name]);
|
||||
return { ...p, filters: { ...p.filters, [name]: !p.filters[name] } };
|
||||
});
|
||||
}, []);
|
||||
const setSprints = useCallback((s: Set<string>) => { st.writeSprints(s); setSel((x) => ({ ...x, sprints: new Set(s) })); }, []);
|
||||
const setWho = useCallback((s: Set<string>) => { st.writeWho(s); setSel((x) => ({ ...x, who: new Set(s) })); }, []);
|
||||
const toggleHidden = useCallback((what: "kind" | "edge", value: string) => {
|
||||
setPrefs((p) => {
|
||||
const key = what === "kind" ? "hideKind" : "hideEdge";
|
||||
const next = new Set(p.filters[key]);
|
||||
if (next.has(value)) next.delete(value); else next.add(value);
|
||||
const filters = { ...p.filters, [key]: next };
|
||||
st.writeHidden(filters.hideKind, filters.hideEdge);
|
||||
return { ...p, filters };
|
||||
});
|
||||
}, []);
|
||||
const toggleLane = useCallback((name: string) => { st.toggleLane(name); bump(); }, [bump]);
|
||||
const moveLane = useCallback((current: string[], name: string, to: number) => { if (st.moveLane(current, name, to)) bump(); }, [bump]);
|
||||
const setColWidth = useCallback((date: string, w: number | null) => { st.setColWidth(date, w); bump(); }, [bump]);
|
||||
const resetLayout = useCallback(() => { st.resetLayout(prefs.filters.byDate); bump(); }, [prefs.filters.byDate, bump]);
|
||||
const applyJson = useCallback((r: JsonImport) => {
|
||||
const patch = st.applyJsonSettings(r);
|
||||
setPrefs((p) => ({ ...p, filters: { ...p.filters, ...patch } }));
|
||||
setSel(pick(model, { ...prefs.filters, ...patch }));
|
||||
bump();
|
||||
}, [model, prefs.filters, bump]);
|
||||
|
||||
return { filters, snap: prefs.snap, table: prefs.table, problems: prefs.problems, store,
|
||||
toggle, setSprints, setWho, toggleHidden, toggleLane, moveLane, setColWidth, resetLayout, applyJson };
|
||||
}
|
||||
@ -177,9 +177,12 @@ export function useBoardSource(dicts: Dicts | null, { pollMs = POLL_MS }: { poll
|
||||
}, [dicts, board.data, qc]);
|
||||
|
||||
const ls = board.data?.last_sync ?? null;
|
||||
const model = source.kind === "file" ? source.model : hubModel ?? emptyModel(board.isPending
|
||||
// заглушка без данных — один и тот же объект между рендерами (от identity модели зависит выбор спринтов на странице)
|
||||
const pending = board.isPending;
|
||||
const emptyHub = useMemo(() => emptyModel(pending
|
||||
? "Загрузка данных хаба…"
|
||||
: "Нет данных: хаб не отвечает. Запустите хаб (jira-hub serve) и нажмите «Перечитать» или загрузите файл CSV из фильтра Jira.");
|
||||
: "Нет данных: хаб не отвечает. Запустите хаб (jira-hub serve) и нажмите «Перечитать» или загрузите файл CSV из фильтра Jira."), [pending]);
|
||||
const model = source.kind === "file" ? source.model : hubModel ?? emptyHub;
|
||||
const label = source.kind === "file" ? `${source.model.fileName} (из файла)`
|
||||
: board.data ? (ls ? `Хаб · данные на ${fmtStamp(ls.finished_at)}` : "Хаб · полного обновления не было")
|
||||
: board.isPending ? "Хаб" : "Хаб недоступен";
|
||||
|
||||
@ -8,6 +8,9 @@ describe("тема", () => {
|
||||
localStorage.setItem("graph-theme", "purple");
|
||||
expect(readTheme()).toBe("");
|
||||
});
|
||||
it("без сохранённого значения readTheme возвращает пустую строку, а не null", () => {
|
||||
expect(readTheme()).toBe("");
|
||||
});
|
||||
it("applyTheme ставит и снимает data-theme, saveTheme пишет ключ", () => {
|
||||
applyTheme("light");
|
||||
expect(document.documentElement.getAttribute("data-theme")).toBe("light");
|
||||
|
||||
@ -7,7 +7,7 @@ const KEY = "graph-theme";
|
||||
export function readTheme(): Theme {
|
||||
let v: string | null = null;
|
||||
try { v = localStorage.getItem(KEY); } catch { /* закрытое хранилище */ }
|
||||
return (THEMES as readonly string[]).includes(v ?? "") ? (v as Theme) : "";
|
||||
return (THEMES as readonly string[]).includes(v ?? "") ? ((v ?? "") as Theme) : "";
|
||||
}
|
||||
|
||||
export function saveTheme(t: Theme): void {
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user