quality / commitlint (push) Skipped
quality / changes (push) Successful in 6s
quality / go (push) Skipped
quality / bird2 (push) Skipped
quality / docker-check (push) Skipped
quality / openapi (push) Successful in 23s
quality / web (push) Successful in 1m4s
CD / quality (push) Successful in 1m36s
CD / publish (push) Successful in 2m55s
- Introduced `KIT_ACTION_COLUMN_SIZE` for consistent action column sizing across data grids. - Updated `applyKitActionColumn` to enforce action column properties such as fixed size, no sorting, and no resizing. - Enhanced `kitColumnPinning` logic to conditionally enable pinning based on horizontal scrolling. - Added tests for `applyKitActionColumn` and `kitColumnPinning` to ensure expected behavior. - Adjusted `AccessApiKeysGrid`, `ScheduleModulesGrid`, and `ResourcePageFiltered` components to utilize new action column features.
542 lines
16 KiB
TypeScript
542 lines
16 KiB
TypeScript
import {
|
|
cloneElement,
|
|
isValidElement,
|
|
useEffect,
|
|
useState,
|
|
type ReactElement,
|
|
type ReactNode,
|
|
} from 'react'
|
|
import {
|
|
useTable,
|
|
type ColumnDef,
|
|
type ColumnVisibilityState,
|
|
type ExpandedState,
|
|
type OnChangeFn,
|
|
type PaginationState,
|
|
type RowSelectionState,
|
|
type SortingState,
|
|
} from '@tanstack/react-table'
|
|
import { Columns3Icon } from 'lucide-react'
|
|
|
|
import { Button } from '@evobgp/ui/components/button'
|
|
import { Separator } from '@evobgp/ui/components/separator'
|
|
import { cn } from '@evobgp/ui/lib/utils'
|
|
import {
|
|
DataGrid,
|
|
dataGridFeatures,
|
|
type DataGridFeatures,
|
|
type DataGridTableInstance,
|
|
} from '@/components/reui/data-grid/data-grid'
|
|
import { DataGridColumnHeader } from '@/components/reui/data-grid/data-grid-column-header'
|
|
import { DataGridColumnVisibility } from '@/components/reui/data-grid/data-grid-column-visibility'
|
|
import { DataGridPagination } from '@/components/reui/data-grid/data-grid-pagination'
|
|
import { DataGridScrollArea } from '@/components/reui/data-grid/data-grid-scroll-area'
|
|
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 {
|
|
Frame,
|
|
FrameDescription,
|
|
FrameFooter,
|
|
FrameHeader,
|
|
FramePanel,
|
|
FrameTitle,
|
|
} from '@/components/reui/frame'
|
|
import { EmptyState } from '@/components/empty-state'
|
|
import { DATA_GRID_PAGINATION_RU } from '@/lib/data-grid-defaults'
|
|
|
|
export type DataGridColumnDef<TData extends object> = ColumnDef<DataGridFeatures, TData>
|
|
|
|
/**
|
|
* Единый tableLayout для всех ops-гридов.
|
|
* Visual SoT: установленный data-grid-filtering-2 (`dense: true`, без zebra).
|
|
* Preview: https://reui.io/preview/base/data-grid-filtering-2
|
|
* Docs: https://reui.io/docs/components/base/data-grid
|
|
*/
|
|
export function kitDataGridTableLayout(
|
|
opts: {
|
|
dense?: boolean
|
|
width?: 'fixed' | 'auto'
|
|
columnsPinnable?: boolean
|
|
columnsVisibility?: boolean
|
|
} = {},
|
|
) {
|
|
return {
|
|
dense: opts.dense ?? true,
|
|
rowBorder: true,
|
|
headerSticky: false,
|
|
headerBackground: false,
|
|
headerBorder: true,
|
|
width: opts.width ?? ('fixed' as const),
|
|
columnsVisibility: opts.columnsVisibility ?? false,
|
|
columnsResizable: false,
|
|
columnsPinnable: opts.columnsPinnable ?? false,
|
|
columnsMovable: false,
|
|
rowsDraggable: false,
|
|
rowsPinnable: false,
|
|
}
|
|
}
|
|
|
|
/** filtering-2 edge inset + Frame-surface pinned cells (not page --background). */
|
|
export const kitDataGridTableClassNames = {
|
|
base: '[&_[data-pinned]]:bg-(--frame-panel-bg)',
|
|
edgeCell: 'first:ps-3 last:pe-3',
|
|
} as const
|
|
|
|
/**
|
|
* Compact action column size from data-grid-filtering-2.
|
|
* Preview: https://reui.io/preview/base/data-grid-filtering-2
|
|
*/
|
|
export const KIT_ACTION_COLUMN_SIZE = 56
|
|
|
|
const ACTION_CELL_ALIGN = 'flex items-center justify-end'
|
|
|
|
function lastColumnId<T extends object>(columns: DataGridColumnDef<T>[]): string {
|
|
const last = columns[columns.length - 1]
|
|
if (!last) return ''
|
|
if (last.id) return last.id
|
|
if ('accessorKey' in last && typeof last.accessorKey === 'string') return last.accessorKey
|
|
return ''
|
|
}
|
|
|
|
function wrapActionCell<T extends object>(
|
|
cell: DataGridColumnDef<T>['cell'],
|
|
): DataGridColumnDef<T>['cell'] {
|
|
if (typeof cell !== 'function') {
|
|
return () => <div className={ACTION_CELL_ALIGN}>{cell as ReactNode}</div>
|
|
}
|
|
return (ctx) => <div className={ACTION_CELL_ALIGN}>{cell(ctx)}</div>
|
|
}
|
|
|
|
/**
|
|
* filtering-2 action column DNA: locked width, no sort/resize, inner justify-end
|
|
* (flex on the cell wrapper, never on `td` — that breaks rowBorder alignment).
|
|
* Preview: https://reui.io/preview/base/data-grid-filtering-2
|
|
*/
|
|
export function applyKitActionColumn<T extends object>(
|
|
columns: DataGridColumnDef<T>[],
|
|
opts: { pinLastColumn?: boolean } = {},
|
|
): DataGridColumnDef<T>[] {
|
|
if (columns.length === 0) return columns
|
|
const last = columns[columns.length - 1]
|
|
const lastId = lastColumnId(columns)
|
|
if (lastId !== 'actions' && !opts.pinLastColumn) return columns
|
|
|
|
const size = last.size ?? KIT_ACTION_COLUMN_SIZE
|
|
return [
|
|
...columns.slice(0, -1),
|
|
{
|
|
...last,
|
|
size,
|
|
minSize: last.minSize ?? size,
|
|
maxSize: last.maxSize ?? size,
|
|
enableSorting: false,
|
|
enableResizing: false,
|
|
cell: last.cell ? wrapActionCell(last.cell) : last.cell,
|
|
},
|
|
]
|
|
}
|
|
|
|
/** End-pin only when the grid actually scrolls horizontally (not for action columns). */
|
|
export function kitColumnPinning(opts: {
|
|
pinLastColumn?: boolean
|
|
horizontalScroll?: boolean
|
|
lastColId: string
|
|
pinLeftColumnIds?: string[]
|
|
}): {
|
|
enablePinning: boolean
|
|
columnPinning: { start: string[]; end: string[] }
|
|
} {
|
|
const pinLeft = opts.pinLeftColumnIds ?? []
|
|
const pinEnd = Boolean(opts.pinLastColumn && opts.horizontalScroll && opts.lastColId)
|
|
return {
|
|
enablePinning: pinEnd || pinLeft.length > 0,
|
|
columnPinning: {
|
|
start: pinLeft,
|
|
end: pinEnd ? [opts.lastColId] : [],
|
|
},
|
|
}
|
|
}
|
|
|
|
function loadStoredColumnVisibility(key: string): ColumnVisibilityState | undefined {
|
|
try {
|
|
const raw = localStorage.getItem(key)
|
|
if (!raw) return undefined
|
|
return JSON.parse(raw) as ColumnVisibilityState
|
|
} catch {
|
|
return undefined
|
|
}
|
|
}
|
|
|
|
export { loadStoredColumnVisibility }
|
|
|
|
function applyColumnPinControls<T extends object>(
|
|
columns: DataGridColumnDef<T>[],
|
|
columnPinControls: boolean,
|
|
): DataGridColumnDef<T>[] {
|
|
return columns.map((col) => {
|
|
const origHeader = col.header
|
|
if (typeof origHeader !== 'function') return col
|
|
return {
|
|
...col,
|
|
header: (ctx) => {
|
|
const node = origHeader(ctx)
|
|
if (isValidElement(node) && node.type === DataGridColumnHeader) {
|
|
return cloneElement(node as ReactElement<{ pinnable?: boolean }>, {
|
|
pinnable: columnPinControls,
|
|
})
|
|
}
|
|
return node
|
|
},
|
|
} as DataGridColumnDef<T>
|
|
})
|
|
}
|
|
|
|
export interface FrameDataGridProps<TData extends object> {
|
|
title?: ReactNode
|
|
description?: ReactNode
|
|
actions?: ReactNode
|
|
columns: DataGridColumnDef<TData>[]
|
|
data: TData[]
|
|
rowId?: (row: TData, index: number) => string
|
|
emptyTitle?: string
|
|
emptyDescription?: string
|
|
emptyAction?: ReactNode
|
|
onRowClick?: (row: TData) => void
|
|
pagination?: boolean
|
|
pageSize?: number
|
|
footerContent?: ReactNode
|
|
dense?: boolean
|
|
pinLastColumn?: boolean
|
|
initialSorting?: SortingState
|
|
virtualization?: boolean
|
|
height?: number
|
|
enableRowSelection?: boolean
|
|
onRowSelectionChange?: (selectedIds: string[]) => void
|
|
enableColumnVisibility?: boolean
|
|
columnVisibility?: ColumnVisibilityState
|
|
onColumnVisibilityChange?: OnChangeFn<ColumnVisibilityState>
|
|
columnVisibilityTrigger?: boolean
|
|
columnVisibilityStorageKey?: string
|
|
initialColumnVisibility?: ColumnVisibilityState
|
|
className?: string
|
|
expandedContent?: (row: TData) => ReactNode
|
|
getRowCanExpand?: (row: TData) => boolean
|
|
pinLeftColumnIds?: string[]
|
|
horizontalScroll?: boolean
|
|
tableWidth?: 'fixed' | 'auto'
|
|
columnPinControls?: boolean
|
|
isLoading?: boolean
|
|
}
|
|
|
|
function DataGridSectionHeader({
|
|
title,
|
|
description,
|
|
actions,
|
|
}: {
|
|
title?: ReactNode
|
|
description?: ReactNode
|
|
actions?: ReactNode
|
|
}) {
|
|
if (!title && !description && !actions) return null
|
|
|
|
return (
|
|
<FrameHeader className="flex-row items-start justify-between gap-3">
|
|
<div className="flex min-w-0 flex-col gap-px">
|
|
{title ? <FrameTitle className="text-balance">{title}</FrameTitle> : null}
|
|
{description ? (
|
|
<FrameDescription className="text-xs text-pretty">{description}</FrameDescription>
|
|
) : null}
|
|
</div>
|
|
{actions ? (
|
|
<div className="flex shrink-0 flex-wrap items-center justify-end gap-2">{actions}</div>
|
|
) : null}
|
|
</FrameHeader>
|
|
)
|
|
}
|
|
|
|
function FrameDataGridBody<TData extends object>({
|
|
table,
|
|
data,
|
|
emptyTitle,
|
|
onRowClick,
|
|
dense,
|
|
virtualization,
|
|
height,
|
|
footerContent,
|
|
showPagination,
|
|
enableColumnVisibility,
|
|
columnsPinnable,
|
|
tableWidth,
|
|
}: {
|
|
table: DataGridTableInstance<TData>
|
|
data: TData[]
|
|
emptyTitle: string
|
|
onRowClick?: (row: TData) => void
|
|
dense: boolean
|
|
virtualization: boolean
|
|
height: number
|
|
footerContent?: ReactNode
|
|
showPagination: boolean
|
|
enableColumnVisibility: boolean
|
|
columnsPinnable: boolean
|
|
tableWidth: 'fixed' | 'auto'
|
|
}) {
|
|
const tableNode = virtualization ? (
|
|
<DataGridTableVirtual height={height} footerContent={footerContent} />
|
|
) : (
|
|
<DataGridTable footerContent={footerContent} />
|
|
)
|
|
|
|
return (
|
|
<DataGrid
|
|
table={table}
|
|
recordCount={data.length}
|
|
onRowClick={onRowClick}
|
|
emptyMessage={emptyTitle}
|
|
tableLayout={kitDataGridTableLayout({
|
|
dense,
|
|
width: tableWidth,
|
|
columnsPinnable,
|
|
columnsVisibility: enableColumnVisibility,
|
|
})}
|
|
tableClassNames={kitDataGridTableClassNames}
|
|
>
|
|
{virtualization ? (
|
|
<DataGridScrollArea orientation="vertical" style={{ height }}>
|
|
{tableNode}
|
|
</DataGridScrollArea>
|
|
) : (
|
|
<DataGridScrollArea>{tableNode}</DataGridScrollArea>
|
|
)}
|
|
{showPagination ? (
|
|
<>
|
|
<Separator />
|
|
<FrameFooter>
|
|
<DataGridPagination {...DATA_GRID_PAGINATION_RU} />
|
|
</FrameFooter>
|
|
</>
|
|
) : null}
|
|
</DataGrid>
|
|
)
|
|
}
|
|
|
|
export function FrameDataGrid<TData extends object>({
|
|
title,
|
|
description,
|
|
actions,
|
|
columns,
|
|
data,
|
|
rowId,
|
|
emptyTitle = 'Нет записей',
|
|
emptyDescription,
|
|
emptyAction,
|
|
onRowClick,
|
|
pagination,
|
|
pageSize = 10,
|
|
footerContent,
|
|
dense = true,
|
|
pinLastColumn = false,
|
|
initialSorting,
|
|
virtualization = false,
|
|
height = 480,
|
|
enableRowSelection = false,
|
|
onRowSelectionChange,
|
|
enableColumnVisibility = false,
|
|
columnVisibility: columnVisibilityProp,
|
|
onColumnVisibilityChange,
|
|
columnVisibilityTrigger,
|
|
columnVisibilityStorageKey,
|
|
initialColumnVisibility,
|
|
className,
|
|
expandedContent,
|
|
getRowCanExpand,
|
|
pinLeftColumnIds,
|
|
horizontalScroll = false,
|
|
tableWidth = 'fixed',
|
|
columnPinControls = false,
|
|
}: FrameDataGridProps<TData>) {
|
|
const showPagination = pagination ?? true
|
|
const [sorting, setSorting] = useState<SortingState>(initialSorting ?? [])
|
|
const [rowSelection, setRowSelection] = useState<RowSelectionState>({})
|
|
const [expanded, setExpanded] = useState<ExpandedState>({})
|
|
const [paginationState, setPaginationState] = useState<PaginationState>({
|
|
pageIndex: 0,
|
|
pageSize: showPagination ? pageSize : Number.POSITIVE_INFINITY,
|
|
})
|
|
const [internalColumnVisibility, setInternalColumnVisibility] = useState<ColumnVisibilityState>(
|
|
() => {
|
|
const stored = columnVisibilityStorageKey
|
|
? loadStoredColumnVisibility(columnVisibilityStorageKey)
|
|
: undefined
|
|
return { ...initialColumnVisibility, ...stored }
|
|
},
|
|
)
|
|
|
|
const isColumnVisibilityControlled = columnVisibilityProp !== undefined
|
|
const columnVisibility = isColumnVisibilityControlled
|
|
? columnVisibilityProp
|
|
: internalColumnVisibility
|
|
const setColumnVisibility: OnChangeFn<ColumnVisibilityState> = 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: DataGridColumnDef<TData> = {
|
|
id: 'select',
|
|
header: () => <DataGridTableRowSelectAll />,
|
|
cell: ({ row }) => <DataGridTableRowSelect row={row} />,
|
|
enableSorting: false,
|
|
enableHiding: false,
|
|
size: 40,
|
|
meta: { cellClassName: 'w-10' },
|
|
}
|
|
|
|
const expandColumn: DataGridColumnDef<TData> = {
|
|
id: 'expand',
|
|
header: () => null,
|
|
cell: ({ row }) => <DataGridTableRowExpand row={row} />,
|
|
enableSorting: false,
|
|
enableHiding: false,
|
|
size: 40,
|
|
meta: {
|
|
cellClassName: 'w-10',
|
|
expandedContent,
|
|
},
|
|
}
|
|
|
|
const tableColumns: DataGridColumnDef<TData>[] = applyColumnPinControls(
|
|
applyKitActionColumn(
|
|
[
|
|
...(expandedContent ? [expandColumn] : []),
|
|
...(enableRowSelection ? [selectColumn] : []),
|
|
...columns,
|
|
],
|
|
{ pinLastColumn },
|
|
),
|
|
columnPinControls,
|
|
)
|
|
|
|
const { enablePinning, columnPinning } = kitColumnPinning({
|
|
pinLastColumn,
|
|
horizontalScroll,
|
|
lastColId: lastColumnId(tableColumns),
|
|
pinLeftColumnIds,
|
|
})
|
|
|
|
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
|
|
? (updater) => {
|
|
setRowSelection((prev) => {
|
|
const next = typeof updater === 'function' ? updater(prev) : updater
|
|
if (onRowSelectionChange && rowId) {
|
|
const ids = Object.keys(next).filter((k) => next[k])
|
|
onRowSelectionChange(ids)
|
|
}
|
|
return next
|
|
})
|
|
}
|
|
: undefined,
|
|
initialState: enablePinning ? { columnPinning } : undefined,
|
|
getRowId: rowId ? (row, index) => rowId(row, index) : undefined,
|
|
getRowCanExpand: expandedContent
|
|
? (row) => (getRowCanExpand ? getRowCanExpand(row.original) : true)
|
|
: undefined,
|
|
enableRowSelection,
|
|
enableHiding: enableColumnVisibility,
|
|
})
|
|
|
|
const showColumnVisibilityTrigger =
|
|
enableColumnVisibility && (columnVisibilityTrigger ?? true)
|
|
|
|
const columnVisibilityAction = showColumnVisibilityTrigger ? (
|
|
<DataGridColumnVisibility
|
|
table={table}
|
|
trigger={
|
|
<Button variant="ghost" size="sm">
|
|
<Columns3Icon data-icon="inline-start" />
|
|
Колонки
|
|
</Button>
|
|
}
|
|
/>
|
|
) : null
|
|
|
|
const headerActions = actions ? (
|
|
<div className="flex items-center gap-2">
|
|
{columnVisibilityAction}
|
|
{actions}
|
|
</div>
|
|
) : (
|
|
columnVisibilityAction
|
|
)
|
|
|
|
const hasHeader = Boolean(title || description || actions || showColumnVisibilityTrigger)
|
|
|
|
if (data.length === 0) {
|
|
return (
|
|
<Frame dense variant="default" spacing="sm" className={cn('w-full', className)}>
|
|
{hasHeader ? (
|
|
<DataGridSectionHeader title={title} description={description} actions={headerActions} />
|
|
) : null}
|
|
<FramePanel className="flex min-h-72 w-full flex-col items-center justify-center">
|
|
<EmptyState title={emptyTitle} description={emptyDescription} action={emptyAction} />
|
|
</FramePanel>
|
|
</Frame>
|
|
)
|
|
}
|
|
|
|
return (
|
|
<Frame dense variant="default" spacing="sm" className={cn('w-full', className)}>
|
|
{hasHeader ? (
|
|
<DataGridSectionHeader title={title} description={description} actions={headerActions} />
|
|
) : null}
|
|
<FramePanel className="p-0 shadow-none!">
|
|
<FrameDataGridBody
|
|
table={table}
|
|
data={data}
|
|
emptyTitle={emptyTitle}
|
|
onRowClick={onRowClick}
|
|
dense={dense}
|
|
virtualization={virtualization}
|
|
height={height}
|
|
footerContent={footerContent}
|
|
showPagination={showPagination}
|
|
enableColumnVisibility={enableColumnVisibility}
|
|
columnsPinnable={enablePinning}
|
|
tableWidth={tableWidth}
|
|
/>
|
|
</FramePanel>
|
|
</Frame>
|
|
)
|
|
}
|