Волна minor-находок карточки: tsbuildinfo, depth, closingDirect, имя окна, key связей
- T3.3/T4.6/O-2: *.tsbuildinfo в .gitignore - T3.4: cardState принимает depth только целым неотрицательным, иначе 0 - T4.1: отказ от закрытия («Остаться») сбрасывает closingDirect через CloseAbortContext - T4.4: у <dialog> запасное имя aria-label = ключ задачи (загрузка, 404, ошибка) - T5.1: проверка закрытия формы заметки в тесте T14.1 - T5.3: уникальный key строки связи (индекс + ключ + тег) Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
parent
fd63335e5b
commit
62f6d8a11f
1
.gitignore
vendored
1
.gitignore
vendored
@ -16,3 +16,4 @@ src/jira_hub/web/app/
|
||||
web/test-results/
|
||||
web/playwright-report/
|
||||
web/.e2e-hub/
|
||||
*.tsbuildinfo
|
||||
|
||||
@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
@ -85,8 +85,8 @@ function Links({ issue, linkTypes }: { issue: IssueDetail; linkTypes: Record<str
|
||||
<div key={id} className="group" role="group" aria-labelledby={`links-${id}`}>
|
||||
<h4 id={`links-${id}`}>{title}</h4>
|
||||
<ul className="links">
|
||||
{links.map((l) => (
|
||||
<li key={id + l.key + (l.tag ?? "")}>
|
||||
{links.map((l, n) => (
|
||||
<li key={`${n}:${l.key}:${l.tag ?? ""}`}>
|
||||
{l.tag && <><span className="tag">{l.tag}</span>{" "}</>}
|
||||
<IssueLink issueKey={l.key} />{" "}
|
||||
{l.summary === null && l.status === null
|
||||
|
||||
@ -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(<App router={router} />);
|
||||
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<Response>(() => {});
|
||||
if (url.pathname === "/issues/AURORA-2") return json(404, { detail: "нет" });
|
||||
return json(200, {});
|
||||
});
|
||||
const r1 = render(<App router={at(["/app/issues/AURORA-1"])} />);
|
||||
expect(await screen.findByRole("dialog", { name: "AURORA-1" })).toBeInTheDocument();
|
||||
r1.unmount();
|
||||
render(<App router={at(["/app/issues/AURORA-2"])} />);
|
||||
expect(await screen.findByRole("dialog", { name: "AURORA-2" })).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
@ -2,6 +2,7 @@ import { useEffect, useRef } from "react";
|
||||
import { ErrorBoundary } from "../shell/ErrorBoundary.tsx";
|
||||
import { IssueCard } from "./IssueCard.tsx";
|
||||
|
||||
// имя окна — заголовок карточки; пока его нет (загрузка, 404, ошибка), запасное имя — ключ задачи
|
||||
// окно карточки (design 13): <dialog> держит фокус и делает фон недоступным; Esc и щелчок по фону — onClose.
|
||||
// Сами окно не закрываем: оно исчезает вместе с адресом карточки. Если браузер закрыл его сам (Esc без активации
|
||||
// пользователя — cancel не отменяемый), открываем снова и просим закрытия по общему пути (блокировщик заметок).
|
||||
@ -15,7 +16,7 @@ export function IssueModal({ issueKey, onClose }: { issueKey: string; onClose: (
|
||||
return () => { document.body.style.overflow = prev; };
|
||||
}, []);
|
||||
return (
|
||||
<dialog ref={ref} className="issue-modal" aria-labelledby="issue-modal-title"
|
||||
<dialog ref={ref} className="issue-modal" aria-labelledby="issue-modal-title" aria-label={issueKey}
|
||||
onCancel={(e) => { 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(); }}>
|
||||
|
||||
@ -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: () =>
|
||||
<div className="confirm" role="alertdialog" ref={confirmRef}>
|
||||
<span>Есть несохранённые правки заметки. Уйти без сохранения?</span>
|
||||
<button className="btn" type="button" onClick={() => blocker.proceed()}>Уйти</button>
|
||||
<button className="btn" type="button" onClick={() => blocker.reset()}>Остаться</button>
|
||||
<button className="btn" type="button" onClick={() => { blocker.reset(); abortClose(); }}>Остаться</button>
|
||||
</div>
|
||||
)}
|
||||
</form>
|
||||
|
||||
5
web/src/issues/closeAbort.ts
Normal file
5
web/src/issues/closeAbort.ts
Normal file
@ -0,0 +1,5 @@
|
||||
import { createContext, useContext } from "react";
|
||||
|
||||
// окно закрывали, но пользователь отказался (блокировщик заметок, «Остаться»): Layout сбрасывает ожидание закрытия
|
||||
export const CloseAbortContext = createContext<() => void>(() => {});
|
||||
export const useCloseAbort = () => useContext(CloseAbortContext);
|
||||
@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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() {
|
||||
<main className="page">
|
||||
<ErrorBoundary key={at}>{page}</ErrorBoundary>
|
||||
</main>
|
||||
{issueKey && <IssueModal issueKey={issueKey} onClose={close} />}
|
||||
{issueKey && <CloseAbortContext value={abortClose}><IssueModal issueKey={issueKey} onClose={close} /></CloseAbortContext>}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user