Страница «Лента» и раздел в панели (#77)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Andrey Epifancev 2026-10-22 11:26:10 +04:00
parent 6f5fae7d82
commit 37f28c3812
10 changed files with 333 additions and 5 deletions

44
web/e2e/feed.spec.ts Normal file
View 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);
});

View 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>
);
}

View 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
View 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
View 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}

View File

@ -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>

View File

@ -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");

View File

@ -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)

View File

@ -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 /> },
];

View File

@ -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"/></>);