feat(web): оболочка приложения — маршруты /app, разделы, ошибки страниц, тема (#21)
Co-Authored-By: Claude Haiku 5.5 <noreply@anthropic.com>
This commit is contained in:
parent
c124e082ae
commit
6dc0543a50
@ -1,8 +1,56 @@
|
||||
import { render, screen } from "@testing-library/react";
|
||||
import { expect, it } from "vitest";
|
||||
import { screen } from "@testing-library/react";
|
||||
import { render } 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 { clearToken, getAuth, setToken } from "./auth/token.ts";
|
||||
import { routes } from "./routes.tsx";
|
||||
import { json, mockFetch } from "./test/fetchMock.ts";
|
||||
|
||||
it("показывает заголовок", () => {
|
||||
render(<App />);
|
||||
expect(screen.getByRole("heading", { name: "Хаб Jira" })).toBeInTheDocument();
|
||||
const at = (path: string) => createMemoryRouter(routes, { initialEntries: [path], basename: "/app" });
|
||||
|
||||
describe("оболочка", () => {
|
||||
beforeEach(() => { setToken("tok"); mockFetch(() => json(200, {})); });
|
||||
|
||||
it("/app ведёт на доску", async () => {
|
||||
render(<App router={at("/app")} />);
|
||||
expect(await screen.findByTestId("page-board")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("переход «Задачи» и обратно", async () => {
|
||||
const router = at("/app/board");
|
||||
render(<App router={router} />);
|
||||
await userEvent.click(screen.getByRole("link", { name: "Задачи" }));
|
||||
expect(router.state.location.pathname).toBe("/app/issues");
|
||||
expect(await screen.findByTestId("page-issues")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("неизвестный путь — «не найдено» со ссылками", async () => {
|
||||
render(<App router={at("/app/nope")} />);
|
||||
expect(await screen.findByRole("heading", { name: "Страница не найдена" })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("«Сменить токен» показывает экран входа", async () => {
|
||||
render(<App router={at("/app/board")} />);
|
||||
await userEvent.click(screen.getByRole("button", { name: "Сменить токен" }));
|
||||
expect(getAuth().token).toBe("");
|
||||
expect(screen.getByRole("heading", { name: "Вход в хаб" })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("исключение в странице — сообщение и «Перезагрузить», панель на месте", async () => {
|
||||
const Boom = () => { throw new Error("сломалось"); };
|
||||
const router = createMemoryRouter([{ path: "/", element: <App.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();
|
||||
});
|
||||
|
||||
it("без токена — только экран входа", () => {
|
||||
clearToken();
|
||||
render(<App router={at("/app/board")} />);
|
||||
expect(screen.queryByRole("link", { name: "Доска" })).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
@ -1,3 +1,23 @@
|
||||
export function App() {
|
||||
return <h1>Хаб Jira</h1>;
|
||||
import { QueryClientProvider } from "@tanstack/react-query";
|
||||
import { useState } from "react";
|
||||
import { createBrowserRouter, RouterProvider } from "react-router";
|
||||
import { makeQueryClient } from "./api/queryClient.ts";
|
||||
import { AuthGate } from "./auth/AuthGate.tsx";
|
||||
import { routes } from "./routes.tsx";
|
||||
import { Layout } from "./shell/Layout.tsx";
|
||||
import "./styles/theme.css";
|
||||
import "./styles/base.css";
|
||||
import "./styles/controls.css";
|
||||
|
||||
type Router = ReturnType<typeof createBrowserRouter>;
|
||||
|
||||
export function App({ router }: { router?: Router }) {
|
||||
const [client] = useState(makeQueryClient);
|
||||
const [r] = useState(() => router ?? createBrowserRouter(routes, { basename: "/app" }));
|
||||
return (
|
||||
<QueryClientProvider client={client}>
|
||||
<AuthGate><RouterProvider router={r} /></AuthGate>
|
||||
</QueryClientProvider>
|
||||
);
|
||||
}
|
||||
App.Layout = Layout;
|
||||
|
||||
2
web/src/board/BoardPage.tsx
Normal file
2
web/src/board/BoardPage.tsx
Normal file
@ -0,0 +1,2 @@
|
||||
// временная страница (заменяется в Task 11)
|
||||
export function BoardPage() { return <div data-testid="page-board" />; }
|
||||
2
web/src/issues/IssuePage.tsx
Normal file
2
web/src/issues/IssuePage.tsx
Normal file
@ -0,0 +1,2 @@
|
||||
// временная страница (заменяется в Task 14)
|
||||
export function IssuePage() { return <div data-testid="page-issue" />; }
|
||||
2
web/src/issues/IssuesPage.tsx
Normal file
2
web/src/issues/IssuesPage.tsx
Normal file
@ -0,0 +1,2 @@
|
||||
// временная страница (заменяется в Task 13)
|
||||
export function IssuesPage() { return <div data-testid="page-issues" />; }
|
||||
@ -1,6 +1,9 @@
|
||||
import { StrictMode } from "react";
|
||||
import { createRoot } from "react-dom/client";
|
||||
import { App } from "./App.tsx";
|
||||
import { applyTheme, readTheme } from "./shell/theme.ts";
|
||||
|
||||
applyTheme(readTheme());
|
||||
|
||||
createRoot(document.getElementById("root")!).render(
|
||||
<StrictMode>
|
||||
|
||||
19
web/src/routes.tsx
Normal file
19
web/src/routes.tsx
Normal file
@ -0,0 +1,19 @@
|
||||
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 { 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 /> },
|
||||
],
|
||||
}];
|
||||
20
web/src/shell/ErrorBoundary.tsx
Normal file
20
web/src/shell/ErrorBoundary.tsx
Normal file
@ -0,0 +1,20 @@
|
||||
import { Component, type ErrorInfo, type ReactNode } from "react";
|
||||
|
||||
interface State { error: Error | null }
|
||||
|
||||
// неожиданная ошибка отрисовки страницы: сообщение и перезагрузка вместо пустого экрана; панель разделов остаётся
|
||||
export class ErrorBoundary extends Component<{ children: ReactNode }, State> {
|
||||
state: State = { error: null };
|
||||
static getDerivedStateFromError(error: Error): State { return { error }; }
|
||||
componentDidCatch(error: Error, info: ErrorInfo) { console.error("ошибка страницы", error, info.componentStack); }
|
||||
render() {
|
||||
if (!this.state.error) return this.props.children;
|
||||
return (
|
||||
<div className="crash" role="alert">
|
||||
<h2>Страница сломалась</h2>
|
||||
<p>{this.state.error.message}</p>
|
||||
<button className="btn" type="button" onClick={() => window.location.reload()}>Перезагрузить</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
26
web/src/shell/Layout.tsx
Normal file
26
web/src/shell/Layout.tsx
Normal file
@ -0,0 +1,26 @@
|
||||
import { useQueryClient } from "@tanstack/react-query";
|
||||
import { NavLink, Outlet, useLocation } from "react-router";
|
||||
import { clearToken } from "../auth/token.ts";
|
||||
import { ErrorBoundary } from "./ErrorBoundary.tsx";
|
||||
import "./shell.css";
|
||||
|
||||
export function Layout() {
|
||||
const qc = useQueryClient();
|
||||
const { pathname } = useLocation();
|
||||
return (
|
||||
<>
|
||||
<header className="appbar">
|
||||
<span className="brand">Хаб Jira</span>
|
||||
<nav>
|
||||
<NavLink to="/board">Доска</NavLink>
|
||||
<NavLink to="/issues" end={false}>Задачи</NavLink>
|
||||
</nav>
|
||||
<span className="spacer" />
|
||||
<button type="button" className="link" onClick={() => { clearToken(); qc.clear(); }}>Сменить токен</button>
|
||||
</header>
|
||||
<main className="page">
|
||||
<ErrorBoundary key={pathname}><Outlet /></ErrorBoundary>
|
||||
</main>
|
||||
</>
|
||||
);
|
||||
}
|
||||
10
web/src/shell/NotFound.tsx
Normal file
10
web/src/shell/NotFound.tsx
Normal file
@ -0,0 +1,10 @@
|
||||
import { Link } from "react-router";
|
||||
|
||||
export function NotFound() {
|
||||
return (
|
||||
<div className="crash">
|
||||
<h2>Страница не найдена</h2>
|
||||
<p>Разделы приложения: <Link to="/board">доска</Link>, <Link to="/issues">задачи</Link>.</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
8
web/src/shell/shell.css
Normal file
8
web/src/shell/shell.css
Normal file
@ -0,0 +1,8 @@
|
||||
/* оболочка: верхняя панель разделов */
|
||||
.appbar{display:flex;align-items:center;gap:16px;padding:10px 16px;border-bottom:1px solid var(--line);background:var(--panel)}
|
||||
.appbar .brand{font-weight:600}
|
||||
.appbar nav{display:flex;gap:4px}
|
||||
.appbar nav a{color:var(--fg);text-decoration:none;padding:6px 10px;border-radius:6px}
|
||||
.appbar nav a.active{background:var(--accent);color:var(--accent-fg)}
|
||||
.appbar .spacer{flex:1 1 auto}
|
||||
.crash{max-width:640px;margin:48px auto}
|
||||
19
web/src/shell/theme.test.ts
Normal file
19
web/src/shell/theme.test.ts
Normal file
@ -0,0 +1,19 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { applyTheme, readTheme, saveTheme } from "./theme.ts";
|
||||
|
||||
describe("тема", () => {
|
||||
it("читает graph-theme, неизвестное значение — системная", () => {
|
||||
localStorage.setItem("graph-theme", "dark");
|
||||
expect(readTheme()).toBe("dark");
|
||||
localStorage.setItem("graph-theme", "purple");
|
||||
expect(readTheme()).toBe("");
|
||||
});
|
||||
it("applyTheme ставит и снимает data-theme, saveTheme пишет ключ", () => {
|
||||
applyTheme("light");
|
||||
expect(document.documentElement.getAttribute("data-theme")).toBe("light");
|
||||
applyTheme("");
|
||||
expect(document.documentElement.hasAttribute("data-theme")).toBe(false);
|
||||
saveTheme("dark");
|
||||
expect(localStorage.getItem("graph-theme")).toBe("dark");
|
||||
});
|
||||
});
|
||||
20
web/src/shell/theme.ts
Normal file
20
web/src/shell/theme.ts
Normal file
@ -0,0 +1,20 @@
|
||||
// тема интерфейса: "" — системная, light, dark; ключ graph-theme, как у прежней доски (links.html, строки 1000–1003)
|
||||
export const THEMES = ["", "light", "dark"] as const;
|
||||
export type Theme = (typeof THEMES)[number];
|
||||
export const THEME_NAMES: Record<Theme, string> = { "": "системная", light: "светлая", dark: "тёмная" };
|
||||
const KEY = "graph-theme";
|
||||
|
||||
export function readTheme(): Theme {
|
||||
let v: string | null = null;
|
||||
try { v = localStorage.getItem(KEY); } catch { /* закрытое хранилище */ }
|
||||
return (THEMES as readonly string[]).includes(v ?? "") ? (v as Theme) : "";
|
||||
}
|
||||
|
||||
export function saveTheme(t: Theme): void {
|
||||
try { localStorage.setItem(KEY, t); } catch { /* закрытое хранилище */ }
|
||||
}
|
||||
|
||||
export function applyTheme(t: Theme): void {
|
||||
if (t) document.documentElement.setAttribute("data-theme", t);
|
||||
else document.documentElement.removeAttribute("data-theme");
|
||||
}
|
||||
11
web/src/styles/base.css
Normal file
11
web/src/styles/base.css
Normal file
@ -0,0 +1,11 @@
|
||||
/* 2. Основа */
|
||||
html,body{min-height:100%}
|
||||
body{background:var(--bg);color:var(--fg);font-family:var(--font-body);font-size:15px;line-height:1.45;margin:0}
|
||||
h1{font-size:20px;font-weight:600;margin:0}
|
||||
[hidden]{display:none!important}
|
||||
.row{display:flex;align-items:center;gap:8px;flex-wrap:wrap}
|
||||
.spacer{flex:1 1 auto}
|
||||
.hint{font-size:13px;color:var(--muted)}
|
||||
.warn{color:var(--p-high)}
|
||||
.page{padding-inline:16px;padding-block:0 32px}
|
||||
.login{max-width:560px;margin:48px auto}.login form{display:flex;gap:8px}.login input{flex:1;font:inherit;height:34px;padding:0 10px;border:1px solid var(--line);border-radius:6px;background:var(--card);color:var(--fg)}.login .err{color:var(--p-crit)}
|
||||
42
web/src/styles/controls.css
Normal file
42
web/src/styles/controls.css
Normal file
@ -0,0 +1,42 @@
|
||||
.link{font:inherit;font-size:14px;color:var(--accent);cursor:pointer;background:none;border:0;padding:0;text-decoration:underline dotted;white-space:nowrap}
|
||||
.link:hover,.link:focus-visible{text-decoration-style:solid;outline:none}
|
||||
.link:disabled{color:var(--muted);cursor:default;text-decoration:none}
|
||||
/* 4. Кнопки и выпадающие списки */
|
||||
.btn{font:inherit;font-size:14px;line-height:1.2;color:var(--fg);background:var(--card);border:1px solid var(--line);border-radius:6px;padding:0 12px;cursor:pointer;height:34px;box-sizing:border-box;display:inline-flex;align-items:center;justify-content:center;white-space:nowrap;gap:5px}
|
||||
.btn:hover,.btn:focus-visible{border-color:var(--accent);outline:none}
|
||||
.btn[aria-pressed="true"]{background:var(--accent);color:var(--accent-fg);border-color:var(--accent)}
|
||||
.btn .n{font-weight:600;color:var(--p-crit)}
|
||||
.btn .n.zero{font-weight:500;color:var(--muted)}
|
||||
.btn[aria-pressed="true"] .n{color:inherit}
|
||||
.btn.sm{height:28px;padding:0 8px;font-size:13px}
|
||||
.more-btn{padding:0 10px;font-size:20px;line-height:1;letter-spacing:.05em}
|
||||
.dd{position:relative;display:inline-block}
|
||||
.dd-btn{max-width:360px;gap:8px;justify-content:space-between}
|
||||
.dd-btn .lbl{overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
|
||||
.dd-btn .car{font-size:10px;opacity:.7}
|
||||
.dd-menu{position:absolute;top:calc(100% + 4px);left:0;z-index:20;min-width:280px;max-width:420px;background:var(--panel);border:1px solid var(--line);border-radius:8px;box-shadow:0 10px 28px rgba(0,0,0,.18);padding:8px;display:flex;flex-direction:column;gap:6px}
|
||||
.dd-menu input[type=search]{font:inherit;font-size:14px;color:var(--fg);background:var(--card);border:1px solid var(--line);border-radius:6px;padding:6px 8px;width:100%;box-sizing:border-box}
|
||||
.dd-menu input[type=search]:focus-visible{border-color:var(--accent);outline:none}
|
||||
.dd-list{max-height:260px;overflow:auto;display:flex;flex-direction:column}
|
||||
.dd-item{display:flex;align-items:center;gap:8px;padding:5px 6px;border-radius:5px;cursor:pointer;font-size:14px}
|
||||
.dd-item:hover{background:var(--soft)}
|
||||
.dd-item input{accent-color:var(--accent);margin:0;width:14px;height:14px;flex:0 0 auto}
|
||||
.dd-item .n{margin-left:auto;font-size:11px;color:var(--muted);font-variant-numeric:tabular-nums}
|
||||
.dd-item.none{color:var(--muted);font-style:italic}
|
||||
.dd-item.zero{opacity:.5}
|
||||
.dd-foot{display:flex;gap:6px;justify-content:space-between;border-top:1px solid var(--line);padding-top:6px}
|
||||
.dd-foot .btn{height:30px;padding:0 10px;font-size:13.5px}
|
||||
.dd-empty{font-size:12.5px;color:var(--muted);padding:6px}
|
||||
.dd-sep{border:0;border-top:1px solid var(--line);margin:4px 0}
|
||||
/* меню «⋯»: открывается к левому краю, пункты с галочкой — переключатели */
|
||||
.dd-menu.menu{right:0;left:auto;min-width:230px;padding:6px}
|
||||
.menu button{font:inherit;font-size:14px;text-align:left;background:none;border:0;border-radius:5px;padding:7px 10px;color:var(--fg);cursor:pointer;width:100%;display:flex;justify-content:space-between;align-items:center;gap:10px}
|
||||
.menu button:hover,.menu button:focus-visible{background:var(--soft);outline:none}
|
||||
.menu button[aria-pressed="true"]::after{content:"✓";color:var(--accent);font-weight:600}
|
||||
.menu hr{border:0;border-top:1px solid var(--line);margin:4px 0}
|
||||
.menu .danger{color:var(--p-crit)}
|
||||
.confirm{display:flex;flex-direction:column;gap:6px;padding:6px 10px;font-size:14px}
|
||||
.confirm .row{flex-wrap:nowrap}
|
||||
.confirm .btn{height:28px;padding:0 10px;width:auto;flex:1 1 0}
|
||||
.confirm .btn.yes{background:var(--p-crit);color:#fff;border-color:var(--p-crit)}
|
||||
|
||||
19
web/src/styles/theme.css
Normal file
19
web/src/styles/theme.css
Normal file
@ -0,0 +1,19 @@
|
||||
/* 1. Цвета и темы: светлая по умолчанию, тёмная по системе или по выбору в меню */
|
||||
:root{
|
||||
--bg:#f3f4f1; --fg:#1f2321; --muted:#6b7069; --line:#d6d8d0; --card:#ffffff; --panel:#ffffff; --soft:#eaece6;
|
||||
--accent:#1f6f8b; --accent-fg:#ffffff; --edge:#9a9f97; --edge-strong:#1f6f8b; --guide:#c2410c;
|
||||
--k-decision:#b4542a; --k-analytics:#8a6d1f; --k-dev:#1f6f8b; --k-ui:#5b4b9a; --k-test:#2f7d4f; --k-org:#6b7069;
|
||||
--p-crit:#b23a3a; --p-high:#c7772a; --p-mid:#6b7069; --p-low:#9a9f97;
|
||||
--font-body:"IBM Plex Sans",system-ui,-apple-system,"Segoe UI",sans-serif;
|
||||
--font-mono:"IBM Plex Mono",ui-monospace,SFMono-Regular,Menlo,monospace;
|
||||
}
|
||||
@media (prefers-color-scheme: dark){ :root:not([data-theme="light"]){
|
||||
--bg:#151817; --fg:#e8e6df; --muted:#9a9f97; --line:#303532; --card:#1f2422; --panel:#1b201e; --soft:#252a28;
|
||||
--accent:#6fb3cc; --accent-fg:#101413; --edge:#6c726b; --edge-strong:#6fb3cc; --guide:#f08a5a;
|
||||
--k-decision:#e08a5a; --k-analytics:#d4b35a; --k-dev:#6fb3cc; --k-ui:#a493d8; --k-test:#6cc08f; --k-org:#9a9f97;
|
||||
--p-crit:#e36b6b; --p-high:#e3a059; --p-mid:#9a9f97; --p-low:#6c726b; color-scheme:dark }}
|
||||
:root[data-theme="dark"]{
|
||||
--bg:#151817; --fg:#e8e6df; --muted:#9a9f97; --line:#303532; --card:#1f2422; --panel:#1b201e; --soft:#252a28;
|
||||
--accent:#6fb3cc; --accent-fg:#101413; --edge:#6c726b; --edge-strong:#6fb3cc; --guide:#f08a5a;
|
||||
--k-decision:#e08a5a; --k-analytics:#d4b35a; --k-dev:#6fb3cc; --k-ui:#a493d8; --k-test:#6cc08f; --k-org:#9a9f97;
|
||||
--p-crit:#e36b6b; --p-high:#e3a059; --p-mid:#9a9f97; --p-low:#6c726b; color-scheme:dark }
|
||||
Loading…
x
Reference in New Issue
Block a user