fix(page-history): устранить замечания ревью #578 (empty/error, i18n ru, a11y, тесты)

F1 (регрессия) — вместо пустой панели показываем явные состояния: ошибка загрузки
истории и «нет истории». Список и правая панель несут свои error/empty-состояния;
heatmap по-прежнему fail-open (пустая сетка) независимо.

F2 (i18n) — добавлены 8 недостающих ключей в ru-RU (Selected version, via, of,
Previous/Next change, Previous/Next month, No revisions found for that day) плюс
новый aria-ключ. ru-RU правится текстовым append'ом — существующие (в т.ч.
дублирующиеся) ключи не тронуты.

F3 (a11y) — новые интерактивы доступны с клавиатуры: строки ревизий и ячейки
календаря — role=button + tabIndex + активация Enter/Space. Heatmap больше не
только-цвет: каждая ячейка несёт aria-label/title «дата: N versions»
(не-цветовой сигнал числа ревизий).

F4 — тесты history-nav-panel.tsx: рендер плотных строк, переключатель «Only
versions» реально фильтрует автосейвы (mutation-check), pick-day скроллит к дню,
error/empty-состояния.

F5 — тесты mini-calendar: границы тиров heat, клавиатурная активация, aria-cue.

F6 (hover-prefetch) — отложено (опционально, не тривиально в новой раскладке).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-07-12 23:06:41 +03:00
parent 642f50e9df
commit 3d44fd1943
8 changed files with 294 additions and 13 deletions
@@ -1478,5 +1478,6 @@
"Next change": "Next change",
"Previous month": "Previous month",
"Next month": "Next month",
"No revisions found for that day": "No revisions found for that day"
"No revisions found for that day": "No revisions found for that day",
"{{date}}: {{count}} versions": "{{date}}: {{count}} versions"
}
@@ -1487,5 +1487,14 @@
"≈ {{minutes}}m": "≈ {{minutes}} мин",
"{{hours}}h {{minutes}}m": "{{hours}} ч {{minutes}} м",
"{{hours}}h": "{{hours}} ч",
"{{minutes}}m": "{{minutes}} м"
"{{minutes}}m": "{{minutes}} м",
"Selected version": "Выбранная версия",
"via": "через",
"of": "из",
"Previous change": "Предыдущее изменение",
"Next change": "Следующее изменение",
"Previous month": "Предыдущий месяц",
"Next month": "Следующий месяц",
"No revisions found for that day": "Нет ревизий за этот день",
"{{date}}: {{count}} versions": "{{date}}: версий — {{count}}"
}
@@ -2,6 +2,7 @@ import {
ActionIcon,
Box,
Button,
Center,
Divider,
Group,
ScrollArea,
@@ -72,8 +73,14 @@ export default function HistoryModalDesktop({ pageId, onClose }: Props) {
const { currentChangeIndex, handlePrevChange, handleNextChange } =
useDiffNavigation(scrollViewportRef);
const { data, fetchNextPage, hasNextPage, isFetchingNextPage } =
usePageHistoryListQuery(pageId);
const {
data,
fetchNextPage,
hasNextPage,
isFetchingNextPage,
isError,
isLoading,
} = usePageHistoryListQuery(pageId);
const historyItems = useMemo(
() => data?.pages.flatMap((page) => page.items) ?? [],
[data],
@@ -206,6 +213,7 @@ export default function HistoryModalDesktop({ pageId, onClose }: Props) {
fetchNextPage={fetchNextPage}
hasNextPage={!!hasNextPage}
isFetchingNextPage={isFetchingNextPage}
isError={isError}
counts={counts}
selectedDayISO={selectedRow?.dayISO ?? null}
tz={tz}
@@ -218,9 +226,26 @@ export default function HistoryModalDesktop({ pageId, onClose }: Props) {
viewportRef={scrollViewportRef}
scrollbarSize={5}
>
<Box p="26px 0" maw={720} mx="auto">
{activeHistoryId && <HistoryView />}
</Box>
{/* F1 — the right pane mirrors the list state instead of going blank:
error on a failed history query, an empty state for a page with no
revisions, otherwise the rendered version. */}
{isError ? (
<Center h="100%" p={40}>
<Text size="sm" c="dimmed" ta="center">
{t("Error fetching page data.")}
</Text>
</Center>
) : !isLoading && historyItems.length === 0 ? (
<Stack align="center" justify="center" h="100%" gap={6} p={40}>
<Text fw={600} fz="sm">
{t("No page history saved yet.")}
</Text>
</Stack>
) : (
<Box p="26px 0" maw={720} mx="auto">
{activeHistoryId && <HistoryView />}
</Box>
)}
</ScrollArea>
</div>
</div>
@@ -0,0 +1,145 @@
import { describe, it, expect, vi, beforeEach } from "vitest";
import { render, screen, fireEvent } from "@testing-library/react";
import { MantineProvider } from "@mantine/core";
import { useState } from "react";
import HistoryNavPanel from "./history-nav-panel";
import { IPageHistory } from "@/features/page-history/types/page.types";
import { isoDayInTz } from "@/features/page-history/utils/revision-row";
// matchMedia / ResizeObserver are stubbed globally in vitest.setup.ts.
// react-i18next with no initialized instance returns the key as the label.
const TZ = "UTC";
const todayISO = isoDayInTz(new Date(), TZ);
function item(partial: Partial<IPageHistory>): IPageHistory {
return {
id: "x",
// Default to "today" so the current-month calendar has a matching cell.
createdAt: new Date().toISOString(),
lastUpdatedBy: { id: "u1", name: "Alice", avatarUrl: "" },
...partial,
} as IPageHistory;
}
const manual = item({ id: "m1", kind: "manual", lastUpdatedSource: "user" });
const autosave = item({ id: "a1", kind: null, lastUpdatedSource: "user" });
// Controlled harness so the "Only versions" switch actually toggles the filter,
// exercising the switch → filter wiring (not just a static prop).
function Harness({
items = [manual, autosave],
counts = new Map<string, number>(),
onPick = vi.fn(),
fetchNextPage = vi.fn(),
}: {
items?: IPageHistory[];
counts?: Map<string, number>;
onPick?: () => void;
fetchNextPage?: () => Promise<any>;
}) {
const [onlyVersions, setOnlyVersions] = useState(false);
return (
<MantineProvider>
<HistoryNavPanel
fullItems={items}
activeId=""
onSelect={onPick}
fetchNextPage={fetchNextPage as any}
hasNextPage={false}
isFetchingNextPage={false}
isError={false}
counts={counts}
selectedDayISO={todayISO}
tz={TZ}
onlyVersions={onlyVersions}
setOnlyVersions={setOnlyVersions}
/>
</MantineProvider>
);
}
describe("HistoryNavPanel (#568 left nav)", () => {
beforeEach(() => {
// jsdom has no scrollTo on elements — provide a spyable stub.
// @ts-ignore
Element.prototype.scrollTo = vi.fn();
});
it("renders one dense row per revision and the mini-calendar", () => {
render(<Harness />);
expect(screen.getAllByTestId("revision-row")).toHaveLength(2);
// 42-cell month grid is present.
expect(screen.getAllByTestId("calendar-day")).toHaveLength(42);
});
it("the 'Only versions' switch filters out autosaves (non-vacuous)", () => {
render(<Harness />);
// Both the manual version and the autosave are shown initially.
expect(screen.getAllByTestId("revision-row")).toHaveLength(2);
fireEvent.click(screen.getByLabelText("Only versions"));
// After filtering, only the manual VERSION row remains — if the filter
// predicate were dropped this assertion would fail.
const rows = screen.getAllByTestId("revision-row");
expect(rows).toHaveLength(1);
expect(screen.getByText("Alice")).toBeDefined();
});
it("picking a loaded day scrolls the list to that day's anchor", () => {
render(<Harness counts={new Map([[todayISO, 1]])} />);
const todayCell = screen
.getAllByTestId("calendar-day")
.find((c) => c.getAttribute("data-day") === todayISO)!;
fireEvent.click(todayCell);
// The day IS loaded (the row is on `today`), so scrollTo is invoked directly
// without any pagination fetch.
expect(Element.prototype.scrollTo).toHaveBeenCalled();
});
it("shows an explicit error state instead of a blank list", () => {
render(
<MantineProvider>
<HistoryNavPanel
fullItems={[]}
activeId=""
onSelect={vi.fn()}
fetchNextPage={vi.fn() as any}
hasNextPage={false}
isFetchingNextPage={false}
isError
counts={new Map()}
selectedDayISO={null}
tz={TZ}
onlyVersions={false}
setOnlyVersions={vi.fn()}
/>
</MantineProvider>,
);
expect(screen.getByText("Error loading page history.")).toBeDefined();
expect(screen.queryAllByTestId("revision-row")).toHaveLength(0);
});
it("shows an empty state (not blank) when there is no history", () => {
render(
<MantineProvider>
<HistoryNavPanel
fullItems={[]}
activeId=""
onSelect={vi.fn()}
fetchNextPage={vi.fn() as any}
hasNextPage={false}
isFetchingNextPage={false}
isError={false}
counts={new Map()}
selectedDayISO={null}
tz={TZ}
onlyVersions={false}
setOnlyVersions={vi.fn()}
/>
</MantineProvider>,
);
expect(screen.getByText("No page history saved yet.")).toBeDefined();
});
});
@@ -29,6 +29,7 @@ interface Props {
fetchNextPage: FetchNextPage;
hasNextPage: boolean;
isFetchingNextPage: boolean;
isError: boolean;
counts: Map<string, number>;
selectedDayISO: string | null;
tz: string;
@@ -51,6 +52,7 @@ export default function HistoryNavPanel({
fetchNextPage,
hasNextPage,
isFetchingNextPage,
isError,
counts,
selectedDayISO,
tz,
@@ -156,14 +158,25 @@ export default function HistoryNavPanel({
/>
<ScrollArea style={{ flex: 1 }} viewportRef={viewportRef} scrollbarSize={5}>
{onlyVersions && groups.length === 0 && (
<Center py="md">
<Text size="sm" c="dimmed">
{t("No saved versions yet.")}
{/* F1 — explicit error/empty states instead of a blank panel. The
heatmap fails open independently; the list keeps its own states. */}
{isError ? (
<Center py="md" px="sm">
<Text size="sm" c="dimmed" ta="center">
{t("Error loading page history.")}
</Text>
</Center>
)}
{groups.map((group) => (
) : groups.length === 0 ? (
<Center py="md" px="sm">
<Text size="sm" c="dimmed" ta="center">
{onlyVersions
? t("No saved versions yet.")
: t("No page history saved yet.")}
</Text>
</Center>
) : null}
{!isError &&
groups.map((group) => (
<Box key={group.dayISO}>
<Text
className={classes.dayHeading}
@@ -53,4 +53,57 @@ describe("MiniCalendar (#568 heatmap)", () => {
fireEvent.click(today);
expect(onPickDay).toHaveBeenCalledWith(todayISO);
});
it("Enter/Space activates a focusable day cell (F3 keyboard)", () => {
const { todayISO, onPickDay } = renderCal(new Map());
const today = screen
.getAllByTestId("calendar-day")
.find((c) => c.getAttribute("data-day") === todayISO)!;
// In-month cells are focusable buttons.
expect(today.getAttribute("role")).toBe("button");
expect(today.getAttribute("tabindex")).toBe("0");
fireEvent.keyDown(today, { key: "Enter" });
fireEvent.keyDown(today, { key: " " });
expect(onPickDay).toHaveBeenCalledTimes(2);
});
it("day cells carry a non-color aria/title cue for the count (F3)", () => {
const day = isoDayInTz(new Date(), TZ);
renderCal(new Map([[day, 3]]));
const today = screen
.getAllByTestId("calendar-day")
.find((c) => c.getAttribute("data-day") === day)!;
// A non-color cue is wired (the interpolated count is filled by the real
// i18next instance in-app; the test's key-fallback t returns the template).
const label = today.getAttribute("aria-label") ?? "";
expect(label).toContain("versions");
// The same text is mirrored to the native tooltip.
expect(today.getAttribute("title")).toBe(label);
// Outside-month cells are inert (no aria-label / not focusable).
const outside = screen
.getAllByTestId("calendar-day")
.find((c) => c.getAttribute("role") !== "button");
expect(outside?.getAttribute("aria-label")).toBeNull();
});
it("maps count tiers to heat classes at the prototype boundaries", () => {
const cases: Array<[number, string]> = [
[0, "calHeat0"],
[2, "calHeat1"],
[3, "calHeat2"],
[4, "calHeat2"],
[5, "calHeat3"],
];
for (const [count, cls] of cases) {
const day = isoDayInTz(new Date(), TZ);
const { unmount } = renderCal(
count > 0 ? new Map([[day, count]]) : new Map(),
);
const today = screen
.getAllByTestId("calendar-day")
.find((c) => c.getAttribute("data-day") === day)!;
expect(today.className).toContain(cls);
unmount();
}
});
});
@@ -152,12 +152,36 @@ export default function MiniCalendar({
const count = counts.get(cell.dayISO) ?? 0;
const level = cell.inMonth ? heatLevel(count) : 0;
const selected = cell.inMonth && cell.dayISO === selectedDayISO;
// Non-color cue (F3 a11y): the count is announced, not conveyed by the
// heat color alone — e.g. "12 Jul: 3 versions".
const dayLabel = new Intl.DateTimeFormat(undefined, {
timeZone: tz,
day: "numeric",
month: "short",
}).format(cell.date);
const ariaLabel = t("{{date}}: {{count}} versions", {
date: dayLabel,
count,
});
return (
<Box
key={cell.dayISO}
data-testid="calendar-day"
data-day={cell.dayISO}
// Only in-month cells are interactive → focusable buttons with a
// title/aria-label and Enter/Space activation; outside cells inert.
role={cell.inMonth ? "button" : undefined}
tabIndex={cell.inMonth ? 0 : undefined}
aria-label={cell.inMonth ? ariaLabel : undefined}
aria-pressed={cell.inMonth ? selected : undefined}
title={cell.inMonth ? ariaLabel : undefined}
onClick={() => cell.inMonth && onPickDay(cell.dayISO)}
onKeyDown={(e) => {
if (cell.inMonth && (e.key === "Enter" || e.key === " ")) {
e.preventDefault();
onPickDay(cell.dayISO);
}
}}
className={clsx(
classes.calDay,
classes[`calHeat${level}` as keyof typeof classes],
@@ -39,7 +39,18 @@ const RevisionRow = memo(function RevisionRow({
px={12}
data-testid="revision-row"
data-day={row.dayISO}
// Keyboard-accessible (F3 a11y): a focusable button-role row activated by
// Enter/Space, not just a mouse onClick.
role="button"
tabIndex={0}
aria-pressed={selected}
onClick={() => onSelect(row.id)}
onKeyDown={(e) => {
if (e.key === "Enter" || e.key === " ") {
e.preventDefault();
onSelect(row.id);
}
}}
onMouseEnter={() => onHover?.(row.id)}
onMouseLeave={onHoverEnd}
className={clsx(classes.revisionRow, {