feat(web): карточка задачи окном поверх списка и доски (#37)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Andrey Epifancev 2026-10-15 13:09:20 +04:00
parent 092e7b32ed
commit 10285371b2
13 changed files with 270 additions and 62 deletions

View File

@ -5,7 +5,7 @@ import { beforeEach, describe, expect, it } from "vitest";
import { App } from "./App.tsx";
import { clearToken, getAuth, setToken } from "./auth/token.ts";
import { routes } from "./routes.tsx";
import { Layout } from "./shell/Layout.tsx";
import { PAGE_ROUTES } from "./shell/pages.tsx";
import { json, mockFetch } from "./test/fetchMock.ts";
const at = (path: string) => createMemoryRouter(routes, { initialEntries: [path], basename: "/app" });
@ -45,12 +45,15 @@ describe("оболочка", () => {
it("исключение в странице — сообщение и «Перезагрузить», панель на месте", async () => {
const Boom = () => { throw new Error("сломалось"); };
const router = createMemoryRouter([{ path: "/", element: <Layout />, children: [{ path: "x", element: <Boom /> }] }],
{ initialEntries: ["/x"] });
render(<App router={router} />);
expect(await screen.findByText(/сломалось/)).toBeInTheDocument();
expect(screen.getByRole("button", { name: "Перезагрузить" })).toBeInTheDocument();
expect(screen.getByRole("link", { name: "Доска" })).toBeInTheDocument();
const boom = { path: "/x", element: <Boom /> };
PAGE_ROUTES.unshift(boom);
try {
const router = createMemoryRouter(routes, { initialEntries: ["/app/x"], basename: "/app" });
render(<App router={router} />);
expect(await screen.findByText(/сломалось/)).toBeInTheDocument();
expect(screen.getByRole("button", { name: "Перезагрузить" })).toBeInTheDocument();
expect(screen.getByRole("link", { name: "Доска" })).toBeInTheDocument();
} finally { PAGE_ROUTES.splice(PAGE_ROUTES.indexOf(boom), 1); }
});
it("без токена — только экран входа", () => {

View File

@ -1,5 +1,6 @@
import { memo, type PointerEvent } from "react";
import { Link } from "react-router";
import { BOARD_CARD_STATE } from "../../issues/modal.ts";
import { CW, JIRA_URL, KIND, PRIO } from "../constants.ts";
import { lineClamp, type Rect, type RelBox } from "../layout/layout.ts";
import { fmtDate, shortName, unassigned } from "../model/util.ts";
@ -31,7 +32,7 @@ export const Card = memo(function Card({ node: n, box, rel, multiSprint, sprintS
onPointerDown={onPointerDown ? (e) => onPointerDown(e, n.key) : undefined}>
<div className="head">
<a className="key" href={`${JIRA_URL}/browse/${n.key}`} target="_blank" rel="noopener">{n.key}</a>
{!n.stub && <Link className="open-card" to={`/issues/${n.key}`} title="Открыть карточку задачи"
{!n.stub && <Link className="open-card" to={`/issues/${n.key}`} state={BOARD_CARD_STATE} title="Открыть карточку задачи"
aria-label={`Открыть карточку ${n.key}`}>ⓘ</Link>}
{showTag && (n.sprint
? <span className={"sprint-tag" + (sprintSel.has(n.sprint) ? " sel" : "")} title={n.sprint}>{n.sprint.replace(/^AWPCA_/, "")}</span>

View File

@ -1,7 +1,7 @@
import { act, render, screen, waitFor } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { QueryClientProvider } from "@tanstack/react-query";
import { createMemoryRouter, RouterProvider } from "react-router";
import { createMemoryRouter, RouterProvider, useNavigate, useParams } from "react-router";
import { beforeEach, describe, expect, it } from "vitest";
import { qk } from "../api/queries.ts";
import { makeQueryClient } from "../api/queryClient.ts";
@ -9,13 +9,19 @@ import { setToken } from "../auth/token.ts";
import settings from "../board/__fixtures__/settings.json";
import { json, mockFetch } from "../test/fetchMock.ts";
import { issue, note } from "../test/issueFixtures.ts";
import { IssuePage } from "./IssuePage.tsx";
import { IssueCard } from "./IssueCard.tsx";
const deferred = <T,>() => { let resolve!: (v: T) => void; const promise = new Promise<T>((r) => { resolve = r; }); return { promise, resolve }; };
function CardAt() {
const { key = "" } = useParams();
const navigate = useNavigate();
return <IssueCard issueKey={key} onClose={() => void navigate("/board")} />;
}
function open(path: string, client = makeQueryClient()) {
const router = createMemoryRouter([
{ path: "/issues/:key", element: <IssuePage /> },
{ path: "/issues/:key", element: <CardAt /> },
{ path: "/board", element: <p>доска</p> },
], { initialEntries: [path] });
render(<QueryClientProvider client={client}><RouterProvider router={router} /></QueryClientProvider>);
@ -37,17 +43,18 @@ describe("карточка задачи", () => {
expect(screen.getByRole("link", { name: "Открыть в Jira" })).toHaveAttribute("href", "https://sdlc.go.rshbank.ru/jira/browse/AURORA-9001");
});
it("заголовок страницы — h1 (бренд в оболочке — span)", async () => {
it("заголовок карточки — h2 с id для aria-labelledby окна", async () => {
mockFetch((_m, url) => url.pathname === "/settings" ? json(200, settings) : json(200, issue("AURORA-9001")));
open("/issues/AURORA-9001");
expect(await screen.findByRole("heading", { level: 1, name: /AURORA-9001/ })).toBeInTheDocument();
expect(await screen.findByRole("heading", { level: 2, name: /AURORA-9001/ })).toHaveAttribute("id", "issue-modal-title");
});
it("задачи нет в зеркале — сообщение и ссылка на список", async () => {
it("задачи нет в зеркале — сообщение и «Закрыть»", async () => {
mockFetch((_m, url) => url.pathname === "/settings" ? json(200, settings) : json(404, { error: "not_found", detail: "issue" }));
open("/issues/AURORA-1");
expect(await screen.findByText(/Задачи AURORA-1 нет в зеркале/)).toBeInTheDocument();
expect(screen.getByRole("link", { name: "к списку задач" })).toHaveAttribute("href", "/issues");
const router = open("/issues/AURORA-1");
expect(await screen.findByText("Задачи AURORA-1 нет в зеркале хаба.")).toBeInTheDocument();
await userEvent.click(screen.getByRole("button", { name: "Закрыть" }));
expect(router.state.location.pathname).toBe("/board");
});
it("сохраняются только изменённые поля, очищенное — null", async () => {
@ -169,13 +176,6 @@ describe("карточка задачи", () => {
await waitFor(() => expect(screen.queryByRole("alert")).toBeNull());
});
it("O-10: ссылка в ветке «задачи нет в зеркале» стоит внутри .issue (цвет темы)", async () => {
mockFetch((_m, url) => url.pathname === "/settings" ? json(200, settings) : json(404, { error: "not_found", detail: "issue" }));
open("/issues/AURORA-1");
const link = await screen.findByRole("link", { name: "к списку задач" });
expect(link.closest(".issue")).not.toBeNull();
});
it("O-11: задача без команд — текст вместо пустой таблицы", async () => {
mockFetch((_m, url) => url.pathname === "/settings" ? json(200, settings) : json(200, issue("AURORA-9001", { commands: [] })));
open("/issues/AURORA-9001");

View File

@ -1,4 +1,4 @@
import { Link, useParams } from "react-router";
import { IssueLink } from "./IssueLink.tsx";
import { ApiError } from "../api/client.ts";
import { useIssue, useSettings } from "../api/queries.ts";
import { JIRA_URL } from "../board/constants.ts";
@ -7,21 +7,20 @@ import { NoteForm } from "./NoteForm.tsx";
import { dueOf } from "./options.ts";
import "./issues.css";
export function IssuePage() {
const { key = "" } = useParams();
export function IssueCard({ issueKey: key, onClose }: { issueKey: string; onClose: () => void }) {
const issue = useIssue(key);
const settings = useSettings();
// подпись типа связи: логический тип из link_types настроек, иначе имя типа Jira
const typeName = (jira: string) =>
Object.entries(settings.data?.link_types ?? {}).find(([, j]) => j === jira)?.[0] ?? jira;
if (issue.isPending) return <p className="hint" data-testid="page-issue">загрузка {key}…</p>;
if (issue.isPending) return <p className="hint" data-testid="issue-card">загрузка {key}…</p>;
if (issue.error instanceof ApiError && issue.error.status === 404) {
return <p className="issue" data-testid="page-issue">Задачи {key} нет в зеркале хаба. <Link to="/issues">к списку задач</Link></p>;
return <div className="issue" data-testid="issue-card"><p>Задачи {key} нет в зеркале хаба.</p><button className="btn" type="button" onClick={onClose}>Закрыть</button></div>;
}
if (issue.isError && !issue.data) {
return (
<p className="warn" role="alert" data-testid="page-issue">{issue.error.message}{" "}
<p className="warn" role="alert" data-testid="issue-card">{issue.error.message}{" "}
<button className="btn sm" type="button" onClick={() => void issue.refetch()}>Повторить</button></p>
);
}
@ -33,13 +32,13 @@ export function IssuePage() {
["Обновлена в Jira", i.jira_updated ? fmtStamp(i.jira_updated) : null],
];
return (
<div data-testid="page-issue" className="issue">
<div data-testid="issue-card" className="issue">
{issue.isError && (
<p className="warn" role="alert">{issue.error.message}{" "}
<button className="btn sm" type="button" onClick={() => void issue.refetch()}>Повторить</button></p>
)}
<p><Link to="/issues">← задачи</Link></p>
<h1>{i.key} · {i.summary}</h1>
<p><button className="btn sm" type="button" aria-label="Закрыть" onClick={onClose}>✕</button></p>
<h2 id="issue-modal-title">{i.key} · {i.summary}</h2>
<p><a href={`${JIRA_URL}/browse/${i.key}`} target="_blank" rel="noopener">Открыть в Jira</a></p>
<div className="cols">
<div>
@ -47,8 +46,8 @@ export function IssuePage() {
{i.description && <pre className="description">{i.description}</pre>}
<h3>Связи</h3>
<ul className="links">
{i.links_out.map((l) => <li key={"o" + l.target_key + l.link_type}><span>{typeName(l.link_type)}</span> → <Link to={`/issues/${l.target_key}`}>{l.target_key}</Link></li>)}
{i.links_in.map((l) => <li key={"i" + l.source_key + l.link_type}><Link to={`/issues/${l.source_key}`}>{l.source_key}</Link> → <span>{typeName(l.link_type)}</span> → эта задача</li>)}
{i.links_out.map((l) => <li key={"o" + l.target_key + l.link_type}><span>{typeName(l.link_type)}</span> → <IssueLink issueKey={l.target_key} /></li>)}
{i.links_in.map((l) => <li key={"i" + l.source_key + l.link_type}><IssueLink issueKey={l.source_key} /> → <span>{typeName(l.link_type)}</span> → эта задача</li>)}
{!i.links_out.length && !i.links_in.length && <li className="hint">связей нет</li>}
</ul>
<h3>Последние команды</h3>

View File

@ -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 <Link className={className} to={`/issues/${encodeURIComponent(issueKey)}`} state={openCardState(loc)}>{children ?? issueKey}</Link>;
}

View File

@ -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(<App router={router} />);
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(<App router={router} />);
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(<App router={router} />);
await userEvent.click(await screen.findByRole("link", { name: "AURORA-9001" }));
await within(dialog()).findByRole("heading", { name: /AURORA-9001/ });
await userEvent.click(dialog()); // цель — сам <dialog>, не содержимое
await waitFor(() => expect(screen.queryByRole("dialog")).toBeNull());
expect(router.state.location.pathname).toBe("/app/issues");
});
it("с доски: окно поверх доски, вкладка «Доска» активна, после закрытия доска та же", async () => {
const router = at(["/app/board"]);
render(<App router={router} />);
const ilink = await screen.findByRole("link", { name: "Открыть карточку AURORA-9001" });
const boardEl = screen.getByTestId("page-board");
await userEvent.click(ilink);
expect(await within(dialog()).findByRole("heading", { name: /AURORA-9001/ })).toBeInTheDocument();
expect(screen.getByTestId("page-board")).toBe(boardEl);
expect(screen.getByRole("link", { name: "Доска" })).toHaveClass("active");
await userEvent.click(within(dialog()).getByRole("button", { name: "Закрыть" }));
await waitFor(() => expect(router.state.location.pathname).toBe("/app/board"));
expect(screen.getByTestId("page-board")).toBe(boardEl);
});
it("прямой адрес: под окном список задач, ✕ ведёт в список заменой записи", async () => {
const router = at(["/app/board", "/app/issues/AURORA-9001"]);
render(<App router={router} />);
expect(await within(await screen.findByRole("dialog")).findByRole("heading", { name: /AURORA-9001/ })).toBeInTheDocument();
expect(await screen.findByTestId("page-issues")).toBeInTheDocument();
await waitFor(() => expect(router.state.location.state).toMatchObject({ depth: 0, direct: true }));
await userEvent.click(within(dialog()).getByRole("button", { name: "Закрыть" }));
await waitFor(() => expect(router.state.location.pathname).toBe("/app/issues"));
await act(async () => { await router.navigate(-1); });
expect(router.state.location.pathname).toBe("/app/board"); // не карточка
});
it("прямой адрес и переход по связи: закрытие возвращает в список, а не на первую карточку", 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/ });
fireEvent(dialog(), new Event("cancel", { cancelable: true }));
await waitFor(() => expect(router.state.location.pathname).toBe("/app/issues"));
await waitFor(() => expect(screen.queryByRole("dialog")).toBeNull());
});
it("Esc с несохранённой заметкой спрашивает; «Остаться» оставляет окно с текстом", async () => {
const router = at(["/app/issues"]);
render(<App router={router} />);
await userEvent.click(await screen.findByRole("link", { name: "AURORA-9001" }));
const box = await within(dialog()).findByLabelText("Заметка владельца");
await userEvent.type(box, "черновик");
fireEvent(dialog(), new Event("cancel", { cancelable: true }));
expect(await within(dialog()).findByText("Есть несохранённые правки заметки. Уйти без сохранения?")).toBeInTheDocument();
await userEvent.click(within(dialog()).getByRole("button", { name: "Остаться" }));
expect(router.state.location.pathname).toBe("/app/issues/AURORA-9001");
expect(within(dialog()).getByLabelText("Заметка владельца")).toHaveValue("черновик");
});
});

View File

@ -0,0 +1,25 @@
import { useEffect, useRef } from "react";
import { ErrorBoundary } from "../shell/ErrorBoundary.tsx";
import { IssueCard } from "./IssueCard.tsx";
// окно карточки (design 13): <dialog> держит фокус и делает фон недоступным; Esc и щелчок по фону — onClose.
// Сами окно не закрываем: оно исчезает вместе с адресом карточки. Если браузер закрыл его сам (Esc без активации
// пользователя — cancel не отменяемый), открываем снова и просим закрытия по общему пути (блокировщик заметок).
export function IssueModal({ issueKey, onClose }: { issueKey: string; onClose: () => void }) {
const ref = useRef<HTMLDialogElement>(null);
useEffect(() => {
const d = ref.current;
if (d && !d.open) d.showModal();
const prev = document.body.style.overflow;
document.body.style.overflow = "hidden"; // страница под окном не прокручивается
return () => { document.body.style.overflow = prev; };
}, []);
return (
<dialog ref={ref} className="issue-modal" aria-labelledby="issue-modal-title"
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(); }}>
<ErrorBoundary key={issueKey}><IssueCard issueKey={issueKey} onClose={onClose} /></ErrorBoundary>
</dialog>
);
}

View File

@ -1,6 +1,7 @@
import { useEffect, useMemo, useRef, useState } from "react";
import { Link, useSearchParams } from "react-router";
import { useSearchParams } from "react-router";
import { ISSUE_FILTER_NAMES, useBoard, useIssues, useSettings, type IssueFilters } from "../api/queries.ts";
import { IssueLink } from "./IssueLink.tsx";
import { dueOf, epicNames, filterOptions, withCurrent } from "./options.ts";
import "./issues.css";
@ -63,7 +64,7 @@ export function IssuesPage() {
<tbody>
{rows.map((i) => (
<tr key={i.key}>
<td><Link to={`/issues/${i.key}`}>{i.key}</Link></td><td>{i.summary}</td><td>{i.issue_type}</td><td>{i.status}</td>
<td><IssueLink issueKey={i.key} /></td><td>{i.summary}</td><td>{i.issue_type}</td><td>{i.status}</td>
<td>{i.assignee}</td><td>{i.sprint}</td><td>{dueOf(i)}</td><td>{i.epic_key ? names[i.epic_key] ?? i.epic_key : ""}</td>
<td>{i.work_kind}</td><td>{i.fact_status}</td><td>{i.neighbor ? "да" : ""}</td>
</tr>

View File

@ -70,7 +70,7 @@ export function NoteForm({ issue }: { issue: IssueDetail }) {
{saved && <span className="hint">сохранено {fmtStamp(saved.updated_at)}, {saved.updated_by}</span>}
</div>
{blocker.state === "blocked" && (
<div className="confirm" role="dialog">
<div className="confirm" role="alertdialog">
<span>Есть несохранённые правки заметки. Уйти без сохранения?</span>
<button className="btn" type="button" onClick={() => blocker.proceed()}>Уйти</button>
<button className="btn" type="button" onClick={() => blocker.reset()}>Остаться</button>

View File

@ -20,3 +20,9 @@
.note input,.note select,.note textarea{font:inherit;font-size:14px;color:var(--fg);background:var(--card);border:1px solid var(--line);border-radius:6px;padding:6px 8px}
.note .err{color:var(--p-crit)}
.note .confirm{display:flex;gap:8px;align-items:center;flex-wrap:wrap}
/* окно карточки задачи (design 13) */
.issue-modal{width:min(1000px,calc(100vw - 32px));max-width:none;max-height:90vh;padding:0;border:1px solid var(--line);border-radius:10px;background:var(--bg);color:var(--fg);overflow:auto}
.issue-modal::backdrop{background:rgb(0 0 0 / .45)}
.issue-modal > .issue{padding:0 20px 20px}
@media (max-width:700px){.issue-modal{width:100vw;max-width:100vw;height:100vh;max-height:100vh;margin:0;border-radius:0}}

View File

@ -1,19 +1,5 @@
import { Navigate, type RouteObject } from "react-router";
import { BoardPage } from "./board/BoardPage.tsx";
import { IssuePage } from "./issues/IssuePage.tsx";
import { IssuesPage } from "./issues/IssuesPage.tsx";
import type { RouteObject } from "react-router";
import { Layout } from "./shell/Layout.tsx";
import { NotFound } from "./shell/NotFound.tsx";
// адреса относительно basename "/app": /app → /app/board
export const routes: RouteObject[] = [{
path: "/",
element: <Layout />,
children: [
{ index: true, element: <Navigate to="/board" replace /> },
{ path: "board", element: <BoardPage /> },
{ path: "issues", element: <IssuesPage /> },
{ path: "issues/:key", element: <IssuePage /> },
{ path: "*", element: <NotFound /> },
],
}];
// адреса относительно basename "/app": страницы и окно карточки рисует Layout (shell/pages.tsx, design 10)
export const routes: RouteObject[] = [{ path: "*", element: <Layout /> }];

View File

@ -1,26 +1,66 @@
import { useQueryClient } from "@tanstack/react-query";
import { NavLink, Outlet, useLocation } from "react-router";
import { useCallback, useEffect, useMemo, useRef } from "react";
import { Link, matchPath, useLocation, useNavigate, useRoutes } from "react-router";
import { clearToken } from "../auth/token.ts";
import { IssueModal } from "../issues/IssueModal.tsx";
import { cardState, LIST_BACKGROUND } from "../issues/modal.ts";
import { ErrorBoundary } from "./ErrorBoundary.tsx";
import { PAGE_ROUTES } from "./pages.tsx";
import "./shell.css";
export function Layout() {
const qc = useQueryClient();
const { pathname } = useLocation();
const location = useLocation();
const navigate = useNavigate();
const issueKey = matchPath("/issues/:key", location.pathname)?.params.key ?? null;
const card = issueKey ? cardState(location.state) : null;
const bgPath = issueKey ? (card?.background ?? LIST_BACKGROUND).pathname : null;
const bgSearch = issueKey ? (card?.background ?? LIST_BACKGROUND).search : "";
// адрес страницы под окном; передаётся в useRoutes всегда (и без окна), чтобы дерево страницы не менялось при
// открытии окна и useSearchParams внутри неё видел её собственный адрес (design 10)
const bgLocation = useMemo(() => bgPath === null ? null : { pathname: bgPath, search: bgSearch, hash: "", state: null, key: "card-background" },
[bgPath, bgSearch]);
const pageLocation = bgLocation ?? location;
const page = useRoutes(PAGE_ROUTES, pageLocation);
// прямой адрес карточки: запись истории получает state окна поверх списка задач (design 11)
useEffect(() => {
if (issueKey && !card) {
void navigate(location.pathname + location.search, { replace: true, state: { background: LIST_BACKGROUND, depth: 0, direct: true } });
}
}, [issueKey, card, location.pathname, location.search, navigate]);
// закрытие (design 12): назад на depth записей; запись прямого адреса, на которую вернулись, заменяется списком
const closingDirect = useRef(false);
useEffect(() => {
if (closingDirect.current && card?.direct && card.depth === 0) {
closingDirect.current = false;
void navigate("/issues", { replace: true });
}
}, [card, navigate]);
const close = useCallback(() => {
if (!card || card.depth === 0) { void navigate("/issues", { replace: true }); return; }
closingDirect.current = card.direct;
void navigate(-card.depth);
}, [card, navigate]);
const at = pageLocation.pathname;
const tab = (to: string, label: string) => {
const active = at === to || at.startsWith(to + "/");
return <Link to={to} className={active ? "active" : undefined} aria-current={active ? "page" : undefined}>{label}</Link>;
};
return (
<>
<header className="appbar">
<span className="brand">Хаб Jira</span>
<nav>
<NavLink to="/board">Доска</NavLink>
<NavLink to="/issues" end={false}>Задачи</NavLink>
</nav>
<nav>{tab("/board", "Доска")}{tab("/issues", "Задачи")}</nav>
<span className="spacer" />
<button type="button" className="link" onClick={() => { clearToken(); qc.clear(); }}>Сменить токен</button>
</header>
<main className="page">
<ErrorBoundary key={pathname}><Outlet /></ErrorBoundary>
<ErrorBoundary key={at}>{page}</ErrorBoundary>
</main>
{issueKey && <IssueModal issueKey={issueKey} onClose={close} />}
</>
);
}

12
web/src/shell/pages.tsx Normal file
View File

@ -0,0 +1,12 @@
import { Navigate, type RouteObject } from "react-router";
import { BoardPage } from "../board/BoardPage.tsx";
import { IssuesPage } from "../issues/IssuesPage.tsx";
import { NotFound } from "./NotFound.tsx";
// страницы приложения; Layout рисует их по адресу страницы под окном карточки (design 10)
export const PAGE_ROUTES: RouteObject[] = [
{ path: "/", element: <Navigate to="/board" replace /> },
{ path: "/board", element: <BoardPage /> },
{ path: "/issues", element: <IssuesPage /> },
{ path: "*", element: <NotFound /> },
];