From 154cc88913eabcab20c436ba79bfa1389c8f1168 Mon Sep 17 00:00:00 2001 From: Andrey Epifancev Date: Mon, 26 Oct 2026 00:59:03 +0400 Subject: [PATCH] =?UTF-8?q?=D0=9E=D0=BA=D0=BD=D0=BE=20=D0=BA=D0=B0=D1=80?= =?UTF-8?q?=D1=82=D0=BE=D1=87=D0=BA=D0=B8:=20=D0=BF=D0=BE=D0=B8=D1=81?= =?UTF-8?q?=D0=BA=20=D0=BD=D0=B5=20=D0=B7=D0=B0=D0=BA=D1=80=D1=8B=D0=B2?= =?UTF-8?q?=D0=B0=D0=B5=D1=82=20=D0=BE=D0=BA=D0=BD=D0=BE,=20=D0=BF=D0=BE?= =?UTF-8?q?=D0=B2=D1=82=D0=BE=D1=80=D0=BD=D0=BE=D0=B5=20=D0=B7=D0=B0=D0=BA?= =?UTF-8?q?=D1=80=D1=8B=D1=82=D0=B8=D0=B5=20=D0=B8=D0=B3=D0=BD=D0=BE=D1=80?= =?UTF-8?q?=D0=B8=D1=80=D1=83=D0=B5=D1=82=D1=81=D1=8F,=20=D1=84=D0=BE?= =?UTF-8?q?=D0=BA=D1=83=D1=81=20=D0=BF=D0=BE=D0=B4=D1=82=D0=B2=D0=B5=D1=80?= =?UTF-8?q?=D0=B6=D0=B4=D0=B5=D0=BD=D0=B8=D1=8F=20=D1=83=D1=85=D0=BE=D0=B4?= =?UTF-8?q?=D0=B0=20(#124,=20#42=20M-1,=20M-2,=20M-4)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5.5 --- web/e2e/issues.spec.ts | 13 +++++ web/src/modules/jira/issues/IssueLink.tsx | 14 +++-- .../modules/jira/issues/IssueModal.test.tsx | 55 +++++++++++++++++++ web/src/modules/jira/issues/IssueTable.tsx | 2 +- .../modules/jira/issues/IssuesPage.test.tsx | 12 +++- web/src/modules/jira/issues/IssuesPage.tsx | 28 ++++++++-- web/src/modules/jira/issues/NoteForm.tsx | 10 +++- web/src/modules/jira/issues/overlay.tsx | 9 ++- 8 files changed, 127 insertions(+), 16 deletions(-) diff --git a/web/e2e/issues.spec.ts b/web/e2e/issues.spec.ts index 999ca30..6dbf3b5 100644 --- a/web/e2e/issues.spec.ts +++ b/web/e2e/issues.spec.ts @@ -207,3 +207,16 @@ test("закрытые скрыты по умолчанию, «Все» пока await expect(page.getByRole("link", { name: "AURORA-9006", exact: true })).toBeVisible(); await expect(page.locator("table.issue-table tbody tr")).toHaveCount(10); }); + +test("поиск перед открытием карточки: окно не закрывается, адрес списка сохраняет q", async ({ page }) => { + await login(page, "/app/jira/issues"); + await expect(page.locator("table.issue-table tbody tr")).toHaveCount(9); + await page.getByRole("searchbox").fill("свод"); + await page.locator("table.issue-table tbody tr").first().getByRole("link").click(); + await expect(page.getByRole("dialog")).toBeVisible(); + await page.waitForTimeout(600); + await expect(page.getByRole("dialog")).toBeVisible(); + await page.keyboard.press("Escape"); + await expect(page.getByRole("dialog")).toHaveCount(0); + await expect(page).toHaveURL(/\/app\/jira\/issues\?.*q=/); +}); diff --git a/web/src/modules/jira/issues/IssueLink.tsx b/web/src/modules/jira/issues/IssueLink.tsx index b214b55..ce43e3c 100644 --- a/web/src/modules/jira/issues/IssueLink.tsx +++ b/web/src/modules/jira/issues/IssueLink.tsx @@ -1,8 +1,8 @@ -import type { ReactNode } from "react"; +import type { MouseEvent, ReactNode } from "react"; import { Link, useLocation, useNavigate } from "react-router"; import { openCardState } from "./modal.ts"; -const cardPath = (key: string) => `/jira/issues/${encodeURIComponent(key)}`; +export const cardPath = (key: string) => `/jira/issues/${encodeURIComponent(key)}`; // переход к карточке задачи: окно поверх текущей страницы (или смена содержимого открытого окна) — как у ссылки-ключа export function useOpenIssue(): (key: string) => void { @@ -12,7 +12,13 @@ export function useOpenIssue(): (key: string) => void { } // ссылка на карточку задачи: открывает окно поверх текущей страницы (или меняет содержимое открытого окна) -export function IssueLink({ issueKey, className, children }: { issueKey: string; className?: string; children?: ReactNode }) { +// onOpen: обычный щелчок (левая кнопка без модификаторов) отдаётся вызывающему; Ctrl/Cmd/Shift/средняя кнопка — обычная ссылка +export function IssueLink({ issueKey, className, children, onOpen }: { issueKey: string; className?: string; children?: ReactNode; onOpen?(key: string): void }) { const loc = useLocation(); - return {children ?? issueKey}; + const click = onOpen && ((e: MouseEvent) => { + if (e.defaultPrevented || e.button !== 0 || e.ctrlKey || e.metaKey || e.shiftKey || e.altKey) return; + e.preventDefault(); + onOpen(issueKey); + }); + return {children ?? issueKey}; } diff --git a/web/src/modules/jira/issues/IssueModal.test.tsx b/web/src/modules/jira/issues/IssueModal.test.tsx index 5ce2091..ed6ff1b 100644 --- a/web/src/modules/jira/issues/IssueModal.test.tsx +++ b/web/src/modules/jira/issues/IssueModal.test.tsx @@ -137,6 +137,8 @@ describe("карточка окном", () => { await userEvent.click(within(dialog()).getByRole("button", { name: "Остаться" })); expect(router.state.location.pathname).toBe("/app/jira/issues/AURORA-9001"); expect(within(dialog()).getByLabelText("Заметка владельца")).toHaveValue("черновик"); + fireEvent(dialog(), new Event("cancel", { cancelable: true })); // после «Остаться» повторный Esc снова спрашивает (closingKey сброшен) + expect(await within(dialog()).findByRole("button", { name: "Уйти" })).toBeInTheDocument(); }); it("T4.1: отказ от закрытия («Остаться») сбрасывает ожидание закрытия: «назад» на запись прямого адреса не уводит в список", async () => { @@ -155,6 +157,59 @@ describe("карточка окном", () => { expect(router.state.location.pathname).toBe("/app/jira/issues/AURORA-9001"); }); + it("поиск перед открытием не закрывает окно (#42 M-1): ключ и ячейка темы", async () => { + for (const open of ["key", "sum"] as const) { + const router = at(["/app/jira/issues"]); + const { unmount } = render(); + await screen.findByRole("table"); + await userEvent.type(screen.getByRole("searchbox"), "свод"); + if (open === "key") await userEvent.click(screen.getByRole("link", { name: "AURORA-9002" })); + else await userEvent.click(screen.getByRole("cell", { name: "API свода" })); + await within(dialog()).findByRole("heading", { name: /AURORA-9002/ }); + await new Promise((r) => setTimeout(r, 400)); + expect(screen.getByRole("dialog")).toBeInTheDocument(); + expect(router.state.location.pathname).toBe("/app/jira/issues/AURORA-9002"); + await userEvent.click(within(dialog()).getByRole("button", { name: "Закрыть" })); + await waitFor(() => expect(screen.queryByRole("dialog")).toBeNull()); + expect(router.state.location.pathname).toBe("/app/jira/issues"); + expect(new URLSearchParams(router.state.location.search).get("q")).toBe("свод"); + unmount(); + } + }); + + it("двойной щелчок по ✕ не уводит дальше назад (#42 M-2)", async () => { + const router = at(["/app/jira/links", "/app/jira/issues"]); + render(); + await userEvent.click(await screen.findByRole("link", { name: "AURORA-9001" })); + await userEvent.click(await within(dialog()).findByRole("link", { name: "AURORA-9002" })); + await within(dialog()).findByRole("heading", { name: /AURORA-9002/ }); + const x = within(dialog()).getByRole("button", { name: "Закрыть" }); + await act(async () => { fireEvent.click(x); fireEvent.click(x); }); // оба щелчка до перерисовки + await waitFor(() => expect(screen.queryByRole("dialog")).toBeNull()); + expect(router.state.location.pathname).toBe("/app/jira/issues"); + }); + + it("двойной ✕ у прямого адреса: адрес списка, дальше по истории не уходит (Review Focus 2)", async () => { + const router = at(["/app/jira/issues/AURORA-9002"]); + render(); + await within(await screen.findByRole("dialog")).findByRole("heading", { name: /AURORA-9002/ }); + await waitFor(() => expect(router.state.location.state).toMatchObject({ depth: 0, direct: true })); + await userEvent.dblClick(within(dialog()).getByRole("button", { name: "Закрыть" })); + await waitFor(() => expect(screen.queryByRole("dialog")).toBeNull()); + expect(router.state.location.pathname).toBe("/app/jira/issues"); + }); + + it("подтверждение ухода: имя и фокус на «Остаться» (#42 M-4)", async () => { + const router = at(["/app/jira/issues"]); + render(); + await userEvent.click(await screen.findByRole("link", { name: "AURORA-9001" })); + await userEvent.click(await within(dialog()).findByRole("button", { name: "Править" })); + await userEvent.type(await within(dialog()).findByLabelText("Заметка владельца"), "черновик"); + fireEvent(dialog(), new Event("cancel", { cancelable: true })); + const ask = await within(dialog()).findByRole("alertdialog", { name: "Уйти без сохранения заметки?" }); + await waitFor(() => expect(document.activeElement).toBe(within(ask).getByRole("button", { name: "Остаться" }))); + }); + it("T4.4: у окна есть имя в состоянии загрузки и 404", async () => { mockFetch((_m, url) => { if (url.pathname === "/settings") return json(200, settings); diff --git a/web/src/modules/jira/issues/IssueTable.tsx b/web/src/modules/jira/issues/IssueTable.tsx index ecacc71..13f8cab 100644 --- a/web/src/modules/jira/issues/IssueTable.tsx +++ b/web/src/modules/jira/issues/IssueTable.tsx @@ -35,7 +35,7 @@ export function IssueTable({ rows, epicNames, closed, onOpen }: { const due = dueOf(i); return ( rowClick(e, i.key, onOpen)}> - {i.neighbor && сосед} + {i.neighbor && сосед} {i.summary} {i.status && {i.status}} {i.assignee} diff --git a/web/src/modules/jira/issues/IssuesPage.test.tsx b/web/src/modules/jira/issues/IssuesPage.test.tsx index 86ffda9..a1795eb 100644 --- a/web/src/modules/jira/issues/IssuesPage.test.tsx +++ b/web/src/modules/jira/issues/IssuesPage.test.tsx @@ -1,4 +1,4 @@ -import { act, screen, waitFor, within } from "@testing-library/react"; +import { act, 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"; @@ -41,6 +41,16 @@ describe("список задач", () => { expect(q.get("offset")).toBe("0"); }); + it("ключ строки: href прежний, щелчок с Ctrl не открывает карточку внутри приложения (Review Focus 1)", async () => { + hub(() => [row(1)]); + const { router } = renderWithProviders(, { route: "/issues", path: "/issues" }); + const link = await screen.findByRole("link", { name: "AURORA-9001" }); + expect(link).toHaveAttribute("href", "/jira/issues/AURORA-9001"); + fireEvent.click(link, { ctrlKey: true }); + fireEvent.click(link, { metaKey: true }); + expect(router.state.location.pathname).toBe("/issues"); + }); + it("выбор исполнителя пишет его в адрес и в запрос", async () => { expanded(); const fetch = hub(() => [row(1)]); diff --git a/web/src/modules/jira/issues/IssuesPage.tsx b/web/src/modules/jira/issues/IssuesPage.tsx index a3f97e9..c612f0a 100644 --- a/web/src/modules/jira/issues/IssuesPage.tsx +++ b/web/src/modules/jira/issues/IssuesPage.tsx @@ -1,11 +1,12 @@ import { useEffect, useMemo, useRef, useState } from "react"; -import { useSearchParams } from "react-router"; +import { useLocation, useNavigate, useSearchParams } from "react-router"; import { isClosed, useClosedStatuses } from "../api/closed.ts"; import { useSettings } from "../../../api/queries.ts"; import { ISSUE_FILTER_NAMES, useBoard, useIssues, type IssueFilters as Filters } from "../api/queries.ts"; import { Button, Notice, PageHeader } from "../../../ui/index.ts"; import { IssueFilters, SELECTS } from "./IssueFilters.tsx"; -import { useOpenIssue } from "./IssueLink.tsx"; +import { cardPath } from "./IssueLink.tsx"; +import { openCardState } from "./modal.ts"; import { RefreshButton } from "../RefreshButton.tsx"; import { IssueTable } from "./IssueTable.tsx"; import { epicNames, filterOptions } from "./options.ts"; @@ -28,14 +29,32 @@ export function IssuesPage({ project }: { project: string }) { written.current = fromUrl; setQ(fromUrl); }, [filters.q]); + const timer = useRef | undefined>(undefined); useEffect(() => { if (q === (filters.q ?? "")) return; - const t = setTimeout(() => { + timer.current = setTimeout(() => { written.current = q; setParams((p) => { const n = new URLSearchParams(p); if (q) n.set("q", q); else n.delete("q"); return n; }, { replace: true }); }, Q_DELAY); - return () => clearTimeout(t); + return () => clearTimeout(timer.current); }, [q, filters.q, setParams]); + // открытие карточки до срабатывания таймера поиска: q сначала пишется в запись списка (replace), окно открывается поверх неё — + // иначе таймер переписал бы адрес карточки и закрыл окно (#42 M-1) + const loc = useLocation(); + const navigate = useNavigate(); + const open = (key: string) => { + clearTimeout(timer.current); + let bg = loc; + if (q !== (filters.q ?? "")) { + const n = new URLSearchParams(loc.search); + if (q) n.set("q", q); else n.delete("q"); + const search = n.size ? "?" + n.toString() : ""; + written.current = q; + void navigate({ pathname: loc.pathname, search }, { replace: true, state: loc.state }); + bg = { ...loc, search }; + } + void navigate(cardPath(key), { state: openCardState(bg) }); + }; const board = useBoard(project), settings = useSettings(); const opts = useMemo(() => filterOptions(board.data, settings.data), [board.data, settings.data]); @@ -47,7 +66,6 @@ export function IssuesPage({ project }: { project: string }) { [filters, project, showClosed, closed]); const list = useIssues(query); const rows = list.data?.pages.flat() ?? []; - const open = useOpenIssue(); return (
diff --git a/web/src/modules/jira/issues/NoteForm.tsx b/web/src/modules/jira/issues/NoteForm.tsx index 1a66171..f43a866 100644 --- a/web/src/modules/jira/issues/NoteForm.tsx +++ b/web/src/modules/jira/issues/NoteForm.tsx @@ -39,7 +39,11 @@ export function NoteForm({ issue, onDone }: { issue: IssueDetail; onDone: () => return () => window.removeEventListener("beforeunload", warn); }, [dirty]); // подтверждение ухода может оказаться ниже видимой части окна — прокрутить к нему - useEffect(() => { if (blocker.state === "blocked") confirmRef.current?.scrollIntoView?.({ block: "nearest" }); }, [blocker.state]); + useEffect(() => { + if (blocker.state !== "blocked") return; + confirmRef.current?.scrollIntoView?.({ block: "nearest" }); + confirmRef.current?.querySelector("[data-stay]")?.focus(); + }, [blocker.state]); const kinds = Object.keys(settings.data?.kind_labels ?? {}); const kindOptions = values.work_kind && !kinds.includes(values.work_kind) ? [values.work_kind, ...kinds] : kinds; @@ -79,11 +83,11 @@ export function NoteForm({ issue, onDone }: { issue: IssueDetail; onDone: () =>
)} {blocker.state === "blocked" && ( -
+
Есть несохранённые правки заметки. Уйти без сохранения?
- +
)} diff --git a/web/src/modules/jira/issues/overlay.tsx b/web/src/modules/jira/issues/overlay.tsx index b980c70..4ece74d 100644 --- a/web/src/modules/jira/issues/overlay.tsx +++ b/web/src/modules/jira/issues/overlay.tsx @@ -34,13 +34,18 @@ export function useIssueOverlay(): Overlay { void navigate(ISSUES_PATH + direct.search, { replace: true }); } }, [card, navigate, direct.search]); + // повторное закрытие (двойной щелчок по ✕) на той же записи истории игнорируется (#42 M-2) + const closingKey = useRef(null); + useEffect(() => { closingKey.current = null; }, [location.key]); const close = useCallback(() => { + if (closingKey.current === location.key) return; + closingKey.current = location.key; if (!card || card.depth === 0) { void navigate(ISSUES_PATH + direct.search, { replace: true }); return; } closingDirect.current = card.direct; void navigate(-card.depth); - }, [card, navigate, direct.search]); + }, [card, navigate, direct.search, location.key]); - const abortClose = useCallback(() => { closingDirect.current = false; }, []); + const abortClose = useCallback(() => { closingDirect.current = false; closingKey.current = null; }, []); // «Назад» в окне — только если оно открыто переходом из другой карточки (из списка depth = 1; прямой адрес — direct, depth 0) const onBack = card && (card.depth >= 2 || (card.direct && card.depth >= 1)) ? () => void navigate(-1) : undefined;