Веб: правки вики в общей ленте (#140)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Andrey Epifancev 2026-10-28 13:04:40 +04:00
parent 558fe7812b
commit 93eff7eac0
11 changed files with 265 additions and 21 deletions

View File

@ -17,4 +17,9 @@ describe("сводка вики", () => {
expect(screen.getByText("ещё не синхронизирована")).toBeInTheDocument();
expect(screen.queryByText(/0 страниц/)).toBeNull();
});
it("у проекта с корнем — ссылка «Правки» на ленту с источником вики", () => {
renderWithProviders(<Summary project={P} />);
expect(screen.getByRole("link", { name: "Правки" })).toHaveAttribute("href", "/feed?project=AWPCA&sources=wiki");
});
});

View File

@ -26,6 +26,7 @@ export function Summary({ project }: { project: Project }) {
<p className="wiki-summary__text">{pages === null ? "ещё не синхронизирована" : `${pages} страниц`}</p>
<form onSubmit={submit} role="search" className="wiki-summary__search"><SearchBox value={q} onChange={setQ} /></form>
<Link to={`/wiki${base}`} className="wiki-summary__link">Вики</Link>
<Link to={`/feed${base}&sources=wiki`} className="wiki-summary__link">Правки</Link>
</div>
);
}

View File

@ -4,7 +4,7 @@ import { api } from "../../../api/client.ts";
export interface WikiBatch { id: number; status: string }
export interface WikiStatus {
root: string; root_title: string | null; synced_at: string | null; pages: number; error: string | null; batch: WikiBatch | null;
root: string; root_title: string | null; synced_at: string | null; history_at?: string | null; pages: number; error: string | null; batch: WikiBatch | null;
}
export interface WikiNode { code: string; title: string | null; depth: number; parent_code: string | null; size: number | null }
export interface WikiAttachment { id: number; name: string | null; state: string; size: number | null; error?: string | null; image?: boolean }
@ -14,6 +14,14 @@ export interface WikiPageData {
export interface WikiHit { code: string; title: string | null; path: string; snippet: string }
export interface WikiAttachmentState { id: number; name: string | null; state: string; error: string | null; size: number | null }
export interface WikiChange {
at: string; page_code: string; title: string | null; path: string | null; version: string;
author: { login: string | null; name: string | null }; added: number; removed: number;
}
export interface WikiChanges {
since: string; until: string; synced_at: string | null; history_at: string | null; truncated: boolean; changes: WikiChange[];
}
export const POLL_MS = 3000; // опрос хода синхронизации, пока пакет не завершён
export const SEARCH_LIMIT = 20;
@ -24,6 +32,7 @@ export const wikiQk = {
tree: (p: string) => ["wiki", p, "tree"] as const,
page: (p: string, code: string) => ["wiki", p, "page", code] as const,
search: (p: string, q: string) => ["wiki", p, "search", q] as const,
changes: (p: string, period: string) => ["wiki", p, "changes", period] as const,
};
const terminal = (b: WikiBatch | null) => b === null || b.status === "done" || b.status === "cancelled";
@ -62,3 +71,14 @@ export const useWikiSearch = (project: string, q: string) =>
queryKey: wikiQk.search(project, q), enabled: q !== "",
queryFn: () => api.get<{ results: WikiHit[] }>("/wiki/search", { project, q, limit: SEARCH_LIMIT }).then((r) => r.results),
});
// правки за период для ленты; границы считает запрос в момент выполнения (перечитывание берёт «до сейчас»)
export const WIKI_CHANGES_LIMIT = 2000;
export const useWikiChanges = (project: string, period: string, bounds: () => { since: Date; until: Date }, enabled: boolean) =>
useQuery({
queryKey: wikiQk.changes(project, period), enabled, refetchOnWindowFocus: true,
queryFn: () => {
const { since, until } = bounds();
return api.get<WikiChanges>("/wiki/changes", { project, since: since.toISOString(), until: until.toISOString(), limit: WIKI_CHANGES_LIMIT });
},
});

View File

