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).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);
|
||||||
|
});
|
||||||
|
|||||||
@ -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();
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@ -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>;
|
||||||
|
|||||||
@ -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();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@ -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)
|
||||||
|
|||||||
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;
|
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);
|
||||||
|
|||||||
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 { 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;
|
||||||
|
|||||||
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);
|
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 })}
|
||||||
|
|||||||
@ -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}>
|
||||||
|
|||||||
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 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]);
|
||||||
|
|||||||
@ -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();
|
||||||
});
|
});
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user