Веб: окно правки вики с диффом поверх ленты (#140)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
parent
93eff7eac0
commit
2f73f11170
@ -119,6 +119,10 @@
|
||||
(`WikiPage`, `PageView` — `react-markdown` + `remark-gfm`, без `rehype-raw`; ссылки `page:` ведут на страницу вики, `attachment:`
|
||||
скачивают файл через `download.ts`, `urlTransform.ts` пропускает только эти схемы и http(s)), форма корня без привязки (`RootForm`),
|
||||
сводка для «Обзора» (`Summary`) и `status` для ядра; поле «Корень вики» в окне «Новый проект» — через `projectFields`.
|
||||
Источник ленты (`feed/`): правки страниц группами по странице. Окно правки `/app/wiki/pages/{code}/versions/{version}` — поверх
|
||||
страницы под ним (`versions/overlay.tsx`, `useOverlay`; фон — `location.state.background`, прямой адрес — поверх «Вики» с этой
|
||||
страницей), «Предыдущая/Следующая правка» заменяют запись истории, дифф (`versions/`: `VersionModal`, `diffLines` — склейка частей
|
||||
`GET …/diff` и разбор на строки) без горизонтальной прокрутки страницы.
|
||||
|
||||
Модуль `jira` (`web/src/modules/jira/index.tsx`): страницы «Связи» (`/app/jira/links`, заголовок страницы — «Доска связей»), «Задачи» (`/app/jira/issues`) (страницы «Лента» у модуля нет — она в ядре; `/app/jira/feed`
|
||||
перенаправляется на `/app/feed`), карточка `/app/jira/issues/{key}` окном поверх страницы (`issues/overlay.tsx`, `IssueModal.tsx`; оболочка рисует
|
||||
|
||||
@ -33,3 +33,31 @@ test("вики: вложение скачивается через запрос
|
||||
await req; // агента в тестовом хабе нет: скачивание остаётся «Загружается…», достаточно, что запрос ушёл
|
||||
await expect(page.getByRole("article").getByText("Загружается…")).toBeVisible();
|
||||
});
|
||||
|
||||
// две правки DOC-9003 засеяны seed-hub.py (сегодня, автор Дмитрий Соснов): новая «+1 / −1», старая «+5 / −0»
|
||||
test("правка из ленты: окно с диффом, «Предыдущая правка», назад — лента", async ({ page }) => {
|
||||
await login(page, "/app/feed?project=AWPCA&period=7d&sources=wiki");
|
||||
const feed = page.getByTestId("page-feed");
|
||||
await feed.getByRole("link", { name: /\+1 \/ −1/ }).click();
|
||||
const d = page.getByRole("dialog");
|
||||
await expect(d.getByRole("heading", { name: "Регламент согласования" })).toBeVisible();
|
||||
await expect(d.getByText("правка 2 из 2")).toBeVisible();
|
||||
await expect(d.locator(".wiki-version-line--add").first()).toBeVisible();
|
||||
await expect(d.locator(".wiki-version-line--del").first()).toBeVisible();
|
||||
await expect(feed).toBeVisible(); // лента под окном
|
||||
await d.getByRole("button", { name: "Предыдущая правка" }).click();
|
||||
await expect(d.getByText("правка 1 из 2")).toBeVisible();
|
||||
await expect(d.getByRole("button", { name: "Предыдущая правка" })).toBeDisabled();
|
||||
await page.goBack();
|
||||
await expect(page.getByRole("dialog")).toHaveCount(0);
|
||||
await expect(page).toHaveURL(/\/app\/feed\?project=AWPCA&period=7d&sources=wiki/);
|
||||
});
|
||||
|
||||
test("окно правки на 390 px: горизонтальной прокрутки страницы нет", async ({ page }) => {
|
||||
await page.setViewportSize({ width: 390, height: 800 });
|
||||
await login(page, "/app/feed?project=AWPCA&period=7d&sources=wiki");
|
||||
await page.getByTestId("page-feed").getByRole("link", { name: /\+5 \/ −0/ }).click();
|
||||
await expect(page.getByRole("dialog").locator(".wiki-version-line--add").first()).toBeVisible();
|
||||
const fits = await page.evaluate(() => document.documentElement.scrollWidth <= document.documentElement.clientWidth);
|
||||
expect(fits).toBe(true);
|
||||
});
|
||||
|
||||
@ -3,7 +3,7 @@
|
||||
Дополнения только для тестового хаба (описание, комментарии, команды) — из seed-extras.json рядом со скриптом."""
|
||||
import json
|
||||
import sys
|
||||
from datetime import date, timedelta
|
||||
from datetime import date, datetime, timedelta, timezone
|
||||
from pathlib import Path
|
||||
|
||||
from sdlc_hub.core import db, paths, settings
|
||||
@ -109,6 +109,14 @@ for r in wiki_rows:
|
||||
"VALUES ('AWPCA', ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)",
|
||||
(r["code"], r["eva_id"], r["title"], r["parent_code"], r["orderno"], r["depth"], r["path"], r["url"], r["size"], r["synced_at"]))
|
||||
wiki_search.replace(conn, "AWPCA", [(r["code"], r["title"], r["markdown"]) for r in wiki_rows])
|
||||
# история правок DOC-9003 (две версии сегодня) и отметка загрузки истории — для ленты и окна правки
|
||||
_md = WIKI_PAGES[2]["markdown"]
|
||||
_v1 = _md.replace("Порядок согласования описан в файле.", "Черновик порядка согласования.")
|
||||
for _code, _ago, _md_v, _added, _removed in (("DOCH-9001", 50, _v1, 5, 0), ("DOCH-9002", 20, _md, 1, 1)):
|
||||
_at = (datetime.now(timezone.utc) - timedelta(minutes=_ago)).replace(microsecond=0).isoformat()
|
||||
conn.execute("INSERT OR REPLACE INTO wiki_version(label, code, page_code, created_at, author_login, author_name, markdown, added, removed) "
|
||||
"VALUES ('AWPCA', ?, 'DOC-9003', ?, 'd.sosnov', 'Дмитрий Соснов', ?, ?, ?)", (_code, _at, _md_v, _added, _removed))
|
||||
conn.execute("INSERT OR REPLACE INTO wiki_history(label, history_at) VALUES ('AWPCA', ?)", (db.now(),))
|
||||
(paths.home() / "tokens.json").write_text(json.dumps(settings.tokens(conn)), encoding="utf-8")
|
||||
conn.close()
|
||||
print("зеркало:", len(issues), "задач,", len(links), "связей; токены —", paths.home() / "tokens.json")
|
||||
|
||||
@ -1,4 +1,4 @@
|
||||
import { useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { useInfiniteQuery, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { useEffect, useRef } from "react";
|
||||
import { api } from "../../../api/client.ts";
|
||||
|
||||
@ -22,6 +22,13 @@ export interface WikiChanges {
|
||||
since: string; until: string; synced_at: string | null; history_at: string | null; truncated: boolean; changes: WikiChange[];
|
||||
}
|
||||
|
||||
export interface WikiAuthor { login: string | null; name: string | null }
|
||||
export interface WikiVersion { version: string; at: string; author: WikiAuthor; added: number; removed: number }
|
||||
export interface WikiDiff {
|
||||
code: string; title: string | null; path: string | null; version: string; prev_version: string | null; at: string; author: WikiAuthor;
|
||||
added: number; removed: number; index: number; total: number; offset: number; next_offset: number | null; diff: string;
|
||||
}
|
||||
|
||||
export const POLL_MS = 3000; // опрос хода синхронизации, пока пакет не завершён
|
||||
export const SEARCH_LIMIT = 20;
|
||||
|
||||
@ -33,6 +40,8 @@ export const wikiQk = {
|
||||
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,
|
||||
versions: (p: string, code: string) => ["wiki", p, "versions", code] as const,
|
||||
diff: (p: string, code: string, version: string) => ["wiki", p, "diff", code, version] as const,
|
||||
};
|
||||
|
||||
const terminal = (b: WikiBatch | null) => b === null || b.status === "done" || b.status === "cancelled";
|
||||
@ -82,3 +91,18 @@ export const useWikiChanges = (project: string, period: string, bounds: () => {
|
||||
return api.get<WikiChanges>("/wiki/changes", { project, since: since.toISOString(), until: until.toISOString(), limit: WIKI_CHANGES_LIMIT });
|
||||
},
|
||||
});
|
||||
|
||||
// версии страницы, новые первыми — для «Предыдущая/Следующая правка» и «правка N из M»
|
||||
export const useWikiVersions = (project: string, code: string) =>
|
||||
useQuery({
|
||||
queryKey: wikiQk.versions(project, code),
|
||||
queryFn: () => api.get<{ code: string; title: string | null; versions: WikiVersion[] }>(`/wiki/pages/${encodeURIComponent(code)}/versions`, { project }).then((r) => r.versions),
|
||||
});
|
||||
|
||||
// дифф правки частями: pageParam — offset (0, затем next_offset прежней части)
|
||||
export const useWikiDiff = (project: string, code: string, version: string) =>
|
||||
useInfiniteQuery({
|
||||
queryKey: wikiQk.diff(project, code, version), initialPageParam: 0,
|
||||
queryFn: ({ pageParam }) => api.get<WikiDiff>(`/wiki/pages/${encodeURIComponent(code)}/diff`, { project, version, offset: pageParam }),
|
||||
getNextPageParam: (last) => last.next_offset,
|
||||
});
|
||||
|
||||
@ -4,6 +4,7 @@ import type { HubModule } from "../types.ts";
|
||||
import { wikiFeed } from "./feed/source.tsx";
|
||||
import { wikiStatus } from "./status.ts";
|
||||
import { Summary } from "./Summary.tsx";
|
||||
import { useVersionOverlay } from "./versions/overlay.tsx";
|
||||
import { WikiPage } from "./WikiPage.tsx";
|
||||
|
||||
export const wikiModule: HubModule = {
|
||||
@ -14,5 +15,6 @@ export const wikiModule: HubModule = {
|
||||
status: wikiStatus,
|
||||
Summary,
|
||||
feed: wikiFeed,
|
||||
useOverlay: useVersionOverlay,
|
||||
projectFields: [{ name: "wiki_root", label: "Корень вики", hint: "DOC-… или ссылка на страницу Eva", kind: "text" }],
|
||||
};
|
||||
|
||||
184
web/src/modules/wiki/versions/VersionModal.test.tsx
Normal file
184
web/src/modules/wiki/versions/VersionModal.test.tsx
Normal file
@ -0,0 +1,184 @@
|
||||
import { act, fireEvent, render, screen, waitFor, within } 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 { setToken } from "../../../auth/token.ts";
|
||||
import settings from "../../jira/board/__fixtures__/settings.json";
|
||||
import { routes } from "../../../routes.tsx";
|
||||
import { json, mockFetch } from "../../../test/fetchMock.ts";
|
||||
import type { WikiChange } from "../api/queries.ts";
|
||||
|
||||
const A = { label: "AWPCA", name: "АРМ КА", board_id: null, board_type: null, created_at: "", issues_count: 9, wiki_root: "DOC-1" };
|
||||
const SOSNOV = { login: "d.sosnov", name: "Дмитрий Соснов" };
|
||||
const at = (hhmm: string) => { const d = new Date(); d.setHours(+hhmm.slice(0, 2), +hhmm.slice(3), 0, 0); return d.toISOString(); };
|
||||
const TIMES = ["10:00", "11:00", "12:00", "12:40"];
|
||||
const VERSIONS = [4, 3, 2, 1].map((n) => ({ version: `DOCH-${n}`, at: at(TIMES[n - 1]), author: SOSNOV, added: n === 4 ? 40 : n, removed: n === 4 ? 12 : 0 }));
|
||||
const change = (n: number): WikiChange => ({
|
||||
at: at(TIMES[n - 1]), page_code: "DOC-101252", title: "Методика расчёта", path: "АРМ / Методика", version: `DOCH-${n}`,
|
||||
author: SOSNOV, added: n === 4 ? 40 : n, removed: n === 4 ? 12 : 0,
|
||||
});
|
||||
type Part = { diff: string; next_offset: number | null };
|
||||
const diffBody = (version: string, offset: number, part: Part) => {
|
||||
const n = Number(version.slice(5));
|
||||
return {
|
||||
code: "DOC-101252", title: "Методика расчёта", path: "АРМ / Методика", version, prev_version: n > 1 ? `DOCH-${n - 1}` : null,
|
||||
at: at(TIMES[n - 1]), author: SOSNOV, added: n === 4 ? 40 : n, removed: n === 4 ? 12 : 0, index: n, total: 4, offset, ...part,
|
||||
};
|
||||
};
|
||||
const DEFAULT_DIFF = (v: string): Part => ({ diff: `--- DOCH-prev\n+++ ${v}\n@@ -1 +1 @@\n-старая строка\n+новая строка\n общая строка\n`, next_offset: null });
|
||||
|
||||
function hub(parts: (version: string, offset: number) => Part = (v) => DEFAULT_DIFF(v)) {
|
||||
const calls: { method: string; url: URL }[] = [];
|
||||
mockFetch((method, url) => {
|
||||
calls.push({ method, url });
|
||||
const k = `${method} ${url.pathname}`;
|
||||
if (k === "GET /projects") return json(200, { projects: [A] });
|
||||
if (k === "GET /settings") return json(200, settings);
|
||||
if (k === "GET /batches") return json(200, { batches: [] });
|
||||
if (k === "GET /agents") return json(200, { agents: [] });
|
||||
if (k === "GET /changes") return json(200, { since: "", until: "", truncated: false, changes: [] });
|
||||
if (k === "GET /wiki/changes") {
|
||||
return json(200, { since: "", until: "", synced_at: null, history_at: "2026-10-28T09:00:00+00:00", truncated: false, changes: [change(4), change(3)] });
|
||||
}
|
||||
if (k === "GET /wiki/status") return json(200, { root: "DOC-1", root_title: "Корень", synced_at: null, pages: 1, error: null, batch: null });
|
||||
if (k === "GET /wiki/tree") return json(200, { pages: [{ code: "DOC-101252", title: "Методика расчёта", depth: 0, parent_code: null, size: 1 }] });
|
||||
if (k === "GET /wiki/pages/DOC-101252") {
|
||||
return json(200, { code: "DOC-101252", title: "Методика расчёта", path: "АРМ / Методика", url: null, synced_at: null, markdown: "Текст страницы", attachments: [] });
|
||||
}
|
||||
if (k === "GET /wiki/pages/DOC-101252/versions") return json(200, { code: "DOC-101252", title: "Методика расчёта", versions: VERSIONS });
|
||||
if (k === "GET /wiki/pages/DOC-101252/diff") {
|
||||
const version = url.searchParams.get("version") ?? "DOCH-4";
|
||||
const offset = Number(url.searchParams.get("offset") ?? 0);
|
||||
return json(200, diffBody(version, offset, parts(version, offset)));
|
||||
}
|
||||
return json(404, { error: "not_found" });
|
||||
});
|
||||
return calls;
|
||||
}
|
||||
const open = (entries: string[], index = entries.length - 1) => {
|
||||
const router = createMemoryRouter(routes, { initialEntries: entries, initialIndex: index, basename: "/app" });
|
||||
render(<App router={router} />);
|
||||
return router;
|
||||
};
|
||||
const FEED = "/app/feed?project=AWPCA&period=7d&sources=wiki";
|
||||
const dialog = () => screen.getByRole("dialog");
|
||||
async function openFromFeed(entries = [FEED]) {
|
||||
const router = open(entries);
|
||||
await userEvent.click((await screen.findAllByRole("link", { name: /\+40 \/ −12/ }))[0]);
|
||||
await screen.findByRole("dialog");
|
||||
return router;
|
||||
}
|
||||
|
||||
describe("окно правки вики", () => {
|
||||
beforeEach(() => setToken("tok"));
|
||||
|
||||
it("«Правка из ленты»: шапка, строки диффа и лента под окном", async () => {
|
||||
const calls = hub();
|
||||
await openFromFeed();
|
||||
const d = dialog();
|
||||
expect(await within(d).findByRole("heading", { name: "Методика расчёта" })).toBeInTheDocument();
|
||||
expect(within(d).getByText("правка 4 из 4")).toBeInTheDocument();
|
||||
expect(within(d).getByText("Дмитрий Соснов")).toBeInTheDocument();
|
||||
expect(within(d).getByText("12:40")).toBeInTheDocument();
|
||||
expect(within(d).getByText("+40 / −12")).toBeInTheDocument();
|
||||
const add = await within(d).findByText("+новая строка");
|
||||
expect(add).toHaveClass("wiki-version-line--add");
|
||||
expect(within(add).getByText("добавлено:")).toHaveClass("visually-hidden");
|
||||
const del = within(d).getByText("-старая строка");
|
||||
expect(del).toHaveClass("wiki-version-line--del");
|
||||
expect(within(del).getByText("удалено:")).toHaveClass("visually-hidden");
|
||||
expect(screen.getByTestId("page-feed")).toBeInTheDocument();
|
||||
const dc = calls.find((c) => c.url.pathname === "/wiki/pages/DOC-101252/diff")!;
|
||||
expect(dc.url.searchParams.get("version")).toBe("DOCH-4");
|
||||
});
|
||||
|
||||
it("«Предыдущая правка»: 3 из 4, у первой недоступна; назад закрывает окно, лента прежняя", async () => {
|
||||
hub();
|
||||
const router = await openFromFeed();
|
||||
const d = dialog();
|
||||
await within(d).findByText("правка 4 из 4");
|
||||
expect(within(d).getByRole("button", { name: "Следующая правка" })).toBeDisabled();
|
||||
await userEvent.click(within(d).getByRole("button", { name: "Предыдущая правка" }));
|
||||
expect(await within(d).findByText("правка 3 из 4")).toBeInTheDocument();
|
||||
expect(router.state.location.pathname).toBe("/app/wiki/pages/DOC-101252/versions/DOCH-3");
|
||||
expect(within(d).getByRole("button", { name: "Следующая правка" })).toBeEnabled();
|
||||
await userEvent.click(within(d).getByRole("button", { name: "Предыдущая правка" }));
|
||||
await userEvent.click(within(d).getByRole("button", { name: "Предыдущая правка" }));
|
||||
await within(d).findByText("правка 1 из 4");
|
||||
expect(within(d).getByRole("button", { name: "Предыдущая правка" })).toBeDisabled();
|
||||
act(() => { void router.navigate(-1); });
|
||||
await waitFor(() => expect(screen.queryByRole("dialog")).toBeNull());
|
||||
expect(router.state.location.pathname + router.state.location.search).toBe(FEED);
|
||||
});
|
||||
|
||||
it("Escape и «Закрыть» закрывают окно — адрес ленты прежний", async () => {
|
||||
hub();
|
||||
const router = await openFromFeed();
|
||||
await within(dialog()).findByText("правка 4 из 4");
|
||||
await userEvent.click(within(dialog()).getByRole("button", { name: "Закрыть" }));
|
||||
await waitFor(() => expect(screen.queryByRole("dialog")).toBeNull());
|
||||
expect(router.state.location.pathname + router.state.location.search).toBe(FEED);
|
||||
await userEvent.click((await screen.findAllByRole("link", { name: /\+40 \/ −12/ }))[0]);
|
||||
await screen.findByRole("dialog");
|
||||
fireEvent(dialog(), new Event("cancel", { cancelable: true })); // Esc в jsdom: событие cancel окна
|
||||
await waitFor(() => expect(screen.queryByRole("dialog")).toBeNull());
|
||||
expect(router.state.location.pathname + router.state.location.search).toBe(FEED);
|
||||
});
|
||||
|
||||
it("«Окно по ссылке»: поверх «Вики» с этой страницей, закрытие — на страницу", async () => {
|
||||
hub();
|
||||
const router = open(["/app/wiki/pages/DOC-101252/versions/DOCH-3?project=AWPCA"]);
|
||||
const d = await screen.findByRole("dialog");
|
||||
expect(await within(d).findByText("правка 3 из 4")).toBeInTheDocument();
|
||||
expect(await screen.findByRole("heading", { level: 1, name: "Вики" })).toBeInTheDocument();
|
||||
await userEvent.click(within(d).getByRole("button", { name: "Закрыть" }));
|
||||
await waitFor(() => expect(screen.queryByRole("dialog")).toBeNull());
|
||||
expect(router.state.location.pathname + router.state.location.search).toBe("/app/wiki?project=AWPCA&page=DOC-101252");
|
||||
});
|
||||
|
||||
it("«Длинный дифф»: «Показать дальше» грузит со следующего offset; без next_offset кнопки нет", async () => {
|
||||
const calls = hub((_v, offset) => offset === 0
|
||||
? { diff: "--- a\n+++ b\n+первая часть\n", next_offset: 20000 }
|
||||
: { diff: "+вторая часть\n", next_offset: null });
|
||||
await openFromFeed();
|
||||
const d = dialog();
|
||||
await within(d).findByText("+первая часть");
|
||||
expect(within(d).queryByText("+вторая часть")).toBeNull();
|
||||
await userEvent.click(within(d).getByRole("button", { name: "Показать дальше" }));
|
||||
const second = await within(d).findByText("+вторая часть");
|
||||
expect(calls.some((c) => c.url.searchParams.get("offset") === "20000")).toBe(true);
|
||||
expect(within(d).getByText("+первая часть").compareDocumentPosition(second) & Node.DOCUMENT_POSITION_FOLLOWING).toBeTruthy();
|
||||
expect(within(d).queryByRole("button", { name: "Показать дальше" })).toBeNull();
|
||||
});
|
||||
|
||||
it("«Показать дальше»: часть рвёт строку — строка целая и одна", async () => {
|
||||
hub((_v, offset) => offset === 0
|
||||
? { diff: "--- a\n+++ b\n+строка дл", next_offset: 20000 }
|
||||
: { diff: "инная\n+другая\n", next_offset: null });
|
||||
await openFromFeed();
|
||||
const d = dialog();
|
||||
await within(d).findByText("+строка дл");
|
||||
await userEvent.click(within(d).getByRole("button", { name: "Показать дальше" }));
|
||||
expect(await within(d).findByText("+строка длинная")).toHaveClass("wiki-version-line--add");
|
||||
expect(d.querySelectorAll(".wiki-version-line--add")).toHaveLength(2);
|
||||
expect(within(d).queryByText("+строка дл")).toBeNull();
|
||||
});
|
||||
|
||||
it("«Открыть страницу» ведёт на страницу вики", async () => {
|
||||
hub();
|
||||
await openFromFeed();
|
||||
const link = await within(dialog()).findByRole("link", { name: "Открыть страницу" });
|
||||
expect(link).toHaveAttribute("href", "/app/wiki?project=AWPCA&page=DOC-101252");
|
||||
});
|
||||
|
||||
it("окно ничего не пишет: только GET", async () => {
|
||||
const calls = hub();
|
||||
await openFromFeed();
|
||||
await within(dialog()).findByText("+новая строка");
|
||||
await userEvent.click(within(dialog()).getByRole("button", { name: "Предыдущая правка" }));
|
||||
await within(dialog()).findByText("правка 3 из 4");
|
||||
await userEvent.click(within(dialog()).getByRole("button", { name: "Закрыть" }));
|
||||
expect(calls.filter((c) => c.method !== "GET")).toEqual([]);
|
||||
});
|
||||
});
|
||||
98
web/src/modules/wiki/versions/VersionModal.tsx
Normal file
98
web/src/modules/wiki/versions/VersionModal.tsx
Normal file
@ -0,0 +1,98 @@
|
||||
import { useEffect, useMemo, useRef } from "react";
|
||||
import { Link } from "react-router";
|
||||
import { ErrorBoundary } from "../../../shell/ErrorBoundary.tsx";
|
||||
import { Button, Dialog, IconButton, IconClose, Notice } from "../../../ui/index.ts";
|
||||
import { eventTime } from "../../../ui/format.ts";
|
||||
import { useWikiDiff, useWikiVersions } from "../api/queries.ts";
|
||||
import { authorName, deltaText } from "../feed/group.ts";
|
||||
import { wikiPageHref } from "../paths.ts";
|
||||
import { diffLines } from "./diffLines.ts";
|
||||
import "./versions.css";
|
||||
|
||||
type Props = { project: string; code: string; version: string; onClose: () => void; onNavigate: (version: string) => void };
|
||||
|
||||
// окно правки (spec web-app «Правки вики в ленте»): Esc, «Закрыть» и щелчок по фону — onClose; окно исчезает вместе с адресом
|
||||
export function VersionModal(props: Props) {
|
||||
const downOnBackdrop = useRef(false);
|
||||
const { onClose } = props;
|
||||
useEffect(() => {
|
||||
const prev = document.body.style.overflow;
|
||||
document.body.style.overflow = "hidden"; // страница под окном не прокручивается
|
||||
return () => { document.body.style.overflow = prev; };
|
||||
}, []);
|
||||
return (
|
||||
<Dialog size="lg" className="wiki-version-modal" label={`Правка ${props.version}`} onClose={onClose} aria-labelledby="wiki-version-title"
|
||||
onCancel={(e) => { if (e.cancelable) { e.preventDefault(); onClose(); } }}
|
||||
onNativeClose={(e) => { const d = e.currentTarget; if (d.isConnected) { d.showModal(); onClose(); } }}
|
||||
onPointerDown={(e) => { downOnBackdrop.current = e.target === e.currentTarget; }}
|
||||
onClick={(e) => {
|
||||
const fromBackdrop = downOnBackdrop.current;
|
||||
downOnBackdrop.current = false;
|
||||
if (e.target === e.currentTarget && fromBackdrop) onClose();
|
||||
}}>
|
||||
<ErrorBoundary key={props.version}><VersionBody {...props} /></ErrorBoundary>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
|
||||
function VersionBody({ project, code, version, onClose, onNavigate }: Props) {
|
||||
const diff = useWikiDiff(project, code, version);
|
||||
const versions = useWikiVersions(project, code);
|
||||
const first = diff.data?.pages[0];
|
||||
// части склеиваются до разбора на строки: часть может оборваться посреди строки
|
||||
const text = diff.data?.pages.map((p) => p.diff).join("") ?? "";
|
||||
const lines = useMemo(() => diffLines(text), [text]);
|
||||
const list = versions.data;
|
||||
const pos = list ? list.findIndex((v) => v.version === version) : -1;
|
||||
const older = list && pos >= 0 ? list[pos + 1] : undefined;
|
||||
const newer = list && pos > 0 ? list[pos - 1] : undefined;
|
||||
const index = first?.index ?? (list && pos >= 0 ? list.length - pos : null);
|
||||
const total = first?.total ?? list?.length ?? null;
|
||||
const title = first?.title || code;
|
||||
const path = first?.path;
|
||||
const now = new Date();
|
||||
|
||||
return (
|
||||
<div className="wiki-version" data-testid="wiki-version">
|
||||
<header className="wiki-version__head">
|
||||
<div className="wiki-version__row">
|
||||
<h2 id="wiki-version-title" className="wiki-version__title">{title}</h2>
|
||||
<IconButton label="Закрыть" icon={<IconClose />} onClick={onClose} />
|
||||
</div>
|
||||
{path && <p className="wiki-version__path">{path}</p>}
|
||||
<p className="wiki-version__meta">
|
||||
{index !== null && total !== null && <span>{`правка ${index} из ${total}`}</span>}
|
||||
{first && <>
|
||||
<span className="wiki-version__author">{authorName(first.author)}</span>
|
||||
<span className="wiki-version__time">{eventTime(first.at, now)}</span>
|
||||
<span className="wiki-version__delta">{deltaText(first)}</span>
|
||||
</>}
|
||||
</p>
|
||||
<div className="wiki-version__nav">
|
||||
<Button disabled={!older} onClick={() => older && onNavigate(older.version)}>Предыдущая правка</Button>
|
||||
<Button disabled={!newer} onClick={() => newer && onNavigate(newer.version)}>Следующая правка</Button>
|
||||
<Link className="wiki-version__page" to={wikiPageHref(project, code)}>Открыть страницу</Link>
|
||||
</div>
|
||||
</header>
|
||||
{diff.isPending && <p className="wiki-version__hint">загрузка правки…</p>}
|
||||
{diff.isError && !diff.data && (
|
||||
<Notice tone="error" action={<Button onClick={() => void diff.refetch()}>Повторить</Button>}>{(diff.error as Error).message}</Notice>
|
||||
)}
|
||||
{first && (
|
||||
<div className="wiki-version__diff" data-testid="wiki-version-diff">
|
||||
{lines.length === 0 && <p className="wiki-version__hint">В правке нет изменений текста.</p>}
|
||||
{lines.map((l, i) => (
|
||||
<div key={i} className={`wiki-version-line wiki-version-line--${l.kind}`}>
|
||||
{l.kind === "add" && <span className="visually-hidden">добавлено: </span>}
|
||||
{l.kind === "del" && <span className="visually-hidden">удалено: </span>}
|
||||
{l.text}
|
||||
</div>
|
||||
))}
|
||||
{diff.hasNextPage && (
|
||||
<Button loading={diff.isFetchingNextPage} onClick={() => void diff.fetchNextPage()}>Показать дальше</Button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
19
web/src/modules/wiki/versions/diffLines.test.ts
Normal file
19
web/src/modules/wiki/versions/diffLines.test.ts
Normal file
@ -0,0 +1,19 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { diffLines } from "./diffLines.ts";
|
||||
|
||||
describe("diffLines", () => {
|
||||
it("разбирает unified diff по видам строк", () => {
|
||||
const lines = diffLines("--- A\n+++ B\n@@ -1 +1 @@\n-x\n+y\n z\n");
|
||||
expect(lines.map((l) => l.kind)).toEqual(["head", "head", "hunk", "del", "add", "ctx"]);
|
||||
expect(lines[3]).toEqual({ kind: "del", text: "-x" });
|
||||
});
|
||||
|
||||
it("после первого @@ строка «--- …» — удаление, а не заголовок", () => {
|
||||
expect(diffLines("--- A\n+++ B\n@@ -1 +1 @@\n--- x\n").map((l) => l.kind)).toEqual(["head", "head", "hunk", "del"]);
|
||||
});
|
||||
|
||||
it("хвостовой пустой строки нет, пустой текст — пустой список", () => {
|
||||
expect(diffLines("+a\n")).toHaveLength(1);
|
||||
expect(diffLines("")).toEqual([]);
|
||||
});
|
||||
});
|
||||
16
web/src/modules/wiki/versions/diffLines.ts
Normal file
16
web/src/modules/wiki/versions/diffLines.ts
Normal file
@ -0,0 +1,16 @@
|
||||
export type DiffLine = { kind: "head" | "hunk" | "add" | "del" | "ctx"; text: string };
|
||||
|
||||
// unified diff — в строки с видом; хвостовой пустой строки нет. `---`/`+++` — заголовок только до первого `@@` (дальше это удалённая/добавленная строка)
|
||||
export function diffLines(text: string): DiffLine[] {
|
||||
if (text === "") return [];
|
||||
const rows = text.split("\n");
|
||||
if (rows[rows.length - 1] === "") rows.pop();
|
||||
let inHunk = false;
|
||||
return rows.map((t): DiffLine => {
|
||||
if (!inHunk && (t.startsWith("---") || t.startsWith("+++"))) return { kind: "head", text: t };
|
||||
if (t.startsWith("@@")) { inHunk = true; return { kind: "hunk", text: t }; }
|
||||
if (t.startsWith("+")) return { kind: "add", text: t };
|
||||
if (t.startsWith("-")) return { kind: "del", text: t };
|
||||
return { kind: "ctx", text: t };
|
||||
});
|
||||
}
|
||||
50
web/src/modules/wiki/versions/overlay.tsx
Normal file
50
web/src/modules/wiki/versions/overlay.tsx
Normal file
@ -0,0 +1,50 @@
|
||||
import { useCallback, useEffect, useMemo, useRef } from "react";
|
||||
import { matchPath, useLocation, useNavigate } from "react-router";
|
||||
import type { Overlay } from "../../types.ts";
|
||||
import { VERSION_PATH, versionHref } from "../paths.ts";
|
||||
import { pageBackground, versionState } from "./versionState.ts";
|
||||
import { VersionModal } from "./VersionModal.tsx";
|
||||
|
||||
// окно правки /wiki/pages/{code}/versions/{version} поверх страницы под ним (spec web-app «Правки вики в ленте»)
|
||||
export function useVersionOverlay(): Overlay {
|
||||
const location = useLocation();
|
||||
const navigate = useNavigate();
|
||||
const params = matchPath(VERSION_PATH, location.pathname)?.params;
|
||||
const code = params?.code ?? null;
|
||||
const version = params?.version ?? null;
|
||||
const open = code !== null && version !== null;
|
||||
const st = open ? versionState(location.state) : null;
|
||||
const direct = code ? pageBackground(code, location.search) : null;
|
||||
const bg = open ? (st?.background ?? direct) : null;
|
||||
const bgPath = bg?.pathname ?? null;
|
||||
const bgSearch = bg?.search ?? "";
|
||||
const pageLocation = useMemo(() => bgPath === null ? null : { pathname: bgPath, search: bgSearch, hash: "", state: null, key: "version-background" },
|
||||
[bgPath, bgSearch]);
|
||||
|
||||
// прямой адрес: запись истории получает state окна поверх «Вики» с этой страницей
|
||||
useEffect(() => {
|
||||
if (open && !st && direct) {
|
||||
void navigate(location.pathname + location.search, { replace: true, state: { background: direct, depth: 0, direct: true } });
|
||||
}
|
||||
}, [open, st, direct, location.pathname, location.search, navigate]);
|
||||
|
||||
// повторное закрытие на той же записи истории игнорируется
|
||||
const closingKey = useRef<string | null>(null);
|
||||
useEffect(() => { closingKey.current = null; }, [location.key]);
|
||||
const close = useCallback(() => {
|
||||
if (closingKey.current === location.key || !direct) return;
|
||||
closingKey.current = location.key;
|
||||
if (!st || st.depth === 0) { void navigate(st?.background ?? direct, { replace: true }); return; }
|
||||
void navigate(-st.depth);
|
||||
}, [st, direct, navigate, location.key]);
|
||||
|
||||
const project = new URLSearchParams(location.search).get("project") ?? "";
|
||||
// соседняя правка заменяет запись: то же state, «назад» закрывает окно, а не листает правки
|
||||
const go = useCallback((v: string) => {
|
||||
if (!code) return;
|
||||
void navigate(versionHref(project, code, v), { replace: true, state: st ?? undefined });
|
||||
}, [navigate, project, code, st]);
|
||||
|
||||
const element = open ? <VersionModal project={project} code={code} version={version} onClose={close} onNavigate={go} /> : null;
|
||||
return { pageLocation, element };
|
||||
}
|
||||
22
web/src/modules/wiki/versions/versionState.test.ts
Normal file
22
web/src/modules/wiki/versions/versionState.test.ts
Normal file
@ -0,0 +1,22 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { pageBackground, versionState } from "./versionState.ts";
|
||||
|
||||
describe("versionState", () => {
|
||||
it("мусорный state — null", () => {
|
||||
expect(versionState(null)).toBeNull();
|
||||
expect(versionState("x")).toBeNull();
|
||||
expect(versionState({ background: 1, depth: 1 })).toBeNull();
|
||||
expect(versionState({ background: { pathname: "/feed", search: "" }, depth: "1" })).toBeNull();
|
||||
});
|
||||
|
||||
it("разбирает фон, глубину и direct; глубина нормализуется", () => {
|
||||
expect(versionState({ background: { pathname: "/feed", search: "?a=1" }, depth: 1 }))
|
||||
.toEqual({ background: { pathname: "/feed", search: "?a=1" }, depth: 1, direct: false });
|
||||
expect(versionState({ background: { pathname: "/wiki", search: "" }, depth: -3, direct: true })?.depth).toBe(0);
|
||||
});
|
||||
|
||||
it("фон по прямому адресу — «Вики» с этой страницей", () => {
|
||||
expect(pageBackground("DOC-1", "?project=AWPCA")).toEqual({ pathname: "/wiki", search: "?project=AWPCA&page=DOC-1" });
|
||||
expect(pageBackground("DOC-1", "")).toEqual({ pathname: "/wiki", search: "?page=DOC-1" });
|
||||
});
|
||||
});
|
||||
22
web/src/modules/wiki/versions/versionState.ts
Normal file
22
web/src/modules/wiki/versions/versionState.ts
Normal file
@ -0,0 +1,22 @@
|
||||
// состояние окна правки в записи истории: страница под окном, сколько записей заняли окна поверх неё, прямой ли адрес
|
||||
// (своя копия логики окна карточки: модуль не импортирует другой модуль)
|
||||
export interface Background { pathname: string; search: string }
|
||||
export interface VersionState { background: Background; depth: number; direct: boolean }
|
||||
|
||||
export function versionState(s: unknown): VersionState | null {
|
||||
const c = s as { background?: { pathname?: unknown; search?: unknown }; depth?: unknown; direct?: unknown } | null;
|
||||
if (!c || typeof c !== "object" || !c.background || typeof c.background !== "object") return null;
|
||||
const { pathname, search } = c.background;
|
||||
if (typeof pathname !== "string" || typeof search !== "string" || typeof c.depth !== "number") return null;
|
||||
const depth = Number.isInteger(c.depth) && c.depth >= 0 ? c.depth : 0;
|
||||
return { background: { pathname, search }, depth, direct: c.direct === true };
|
||||
}
|
||||
|
||||
// фон по прямому адресу: «Вики» с этой страницей, проект — из адреса окна
|
||||
export function pageBackground(code: string, search: string): Background {
|
||||
const params = new URLSearchParams();
|
||||
const project = new URLSearchParams(search).get("project");
|
||||
if (project) params.set("project", project);
|
||||
params.set("page", code);
|
||||
return { pathname: "/wiki", search: "?" + params.toString() };
|
||||
}
|
||||
20
web/src/modules/wiki/versions/versions.css
Normal file
20
web/src/modules/wiki/versions/versions.css
Normal file
@ -0,0 +1,20 @@
|
||||
/* окно правки вики (spec web-app «Правки вики в ленте»): только токены дизайн-системы */
|
||||
.ui-dialog.wiki-version-modal{padding:0}
|
||||
.wiki-version{display:flex;flex-direction:column;gap:var(--space-4);padding:0 var(--space-6) var(--space-6);min-width:0}
|
||||
.wiki-version__head{display:flex;flex-direction:column;gap:var(--space-2);position:sticky;top:0;z-index:1;background:var(--color-bg-surface);padding:var(--space-4) 0 var(--space-3);border-bottom:1px solid var(--color-border-default)}
|
||||
.wiki-version__row{display:flex;align-items:flex-start;gap:var(--space-3);justify-content:space-between}
|
||||
.wiki-version__title{margin:0;min-width:0;overflow-wrap:anywhere;font-size:var(--fs-section)}
|
||||
.wiki-version__path{margin:0;font-size:13px;color:var(--color-text-secondary);overflow-wrap:anywhere}
|
||||
.wiki-version__meta{margin:0;display:flex;flex-wrap:wrap;gap:var(--space-1) var(--space-3);color:var(--color-text-secondary)}
|
||||
.wiki-version__author{font-weight:600;color:var(--color-text-primary);overflow-wrap:anywhere}
|
||||
.wiki-version__time,.wiki-version__delta{font-family:var(--font-mono);font-size:13px;white-space:nowrap}
|
||||
.wiki-version__nav{display:flex;flex-wrap:wrap;align-items:center;gap:var(--space-2) var(--space-3)}
|
||||
.wiki-version__page{color:var(--color-action-primary)}
|
||||
.wiki-version__hint{margin:0;color:var(--color-text-secondary)}
|
||||
.wiki-version__diff{display:flex;flex-direction:column;gap:var(--space-3);align-items:flex-start;min-width:0}
|
||||
.wiki-version-line{font-family:var(--font-mono);font-size:13px;line-height:1.5;white-space:pre-wrap;overflow-wrap:anywhere;min-width:0;max-width:100%;align-self:stretch}
|
||||
.wiki-version-line--add{color:var(--color-status-success);background:var(--color-status-success-bg)}
|
||||
.wiki-version-line--del{color:var(--color-status-error);background:var(--color-status-error-bg)}
|
||||
.wiki-version-line--head,.wiki-version-line--hunk{color:var(--color-text-secondary)}
|
||||
.wiki-version-line--ctx{color:var(--color-text-primary)}
|
||||
@media (max-width:640px){.wiki-version{padding:0 var(--space-4) var(--space-4)}}
|
||||
Loading…
x
Reference in New Issue
Block a user