diff --git a/components/data-grids/compact-data-grid.tsx b/components/data-grids/compact-data-grid.tsx index aa483d37..27241f0d 100644 --- a/components/data-grids/compact-data-grid.tsx +++ b/components/data-grids/compact-data-grid.tsx @@ -1,6 +1,6 @@ "use client" -import { dataGridFeatures, type DataGridFeatures } from "@/components/reui/data-grid" +import { DataGridContainer, DataGridScrollArea, DataGridTable, dataGridFeatures, type DataGridFeatures } from "@/components/reui/data-grid" import { useMemo, useEffect, type ReactNode } from "react" import { @@ -31,6 +31,7 @@ interface CompactDataGridProps { data: T[] columns: CompactDataGridColumn[] isLoading?: boolean + horizontalScroll?: boolean emptyTitle?: string emptyDescription?: string onRowClick?: (row: T) => void @@ -41,6 +42,7 @@ function CompactDataGrid({ data, columns, isLoading, + horizontalScroll = false, emptyTitle = "Нет записей", emptyDescription, onRowClick, @@ -118,7 +120,15 @@ function CompactDataGrid({ className="border-0 py-10" /> } - /> + > + {horizontalScroll ? ( + + + + + + ) : undefined} + ) } diff --git a/components/statistics/statistics-pivot-grid.tsx b/components/statistics/statistics-pivot-grid.tsx index e5b43ccd..3049ed6d 100644 --- a/components/statistics/statistics-pivot-grid.tsx +++ b/components/statistics/statistics-pivot-grid.tsx @@ -47,20 +47,24 @@ export function StatisticsPivotGrid({ accessorKey: `c:${col.id}` as const, cell: (row: PivotGridRow) => { const value = Number(row[`c:${col.id}`] ?? 0) + const label = data.metric === "packets" ? value.toLocaleString("ru-RU") : formatBytes(value) + if (col.id === "__other__" || row.id === "__other__") { + return {label} + } return ( ) }, @@ -80,6 +84,7 @@ export function StatisticsPivotGrid({ return ( { + await page.setViewportSize({ width, height: 1000 }) + const columns = Array.from({ length: 16 }, (_, i) => ({ id: i === 15 ? "__other__" : "service-" + (i + 1), label: i === 15 ? "Прочие сервисы" : "Сервис " + (i + 1), total: 0 })) + const rows = Array.from({ length: 51 }, (_, i) => ({ id: i === 50 ? "__other__" : String(i + 1), label: i === 50 ? "Прочие клиенты" : "Клиент " + String(i + 1).padStart(4, "0"), total: 0, cells: Object.fromEntries(columns.map((c, j) => [c.id, i === 0 && j === 0 ? 0 : (i + 1) * (j + 1)])) })) + for (const row of rows) row.total = Object.values(row.cells).reduce((sum, n) => sum + n, 0) + for (const col of columns) col.total = rows.reduce((sum, row) => sum + row.cells[col.id], 0) + const pivot = statisticsPivotDtoSchema.parse({ rowDim: "user", colDim: "service", metric: "bytes", rows, columns, otherBytes: rows.at(-1)!.total + columns.at(-1)!.total - rows.at(-1)!.cells.__other__ }) + const reads: URL[] = [], writes: string[] = [] + await page.route("**/health", r => r.fulfill({ json: { ok: true } })) + await page.route("**/api/**", r => { + const url = new URL(r.request().url()); reads.push(url) + if (r.request().method() !== "GET") writes.push(url.pathname) + return r.fulfill({ json: url.pathname === "/api/statistics/pivot" ? pivot : url.pathname === "/api/statistics" ? statisticsDtoSchema.parse({ from: url.searchParams.get("from"), to: url.searchParams.get("to"), grain: "day", kpis: { bytes: rows.reduce((sum, row) => sum + row.total, 0), packets: 0, avgBps: 0, users: 51, servers: 0, ifaces: 0, topCountry: "", topService: "" }, series: [], users: [], servers: [], interfaces: [], countries: [], services: [], asns: [] }) : url.pathname === "/api/auth/config" ? { required: false } : url.pathname === "/api/servers" ? [] : {} }) + }) + const target = "/statistics?from=2026-09-01&to=2026-09-03&view=pivot&pivotRow=user&pivotCol=service" + const start = Date.now(); await page.goto(target) + const rendered = page.locator("tr[data-row-id]") + await expect(rendered).toHaveCount(50) + const sample = { fixtureRows: 51, fixtureColumns: 16, fixtureBytes: Buffer.byteLength(JSON.stringify(pivot)), renderedRows: await rendered.count(), domNodes: await page.locator("*").count(), readyMs: Date.now() - start, width } + console.log(JSON.stringify(sample)); await info.attach("wide-pivot-metrics", { body: JSON.stringify(sample), contentType: "application/json" }) + expect(sample.domNodes).toBeLessThan(10000) + await expect(page.getByText("1–50 из 51", { exact: true })).toBeVisible() + await page.getByRole("button", { name: "Следующая страница", exact: true }).click() + await expect(rendered).toHaveCount(1); await expect(rendered.first()).toContainText("Прочие клиенты") + await page.getByRole("button", { name: "Предыдущая страница", exact: true }).click() + await page.getByRole("table").getByRole("button", { name: "Итого", exact: true }).click() + await expect(rendered.first()).toHaveAttribute("data-row-id", "__other__") + await expect(rendered.nth(1)).toHaveAttribute("data-row-id", "50") + const scroll = page.locator('[data-slot="data-grid"] [data-slot="scroll-area-viewport"]') + await expect(scroll).toHaveCount(1) + expect(await scroll.evaluate(el => el.scrollWidth > el.clientWidth)).toBe(true) + await scroll.evaluate(el => { el.scrollLeft = el.scrollWidth }) + await expect.poll(() => scroll.evaluate(el => el.scrollLeft)).toBeGreaterThan(0) + expect(await page.evaluate(() => document.documentElement.scrollWidth <= innerWidth)).toBe(true) + await expect(rendered.first().getByRole("button")).toHaveCount(0) + await expect(rendered.nth(1).getByRole("cell").nth(16).getByRole("button")).toHaveCount(0) + await page.addScriptTag({ content: axe.source }) + const violations = await page.evaluate(async () => (await (window as unknown as { axe: typeof axe }).axe.run(document.querySelector('[data-slot="data-grid"]')!, { runOnly: { type: "tag", values: ["wcag2a", "wcag2aa", "wcag21a", "wcag21aa"] } })).violations) + expect(violations).toEqual([]) + await page.addStyleTag({ content: "nextjs-portal { display: none !important; }" }) + mkdirSync("docs/ux-ui/screens", { recursive: true }) + await page.screenshot({ path: "docs/ux-ui/screens/wide-statistics-pivot-" + width + ".png", animations: "disabled" }) + const lastCell = rendered.nth(1).getByRole("cell").nth(15).getByRole("button") + await lastCell.focus(); await page.keyboard.press("Enter") + await expect(page).toHaveURL(/view=explore/) + const selected = new URL(page.url()); expect(selected.searchParams.get("userId")).toBe("50"); expect(selected.searchParams.get("service")).toBe("service-15") + expect(reads.some(url => url.pathname === "/api/statistics" && url.searchParams.get("userId") === "50" && url.searchParams.get("service") === "service-15")).toBe(true) + await page.goBack(); await expect(rendered).toHaveCount(50) + await page.reload(); await expect(rendered).toHaveCount(50) + await expect(page.getByRole("button", { name: "Следующая страница", exact: true })).toBeVisible() + expect(writes).toEqual([]) +})