Страница «Лента» и раздел в панели (#77)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
parent
6f5fae7d82
commit
37f28c3812
44
web/e2e/feed.spec.ts
Normal file
44
web/e2e/feed.spec.ts
Normal file
@ -0,0 +1,44 @@
|
||||
import { expect, test } from "@playwright/test";
|
||||
import { HUB_URL } from "../playwright.config.ts";
|
||||
import { login, tokens } from "./hub.ts";
|
||||
|
||||
// журнал изменений засевается агентским маршрутом, как его шлёт агент v13 (#70); id записей уникальны на прогон
|
||||
async function seedHistory() {
|
||||
const run = Date.now();
|
||||
const created = new Date(Date.now() - 10 * 60_000).toISOString();
|
||||
const changes = [
|
||||
{ history_id: `e2e-${run}-1`, seq: 0, key: "AURORA-9002", created, author: "Петров Пётр", field_id: "status",
|
||||
field_name: "status", from: "1", from_string: "В работе", to: "2", to_string: "Готово" },
|
||||
{ history_id: `e2e-${run}-2`, seq: 0, key: "AURORA-9002", created, author: "Петров Пётр", field_id: "assignee",
|
||||
field_name: "assignee", from: null, from_string: null, to: "x".repeat(200), to_string: "Очень-длинное-имя-без-пробелов-".repeat(8) },
|
||||
];
|
||||
const r = await fetch(HUB_URL + "/agent/history", { method: "POST", headers: { Authorization: "Bearer " + tokens().agent, "Content-Type": "application/json" }, body: JSON.stringify({ changes }) });
|
||||
expect(r.ok).toBe(true);
|
||||
}
|
||||
|
||||
test("лента: событие за сегодня, карточка окном поверх ленты", async ({ page }) => {
|
||||
await seedHistory();
|
||||
await login(page, "/app/feed?project=AWPCA");
|
||||
const group = page.getByTestId("feed-group").filter({ has: page.getByRole("link", { name: "AURORA-9002", exact: true }) });
|
||||
await expect(group.getByText("статус «В работе» → «Готово»").first()).toBeVisible();
|
||||
const other = page.getByRole("checkbox", { name: "Прочие поля" });
|
||||
await other.click(); // флажок управляемый: состояние приходит из адреса после навигации
|
||||
await expect(other).toBeChecked();
|
||||
await expect(page).toHaveURL(/kinds=status%2Cdescription%2Cdates%2Ccomment%2Cother|kinds=status,description,dates,comment,other/);
|
||||
await expect(group.getByText(/исполнитель:/).first()).toBeVisible();
|
||||
await group.getByRole("link", { name: "AURORA-9002", exact: true }).click();
|
||||
const d = page.getByRole("dialog");
|
||||
await expect(d.getByRole("heading", { name: "API свода" })).toBeVisible();
|
||||
await page.keyboard.press("Escape");
|
||||
await expect(page.getByRole("dialog")).toHaveCount(0);
|
||||
await expect(page.getByTestId("page-feed")).toBeVisible();
|
||||
});
|
||||
|
||||
test("лента на 390 px: нет горизонтальной прокрутки при длинных значениях", async ({ page }) => {
|
||||
await seedHistory();
|
||||
await page.setViewportSize({ width: 390, height: 844 });
|
||||
await login(page, "/app/feed?project=AWPCA&kinds=status,other");
|
||||
await expect(page.getByTestId("feed-group").first()).toBeVisible();
|
||||
const overflow = await page.evaluate(() => document.documentElement.scrollWidth - window.innerWidth);
|
||||
expect(overflow).toBeLessThanOrEqual(0);
|
||||
});
|
||||
20
web/src/feed/EventList.tsx
Normal file
20
web/src/feed/EventList.tsx
Normal file
@ -0,0 +1,20 @@
|
||||
import type { ChangeEvent } from "../api/types.ts";
|
||||
import { eventText, eventTime } from "./events.ts";
|
||||
import "./feed.css";
|
||||
|
||||
// строки событий журнала: время, автор, что изменилось (лента и история в карточке; spec web-app «Лента изменений»)
|
||||
export function EventList({ events, now, alwaysDate = false }: { events: ChangeEvent[]; now: Date; alwaysDate?: boolean }) {
|
||||
return (
|
||||
<ul className="feed-events">
|
||||
{events.map((e, n) => (
|
||||
<li key={n} className="feed-event">
|
||||
<span className="feed-event__time">{eventTime(e.at, now, alwaysDate)}</span>
|
||||
<span className="feed-event__body">
|
||||
<span className="feed-event__author">{e.author ?? "—"}</span>{" "}
|
||||
<span className="feed-event__text">{eventText(e)}</span>
|
||||
</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
);
|
||||
}
|
||||
152
web/src/feed/FeedPage.test.tsx
Normal file
152
web/src/feed/FeedPage.test.tsx
Normal file
@ -0,0 +1,152 @@
|
||||
import { render, screen, waitFor, within } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { readFileSync } from "node:fs";
|
||||
import { join } from "node:path";
|
||||
import { createMemoryRouter } from "react-router";
|
||||
import { beforeEach, describe, expect, it } from "vitest";
|
||||
import { App } from "../App.tsx";
|
||||
import type { ChangeEvent } from "../api/types.ts";
|
||||
import { setToken } from "../auth/token.ts";
|
||||
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 SNAP = JSON.parse(readFileSync(join(import.meta.dirname, "..", "board", "__fixtures__", "hub-basic.board.json"), "utf8"));
|
||||
const A = { label: "AWPCA", name: "АРМ КА", board_id: null, board_type: null, created_at: "", issues_count: 9 };
|
||||
const B = { label: "STOCKS", name: "Запасы", board_id: null, board_type: null, created_at: "", issues_count: 1 };
|
||||
const minutesAgo = (m: number) => new Date(Date.now() - m * 60_000).toISOString();
|
||||
const ev = (over: Partial<ChangeEvent>): ChangeEvent => ({
|
||||
at: minutesAgo(30), key: "AURORA-9002", summary: "API свода", status: "Готово", kind: "status", field: "status",
|
||||
author: "Петров Пётр", from: "В работе", to: "Готово", from_value: null, to_value: null, ...over,
|
||||
});
|
||||
|
||||
type Changes = { since?: string; until?: string; truncated?: boolean; changes: ChangeEvent[] } | Response;
|
||||
function hub(changes: () => Changes = () => ({ changes: [] })) {
|
||||
const calls: URL[] = [];
|
||||
mockFetch((method, url) => {
|
||||
const k = `${method} ${url.pathname}`;
|
||||
if (k === "GET /changes") {
|
||||
calls.push(url);
|
||||
const r = changes();
|
||||
return r instanceof Response ? r : json(200, { since: "", until: "", truncated: false, ...r });
|
||||
}
|
||||
if (k === "GET /projects") return json(200, { projects: [A, B] });
|
||||
if (k === "GET /settings") return json(200, settings);
|
||||
if (k === "GET /board") return json(200, SNAP);
|
||||
if (k === "GET /issues") return json(200, { issues: [] });
|
||||
if (k === "GET /batches") return json(200, { batches: [] });
|
||||
if (k === "GET /agent") return json(200, { online: true, last_seen_at: null });
|
||||
if (k.startsWith("GET /issues/")) return json(200, issue(decodeURIComponent(url.pathname.split("/").pop()!)));
|
||||
return json(404, { error: "not_found" });
|
||||
});
|
||||
return calls;
|
||||
}
|
||||
const open = (path: string) => {
|
||||
const router = createMemoryRouter(routes, { initialEntries: [path], basename: "/app" });
|
||||
render(<App router={router} />);
|
||||
return router;
|
||||
};
|
||||
const page = () => screen.findByTestId("page-feed");
|
||||
|
||||
describe("лента изменений", () => {
|
||||
beforeEach(() => { setToken("tok"); localStorage.clear(); });
|
||||
|
||||
it("по умолчанию — «Сегодня», виды без «Прочих полей», события сгруппированы по задаче", async () => {
|
||||
const calls = hub(() => ({ changes: [
|
||||
ev({}), ev({ key: "AURORA-9001", summary: "Схема", status: "В работе", kind: "comment", field: "comment", from: null, to: "Готово к проверке" }),
|
||||
ev({ at: minutesAgo(40), kind: "description", field: "description" }),
|
||||
] }));
|
||||
open("/app/feed?project=AWPCA");
|
||||
await page();
|
||||
const groups = await screen.findAllByTestId("feed-group");
|
||||
expect(groups.map((g) => within(g).getByRole("link").textContent)).toEqual(["AURORA-9002", "AURORA-9001"]);
|
||||
expect(within(groups[0]).getByText("API свода")).toBeInTheDocument();
|
||||
expect(within(groups[0]).getByText(/статус «В работе» → «Готово»/)).toBeInTheDocument();
|
||||
expect(within(groups[0]).getByText("описание изменено")).toBeInTheDocument();
|
||||
expect(within(groups[1]).getByText("комментарий: Готово к проверке")).toBeInTheDocument();
|
||||
const q = calls[0].searchParams;
|
||||
expect(q.get("project")).toBe("AWPCA");
|
||||
expect(q.get("kinds")).toBe("status,description,dates,comment");
|
||||
const midnight = new Date(); midnight.setHours(0, 0, 0, 0);
|
||||
expect(new Date(q.get("since")!).getTime()).toBe(midnight.getTime());
|
||||
expect(q.get("limit")).toBe("2000");
|
||||
expect(screen.getByRole("radio", { name: "Сегодня" })).toHaveAttribute("aria-checked", "true");
|
||||
expect(screen.getByText(/3 события/)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("«Что закрылось за два часа»: период и виды в адресе и в запросе", async () => {
|
||||
const calls = hub();
|
||||
const router = open("/app/feed?project=AWPCA");
|
||||
await page();
|
||||
await userEvent.click(screen.getByRole("radio", { name: "2 часа" }));
|
||||
for (const name of ["Описание", "Сроки", "Комментарии"]) await userEvent.click(screen.getByRole("checkbox", { name }));
|
||||
await waitFor(() => expect(router.state.location.search).toBe("?project=AWPCA&period=2h&kinds=status"));
|
||||
expect(router.state.location.pathname).toBe("/app/feed");
|
||||
await waitFor(() => expect(calls.at(-1)!.searchParams.get("kinds")).toBe("status"));
|
||||
const since = new Date(calls.at(-1)!.searchParams.get("since")!).getTime();
|
||||
expect(Math.abs(Date.now() - 2 * 3600_000 - since)).toBeLessThan(60_000);
|
||||
});
|
||||
|
||||
it("карточка из ленты — окно поверх, закрытие возвращает ленту с тем же периодом", async () => {
|
||||
hub(() => ({ changes: [ev({})] }));
|
||||
const router = open("/app/feed?project=AWPCA&period=7d");
|
||||
await page();
|
||||
await userEvent.click(await screen.findByRole("link", { name: "AURORA-9002" }));
|
||||
const d = await screen.findByRole("dialog");
|
||||
expect(await within(d).findByRole("heading", { name: "Тема AURORA-9002" })).toBeInTheDocument();
|
||||
expect(screen.getByTestId("page-feed")).toBeInTheDocument();
|
||||
await userEvent.click(within(d).getAllByRole("button", { name: "Закрыть" })[0]);
|
||||
await waitFor(() => expect(screen.queryByRole("dialog")).toBeNull());
|
||||
expect(router.state.location.pathname + router.state.location.search).toBe("/app/feed?project=AWPCA&period=7d");
|
||||
});
|
||||
|
||||
it("пустой период, truncated, ошибка с «Повторить»", async () => {
|
||||
let mode: "empty" | "truncated" | "error" = "empty";
|
||||
hub(() => mode === "error" ? json(500, { error: "boom", detail: "сбой" })
|
||||
: mode === "truncated" ? { truncated: true, changes: [ev({})] } : { changes: [] });
|
||||
open("/app/feed?project=AWPCA");
|
||||
expect(await screen.findByText("за период изменений нет")).toBeInTheDocument();
|
||||
mode = "truncated";
|
||||
await userEvent.click(screen.getByRole("radio", { name: "Вчера" }));
|
||||
expect(await screen.findByText("показаны последние 1 событий — выберите период короче")).toBeInTheDocument();
|
||||
mode = "error";
|
||||
await userEvent.click(screen.getByRole("radio", { name: "7 дней" }));
|
||||
const retry = await screen.findByRole("button", { name: "Повторить" });
|
||||
mode = "empty";
|
||||
await userEvent.click(retry);
|
||||
expect(await screen.findByText("за период изменений нет")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("все флажки сняты — подсказка, запроса нет", async () => {
|
||||
const calls = hub();
|
||||
open("/app/feed?project=AWPCA&kinds=status");
|
||||
await page();
|
||||
await waitFor(() => expect(calls.length).toBe(1));
|
||||
await userEvent.click(screen.getByRole("checkbox", { name: "Статусы" }));
|
||||
expect(await screen.findByText("выберите хотя бы один вид")).toBeInTheDocument();
|
||||
expect(calls.length).toBe(1);
|
||||
});
|
||||
|
||||
it("мусор в адресе: период по умолчанию, неизвестные виды отброшены", async () => {
|
||||
const calls = hub();
|
||||
open("/app/feed?project=AWPCA&period=foo&kinds=status,foo");
|
||||
await page();
|
||||
await waitFor(() => expect(calls.length).toBeGreaterThan(0));
|
||||
expect(screen.getByRole("radio", { name: "Сегодня" })).toHaveAttribute("aria-checked", "true");
|
||||
expect(calls[0].searchParams.get("kinds")).toBe("status");
|
||||
expect(screen.getByRole("checkbox", { name: "Статусы" })).toBeChecked();
|
||||
expect(screen.getByRole("checkbox", { name: "Сроки" })).not.toBeChecked();
|
||||
});
|
||||
|
||||
it("смена проекта: запрос по новому проекту, период и виды сохраняются", async () => {
|
||||
const calls = hub();
|
||||
const router = open("/app/feed?project=AWPCA&period=2h&kinds=status");
|
||||
await page();
|
||||
await userEvent.selectOptions(screen.getByRole("combobox", { name: "Проект" }), "STOCKS");
|
||||
await waitFor(() => expect(calls.at(-1)!.searchParams.get("project")).toBe("STOCKS"));
|
||||
expect(calls.at(-1)!.searchParams.get("kinds")).toBe("status");
|
||||
expect(router.state.location.pathname).toBe("/app/feed");
|
||||
expect(router.state.location.search).toBe("?project=STOCKS&period=2h&kinds=status");
|
||||
});
|
||||
});
|
||||
79
web/src/feed/FeedPage.tsx
Normal file
79
web/src/feed/FeedPage.tsx
Normal file
@ -0,0 +1,79 @@
|
||||
import { useMemo, useState } from "react";
|
||||
import { useSearchParams } from "react-router";
|
||||
import { useChanges } from "../api/queries.ts";
|
||||
import type { ChangeKind } from "../api/types.ts";
|
||||
import { IssueLink } from "../issues/IssueLink.tsx";
|
||||
import { Badge, Button, Checkbox, Notice, PageHeader, Panel, Segmented } from "../ui/index.ts";
|
||||
import { EventList } from "./EventList.tsx";
|
||||
import { groupByIssue, KINDS, kindsParam, parseKinds, parsePeriod, PERIODS, periodRange, type Period } from "./events.ts";
|
||||
import "./feed.css";
|
||||
|
||||
const pad = (n: number) => String(n).padStart(2, "0");
|
||||
const stamp = (d: Date) => `${pad(d.getDate())}.${pad(d.getMonth() + 1)} ${pad(d.getHours())}:${pad(d.getMinutes())}`;
|
||||
|
||||
function plural(n: number): string {
|
||||
const m10 = n % 10, m100 = n % 100;
|
||||
if (m10 === 1 && m100 !== 11) return "событие";
|
||||
if (m10 >= 2 && m10 <= 4 && (m100 < 12 || m100 > 14)) return "события";
|
||||
return "событий";
|
||||
}
|
||||
|
||||
// лента изменений проекта за период (spec web-app «Лента изменений», design web-feed 1–7)
|
||||
export function FeedPage({ project }: { project: string }) {
|
||||
const [params, setParams] = useSearchParams();
|
||||
const period = parsePeriod(params.get("period"));
|
||||
const kinds = useMemo(() => parseKinds(params.get("kinds")), [params]);
|
||||
// «сейчас» фиксируется на смену периода: границы запроса стабильны, ключ кэша не меняется на каждой отрисовке
|
||||
const [now, setNow] = useState(() => new Date());
|
||||
const range = useMemo(() => periodRange(period, now), [period, now]);
|
||||
const changes = useChanges({ project, since: range.since.toISOString(), until: range.until.toISOString(), kinds: kinds.join(",") },
|
||||
kinds.length > 0);
|
||||
|
||||
const setParam = (name: string, value: string | null) => setParams((p) => {
|
||||
const n = new URLSearchParams(p);
|
||||
if (value) n.set(name, value); else n.delete(name);
|
||||
return n;
|
||||
});
|
||||
const choosePeriod = (p: Period) => { setNow(new Date()); setParam("period", p === "today" ? null : p); };
|
||||
const toggleKind = (k: ChangeKind, on: boolean) => {
|
||||
const next = on ? [...kinds, k] : kinds.filter((x) => x !== k);
|
||||
setParam("kinds", next.length ? kindsParam(next) : "none"); // пустой набор — явное «none», иначе адрес вернул бы умолчание
|
||||
};
|
||||
|
||||
const data = changes.data;
|
||||
const groups = useMemo(() => groupByIssue(data?.changes ?? []), [data]);
|
||||
const count = data?.changes.length ?? 0;
|
||||
const subtitle = kinds.length && data ? `${count} ${plural(count)} · ${stamp(range.since)} — ${stamp(range.until)}` : undefined;
|
||||
|
||||
return (
|
||||
<div data-testid="page-feed" className="feed">
|
||||
<PageHeader title="Лента" subtitle={subtitle} />
|
||||
<div className="feed__controls">
|
||||
<Segmented label="Период" value={period} options={PERIODS} onChange={choosePeriod} />
|
||||
<div className="feed__kinds" role="group" aria-label="Виды событий">
|
||||
{KINDS.map((k) => (
|
||||
<Checkbox key={k.value} label={k.label} checked={kinds.includes(k.value)}
|
||||
onChange={(e) => toggleKind(k.value, e.currentTarget.checked)} />
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
{!kinds.length && <Notice tone="info">выберите хотя бы один вид</Notice>}
|
||||
{kinds.length > 0 && changes.isError && (
|
||||
<Notice tone="error" action={<Button onClick={() => void changes.refetch()}>Повторить</Button>}>{(changes.error as Error).message}</Notice>
|
||||
)}
|
||||
{kinds.length > 0 && changes.isPending && <p className="feed__hint">Загрузка…</p>}
|
||||
{data && !count && <Notice tone="info">за период изменений нет</Notice>}
|
||||
{groups.map((g) => (
|
||||
<Panel key={g.key} as="section" className="feed-group" data-testid="feed-group" aria-label={g.key}>
|
||||
<header className="feed-group__head">
|
||||
<IssueLink issueKey={g.key} className="feed-group__key" />
|
||||
<span className="feed-group__sum">{g.summary ?? "нет в зеркале"}</span>
|
||||
{g.status && <Badge>{g.status}</Badge>}
|
||||
</header>
|
||||
<EventList events={g.events} now={now} />
|
||||
</Panel>
|
||||
))}
|
||||
{data?.truncated && <Notice tone="warning">{`показаны последние ${count} событий — выберите период короче`}</Notice>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
16
web/src/feed/feed.css
Normal file
16
web/src/feed/feed.css
Normal file
@ -0,0 +1,16 @@
|
||||
/* лента изменений (spec web-app «Лента изменений»): только токены дизайн-системы */
|
||||
.feed{display:flex;flex-direction:column;gap:var(--space-4);padding-top:var(--space-6)}
|
||||
.feed .ui-page-header{margin-bottom:0}
|
||||
.feed__controls{display:flex;flex-wrap:wrap;align-items:center;gap:var(--space-3) var(--space-5)}
|
||||
.feed__kinds{display:flex;flex-wrap:wrap;align-items:center;gap:var(--space-2) var(--space-4)}
|
||||
.feed__hint{margin:0;color:var(--color-text-secondary)}
|
||||
.feed-group{display:flex;flex-direction:column;gap:var(--space-3);min-width:0}
|
||||
.feed-group__head{display:flex;flex-wrap:wrap;align-items:center;gap:var(--space-2)}
|
||||
.feed-group__key{font-family:var(--font-mono);font-size:13px;color:var(--color-action-primary);text-decoration:none}
|
||||
.feed-group__key:hover{text-decoration:underline}
|
||||
.feed-group__sum{font-weight:600;min-width:0;overflow-wrap:anywhere}
|
||||
.feed-events{list-style:none;margin:0;padding:0;display:flex;flex-direction:column;gap:var(--space-2)}
|
||||
.feed-event{display:flex;gap:var(--space-3);font-size:var(--fs-body);line-height:var(--lh-body);min-width:0}
|
||||
.feed-event__time{flex:0 0 auto;font-family:var(--font-mono);font-size:13px;color:var(--color-text-secondary);white-space:nowrap}
|
||||
.feed-event__body{min-width:0;overflow-wrap:anywhere;color:var(--color-text-primary)}
|
||||
.feed-event__author{font-weight:600}
|
||||
@ -70,8 +70,11 @@ export function Layout() {
|
||||
}, [okLabel, fromUrl, issueKey, onPage, at, pageLocation.search, navigate]);
|
||||
|
||||
const [newOpen, setNewOpen] = useState(false);
|
||||
const section = at === "/issues" || at === "/board" ? at : null;
|
||||
const select = useCallback((label: string) => { void navigate({ pathname: section ?? "/board", search: withProject("", label) }); }, [navigate, section]);
|
||||
const section = at === "/issues" || at === "/board" || at === "/feed" ? at : null;
|
||||
// смена проекта: фильтры доски и списка — проектные и сбрасываются; период и виды ленты от проекта не зависят (R-1)
|
||||
const select = useCallback((label: string) => {
|
||||
void navigate({ pathname: section ?? "/board", search: withProject(section === "/feed" ? pageLocation.search : "", label) });
|
||||
}, [navigate, section, pageLocation.search]);
|
||||
const ctx = useMemo<ProjectCtx>(() => ({ state, projects: projects.data ?? [], select, openNew: () => setNewOpen(true) }),
|
||||
[state, projects.data, select]);
|
||||
|
||||
@ -80,7 +83,7 @@ export function Layout() {
|
||||
<ProjectContext value={ctx}>
|
||||
<div className="shell">
|
||||
<Sidebar section={section} project={okLabel} />
|
||||
<main className={"page" + (section !== "/issues" ? " page--board" : "")}>
|
||||
<main className={"page" + (section !== "/issues" && section !== "/feed" ? " page--board" : "")}>
|
||||
<ErrorBoundary key={at}>{page}</ErrorBoundary>
|
||||
</main>
|
||||
</div>
|
||||
|
||||
@ -52,6 +52,16 @@ describe("боковая панель", () => {
|
||||
expect(screen.getByRole("link", { name: "Доска связей" })).not.toHaveAttribute("aria-current");
|
||||
});
|
||||
|
||||
it("раздел «Лента» ведёт на /feed с проектом и отмечается как текущий", async () => {
|
||||
hub({ "GET /changes": () => json(200, { since: "", until: "", truncated: false, changes: [] }) });
|
||||
open("/app/feed?project=AWPCA");
|
||||
expect(await screen.findByTestId("page-feed")).toBeInTheDocument();
|
||||
const nav = within(bar()).getByRole("navigation", { name: "Разделы" });
|
||||
await waitFor(() => expect(within(nav).getByRole("link", { name: "Лента" })).toHaveAttribute("href", "/app/feed?project=AWPCA"));
|
||||
expect(within(nav).getByRole("link", { name: "Лента" })).toHaveAttribute("aria-current", "page");
|
||||
expect(within(nav).getByRole("link", { name: "Задачи" })).not.toHaveAttribute("aria-current");
|
||||
});
|
||||
|
||||
it("на странице «не найдено» ни один раздел не активен; ссылки в сообщении ведут на разделы", async () => {
|
||||
hub();
|
||||
open("/app/nowhere?project=AWPCA");
|
||||
|
||||
@ -4,17 +4,18 @@ import { Link } from "react-router";
|
||||
import { clearToken } from "../auth/token.ts";
|
||||
import { withProject } from "../projects/current.ts";
|
||||
import { ProjectPicker } from "../projects/ProjectPicker.tsx";
|
||||
import { IconBoard, IconButton, IconClose, IconList, IconMenu, IconSidebar, Menu, MenuItem, ThemeSwitch } from "../ui/index.ts";
|
||||
import { IconBoard, IconButton, IconClose, IconHistory, IconList, IconMenu, IconSidebar, Menu, MenuItem, ThemeSwitch } from "../ui/index.ts";
|
||||
import { DataStatus } from "./DataStatus.tsx";
|
||||
import { useJiraRefresh } from "./refresh.ts";
|
||||
import { refreshText } from "./refreshStatus.ts";
|
||||
import { readSidebar, saveSidebar } from "./sidebarPref.ts";
|
||||
|
||||
export type Section = "/board" | "/issues";
|
||||
export type Section = "/board" | "/issues" | "/feed";
|
||||
|
||||
const SECTIONS: { to: Section; label: string; icon: React.ReactNode }[] = [
|
||||
{ to: "/board", label: "Доска связей", icon: <IconBoard /> },
|
||||
{ to: "/issues", label: "Задачи", icon: <IconList /> },
|
||||
{ to: "/feed", label: "Лента", icon: <IconHistory /> },
|
||||
];
|
||||
|
||||
// узкий экран (<640 px): панель не в потоке, а выезжает по кнопке «Меню» из закреплённой строки (design 4)
|
||||
|
||||
@ -1,5 +1,6 @@
|
||||
import { Navigate, type RouteObject } from "react-router";
|
||||
import { BoardPage } from "../board/BoardPage.tsx";
|
||||
import { FeedPage } from "../feed/FeedPage.tsx";
|
||||
import { IssuesPage } from "../issues/IssuesPage.tsx";
|
||||
import { ProjectGate } from "../projects/ProjectGate.tsx";
|
||||
import { NotFound } from "./NotFound.tsx";
|
||||
@ -9,5 +10,6 @@ export const PAGE_ROUTES: RouteObject[] = [
|
||||
{ path: "/", element: <Navigate to="/board" replace /> },
|
||||
{ path: "/board", element: <ProjectGate>{(p) => <BoardPage key={p.label} project={p.label} />}</ProjectGate> },
|
||||
{ path: "/issues", element: <ProjectGate>{(p) => <IssuesPage key={p.label} project={p.label} />}</ProjectGate> },
|
||||
{ path: "/feed", element: <ProjectGate>{(p) => <FeedPage key={p.label} project={p.label} />}</ProjectGate> },
|
||||
{ path: "*", element: <NotFound /> },
|
||||
];
|
||||
|
||||
@ -35,4 +35,5 @@ export const IconOpen = make(<><path d="M5 12h14"/><path d="M13 6l6 6-6 6"/></>)
|
||||
export const IconBoard = make(<><rect x="3" y="4" width="7" height="6" rx="1.5"/><rect x="14" y="4" width="7" height="6" rx="1.5"/><rect x="8.5" y="15" width="7" height="6" rx="1.5"/><path d="M10 7h4M6.5 10v2.5h5.5V15"/></>);
|
||||
export const IconList = make(<><path d="M9 6h11M9 12h11M9 18h11"/><path d="M4 6h1M4 12h1M4 18h1"/></>);
|
||||
export const IconMenu = make(<><path d="M4 6h16M4 12h16M4 18h16"/></>);
|
||||
export const IconHistory = make(<><path d="M4 12a8 8 0 1 0 2.3-5.7"/><path d="M4 4v4h4"/><path d="M12 8v4l3 2"/></>);
|
||||
export const IconSidebar = make(<><rect x="3" y="4" width="18" height="16" rx="2"/><path d="M9 4v16"/></>);
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user