feat(web): прокрутка доски после возврата, memo карточек, обработчики без побочных действий в updater (M-3, T11.1–T11.4, T12.1–T12.4, #29)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Andrey Epifancev 2026-10-15 01:17:43 +04:00
parent 841ce4ea45
commit 960a71d62c
15 changed files with 191 additions and 40 deletions

View File

@ -57,3 +57,15 @@ test("ⓘ открывает карточку задачи в приложени
await expect(page).toHaveURL(/\/app\/issues\/AURORA-9001$/); await expect(page).toHaveURL(/\/app\/issues\/AURORA-9001$/);
await expect(page.getByRole("heading", { name: /AURORA-9001/ })).toBeVisible(); await expect(page.getByRole("heading", { name: /AURORA-9001/ })).toBeVisible();
}); });
test("M-3: прокрутка доски после возврата из карточки задачи", async ({ page }) => {
await page.setViewportSize({ width: 1600, height: 600 });
await expect(page.locator(".board .card[data-key]").first()).toBeVisible();
const y = await page.evaluate(() => { window.scrollTo(0, document.documentElement.scrollHeight); return window.scrollY; });
expect(y).toBeGreaterThan(100);
// щелчок без прокрутки к элементу: иначе Playwright сам сдвинет страницу перед переходом
await page.evaluate(() => (document.querySelector(".board .card[data-key] a.open-card") as HTMLElement).click());
await expect(page).toHaveURL(/\/app\/issues\//);
await page.getByRole("link", { name: "Доска", exact: true }).click();
await expect.poll(() => page.evaluate(() => window.scrollY)).toBeGreaterThan(y - 2);
});

View File

@ -2,7 +2,8 @@ import { fireEvent, screen, waitFor, within } from "@testing-library/react";
import userEvent from "@testing-library/user-event"; import userEvent from "@testing-library/user-event";
import { readFileSync } from "node:fs"; import { readFileSync } from "node:fs";
import { join } from "node:path"; import { join } from "node:path";
import { beforeEach, describe, expect, it } from "vitest"; import { StrictMode } from "react";
import { beforeEach, describe, expect, it, vi } from "vitest";
import { setToken } from "../auth/token.ts"; import { setToken } from "../auth/token.ts";
import { json, mockFetch } from "../test/fetchMock.ts"; import { json, mockFetch } from "../test/fetchMock.ts";
import { renderWithProviders } from "../test/render.tsx"; import { renderWithProviders } from "../test/render.tsx";
@ -132,4 +133,29 @@ describe("страница доски", () => {
page(); page();
expect(await screen.findByText(/В настройках хаба нет link_types —/)).toBeInTheDocument(); expect(await screen.findByText(/В настройках хаба нет link_types —/)).toBeInTheDocument();
}); });
it("T12.3: --head-h снимается при уходе со страницы", async () => {
hub();
const r = page();
await screen.findByText(/^Хаб · данные на/);
r.unmount();
expect(document.documentElement.style.getPropertyValue("--head-h")).toBe("");
});
it("T12.2: переключатель пишет флаг в хранилище один раз, в том числе в StrictMode", async () => {
hub();
renderWithProviders(<StrictMode><BoardPage /></StrictMode>, { route: "/board", path: "/board" });
await screen.findByText(/^Хаб · данные на/);
const set = vi.spyOn(Storage.prototype, "setItem");
fireEvent.click(screen.getByRole("button", { name: /критический путь/ }));
expect(set.mock.calls.filter(([k]) => k === "graph-crit")).toEqual([["graph-crit", "1"]]);
});
it("T12.4: список исполнителей — группа флажков с подписью", async () => {
hub();
page();
await userEvent.click(await screen.findByRole("button", { name: /Все исполнители/ }));
expect(screen.getByRole("group", { name: "Все исполнители" })).toBeInTheDocument();
expect(screen.queryByRole("listbox")).toBeNull();
});
}); });

View File