@ -0,0 +1,31 @@
import { Link, useLocation } from "react-router";
import { Panel } from "../../../ui/index.ts";
import { eventTime } from "../../../ui/format.ts";
import { versionHref, wikiPageHref } from "../paths.ts";
import { authorName, deltaText, type PageGroup } from "./group.ts";
import "./feed.css";
// группа ленты — одна страница вики: название ссылкой на страницу, путь и правки; правка открывает окно версии поверх ленты
export function WikiFeedGroup({ project, group, now }: { project: string; group: PageGroup; now: Date }) {
const loc = useLocation();
const state = { background: { pathname: loc.pathname, search: loc.search }, depth: 1 };
return (
<Panel className="wiki-feed-group" data-testid="feed-group">
<header className="wiki-feed-group__head">
<Link className="wiki-feed-group__title" to={wikiPageHref(project, group.code)}>{group.title || group.code}</Link>
{group.path && <span className="wiki-feed-group__path">{group.path}</span>}
</header>
<ul className="wiki-feed-edits">
{group.changes.map((c) => (
<li key={c.version}>
<Link className="wiki-feed-edit" to={versionHref(project, c.page_code, c.version)} state={state}>
<span className="wiki-feed-edit__time">{eventTime(c.at, now)}</span>
<span className="wiki-feed-edit__author">{authorName(c.author)}</span>
<span className="wiki-feed-edit__delta">{deltaText(c)}</span>
</Link>
</li>
))}
</ul>
</Panel>
);
}

View File

@ -0,0 +1,12 @@
/* источник «Вики» в ленте (spec web-app «Правки вики в ленте»): только токены дизайн-системы */
.wiki-feed-group{display:flex;flex-direction:column;gap:var(--space-3);min-width:0}
.wiki-feed-group__head{display:flex;flex-wrap:wrap;align-items:baseline;gap:var(--space-1) var(--space-3)}
.wiki-feed-group__title{font-weight:600;min-width:0;overflow-wrap:anywhere;color:var(--color-action-primary);text-decoration:none}
.wiki-feed-group__title:hover{text-decoration:underline}
.wiki-feed-group__path{min-width:0;overflow-wrap:anywhere;font-size:13px;color:var(--color-text-secondary)}
.wiki-feed-edits{list-style:none;margin:0;padding:0;display:flex;flex-direction:column;gap:var(--space-2)}
.wiki-feed-edit{display:flex;flex-wrap:wrap;gap:var(--space-1) var(--space-3);font-size:var(--fs-body);line-height:var(--lh-body);min-width:0;color:var(--color-text-primary);text-decoration:none}
.wiki-feed-edit:hover .wiki-feed-edit__author{text-decoration:underline}
.wiki-feed-edit__time{flex:0 0 auto;font-family:var(--font-mono);font-size:13px;color:var(--color-text-secondary);white-space:nowrap}
.wiki-feed-edit__author{font-weight:600;min-width:0;overflow-wrap:anywhere}
.wiki-feed-edit__delta{font-family:var(--font-mono);font-size:13px;color:var(--color-text-secondary);white-space:nowrap}

View File

@ -0,0 +1,24 @@
import { describe, expect, it } from "vitest";
import type { WikiChange } from "../api/queries.ts";
import { authorName, deltaText, groupByPage } from "./group.ts";
const c = (page_code: string, version: string): WikiChange => ({
at: "2026-10-28T09:00:00+00:00", page_code, title: page_code, path: null, version,
author: { login: null, name: null }, added: 1, removed: 0,
});
describe("группировка правок вики", () => {
it("по странице, по первому появлению; правки — в порядке ответа", () => {
const g = groupByPage([c("DOC-3", "V3"), c("DOC-2", "V2"), c("DOC-3", "V1")]);
expect(g.map((x) => x.code)).toEqual(["DOC-3", "DOC-2"]);
expect(g[0].changes.map((x) => x.version)).toEqual(["V3", "V1"]);
});
it("deltaText: плюс, косая черта, минус U+2212", () => {
expect(deltaText({ added: 40, removed: 12 })).toBe("+40 / −12");
});
it("authorName: имя, иначе логин, иначе «—»", () => {
expect(authorName({ login: "x", name: null })).toBe("x");
expect(authorName({ login: "x", name: "Икс" })).toBe("Икс");
expect(authorName({ login: null, name: null })).toBe("—");
});
});

View File

