From 62f6d8a11fe70e9015dfe83cf031f0de9bc964cf Mon Sep 17 00:00:00 2001 From: Andrey Epifancev Date: Thu, 15 Oct 2026 13:28:34 +0400 Subject: [PATCH] =?UTF-8?q?=D0=92=D0=BE=D0=BB=D0=BD=D0=B0=20minor-=D0=BD?= =?UTF-8?q?=D0=B0=D1=85=D0=BE=D0=B4=D0=BE=D0=BA=20=D0=BA=D0=B0=D1=80=D1=82?= =?UTF-8?q?=D0=BE=D1=87=D0=BA=D0=B8:=20tsbuildinfo,=20depth,=20closingDire?= =?UTF-8?q?ct,=20=D0=B8=D0=BC=D1=8F=20=D0=BE=D0=BA=D0=BD=D0=B0,=20key=20?= =?UTF-8?q?=D1=81=D0=B2=D1=8F=D0=B7=D0=B5=D0=B9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - T3.3/T4.6/O-2: *.tsbuildinfo в .gitignore - T3.4: cardState принимает depth только целым неотрицательным, иначе 0 - T4.1: отказ от закрытия («Остаться») сбрасывает closingDirect через CloseAbortContext - T4.4: у запасное имя aria-label = ключ задачи (загрузка, 404, ошибка) - T5.1: проверка закрытия формы заметки в тесте T14.1 - T5.3: уникальный key строки связи (индекс + ключ + тег) Co-Authored-By: Claude Opus 5.5 --- .gitignore | 1 + web/src/issues/IssueCard.test.tsx | 13 ++++++++++++- web/src/issues/IssueCard.tsx | 4 ++-- web/src/issues/IssueModal.test.tsx | 30 ++++++++++++++++++++++++++++++ web/src/issues/IssueModal.tsx | 3 ++- web/src/issues/NoteForm.tsx | 4 +++- web/src/issues/closeAbort.ts | 5 +++++ web/src/issues/modal.test.ts | 5 +++++ web/src/issues/modal.ts | 3 ++- web/src/shell/Layout.tsx | 5 ++++- 10 files changed, 66 insertions(+), 7 deletions(-) create mode 100644 web/src/issues/closeAbort.ts diff --git a/.gitignore b/.gitignore index 7f60f8e..b56602c 100644 --- a/.gitignore +++ b/.gitignore @@ -16,3 +16,4 @@ src/jira_hub/web/app/ web/test-results/ web/playwright-report/ web/.e2e-hub/ +*.tsbuildinfo diff --git a/web/src/issues/IssueCard.test.tsx b/web/src/issues/IssueCard.test.tsx index 6d3c4f8..070cff8 100644 --- a/web/src/issues/IssueCard.test.tsx +++ b/web/src/issues/IssueCard.test.tsx @@ -2,7 +2,7 @@ import { act, cleanup, render, screen, waitFor, within } from "@testing-library/ import userEvent from "@testing-library/user-event"; import { QueryClientProvider } from "@tanstack/react-query"; import { createMemoryRouter, RouterProvider, useNavigate, useParams } from "react-router"; -import { beforeEach, describe, expect, it } from "vitest"; +import { beforeEach, describe, expect, it, vi } from "vitest"; import { qk } from "../api/queries.ts"; import { makeQueryClient } from "../api/queryClient.ts"; import { setToken } from "../auth/token.ts"; @@ -254,6 +254,7 @@ describe("карточка задачи", () => { slowPut.resolve(json(200, note({ owner_note: "раз", updated_by: "web" }))); await screen.findByRole("button", { name: "Править" }); // режим чтения (findByText("раз") ловил бы ещё живой textarea) expect(screen.getByText("раз")).toBeInTheDocument(); // с сохранённым значением + expect(screen.queryByLabelText("Заметка владельца")).toBeNull(); // форма закрыта }); it("M-2: ошибка повторной загрузки при уже имеющихся данных — данные остаются, строка с «Повторить»", async () => { @@ -327,4 +328,14 @@ describe("карточка задачи", () => { expect(within(view).getAllByText("—")).toHaveLength(5); expect(within(view).getByRole("button", { name: "Править" })).toBeInTheDocument(); }); + + it("T5.3: одинаковые связи не дают предупреждения о повторяющихся key", async () => { + const err = vi.spyOn(console, "error").mockImplementation(() => {}); + const dup = { target_key: "AURORA-9002", link_type: "ASUPR_Блокер_Начала", summary: "API", status: "Новая" }; + mockFetch((_m, url) => url.pathname === "/settings" ? json(200, settings) : json(200, issue("AURORA-9001", { links_out: [dup, dup] }))); + open("/issues/AURORA-9001"); + await screen.findAllByRole("link", { name: "AURORA-9002" }); + expect(err.mock.calls.flat().join(" ")).not.toContain("same key"); + err.mockRestore(); + }); }); diff --git a/web/src/issues/IssueCard.tsx b/web/src/issues/IssueCard.tsx index f25bca2..6d99946 100644 --- a/web/src/issues/IssueCard.tsx +++ b/web/src/issues/IssueCard.tsx @@ -85,8 +85,8 @@ function Links({ issue, linkTypes }: { issue: IssueDetail; linkTypes: Record
    - {links.map((l) => ( -
  • + {links.map((l, n) => ( +
  • {l.tag && <>{l.tag}{" "}} {" "} {l.summary === null && l.status === null diff --git a/web/src/issues/IssueModal.test.tsx b/web/src/issues/IssueModal.test.tsx index 90e300f..936a6be 100644 --- a/web/src/issues/IssueModal.test.tsx +++ b/web/src/issues/IssueModal.test.tsx @@ -124,4 +124,34 @@ describe("карточка окном", () => { expect(router.state.location.pathname).toBe("/app/issues/AURORA-9001"); expect(within(dialog()).getByLabelText("Заметка владельца")).toHaveValue("черновик"); }); + + it("T4.1: отказ от закрытия («Остаться») сбрасывает ожидание закрытия: «назад» на запись прямого адреса не уводит в список", async () => { + const router = at(["/app/issues/AURORA-9001"]); + render(); + await userEvent.click(await within(await screen.findByRole("dialog")).findByRole("link", { name: "AURORA-9002" })); + await within(dialog()).findByRole("heading", { name: /AURORA-9002/ }); + await userEvent.click(await within(dialog()).findByRole("button", { name: "Править" })); + await userEvent.type(await within(dialog()).findByLabelText("Заметка владельца"), "черновик"); + fireEvent(dialog(), new Event("cancel", { cancelable: true })); + await userEvent.click(await within(dialog()).findByRole("button", { name: "Остаться" })); + await userEvent.click(within(dialog()).getByRole("button", { name: "Отмена" })); + await userEvent.click(await within(dialog()).findByRole("button", { name: "Отменить правки" })); + await act(async () => { await router.navigate(-1); }); + expect(await within(dialog()).findByRole("heading", { name: /AURORA-9001/ })).toBeInTheDocument(); + expect(router.state.location.pathname).toBe("/app/issues/AURORA-9001"); + }); + + it("T4.4: у окна есть имя в состоянии загрузки и 404", async () => { + mockFetch((_m, url) => { + if (url.pathname === "/settings") return json(200, settings); + if (url.pathname === "/issues/AURORA-1") return new Promise(() => {}); + if (url.pathname === "/issues/AURORA-2") return json(404, { detail: "нет" }); + return json(200, {}); + }); + const r1 = render(); + expect(await screen.findByRole("dialog", { name: "AURORA-1" })).toBeInTheDocument(); + r1.unmount(); + render(); + expect(await screen.findByRole("dialog", { name: "AURORA-2" })).toBeInTheDocument(); + }); }); diff --git a/web/src/issues/IssueModal.tsx b/web/src/issues/IssueModal.tsx index ee30cbb..25b8b3d 100644 --- a/web/src/issues/IssueModal.tsx +++ b/web/src/issues/IssueModal.tsx @@ -2,6 +2,7 @@ import { useEffect, useRef } from "react"; import { ErrorBoundary } from "../shell/ErrorBoundary.tsx"; import { IssueCard } from "./IssueCard.tsx"; +// имя окна — заголовок карточки; пока его нет (загрузка, 404, ошибка), запасное имя — ключ задачи // окно карточки (design 13): держит фокус и делает фон недоступным; Esc и щелчок по фону — onClose. // Сами окно не закрываем: оно исчезает вместе с адресом карточки. Если браузер закрыл его сам (Esc без активации // пользователя — cancel не отменяемый), открываем снова и просим закрытия по общему пути (блокировщик заметок). @@ -15,7 +16,7 @@ export function IssueModal({ issueKey, onClose }: { issueKey: string; onClose: ( return () => { document.body.style.overflow = prev; }; }, []); return ( - { if (e.cancelable) { e.preventDefault(); onClose(); } }} onClose={() => { const d = ref.current; if (d?.isConnected) { d.showModal(); onClose(); } }} onClick={(e) => { if (e.target === e.currentTarget) onClose(); }}> diff --git a/web/src/issues/NoteForm.tsx b/web/src/issues/NoteForm.tsx index d2e229b..d5fa280 100644 --- a/web/src/issues/NoteForm.tsx +++ b/web/src/issues/NoteForm.tsx @@ -1,5 +1,6 @@ import { useEffect, useId, useMemo, useRef, useState } from "react"; import { useBlocker } from "react-router"; +import { useCloseAbort } from "./closeAbort.ts"; import { ApiError } from "../api/client.ts"; import { useSaveNote, useSettings } from "../api/queries.ts"; import { NOTE_FIELDS, type IssueDetail, type NoteField, type NoteFields } from "../api/types.ts"; @@ -27,6 +28,7 @@ export function NoteForm({ issue, onDone }: { issue: IssueDetail; onDone: () => const changed = useMemo(() => changedFields(initial, values), [initial, values]); const dirty = Object.keys(changed).length > 0; + const abortClose = useCloseAbort(); const blocker = useBlocker(({ currentLocation, nextLocation }) => dirty && currentLocation.pathname !== nextLocation.pathname); useEffect(() => { if (!dirty) return; @@ -82,7 +84,7 @@ export function NoteForm({ issue, onDone }: { issue: IssueDetail; onDone: () =>
    Есть несохранённые правки заметки. Уйти без сохранения? - +
    )} diff --git a/web/src/issues/closeAbort.ts b/web/src/issues/closeAbort.ts new file mode 100644 index 0000000..d44612d --- /dev/null +++ b/web/src/issues/closeAbort.ts @@ -0,0 +1,5 @@ +import { createContext, useContext } from "react"; + +// окно закрывали, но пользователь отказался (блокировщик заметок, «Остаться»): Layout сбрасывает ожидание закрытия +export const CloseAbortContext = createContext<() => void>(() => {}); +export const useCloseAbort = () => useContext(CloseAbortContext); diff --git a/web/src/issues/modal.test.ts b/web/src/issues/modal.test.ts index 7aa61d8..255902b 100644 --- a/web/src/issues/modal.test.ts +++ b/web/src/issues/modal.test.ts @@ -19,4 +19,9 @@ describe("состояние окна в истории", () => { expect(cardState({ background: { pathname: "/issues", search: "" }, depth: 1 })) .toEqual({ background: { pathname: "/issues", search: "" }, depth: 1, direct: false }); }); + it("глубина — только целое неотрицательное, иначе 0", () => { + const bg = { pathname: "/issues", search: "" }; + for (const depth of [-1, 1.5, NaN, Infinity]) expect(cardState({ background: bg, depth })?.depth).toBe(0); + expect(cardState({ background: bg, depth: 3 })?.depth).toBe(3); + }); }); diff --git a/web/src/issues/modal.ts b/web/src/issues/modal.ts index 3a418b5..6c66b9c 100644 --- a/web/src/issues/modal.ts +++ b/web/src/issues/modal.ts @@ -12,7 +12,8 @@ export function cardState(s: unknown): CardState | null { if (!c || typeof c !== "object" || !c.background || typeof c.background !== "object") return null; const { pathname, search } = c.background; if (typeof pathname !== "string" || typeof search !== "string" || typeof c.depth !== "number") return null; - return { background: { pathname, search }, depth: c.depth, direct: c.direct === true }; + const depth = Number.isInteger(c.depth) && c.depth >= 0 ? c.depth : 0; // NaN, дробные и отрицательные не доходят до navigate(-depth) + return { background: { pathname, search }, depth, direct: c.direct === true }; } // state ссылки на карточку: со страницы — фон = её адрес, глубина 1; из окна — тот же фон, глубина + 1 diff --git a/web/src/shell/Layout.tsx b/web/src/shell/Layout.tsx index 3f82a47..f096bf8 100644 --- a/web/src/shell/Layout.tsx +++ b/web/src/shell/Layout.tsx @@ -2,6 +2,7 @@ import { useQueryClient } from "@tanstack/react-query"; import { useCallback, useEffect, useMemo, useRef } from "react"; import { Link, matchPath, useLocation, useNavigate, useRoutes } from "react-router"; import { clearToken } from "../auth/token.ts"; +import { CloseAbortContext } from "../issues/closeAbort.ts"; import { IssueModal } from "../issues/IssueModal.tsx"; import { cardState, LIST_BACKGROUND } from "../issues/modal.ts"; import { ErrorBoundary } from "./ErrorBoundary.tsx"; @@ -44,6 +45,8 @@ export function Layout() { void navigate(-card.depth); }, [card, navigate]); + const abortClose = useCallback(() => { closingDirect.current = false; }, []); + const at = pageLocation.pathname; const tab = (to: string, label: string) => { const active = at === to || at.startsWith(to + "/"); @@ -60,7 +63,7 @@ export function Layout() {
    {page}
    - {issueKey && } + {issueKey && } ); }