@ -1,4 +1,4 @@
import { useLayoutEffect, useMemo, useRef, useState } from "react"; import { useCallback, useMemo, useRef, useState } from "react";
import { useSettings } from "../api/queries.ts"; import { useSettings } from "../api/queries.ts";
import { computeVisible, emptyReason } from "./analysis/visible.ts"; import { computeVisible, emptyReason } from "./analysis/visible.ts";
import { BoardCanvas } from "./components/BoardCanvas.tsx"; import { BoardCanvas } from "./components/BoardCanvas.tsx";
@ -25,19 +25,16 @@ export function BoardPage() {
[src.model, prefs.filters, prefs.store.laneOrder, today]); [src.model, prefs.filters, prefs.store.laneOrder, today]);
const [exportText, setExportText] = useState<string | null>(null); const [exportText, setExportText] = useState<string | null>(null);
const saveRef = useRef<(() => void) | null>(null); const saveRef = useRef<(() => void) | null>(null);
const headRef = useRef<HTMLDivElement>(null); // высота закреплённой шапки — ящик «Проблемы» и полоса дат липнут под ней (строки 1016–1019); при уходе — снимаем
const headRef = useCallback((el: HTMLDivElement | null) => {
// высота закреплённой шапки — ящик «Проблемы» и полоса дат липнут под ней (строки 1016–1019)
useLayoutEffect(() => {
const el = headRef.current;
if (!el) return; if (!el) return;
const set = () => document.documentElement.style.setProperty("--head-h", (getComputedStyle(el).position === "sticky" ? el.offsetHeight : 0) + "px"); const root = document.documentElement.style;
const set = () => root.setProperty("--head-h", (getComputedStyle(el).position === "sticky" ? el.offsetHeight : 0) + "px");
set(); set();
if (typeof ResizeObserver === "undefined") return; const ro = typeof ResizeObserver === "undefined" ? null : new ResizeObserver(set);
const ro = new ResizeObserver(set); ro?.observe(el);
ro.observe(el); return () => { ro?.disconnect(); root.removeProperty("--head-h"); };
return () => ro.disconnect(); }, []);
});
if (settings.isPending) return <p className="hint" data-testid="page-board">Загрузка настроек хаба…</p>; 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 (settings.error) return <p className="warn" data-testid="page-board">Настройки хаба не загрузились: {settings.error.message}</p>;

View File

