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:
parent
841ce4ea45
commit
960a71d62c
@ -57,3 +57,15 @@ test("ⓘ открывает карточку задачи в приложени
|
||||
await expect(page).toHaveURL(/\/app\/issues\/AURORA-9001$/);
|
||||
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);
|
||||
});
|
||||
|
||||
@ -2,7 +2,8 @@ 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 { StrictMode } from "react";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { setToken } from "../auth/token.ts";
|
||||
import { json, mockFetch } from "../test/fetchMock.ts";
|
||||
import { renderWithProviders } from "../test/render.tsx";
|
||||
@ -132,4 +133,29 @@ describe("страница доски", () => {
|
||||
page();
|
||||
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();
|
||||
});
|
||||
});
|
||||
|
||||
@ -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 { computeVisible, emptyReason } from "./analysis/visible.ts";
|
||||
import { BoardCanvas } from "./components/BoardCanvas.tsx";
|
||||
@ -25,19 +25,16 @@ export function BoardPage() {
|
||||
[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;
|
||||
// высота закреплённой шапки — ящик «Проблемы» и полоса дат липнут под ней (строки 1016–1019); при уходе — снимаем
|
||||
const headRef = useCallback((el: HTMLDivElement | null) => {
|
||||
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();
|
||||
if (typeof ResizeObserver === "undefined") return;
|
||||
const ro = new ResizeObserver(set);
|
||||
ro.observe(el);
|
||||
return () => ro.disconnect();
|
||||
});
|
||||
const ro = typeof ResizeObserver === "undefined" ? null : new ResizeObserver(set);
|
||||
ro?.observe(el);
|
||||
return () => { ro?.disconnect(); root.removeProperty("--head-h"); };
|
||||
}, []);
|
||||
|
||||
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>;
|
||||
|
||||
@ -1,5 +1,5 @@
|
||||
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 { join } from "node:path";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
@ -10,6 +10,7 @@ import { dictsFromSettings } from "../dicts.ts";
|
||||
import settings from "../__fixtures__/settings.json";
|
||||
import { fromHub } from "../model/fromHub.ts";
|
||||
import { noFilters } from "../types.ts";
|
||||
import { readBoardScroll } from "../scrollMemory.ts";
|
||||
import { BoardCanvas, type LayoutStore } from "./BoardCanvas.tsx";
|
||||
|
||||
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 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 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 };
|
||||
}
|
||||
function draw(over: Partial<Parameters<typeof BoardCanvas>[0]> = {}) {
|
||||
const props = baseProps(over);
|
||||
renderWithProviders(<BoardCanvas {...props} />);
|
||||
return props;
|
||||
}
|
||||
@ -87,7 +91,7 @@ describe("BoardCanvas", () => {
|
||||
expect(screen.getByText("Фильтры не оставили")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("сужение выборки при смене фильтра не роняет холст (карточки старой раскладки пропускаются)", () => {
|
||||
it("сужение выборки при смене фильтра не роняет холст (карточки старой раскладки пропускаются)", async () => {
|
||||
const wide = computeVisible(model, noFilters(), [], "2026-10-14");
|
||||
const narrow = computeVisible(model, { ...noFilters(), who: new Set(["Иванов Иван Иванович"]) }, [], "2026-10-14");
|
||||
expect(narrow.nodes.length).toBeLessThan(wide.nodes.length);
|
||||
@ -104,11 +108,37 @@ describe("BoardCanvas", () => {
|
||||
renderWithProviders(<Harness />);
|
||||
expect(board().querySelectorAll(".card[data-key]")).toHaveLength(wide.nodes.length);
|
||||
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("полоса дат получает ширину обёртки доски", () => {
|
||||
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();
|
||||
});
|
||||
});
|
||||
|
||||
@ -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 { alignEdges } from "../layout/align.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 type { Visible } from "../types.ts";
|
||||
import { Card } from "./Card.tsx";
|
||||
@ -131,6 +132,26 @@ export function BoardCanvas({ vis, byDate, snap, today, store, sprintSel, emptyT
|
||||
return () => ro.disconnect();
|
||||
}, [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 multiSprint = layout?.multiSprint ?? false;
|
||||
// слой измерения рисует метку спринта так же, как доска: она влияет на высоту карточки (строка 661)
|
||||
|
||||
16
web/src/board/components/Card.test.ts
Normal file
16
web/src/board/components/Card.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
@ -15,6 +15,13 @@ export interface CardProps {
|
||||
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) {
|
||||
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 };
|
||||
@ -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>
|
||||
);
|
||||
});
|
||||
}, cardPropsEqual);
|
||||
|
||||
20
web/src/board/components/DatesBar.test.tsx
Normal file
20
web/src/board/components/DatesBar.test.tsx
Normal 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);
|
||||
});
|
||||
});
|
||||
@ -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 { 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;
|
||||
}) {
|
||||
const raf = useRef(0);
|
||||
useEffect(() => () => { if (raf.current) cancelAnimationFrame(raf.current); }, []); // T11.2
|
||||
const down = (ev: PointerEvent<HTMLDivElement>, c: DateCol) => {
|
||||
ev.preventDefault();
|
||||
const handle = ev.currentTarget, x0 = ev.clientX;
|
||||
|
||||
16
web/src/board/components/Dropdown.test.tsx
Normal file
16
web/src/board/components/Dropdown.test.tsx
Normal 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);
|
||||
});
|
||||
});
|
||||
@ -13,7 +13,7 @@ export function Dropdown({ button, className = "", children, onOpen }: {
|
||||
document.addEventListener("pointerdown", away);
|
||||
return () => document.removeEventListener("pointerdown", away);
|
||||
}, [open]);
|
||||
const toggle = () => setOpen((o) => { if (!o) onOpen?.(); return !o; });
|
||||
const toggle = () => { if (!open) onOpen?.(); setOpen(!open); }; // T12.1: побочное действие вне updater
|
||||
return (
|
||||
<div className={"dd " + className} ref={ref} onKeyDown={(e) => { if (e.key === "Escape") setOpen(false); }}>
|
||||
{button({ open, toggle })}
|
||||
|
||||
@ -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); };
|
||||
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}>
|
||||
<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>
|
||||
</button>
|
||||
)}>
|
||||
{() => (
|
||||
<>
|
||||
<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.map((i) => (
|
||||
<label key={i.value} className={"dd-item" + (i.cls ? " " + i.cls : "")} title={i.title || i.label}>
|
||||
|
||||
6
web/src/board/scrollMemory.ts
Normal file
6
web/src/board/scrollMemory.ts
Normal 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; };
|
||||
@ -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 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 isView = name === "snap" || name === "table" || name === "problems";
|
||||
const on = isView ? !prefs[name] : !prefs.filters[name];
|
||||
st.writeFlag(name, on);
|
||||
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 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 key = what === "kind" ? "hideKind" : "hideEdge";
|
||||
const next = new Set(prefs.filters[key]);
|
||||
if (next.has(value)) next.delete(value); else next.add(value);
|
||||
const filters = { ...prefs.filters, [key]: next };
|
||||
st.writeHidden(filters.hideKind, filters.hideEdge);
|
||||
setPrefs((p) => ({ ...p, filters: { ...p.filters, [key]: next } }));
|
||||
}, [prefs]);
|
||||
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]);
|
||||
|
||||
@ -1,8 +1,10 @@
|
||||
import "@testing-library/jest-dom/vitest";
|
||||
import { cleanup } from "@testing-library/react";
|
||||
import { afterEach } from "vitest";
|
||||
import { saveBoardScroll } from "../board/scrollMemory.ts";
|
||||
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
saveBoardScroll(null);
|
||||
localStorage.clear();
|
||||
});
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user