Окно карточки: поиск не закрывает окно, повторное закрытие игнорируется, фокус подтверждения ухода (#124, #42 M-1, M-2, M-4)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
parent
7b297fef23
commit
154cc88913
@ -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=/);
|
||||
});
|
||||
|
||||
@ -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 <Link className={className} to={cardPath(issueKey)} state={openCardState(loc)}>{children ?? issueKey}</Link>;
|
||||
const click = onOpen && ((e: MouseEvent<HTMLAnchorElement>) => {
|
||||
if (e.defaultPrevented || e.button !== 0 || e.ctrlKey || e.metaKey || e.shiftKey || e.altKey) return;
|
||||
e.preventDefault();
|
||||
onOpen(issueKey);
|
||||
});
|
||||
return <Link className={className} to={cardPath(issueKey)} state={openCardState(loc)} onClick={click}>{children ?? issueKey}</Link>;
|
||||
}
|
||||
|
||||
@ -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(<App router={router} />);
|
||||
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(<App router={router} />);
|
||||
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(<App router={router} />);
|
||||
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(<App router={router} />);
|
||||
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);
|
||||
|
||||
@ -35,7 +35,7 @@ export function IssueTable({ rows, epicNames, closed, onOpen }: {
|
||||
const due = dueOf(i);
|
||||
return (
|
||||
<tr key={i.key} onClick={(e) => rowClick(e, i.key, onOpen)}>
|
||||
<td className="issue-table__key"><IssueLink issueKey={i.key} />{i.neighbor && <Badge tone="neutral">сосед</Badge>}</td>
|
||||
<td className="issue-table__key"><IssueLink issueKey={i.key} onOpen={onOpen} />{i.neighbor && <Badge tone="neutral">сосед</Badge>}</td>
|
||||
<td className="issue-table__sum">{i.summary}</td>
|
||||
<td>{i.status && <Badge tone="info">{i.status}</Badge>}</td>
|
||||
<td>{i.assignee}</td>
|
||||
|
||||
@ -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(<IssuesPage project="AWPCA" />, { 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)]);
|
||||
|
||||
@ -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<ReturnType<typeof setTimeout> | 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 (
|
||||
<div data-testid="page-issues" className="issues">
|
||||
|
||||
@ -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<HTMLButtonElement>("[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: () =>
|
||||
</div>
|
||||
)}
|
||||
{blocker.state === "blocked" && (
|
||||
<div className="note-confirm" role="alertdialog" ref={confirmRef}>
|
||||
<div className="note-confirm" role="alertdialog" aria-label="Уйти без сохранения заметки?" ref={confirmRef}>
|
||||
<span>Есть несохранённые правки заметки. Уйти без сохранения?</span>
|
||||
<div className="row">
|
||||
<Button onClick={() => blocker.proceed()}>Уйти</Button>
|
||||
<Button variant="primary" onClick={() => { blocker.reset(); abortClose(); }}>Остаться</Button>
|
||||
<Button variant="primary" data-stay onClick={() => { blocker.reset(); abortClose(); }}>Остаться</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
@ -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<string | null>(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;
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user