diff --git a/apps/web/package.json b/apps/web/package.json index 0cc7fa1..27cef16 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -23,7 +23,7 @@ "@tanstack/react-query-devtools": "^5.90.2", "@tanstack/react-router": "^1.130.2", "@tanstack/react-router-devtools": "^1.130.2", - "@tanstack/react-table": "^8.21.3", + "@tanstack/react-table": "^9.1.2", "@tanstack/react-virtual": "^3.14.4", "@xyflow/react": "^12.11.2", "class-variance-authority": "^0.7.1", diff --git a/apps/web/src/components/censorcheck/blocking-filters.test.ts b/apps/web/src/components/censorcheck/blocking-filters.test.ts index 48796d9..336c4c4 100644 --- a/apps/web/src/components/censorcheck/blocking-filters.test.ts +++ b/apps/web/src/components/censorcheck/blocking-filters.test.ts @@ -1,6 +1,6 @@ import { describe, expect, it } from 'vitest' import type { Filter } from '@/components/reui/filters' -import { filterCensorcheckRuns, groupRunsByService } from './blocking-filters' +import { filterCensorcheckRuns, groupRunsByService, collectServiceColumns, collectProbeColumns, shortHostLabel } from './blocking-filters' import type { CensorcheckRunDto } from './types' const run = (overrides: Partial = {}): CensorcheckRunDto => ({ @@ -95,5 +95,49 @@ describe('groupRunsByService', () => { const groups = groupRunsByService([run()]) expect(groups.map((g) => g.serviceKey)).toEqual(['netflix.com', 'youtube.com']) expect(groups[1]?.probes[0]?.status).toBe('blocked') + expect(groups[1]?.probes[0]?.httpStatus).toBe(-1) }) }) + +describe('collectServiceColumns', () => { + it('ставит канонические сервисы первыми и custom в конец', () => { + const cols = collectServiceColumns([ + run({ + results: [ + ...(run().results ?? []), + { + id: 'r3', + runId: 'ccrun-1', + serviceKey: 'custom.example', + serviceLabel: 'custom.example', + category: 'custom', + status: 'available', + httpStatus: 200, + detail: null, + }, + ], + }), + ]) + expect(cols[0]?.key).toBe('youtube.com') + expect(cols.map((c) => c.key)).toContain('netflix.com') + expect(cols.at(-1)?.key).toBe('custom.example') + }) +}) + +describe('collectProbeColumns', () => { + it('берёт dns как короткий header', () => { + expect(collectProbeColumns([run()])[0]).toMatchObject({ + key: 'ccrun-1', + label: 'edge.example', + title: 'edge.example.com', + }) + }) +}) + +describe('shortHostLabel', () => { + it('отрезает типичный TLD', () => { + expect(shortHostLabel('youtube.com')).toBe('youtube') + expect(shortHostLabel('api.telegram.org')).toBe('api.telegram') + }) +}) + diff --git a/apps/web/src/components/censorcheck/blocking-filters.ts b/apps/web/src/components/censorcheck/blocking-filters.ts index ab9598f..44abcfc 100644 --- a/apps/web/src/components/censorcheck/blocking-filters.ts +++ b/apps/web/src/components/censorcheck/blocking-filters.ts @@ -1,6 +1,15 @@ import { getActiveFilters } from '@/components/reui-kit' import type { Filter } from '@/components/reui/filters' -import { runSearchText, type CensorcheckRunDto } from './types' +import { + CENSORCHECK_DPI_HOSTS, + CENSORCHECK_GEOBLOCK_HOSTS, + inferCensorcheckCategory, +} from '@cfdm/shared/contracts/censorcheck' +import { + runSearchText, + type CensorcheckResultDto, + type CensorcheckRunDto, +} from './types' export function filterCensorcheckRuns( runs: CensorcheckRunDto[], @@ -68,6 +77,7 @@ export type BlockingServiceRow = { dns: string country: string status: string + httpStatus: number | null createdAt: string vpsId: string | null }> @@ -85,6 +95,7 @@ export function groupRunsByService(runs: CensorcheckRunDto[]): BlockingServiceRo dns: run.vps?.dns ?? '', country: run.vps?.country ?? '', status: result.status, + httpStatus: result.httpStatus, createdAt: run.createdAt, vpsId: run.matchedVpsId, } @@ -103,3 +114,68 @@ export function groupRunsByService(runs: CensorcheckRunDto[]): BlockingServiceRo } return [...map.values()].sort((a, b) => a.serviceKey.localeCompare(b.serviceKey)) } + +export type MatrixColumn = { + key: string + label: string + title: string +} + +export function shortHostLabel(value: string): string { + const host = value.trim().split('/')[0] ?? value + return host.replace(/\.(com|org|net|io|ag|is)$/i, '') +} + +const CANONICAL_SERVICES = [...CENSORCHECK_DPI_HOSTS, ...CENSORCHECK_GEOBLOCK_HOSTS] + +export function collectServiceColumns(runs: CensorcheckRunDto[]): MatrixColumn[] { + const canonicalSet = new Set(CANONICAL_SERVICES) + const extras = new Set() + for (const run of runs) { + for (const result of run.results ?? []) { + if (!canonicalSet.has(result.serviceKey)) extras.add(result.serviceKey) + } + } + const keys = [ + ...CANONICAL_SERVICES, + ...[...extras].sort((a, b) => a.localeCompare(b)), + ] + return keys.map((key) => ({ + key, + label: shortHostLabel(key), + title: key, + })) +} + +export function collectProbeColumns(runs: CensorcheckRunDto[]): MatrixColumn[] { + return runs.map((run) => { + const title = run.vps?.dns || run.probePublicIp + return { + key: run.id, + label: shortHostLabel(title), + title, + } + }) +} + +export function resultByService( + run: CensorcheckRunDto, + serviceKey: string, +): CensorcheckResultDto | undefined { + return (run.results ?? []).find((row) => row.serviceKey === serviceKey) +} + +export function serviceMatrixRows(runs: CensorcheckRunDto[]): BlockingServiceRow[] { + const grouped = new Map(groupRunsByService(runs).map((row) => [row.serviceKey, row])) + return collectServiceColumns(runs).map((col) => { + const existing = grouped.get(col.key) + if (existing) return existing + return { + id: col.key, + serviceKey: col.key, + serviceLabel: col.key, + category: inferCensorcheckCategory(col.key), + probes: [], + } + }) +} diff --git a/apps/web/src/components/censorcheck/blocking-grid.tsx b/apps/web/src/components/censorcheck/blocking-grid.tsx index 1021a46..70d64fa 100644 --- a/apps/web/src/components/censorcheck/blocking-grid.tsx +++ b/apps/web/src/components/censorcheck/blocking-grid.tsx @@ -1,74 +1,31 @@ -import type { ReactNode } from 'react' +import { useMemo, type ReactNode } from 'react' import { Link } from '@tanstack/react-router' -import { GlobeIcon, MapPinIcon, ServerIcon, ShieldAlertIcon } from 'lucide-react' +import { ServerIcon, ShieldAlertIcon } from 'lucide-react' import type { DataGridColumn } from '@/components/data-grid-types' -import { dataGridCellStack, dataGridCellWithFlag } from '@/components/data-grid-cells' -import { CountryFlag } from '@/components/country-flag' -import { StatusBadge } from '@/components/status-badge' -import { columnDefFromDataGrid, ExpandableResourceGrid } from '@/components/reui-kit' -import { Badge } from '@/components/reui/badge' +import { dataGridCellStack } from '@/components/data-grid-cells' +import { columnDefFromDataGrid, FrameDataGrid } from '@/components/reui-kit' import { - CENSORCHECK_STATUS_LABELS, - formatCheckedAt, - formatVpsResources, - type CensorcheckRunDto, -} from './types' -import type { BlockingServiceRow } from './blocking-filters' + collectProbeColumns, + collectServiceColumns, + resultByService, + type BlockingServiceRow, +} from './blocking-filters' +import { StatusMatrixCell } from './status-matrix-cell' +import type { CensorcheckRunDto } from './types' -function SummaryBadges({ run }: { run: CensorcheckRunDto }) { - const { summary } = run - return ( -
- {summary.available > 0 ? ( - {summary.available} ок - ) : null} - {summary.blocked > 0 ? ( - {summary.blocked} блок - ) : null} - {summary.denied > 0 ? ( - {summary.denied} отказ - ) : null} - {summary.timeout > 0 ? ( - {summary.timeout} timeout - ) : null} - {summary.error > 0 ? ( - {summary.error} err - ) : null} -
- ) -} +const MATRIX_CELL = 'w-16 min-w-16 px-1 text-center' -function NestedList({ - rows, -}: { - rows: Array<{ key: string; primary: string; secondary?: string; status: string }> -}) { - return ( -
- {rows.map((row) => ( -
-
- {row.primary} - {row.secondary ? ( - {row.secondary} - ) : null} -
- -
- ))} -
- ) -} - -const vpsColumns: DataGridColumn[] = [ - { +function vpsIdentityColumn(): DataGridColumn { + return { key: 'vps', header: 'VPS / IP', + headerTitle: 'VPS / IP', icon: ServerIcon, + enableHiding: false, + enablePinning: true, + size: 220, + minSize: 180, sortValue: (row) => row.vps?.dns || row.probePublicIp, cell: (row) => { const title = row.vps?.dns || row.probePublicIp @@ -87,68 +44,8 @@ const vpsColumns: DataGridColumn[] = [ ) return dataGridCellStack(link, ip) }, - }, - { - key: 'dns', - header: 'DNS', - icon: GlobeIcon, - sortValue: (row) => row.vps?.dns ?? '', - cell: (row) => row.vps?.dns || '—', - }, - { - key: 'hoster', - header: 'Хостер', - sortValue: (row) => row.vps?.providerName ?? '', - cell: (row) => row.vps?.providerName || '—', - }, - { - key: 'country', - header: 'Страна', - icon: MapPinIcon, - sortValue: (row) => row.vps?.country ?? '', - cell: (row) => - row.vps?.country - ? dataGridCellWithFlag(, row.vps.country) - : '—', - }, - { - key: 'resources', - header: 'Ресурсы', - sortValue: (row) => row.vps?.vcpu ?? 0, - cell: (row) => - row.vps - ? formatVpsResources(row.vps.vcpu, row.vps.ramGb, row.vps.diskGb) - : '—', - }, - { - key: 'summary', - header: 'Сводка', - cell: (row) => , - }, - { - key: 'checked', - header: 'Проверено', - sortValue: (row) => row.createdAt, - cell: (row) => formatCheckedAt(row.createdAt), - }, -] - -const serviceColumns: DataGridColumn[] = [ - { - key: 'service', - header: 'Сервис', - icon: ShieldAlertIcon, - sortValue: (row) => row.serviceKey, - cell: (row) => dataGridCellStack(row.serviceLabel, row.category), - }, - { - key: 'probes', - header: 'Пробы', - sortValue: (row) => row.probes.length, - sortingFn: 'basic', - cell: (row) => row.probes.length, - }, -] + } +} export function BlockingVpsGrid({ runs, @@ -159,59 +56,133 @@ export function BlockingVpsGrid({ onRowClick: (run: CensorcheckRunDto) => void emptyAction?: ReactNode }) { + const serviceCols = useMemo(() => collectServiceColumns(runs), [runs]) + const columns = useMemo((): DataGridColumn[] => { + return [ + vpsIdentityColumn(), + ...serviceCols.map( + (svc): DataGridColumn => ({ + key: `svc:${svc.key}`, + header: ( + + {svc.label} + + ), + headerTitle: svc.title, + className: MATRIX_CELL, + headerClassName: MATRIX_CELL, + size: 72, + minSize: 64, + sortable: true, + sortValue: (row) => resultByService(row, svc.key)?.status ?? '', + cell: (row) => { + const item = resultByService(row, svc.key) + return ( + + ) + }, + }), + ), + ] + }, [serviceCols]) + return ( - row.id} dense pagination={runs.length > 10} + pinLeftColumnIds={['vps']} + horizontalScroll emptyTitle="Нет проверок" emptyDescription="Запустите launcher на VPS, чтобы увидеть статусы блокировок." emptyAction={emptyAction} onRowClick={onRowClick} - getRowCanExpand={(row) => (row.results?.length ?? 0) > 0} - expandedContent={(row) => ( - ({ - key: item.id, - primary: item.serviceLabel, - secondary: item.category, - status: item.status, - }))} - /> - )} /> ) } export function BlockingServiceGrid({ groups, + runs, + onProbeClick, emptyAction, }: { groups: BlockingServiceRow[] + runs: CensorcheckRunDto[] + onProbeClick: (run: CensorcheckRunDto) => void emptyAction?: ReactNode }) { + const probeCols = useMemo(() => collectProbeColumns(runs), [runs]) + const runById = useMemo(() => new Map(runs.map((row) => [row.id, row])), [runs]) + + const columns = useMemo((): DataGridColumn[] => { + return [ + { + key: 'service', + header: 'Сервис', + headerTitle: 'Сервис', + icon: ShieldAlertIcon, + enableHiding: false, + enablePinning: true, + size: 180, + minSize: 140, + sortValue: (row) => row.serviceKey, + cell: (row) => dataGridCellStack(row.serviceLabel, row.category), + }, + ...probeCols.map( + (probe): DataGridColumn => ({ + key: `probe:${probe.key}`, + header: ( + + {probe.label} + + ), + headerTitle: probe.title, + className: MATRIX_CELL, + headerClassName: MATRIX_CELL, + size: 72, + minSize: 64, + sortable: true, + sortValue: (row) => + row.probes.find((item) => item.runId === probe.key)?.status ?? '', + cell: (row) => { + const item = row.probes.find((probeRow) => probeRow.runId === probe.key) + const run = runById.get(probe.key) + return ( + onProbeClick(run) : undefined} + /> + ) + }, + }), + ), + ] + }, [onProbeClick, probeCols, runById]) + return ( - row.id} dense pagination={groups.length > 10} + pinLeftColumnIds={['service']} + horizontalScroll emptyTitle="Нет сервисов" emptyAction={emptyAction} - getRowCanExpand={(row) => row.probes.length > 0} - expandedContent={(row) => ( - ({ - key: `${probe.runId}-${probe.probePublicIp}`, - primary: probe.dns || probe.probePublicIp, - secondary: `${probe.probePublicIp} · ${formatCheckedAt(probe.createdAt)}`, - status: probe.status, - }))} - /> - )} /> ) } diff --git a/apps/web/src/components/censorcheck/blocking-page.tsx b/apps/web/src/components/censorcheck/blocking-page.tsx index aacdac1..8a3ba8f 100644 --- a/apps/web/src/components/censorcheck/blocking-page.tsx +++ b/apps/web/src/components/censorcheck/blocking-page.tsx @@ -27,7 +27,7 @@ import { StatusBadge } from '@/components/status-badge' import type { DataGridColumn } from '@/components/data-grid-types' import { BlockingServiceGrid, BlockingVpsGrid } from './blocking-grid' import { CheckRunSheet } from './check-run-sheet' -import { filterCensorcheckRuns, groupRunsByService } from './blocking-filters' +import { filterCensorcheckRuns, serviceMatrixRows } from './blocking-filters' import { CENSORCHECK_STATUS_LABELS, LAUNCHER_CMD, @@ -119,7 +119,7 @@ export function BlockingPage() { const runs = currentQuery.data?.items ?? [] const filtered = useMemo(() => filterCensorcheckRuns(runs, filters), [runs, filters]) - const serviceGroups = useMemo(() => groupRunsByService(filtered), [filtered]) + const serviceGroups = useMemo(() => serviceMatrixRows(filtered), [filtered]) const matched = filtered.filter((row) => row.matchedVpsId).length const blocked = filtered.reduce((sum, row) => sum + row.summary.blocked, 0) @@ -230,7 +230,12 @@ export function BlockingPage() { emptyAction={copyLauncher} /> ) : ( - + ) } diff --git a/apps/web/src/components/censorcheck/status-matrix-cell.tsx b/apps/web/src/components/censorcheck/status-matrix-cell.tsx new file mode 100644 index 0000000..9148de9 --- /dev/null +++ b/apps/web/src/components/censorcheck/status-matrix-cell.tsx @@ -0,0 +1,75 @@ +import { Badge } from '@/components/reui/badge' +import { + Tooltip, + TooltipContent, + TooltipTrigger, +} from '@cfdm/ui/components/tooltip' +import { StatusBadge } from '@/components/status-badge' +import { CENSORCHECK_STATUS_LABELS, formatCheckedAt } from './types' + +/** Compact timesheet-style cell — preview: https://reui.io/preview/base/data-grid-base-4 */ +const MATRIX_SHORT: Record = { + available: 'ОК', + blocked: 'Блок', + denied: 'Отказ', + timeout: 'TO', + redirected: '3xx', + error: 'Err', +} + +export function StatusMatrixCell({ + status, + serviceLabel, + vpsLabel, + httpStatus, + checkedAt, + onSelect, +}: { + status?: string | null + serviceLabel: string + vpsLabel: string + httpStatus?: number | null + checkedAt?: string + onSelect?: () => void +}) { + const short = status ? (MATRIX_SHORT[status] ?? status) : '—' + const full = status ? (CENSORCHECK_STATUS_LABELS[status] ?? status) : 'Нет результата' + const tip = [ + serviceLabel, + vpsLabel, + full, + httpStatus != null ? `HTTP ${httpStatus}` : null, + checkedAt ? formatCheckedAt(checkedAt) : null, + ] + .filter(Boolean) + .join(' · ') + + const badge = status ? ( + + ) : ( + + — + + ) + + return ( + + { + if (!onSelect) return + event.stopPropagation() + onSelect() + }} + /> + } + > + {badge} + + {tip} + + ) +} diff --git a/apps/web/src/components/data-grid-types.ts b/apps/web/src/components/data-grid-types.ts index b8b7526..5e63a7b 100644 --- a/apps/web/src/components/data-grid-types.ts +++ b/apps/web/src/components/data-grid-types.ts @@ -8,12 +8,16 @@ export interface DataGridColumn { icon?: LucideIcon sortable?: boolean sortValue?: (row: T) => string | number - /** TanStack sortingFn; для числовых sortValue — `'basic'`. */ + /** TanStack v9 `sortFn`; для числовых sortValue — `'basic'`. */ sortingFn?: 'auto' | 'alphanumeric' | 'basic' | 'text' | 'datetime' headerTitle?: string className?: string headerClassName?: string enableHiding?: boolean + size?: number + minSize?: number + maxSize?: number + enablePinning?: boolean } /** @deprecated Используйте DataGridColumn */ diff --git a/apps/web/src/components/reui-kit/frame-data-grid.tsx b/apps/web/src/components/reui-kit/frame-data-grid.tsx index 3196098..ca1add5 100644 --- a/apps/web/src/components/reui-kit/frame-data-grid.tsx +++ b/apps/web/src/components/reui-kit/frame-data-grid.tsx @@ -1,28 +1,32 @@ import { useState, useEffect, type ReactNode } from 'react' import { - useReactTable, - getCoreRowModel, - getSortedRowModel, - getPaginationRowModel, - getExpandedRowModel, + useTable, flexRender, type ColumnDef, type SortingState, type RowSelectionState, - type VisibilityState, + type ColumnVisibilityState, type ExpandedState, type OnChangeFn, + type PaginationState, } from '@tanstack/react-table' -import { ChevronDownIcon, ChevronRightIcon, Columns3Icon } from 'lucide-react' +import { Columns3Icon } from 'lucide-react' -import { Checkbox } from '@cfdm/ui/components/checkbox' import { Button } from '@cfdm/ui/components/button' import { cn } from '@cfdm/ui/lib/utils' import { DataGrid, DataGridContainer, + dataGridFeatures, + type DataGridFeatures, + type DataGridTableInstance, } from '@/components/reui/data-grid/data-grid' -import { DataGridTable } from '@/components/reui/data-grid/data-grid-table' +import { + DataGridTable, + DataGridTableRowExpand, + DataGridTableRowSelect, + DataGridTableRowSelectAll, +} from '@/components/reui/data-grid/data-grid-table' import { DataGridTableVirtual } from '@/components/reui/data-grid/data-grid-table-virtual' import { DataGridScrollArea } from '@/components/reui/data-grid/data-grid-scroll-area' import { DataGridPagination } from '@/components/reui/data-grid/data-grid-pagination' @@ -39,14 +43,16 @@ import { FrameTitle, } from '@/components/reui/frame' +export type DataGridColumnDef = ColumnDef< + DataGridFeatures, + TData +> + const PAGINATION_LABELS = { rowsPerPageLabel: 'Строк на странице', info: '{from}–{to} из {count}', previousPageLabel: 'Предыдущая страница', nextPageLabel: 'Следующая страница', - pageLabel: 'Страница {page}', - previousPagesLabel: 'Предыдущие страницы', - nextPagesLabel: 'Следующие страницы', } as const function resolveHeaderTitle(header: ReactNode, headerTitle?: string): string { @@ -55,11 +61,11 @@ function resolveHeaderTitle(header: ReactNode, headerTitle?: string): string { return '' } -function loadStoredColumnVisibility(key: string): VisibilityState | undefined { +function loadStoredColumnVisibility(key: string): ColumnVisibilityState | undefined { try { const raw = localStorage.getItem(key) if (!raw) return undefined - return JSON.parse(raw) as VisibilityState + return JSON.parse(raw) as ColumnVisibilityState } catch { return undefined } @@ -87,7 +93,7 @@ export interface FrameDataGridProps { title?: ReactNode description?: ReactNode actions?: ReactNode - columns: ColumnDef[] + columns: DataGridColumnDef[] data: TData[] /** Ключ строки — функция, возвращающая уникальный id. */ rowId?: (row: TData, index: number) => string @@ -118,18 +124,22 @@ export interface FrameDataGridProps { /** Показать picker видимости колонок. */ enableColumnVisibility?: boolean /** Управляемая видимость колонок (для внешнего UI, напр. тулбар «Вид»). */ - columnVisibility?: VisibilityState - onColumnVisibilityChange?: OnChangeFn + columnVisibility?: ColumnVisibilityState + onColumnVisibilityChange?: OnChangeFn /** Показать встроенную кнопку «Колонки». По умолчанию true при enableColumnVisibility. */ columnVisibilityTrigger?: boolean /** Ключ localStorage для сохранения видимости колонок. */ columnVisibilityStorageKey?: string /** Начальная видимость колонок (перекрывает localStorage для отсутствующих ключей). */ - initialColumnVisibility?: VisibilityState + initialColumnVisibility?: ColumnVisibilityState className?: string /** Expandable rows — c-data-grid-8 / https://reui.io/preview/base/components/c-data-grid-8 */ expandedContent?: (row: TData) => ReactNode getRowCanExpand?: (row: TData) => boolean + /** Закрепить колонки слева (ids). Timesheet DNA: https://reui.io/preview/base/data-grid-base-4 */ + pinLeftColumnIds?: string[] + /** Горизонтальный скролл широкой матрицы. */ + horizontalScroll?: boolean } function DataGridSectionHeader({ @@ -177,8 +187,10 @@ function FrameDataGridBody({ footerContent, showPagination, enableColumnVisibility, + columnsPinnable, + horizontalScroll, }: { - table: ReturnType> + table: DataGridTableInstance data: TData[] emptyTitle: string onRowClick?: (row: TData) => void @@ -188,7 +200,15 @@ function FrameDataGridBody({ footerContent?: ReactNode showPagination: boolean enableColumnVisibility: boolean + columnsPinnable: boolean + horizontalScroll: boolean }) { + const tableNode = virtualization ? ( + + ) : ( + + ) + return ( ({ width: 'auto', columnsVisibility: enableColumnVisibility, columnsResizable: false, - columnsPinnable: false, + columnsPinnable, columnsMovable: false, rowsDraggable: false, rowsPinnable: false, @@ -215,12 +235,15 @@ function FrameDataGridBody({ }} > - {virtualization ? ( - - + {virtualization || horizontalScroll ? ( + + {tableNode} ) : ( - + tableNode )} {showPagination ? : null} @@ -258,11 +281,18 @@ export function FrameDataGrid({ className, expandedContent, getRowCanExpand, + pinLeftColumnIds, + horizontalScroll = false, }: FrameDataGridProps) { + const showPagination = pagination ?? true const [sorting, setSorting] = useState(initialSorting ?? []) const [rowSelection, setRowSelection] = useState({}) const [expanded, setExpanded] = useState({}) - const [internalColumnVisibility, setInternalColumnVisibility] = useState(() => { + const [paginationState, setPaginationState] = useState({ + pageIndex: 0, + pageSize: showPagination ? pageSize : Number.POSITIVE_INFINITY, + }) + const [internalColumnVisibility, setInternalColumnVisibility] = useState(() => { const stored = columnVisibilityStorageKey ? loadStoredColumnVisibility(columnVisibilityStorageKey) : undefined @@ -271,89 +301,73 @@ export function FrameDataGrid({ const isColumnVisibilityControlled = columnVisibilityProp !== undefined const columnVisibility = isColumnVisibilityControlled ? columnVisibilityProp : internalColumnVisibility - const setColumnVisibility: OnChangeFn = isColumnVisibilityControlled + const setColumnVisibility: OnChangeFn = isColumnVisibilityControlled ? (onColumnVisibilityChange ?? (() => undefined)) : setInternalColumnVisibility + useEffect(() => { + setPaginationState((current) => ({ + pageIndex: showPagination ? current.pageIndex : 0, + pageSize: showPagination ? pageSize : Number.POSITIVE_INFINITY, + })) + }, [pageSize, showPagination]) + useEffect(() => { if (isColumnVisibilityControlled || !columnVisibilityStorageKey) return localStorage.setItem(columnVisibilityStorageKey, JSON.stringify(columnVisibility)) }, [columnVisibility, columnVisibilityStorageKey, isColumnVisibilityControlled]) - const selectColumn: ColumnDef = { + const selectColumn: DataGridColumnDef = { id: 'select', - header: ({ table }) => ( - table.toggleAllPageRowsSelected(!!value)} - aria-label="Выбрать все" - /> - ), - cell: ({ row }) => ( - row.toggleSelected(!!value)} - aria-label="Выбрать строку" - onClick={(e) => e.stopPropagation()} - /> - ), + header: () => , + cell: ({ row }) => , enableSorting: false, enableHiding: false, + size: 40, meta: { cellClassName: 'w-10' }, } - const expandColumn: ColumnDef = { + const expandColumn: DataGridColumnDef = { id: 'expand', header: () => null, - cell: ({ row }) => - row.getCanExpand() ? ( - - ) : null, + cell: ({ row }) => , enableSorting: false, enableHiding: false, + size: 40, meta: { cellClassName: 'w-10', expandedContent, }, } - const tableColumns = [ + const tableColumns: DataGridColumnDef[] = [ ...(expandedContent ? [expandColumn] : []), ...(enableRowSelection ? [selectColumn] : []), ...columns, ] const lastColId = pinLastColumn ? tableColumns[tableColumns.length - 1]?.id ?? '' : '' + const pinLeft = pinLeftColumnIds ?? [] + const enablePinning = pinLastColumn || pinLeft.length > 0 + const columnPinning = { + start: pinLeft, + end: pinLastColumn && lastColId ? [lastColId] : [], + } - const showPagination = pagination ?? true - - const table = useReactTable({ + const table = useTable({ + features: dataGridFeatures, data, columns: tableColumns, state: { sorting, + pagination: paginationState, columnVisibility, expanded, + ...(enablePinning ? { columnPinning } : {}), ...(enableRowSelection ? { rowSelection } : {}), }, onSortingChange: setSorting, + onPaginationChange: setPaginationState, onExpandedChange: setExpanded, onColumnVisibilityChange: setColumnVisibility, onRowSelectionChange: enableRowSelection @@ -368,21 +382,11 @@ export function FrameDataGrid({ }) } : undefined, - getCoreRowModel: getCoreRowModel(), - getSortedRowModel: getSortedRowModel(), - getExpandedRowModel: expandedContent ? getExpandedRowModel() : undefined, - getPaginationRowModel: showPagination ? getPaginationRowModel() : undefined, - initialState: { - ...(showPagination ? { pagination: { pageIndex: 0, pageSize } } : {}), - ...(pinLastColumn && lastColId ? { columnPinning: { right: [lastColId] } } : {}), - }, - getRowId: rowId - ? (row, index) => rowId(row, index) - : undefined, + initialState: enablePinning ? { columnPinning } : undefined, + getRowId: rowId ? (row, index) => rowId(row, index) : undefined, getRowCanExpand: expandedContent ? (row) => (getRowCanExpand ? getRowCanExpand(row.original) : true) : undefined, - enableColumnPinning: pinLastColumn, enableRowSelection, enableHiding: enableColumnVisibility, }) @@ -438,6 +442,8 @@ export function FrameDataGrid({ footerContent={footerContent} showPagination={showPagination} enableColumnVisibility={enableColumnVisibility} + columnsPinnable={enablePinning} + horizontalScroll={horizontalScroll} /> ) @@ -452,9 +458,9 @@ export function FrameDataGrid({ } /** Хелпер для конвертации DataGridColumn → ColumnDef с DataGridColumnHeader. */ -export function columnDefFromDataGrid( +export function columnDefFromDataGrid( cols: DataGridColumn[], -): ColumnDef[] { +): DataGridColumnDef[] { return cols.map((c) => { const title = resolveHeaderTitle(c.header, c.headerTitle) const Icon = c.icon @@ -467,7 +473,7 @@ export function columnDefFromDataGrid( accessorFn: c.sortValue ? (row: T) => c.sortValue!(row) : (row: T) => (row as Record)[c.key] as string | number, - sortingFn: c.sortingFn ?? 'auto', + sortFn: c.sortingFn ?? 'auto', } : {}), header: Icon @@ -482,6 +488,10 @@ export function columnDefFromDataGrid( cell: ({ row }) => c.cell(row.original, row.index), enableSorting: sortable, enableHiding: c.enableHiding ?? true, + enablePinning: c.enablePinning, + size: c.size, + minSize: c.minSize, + maxSize: c.maxSize, meta: { headerTitle: title || undefined, cellClassName: c.className, diff --git a/apps/web/src/components/reui-kit/index.ts b/apps/web/src/components/reui-kit/index.ts index 2378f39..b7e2ebc 100644 --- a/apps/web/src/components/reui-kit/index.ts +++ b/apps/web/src/components/reui-kit/index.ts @@ -18,6 +18,7 @@ export { loadStoredColumnVisibility, dataGridColumnVisibilityOptions, type FrameDataGridProps, + type DataGridColumnDef, type DataGridColumnVisibilityOption, } from './frame-data-grid' export { ExpandableResourceGrid } from './expandable-resource-grid' diff --git a/apps/web/src/components/reui-kit/resource-page.tsx b/apps/web/src/components/reui-kit/resource-page.tsx index 03fb3e8..de56233 100644 --- a/apps/web/src/components/reui-kit/resource-page.tsx +++ b/apps/web/src/components/reui-kit/resource-page.tsx @@ -1,10 +1,6 @@ import { useCallback, useMemo, useState, type ReactNode } from 'react' import { - getCoreRowModel, - getPaginationRowModel, - getSortedRowModel, - useReactTable, - type ColumnDef, + useTable, type PaginationState, type RowSelectionState, type SortingState, @@ -13,7 +9,7 @@ import { CircleAlertIcon, FilterIcon, FilterXIcon } from 'lucide-react' import { CountedLineTabs } from '@/components/counted-line-tabs' import { Badge } from '@/components/reui/badge' -import { DataGrid } from '@/components/reui/data-grid/data-grid' +import { DataGrid, dataGridFeatures } from '@/components/reui/data-grid/data-grid' import { DataGridPagination } from '@/components/reui/data-grid/data-grid-pagination' import { DataGridScrollArea } from '@/components/reui/data-grid/data-grid-scroll-area' import { DataGridTable } from '@/components/reui/data-grid/data-grid-table' @@ -42,6 +38,7 @@ import { EmptyState } from '@/components/empty-state' import { applyFiltersToData } from './filter-utils' import { FrameDataGrid, + type DataGridColumnDef, type FrameDataGridProps, } from './frame-data-grid' @@ -84,7 +81,7 @@ export interface ResourcePageProps extends SimpleGridPassthrou onFiltersChange?: (filters: Filter[]) => void onClearFilters?: () => void getFilterFieldValue?: (item: T, field: string) => unknown - columns: ColumnDef[] + columns: DataGridColumnDef[] data: T[] getRowId: (row: T, index?: number) => string isLoading?: boolean @@ -323,7 +320,8 @@ function ResourcePageFiltered({ setRowSelection({}) }, []) - const table = useReactTable({ + const table = useTable({ + features: dataGridFeatures, data: filteredData, columns, getRowId: (row) => getRowId(row), @@ -332,9 +330,6 @@ function ResourcePageFiltered({ onSortingChange: setSorting, onRowSelectionChange: setRowSelection, onPaginationChange: setPagination, - getCoreRowModel: getCoreRowModel(), - getSortedRowModel: getSortedRowModel(), - getPaginationRowModel: getPaginationRowModel(), }) const handleTabChange = useCallback( @@ -399,7 +394,15 @@ function ResourcePageFiltered({ table={table} recordCount={filteredData.length} emptyMessage="Нет записей по выбранным фильтрам." - tableLayout={{ dense: true }} + tableLayout={{ + dense: true, + stripped: true, + rowBorder: true, + headerSticky: true, + headerBackground: true, + headerBorder: true, + width: 'auto', + }} > {!hideHeader ? ( @@ -495,7 +498,7 @@ function ResourcePageFiltered({ diff --git a/apps/web/src/components/reui/data-grid/data-grid-column-filter.tsx b/apps/web/src/components/reui/data-grid/data-grid-column-filter.tsx index 14af73d..c611bf2 100644 --- a/apps/web/src/components/reui/data-grid/data-grid-column-filter.tsx +++ b/apps/web/src/components/reui/data-grid/data-grid-column-filter.tsx @@ -1,6 +1,9 @@ +"use client" + import { useMemo, useState } from "react" import { Badge } from "@/components/reui/badge" -import { type Column } from "@tanstack/react-table" +import type { DataGridFeatures } from "@/components/reui/data-grid/data-grid" +import type { Column } from "@tanstack/react-table" import { cn } from "@cfdm/ui/lib/utils" import { Button } from "@cfdm/ui/components/button" @@ -11,10 +14,10 @@ import { PopoverTrigger, } from "@cfdm/ui/components/popover" import { Separator } from "@cfdm/ui/components/separator" -import { CirclePlusIcon, CheckIcon } from "lucide-react" +import { CheckIcon, CirclePlusIcon } from "lucide-react" -interface DataGridColumnFilterProps { - column?: Column +interface DataGridColumnFilterProps { + column?: Column title?: string options: { label: string @@ -23,13 +26,16 @@ interface DataGridColumnFilterProps { }[] } -function DataGridColumnFilter({ +function DataGridColumnFilter({ column, title, options, }: DataGridColumnFilterProps) { const facets = column?.getFacetedUniqueValues() - const selectedValues = new Set(column?.getFilterValue() as string[]) + const filterValue = column?.getFilterValue() + const selectedValues = new Set( + Array.isArray(filterValue) ? (filterValue as string[]) : [] + ) const [searchQuery, setSearchQuery] = useState("") const filteredOptions = useMemo(() => { @@ -51,16 +57,13 @@ function DataGridColumnFilter({ {selectedValues.size}
{selectedValues.size > 2 ? ( - + {selectedValues.size} selected ) : ( @@ -70,7 +73,7 @@ function DataGridColumnFilter({ {option.label} @@ -100,28 +103,39 @@ function DataGridColumnFilter({
{filteredOptions.map((option) => { const isSelected = selectedValues.has(option.value) + const facetCount = facets?.get(option.value) + const toggleOption = () => { + if (isSelected) { + selectedValues.delete(option.value) + } else { + selectedValues.add(option.value) + } + const filterValues = Array.from(selectedValues) + column?.setFilterValue( + filterValues.length ? filterValues : undefined + ) + } return (
{ - if (isSelected) { - selectedValues.delete(option.value) - } else { - selectedValues.add(option.value) + role="button" + tabIndex={0} + aria-pressed={isSelected} + onClick={toggleOption} + onKeyDown={(e) => { + if (e.key === "Enter" || e.key === " ") { + e.preventDefault() + toggleOption() } - const filterValues = Array.from(selectedValues) - column?.setFilterValue( - filterValues.length ? filterValues : undefined - ) }} className={cn( - "relative flex cursor-default items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-hidden select-none", + "rounded-md relative flex cursor-pointer items-center gap-2 px-2 py-1.5 text-sm outline-hidden select-none", "hover:bg-accent hover:text-accent-foreground focus:bg-accent focus:text-accent-foreground" )} >
({
{option.icon && ( - + )} {option.label} - {facets?.get(option.value) && ( + {facetCount !== undefined && ( - {facets.get(option.value)} + {facetCount} )}
@@ -148,8 +162,16 @@ function DataGridColumnFilter({
column?.setFilterValue(undefined)} - className="hover:bg-accent hover:text-accent-foreground focus:bg-accent focus:text-accent-foreground relative flex cursor-default items-center justify-center rounded-sm px-2 py-1.5 text-sm outline-hidden select-none" + onKeyDown={(e) => { + if (e.key === "Enter" || e.key === " ") { + e.preventDefault() + column?.setFilterValue(undefined) + } + }} + className="hover:bg-accent hover:text-accent-foreground focus:bg-accent focus:text-accent-foreground rounded-md relative flex cursor-pointer items-center justify-center px-2 py-1.5 text-sm outline-hidden select-none" > Clear filters
diff --git a/apps/web/src/components/reui/data-grid/data-grid-column-header.tsx b/apps/web/src/components/reui/data-grid/data-grid-column-header.tsx index fc30804..a45a4c8 100644 --- a/apps/web/src/components/reui/data-grid/data-grid-column-header.tsx +++ b/apps/web/src/components/reui/data-grid/data-grid-column-header.tsx @@ -1,11 +1,14 @@ "use client" -import { type HTMLAttributes, memo, type ReactNode, useMemo } from "react" +import { memo, useMemo } from "react" +import type { HTMLAttributes, ReactNode } from "react" import { getColumnHeaderLabel, useDataGrid, } from "@/components/reui/data-grid/data-grid" -import { type Column } from "@tanstack/react-table" +import type { DataGridFeatures } from "@/components/reui/data-grid/data-grid" +import { Subscribe } from "@tanstack/react-table" +import type { Column } from "@tanstack/react-table" import { cn } from "@cfdm/ui/lib/utils" import { Button } from "@cfdm/ui/components/button" @@ -22,22 +25,23 @@ import { DropdownMenuSubTrigger, DropdownMenuTrigger, } from "@cfdm/ui/components/dropdown-menu" -import { ArrowDownIcon, ArrowUpIcon, ChevronsUpDownIcon, CheckIcon, ArrowLeftToLineIcon, ArrowRightToLineIcon, ArrowLeftIcon, ArrowRightIcon, Settings2Icon, PinOffIcon } from "lucide-react" +import { ArrowDownIcon, ArrowLeftIcon, ArrowLeftToLineIcon, ArrowRightIcon, ArrowRightToLineIcon, ArrowUpIcon, CheckIcon, ChevronsUpDownIcon, PinOffIcon, Settings2Icon } from "lucide-react" interface DataGridColumnHeaderProps< - TData, + TData extends object, TValue, > extends HTMLAttributes { - column: Column + column: Column /** When omitted, uses `column.columnDef.meta.headerTitle`, then a string `columnDef.header`, then `column.id`. */ title?: string icon?: ReactNode + /** Reserved; pin controls are gated by tableLayout.columnsPinnable + column.getCanPin(). */ pinnable?: boolean filter?: ReactNode visibility?: boolean } -function DataGridColumnHeaderInner({ +function DataGridColumnHeaderInner({ column, title, icon, @@ -45,11 +49,20 @@ function DataGridColumnHeaderInner({ filter, visibility = false, }: DataGridColumnHeaderProps) { - const { isLoading, table, props, recordCount } = useDataGrid() + const { isLoading, table, props } = useDataGrid() const resolvedTitle = title ?? getColumnHeaderLabel(column) - const columnOrder = table.getState().columnOrder - const columnVisibilityKey = JSON.stringify(table.getState().columnVisibility) + // TanStack's columnOrder defaults to [] until a consumer seeds it; fall + // back to the definition order so Move Left/Right work out of the box. + const columnOrderState = table.state.columnOrder + const columnOrder = + columnOrderState.length > 0 + ? columnOrderState + : table.getAllLeafColumns().map((leafColumn) => leafColumn.id) + const columnVisibilityKey = + props.tableLayout?.columnsVisibility && visibility + ? JSON.stringify(table.state.columnVisibility) + : "" const isSorted = column.getIsSorted() const isPinned = column.getIsPinned() const canSort = column.getCanSort() @@ -76,18 +89,18 @@ function DataGridColumnHeaderInner({ ) const headerButtonClassName = cn( - "text-secondary-foreground/80 hover:bg-secondary data-[state=open]:bg-secondary hover:text-foreground data-[state=open]:text-foreground -ms-2 px-2 font-normal h-6 rounded-lg", + "text-secondary-foreground/80 hover:bg-secondary data-[state=open]:bg-secondary hover:text-foreground data-[state=open]:text-foreground px-2 font-normal h-6 rounded-lg", className ) const sortIcon = canSort && (isSorted === "desc" ? ( - +