CI / changes (push) Successful in 10s
CI / commitlint (push) Has been skipped
CI / openapi (push) Has been skipped
CI / web (push) Successful in 52s
CI / go (push) Has been skipped
CI / bird2 (push) Has been skipped
CI / release (push) Successful in 4m7s
Refactored multiple dashboard components to utilize the new PanelCard for better organization and presentation. Updated the DashboardFramePanel, DashboardQuickLinks, and DashboardOperationsBreakdown components to streamline layouts and enhance user experience. Removed deprecated components and improved loading states in various sections, ensuring a cohesive interface throughout the application.
599 lines
17 KiB
TypeScript
599 lines
17 KiB
TypeScript
"use client"
|
|
|
|
import {
|
|
CSSProperties,
|
|
memo,
|
|
ReactNode,
|
|
useCallback,
|
|
useEffect,
|
|
useState,
|
|
} from "react"
|
|
import { useDataGrid } from "@/components/reui/data-grid/data-grid"
|
|
import {
|
|
DataGridTableBase,
|
|
DataGridTableBody,
|
|
DataGridTableEmpty,
|
|
DataGridTableFillBodyCell,
|
|
DataGridTableFillHeadCell,
|
|
DataGridTableFoot,
|
|
DataGridTableHead,
|
|
DataGridTableHeadRow,
|
|
DataGridTableHeadRowCell,
|
|
DataGridTableHeadRowCellResize,
|
|
DataGridTableRenderedRow,
|
|
DataGridTableRowSpacer,
|
|
DataGridTableViewport,
|
|
getDataGridTableMergedHeaderGroups,
|
|
getDataGridTableRowSections,
|
|
getPinningStyles,
|
|
hasDataGridTableRightPinnedColumns,
|
|
} from "@/components/reui/data-grid/data-grid-table"
|
|
import { Column, flexRender, Row, Table } from "@tanstack/react-table"
|
|
import {
|
|
useVirtualizer,
|
|
VirtualItem,
|
|
Virtualizer,
|
|
VirtualizerOptions,
|
|
} from "@tanstack/react-virtual"
|
|
|
|
import { cn } from "@evobgp/ui/lib/utils"
|
|
import { Spinner } from "@evobgp/ui/components/spinner"
|
|
|
|
type DataGridTableVirtualScrollElements = {
|
|
containerElement: HTMLDivElement | null
|
|
scrollElement: HTMLElement | null
|
|
}
|
|
|
|
type DataGridTableVirtualizerInstance = Virtualizer<
|
|
HTMLElement,
|
|
HTMLTableRowElement
|
|
>
|
|
|
|
type DataGridTableVirtualizerOptions<TData> = Omit<
|
|
VirtualizerOptions<HTMLElement, HTMLTableRowElement>,
|
|
"count" | "estimateSize" | "getItemKey" | "getScrollElement"
|
|
> & {
|
|
estimateSize?: (index: number, row: Row<TData>) => number
|
|
getItemKey?: (index: number, row: Row<TData>) => string | number
|
|
getScrollElement?: (
|
|
elements: DataGridTableVirtualScrollElements
|
|
) => HTMLElement | null
|
|
}
|
|
|
|
interface DataGridTableVirtualProps<TData> {
|
|
height?: number | string
|
|
estimateSize?: number
|
|
overscan?: number
|
|
footerContent?: ReactNode
|
|
renderHeader?: boolean
|
|
onFetchMore?: () => void
|
|
isFetchingMore?: boolean
|
|
hasMore?: boolean
|
|
fetchMoreOffset?: number
|
|
virtualizerOptions?: DataGridTableVirtualizerOptions<TData>
|
|
}
|
|
|
|
interface VirtualBodyProps<TData> {
|
|
table: Table<TData>
|
|
topRows: Row<TData>[]
|
|
centerRows: Row<TData>[]
|
|
bottomRows: Row<TData>[]
|
|
virtualItems: VirtualItem[]
|
|
totalSize: number
|
|
isVirtualizationEnabled: boolean
|
|
isInfiniteMode: boolean
|
|
isFetchingMore: boolean
|
|
hasMore?: boolean
|
|
loadingMoreMessage: ReactNode
|
|
allRowsLoadedMessage: ReactNode
|
|
measureRowRef?: (element: HTMLTableRowElement | null) => void
|
|
}
|
|
|
|
function DataGridTableVirtualPinnedPlaceholderCell<TData>({
|
|
column,
|
|
}: {
|
|
column: Column<TData>
|
|
}) {
|
|
const { props } = useDataGrid()
|
|
const isPinned = column.getIsPinned()
|
|
const isLastLeftPinned = isPinned === "left" && column.getIsLastColumn("left")
|
|
const isFirstRightPinned =
|
|
isPinned === "right" && column.getIsFirstColumn("right")
|
|
|
|
return (
|
|
<td
|
|
aria-hidden="true"
|
|
style={{
|
|
...(props.tableLayout?.columnsPinnable &&
|
|
column.getCanPin() &&
|
|
getPinningStyles(column)),
|
|
...(props.tableLayout?.columnsResizable && {
|
|
width: `calc(var(--col-${column.id}-size) * 1px)`,
|
|
}),
|
|
}}
|
|
data-pinned={isPinned || undefined}
|
|
data-last-col={
|
|
isLastLeftPinned ? "left" : isFirstRightPinned ? "right" : undefined
|
|
}
|
|
className={cn(
|
|
"p-0",
|
|
props.tableLayout?.cellBorder && "border-e",
|
|
props.tableLayout?.columnsPinnable &&
|
|
column.getCanPin() &&
|
|
"data-pinned:bg-background data-pinned:isolate [&[data-pinned=left][data-last-col=left]]:shadow-[inset_-1px_0_0_0_var(--border)] [&[data-pinned=right][data-last-col=right]]:shadow-[inset_1px_0_0_0_var(--border)]"
|
|
)}
|
|
/>
|
|
)
|
|
}
|
|
|
|
function DataGridTableVirtualUtilityRow<TData>({
|
|
table,
|
|
children,
|
|
centerCellClassName,
|
|
centerCellStyle,
|
|
rowClassName,
|
|
ariaHidden,
|
|
}: {
|
|
table: Table<TData>
|
|
children: ReactNode
|
|
centerCellClassName?: string
|
|
centerCellStyle?: CSSProperties
|
|
rowClassName?: string
|
|
ariaHidden?: boolean
|
|
}) {
|
|
const { props } = useDataGrid()
|
|
const leftVisibleColumns = table.getLeftVisibleLeafColumns()
|
|
const centerVisibleColumns = table.getCenterVisibleLeafColumns()
|
|
const rightVisibleColumns = table.getRightVisibleLeafColumns()
|
|
const hasRightPinnedColumns = hasDataGridTableRightPinnedColumns(table)
|
|
|
|
return (
|
|
<tr aria-hidden={ariaHidden || undefined} className={rowClassName}>
|
|
{leftVisibleColumns.map((column) => (
|
|
<DataGridTableVirtualPinnedPlaceholderCell
|
|
column={column}
|
|
key={column.id}
|
|
/>
|
|
))}
|
|
<td
|
|
colSpan={Math.max(centerVisibleColumns.length, 1)}
|
|
className={centerCellClassName}
|
|
style={centerCellStyle}
|
|
>
|
|
{children}
|
|
</td>
|
|
{props.tableLayout?.columnsResizable && hasRightPinnedColumns ? (
|
|
<DataGridTableFillBodyCell />
|
|
) : null}
|
|
{rightVisibleColumns.map((column) => (
|
|
<DataGridTableVirtualPinnedPlaceholderCell
|
|
column={column}
|
|
key={column.id}
|
|
/>
|
|
))}
|
|
{props.tableLayout?.columnsResizable && !hasRightPinnedColumns ? (
|
|
<DataGridTableFillBodyCell />
|
|
) : null}
|
|
</tr>
|
|
)
|
|
}
|
|
|
|
function DataGridTableVirtualSpacer<TData>({
|
|
table,
|
|
height,
|
|
}: {
|
|
table: Table<TData>
|
|
height: number
|
|
}) {
|
|
if (height <= 0) return null
|
|
|
|
return (
|
|
<DataGridTableVirtualUtilityRow
|
|
table={table}
|
|
ariaHidden
|
|
centerCellClassName="p-0"
|
|
centerCellStyle={{ height, padding: 0 }}
|
|
>
|
|
{null}
|
|
</DataGridTableVirtualUtilityRow>
|
|
)
|
|
}
|
|
|
|
function DataGridTableVirtualStatusRow<TData>({
|
|
table,
|
|
children,
|
|
className,
|
|
}: {
|
|
table: Table<TData>
|
|
children: ReactNode
|
|
className?: string
|
|
}) {
|
|
return (
|
|
<DataGridTableVirtualUtilityRow
|
|
table={table}
|
|
centerCellClassName={cn(
|
|
"text-muted-foreground py-4 text-center text-sm",
|
|
className
|
|
)}
|
|
>
|
|
{children}
|
|
</DataGridTableVirtualUtilityRow>
|
|
)
|
|
}
|
|
|
|
function DataGridTableVirtualBody<TData>({
|
|
table,
|
|
topRows,
|
|
centerRows,
|
|
bottomRows,
|
|
virtualItems,
|
|
totalSize,
|
|
isVirtualizationEnabled,
|
|
isInfiniteMode,
|
|
isFetchingMore,
|
|
hasMore,
|
|
loadingMoreMessage,
|
|
allRowsLoadedMessage,
|
|
measureRowRef,
|
|
}: VirtualBodyProps<TData>) {
|
|
const totalRows = topRows.length + centerRows.length + bottomRows.length
|
|
|
|
if (!totalRows) return <DataGridTableEmpty />
|
|
|
|
const hasCenterRows = centerRows.length > 0
|
|
const showFetchingRow = isInfiniteMode && isFetchingMore
|
|
const showCompleteRow = isInfiniteMode && hasMore === false && totalRows > 0
|
|
const hasMiddleSection = hasCenterRows || showFetchingRow || showCompleteRow
|
|
const leadingSpacerHeight =
|
|
isVirtualizationEnabled && hasCenterRows && virtualItems.length > 0
|
|
? (virtualItems[0]?.start ?? 0)
|
|
: 0
|
|
const trailingSpacerHeight =
|
|
isVirtualizationEnabled && hasCenterRows && virtualItems.length > 0
|
|
? Math.max(
|
|
0,
|
|
totalSize - (virtualItems[virtualItems.length - 1]?.end ?? 0)
|
|
)
|
|
: 0
|
|
|
|
const renderedRows: ReactNode[] = []
|
|
|
|
topRows.forEach((row, index) => {
|
|
renderedRows.push(
|
|
<DataGridTableRenderedRow
|
|
key={row.id}
|
|
row={row}
|
|
pinnedBoundary={
|
|
index === topRows.length - 1 && hasMiddleSection ? "top" : undefined
|
|
}
|
|
/>
|
|
)
|
|
})
|
|
|
|
if (isVirtualizationEnabled) {
|
|
if (leadingSpacerHeight > 0) {
|
|
renderedRows.push(
|
|
<DataGridTableVirtualSpacer
|
|
key="virtual-spacer-start"
|
|
table={table}
|
|
height={leadingSpacerHeight}
|
|
/>
|
|
)
|
|
}
|
|
|
|
virtualItems.forEach((virtualRow) => {
|
|
const row = centerRows[virtualRow.index]
|
|
|
|
if (!row) return
|
|
|
|
renderedRows.push(
|
|
<DataGridTableRenderedRow
|
|
key={row.id}
|
|
row={row}
|
|
rowRef={measureRowRef}
|
|
/>
|
|
)
|
|
})
|
|
|
|
if (trailingSpacerHeight > 0) {
|
|
renderedRows.push(
|
|
<DataGridTableVirtualSpacer
|
|
key="virtual-spacer-end"
|
|
table={table}
|
|
height={trailingSpacerHeight}
|
|
/>
|
|
)
|
|
}
|
|
} else {
|
|
centerRows.forEach((row) => {
|
|
renderedRows.push(<DataGridTableRenderedRow key={row.id} row={row} />)
|
|
})
|
|
}
|
|
|
|
if (showFetchingRow) {
|
|
renderedRows.push(
|
|
<DataGridTableVirtualStatusRow key="virtual-status-loading" table={table}>
|
|
<div className="flex items-center justify-center gap-2">
|
|
<Spinner className="size-4 opacity-60" />
|
|
{loadingMoreMessage}
|
|
</div>
|
|
</DataGridTableVirtualStatusRow>
|
|
)
|
|
}
|
|
|
|
if (showCompleteRow) {
|
|
renderedRows.push(
|
|
<DataGridTableVirtualStatusRow
|
|
key="virtual-status-complete"
|
|
table={table}
|
|
className="py-3 text-xs"
|
|
>
|
|
{allRowsLoadedMessage}
|
|
</DataGridTableVirtualStatusRow>
|
|
)
|
|
}
|
|
|
|
bottomRows.forEach((row, index) => {
|
|
renderedRows.push(
|
|
<DataGridTableRenderedRow
|
|
key={row.id}
|
|
row={row}
|
|
pinnedBoundary={
|
|
index === 0 && (topRows.length > 0 || hasMiddleSection)
|
|
? "bottom"
|
|
: undefined
|
|
}
|
|
/>
|
|
)
|
|
})
|
|
|
|
return <>{renderedRows}</>
|
|
}
|
|
|
|
/**
|
|
* Memoized virtual body: skip re-renders during active column resize.
|
|
* Column widths update via CSS variables on the <table> element,
|
|
* so the browser handles width changes without React re-renders.
|
|
*/
|
|
const MemoizedVirtualBody = memo(
|
|
DataGridTableVirtualBody,
|
|
(_prev, next) => !!next.table.getState().columnSizingInfo.isResizingColumn
|
|
) as typeof DataGridTableVirtualBody
|
|
|
|
function DataGridTableVirtual<TData>({
|
|
height,
|
|
estimateSize = 48,
|
|
overscan = 10,
|
|
footerContent,
|
|
renderHeader = true,
|
|
onFetchMore,
|
|
isFetchingMore = false,
|
|
hasMore,
|
|
fetchMoreOffset = 0,
|
|
virtualizerOptions,
|
|
}: DataGridTableVirtualProps<TData>) {
|
|
const { table, props } = useDataGrid()
|
|
const mergedHeaderGroups = getDataGridTableMergedHeaderGroups(table)
|
|
const hasRightPinnedColumns = hasDataGridTableRightPinnedColumns(table)
|
|
const { topRows, centerRows, bottomRows } = getDataGridTableRowSections(
|
|
table,
|
|
props.tableLayout?.rowsPinnable
|
|
)
|
|
const isInfiniteMode = typeof onFetchMore === "function"
|
|
const [viewportElements, setViewportElements] =
|
|
useState<DataGridTableVirtualScrollElements>({
|
|
containerElement: null,
|
|
scrollElement: null,
|
|
})
|
|
|
|
const {
|
|
estimateSize: customEstimateSize,
|
|
getItemKey: customGetItemKey,
|
|
getScrollElement: customGetScrollElement,
|
|
measureElement: customMeasureElement,
|
|
overscan: customOverscan,
|
|
...virtualizerOptionsRest
|
|
} = virtualizerOptions ?? {}
|
|
|
|
const isVirtualizationEnabled = virtualizerOptions?.enabled !== false
|
|
const loadingMoreMessage =
|
|
props.fetchingMoreMessage || props.loadingMessage || "Loading..."
|
|
const allRowsLoadedMessage =
|
|
props.allRowsLoadedMessage || "All records loaded"
|
|
|
|
const handleViewportRef = useCallback((node: HTMLDivElement | null) => {
|
|
setViewportElements({
|
|
containerElement: node,
|
|
scrollElement:
|
|
(node?.closest(
|
|
'[data-slot="scroll-area-viewport"]'
|
|
) as HTMLElement | null) ?? node,
|
|
})
|
|
}, [])
|
|
|
|
const usesExternalScrollArea =
|
|
viewportElements.scrollElement !== null &&
|
|
viewportElements.scrollElement !== viewportElements.containerElement
|
|
|
|
const resolveScrollElement = useCallback(() => {
|
|
if (customGetScrollElement) {
|
|
return customGetScrollElement(viewportElements)
|
|
}
|
|
|
|
return viewportElements.scrollElement
|
|
}, [customGetScrollElement, viewportElements])
|
|
|
|
const resolveItemKey = useCallback(
|
|
(index: number) => {
|
|
const row = centerRows[index]
|
|
|
|
if (!row) return index
|
|
|
|
return customGetItemKey?.(index, row) ?? row.id ?? index
|
|
},
|
|
[centerRows, customGetItemKey]
|
|
)
|
|
|
|
const resolveEstimateSize = useCallback(
|
|
(index: number) => {
|
|
const row = centerRows[index]
|
|
|
|
return row
|
|
? (customEstimateSize?.(index, row) ?? estimateSize)
|
|
: estimateSize
|
|
},
|
|
[centerRows, customEstimateSize, estimateSize]
|
|
)
|
|
|
|
const virtualizer = useVirtualizer({
|
|
count: centerRows.length,
|
|
getScrollElement: resolveScrollElement,
|
|
getItemKey: resolveItemKey,
|
|
estimateSize: resolveEstimateSize,
|
|
overscan: customOverscan ?? overscan,
|
|
measureElement: customMeasureElement,
|
|
...virtualizerOptionsRest,
|
|
}) as DataGridTableVirtualizerInstance
|
|
|
|
const virtualItems = isVirtualizationEnabled
|
|
? virtualizer.getVirtualItems()
|
|
: []
|
|
const totalSize = isVirtualizationEnabled ? virtualizer.getTotalSize() : 0
|
|
const measureRowRef =
|
|
isVirtualizationEnabled && customMeasureElement
|
|
? virtualizer.measureElement
|
|
: undefined
|
|
const resolvedFetchMoreOffset = Math.max(0, fetchMoreOffset)
|
|
|
|
useEffect(() => {
|
|
if (
|
|
!isVirtualizationEnabled ||
|
|
!isInfiniteMode ||
|
|
hasMore === false ||
|
|
isFetchingMore
|
|
) {
|
|
return
|
|
}
|
|
|
|
const lastItem = virtualItems[virtualItems.length - 1]
|
|
if (!lastItem) return
|
|
|
|
if (lastItem.index >= centerRows.length - 1 - resolvedFetchMoreOffset) {
|
|
onFetchMore?.()
|
|
}
|
|
}, [
|
|
centerRows.length,
|
|
hasMore,
|
|
isFetchingMore,
|
|
isInfiniteMode,
|
|
isVirtualizationEnabled,
|
|
onFetchMore,
|
|
resolvedFetchMoreOffset,
|
|
virtualItems,
|
|
])
|
|
|
|
return (
|
|
<DataGridTableViewport
|
|
viewportRef={handleViewportRef}
|
|
className={!usesExternalScrollArea ? "block" : undefined}
|
|
style={
|
|
usesExternalScrollArea
|
|
? undefined
|
|
: { height, overflow: "auto", position: "relative" }
|
|
}
|
|
>
|
|
<DataGridTableBase>
|
|
{renderHeader && (
|
|
<DataGridTableHead>
|
|
{mergedHeaderGroups.map((headerGroup) => (
|
|
<DataGridTableHeadRow key={headerGroup.id} rowId={headerGroup.id}>
|
|
{headerGroup.headers
|
|
.filter((header) => header.column.getIsPinned() !== "right")
|
|
.map((header) => {
|
|
const { column } = header
|
|
|
|
return (
|
|
<DataGridTableHeadRowCell header={header} key={header.id}>
|
|
{header.isPlaceholder
|
|
? null
|
|
: flexRender(
|
|
header.column.columnDef.header,
|
|
header.getContext()
|
|
)}
|
|
{props.tableLayout?.columnsResizable &&
|
|
column.getCanResize() && (
|
|
<DataGridTableHeadRowCellResize header={header} />
|
|
)}
|
|
</DataGridTableHeadRowCell>
|
|
)
|
|
})}
|
|
{props.tableLayout?.columnsResizable &&
|
|
hasRightPinnedColumns ? (
|
|
<DataGridTableFillHeadCell />
|
|
) : null}
|
|
{headerGroup.headers
|
|
.filter((header) => header.column.getIsPinned() === "right")
|
|
.map((header) => {
|
|
const { column } = header
|
|
|
|
return (
|
|
<DataGridTableHeadRowCell header={header} key={header.id}>
|
|
{header.isPlaceholder
|
|
? null
|
|
: flexRender(
|
|
header.column.columnDef.header,
|
|
header.getContext()
|
|
)}
|
|
{props.tableLayout?.columnsResizable &&
|
|
column.getCanResize() && (
|
|
<DataGridTableHeadRowCellResize header={header} />
|
|
)}
|
|
</DataGridTableHeadRowCell>
|
|
)
|
|
})}
|
|
{props.tableLayout?.columnsResizable &&
|
|
!hasRightPinnedColumns ? (
|
|
<DataGridTableFillHeadCell />
|
|
) : null}
|
|
</DataGridTableHeadRow>
|
|
))}
|
|
</DataGridTableHead>
|
|
)}
|
|
|
|
{renderHeader &&
|
|
(props.tableLayout?.stripped || !props.tableLayout?.rowBorder) && (
|
|
<DataGridTableRowSpacer />
|
|
)}
|
|
|
|
<DataGridTableBody>
|
|
<MemoizedVirtualBody
|
|
table={table}
|
|
topRows={topRows}
|
|
centerRows={centerRows}
|
|
bottomRows={bottomRows}
|
|
virtualItems={virtualItems}
|
|
totalSize={totalSize}
|
|
isVirtualizationEnabled={isVirtualizationEnabled}
|
|
isInfiniteMode={isInfiniteMode}
|
|
isFetchingMore={isFetchingMore}
|
|
hasMore={hasMore}
|
|
loadingMoreMessage={loadingMoreMessage}
|
|
allRowsLoadedMessage={allRowsLoadedMessage}
|
|
measureRowRef={measureRowRef}
|
|
/>
|
|
</DataGridTableBody>
|
|
|
|
{footerContent && (
|
|
<DataGridTableFoot>{footerContent}</DataGridTableFoot>
|
|
)}
|
|
</DataGridTableBase>
|
|
</DataGridTableViewport>
|
|
)
|
|
}
|
|
|
|
export { DataGridTableVirtual }
|
|
export type {
|
|
DataGridTableVirtualProps,
|
|
DataGridTableVirtualScrollElements,
|
|
DataGridTableVirtualizerOptions,
|
|
} |