@ -1,5 +1,5 @@
import { useState } from "react"; import { useState } from "react";
import { fireEvent, screen, within } from "@testing-library/react"; import { fireEvent, screen, waitFor, within } from "@testing-library/react";
import { readFileSync } from "node:fs"; import { readFileSync } from "node:fs";
import { join } from "node:path"; import { join } from "node:path";
import { describe, expect, it, vi } from "vitest"; import { describe, expect, it, vi } from "vitest";
@ -10,6 +10,7 @@ import { dictsFromSettings } from "../dicts.ts";
import settings from "../__fixtures__/settings.json"; import settings from "../__fixtures__/settings.json";
import { fromHub } from "../model/fromHub.ts"; import { fromHub } from "../model/fromHub.ts";
import { noFilters } from "../types.ts"; import { noFilters } from "../types.ts";
import { readBoardScroll } from "../scrollMemory.ts";
import { BoardCanvas, type LayoutStore } from "./BoardCanvas.tsx"; import { BoardCanvas, type LayoutStore } from "./BoardCanvas.tsx";
const fx = (name: string) => readFileSync(join(import.meta.dirname, "..", "__fixtures__", name), "utf8"); const fx = (name: string) => readFileSync(join(import.meta.dirname, "..", "__fixtures__", name), "utf8");
@ -17,11 +18,14 @@ const { dicts } = dictsFromSettings(settings);
const model = fromHub(JSON.parse(fx("hub-basic.board.json")) as BoardSnapshot, dicts); const model = fromHub(JSON.parse(fx("hub-basic.board.json")) as BoardSnapshot, dicts);
const store: LayoutStore = { laneOrder: [], folded: new Set(), colWidths: {}, version: 0 }; const store: LayoutStore = { laneOrder: [], folded: new Set(), colWidths: {}, version: 0 };
function draw(over: Partial<Parameters<typeof BoardCanvas>[0]> = {}) { function baseProps(over: Partial<Parameters<typeof BoardCanvas>[0]> = {}) {
const f = { ...noFilters(), ...(over.byDate ? { byDate: true } : {}) }; const f = { ...noFilters(), ...(over.byDate ? { byDate: true } : {}) };
const vis = computeVisible(model, f, [], "2026-10-14"); const vis = computeVisible(model, f, [], "2026-10-14");
const props = { vis, byDate: false, snap: true, today: "2026-10-14", store, sprintSel: new Set<string>(), emptyText: "пусто", return { vis, byDate: false, snap: true, today: "2026-10-14", store, sprintSel: new Set<string>(), emptyText: "пусто",
onToggleLane: vi.fn(), onMoveLane: vi.fn(), onColWidth: vi.fn(), ...over }; onToggleLane: vi.fn(), onMoveLane: vi.fn(), onColWidth: vi.fn(), ...over };
}
function draw(over: Partial<Parameters<typeof BoardCanvas>[0]> = {}) {
const props = baseProps(over);
renderWithProviders(<BoardCanvas {...props} />); renderWithProviders(<BoardCanvas {...props} />);
return props; return props;
} }
@ -87,7 +91,7 @@ describe("BoardCanvas", () => {
expect(screen.getByText("Фильтры не оставили")).toBeInTheDocument(); expect(screen.getByText("Фильтры не оставили")).toBeInTheDocument();
}); });
it("сужение выборки при смене фильтра не роняет холст (карточки старой раскладки пропускаются)", () => { it("сужение выборки при смене фильтра не роняет холст (карточки старой раскладки пропускаются)", async () => {
const wide = computeVisible(model, noFilters(), [], "2026-10-14"); const wide = computeVisible(model, noFilters(), [], "2026-10-14");
const narrow = computeVisible(model, { ...noFilters(), who: new Set(["Иванов Иван Иванович"]) }, [], "2026-10-14"); const narrow = computeVisible(model, { ...noFilters(), who: new Set(["Иванов Иван Иванович"]) }, [], "2026-10-14");
expect(narrow.nodes.length).toBeLessThan(wide.nodes.length); expect(narrow.nodes.length).toBeLessThan(wide.nodes.length);
@ -104,11 +108,37 @@ describe("BoardCanvas", () => {
renderWithProviders(<Harness />); renderWithProviders(<Harness />);
expect(board().querySelectorAll(".card[data-key]")).toHaveLength(wide.nodes.length); expect(board().querySelectorAll(".card[data-key]")).toHaveLength(wide.nodes.length);
fireEvent.click(screen.getByText("сузить")); fireEvent.click(screen.getByText("сузить"));
expect(board().querySelectorAll(".card[data-key]").length).toBeLessThanOrEqual(narrow.nodes.length); await waitFor(() => expect(board().querySelectorAll(".card[data-key]")).toHaveLength(narrow.nodes.length));
}); });
it("полоса дат получает ширину обёртки доски", () => { it("полоса дат получает ширину обёртки доски", () => {
draw({ byDate: true }); draw({ byDate: true });
expect((document.querySelector(".dates-in") as HTMLElement).style.width).toMatch(/px$|^0/); const wrap = document.querySelector(".wrap") as HTMLElement;
expect((document.querySelector(".dates-in") as HTMLElement).style.width).toBe(`${wrap.offsetWidth}px`);
});
it("T11.3: перетаскивание названия дорожки вниз — onMoveLane с новым местом", () => {
// в jsdom может не быть PointerEvent и захвата указателя — минимальные замены только для теста
if (typeof window.PointerEvent === "undefined") Object.defineProperty(window, "PointerEvent", { configurable: true, value: class extends MouseEvent {} });
if (typeof Element.prototype.setPointerCapture !== "function") Object.defineProperty(Element.prototype, "setPointerCapture", { configurable: true, value: () => {} });
const p = draw();
const name = [...board().querySelectorAll<HTMLElement>(".lane-name")].find((e) => e.textContent === "Гудвилл")!;
fireEvent.pointerDown(name, { pointerId: 1, clientY: 5 });
fireEvent.pointerMove(name, { pointerId: 1, clientY: 100000 });
fireEvent.pointerUp(name, { pointerId: 1, clientY: 100000 });
expect(p.onMoveLane).toHaveBeenCalledWith("Гудвилл", p.vis.lanes.length - 1);
});
it("M-3: прокрутка запоминается по событию scroll и восстанавливается при новом монтировании", () => {
const to = vi.spyOn(window, "scrollTo").mockImplementation(() => {});
const first = renderWithProviders(<BoardCanvas {...baseProps()} />);
Object.defineProperty(window, "scrollY", { configurable: true, value: 640 });
fireEvent.scroll(window);
expect(readBoardScroll()).toMatchObject({ y: 640 });
first.unmount();
renderWithProviders(<BoardCanvas {...baseProps()} />);
expect(to).toHaveBeenCalledWith(0, 640);
Object.defineProperty(window, "scrollY", { configurable: true, value: 0 });
vi.restoreAllMocks();
}); });
}); });

