diff --git a/web/src/modules/wiki/Summary.test.tsx b/web/src/modules/wiki/Summary.test.tsx index cb7e620..5936642 100644 --- a/web/src/modules/wiki/Summary.test.tsx +++ b/web/src/modules/wiki/Summary.test.tsx @@ -17,4 +17,9 @@ describe("сводка вики", () => { expect(screen.getByText("ещё не синхронизирована")).toBeInTheDocument(); expect(screen.queryByText(/0 страниц/)).toBeNull(); }); + + it("у проекта с корнем — ссылка «Правки» на ленту с источником вики", () => { + renderWithProviders(); + expect(screen.getByRole("link", { name: "Правки" })).toHaveAttribute("href", "/feed?project=AWPCA&sources=wiki"); + }); }); diff --git a/web/src/modules/wiki/Summary.tsx b/web/src/modules/wiki/Summary.tsx index 70ed80e..43e205c 100644 --- a/web/src/modules/wiki/Summary.tsx +++ b/web/src/modules/wiki/Summary.tsx @@ -26,6 +26,7 @@ export function Summary({ project }: { project: Project }) {

{pages === null ? "ещё не синхронизирована" : `${pages} страниц`}

Вики + Правки ); } diff --git a/web/src/modules/wiki/api/queries.ts b/web/src/modules/wiki/api/queries.ts index fd6cf2c..771db73 100644 --- a/web/src/modules/wiki/api/queries.ts +++ b/web/src/modules/wiki/api/queries.ts @@ -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("/wiki/changes", { project, since: since.toISOString(), until: until.toISOString(), limit: WIKI_CHANGES_LIMIT }); + }, + }); diff --git a/web/src/modules/wiki/feed/WikiFeedGroup.tsx b/web/src/modules/wiki/feed/WikiFeedGroup.tsx new file mode 100644 index 0000000..2948f74 --- /dev/null +++ b/web/src/modules/wiki/feed/WikiFeedGroup.tsx @@ -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 ( + +
+ {group.title || group.code} + {group.path && {group.path}} +
+
    + {group.changes.map((c) => ( +
  • + + {eventTime(c.at, now)} + {authorName(c.author)} + {deltaText(c)} + +
  • + ))} +
+
+ ); +} diff --git a/web/src/modules/wiki/feed/feed.css b/web/src/modules/wiki/feed/feed.css new file mode 100644 index 0000000..dc4ec53 --- /dev/null +++ b/web/src/modules/wiki/feed/feed.css @@ -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} diff --git a/web/src/modules/wiki/feed/group.test.ts b/web/src/modules/wiki/feed/group.test.ts new file mode 100644 index 0000000..e79b8d4 --- /dev/null +++ b/web/src/modules/wiki/feed/group.test.ts @@ -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("—"); + }); +}); diff --git a/web/src/modules/wiki/feed/group.ts b/web/src/modules/wiki/feed/group.ts new file mode 100644 index 0000000..1675935 --- /dev/null +++ b/web/src/modules/wiki/feed/group.ts @@ -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(); + 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 || "—"; diff --git a/web/src/modules/wiki/feed/source.tsx b/web/src/modules/wiki/feed/source.tsx new file mode 100644 index 0000000..6f0299b --- /dev/null +++ b/web/src/modules/wiki/feed/source.tsx @@ -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: , + })); + }, [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 }; diff --git a/web/src/modules/wiki/index.tsx b/web/src/modules/wiki/index.tsx index 36d9a82..086d358 100644 --- a/web/src/modules/wiki/index.tsx +++ b/web/src/modules/wiki/index.tsx @@ -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: , padded: true }], status: wikiStatus, Summary, + feed: wikiFeed, projectFields: [{ name: "wiki_root", label: "Корень вики", hint: "DOC-… или ссылка на страницу Eva", kind: "text" }], }; diff --git a/web/src/modules/wiki/paths.ts b/web/src/modules/wiki/paths.ts new file mode 100644 index 0000000..5c73956 --- /dev/null +++ b/web/src/modules/wiki/paths.ts @@ -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 })}`; +} diff --git a/web/src/shell/feed/FeedPage.test.tsx b/web/src/shell/feed/FeedPage.test.tsx index f3342e8..26b9bbe 100644 --- a/web/src/shell/feed/FeedPage.test.tsx +++ b/web/src/shell/feed/FeedPage.test.tsx @@ -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 => ({ }); type Changes = { since?: string; until?: string; truncated?: boolean; changes: ChangeEvent[] } | Response; -function hub(changes: () => Changes = () => ({ changes: [] })) { +const wc = (over: Partial = {}): 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 | 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");