@ -0,0 +1,18 @@
import type { WikiChange } from "../api/queries.ts";
export type PageGroup = { code: string; title: string | null; path: string | null; changes: WikiChange[] };
// правки одной страницы вместе; группы — по первому появлению (ответ хаба идёт от новых к старым), правки — в порядке ответа
export function groupByPage(changes: WikiChange[]): PageGroup[] {
const by = new Map<string, PageGroup>();
for (const c of changes) {
let g = by.get(c.page_code);
if (!g) { g = { code: c.page_code, title: c.title, path: c.path, changes: [] }; by.set(c.page_code, g); }
g.changes.push(c);
}
return [...by.values()];
}
export const deltaText = (c: { added: number; removed: number }): string => `+${c.added} / −${c.removed}`;
export const authorName = (a: { login: string | null; name: string | null }): string => a.name || a.login || "—";

View File

@ -0,0 +1,29 @@
import { useMemo } from "react";
import type { FeedResult, FeedSource, FeedSourceInput } from "../../types.ts";
import { useWikiChanges } from "../api/queries.ts";
import { groupByPage } from "./group.ts";
import { WikiFeedGroup } from "./WikiFeedGroup.tsx";
const EMPTY: FeedResult = { groups: [], count: 0, window: null, pending: false, error: null, truncated: false, notice: null };
function useWikiFeed(input: FeedSourceInput): FeedResult {
const project = input.project.label;
const q = useWikiChanges(project, input.period, input.bounds, input.enabled);
const data = input.enabled ? q.data : undefined;
const updatedAt = q.dataUpdatedAt;
const groups = useMemo(() => {
const now = new Date(updatedAt || Date.now()); // «текущие сутки» для времени правок — на момент ответа
return groupByPage(data?.changes ?? []).map((g) => ({
key: g.code, at: g.changes[0]?.at ?? "", node: <WikiFeedGroup project={project} group={g} now={now} />,
}));
}, [data, updatedAt, project]);
if (!input.enabled) return EMPTY;
return {
groups, count: data?.changes.length ?? 0, window: data ? { since: data.since, until: data.until } : null,
pending: q.isPending, truncated: !!data?.truncated,
error: q.isError ? { message: (q.error as Error).message, retry: () => void q.refetch() } : null,
notice: data && data.history_at === null ? "история вики ещё не загружена — нужен eva-agent v2" : null,
};
}
export const wikiFeed: FeedSource = { available: (p) => !!p.wiki_root, useFeed: useWikiFeed };

View File

@ -1,6 +1,7 @@
import { ProjectGate } from "../../projects/ProjectGate.tsx";
import { IconBook } from "../../ui/index.ts";
import type { HubModule } from "../types.ts";
import { wikiFeed } from "./feed/source.tsx";
import { wikiStatus } from "./status.ts";
import { Summary } from "./Summary.tsx";
import { WikiPage } from "./WikiPage.tsx";
@ -12,5 +13,6 @@ export const wikiModule: HubModule = {
nav: [{ to: "/wiki", label: "Вики", icon: <IconBook />, padded: true }],
status: wikiStatus,
Summary,
feed: wikiFeed,
projectFields: [{ name: "wiki_root", label: "Корень вики", hint: "DOC-… или ссылка на страницу Eva", kind: "text" }],
};

View File

@ -0,0 +1,10 @@
// адреса вики: страница с выбранным кодом и окно версии (окно — поверх текущей страницы)
export const VERSION_PATH = "/wiki/pages/:code/versions/:version";
export function wikiPageHref(project: string, code: string): string {
return `/wiki?${new URLSearchParams({ project, page: code })}`;
}
export function versionHref(project: string, code: string, version: string): string {
return `/wiki/pages/${encodeURIComponent(code)}/versions/${encodeURIComponent(version)}?${new URLSearchParams({ project })}`;
}

View File