View File

@ -1,7 +1,8 @@
import { useCallback, useLayoutEffect, useMemo, useRef, useState, type PointerEvent, type ReactNode } from "react"; import { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState, type PointerEvent, type ReactNode } from "react";
import { CW, GRID, MINH, MINW, TOP } from "../constants.ts"; import { CW, GRID, MINH, MINW, TOP } from "../constants.ts";
import { alignEdges } from "../layout/align.ts"; import { alignEdges } from "../layout/align.ts";
import { clampToCol, computeLayout, measuredHeight, place, type Layout, type Placed, type RelBox } from "../layout/layout.ts"; import { clampToCol, computeLayout, measuredHeight, place, type Layout, type Placed, type RelBox } from "../layout/layout.ts";
import { readBoardScroll, saveBoardScroll } from "../scrollMemory.ts";
import { saveLayout, savedLayout } from "../storage.ts"; import { saveLayout, savedLayout } from "../storage.ts";
import type { Visible } from "../types.ts"; import type { Visible } from "../types.ts";
import { Card } from "./Card.tsx"; import { Card } from "./Card.tsx";
@ -131,6 +132,26 @@ export function BoardCanvas({ vis, byDate, snap, today, store, sprintSel, emptyT
return () => ro.disconnect(); return () => ro.disconnect();
}, [syncScroll, placed]); }, [syncScroll, placed]);
// M-3: прокрутка доски переживает переход в карточку задачи и обратно (R-1); восстанавливаем, когда карточки расставлены
const restore = useRef(readBoardScroll());
useLayoutEffect(() => {
const s = restore.current;
if (!s || !placed) return;
restore.current = null;
window.scrollTo(s.x, s.y);
if (wrapRef.current) wrapRef.current.scrollLeft = s.left;
syncScroll();
}, [placed, syncScroll]);
// запоминаем по событиям прокрутки, а не при размонтировании: к моменту ухода браузер может уже сжать прокрутку
// под короткую страницу карточки
useEffect(() => {
const w = wrapRef.current;
const save = () => saveBoardScroll({ x: window.scrollX, y: window.scrollY, left: w?.scrollLeft ?? 0 });
window.addEventListener("scroll", save, { passive: true });
w?.addEventListener("scroll", save, { passive: true });
return () => { window.removeEventListener("scroll", save); w?.removeEventListener("scroll", save); };
}, []);
const empty = !vis.nodes.length; const empty = !vis.nodes.length;
const multiSprint = layout?.multiSprint ?? false; const multiSprint = layout?.multiSprint ?? false;
// слой измерения рисует метку спринта так же, как доска: она влияет на высоту карточки (строка 661) // слой измерения рисует метку спринта так же, как доска: она влияет на высоту карточки (строка 661)

View File

