diff --git a/web/src/App.test.tsx b/web/src/App.test.tsx index fde4622..1cce5c8 100644 --- a/web/src/App.test.tsx +++ b/web/src/App.test.tsx @@ -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: , children: [{ path: "x", element: }] }], - { initialEntries: ["/x"] }); - render(); - expect(await screen.findByText(/сломалось/)).toBeInTheDocument(); - expect(screen.getByRole("button", { name: "Перезагрузить" })).toBeInTheDocument(); - expect(screen.getByRole("link", { name: "Доска" })).toBeInTheDocument(); + const boom = { path: "/x", element: }; + PAGE_ROUTES.unshift(boom); + try { + const router = createMemoryRouter(routes, { initialEntries: ["/app/x"], basename: "/app" }); + render(); + 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("без токена — только экран входа", () => { diff --git a/web/src/board/components/Card.tsx b/web/src/board/components/Card.tsx index 9d05362..0623e40 100644 --- a/web/src/board/components/Card.tsx +++ b/web/src/board/components/Card.tsx @@ -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}>
{n.key} - {!n.stub && ⓘ} {showTag && (n.sprint ? {n.sprint.replace(/^AWPCA_/, "")} diff --git a/web/src/issues/IssuePage.test.tsx b/web/src/issues/IssueCard.test.tsx similarity index 90% rename from web/src/issues/IssuePage.test.tsx rename to web/src/issues/IssueCard.test.tsx index 9cda5fe..452c79d 100644 --- a/web/src/issues/IssuePage.test.tsx +++ b/web/src/issues/IssueCard.test.tsx @@ -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 = () => { let resolve!: (v: T) => void; const promise = new Promise((r) => { resolve = r; }); return { promise, resolve }; }; +function CardAt() { + const { key = "" } = useParams(); + const navigate = useNavigate(); + return void navigate("/board")} />; +} + function open(path: string, client = makeQueryClient()) { const router = createMemoryRouter([ - { path: "/issues/:key", element: }, + { path: "/issues/:key", element: }, { path: "/board", element:

доска

}, ], { initialEntries: [path] }); render(); @@ -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"); diff --git a/web/src/issues/IssuePage.tsx b/web/src/issues/IssueCard.tsx similarity index 76% rename from web/src/issues/IssuePage.tsx rename to web/src/issues/IssueCard.tsx index 9a8b93c..222e4da 100644 --- a/web/src/issues/IssuePage.tsx +++ b/web/src/issues/IssueCard.tsx @@ -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

загрузка {key}…

; + if (issue.isPending) return

загрузка {key}…

; if (issue.error instanceof ApiError && issue.error.status === 404) { - return

Задачи {key} нет в зеркале хаба. к списку задач

; + return

Задачи {key} нет в зеркале хаба.

; } if (issue.isError && !issue.data) { return ( -

{issue.error.message}{" "} +

{issue.error.message}{" "}

); } @@ -33,13 +32,13 @@ export function IssuePage() { ["Обновлена в Jira", i.jira_updated ? fmtStamp(i.jira_updated) : null], ]; return ( -
+
{issue.isError && (

{issue.error.message}{" "}

)} -

← задачи

-

{i.key} · {i.summary}

+

+

{i.key} · {i.summary}

Открыть в Jira

@@ -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()); // цель — сам , не содержимое + await waitFor(() => expect(screen.queryByRole("dialog")).toBeNull()); + expect(router.state.location.pathname).toBe("/app/issues"); + }); + + it("с доски: окно поверх доски, вкладка «Доска» активна, после закрытия доска та же", async () => { + const router = at(["/app/board"]); + render(); + 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(); + 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(); + 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(); + 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("черновик"); + }); +}); diff --git a/web/src/issues/IssueModal.tsx b/web/src/issues/IssueModal.tsx new file mode 100644 index 0000000..ee30cbb --- /dev/null +++ b/web/src/issues/IssueModal.tsx @@ -0,0 +1,25 @@ +import { useEffect, useRef } from "react"; +import { ErrorBoundary } from "../shell/ErrorBoundary.tsx"; +import { IssueCard } from "./IssueCard.tsx"; + +// окно карточки (design 13): держит фокус и делает фон недоступным; Esc и щелчок по фону — onClose. +// Сами окно не закрываем: оно исчезает вместе с адресом карточки. Если браузер закрыл его сам (Esc без активации +// пользователя — cancel не отменяемый), открываем снова и просим закрытия по общему пути (блокировщик заметок). +export function IssueModal({ issueKey, onClose }: { issueKey: string; onClose: () => void }) { + const ref = useRef(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 ( + { 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/IssuesPage.tsx b/web/src/issues/IssuesPage.tsx index 2015915..44da18b 100644 --- a/web/src/issues/IssuesPage.tsx +++ b/web/src/issues/IssuesPage.tsx @@ -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() { {rows.map((i) => ( - {i.key}{i.summary}{i.issue_type}{i.status} + {i.summary}{i.issue_type}{i.status} {i.assignee}{i.sprint}{dueOf(i)}{i.epic_key ? names[i.epic_key] ?? i.epic_key : ""} {i.work_kind}{i.fact_status}{i.neighbor ? "да" : ""} diff --git a/web/src/issues/NoteForm.tsx b/web/src/issues/NoteForm.tsx index 833deb4..e4ea965 100644 --- a/web/src/issues/NoteForm.tsx +++ b/web/src/issues/NoteForm.tsx @@ -70,7 +70,7 @@ export function NoteForm({ issue }: { issue: IssueDetail }) { {saved && сохранено {fmtStamp(saved.updated_at)}, {saved.updated_by}}
{blocker.state === "blocked" && ( -
+
Есть несохранённые правки заметки. Уйти без сохранения? diff --git a/web/src/issues/issues.css b/web/src/issues/issues.css index a2053ac..574b4a4 100644 --- a/web/src/issues/issues.css +++ b/web/src/issues/issues.css @@ -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}} diff --git a/web/src/routes.tsx b/web/src/routes.tsx index c120fcf..afa157a 100644 --- a/web/src/routes.tsx +++ b/web/src/routes.tsx @@ -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: , - children: [ - { index: true, element: }, - { path: "board", element: }, - { path: "issues", element: }, - { path: "issues/:key", element: }, - { path: "*", element: }, - ], -}]; +// адреса относительно basename "/app": страницы и окно карточки рисует Layout (shell/pages.tsx, design 10) +export const routes: RouteObject[] = [{ path: "*", element: }]; diff --git a/web/src/shell/Layout.tsx b/web/src/shell/Layout.tsx index f08a4b3..3f82a47 100644 --- a/web/src/shell/Layout.tsx +++ b/web/src/shell/Layout.tsx @@ -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 {label}; + }; return ( <>
Хаб Jira - +
- + {page}
+ {issueKey && } ); } diff --git a/web/src/shell/pages.tsx b/web/src/shell/pages.tsx new file mode 100644 index 0000000..5111b01 --- /dev/null +++ b/web/src/shell/pages.tsx @@ -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: }, + { path: "/board", element: }, + { path: "/issues", element: }, + { path: "*", element: }, +];