@@ -47,8 +46,8 @@ export function IssuePage() {
{i.description &&
{i.description}}
Связи
- {i.links_out.map((l) => - {typeName(l.link_type)} → {l.target_key}
)}
- {i.links_in.map((l) => - {l.source_key} → {typeName(l.link_type)} → эта задача
)}
+ {i.links_out.map((l) => - {typeName(l.link_type)} →
)}
+ {i.links_in.map((l) => - → {typeName(l.link_type)} → эта задача
)}
{!i.links_out.length && !i.links_in.length && - связей нет
}
Последние команды
diff --git a/web/src/issues/IssueLink.tsx b/web/src/issues/IssueLink.tsx
new file mode 100644
index 0000000..dff0d22
--- /dev/null
+++ b/web/src/issues/IssueLink.tsx
@@ -0,0 +1,9 @@
+import type { ReactNode } from "react";
+import { Link, useLocation } from "react-router";
+import { openCardState } from "./modal.ts";
+
+// ссылка на карточку задачи: открывает окно поверх текущей страницы (или меняет содержимое открытого окна)
+export function IssueLink({ issueKey, className, children }: { issueKey: string; className?: string; children?: ReactNode }) {
+ const loc = useLocation();
+ return
{children ?? issueKey};
+}
diff --git a/web/src/issues/IssueModal.test.tsx b/web/src/issues/IssueModal.test.tsx
new file mode 100644
index 0000000..1e9f6d8
--- /dev/null
+++ b/web/src/issues/IssueModal.test.tsx
@@ -0,0 +1,126 @@
+import { act, fireEvent, render, screen, waitFor, within } from "@testing-library/react";
+import userEvent from "@testing-library/user-event";
+import { createMemoryRouter } from "react-router";
+import { beforeEach, describe, expect, it } from "vitest";
+import { App } from "../App.tsx";
+import { setToken } from "../auth/token.ts";
+import board from "../board/__fixtures__/hub-basic.board.json";
+import settings from "../board/__fixtures__/settings.json";
+import { routes } from "../routes.tsx";
+import { json, mockFetch } from "../test/fetchMock.ts";
+import { issue } from "../test/issueFixtures.ts";
+
+const rows = board.issues.map((i) => ({ ...i, id: i.key, description: null, reporter: null, created: null, jira_updated: null, fetched_at: "" }));
+
+function hub() {
+ return mockFetch((_m, url) => {
+ const p = url.pathname;
+ if (p === "/settings") return json(200, settings);
+ if (p === "/board") return json(200, board);
+ if (p === "/issues") return json(200, { issues: rows });
+ if (p.startsWith("/issues/")) return json(200, issue(decodeURIComponent(p.slice("/issues/".length))));
+ if (p === "/batches") return json(200, { batches: [] });
+ return json(200, {});
+ });
+}
+const at = (entries: string[], index = entries.length - 1) =>
+ createMemoryRouter(routes, { initialEntries: entries, initialIndex: index, basename: "/app" });
+const dialog = () => screen.getByRole("dialog");
+
+describe("карточка окном", () => {
+ beforeEach(() => { setToken("tok"); hub(); });
+
+ it("из списка: окно поверх списка, фон не пересоздаётся, ✕ возвращает со старым фильтром, «назад» не открывает карточку", async () => {
+ const router = at(["/app/board", "/app/issues?sprint=AWPCA_Спринт 2"]);
+ render(
);
+ const table = await screen.findByRole("table");
+ expect(screen.getByLabelText("Спринт")).toHaveValue("AWPCA_Спринт 2");
+ await userEvent.click(await within(table).findByRole("link", { name: "AURORA-9002" }));
+ expect(router.state.location.pathname).toBe("/app/issues/AURORA-9002");
+ expect(await within(dialog()).findByRole("heading", { name: /AURORA-9002/ })).toBeInTheDocument();
+ expect(within(screen.getByRole("main")).getByRole("table")).toBe(table); // таблица команд карточки — в окне, не в main // фон не пересоздаётся
+ expect(screen.getByLabelText("Спринт")).toHaveValue("AWPCA_Спринт 2"); // фильтр фона — из его адреса
+ expect(screen.getByRole("link", { name: "Задачи" })).toHaveClass("active");
+ await userEvent.click(within(dialog()).getByRole("button", { name: "Закрыть" }));
+ await waitFor(() => expect(screen.queryByRole("dialog")).toBeNull());
+ expect(router.state.location.pathname).toBe("/app/issues");
+ expect(new URLSearchParams(router.state.location.search).get("sprint")).toBe("AWPCA_Спринт 2");
+ expect(within(screen.getByRole("main")).getByRole("table")).toBe(table); // таблица команд карточки — в окне, не в main
+ await act(async () => { await router.navigate(-1); });
+ expect(router.state.location.pathname).toBe("/app/board");
+ });
+
+ it("переход по связи меняет окно, «назад» возвращает прежнюю карточку, Esc закрывает всё", async () => {
+ const router = at(["/app/issues"]);
+ render(
);
+ await userEvent.click(await screen.findByRole("link", { name: "AURORA-9001" }));
+ await userEvent.click(await within(dialog()).findByRole("link", { name: "AURORA-9002" }));
+ expect(await within(dialog()).findByRole("heading", { name: /AURORA-9002/ })).toBeInTheDocument();
+ await act(async () => { await router.navigate(-1); });
+ expect(await within(dialog()).findByRole("heading", { name: /AURORA-9001/ })).toBeInTheDocument();
+ await userEvent.click(within(dialog()).getByRole("link", { name: "AURORA-9002" }));
+ await within(dialog()).findByRole("heading", { name: /AURORA-9002/ });
+ fireEvent(dialog(), new Event("cancel", { cancelable: true }));
+ await waitFor(() => expect(screen.queryByRole("dialog")).toBeNull());
+ expect(router.state.location.pathname).toBe("/app/issues");
+ });
+
+ it("щелчок по фону окна закрывает его", async () => {
+ const router = at(["/app/issues"]);
+ render(
);
+ await userEvent.click(await screen.findByRole("link", { name: "AURORA-9001" }));
+ await within(dialog()).findByRole("heading", { name: /AURORA-9001/ });
+ await userEvent.click(dialog()); // цель — сам