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:
@@ -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, {
|
||||
|
||||
Reference in New Issue
Block a user