diff --git a/src/app/routes/AppRoutes.tsx b/src/app/routes/AppRoutes.tsx index 85dc1a8..e6ad30f 100644 --- a/src/app/routes/AppRoutes.tsx +++ b/src/app/routes/AppRoutes.tsx @@ -8,6 +8,7 @@ import { NotFoundPage } from "@/pages/not-found"; import { RegisterPage } from "@/pages/register"; import { SettingsPage } from "@/pages/settings"; import { AppShell } from "@/shared/ui"; +import { AppBreadcrumbs } from "../ui/AppBreadcrumbs"; function Protected({ children }: { children: React.ReactNode }) { const { user, ready } = useAuth(); @@ -53,7 +54,7 @@ export function AppRoutes() { - + } /> } > diff --git a/src/app/styles.css b/src/app/styles.css index 2c88492..e722b5f 100644 --- a/src/app/styles.css +++ b/src/app/styles.css @@ -32,6 +32,17 @@ a { .bottom-nav { display: none; } +.breadcrumbs { + display: flex; + gap: 0.5rem; + margin-bottom: 1rem; +} +.breadcrumbs-entry { + display: contents; +} +.breadcrumbs a { + color: var(--ft-color-accent); +} .auth-page { align-items: center; display: flex; @@ -64,6 +75,10 @@ a { color: var(--ft-color-accent); font-weight: 600; } + .breadcrumbs[data-depth="3"] .breadcrumbs-item-0, + .breadcrumbs[data-depth="3"] .breadcrumbs-separator-1 { + display: none; + } .mantine-AppShell-main { padding-bottom: 5rem; } diff --git a/src/app/ui/AppBreadcrumbs.test.tsx b/src/app/ui/AppBreadcrumbs.test.tsx new file mode 100644 index 0000000..efad977 --- /dev/null +++ b/src/app/ui/AppBreadcrumbs.test.tsx @@ -0,0 +1,89 @@ +import { cleanup, render, screen } from "@testing-library/react"; +import { MemoryRouter } from "react-router"; +import { afterEach, describe, expect, it, vi } from "vitest"; +import { AppBreadcrumbs } from "./AppBreadcrumbs"; + +const { useBrokerageAccounts } = vi.hoisted(() => ({ + useBrokerageAccounts: vi.fn(), +})); + +vi.mock("@/entities/brokerage-account", () => ({ useBrokerageAccounts })); +vi.mock("@/features/auth", () => ({ + useAuth: () => ({ accessToken: "token" }), +})); + +function renderBreadcrumbs( + path: string, + accounts?: { id: string; name: string }[], +) { + useBrokerageAccounts.mockReturnValue({ data: accounts }); + return render( + + + , + ); +} + +describe("AppBreadcrumbs", () => { + afterEach(() => { + cleanup(); + vi.clearAllMocks(); + }); + + it("shows the current accounts page without a link", () => { + renderBreadcrumbs("/accounts"); + + expect( + screen.getByRole("navigation", { name: "Хлебные крошки" }), + ).toBeInTheDocument(); + expect(screen.getByText("Брокерские счета")).toHaveAttribute( + "aria-current", + "page", + ); + expect(screen.queryByRole("link")).not.toBeInTheDocument(); + }); + + it("shows settings as the current page without a link", () => { + renderBreadcrumbs("/settings"); + + expect(screen.getByText("Настройки")).toHaveAttribute( + "aria-current", + "page", + ); + expect(screen.queryByRole("link")).not.toBeInTheDocument(); + }); + + it("links to the account hierarchy from operations", () => { + renderBreadcrumbs("/accounts/account-1/operations", [ + { id: "account-1", name: "ИИС" }, + ]); + + expect( + screen.getByRole("link", { name: "Брокерские счета" }), + ).toHaveAttribute("href", "/accounts"); + expect(screen.getByRole("link", { name: "ИИС" })).toHaveAttribute( + "href", + "/accounts/account-1", + ); + expect(screen.getByText("Операции")).toHaveAttribute( + "aria-current", + "page", + ); + expect(screen.getByRole("navigation")).toHaveAttribute("data-depth", "3"); + }); + + it("uses the account fallback without exposing the identifier", () => { + renderBreadcrumbs("/accounts/account-1"); + + expect(screen.getByText("Счет")).toHaveAttribute("aria-current", "page"); + expect(screen.queryByText("account-1")).not.toBeInTheDocument(); + }); + + it.each(["/login", "/unknown"])("does not render on %s", (path) => { + renderBreadcrumbs(path); + + expect( + screen.queryByRole("navigation", { name: "Хлебные крошки" }), + ).not.toBeInTheDocument(); + }); +}); diff --git a/src/app/ui/AppBreadcrumbs.tsx b/src/app/ui/AppBreadcrumbs.tsx new file mode 100644 index 0000000..ce314f4 --- /dev/null +++ b/src/app/ui/AppBreadcrumbs.tsx @@ -0,0 +1,100 @@ +import { useTranslation } from "react-i18next"; +import { Link, useMatch } from "react-router"; +import { useBrokerageAccounts } from "@/entities/brokerage-account"; +import { useAuth } from "@/features/auth"; + +type Crumb = { label: string; to?: string }; + +function BreadcrumbTrail({ items }: { items: Crumb[] }) { + const { t } = useTranslation(); + return ( + + ); +} + +function AccountBreadcrumbs({ + accountId, + operations, +}: { + accountId: string; + operations: boolean; +}) { + const { accessToken } = useAuth(); + const { data: accounts } = useBrokerageAccounts(accessToken); + const { t } = useTranslation(); + const accountName = + accounts?.find((account) => account.id === accountId)?.name ?? + t("breadcrumbs.accountFallback"); + const accountPath = `/accounts/${accountId}`; + return ( + + ); +} + +export function AppBreadcrumbs() { + const { t } = useTranslation(); + const accountOperations = useMatch({ + path: "/accounts/:accountId/operations", + end: true, + }); + const account = useMatch({ path: "/accounts/:accountId", end: true }); + const accounts = useMatch({ path: "/accounts", end: true }); + const settings = useMatch({ path: "/settings", end: true }); + + if (accountOperations) + return ( + + ); + if (account) + return ( + + ); + if (accounts) + return ; + if (settings) + return ; + return null; +} diff --git a/src/pages/account-operations/ui/AccountOperationsPage.test.tsx b/src/pages/account-operations/ui/AccountOperationsPage.test.tsx index ae0aa47..a8a3074 100644 --- a/src/pages/account-operations/ui/AccountOperationsPage.test.tsx +++ b/src/pages/account-operations/ui/AccountOperationsPage.test.tsx @@ -82,6 +82,12 @@ describe("AccountOperationsPage", () => { , ); + expect( + await screen.findByRole("heading", { name: "Журнал операций" }), + ).toBeInTheDocument(); + expect( + screen.queryByRole("link", { name: "К счету" }), + ).not.toBeInTheDocument(); fireEvent.click(await screen.findByRole("button", { name: "Далее" })); await waitFor(() => expect(searches.at(-1)).toContain("cursor=next-1")); fireEvent.click(screen.getByRole("checkbox", { name: "Налог" })); diff --git a/src/pages/account-operations/ui/AccountOperationsPage.tsx b/src/pages/account-operations/ui/AccountOperationsPage.tsx index eaddb7a..e20986a 100644 --- a/src/pages/account-operations/ui/AccountOperationsPage.tsx +++ b/src/pages/account-operations/ui/AccountOperationsPage.tsx @@ -10,7 +10,7 @@ import { } from "@mantine/core"; import { useState } from "react"; import { useTranslation } from "react-i18next"; -import { Link, useParams } from "react-router"; +import { useParams } from "react-router"; import { type BrokerageOperation, type OperationCategory, @@ -70,12 +70,7 @@ export function AccountOperationsPage() { ); return ( - - {t("ledger.title")} - - + {t("ledger.title")} + {contentAboveOutlet}