From 960a71d62cf0214856b2841db837722a4718a3fb Mon Sep 17 00:00:00 2001 From: Andrey Epifancev Date: Thu, 15 Oct 2026 01:17:43 +0400 Subject: [PATCH] =?UTF-8?q?feat(web):=20=D0=BF=D1=80=D0=BE=D0=BA=D1=80?= =?UTF-8?q?=D1=83=D1=82=D0=BA=D0=B0=20=D0=B4=D0=BE=D1=81=D0=BA=D0=B8=20?= =?UTF-8?q?=D0=BF=D0=BE=D1=81=D0=BB=D0=B5=20=D0=B2=D0=BE=D0=B7=D0=B2=D1=80?= =?UTF-8?q?=D0=B0=D1=82=D0=B0,=20memo=20=D0=BA=D0=B0=D1=80=D1=82=D0=BE?= =?UTF-8?q?=D1=87=D0=B5=D0=BA,=20=D0=BE=D0=B1=D1=80=D0=B0=D0=B1=D0=BE?= =?UTF-8?q?=D1=82=D1=87=D0=B8=D0=BA=D0=B8=20=D0=B1=D0=B5=D0=B7=20=D0=BF?= =?UTF-8?q?=D0=BE=D0=B1=D0=BE=D1=87=D0=BD=D1=8B=D1=85=20=D0=B4=D0=B5=D0=B9?= =?UTF-8?q?=D1=81=D1=82=D0=B2=D0=B8=D0=B9=20=D0=B2=20updater=20(M-3,=20T11?= =?UTF-8?q?.1=E2=80=93T11.4,=20T12.1=E2=80=93T12.4,=20#29)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5.5 --- web/e2e/board.spec.ts | 12 ++++++ web/src/board/BoardPage.test.tsx | 28 ++++++++++++- web/src/board/BoardPage.tsx | 21 ++++------ web/src/board/components/BoardCanvas.test.tsx | 42 ++++++++++++++++--- web/src/board/components/BoardCanvas.tsx | 23 +++++++++- web/src/board/components/Card.test.ts | 16 +++++++ web/src/board/components/Card.tsx | 9 +++- web/src/board/components/DatesBar.test.tsx | 20 +++++++++ web/src/board/components/DatesBar.tsx | 3 +- web/src/board/components/Dropdown.test.tsx | 16 +++++++ web/src/board/components/Dropdown.tsx | 2 +- web/src/board/components/MultiSelect.tsx | 4 +- web/src/board/scrollMemory.ts | 6 +++ web/src/board/useBoardPrefs.ts | 27 ++++++------ web/src/test/setup.ts | 2 + 15 files changed, 191 insertions(+), 40 deletions(-) create mode 100644 web/src/board/components/Card.test.ts create mode 100644 web/src/board/components/DatesBar.test.tsx create mode 100644 web/src/board/components/Dropdown.test.tsx create mode 100644 web/src/board/scrollMemory.ts diff --git a/web/e2e/board.spec.ts b/web/e2e/board.spec.ts index 5519d69..8ab28ef 100644 --- a/web/e2e/board.spec.ts +++ b/web/e2e/board.spec.ts @@ -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); +}); diff --git a/web/src/board/BoardPage.test.tsx b/web/src/board/BoardPage.test.tsx index 19fc5ed..9ef0da5 100644 --- a/web/src/board/BoardPage.test.tsx +++ b/web/src/board/BoardPage.test.tsx @@ -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(, { 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(); + }); }); diff --git a/web/src/board/BoardPage.tsx b/web/src/board/BoardPage.tsx index 03ae483..ecc58c2 100644 --- a/web/src/board/BoardPage.tsx +++ b/web/src/board/BoardPage.tsx @@ -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(null); const saveRef = useRef<(() => void) | null>(null); - const headRef = useRef(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

Загрузка настроек хаба…

; if (settings.error) return

Настройки хаба не загрузились: {settings.error.message}

; diff --git a/web/src/board/components/BoardCanvas.test.tsx b/web/src/board/components/BoardCanvas.test.tsx index 757fe14..a8d6ce8 100644 --- a/web/src/board/components/BoardCanvas.test.tsx +++ b/web/src/board/components/BoardCanvas.test.tsx @@ -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[0]> = {}) { +function baseProps(over: Partial[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(), emptyText: "пусто", + return { vis, byDate: false, snap: true, today: "2026-10-14", store, sprintSel: new Set(), emptyText: "пусто", onToggleLane: vi.fn(), onMoveLane: vi.fn(), onColWidth: vi.fn(), ...over }; +} +function draw(over: Partial[0]> = {}) { + const props = baseProps(over); renderWithProviders(); 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(); 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(".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(); + Object.defineProperty(window, "scrollY", { configurable: true, value: 640 }); + fireEvent.scroll(window); + expect(readBoardScroll()).toMatchObject({ y: 640 }); + first.unmount(); + renderWithProviders(); + expect(to).toHaveBeenCalledWith(0, 640); + Object.defineProperty(window, "scrollY", { configurable: true, value: 0 }); + vi.restoreAllMocks(); }); }); diff --git a/web/src/board/components/BoardCanvas.tsx b/web/src/board/components/BoardCanvas.tsx index ff2b6b7..1f8865b 100644 --- a/web/src/board/components/BoardCanvas.tsx +++ b/web/src/board/components/BoardCanvas.tsx @@ -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) diff --git a/web/src/board/components/Card.test.ts b/web/src/board/components/Card.test.ts new file mode 100644 index 0000000..2945119 --- /dev/null +++ b/web/src/board/components/Card.test.ts @@ -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); + }); +}); diff --git a/web/src/board/components/Card.tsx b/web/src/board/components/Card.tsx index 615eac0..9d05362 100644 --- a/web/src/board/components/Card.tsx +++ b/web/src/board/components/Card.tsx @@ -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
); -}); +}, cardPropsEqual); diff --git a/web/src/board/components/DatesBar.test.tsx b/web/src/board/components/DatesBar.test.tsx new file mode 100644 index 0000000..6f4e373 --- /dev/null +++ b/web/src/board/components/DatesBar.test.tsx @@ -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(); + 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); + }); +}); diff --git a/web/src/board/components/DatesBar.tsx b/web/src/board/components/DatesBar.tsx index dda5021..ccbb2e8 100644 --- a/web/src/board/components/DatesBar.tsx +++ b/web/src/board/components/DatesBar.tsx @@ -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, c: DateCol) => { ev.preventDefault(); const handle = ev.currentTarget, x0 = ev.clientX; diff --git a/web/src/board/components/Dropdown.test.tsx b/web/src/board/components/Dropdown.test.tsx new file mode 100644 index 0000000..8be5b6f --- /dev/null +++ b/web/src/board/components/Dropdown.test.tsx @@ -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( }>{() =>

меню

}
); + fireEvent.click(screen.getByText("открыть")); + expect(onOpen).toHaveBeenCalledTimes(1); + expect(screen.getByText("меню")).toBeInTheDocument(); + fireEvent.click(screen.getByText("открыть")); + expect(onOpen).toHaveBeenCalledTimes(1); + }); +}); diff --git a/web/src/board/components/Dropdown.tsx b/web/src/board/components/Dropdown.tsx index 6a87228..f7eca59 100644 --- a/web/src/board/components/Dropdown.tsx +++ b/web/src/board/components/Dropdown.tsx @@ -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 (
{ if (e.key === "Escape") setOpen(false); }}> {button({ open, toggle })} diff --git a/web/src/board/components/MultiSelect.tsx b/web/src/board/components/MultiSelect.tsx index 77d6587..fdb587c 100644 --- a/web/src/board/components/MultiSelect.tsx +++ b/web/src/board/components/MultiSelect.tsx @@ -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 ( setQ("")} button={({ open, toggle }) => ( - )}> {() => ( <> setQ(e.target.value)} /> -
+
{!shown.length &&
ничего не найдено
} {shown.map((i) => (