@ -0,0 +1,16 @@
import { describe, expect, it } from "vitest";
import { cardPropsEqual, type CardProps } from "./Card.tsx";
const node = { key: "A-1" } as CardProps["node"];
const base: CardProps = { node, box: { x: 1, y: 2, w: 240, h: 120 }, rel: undefined, multiSprint: false, sprintSel: new Set() };
describe("cardPropsEqual (T11.1)", () => {
it("новый объект box с теми же числами — карточка не перерисовывается", () => {
expect(cardPropsEqual(base, { ...base, box: { x: 1, y: 2, w: 240, h: 120 } })).toBe(true);
});
it("сдвиг, другая карточка или другой выбор спринтов — перерисовка", () => {
expect(cardPropsEqual(base, { ...base, box: { x: 9, y: 2, w: 240, h: 120 } })).toBe(false);
expect(cardPropsEqual(base, { ...base, node: { key: "A-2" } as CardProps["node"] })).toBe(false);
expect(cardPropsEqual(base, { ...base, sprintSel: new Set(["S"]) })).toBe(false);
});
});

View File

@ -15,6 +15,13 @@ export interface CardProps {
onAutoSize?: (key: string) => void; onAutoSize?: (key: string) => void;
} }
// T11.1: при перетаскивании одной карточки place() создаёт новые box у всех — сравниваем box по значению
export function cardPropsEqual(a: CardProps, b: CardProps): boolean {
const sameBox = a.box === b.box || (!!a.box && !!b.box && a.box.x === b.box.x && a.box.y === b.box.y && a.box.w === b.box.w && a.box.h === b.box.h);
return sameBox && a.node === b.node && a.rel === b.rel && a.multiSprint === b.multiSprint && a.sprintSel === b.sprintSel &&
a.onPointerDown === b.onPointerDown && a.onAutoSize === b.onAutoSize;
}
export const Card = memo(function Card({ node: n, box, rel, multiSprint, sprintSel, onPointerDown, onAutoSize }: CardProps) { export const Card = memo(function Card({ node: n, box, rel, multiSprint, sprintSel, onPointerDown, onAutoSize }: CardProps) {
const cls = "card" + (n.dim || n.stub ? " out" : "") + (n.crit ? " crit" : "") + (n.focus ? " focus" : ""); const cls = "card" + (n.dim || n.stub ? " out" : "") + (n.crit ? " crit" : "") + (n.focus ? " focus" : "");
const style = box ? { left: box.x, top: box.y, width: box.w, height: box.h } : { width: CW }; const style = box ? { left: box.x, top: box.y, width: box.w, height: box.h } : { width: CW };
@ -47,4 +54,4 @@ export const Card = memo(function Card({ node: n, box, rel, multiSprint, sprintS
<div className="grip t" title="Тянуть за рамку" /><div className="grip b" /><div className="grip l" /><div className="grip r" /> <div className="grip t" title="Тянуть за рамку" /><div className="grip b" /><div className="grip l" /><div className="grip r" />
</div> </div>
); );
}); }, cardPropsEqual);

View File

@ -0,0 +1,20 @@
import { fireEvent, render } from "@testing-library/react";
import { createRef } from "react";
import { describe, expect, it, vi } from "vitest";
import type { DateCol } from "../layout/layout.ts";
import { DatesBar } from "./DatesBar.tsx";
describe("DatesBar (T11.2)", () => {
it("отложенный кадр растяжки отменяется при размонтировании", () => {
const raf = vi.spyOn(window, "requestAnimationFrame").mockImplementation(() => 42);
const caf = vi.spyOn(window, "cancelAnimationFrame").mockImplementation(() => {});
const cols: DateCol[] = [{ date: "2026-10-20", x0: 0, w: 300, cls: "", count: 1 }];
const r = render(<DatesBar cols={cols} width={300} scrollLeft={0} wrapW={300} inRef={createRef()} onColWidth={vi.fn()} />);
const split = r.container.querySelector(".dsplit") as HTMLElement;
fireEvent.pointerDown(split, { clientX: 300 });
fireEvent.pointerMove(window, { clientX: 340 });
expect(raf).toHaveBeenCalled();
r.unmount();
expect(caf).toHaveBeenCalledWith(42);
});
});

View File

@ -1,4 +1,4 @@
import { Fragment, useRef, type PointerEvent, type RefObject } from "react"; import { Fragment, useEffect, useRef, type PointerEvent, type RefObject } from "react";
import { COLW, GRID } from "../constants.ts"; import { COLW, GRID } from "../constants.ts";
import { dateText, dateTitle, type DateCol } from "../layout/layout.ts"; import { dateText, dateTitle, type DateCol } from "../layout/layout.ts";
@ -8,6 +8,7 @@ export function DatesBar({ cols, width, scrollLeft, wrapW, inRef, onColWidth }:
onColWidth(date: string, w: number | null): void; onColWidth(date: string, w: number | null): void;
}) { }) {
const raf = useRef(0); const raf = useRef(0);
useEffect(() => () => { if (raf.current) cancelAnimationFrame(raf.current); }, []); // T11.2
const down = (ev: PointerEvent<HTMLDivElement>, c: DateCol) => { const down = (ev: PointerEvent<HTMLDivElement>, c: DateCol) => {
ev.preventDefault(); ev.preventDefault();
const handle = ev.currentTarget, x0 = ev.clientX; const handle = ev.currentTarget, x0 = ev.clientX;

View File

@ -0,0 +1,16 @@
import { fireEvent, render, screen } from "@testing-library/react";
import { StrictMode } from "react";
import { describe, expect, it, vi } from "vitest";
import { Dropdown } from "./Dropdown.tsx";
describe("Dropdown (T12.1)", () => {
it("onOpen вызывается один раз на открытие, в том числе в StrictMode", () => {
const onOpen = vi.fn();
render(<StrictMode><Dropdown onOpen={onOpen} button={({ toggle }) => <button onClick={toggle}>открыть</button>}>{() => <p>меню</p>}</Dropdown></StrictMode>);
fireEvent.click(screen.getByText("открыть"));
expect(onOpen).toHaveBeenCalledTimes(1);
expect(screen.getByText("меню")).toBeInTheDocument();
fireEvent.click(screen.getByText("открыть"));
expect(onOpen).toHaveBeenCalledTimes(1);
});
});

View File

@ -13,7 +13,7 @@ export function Dropdown({ button, className = "", children, onOpen }: {
document.addEventListener("pointerdown", away); document.addEventListener("pointerdown", away);
return () => document.removeEventListener("pointerdown", away); return () => document.removeEventListener("pointerdown", away);
}, [open]); }, [open]);
const toggle = () => setOpen((o) => { if (!o) onOpen?.(); return !o; }); const toggle = () => { if (!open) onOpen?.(); setOpen(!open); }; // T12.1: побочное действие вне updater
return ( return (
<div className={"dd " + className} ref={ref} onKeyDown={(e) => { if (e.key === "Escape") setOpen(false); }}> <div className={"dd " + className} ref={ref} onKeyDown={(e) => { if (e.key === "Escape") setOpen(false); }}>
{button({ open, toggle })} {button({ open, toggle })}

View File

@ -13,14 +13,14 @@ export function MultiSelect({ items, selected, allLabel, onChange }: {
const flip = (v: string, on: boolean) => { const n = new Set(selected); if (on) n.add(v); else n.delete(v); onChange(n); }; const flip = (v: string, on: boolean) => { const n = new Set(selected); if (on) n.add(v); else n.delete(v); onChange(n); };
return ( return (
<Dropdown onOpen={() => setQ("")} button={({ open, toggle }) => ( <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}> <button type="button" className="btn dd-btn" aria-haspopup="dialog" aria-expanded={open} title={sel.map((i) => i.label).join(", ")} onClick={toggle}>
<span className="lbl">{label}</span><span className="car">▼</span> <span className="lbl">{label}</span><span className="car">▼</span>
</button> </button>
)}> )}>
{() => ( {() => (
<> <>
<input type="search" placeholder="Поиск…" aria-label="Поиск" autoFocus value={q} onChange={(e) => setQ(e.target.value)} /> <input type="search" placeholder="Поиск…" aria-label="Поиск" autoFocus value={q} onChange={(e) => setQ(e.target.value)} />
<div className="dd-list" role="listbox"> <div className="dd-list" role="group" aria-label={allLabel}>
{!shown.length && <div className="dd-empty">ничего не найдено</div>} {!shown.length && <div className="dd-empty">ничего не найдено</div>}
{shown.map((i) => ( {shown.map((i) => (
<label key={i.value} className={"dd-item" + (i.cls ? " " + i.cls : "")} title={i.title || i.label}> <label key={i.value} className={"dd-item" + (i.cls ? " " + i.cls : "")} title={i.title || i.label}>

View File

@ -0,0 +1,6 @@
// M-3: прокрутка доски на время жизни вкладки (R-1) — переход в карточку задачи и обратно её не сбрасывает,
// перезагрузка страницы сбрасывает (значение живёт в памяти модуля)
export interface BoardScroll { x: number; y: number; left: number }
let saved: BoardScroll | null = null;
export const readBoardScroll = (): BoardScroll | null => saved;
export const saveBoardScroll = (s: BoardScroll | null): void => { saved = s; };

View File

@ -33,24 +33,21 @@ export function useBoardPrefs(model: BoardModel): BoardPrefsApi {
const filters = useMemo<Filters>(() => ({ ...prefs.filters, sprints: sel.sprints, who: sel.who }), [prefs.filters, sel]); const filters = useMemo<Filters>(() => ({ ...prefs.filters, sprints: sel.sprints, who: sel.who }), [prefs.filters, sel]);
const toggle = useCallback((name: st.FlagName | "snap" | "table" | "problems") => { const toggle = useCallback((name: st.FlagName | "snap" | "table" | "problems") => {
setPrefs((p) => { const isView = name === "snap" || name === "table" || name === "problems";
if (name === "snap" || name === "table" || name === "problems") { st.writeFlag(name, !p[name]); return { ...p, [name]: !p[name] }; } const on = isView ? !prefs[name] : !prefs.filters[name];
st.writeFlag(name, !p.filters[name]); st.writeFlag(name, on);
return { ...p, filters: { ...p.filters, [name]: !p.filters[name] } }; setPrefs((p) => (isView ? { ...p, [name]: on } : { ...p, filters: { ...p.filters, [name]: on } }));
}); }, [prefs]);
}, []);
const setSprints = useCallback((s: Set<string>) => { st.writeSprints(s); setSel((x) => ({ ...x, sprints: new Set(s) })); }, []); 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 setWho = useCallback((s: Set<string>) => { st.writeWho(s); setSel((x) => ({ ...x, who: new Set(s) })); }, []);
const toggleHidden = useCallback((what: "kind" | "edge", value: string) => { const toggleHidden = useCallback((what: "kind" | "edge", value: string) => {
setPrefs((p) => {
const key = what === "kind" ? "hideKind" : "hideEdge"; const key = what === "kind" ? "hideKind" : "hideEdge";
const next = new Set(p.filters[key]); const next = new Set(prefs.filters[key]);
if (next.has(value)) next.delete(value); else next.add(value); if (next.has(value)) next.delete(value); else next.add(value);
const filters = { ...p.filters, [key]: next }; const filters = { ...prefs.filters, [key]: next };
st.writeHidden(filters.hideKind, filters.hideEdge); st.writeHidden(filters.hideKind, filters.hideEdge);
return { ...p, filters }; setPrefs((p) => ({ ...p, filters: { ...p.filters, [key]: next } }));
}); }, [prefs]);
}, []);
const toggleLane = useCallback((name: string) => { st.toggleLane(name); bump(); }, [bump]); 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 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 setColWidth = useCallback((date: string, w: number | null) => { st.setColWidth(date, w); bump(); }, [bump]);

View File

@ -1,8 +1,10 @@
import "@testing-library/jest-dom/vitest"; import "@testing-library/jest-dom/vitest";
import { cleanup } from "@testing-library/react"; import { cleanup } from "@testing-library/react";
import { afterEach } from "vitest"; import { afterEach } from "vitest";
import { saveBoardScroll } from "../board/scrollMemory.ts";
afterEach(() => { afterEach(() => {
cleanup(); cleanup();
saveBoardScroll(null);
localStorage.clear(); localStorage.clear();
}); });