@ -8,6 +8,7 @@ import { act } from "@testing-library/react";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { App } from "../../App.tsx";
import type { ChangeEvent } from "../../modules/jira/api/types.ts";
import type { WikiChange, WikiChanges } from "../../modules/wiki/api/queries.ts";
import { setToken } from "../../auth/token.ts";
import settings from "../../modules/jira/board/__fixtures__/settings.json";
import { routes } from "../../routes.tsx";
@ -25,7 +26,12 @@ const ev = (over: Partial<ChangeEvent>): ChangeEvent => ({
});
type Changes = { since?: string; until?: string; truncated?: boolean; changes: ChangeEvent[] } | Response;
function hub(changes: () => Changes = () => ({ changes: [] })) {
const wc = (over: Partial<WikiChange> = {}): WikiChange => ({
at: minutesAgo(20), page_code: "DOC-101252", title: "Методика расчёта", path: "АРМ / Методика", version: "DOCH-2",
author: { login: "d.sosnov", name: "Дмитрий Соснов" }, added: 40, removed: 12, ...over,
});
type WikiBody = Partial<WikiChanges> | Response;
function hub(changes: () => Changes = () => ({ changes: [] }), wiki: () => WikiBody = () => ({ changes: [] })) {
const calls: URL[] = [];
const wikiCalls: URL[] = [];
mockFetch((method, url) => {
@ -37,7 +43,8 @@ function hub(changes: () => Changes = () => ({ changes: [] })) {
}
if (k === "GET /wiki/changes") {
wikiCalls.push(url);
return json(200, { since: "", until: "", synced_at: null, history_at: null, truncated: false, changes: [] });
const r = wiki();
return r instanceof Response ? r : json(200, { since: "", until: "", synced_at: null, history_at: "2026-10-28T09:00:00+00:00", truncated: false, changes: [], ...r });
}
if (k === "GET /projects") return json(200, { projects: [A, B, NOWIKI] });
if (k === "GET /settings") return json(200, settings);
@ -69,7 +76,14 @@ describe("лента изменений", () => {
expect(document.querySelector(".ui-btn--primary")).toBeNull();
});
it.todo("шапка у проекта с вики: и «Обновить вики» (П-10) — источник вики появляется в задаче 6");
it("шапка у проекта с вики: и «Обновить из Jira», и «Обновить вики» (П-10), обе вторичные", async () => {
hub();
open("/app/feed?project=AWPCA");
await page();
expect(screen.getByRole("button", { name: "Обновить из Jira" })).toHaveClass("ui-btn--secondary");
expect(screen.getByRole("button", { name: "Обновить вики" })).toHaveClass("ui-btn--secondary");
expect(document.querySelector(".ui-btn--primary")).toBeNull();
});
it("«Проект без вики»: флажков источников нет, GET /wiki/changes нет", async () => {
const calls = hub(() => ({ changes: [ev({})] }));
@ -80,9 +94,101 @@ describe("лента изменений", () => {
expect(calls.wiki).toHaveLength(0);
});
it.todo("сняты все источники — «выберите хотя бы один источник», запросов нет, адрес sources=none — флажки источников появляются в задаче 6");
it.todo("«Что закрылось за два часа» с источником «Вики»: sources=jira в адресе, GET /wiki/changes после снятия не было — задача 6");
it.todo("«Ошибка одного источника»: /wiki/changes 500 — события Jira показаны, на месте вики — ошибка с «Повторить» — задача 6");
it("сняты все источники — «выберите хотя бы один источник», запросов нет, адрес sources=none", async () => {
const calls = hub();
const router = open("/app/feed?project=AWPCA");
await page();
await waitFor(() => { expect(calls.length).toBe(1); expect(calls.wiki.length).toBe(1); });
await userEvent.click(screen.getByRole("checkbox", { name: "Jira" }));
await userEvent.click(screen.getByRole("checkbox", { name: "Вики" }));
expect(await screen.findByText("выберите хотя бы один источник")).toBeInTheDocument();
expect(router.state.location.search).toBe("?project=AWPCA&sources=none");
expect(calls.length).toBe(1);
expect(calls.wiki.length).toBe(1);
});
it("«Что закрылось за два часа»: период 2h, только Статусы и только Jira — sources=jira, к вики запросов после снятия нет", async () => {
const calls = hub();
const router = open("/app/feed?project=AWPCA");
await page();
await waitFor(() => expect(calls.wiki.length).toBe(1));
await userEvent.click(screen.getByRole("radio", { name: "2 часа" }));
await waitFor(() => expect(calls.wiki.length).toBe(2));
await userEvent.click(screen.getByRole("checkbox", { name: "Вики" }));
for (const name of ["Описание", "Сроки", "Комментарии"]) await userEvent.click(screen.getByRole("checkbox", { name }));
await waitFor(() => expect(router.state.location.search).toBe("?project=AWPCA&period=2h&sources=jira&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);
expect(calls.wiki.length).toBe(2); // после снятия «Вики» новых запросов нет
});
it("«Правка из ленты»: группа — название и путь, правка — время, автор, «+40 / −12»; запрос с project, since, limit", async () => {
const calls = hub(undefined, () => ({ changes: [wc({ at: new Date(new Date().setHours(12, 40, 0, 0)).toISOString() })] }));
open("/app/feed?project=AWPCA");
const group = await screen.findByTestId("feed-group");
const head = within(group).getByRole("link", { name: "Методика расчёта" });
expect(head).toHaveAttribute("href", "/app/wiki?project=AWPCA&page=DOC-101252");
expect(within(group).getByText("АРМ / Методика")).toBeInTheDocument();
const edit = within(group).getByRole("link", { name: /Дмитрий Соснов/ });
expect(edit).toHaveAttribute("href", "/app/wiki/pages/DOC-101252/versions/DOCH-2?project=AWPCA");
expect(edit.textContent).toContain("+40 / −12");
const q = calls.wiki[0].searchParams;
expect(q.get("project")).toBe("AWPCA");
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.getByText(/1 событие/)).toBeInTheDocument();
});
it("«Jira и Вики вперемешку»: группы обоих источников по времени последнего события, новые сверху", async () => {
const today = (h: number) => new Date(new Date().setHours(h, 0, 0, 0)).toISOString();
hub(() => ({ changes: [ev({ at: today(12), key: "AURORA-2", summary: "Вторая" }), ev({ at: today(10), key: "AURORA-1", summary: "Первая" })] }),
() => ({ changes: [wc({ at: today(11), page_code: "DOC-3", title: "Третья" })] }));
open("/app/feed?project=AWPCA");
await waitFor(() => expect(screen.getAllByTestId("feed-group")).toHaveLength(3));
const names = screen.getAllByTestId("feed-group").map((g) => within(g).getAllByRole("link")[0].textContent);
expect(names).toEqual(["AURORA-2", "Третья", "AURORA-1"]);
});
it("«История не загружена»: history_at null — подсказка про eva-agent v2, события Jira на месте", async () => {
hub(() => ({ changes: [ev({})] }), () => ({ history_at: null }));
open("/app/feed?project=AWPCA");
expect(await screen.findByText("история вики ещё не загружена — нужен eva-agent v2")).toBeInTheDocument();
expect(await screen.findAllByTestId("feed-group")).toHaveLength(1);
});
it("«Ошибка одного источника»: /wiki/changes 500 — события Jira показаны, «Вики: …» и «Повторить» перезапрашивает только вики", async () => {
let fail = true;
const calls = hub(() => ({ changes: [ev({})] }), () => fail ? json(500, { error: "boom", detail: "сбой" }) : { changes: [wc()] });
open("/app/feed?project=AWPCA");
expect(await screen.findAllByTestId("feed-group")).toHaveLength(1);
expect(await screen.findByText("Вики: хаб ответил 500")).toBeInTheDocument();
const jiraCalls = calls.length;
fail = false;
await userEvent.click(screen.getByRole("button", { name: "Повторить" }));
expect(await screen.findByText("Методика расчёта")).toBeInTheDocument();
expect(calls.length).toBe(jiraCalls);
expect(screen.queryByText("Вики: хаб ответил 500")).toBeNull();
});
it("«Пустой период»: обе ленты пусты — «за период изменений нет»", async () => {
hub();
open("/app/feed?project=AWPCA");
expect(await screen.findByText("за период изменений нет")).toBeInTheDocument();
});
it("смена проекта на sfm_kedr при sources=wiki — источник Jira, флажков нет, к вики запросов нет", async () => {
const calls = hub(() => ({ changes: [ev({})] }));
open("/app/feed?project=AWPCA&sources=wiki");
await waitFor(() => expect(calls.wiki.length).toBe(1));
expect(calls.length).toBe(0);
await userEvent.selectOptions(screen.getByRole("combobox", { name: "Проект" }), "sfm_kedr");
expect(await screen.findAllByTestId("feed-group")).toHaveLength(1);
expect(screen.queryByRole("group", { name: "Источники" })).toBeNull();
expect(calls.wiki.length).toBe(1);
});
it("прежний /app/jira/feed → /app/feed с тем же search, replace (прежние закладки)", async () => {
hub();
@ -130,20 +236,6 @@ describe("лента изменений", () => {
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")); // источник один (вики — в задаче 6): sources в адрес не пишется
expect(router.state.location.pathname).toBe("/app/feed");
expect(calls.wiki).toHaveLength(0);
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");