mirror of
https://github.com/SigNoz/signoz.git
synced 2026-04-15 16:30:27 +01:00
Compare commits
9 Commits
base-path-
...
refactor/t
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
0acb1e0bed | ||
|
|
5861718972 | ||
|
|
7d8c8e3d7d | ||
|
|
5eae936ab4 | ||
|
|
091d61045a | ||
|
|
ed1217e5d0 | ||
|
|
0389b46836 | ||
|
|
284d6f72d4 | ||
|
|
66abfa3be4 |
@@ -28,17 +28,6 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// In table/column view, keep action buttons visible at the viewport's right edge
|
|
||||||
.log-line-action-buttons.table-view-log-actions {
|
|
||||||
position: absolute;
|
|
||||||
top: 50%;
|
|
||||||
right: 8px;
|
|
||||||
left: auto;
|
|
||||||
transform: translateY(-50%);
|
|
||||||
margin: 0;
|
|
||||||
z-index: 5;
|
|
||||||
}
|
|
||||||
|
|
||||||
.lightMode {
|
.lightMode {
|
||||||
.log-line-action-buttons {
|
.log-line-action-buttons {
|
||||||
border: 1px solid var(--bg-vanilla-400);
|
border: 1px solid var(--bg-vanilla-400);
|
||||||
|
|||||||
@@ -1,6 +1,4 @@
|
|||||||
.log-state-indicator {
|
.log-state-indicator {
|
||||||
padding-left: 8px;
|
|
||||||
|
|
||||||
.line {
|
.line {
|
||||||
margin: 0 8px;
|
margin: 0 8px;
|
||||||
min-height: 24px;
|
min-height: 24px;
|
||||||
|
|||||||
@@ -0,0 +1,43 @@
|
|||||||
|
import { Color } from '@signozhq/design-tokens';
|
||||||
|
|
||||||
|
import { LogType } from './LogStateIndicator';
|
||||||
|
|
||||||
|
export function getRowBackgroundColor(
|
||||||
|
isDarkMode: boolean,
|
||||||
|
logType?: string,
|
||||||
|
): string {
|
||||||
|
if (isDarkMode) {
|
||||||
|
switch (logType) {
|
||||||
|
case LogType.INFO:
|
||||||
|
return `${Color.BG_ROBIN_500}40`;
|
||||||
|
case LogType.WARN:
|
||||||
|
return `${Color.BG_AMBER_500}40`;
|
||||||
|
case LogType.ERROR:
|
||||||
|
return `${Color.BG_CHERRY_500}40`;
|
||||||
|
case LogType.TRACE:
|
||||||
|
return `${Color.BG_FOREST_400}40`;
|
||||||
|
case LogType.DEBUG:
|
||||||
|
return `${Color.BG_AQUA_500}40`;
|
||||||
|
case LogType.FATAL:
|
||||||
|
return `${Color.BG_SAKURA_500}40`;
|
||||||
|
default:
|
||||||
|
return `${Color.BG_ROBIN_500}40`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
switch (logType) {
|
||||||
|
case LogType.INFO:
|
||||||
|
return Color.BG_ROBIN_100;
|
||||||
|
case LogType.WARN:
|
||||||
|
return Color.BG_AMBER_100;
|
||||||
|
case LogType.ERROR:
|
||||||
|
return Color.BG_CHERRY_100;
|
||||||
|
case LogType.TRACE:
|
||||||
|
return Color.BG_FOREST_200;
|
||||||
|
case LogType.DEBUG:
|
||||||
|
return Color.BG_AQUA_100;
|
||||||
|
case LogType.FATAL:
|
||||||
|
return Color.BG_SAKURA_100;
|
||||||
|
default:
|
||||||
|
return Color.BG_VANILLA_300;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
.logBodyCell {
|
||||||
|
font-style: normal;
|
||||||
|
font-weight: 400;
|
||||||
|
letter-spacing: -0.07px;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
font-size: var(--tanstack-plain-cell-font-size, 14px);
|
||||||
|
line-height: var(--tanstack-plain-cell-line-height, 18px);
|
||||||
|
line-clamp: var(--tanstack-plain-body-line-clamp, 1);
|
||||||
|
color: var(--l2-foreground);
|
||||||
|
}
|
||||||
117
frontend/src/components/Logs/TableView/useLogsTableColumns.tsx
Normal file
117
frontend/src/components/Logs/TableView/useLogsTableColumns.tsx
Normal file
@@ -0,0 +1,117 @@
|
|||||||
|
import type { ReactElement } from 'react';
|
||||||
|
import { useMemo } from 'react';
|
||||||
|
import TanStackTable from 'components/TanStackTableView';
|
||||||
|
import { DATE_TIME_FORMATS } from 'constants/dateTimeFormats';
|
||||||
|
import { getSanitizedLogBody } from 'container/LogDetailedView/utils';
|
||||||
|
import { FontSize } from 'container/OptionsMenu/types';
|
||||||
|
import { FlatLogData } from 'lib/logs/flatLogData';
|
||||||
|
import { useTimezone } from 'providers/Timezone';
|
||||||
|
import { IField } from 'types/api/logs/fields';
|
||||||
|
import { ILog } from 'types/api/logs/log';
|
||||||
|
|
||||||
|
import type { TableColumnDef } from '../../TanStackTableView/types';
|
||||||
|
import LogStateIndicator from '../LogStateIndicator/LogStateIndicator';
|
||||||
|
|
||||||
|
import styles from './useLogsTableColumns.module.scss';
|
||||||
|
|
||||||
|
type UseLogsTableColumnsProps = {
|
||||||
|
fields: IField[];
|
||||||
|
linesPerRow: number;
|
||||||
|
fontSize: FontSize;
|
||||||
|
appendTo?: 'center' | 'end';
|
||||||
|
};
|
||||||
|
|
||||||
|
export function useLogsTableColumns({
|
||||||
|
fields,
|
||||||
|
fontSize,
|
||||||
|
appendTo = 'center',
|
||||||
|
}: UseLogsTableColumnsProps): TableColumnDef<ILog>[] {
|
||||||
|
const { formatTimezoneAdjustedTimestamp } = useTimezone();
|
||||||
|
|
||||||
|
return useMemo<TableColumnDef<ILog>[]>(() => {
|
||||||
|
const stateIndicatorCol: TableColumnDef<ILog> = {
|
||||||
|
id: 'state-indicator',
|
||||||
|
header: '',
|
||||||
|
pin: 'left',
|
||||||
|
enableMove: false,
|
||||||
|
enableResize: false,
|
||||||
|
enableRemove: false,
|
||||||
|
width: { fixed: 24 },
|
||||||
|
cell: ({ row }): ReactElement => (
|
||||||
|
<LogStateIndicator
|
||||||
|
fontSize={fontSize}
|
||||||
|
severityText={row.severity_text as string}
|
||||||
|
severityNumber={row.severity_number as number}
|
||||||
|
/>
|
||||||
|
),
|
||||||
|
};
|
||||||
|
|
||||||
|
const fieldColumns: TableColumnDef<ILog>[] = fields
|
||||||
|
.filter((f): boolean => !['id', 'body', 'timestamp'].includes(f.name))
|
||||||
|
.map(
|
||||||
|
(f): TableColumnDef<ILog> => ({
|
||||||
|
id: f.name,
|
||||||
|
header: f.name,
|
||||||
|
accessorFn: (log): unknown => FlatLogData(log)[f.name],
|
||||||
|
enableRemove: true,
|
||||||
|
width: { min: 192 },
|
||||||
|
cell: ({ value }): ReactElement => (
|
||||||
|
<TanStackTable.Text>{String(value ?? '')}</TanStackTable.Text>
|
||||||
|
),
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
const timestampCol: TableColumnDef<ILog> | null = fields.some(
|
||||||
|
(f) => f.name === 'timestamp',
|
||||||
|
)
|
||||||
|
? {
|
||||||
|
id: 'timestamp',
|
||||||
|
header: 'Timestamp',
|
||||||
|
accessorFn: (log): unknown => log.timestamp,
|
||||||
|
width: { min: 170, max: 220 },
|
||||||
|
cell: ({ value }): ReactElement => {
|
||||||
|
const ts = value as string | number;
|
||||||
|
const formatted =
|
||||||
|
typeof ts === 'string'
|
||||||
|
? formatTimezoneAdjustedTimestamp(ts, DATE_TIME_FORMATS.ISO_DATETIME_MS)
|
||||||
|
: formatTimezoneAdjustedTimestamp(
|
||||||
|
ts / 1e6,
|
||||||
|
DATE_TIME_FORMATS.ISO_DATETIME_MS,
|
||||||
|
);
|
||||||
|
return <TanStackTable.Text>{formatted}</TanStackTable.Text>;
|
||||||
|
},
|
||||||
|
}
|
||||||
|
: null;
|
||||||
|
|
||||||
|
const bodyCol: TableColumnDef<ILog> | null = fields.some(
|
||||||
|
(f) => f.name === 'body',
|
||||||
|
)
|
||||||
|
? {
|
||||||
|
id: 'body',
|
||||||
|
header: 'Body',
|
||||||
|
accessorFn: (log): string => log.body,
|
||||||
|
width: { min: 640 },
|
||||||
|
cell: ({ value, isActive }): ReactElement => (
|
||||||
|
<div
|
||||||
|
// eslint-disable-next-line react/no-danger
|
||||||
|
dangerouslySetInnerHTML={{
|
||||||
|
__html: getSanitizedLogBody(value as string, {
|
||||||
|
shouldEscapeHtml: true,
|
||||||
|
}),
|
||||||
|
}}
|
||||||
|
data-active={isActive}
|
||||||
|
className={styles.logBodyCell}
|
||||||
|
/>
|
||||||
|
),
|
||||||
|
}
|
||||||
|
: null;
|
||||||
|
|
||||||
|
return [
|
||||||
|
stateIndicatorCol,
|
||||||
|
...(timestampCol ? [timestampCol] : []),
|
||||||
|
...(appendTo === 'center' ? fieldColumns : []),
|
||||||
|
...(bodyCol ? [bodyCol] : []),
|
||||||
|
...(appendTo === 'end' ? fieldColumns : []),
|
||||||
|
];
|
||||||
|
}, [fields, appendTo, fontSize, formatTimezoneAdjustedTimestamp]);
|
||||||
|
}
|
||||||
@@ -0,0 +1,141 @@
|
|||||||
|
import { ComponentProps, memo } from 'react';
|
||||||
|
import { TableComponents } from 'react-virtuoso';
|
||||||
|
import cx from 'classnames';
|
||||||
|
|
||||||
|
import TanStackRowCells from './TanStackRow';
|
||||||
|
import {
|
||||||
|
useClearRowHovered,
|
||||||
|
useSetRowHovered,
|
||||||
|
} from './TanStackTableStateContext';
|
||||||
|
import { FlatItem, TableRowContext } from './types';
|
||||||
|
|
||||||
|
import tableStyles from './TanStackTable.module.scss';
|
||||||
|
|
||||||
|
type VirtuosoTableRowProps<TData> = ComponentProps<
|
||||||
|
NonNullable<
|
||||||
|
TableComponents<FlatItem<TData>, TableRowContext<TData>>['TableRow']
|
||||||
|
>
|
||||||
|
>;
|
||||||
|
|
||||||
|
function TanStackCustomTableRow<TData>({
|
||||||
|
item,
|
||||||
|
context,
|
||||||
|
...props
|
||||||
|
}: VirtuosoTableRowProps<TData>): JSX.Element {
|
||||||
|
const rowId = item.row.id;
|
||||||
|
const rowData = item.row.original;
|
||||||
|
|
||||||
|
// Stable callbacks for hover state management
|
||||||
|
const setHovered = useSetRowHovered(rowId);
|
||||||
|
const clearHovered = useClearRowHovered(rowId);
|
||||||
|
|
||||||
|
if (item.kind === 'expansion') {
|
||||||
|
return (
|
||||||
|
<tr {...props} className={tableStyles.tableRowExpansion}>
|
||||||
|
<TanStackRowCells
|
||||||
|
row={item.row}
|
||||||
|
itemKind={item.kind}
|
||||||
|
context={context}
|
||||||
|
hasSingleColumn={context?.hasSingleColumn ?? false}
|
||||||
|
columnOrderKey={context?.columnOrderKey ?? ''}
|
||||||
|
columnVisibilityKey={context?.columnVisibilityKey ?? ''}
|
||||||
|
/>
|
||||||
|
</tr>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const isActive = context?.isRowActive?.(rowData) ?? false;
|
||||||
|
const extraClass = context?.getRowClassName?.(rowData) ?? '';
|
||||||
|
const rowStyle = context?.getRowStyle?.(rowData);
|
||||||
|
|
||||||
|
const rowClassName = cx(
|
||||||
|
tableStyles.tableRow,
|
||||||
|
isActive && tableStyles.tableRowActive,
|
||||||
|
extraClass,
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<tr
|
||||||
|
{...props}
|
||||||
|
className={rowClassName}
|
||||||
|
style={rowStyle}
|
||||||
|
onMouseEnter={setHovered}
|
||||||
|
onMouseLeave={clearHovered}
|
||||||
|
>
|
||||||
|
<TanStackRowCells
|
||||||
|
row={item.row}
|
||||||
|
itemKind={item.kind}
|
||||||
|
context={context}
|
||||||
|
hasSingleColumn={context?.hasSingleColumn ?? false}
|
||||||
|
columnOrderKey={context?.columnOrderKey ?? ''}
|
||||||
|
columnVisibilityKey={context?.columnVisibilityKey ?? ''}
|
||||||
|
/>
|
||||||
|
</tr>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Custom comparison - only re-render when row identity or computed values change
|
||||||
|
// eslint-disable-next-line sonarjs/cognitive-complexity
|
||||||
|
function areTableRowPropsEqual<TData>(
|
||||||
|
prev: Readonly<VirtuosoTableRowProps<TData>>,
|
||||||
|
next: Readonly<VirtuosoTableRowProps<TData>>,
|
||||||
|
): boolean {
|
||||||
|
// Different row = must re-render
|
||||||
|
if (prev.item.row.id !== next.item.row.id) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
// Different kind (row vs expansion) = must re-render
|
||||||
|
if (prev.item.kind !== next.item.kind) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
// Same row, same kind - check if computed values would differ
|
||||||
|
// We compare the context callbacks and row data to determine this
|
||||||
|
const prevData = prev.item.row.original;
|
||||||
|
const nextData = next.item.row.original;
|
||||||
|
|
||||||
|
// Row data reference changed = potential re-render needed
|
||||||
|
if (prevData !== nextData) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Column layout changed = must re-render cells
|
||||||
|
if (prev.context?.hasSingleColumn !== next.context?.hasSingleColumn) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
if (prev.context?.columnOrderKey !== next.context?.columnOrderKey) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (prev.context?.columnVisibilityKey !== next.context?.columnVisibilityKey) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Context callbacks changed = computed values may differ
|
||||||
|
if (prev.context !== next.context) {
|
||||||
|
// If context changed, check if the actual computed values differ
|
||||||
|
const prevActive = prev.context?.isRowActive?.(prevData) ?? false;
|
||||||
|
const nextActive = next.context?.isRowActive?.(nextData) ?? false;
|
||||||
|
if (prevActive !== nextActive) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
const prevClass = prev.context?.getRowClassName?.(prevData) ?? '';
|
||||||
|
const nextClass = next.context?.getRowClassName?.(nextData) ?? '';
|
||||||
|
if (prevClass !== nextClass) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
const prevStyle = prev.context?.getRowStyle?.(prevData);
|
||||||
|
const nextStyle = next.context?.getRowStyle?.(nextData);
|
||||||
|
if (prevStyle !== nextStyle) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default memo(
|
||||||
|
TanStackCustomTableRow,
|
||||||
|
areTableRowPropsEqual,
|
||||||
|
) as typeof TanStackCustomTableRow;
|
||||||
@@ -1,8 +1,8 @@
|
|||||||
.tanstack-header-cell {
|
.tanstackHeaderCell {
|
||||||
position: sticky;
|
position: sticky;
|
||||||
top: 0;
|
top: 0;
|
||||||
z-index: 2;
|
z-index: 2;
|
||||||
padding: 0;
|
padding: 0.3rem;
|
||||||
transform: translate3d(
|
transform: translate3d(
|
||||||
var(--tanstack-header-translate-x, 0px),
|
var(--tanstack-header-translate-x, 0px),
|
||||||
var(--tanstack-header-translate-y, 0px),
|
var(--tanstack-header-translate-y, 0px),
|
||||||
@@ -10,16 +10,16 @@
|
|||||||
);
|
);
|
||||||
transition: var(--tanstack-header-transition, none);
|
transition: var(--tanstack-header-transition, none);
|
||||||
|
|
||||||
&.is-dragging {
|
&.isDragging {
|
||||||
opacity: 0.85;
|
opacity: 0.85;
|
||||||
}
|
}
|
||||||
|
|
||||||
&.is-resizing {
|
&.isResizing {
|
||||||
background: var(--l2-background-hover);
|
background: var(--l2-background-hover);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.tanstack-header-content {
|
.tanstackHeaderContent {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
height: 100%;
|
height: 100%;
|
||||||
@@ -28,20 +28,20 @@
|
|||||||
cursor: default;
|
cursor: default;
|
||||||
max-width: 100%;
|
max-width: 100%;
|
||||||
|
|
||||||
&.has-resize-control {
|
&.hasResizeControl {
|
||||||
max-width: calc(100% - 5px);
|
max-width: calc(100% - 5px);
|
||||||
}
|
}
|
||||||
|
|
||||||
&.has-action-control {
|
&.hasActionControl {
|
||||||
max-width: calc(100% - 5px);
|
max-width: calc(100% - 5px);
|
||||||
}
|
}
|
||||||
|
|
||||||
&.has-resize-control.has-action-control {
|
&.hasResizeControl.hasActionControl {
|
||||||
max-width: calc(100% - 10px);
|
max-width: calc(100% - 10px);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.tanstack-grip-slot {
|
.tanstackGripSlot {
|
||||||
display: inline-flex;
|
display: inline-flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
@@ -51,7 +51,7 @@
|
|||||||
flex-shrink: 0;
|
flex-shrink: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.tanstack-grip-activator {
|
.tanstackGripActivator {
|
||||||
display: inline-flex;
|
display: inline-flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
@@ -63,7 +63,7 @@
|
|||||||
touch-action: none;
|
touch-action: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
.tanstack-header-action-trigger {
|
.tanstackHeaderActionTrigger {
|
||||||
display: inline-flex;
|
display: inline-flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
@@ -72,9 +72,11 @@
|
|||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
flex-shrink: 0;
|
flex-shrink: 0;
|
||||||
color: var(--l2-foreground);
|
color: var(--l2-foreground);
|
||||||
|
|
||||||
|
margin-left: auto;
|
||||||
}
|
}
|
||||||
|
|
||||||
.tanstack-column-actions-content {
|
.tanstackColumnActionsContent {
|
||||||
width: 140px;
|
width: 140px;
|
||||||
padding: 0;
|
padding: 0;
|
||||||
background: var(--l2-background);
|
background: var(--l2-background);
|
||||||
@@ -83,7 +85,7 @@
|
|||||||
box-shadow: none;
|
box-shadow: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
.tanstack-remove-column-action {
|
.tanstackRemoveColumnAction {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 8px;
|
gap: 8px;
|
||||||
@@ -105,19 +107,19 @@
|
|||||||
background: var(--l2-background-hover);
|
background: var(--l2-background-hover);
|
||||||
color: var(--l2-foreground);
|
color: var(--l2-foreground);
|
||||||
|
|
||||||
.tanstack-remove-column-action-icon {
|
.tanstackRemoveColumnActionIcon {
|
||||||
color: var(--l2-foreground);
|
color: var(--l2-foreground);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.tanstack-remove-column-action-icon {
|
.tanstackRemoveColumnActionIcon {
|
||||||
font-size: 11px;
|
font-size: 11px;
|
||||||
color: var(--l2-foreground);
|
color: var(--l2-foreground);
|
||||||
opacity: 0.95;
|
opacity: 0.95;
|
||||||
}
|
}
|
||||||
|
|
||||||
.tanstack-header-cell .cursor-col-resize {
|
.tanstackHeaderCell .cursorColResize {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
top: 0;
|
top: 0;
|
||||||
right: 0;
|
right: 0;
|
||||||
@@ -129,25 +131,84 @@
|
|||||||
background: transparent;
|
background: transparent;
|
||||||
}
|
}
|
||||||
|
|
||||||
.tanstack-header-cell.is-resizing .cursor-col-resize {
|
.tanstackHeaderCell.isResizing .cursorColResize {
|
||||||
background: var(--bg-robin-300);
|
background: var(--bg-robin-300);
|
||||||
}
|
}
|
||||||
|
|
||||||
.tanstack-resize-handle-line {
|
.tanstackResizeHandleLine {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
top: 0;
|
top: 0;
|
||||||
bottom: 0;
|
bottom: 0;
|
||||||
left: 50%;
|
left: 50%;
|
||||||
width: 4px;
|
width: 4px;
|
||||||
transform: translateX(-50%);
|
transform: translateX(-50%);
|
||||||
background: var(--l2-border);
|
|
||||||
opacity: 1;
|
opacity: 1;
|
||||||
pointer-events: none;
|
pointer-events: none;
|
||||||
transition: background 120ms ease, width 120ms ease;
|
transition: background 120ms ease, width 120ms ease;
|
||||||
|
background: transparent;
|
||||||
}
|
}
|
||||||
|
|
||||||
.tanstack-header-cell.is-resizing .tanstack-resize-handle-line {
|
.cursorColResize:hover .tanstackResizeHandleLine {
|
||||||
|
background: var(--l2-border);
|
||||||
|
}
|
||||||
|
|
||||||
|
.tanstackHeaderCell.isResizing .tanstackResizeHandleLine {
|
||||||
width: 2px;
|
width: 2px;
|
||||||
background: var(--bg-robin-500);
|
background: var(--bg-robin-500);
|
||||||
transition: none;
|
transition: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.tanstackSortButton {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 4px;
|
||||||
|
background: none;
|
||||||
|
border: none;
|
||||||
|
padding: 0;
|
||||||
|
margin: 0;
|
||||||
|
font: inherit;
|
||||||
|
color: inherit;
|
||||||
|
cursor: pointer;
|
||||||
|
text-align: left;
|
||||||
|
min-width: 0;
|
||||||
|
max-width: 100%;
|
||||||
|
flex: 1;
|
||||||
|
overflow: hidden;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
color: var(--l2-foreground);
|
||||||
|
}
|
||||||
|
|
||||||
|
&.isSorted {
|
||||||
|
color: var(--l2-foreground);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.tanstackHeaderTitle {
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
min-width: 0;
|
||||||
|
flex: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tanstackSortLabel {
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tanstackSortIndicator {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
flex-shrink: 0;
|
||||||
|
width: 14px;
|
||||||
|
height: 14px;
|
||||||
|
color: var(--l2-foreground);
|
||||||
|
}
|
||||||
|
|
||||||
|
.isSortable {
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
255
frontend/src/components/TanStackTableView/TanStackHeaderRow.tsx
Normal file
255
frontend/src/components/TanStackTableView/TanStackHeaderRow.tsx
Normal file
@@ -0,0 +1,255 @@
|
|||||||
|
import type {
|
||||||
|
CSSProperties,
|
||||||
|
MouseEvent as ReactMouseEvent,
|
||||||
|
TouchEvent as ReactTouchEvent,
|
||||||
|
} from 'react';
|
||||||
|
import { useCallback, useMemo } from 'react';
|
||||||
|
import { CloseOutlined, MoreOutlined } from '@ant-design/icons';
|
||||||
|
import { useSortable } from '@dnd-kit/sortable';
|
||||||
|
import { Popover, PopoverContent, PopoverTrigger } from '@signozhq/popover';
|
||||||
|
import { flexRender, Header as TanStackHeader } from '@tanstack/react-table';
|
||||||
|
import cx from 'classnames';
|
||||||
|
import { ChevronDown, ChevronUp, GripVertical } from 'lucide-react';
|
||||||
|
|
||||||
|
import { SortState, TableColumnDef } from './types';
|
||||||
|
|
||||||
|
import headerStyles from './TanStackHeaderRow.module.scss';
|
||||||
|
import tableStyles from './TanStackTable.module.scss';
|
||||||
|
|
||||||
|
type TanStackHeaderRowProps<TData = unknown> = {
|
||||||
|
column: TableColumnDef<TData>;
|
||||||
|
header?: TanStackHeader<TData, unknown>;
|
||||||
|
isDarkMode: boolean;
|
||||||
|
hasSingleColumn: boolean;
|
||||||
|
canRemoveColumn?: boolean;
|
||||||
|
onRemoveColumn?: (columnId: string) => void;
|
||||||
|
orderBy?: SortState | null;
|
||||||
|
onSort?: (sort: SortState | null) => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
const GRIP_ICON_SIZE = 12;
|
||||||
|
|
||||||
|
const SORT_ICON_SIZE = 14;
|
||||||
|
|
||||||
|
// eslint-disable-next-line sonarjs/cognitive-complexity
|
||||||
|
function TanStackHeaderRow<TData>({
|
||||||
|
column,
|
||||||
|
header,
|
||||||
|
isDarkMode,
|
||||||
|
hasSingleColumn,
|
||||||
|
canRemoveColumn = false,
|
||||||
|
onRemoveColumn,
|
||||||
|
orderBy,
|
||||||
|
onSort,
|
||||||
|
}: TanStackHeaderRowProps<TData>): JSX.Element {
|
||||||
|
const columnId = column.id;
|
||||||
|
const isDragColumn = column.enableMove !== false && column.pin == null;
|
||||||
|
const isResizableColumn =
|
||||||
|
column.enableResize !== false && Boolean(header?.column.getCanResize());
|
||||||
|
const isColumnRemovable = Boolean(
|
||||||
|
canRemoveColumn && onRemoveColumn && column.enableRemove,
|
||||||
|
);
|
||||||
|
const isSortable = column.enableSort === true && Boolean(onSort);
|
||||||
|
const currentSortDirection =
|
||||||
|
orderBy?.columnName === columnId ? orderBy.order : null;
|
||||||
|
const isResizing = Boolean(header?.column.getIsResizing());
|
||||||
|
const resizeHandler = header?.getResizeHandler();
|
||||||
|
const headerText =
|
||||||
|
typeof column.header === 'string' && column.header
|
||||||
|
? column.header
|
||||||
|
: String(header?.id ?? columnId);
|
||||||
|
const headerTitleAttr = headerText.replace(/^\w/, (c) => c.toUpperCase());
|
||||||
|
|
||||||
|
const handleSortClick = useCallback((): void => {
|
||||||
|
if (!isSortable || !onSort) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (currentSortDirection === null) {
|
||||||
|
onSort({ columnName: columnId, order: 'asc' });
|
||||||
|
} else if (currentSortDirection === 'asc') {
|
||||||
|
onSort({ columnName: columnId, order: 'desc' });
|
||||||
|
} else {
|
||||||
|
onSort(null);
|
||||||
|
}
|
||||||
|
}, [isSortable, onSort, currentSortDirection, columnId]);
|
||||||
|
|
||||||
|
const handleResizeStart = (
|
||||||
|
event: ReactMouseEvent<HTMLElement> | ReactTouchEvent<HTMLElement>,
|
||||||
|
): void => {
|
||||||
|
event.preventDefault();
|
||||||
|
event.stopPropagation();
|
||||||
|
resizeHandler?.(event);
|
||||||
|
};
|
||||||
|
const {
|
||||||
|
attributes,
|
||||||
|
listeners,
|
||||||
|
setNodeRef,
|
||||||
|
setActivatorNodeRef,
|
||||||
|
transform,
|
||||||
|
transition,
|
||||||
|
isDragging,
|
||||||
|
} = useSortable({
|
||||||
|
id: columnId,
|
||||||
|
disabled: !isDragColumn,
|
||||||
|
});
|
||||||
|
const headerCellStyle = useMemo(
|
||||||
|
() =>
|
||||||
|
({
|
||||||
|
'--tanstack-header-translate-x': `${Math.round(transform?.x ?? 0)}px`,
|
||||||
|
'--tanstack-header-translate-y': `${Math.round(transform?.y ?? 0)}px`,
|
||||||
|
'--tanstack-header-transition': isResizing ? 'none' : transition || 'none',
|
||||||
|
} as CSSProperties),
|
||||||
|
[isResizing, transform?.x, transform?.y, transition],
|
||||||
|
);
|
||||||
|
const headerCellClassName = cx(
|
||||||
|
headerStyles.tanstackHeaderCell,
|
||||||
|
isDragging && headerStyles.isDragging,
|
||||||
|
isResizing && headerStyles.isResizing,
|
||||||
|
);
|
||||||
|
const headerContentClassName = cx(
|
||||||
|
headerStyles.tanstackHeaderContent,
|
||||||
|
isResizableColumn && headerStyles.hasResizeControl,
|
||||||
|
isColumnRemovable && headerStyles.hasActionControl,
|
||||||
|
isSortable && headerStyles.isSortable,
|
||||||
|
);
|
||||||
|
|
||||||
|
const thClassName = cx(
|
||||||
|
tableStyles.tableHeaderCell,
|
||||||
|
headerCellClassName,
|
||||||
|
column.id,
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<th
|
||||||
|
ref={setNodeRef}
|
||||||
|
className={thClassName}
|
||||||
|
key={columnId}
|
||||||
|
style={headerCellStyle}
|
||||||
|
data-dark-mode={isDarkMode}
|
||||||
|
data-single-column={hasSingleColumn || undefined}
|
||||||
|
>
|
||||||
|
<span className={headerContentClassName}>
|
||||||
|
{isDragColumn ? (
|
||||||
|
<span className={headerStyles.tanstackGripSlot}>
|
||||||
|
<span
|
||||||
|
ref={setActivatorNodeRef}
|
||||||
|
{...attributes}
|
||||||
|
{...listeners}
|
||||||
|
role="button"
|
||||||
|
aria-label={`Drag ${String(
|
||||||
|
(typeof column.header === 'string' && column.header) ||
|
||||||
|
header?.id ||
|
||||||
|
columnId,
|
||||||
|
)} column`}
|
||||||
|
className={headerStyles.tanstackGripActivator}
|
||||||
|
>
|
||||||
|
<GripVertical size={GRIP_ICON_SIZE} />
|
||||||
|
</span>
|
||||||
|
</span>
|
||||||
|
) : null}
|
||||||
|
{isSortable ? (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className={cx(
|
||||||
|
'tanstack-header-title',
|
||||||
|
headerStyles.tanstackSortButton,
|
||||||
|
currentSortDirection && headerStyles.isSorted,
|
||||||
|
)}
|
||||||
|
title={headerTitleAttr}
|
||||||
|
onClick={handleSortClick}
|
||||||
|
aria-sort={
|
||||||
|
currentSortDirection === 'asc'
|
||||||
|
? 'ascending'
|
||||||
|
: currentSortDirection === 'desc'
|
||||||
|
? 'descending'
|
||||||
|
: 'none'
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<span className={headerStyles.tanstackSortLabel}>
|
||||||
|
{header?.column?.columnDef
|
||||||
|
? flexRender(header.column.columnDef.header, header.getContext())
|
||||||
|
: typeof column.header === 'function'
|
||||||
|
? column.header()
|
||||||
|
: String(column.header || '').replace(/^\w/, (c) => c.toUpperCase())}
|
||||||
|
</span>
|
||||||
|
<span className={headerStyles.tanstackSortIndicator}>
|
||||||
|
{currentSortDirection === 'asc' ? (
|
||||||
|
<ChevronUp size={SORT_ICON_SIZE} />
|
||||||
|
) : currentSortDirection === 'desc' ? (
|
||||||
|
<ChevronDown size={SORT_ICON_SIZE} />
|
||||||
|
) : null}
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
) : (
|
||||||
|
<span
|
||||||
|
className={cx('tanstack-header-title', headerStyles.tanstackHeaderTitle)}
|
||||||
|
title={headerTitleAttr}
|
||||||
|
>
|
||||||
|
{header?.column?.columnDef
|
||||||
|
? flexRender(header.column.columnDef.header, header.getContext())
|
||||||
|
: typeof column.header === 'function'
|
||||||
|
? column.header()
|
||||||
|
: String(column.header || '').replace(/^\w/, (c) => c.toUpperCase())}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
{isColumnRemovable && (
|
||||||
|
<Popover>
|
||||||
|
<PopoverTrigger asChild>
|
||||||
|
<span
|
||||||
|
role="button"
|
||||||
|
aria-label={`Column actions for ${headerTitleAttr}`}
|
||||||
|
className={headerStyles.tanstackHeaderActionTrigger}
|
||||||
|
onMouseDown={(event): void => {
|
||||||
|
event.stopPropagation();
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<MoreOutlined />
|
||||||
|
</span>
|
||||||
|
</PopoverTrigger>
|
||||||
|
<PopoverContent
|
||||||
|
align="end"
|
||||||
|
sideOffset={6}
|
||||||
|
className={headerStyles.tanstackColumnActionsContent}
|
||||||
|
>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className={headerStyles.tanstackRemoveColumnAction}
|
||||||
|
onClick={(event): void => {
|
||||||
|
event.preventDefault();
|
||||||
|
event.stopPropagation();
|
||||||
|
onRemoveColumn?.(column.id);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<CloseOutlined
|
||||||
|
className={headerStyles.tanstackRemoveColumnActionIcon}
|
||||||
|
/>
|
||||||
|
Remove column
|
||||||
|
</button>
|
||||||
|
</PopoverContent>
|
||||||
|
</Popover>
|
||||||
|
)}
|
||||||
|
</span>
|
||||||
|
{isResizableColumn && (
|
||||||
|
<span
|
||||||
|
role="presentation"
|
||||||
|
className={headerStyles.cursorColResize}
|
||||||
|
title="Drag to resize column"
|
||||||
|
onClick={(event): void => {
|
||||||
|
event.preventDefault();
|
||||||
|
event.stopPropagation();
|
||||||
|
}}
|
||||||
|
onMouseDown={(event): void => {
|
||||||
|
handleResizeStart(event);
|
||||||
|
}}
|
||||||
|
onTouchStart={(event): void => {
|
||||||
|
handleResizeStart(event);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<span className={headerStyles.tanstackResizeHandleLine} />
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</th>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default TanStackHeaderRow;
|
||||||
142
frontend/src/components/TanStackTableView/TanStackRow.tsx
Normal file
142
frontend/src/components/TanStackTableView/TanStackRow.tsx
Normal file
@@ -0,0 +1,142 @@
|
|||||||
|
import type { MouseEvent } from 'react';
|
||||||
|
import { memo, useCallback } from 'react';
|
||||||
|
import { Row as TanStackRowModel } from '@tanstack/react-table';
|
||||||
|
|
||||||
|
import { TanStackRowCell } from './TanStackRowCell';
|
||||||
|
import { useIsRowHovered } from './TanStackTableStateContext';
|
||||||
|
import { TableRowContext } from './types';
|
||||||
|
|
||||||
|
import tableStyles from './TanStackTable.module.scss';
|
||||||
|
|
||||||
|
type TanStackRowCellsProps<TData> = {
|
||||||
|
row: TanStackRowModel<TData>;
|
||||||
|
context: TableRowContext<TData> | undefined;
|
||||||
|
itemKind: 'row' | 'expansion';
|
||||||
|
hasSingleColumn: boolean;
|
||||||
|
columnOrderKey: string;
|
||||||
|
columnVisibilityKey: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
function TanStackRowCellsInner<TData>({
|
||||||
|
row,
|
||||||
|
context,
|
||||||
|
itemKind,
|
||||||
|
hasSingleColumn,
|
||||||
|
columnOrderKey: _columnOrderKey,
|
||||||
|
columnVisibilityKey: _columnVisibilityKey,
|
||||||
|
}: TanStackRowCellsProps<TData>): JSX.Element {
|
||||||
|
// Only re-render this row when ITS hover state changes
|
||||||
|
const hasHovered = useIsRowHovered(row.id);
|
||||||
|
const rowData = row.original;
|
||||||
|
const visibleCells = row.getVisibleCells();
|
||||||
|
const lastCellIndex = visibleCells.length - 1;
|
||||||
|
|
||||||
|
// Stable references via destructuring
|
||||||
|
const onRowClick = context?.onRowClick;
|
||||||
|
const onRowClickNewTab = context?.onRowClickNewTab;
|
||||||
|
const onRowDeactivate = context?.onRowDeactivate;
|
||||||
|
const isRowActive = context?.isRowActive;
|
||||||
|
const getRowKeyData = context?.getRowKeyData;
|
||||||
|
const rowIndex = row.index;
|
||||||
|
|
||||||
|
const handleClick = useCallback(
|
||||||
|
(event: MouseEvent<HTMLTableCellElement>) => {
|
||||||
|
const keyData = getRowKeyData?.(rowIndex);
|
||||||
|
const itemKey = keyData?.itemKey ?? '';
|
||||||
|
|
||||||
|
// Handle ctrl+click or cmd+click (open in new tab)
|
||||||
|
if ((event.ctrlKey || event.metaKey) && onRowClickNewTab) {
|
||||||
|
onRowClickNewTab(rowData, itemKey);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const isActive = isRowActive?.(rowData) ?? false;
|
||||||
|
if (isActive && onRowDeactivate) {
|
||||||
|
onRowDeactivate();
|
||||||
|
} else {
|
||||||
|
onRowClick?.(rowData, itemKey);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[
|
||||||
|
isRowActive,
|
||||||
|
onRowDeactivate,
|
||||||
|
onRowClick,
|
||||||
|
onRowClickNewTab,
|
||||||
|
rowData,
|
||||||
|
getRowKeyData,
|
||||||
|
rowIndex,
|
||||||
|
],
|
||||||
|
);
|
||||||
|
|
||||||
|
if (itemKind === 'expansion') {
|
||||||
|
const keyData = getRowKeyData?.(rowIndex);
|
||||||
|
return (
|
||||||
|
<td
|
||||||
|
colSpan={context?.colCount ?? 1}
|
||||||
|
className={tableStyles.tableCellExpansion}
|
||||||
|
>
|
||||||
|
{context?.renderExpandedRow?.(
|
||||||
|
rowData,
|
||||||
|
keyData?.finalKey ?? '',
|
||||||
|
keyData?.groupMeta,
|
||||||
|
)}
|
||||||
|
</td>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
{visibleCells.map((cell, index) => {
|
||||||
|
const isLastCell = index === lastCellIndex;
|
||||||
|
return (
|
||||||
|
<TanStackRowCell
|
||||||
|
key={cell.id}
|
||||||
|
cell={cell}
|
||||||
|
hasSingleColumn={hasSingleColumn}
|
||||||
|
isLastCell={isLastCell}
|
||||||
|
hasHovered={hasHovered}
|
||||||
|
rowData={rowData}
|
||||||
|
onClick={handleClick}
|
||||||
|
renderRowActions={context?.renderRowActions}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Custom comparison - only re-render when row data changes
|
||||||
|
function areRowCellsPropsEqual<TData>(
|
||||||
|
prev: Readonly<TanStackRowCellsProps<TData>>,
|
||||||
|
next: Readonly<TanStackRowCellsProps<TData>>,
|
||||||
|
): boolean {
|
||||||
|
return (
|
||||||
|
// Row identity
|
||||||
|
prev.row.id === next.row.id &&
|
||||||
|
// Row kind (row vs expansion)
|
||||||
|
prev.itemKind === next.itemKind &&
|
||||||
|
// Layout
|
||||||
|
prev.hasSingleColumn === next.hasSingleColumn &&
|
||||||
|
// Column order - re-render when columns are reordered
|
||||||
|
prev.columnOrderKey === next.columnOrderKey &&
|
||||||
|
// Column visibility - re-render when columns are shown/hidden
|
||||||
|
prev.columnVisibilityKey === next.columnVisibilityKey &&
|
||||||
|
// Context callbacks for click handlers and row actions
|
||||||
|
prev.context?.onRowClick === next.context?.onRowClick &&
|
||||||
|
prev.context?.onRowClickNewTab === next.context?.onRowClickNewTab &&
|
||||||
|
prev.context?.onRowDeactivate === next.context?.onRowDeactivate &&
|
||||||
|
prev.context?.isRowActive === next.context?.isRowActive &&
|
||||||
|
prev.context?.getRowKeyData === next.context?.getRowKeyData &&
|
||||||
|
prev.context?.renderRowActions === next.context?.renderRowActions &&
|
||||||
|
prev.context?.renderExpandedRow === next.context?.renderExpandedRow &&
|
||||||
|
prev.context?.colCount === next.context?.colCount
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
|
const TanStackRowCells = memo(
|
||||||
|
TanStackRowCellsInner,
|
||||||
|
areRowCellsPropsEqual as any,
|
||||||
|
) as <T>(props: TanStackRowCellsProps<T>) => JSX.Element;
|
||||||
|
|
||||||
|
export default TanStackRowCells;
|
||||||
@@ -0,0 +1,86 @@
|
|||||||
|
import type { MouseEvent, ReactNode } from 'react';
|
||||||
|
import { memo } from 'react';
|
||||||
|
import type { Cell } from '@tanstack/react-table';
|
||||||
|
import { flexRender } from '@tanstack/react-table';
|
||||||
|
import { Skeleton } from 'antd';
|
||||||
|
import cx from 'classnames';
|
||||||
|
|
||||||
|
import { useShouldShowCellSkeleton } from './TanStackTableStateContext';
|
||||||
|
|
||||||
|
import tableStyles from './TanStackTable.module.scss';
|
||||||
|
import skeletonStyles from './TanStackTableSkeleton.module.scss';
|
||||||
|
|
||||||
|
export type TanStackRowCellProps<TData> = {
|
||||||
|
cell: Cell<TData, unknown>;
|
||||||
|
hasSingleColumn: boolean;
|
||||||
|
isLastCell: boolean;
|
||||||
|
hasHovered: boolean;
|
||||||
|
rowData: TData;
|
||||||
|
onClick: (event: MouseEvent<HTMLTableCellElement>) => void;
|
||||||
|
renderRowActions?: (row: TData) => ReactNode;
|
||||||
|
};
|
||||||
|
|
||||||
|
function TanStackRowCellInner<TData>({
|
||||||
|
cell,
|
||||||
|
hasSingleColumn,
|
||||||
|
isLastCell,
|
||||||
|
hasHovered,
|
||||||
|
rowData,
|
||||||
|
onClick,
|
||||||
|
renderRowActions,
|
||||||
|
}: TanStackRowCellProps<TData>): JSX.Element {
|
||||||
|
const showSkeleton = useShouldShowCellSkeleton();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<td
|
||||||
|
className={cx(tableStyles.tableCell, 'tanstack-cell-' + cell.column.id)}
|
||||||
|
data-single-column={hasSingleColumn || undefined}
|
||||||
|
onClick={onClick}
|
||||||
|
>
|
||||||
|
{showSkeleton ? (
|
||||||
|
<Skeleton.Input
|
||||||
|
active
|
||||||
|
size="small"
|
||||||
|
className={skeletonStyles.cellSkeleton}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
flexRender(cell.column.columnDef.cell, cell.getContext())
|
||||||
|
)}
|
||||||
|
{isLastCell && hasHovered && renderRowActions && !showSkeleton && (
|
||||||
|
<span className={tableStyles.tableViewRowActions}>
|
||||||
|
{renderRowActions(rowData)}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</td>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function areTanStackRowCellPropsEqual<TData>(
|
||||||
|
prev: Readonly<TanStackRowCellProps<TData>>,
|
||||||
|
next: Readonly<TanStackRowCellProps<TData>>,
|
||||||
|
): boolean {
|
||||||
|
if (next.cell.id.startsWith('skeleton-')) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
prev.cell.id === next.cell.id &&
|
||||||
|
prev.cell.column.id === next.cell.column.id &&
|
||||||
|
Object.is(prev.cell.getValue(), next.cell.getValue()) &&
|
||||||
|
prev.hasSingleColumn === next.hasSingleColumn &&
|
||||||
|
prev.isLastCell === next.isLastCell &&
|
||||||
|
prev.hasHovered === next.hasHovered &&
|
||||||
|
prev.onClick === next.onClick &&
|
||||||
|
prev.renderRowActions === next.renderRowActions &&
|
||||||
|
prev.rowData === next.rowData
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const TanStackRowCellMemo = memo(
|
||||||
|
TanStackRowCellInner,
|
||||||
|
areTanStackRowCellPropsEqual,
|
||||||
|
);
|
||||||
|
|
||||||
|
TanStackRowCellMemo.displayName = 'TanStackRowCell';
|
||||||
|
|
||||||
|
export const TanStackRowCell = TanStackRowCellMemo as typeof TanStackRowCellInner;
|
||||||
@@ -0,0 +1,99 @@
|
|||||||
|
.tanStackTable {
|
||||||
|
width: 100%;
|
||||||
|
border-collapse: separate;
|
||||||
|
border-spacing: 0;
|
||||||
|
table-layout: fixed;
|
||||||
|
|
||||||
|
& td,
|
||||||
|
& th {
|
||||||
|
overflow: hidden;
|
||||||
|
min-width: 0;
|
||||||
|
box-sizing: border-box;
|
||||||
|
vertical-align: middle;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.tableCellText {
|
||||||
|
font-style: normal;
|
||||||
|
font-weight: 400;
|
||||||
|
letter-spacing: -0.07px;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
display: -webkit-box;
|
||||||
|
width: auto;
|
||||||
|
-webkit-box-orient: vertical;
|
||||||
|
-webkit-line-clamp: var(--tanstack-plain-body-line-clamp, 1);
|
||||||
|
line-clamp: var(--tanstack-plain-body-line-clamp, 1);
|
||||||
|
font-size: var(--tanstack-plain-cell-font-size, 14px);
|
||||||
|
line-height: var(--tanstack-plain-cell-line-height, 18px);
|
||||||
|
color: var(--l2-foreground);
|
||||||
|
max-width: 100%;
|
||||||
|
word-break: break-all;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tableViewRowActions {
|
||||||
|
position: absolute;
|
||||||
|
top: 50%;
|
||||||
|
right: 8px;
|
||||||
|
left: auto;
|
||||||
|
transform: translateY(-50%);
|
||||||
|
margin: 0;
|
||||||
|
z-index: 5;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tableCell {
|
||||||
|
padding: 0.3rem;
|
||||||
|
font-style: normal;
|
||||||
|
font-weight: 400;
|
||||||
|
letter-spacing: -0.07px;
|
||||||
|
font-size: var(--tanstack-plain-cell-font-size, 14px);
|
||||||
|
line-height: var(--tanstack-plain-cell-line-height, 18px);
|
||||||
|
color: var(--l2-foreground);
|
||||||
|
}
|
||||||
|
|
||||||
|
.tableRow {
|
||||||
|
cursor: pointer;
|
||||||
|
position: relative;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
.tableCell {
|
||||||
|
background-color: var(--row-hover-bg) !important;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
&.tableRowActive {
|
||||||
|
.tableCell {
|
||||||
|
background-color: var(--row-active-bg) !important;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.tableHeaderCell {
|
||||||
|
padding: 0.3rem;
|
||||||
|
height: 36px;
|
||||||
|
text-align: left;
|
||||||
|
font-size: 14px;
|
||||||
|
font-style: normal;
|
||||||
|
font-weight: 400;
|
||||||
|
line-height: 18px;
|
||||||
|
letter-spacing: -0.07px;
|
||||||
|
color: var(--l1-foreground);
|
||||||
|
|
||||||
|
// TODO: Remove this once background color (l1) is matching the actual background color of the page
|
||||||
|
&[data-dark-mode='true'] {
|
||||||
|
background: #0b0c0d;
|
||||||
|
}
|
||||||
|
|
||||||
|
&[data-dark-mode='false'] {
|
||||||
|
background: #fdfdfd;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.tableRowExpansion {
|
||||||
|
display: table-row;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tableCellExpansion {
|
||||||
|
padding: 0.5rem;
|
||||||
|
vertical-align: top;
|
||||||
|
}
|
||||||
645
frontend/src/components/TanStackTableView/TanStackTable.tsx
Normal file
645
frontend/src/components/TanStackTableView/TanStackTable.tsx
Normal file
@@ -0,0 +1,645 @@
|
|||||||
|
import type { ComponentProps, CSSProperties } from 'react';
|
||||||
|
import {
|
||||||
|
forwardRef,
|
||||||
|
memo,
|
||||||
|
useCallback,
|
||||||
|
useEffect,
|
||||||
|
useImperativeHandle,
|
||||||
|
useMemo,
|
||||||
|
useRef,
|
||||||
|
} from 'react';
|
||||||
|
import type { TableComponents } from 'react-virtuoso';
|
||||||
|
import { TableVirtuoso, TableVirtuosoHandle } from 'react-virtuoso';
|
||||||
|
import { LoadingOutlined } from '@ant-design/icons';
|
||||||
|
import {
|
||||||
|
DndContext,
|
||||||
|
DragEndEvent,
|
||||||
|
PointerSensor,
|
||||||
|
pointerWithin,
|
||||||
|
useSensor,
|
||||||
|
useSensors,
|
||||||
|
} from '@dnd-kit/core';
|
||||||
|
import {
|
||||||
|
arrayMove,
|
||||||
|
horizontalListSortingStrategy,
|
||||||
|
SortableContext,
|
||||||
|
} from '@dnd-kit/sortable';
|
||||||
|
import {
|
||||||
|
ComboboxSimple,
|
||||||
|
ComboboxSimpleItem,
|
||||||
|
TooltipProvider,
|
||||||
|
} from '@signozhq/ui';
|
||||||
|
import { Pagination } from '@signozhq/ui';
|
||||||
|
import type { Row } from '@tanstack/react-table';
|
||||||
|
import {
|
||||||
|
ColumnDef,
|
||||||
|
ColumnPinningState,
|
||||||
|
getCoreRowModel,
|
||||||
|
useReactTable,
|
||||||
|
} from '@tanstack/react-table';
|
||||||
|
import { Spin } from 'antd';
|
||||||
|
import cx from 'classnames';
|
||||||
|
import { useIsDarkMode } from 'hooks/useDarkMode';
|
||||||
|
|
||||||
|
import TanStackCustomTableRow from './TanStackCustomTableRow';
|
||||||
|
import TanStackHeaderRow from './TanStackHeaderRow';
|
||||||
|
import {
|
||||||
|
ColumnVisibilitySync,
|
||||||
|
TableLoadingSync,
|
||||||
|
TanStackTableStateProvider,
|
||||||
|
} from './TanStackTableStateContext';
|
||||||
|
import {
|
||||||
|
FlatItem,
|
||||||
|
TableRowContext,
|
||||||
|
TanStackTableHandle,
|
||||||
|
TanStackTableProps,
|
||||||
|
} from './types';
|
||||||
|
import { useTableParams } from './useTableParams';
|
||||||
|
import { buildTanstackColumnDef } from './utils';
|
||||||
|
import { VirtuosoTableColGroup } from './VirtuosoTableColGroup';
|
||||||
|
|
||||||
|
import tableStyles from './TanStackTable.module.scss';
|
||||||
|
import viewStyles from './TanStackTableView.module.scss';
|
||||||
|
|
||||||
|
const COLUMN_DND_AUTO_SCROLL = {
|
||||||
|
layoutShiftCompensation: false as const,
|
||||||
|
threshold: { x: 0.2, y: 0 },
|
||||||
|
};
|
||||||
|
|
||||||
|
const INCREASE_VIEWPORT_BY = { top: 500, bottom: 500 };
|
||||||
|
|
||||||
|
const noopColumnSizing = (): void => {};
|
||||||
|
const noopColumnVisibility = (): void => {};
|
||||||
|
|
||||||
|
const paginationPageSizeItems: ComboboxSimpleItem[] = [10, 20, 30, 50, 100].map(
|
||||||
|
(value) => ({
|
||||||
|
value: value.toString(),
|
||||||
|
label: value.toString(),
|
||||||
|
displayValue: value.toString(),
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
// eslint-disable-next-line sonarjs/cognitive-complexity
|
||||||
|
function TanStackTableInner<TData>(
|
||||||
|
{
|
||||||
|
data,
|
||||||
|
columns,
|
||||||
|
columnSizing: columnSizingProp,
|
||||||
|
onColumnSizingChange,
|
||||||
|
columnVisibility: columnVisibilityProp,
|
||||||
|
onColumnVisibilityChange,
|
||||||
|
onColumnOrderChange,
|
||||||
|
onRemoveColumn,
|
||||||
|
isLoading = false,
|
||||||
|
skeletonRowCount = 10,
|
||||||
|
enableQueryParams,
|
||||||
|
pagination,
|
||||||
|
onEndReached,
|
||||||
|
getRowId: getRowIdProp,
|
||||||
|
getRowKey,
|
||||||
|
getItemKey,
|
||||||
|
groupBy,
|
||||||
|
getGroupKey,
|
||||||
|
getRowStyle,
|
||||||
|
getRowClassName,
|
||||||
|
isRowActive,
|
||||||
|
renderRowActions,
|
||||||
|
onRowClick,
|
||||||
|
onRowClickNewTab,
|
||||||
|
onRowDeactivate,
|
||||||
|
activeRowIndex,
|
||||||
|
renderExpandedRow,
|
||||||
|
getRowCanExpand,
|
||||||
|
tableScrollerProps,
|
||||||
|
plainTextCellLineClamp,
|
||||||
|
cellTypographySize,
|
||||||
|
className,
|
||||||
|
testId,
|
||||||
|
prefixPaginationContent,
|
||||||
|
suffixPaginationContent,
|
||||||
|
}: TanStackTableProps<TData>,
|
||||||
|
forwardedRef: React.ForwardedRef<TanStackTableHandle>,
|
||||||
|
): JSX.Element {
|
||||||
|
const virtuosoRef = useRef<TableVirtuosoHandle | null>(null);
|
||||||
|
const isDarkMode = useIsDarkMode();
|
||||||
|
|
||||||
|
const {
|
||||||
|
page,
|
||||||
|
limit,
|
||||||
|
setPage,
|
||||||
|
setLimit,
|
||||||
|
orderBy,
|
||||||
|
setOrderBy,
|
||||||
|
expanded,
|
||||||
|
setExpanded,
|
||||||
|
} = useTableParams(enableQueryParams, {
|
||||||
|
page: pagination?.defaultPage,
|
||||||
|
limit: pagination?.defaultLimit,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Track previous data for loading states
|
||||||
|
const prevDataRef = useRef<TData[]>(data);
|
||||||
|
const prevDataSizeRef = useRef(data.length || limit || skeletonRowCount);
|
||||||
|
|
||||||
|
// Update refs when we have real data (not loading)
|
||||||
|
if (!isLoading && data.length > 0) {
|
||||||
|
prevDataRef.current = data;
|
||||||
|
prevDataSizeRef.current = data.length;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Effective data: use current data, previous data, or fake data for skeleton
|
||||||
|
const effectiveData = useMemo((): TData[] => {
|
||||||
|
// Have current data - use it
|
||||||
|
if (data.length > 0) {
|
||||||
|
return data;
|
||||||
|
}
|
||||||
|
// No current data but have previous data - use previous (avoids flash)
|
||||||
|
if (prevDataRef.current.length > 0) {
|
||||||
|
return prevDataRef.current;
|
||||||
|
}
|
||||||
|
// No data at all - create fake data for skeleton rows if loading
|
||||||
|
if (isLoading) {
|
||||||
|
const fakeCount = prevDataSizeRef.current || limit || skeletonRowCount;
|
||||||
|
return Array.from({ length: fakeCount }, (_, i) => ({
|
||||||
|
id: `skeleton-${i}`,
|
||||||
|
})) as TData[];
|
||||||
|
}
|
||||||
|
// Not loading and no data - return empty
|
||||||
|
return data;
|
||||||
|
}, [isLoading, data, limit, skeletonRowCount]);
|
||||||
|
|
||||||
|
// Compute key data for each row (handles duplicates, group prefixes)
|
||||||
|
// Skip computation when loading - skeleton data doesn't have the required properties
|
||||||
|
// eslint-disable-next-line sonarjs/cognitive-complexity
|
||||||
|
const rowKeyData = useMemo(() => {
|
||||||
|
if (!getRowKey || isLoading) {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
const keyCount = new Map<string, number>();
|
||||||
|
|
||||||
|
return effectiveData.map((item, index) => {
|
||||||
|
const itemIdentifier = getRowKey(item);
|
||||||
|
const itemKey = getItemKey?.(item) ?? itemIdentifier;
|
||||||
|
const groupMeta = groupBy?.length ? getGroupKey?.(item) : undefined;
|
||||||
|
|
||||||
|
// Build rowKey with group prefix when grouped
|
||||||
|
let rowKey: string;
|
||||||
|
if (groupBy?.length && groupMeta) {
|
||||||
|
const groupKeyStr = Object.values(groupMeta).join('-');
|
||||||
|
if (groupKeyStr && itemIdentifier) {
|
||||||
|
rowKey = `${groupKeyStr}-${itemIdentifier}`;
|
||||||
|
} else {
|
||||||
|
rowKey = groupKeyStr || itemIdentifier || String(index);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
rowKey = itemIdentifier || String(index);
|
||||||
|
}
|
||||||
|
|
||||||
|
const count = keyCount.get(rowKey) || 0;
|
||||||
|
keyCount.set(rowKey, count + 1);
|
||||||
|
const finalKey = count > 0 ? `${rowKey}-${count}` : rowKey;
|
||||||
|
|
||||||
|
return { finalKey, itemKey, groupMeta };
|
||||||
|
});
|
||||||
|
}, [effectiveData, getRowKey, getItemKey, groupBy, getGroupKey, isLoading]);
|
||||||
|
|
||||||
|
const getRowKeyData = useCallback((index: number) => rowKeyData?.[index], [
|
||||||
|
rowKeyData,
|
||||||
|
]);
|
||||||
|
|
||||||
|
const columnPinning = useMemo<ColumnPinningState>(
|
||||||
|
() => ({
|
||||||
|
left: columns.filter((c) => c.pin === 'left').map((c) => c.id),
|
||||||
|
right: columns.filter((c) => c.pin === 'right').map((c) => c.id),
|
||||||
|
}),
|
||||||
|
[columns],
|
||||||
|
);
|
||||||
|
|
||||||
|
const tanstackColumns = useMemo<ColumnDef<TData>[]>(
|
||||||
|
() =>
|
||||||
|
columns.map((colDef) =>
|
||||||
|
buildTanstackColumnDef(colDef, isRowActive, getRowKeyData),
|
||||||
|
),
|
||||||
|
[columns, isRowActive, getRowKeyData],
|
||||||
|
);
|
||||||
|
|
||||||
|
const getRowId = useCallback(
|
||||||
|
(row: TData, index: number): string => {
|
||||||
|
// Use rowKeyData if available (new API)
|
||||||
|
if (rowKeyData) {
|
||||||
|
return rowKeyData[index]?.finalKey ?? String(index);
|
||||||
|
}
|
||||||
|
// Legacy: use getRowIdProp
|
||||||
|
if (getRowIdProp) {
|
||||||
|
return getRowIdProp(row, index);
|
||||||
|
}
|
||||||
|
const r = row as Record<string, unknown>;
|
||||||
|
if (r != null && typeof r.id !== 'undefined') {
|
||||||
|
return String(r.id);
|
||||||
|
}
|
||||||
|
return String(index);
|
||||||
|
},
|
||||||
|
[rowKeyData, getRowIdProp],
|
||||||
|
);
|
||||||
|
|
||||||
|
const tableGetRowCanExpand = useCallback(
|
||||||
|
(row: Row<TData>): boolean =>
|
||||||
|
getRowCanExpand ? getRowCanExpand(row.original) : true,
|
||||||
|
[getRowCanExpand],
|
||||||
|
);
|
||||||
|
|
||||||
|
const table = useReactTable({
|
||||||
|
data: effectiveData,
|
||||||
|
columns: tanstackColumns,
|
||||||
|
enableColumnResizing: true,
|
||||||
|
enableColumnPinning: true,
|
||||||
|
columnResizeMode: 'onChange',
|
||||||
|
getCoreRowModel: getCoreRowModel(),
|
||||||
|
getRowId,
|
||||||
|
enableExpanding: Boolean(renderExpandedRow),
|
||||||
|
getRowCanExpand: renderExpandedRow ? tableGetRowCanExpand : undefined,
|
||||||
|
onColumnSizingChange: onColumnSizingChange ?? noopColumnSizing,
|
||||||
|
onColumnVisibilityChange: onColumnVisibilityChange ?? noopColumnVisibility,
|
||||||
|
onExpandedChange: setExpanded,
|
||||||
|
state: {
|
||||||
|
columnSizing: columnSizingProp ?? {},
|
||||||
|
columnVisibility: columnVisibilityProp ?? {},
|
||||||
|
columnPinning,
|
||||||
|
expanded,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
// Keep refs to avoid recreating virtuosoComponents on every resize/render
|
||||||
|
const tableRef = useRef(table);
|
||||||
|
tableRef.current = table;
|
||||||
|
const columnsRef = useRef(columns);
|
||||||
|
columnsRef.current = columns;
|
||||||
|
|
||||||
|
const tableRows = table.getRowModel().rows;
|
||||||
|
|
||||||
|
const flatItems = useMemo<FlatItem<TData>[]>(() => {
|
||||||
|
const result: FlatItem<TData>[] = [];
|
||||||
|
for (const row of tableRows) {
|
||||||
|
result.push({ kind: 'row', row });
|
||||||
|
if (renderExpandedRow && row.getIsExpanded()) {
|
||||||
|
result.push({ kind: 'expansion', row });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
// expanded needs to be here, otherwise the rows are not updated when you click to expand
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
}, [tableRows, renderExpandedRow, expanded]);
|
||||||
|
|
||||||
|
// keep previous count just to avoid flashing the pagination component
|
||||||
|
const prevTotalCountRef = useRef(pagination?.total || 0);
|
||||||
|
if (pagination?.total && pagination?.total > 0) {
|
||||||
|
prevTotalCountRef.current = pagination?.total;
|
||||||
|
}
|
||||||
|
const effectiveTotalCount = !isLoading
|
||||||
|
? pagination?.total || 0
|
||||||
|
: prevTotalCountRef.current;
|
||||||
|
|
||||||
|
const flatIndexForActiveRow = useMemo(() => {
|
||||||
|
if (activeRowIndex == null || activeRowIndex < 0) {
|
||||||
|
return -1;
|
||||||
|
}
|
||||||
|
for (let i = 0; i < flatItems.length; i++) {
|
||||||
|
const item = flatItems[i];
|
||||||
|
if (item.kind === 'row' && item.row.index === activeRowIndex) {
|
||||||
|
return i;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return -1;
|
||||||
|
}, [activeRowIndex, flatItems]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (flatIndexForActiveRow < 0) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
virtuosoRef.current?.scrollToIndex({
|
||||||
|
index: flatIndexForActiveRow,
|
||||||
|
align: 'center',
|
||||||
|
behavior: 'auto',
|
||||||
|
});
|
||||||
|
}, [flatIndexForActiveRow]);
|
||||||
|
|
||||||
|
const sensors = useSensors(
|
||||||
|
useSensor(PointerSensor, { activationConstraint: { distance: 4 } }),
|
||||||
|
);
|
||||||
|
|
||||||
|
const columnIds = useMemo(() => columns.map((c) => c.id), [columns]);
|
||||||
|
|
||||||
|
const handleDragEnd = useCallback(
|
||||||
|
(event: DragEndEvent): void => {
|
||||||
|
const { active, over } = event;
|
||||||
|
if (!over || active.id === over.id || !onColumnOrderChange) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const activeCol = columns.find((c) => c.id === String(active.id));
|
||||||
|
const overCol = columns.find((c) => c.id === String(over.id));
|
||||||
|
if (
|
||||||
|
!activeCol ||
|
||||||
|
!overCol ||
|
||||||
|
activeCol.pin != null ||
|
||||||
|
overCol.pin != null ||
|
||||||
|
activeCol.enableMove === false
|
||||||
|
) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const oldIndex = columns.findIndex((c) => c.id === String(active.id));
|
||||||
|
const newIndex = columns.findIndex((c) => c.id === String(over.id));
|
||||||
|
if (oldIndex === -1 || newIndex === -1) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
onColumnOrderChange(arrayMove(columns, oldIndex, newIndex));
|
||||||
|
},
|
||||||
|
[columns, onColumnOrderChange],
|
||||||
|
);
|
||||||
|
|
||||||
|
const hasSingleColumn = useMemo(
|
||||||
|
() => columns.filter((c) => !c.pin && c.enableRemove !== false).length <= 1,
|
||||||
|
[columns],
|
||||||
|
);
|
||||||
|
|
||||||
|
const canRemoveColumn = !hasSingleColumn;
|
||||||
|
|
||||||
|
const flatHeaders = useMemo(
|
||||||
|
() => table.getFlatHeaders().filter((header) => !header.isPlaceholder),
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
[tanstackColumns, columnPinning, columnVisibilityProp],
|
||||||
|
);
|
||||||
|
|
||||||
|
const columnsById = useMemo(
|
||||||
|
() => new Map(columns.map((c) => [c.id, c] as const)),
|
||||||
|
[columns],
|
||||||
|
);
|
||||||
|
|
||||||
|
const visibleColumnsCount = table.getVisibleFlatColumns().length;
|
||||||
|
|
||||||
|
const columnOrderKey = useMemo(() => columnIds.join(','), [columnIds]);
|
||||||
|
const columnVisibilityKey = useMemo(
|
||||||
|
() =>
|
||||||
|
table
|
||||||
|
.getVisibleFlatColumns()
|
||||||
|
.map((c) => c.id)
|
||||||
|
.join(','),
|
||||||
|
// we want to explicitly have table out of this deps
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
[columnVisibilityProp, columnIds],
|
||||||
|
);
|
||||||
|
|
||||||
|
const virtuosoContext = useMemo<TableRowContext<TData>>(
|
||||||
|
() => ({
|
||||||
|
getRowStyle,
|
||||||
|
getRowClassName,
|
||||||
|
isRowActive,
|
||||||
|
renderRowActions,
|
||||||
|
onRowClick,
|
||||||
|
onRowClickNewTab,
|
||||||
|
onRowDeactivate,
|
||||||
|
renderExpandedRow,
|
||||||
|
getRowKeyData,
|
||||||
|
colCount: visibleColumnsCount,
|
||||||
|
isDarkMode,
|
||||||
|
plainTextCellLineClamp,
|
||||||
|
hasSingleColumn,
|
||||||
|
columnOrderKey,
|
||||||
|
columnVisibilityKey,
|
||||||
|
}),
|
||||||
|
[
|
||||||
|
getRowStyle,
|
||||||
|
getRowClassName,
|
||||||
|
isRowActive,
|
||||||
|
renderRowActions,
|
||||||
|
onRowClick,
|
||||||
|
onRowClickNewTab,
|
||||||
|
onRowDeactivate,
|
||||||
|
renderExpandedRow,
|
||||||
|
getRowKeyData,
|
||||||
|
visibleColumnsCount,
|
||||||
|
isDarkMode,
|
||||||
|
plainTextCellLineClamp,
|
||||||
|
hasSingleColumn,
|
||||||
|
columnOrderKey,
|
||||||
|
columnVisibilityKey,
|
||||||
|
],
|
||||||
|
);
|
||||||
|
|
||||||
|
const tableHeader = useCallback(() => {
|
||||||
|
return (
|
||||||
|
<DndContext
|
||||||
|
sensors={sensors}
|
||||||
|
collisionDetection={pointerWithin}
|
||||||
|
onDragEnd={handleDragEnd}
|
||||||
|
autoScroll={COLUMN_DND_AUTO_SCROLL}
|
||||||
|
>
|
||||||
|
<SortableContext items={columnIds} strategy={horizontalListSortingStrategy}>
|
||||||
|
<tr>
|
||||||
|
{flatHeaders.map((header) => {
|
||||||
|
const column = columnsById.get(header.id);
|
||||||
|
if (!column) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
<TanStackHeaderRow
|
||||||
|
key={header.id}
|
||||||
|
column={column}
|
||||||
|
header={header}
|
||||||
|
isDarkMode={isDarkMode}
|
||||||
|
hasSingleColumn={hasSingleColumn}
|
||||||
|
onRemoveColumn={onRemoveColumn}
|
||||||
|
canRemoveColumn={canRemoveColumn}
|
||||||
|
orderBy={orderBy}
|
||||||
|
onSort={setOrderBy}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</tr>
|
||||||
|
</SortableContext>
|
||||||
|
</DndContext>
|
||||||
|
);
|
||||||
|
}, [
|
||||||
|
sensors,
|
||||||
|
handleDragEnd,
|
||||||
|
columnIds,
|
||||||
|
flatHeaders,
|
||||||
|
columnsById,
|
||||||
|
isDarkMode,
|
||||||
|
hasSingleColumn,
|
||||||
|
onRemoveColumn,
|
||||||
|
canRemoveColumn,
|
||||||
|
orderBy,
|
||||||
|
setOrderBy,
|
||||||
|
]);
|
||||||
|
|
||||||
|
const handleEndReached = useCallback(
|
||||||
|
(index: number): void => {
|
||||||
|
onEndReached?.(index);
|
||||||
|
},
|
||||||
|
[onEndReached],
|
||||||
|
);
|
||||||
|
|
||||||
|
// Show loading spinner at the bottom for infinite scroll mode
|
||||||
|
const isInfiniteScrollMode = Boolean(onEndReached);
|
||||||
|
const showInfiniteScrollLoader = isInfiniteScrollMode && isLoading;
|
||||||
|
|
||||||
|
useImperativeHandle(
|
||||||
|
forwardedRef,
|
||||||
|
(): TanStackTableHandle =>
|
||||||
|
new Proxy(
|
||||||
|
{
|
||||||
|
goToPage: (p: number): void => {
|
||||||
|
setPage(p);
|
||||||
|
virtuosoRef.current?.scrollToIndex({
|
||||||
|
index: 0,
|
||||||
|
align: 'start',
|
||||||
|
});
|
||||||
|
},
|
||||||
|
} as TanStackTableHandle,
|
||||||
|
{
|
||||||
|
get(target, prop): unknown {
|
||||||
|
if (prop in target) {
|
||||||
|
return Reflect.get(target, prop);
|
||||||
|
}
|
||||||
|
const v = (virtuosoRef.current as unknown) as Record<string, unknown>;
|
||||||
|
const value = v?.[prop as string];
|
||||||
|
if (typeof value === 'function') {
|
||||||
|
return (value as (...a: unknown[]) => unknown).bind(virtuosoRef.current);
|
||||||
|
}
|
||||||
|
return value;
|
||||||
|
},
|
||||||
|
},
|
||||||
|
),
|
||||||
|
[setPage],
|
||||||
|
);
|
||||||
|
|
||||||
|
const showPagination = Boolean(pagination && !onEndReached);
|
||||||
|
|
||||||
|
const { className: tableScrollerClassName, ...restTableScrollerProps } =
|
||||||
|
tableScrollerProps ?? {};
|
||||||
|
|
||||||
|
const cellTypographyClass = useMemo((): string | undefined => {
|
||||||
|
if (cellTypographySize === 'small') {
|
||||||
|
return viewStyles.cellTypographySmall;
|
||||||
|
}
|
||||||
|
if (cellTypographySize === 'medium') {
|
||||||
|
return viewStyles.cellTypographyMedium;
|
||||||
|
}
|
||||||
|
if (cellTypographySize === 'large') {
|
||||||
|
return viewStyles.cellTypographyLarge;
|
||||||
|
}
|
||||||
|
return undefined;
|
||||||
|
}, [cellTypographySize]);
|
||||||
|
|
||||||
|
const virtuosoClassName = useMemo(
|
||||||
|
() =>
|
||||||
|
cx(
|
||||||
|
viewStyles.tanstackTableVirtuosoScroll,
|
||||||
|
cellTypographyClass,
|
||||||
|
tableScrollerClassName,
|
||||||
|
),
|
||||||
|
[cellTypographyClass, tableScrollerClassName],
|
||||||
|
);
|
||||||
|
|
||||||
|
const virtuosoTableStyle = useMemo(
|
||||||
|
() =>
|
||||||
|
({
|
||||||
|
'--tanstack-plain-body-line-clamp': plainTextCellLineClamp,
|
||||||
|
} as CSSProperties),
|
||||||
|
[plainTextCellLineClamp],
|
||||||
|
);
|
||||||
|
|
||||||
|
type VirtuosoTableComponentProps = ComponentProps<
|
||||||
|
NonNullable<TableComponents<FlatItem<TData>, TableRowContext<TData>>['Table']>
|
||||||
|
>;
|
||||||
|
|
||||||
|
// Use refs in virtuosoComponents to keep the component reference stable during resize
|
||||||
|
// This prevents Virtuoso from re-rendering all rows when columns are resized
|
||||||
|
const virtuosoComponents = useMemo(
|
||||||
|
() => ({
|
||||||
|
Table: ({ style, children }: VirtuosoTableComponentProps): JSX.Element => (
|
||||||
|
<table className={tableStyles.tanStackTable} style={style}>
|
||||||
|
<VirtuosoTableColGroup
|
||||||
|
columns={columnsRef.current}
|
||||||
|
table={tableRef.current}
|
||||||
|
/>
|
||||||
|
{children}
|
||||||
|
</table>
|
||||||
|
),
|
||||||
|
TableRow: TanStackCustomTableRow,
|
||||||
|
}),
|
||||||
|
[],
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={cx(viewStyles.tanstackTableViewWrapper, className)}>
|
||||||
|
<TanStackTableStateProvider>
|
||||||
|
<TableLoadingSync
|
||||||
|
isLoading={isLoading}
|
||||||
|
isInfiniteScrollMode={isInfiniteScrollMode}
|
||||||
|
/>
|
||||||
|
<ColumnVisibilitySync visibility={columnVisibilityProp ?? {}} />
|
||||||
|
<TooltipProvider>
|
||||||
|
<TableVirtuoso<FlatItem<TData>, TableRowContext<TData>>
|
||||||
|
className={virtuosoClassName}
|
||||||
|
ref={virtuosoRef}
|
||||||
|
{...restTableScrollerProps}
|
||||||
|
data={flatItems}
|
||||||
|
totalCount={flatItems.length}
|
||||||
|
context={virtuosoContext}
|
||||||
|
increaseViewportBy={INCREASE_VIEWPORT_BY}
|
||||||
|
initialTopMostItemIndex={
|
||||||
|
flatIndexForActiveRow >= 0 ? flatIndexForActiveRow : 0
|
||||||
|
}
|
||||||
|
fixedHeaderContent={tableHeader}
|
||||||
|
style={virtuosoTableStyle}
|
||||||
|
components={virtuosoComponents}
|
||||||
|
endReached={onEndReached ? handleEndReached : undefined}
|
||||||
|
data-testid={testId}
|
||||||
|
/>
|
||||||
|
{showInfiniteScrollLoader && (
|
||||||
|
<div
|
||||||
|
className={viewStyles.tanstackLoadingOverlay}
|
||||||
|
data-testid="tanstack-infinite-loader"
|
||||||
|
>
|
||||||
|
<Spin indicator={<LoadingOutlined spin />} tip="Loading more..." />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{showPagination && pagination && (
|
||||||
|
<div className={viewStyles.paginationContainer}>
|
||||||
|
{prefixPaginationContent}
|
||||||
|
<Pagination
|
||||||
|
current={page}
|
||||||
|
pageSize={limit}
|
||||||
|
total={effectiveTotalCount}
|
||||||
|
onPageChange={(p): void => {
|
||||||
|
setPage(p);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<div className={viewStyles.paginationPageSize}>
|
||||||
|
<ComboboxSimple
|
||||||
|
value={limit?.toString()}
|
||||||
|
defaultValue="10"
|
||||||
|
onChange={(value): void => setLimit(+value)}
|
||||||
|
items={paginationPageSizeItems}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
{suffixPaginationContent}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</TooltipProvider>
|
||||||
|
</TanStackTableStateProvider>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const TanStackTableForward = forwardRef(TanStackTableInner) as <TData>(
|
||||||
|
props: TanStackTableProps<TData> & {
|
||||||
|
ref?: React.Ref<TanStackTableHandle>;
|
||||||
|
},
|
||||||
|
) => JSX.Element;
|
||||||
|
|
||||||
|
export const TanStackTableBase = memo(
|
||||||
|
TanStackTableForward,
|
||||||
|
) as typeof TanStackTableForward;
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
.headerSkeleton {
|
||||||
|
width: 60% !important;
|
||||||
|
min-width: 50px !important;
|
||||||
|
height: 16px !important;
|
||||||
|
|
||||||
|
:global(.ant-skeleton-input) {
|
||||||
|
min-width: 50px !important;
|
||||||
|
height: 16px !important;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.cellSkeleton {
|
||||||
|
width: 80% !important;
|
||||||
|
min-width: 40px !important;
|
||||||
|
height: 14px !important;
|
||||||
|
|
||||||
|
:global(.ant-skeleton-input) {
|
||||||
|
min-width: 40px !important;
|
||||||
|
height: 14px !important;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,129 @@
|
|||||||
|
import { useMemo } from 'react';
|
||||||
|
import type { ColumnSizingState } from '@tanstack/react-table';
|
||||||
|
import { Skeleton } from 'antd';
|
||||||
|
|
||||||
|
import { TableColumnDef } from './types';
|
||||||
|
import {
|
||||||
|
getColumnInitialSize,
|
||||||
|
getColumnMaxWidth,
|
||||||
|
getColumnMinWidthPx,
|
||||||
|
} from './utils';
|
||||||
|
|
||||||
|
import tableStyles from './TanStackTable.module.scss';
|
||||||
|
import styles from './TanStackTableSkeleton.module.scss';
|
||||||
|
|
||||||
|
type TanStackTableSkeletonProps<TData> = {
|
||||||
|
columns: TableColumnDef<TData>[];
|
||||||
|
rowCount: number;
|
||||||
|
isDarkMode: boolean;
|
||||||
|
columnSizing?: ColumnSizingState;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function TanStackTableSkeleton<TData>({
|
||||||
|
columns,
|
||||||
|
rowCount,
|
||||||
|
isDarkMode,
|
||||||
|
columnSizing,
|
||||||
|
}: TanStackTableSkeletonProps<TData>): JSX.Element {
|
||||||
|
const rows = useMemo(() => Array.from({ length: rowCount }, (_, i) => i), [
|
||||||
|
rowCount,
|
||||||
|
]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<table className={tableStyles.tanStackTable}>
|
||||||
|
<colgroup>
|
||||||
|
{columns.map((column) => {
|
||||||
|
const isFixedColumn = column.width?.fixed != null;
|
||||||
|
const hasDefaultWidth = column.width?.default != null;
|
||||||
|
const hasMinMax = column.width?.min != null && column.width?.max != null;
|
||||||
|
const minWidthPx = getColumnMinWidthPx(column);
|
||||||
|
const maxWidthPx = getColumnMaxWidth(column);
|
||||||
|
const persistedWidth = columnSizing?.[column.id];
|
||||||
|
|
||||||
|
// Fixed columns get exact width with no flexibility
|
||||||
|
if (isFixedColumn) {
|
||||||
|
const fixedWidth = column.width?.fixed;
|
||||||
|
return (
|
||||||
|
<col
|
||||||
|
key={column.id}
|
||||||
|
style={{
|
||||||
|
width: fixedWidth,
|
||||||
|
minWidth: fixedWidth,
|
||||||
|
maxWidth: fixedWidth,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// User has resized this column - use persisted width
|
||||||
|
if (persistedWidth != null) {
|
||||||
|
const width = Math.max(persistedWidth, minWidthPx);
|
||||||
|
return (
|
||||||
|
<col
|
||||||
|
key={column.id}
|
||||||
|
style={{
|
||||||
|
width,
|
||||||
|
minWidth: minWidthPx,
|
||||||
|
maxWidth: maxWidthPx,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Columns with min+max but no default: auto-size within bounds
|
||||||
|
if (hasMinMax && !hasDefaultWidth) {
|
||||||
|
return (
|
||||||
|
<col
|
||||||
|
key={column.id}
|
||||||
|
style={{
|
||||||
|
minWidth: minWidthPx,
|
||||||
|
maxWidth: maxWidthPx,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// For other columns, use initial size with min/max constraints
|
||||||
|
return (
|
||||||
|
<col
|
||||||
|
key={column.id}
|
||||||
|
style={{
|
||||||
|
width: getColumnInitialSize(column),
|
||||||
|
minWidth: minWidthPx,
|
||||||
|
maxWidth: maxWidthPx,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</colgroup>
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
{columns.map((column) => (
|
||||||
|
<th
|
||||||
|
key={column.id}
|
||||||
|
className={tableStyles.tableHeaderCell}
|
||||||
|
data-dark-mode={isDarkMode}
|
||||||
|
>
|
||||||
|
{typeof column.header === 'function' ? (
|
||||||
|
<Skeleton.Input active size="small" className={styles.headerSkeleton} />
|
||||||
|
) : (
|
||||||
|
column.header
|
||||||
|
)}
|
||||||
|
</th>
|
||||||
|
))}
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{rows.map((rowIndex) => (
|
||||||
|
<tr key={rowIndex} className={tableStyles.tableRow}>
|
||||||
|
{columns.map((column) => (
|
||||||
|
<td key={column.id} className={tableStyles.tableCell}>
|
||||||
|
<Skeleton.Input active size="small" className={styles.cellSkeleton} />
|
||||||
|
</td>
|
||||||
|
))}
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,227 @@
|
|||||||
|
/* eslint-disable no-restricted-imports */
|
||||||
|
import {
|
||||||
|
createContext,
|
||||||
|
ReactNode,
|
||||||
|
useCallback,
|
||||||
|
useContext,
|
||||||
|
useEffect,
|
||||||
|
useRef,
|
||||||
|
} from 'react';
|
||||||
|
/* eslint-enable no-restricted-imports */
|
||||||
|
import { VisibilityState } from '@tanstack/react-table';
|
||||||
|
import { createStore, StoreApi, useStore } from 'zustand';
|
||||||
|
|
||||||
|
const CLEAR_HOVER_DELAY_MS = 100;
|
||||||
|
|
||||||
|
type TableState = {
|
||||||
|
// Hover state
|
||||||
|
hoveredRowId: string | null;
|
||||||
|
clearTimeoutId: ReturnType<typeof setTimeout> | null;
|
||||||
|
setHoveredRowId: (id: string | null) => void;
|
||||||
|
scheduleClearHover: (rowId: string) => void;
|
||||||
|
|
||||||
|
// Loading state
|
||||||
|
isLoading: boolean;
|
||||||
|
setIsLoading: (loading: boolean) => void;
|
||||||
|
|
||||||
|
// Infinite scroll mode - when enabled, cells don't show skeleton on load
|
||||||
|
isInfiniteScrollMode: boolean;
|
||||||
|
setIsInfiniteScrollMode: (enabled: boolean) => void;
|
||||||
|
|
||||||
|
// Column visibility state
|
||||||
|
columnVisibility: VisibilityState;
|
||||||
|
setColumnVisibility: (visibility: VisibilityState) => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
const createTableStateStore = (): StoreApi<TableState> =>
|
||||||
|
createStore<TableState>((set, get) => ({
|
||||||
|
// Hover state
|
||||||
|
hoveredRowId: null,
|
||||||
|
clearTimeoutId: null,
|
||||||
|
setHoveredRowId: (id: string | null): void => {
|
||||||
|
const { clearTimeoutId } = get();
|
||||||
|
if (clearTimeoutId) {
|
||||||
|
clearTimeout(clearTimeoutId);
|
||||||
|
set({ clearTimeoutId: null });
|
||||||
|
}
|
||||||
|
set({ hoveredRowId: id });
|
||||||
|
},
|
||||||
|
scheduleClearHover: (rowId: string): void => {
|
||||||
|
const { clearTimeoutId } = get();
|
||||||
|
if (clearTimeoutId) {
|
||||||
|
clearTimeout(clearTimeoutId);
|
||||||
|
}
|
||||||
|
const timeoutId = setTimeout(() => {
|
||||||
|
const current = get().hoveredRowId;
|
||||||
|
if (current === rowId) {
|
||||||
|
set({ hoveredRowId: null, clearTimeoutId: null });
|
||||||
|
}
|
||||||
|
}, CLEAR_HOVER_DELAY_MS);
|
||||||
|
set({ clearTimeoutId: timeoutId });
|
||||||
|
},
|
||||||
|
|
||||||
|
// Loading state
|
||||||
|
isLoading: false,
|
||||||
|
setIsLoading: (loading: boolean): void => {
|
||||||
|
set({ isLoading: loading });
|
||||||
|
},
|
||||||
|
|
||||||
|
// Infinite scroll mode
|
||||||
|
isInfiniteScrollMode: false,
|
||||||
|
setIsInfiniteScrollMode: (enabled: boolean): void => {
|
||||||
|
set({ isInfiniteScrollMode: enabled });
|
||||||
|
},
|
||||||
|
|
||||||
|
// Column visibility state
|
||||||
|
columnVisibility: {},
|
||||||
|
setColumnVisibility: (visibility: VisibilityState): void => {
|
||||||
|
set({ columnVisibility: visibility });
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
|
||||||
|
type TableStateStore = StoreApi<TableState>;
|
||||||
|
|
||||||
|
const TanStackTableStateContext = createContext<TableStateStore | null>(null);
|
||||||
|
|
||||||
|
export function TanStackTableStateProvider({
|
||||||
|
children,
|
||||||
|
}: {
|
||||||
|
children: ReactNode;
|
||||||
|
}): JSX.Element {
|
||||||
|
const storeRef = useRef<TableStateStore | null>(null);
|
||||||
|
if (!storeRef.current) {
|
||||||
|
storeRef.current = createTableStateStore();
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
<TanStackTableStateContext.Provider value={storeRef.current}>
|
||||||
|
{children}
|
||||||
|
</TanStackTableStateContext.Provider>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const defaultStore = createTableStateStore();
|
||||||
|
|
||||||
|
// Hover hooks
|
||||||
|
export const useIsRowHovered = (rowId: string): boolean => {
|
||||||
|
const store = useContext(TanStackTableStateContext);
|
||||||
|
const isHovered = useStore(
|
||||||
|
store ?? defaultStore,
|
||||||
|
(s) => s.hoveredRowId === rowId,
|
||||||
|
);
|
||||||
|
return store ? isHovered : false;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const useSetRowHovered = (rowId: string): (() => void) => {
|
||||||
|
const store = useContext(TanStackTableStateContext);
|
||||||
|
return useCallback(() => {
|
||||||
|
if (store) {
|
||||||
|
const current = store.getState().hoveredRowId;
|
||||||
|
if (current !== rowId) {
|
||||||
|
store.getState().setHoveredRowId(rowId);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, [store, rowId]);
|
||||||
|
};
|
||||||
|
|
||||||
|
export const useClearRowHovered = (rowId: string): (() => void) => {
|
||||||
|
const store = useContext(TanStackTableStateContext);
|
||||||
|
return useCallback(() => {
|
||||||
|
if (store) {
|
||||||
|
store.getState().scheduleClearHover(rowId);
|
||||||
|
}
|
||||||
|
}, [store, rowId]);
|
||||||
|
};
|
||||||
|
|
||||||
|
// Loading hooks
|
||||||
|
export const useIsTableLoading = (): boolean => {
|
||||||
|
const store = useContext(TanStackTableStateContext);
|
||||||
|
return useStore(store ?? defaultStore, (s) => s.isLoading);
|
||||||
|
};
|
||||||
|
|
||||||
|
export const useSetTableLoading = (): ((loading: boolean) => void) => {
|
||||||
|
const store = useContext(TanStackTableStateContext);
|
||||||
|
return useCallback(
|
||||||
|
(loading: boolean) => {
|
||||||
|
if (store) {
|
||||||
|
store.getState().setIsLoading(loading);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[store],
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
// Sync component to update loading state from props
|
||||||
|
export function TableLoadingSync({
|
||||||
|
isLoading,
|
||||||
|
isInfiniteScrollMode,
|
||||||
|
}: {
|
||||||
|
isLoading: boolean;
|
||||||
|
isInfiniteScrollMode: boolean;
|
||||||
|
}): null {
|
||||||
|
const store = useContext(TanStackTableStateContext);
|
||||||
|
|
||||||
|
// Sync on mount and when props change
|
||||||
|
useEffect(() => {
|
||||||
|
if (store) {
|
||||||
|
store.getState().setIsLoading(isLoading);
|
||||||
|
store.getState().setIsInfiniteScrollMode(isInfiniteScrollMode);
|
||||||
|
}
|
||||||
|
}, [isLoading, isInfiniteScrollMode, store]);
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Hook to check if cells should show skeleton (loading but not infinite scroll mode)
|
||||||
|
export const useShouldShowCellSkeleton = (): boolean => {
|
||||||
|
const store = useContext(TanStackTableStateContext);
|
||||||
|
return useStore(
|
||||||
|
store ?? defaultStore,
|
||||||
|
(s) => s.isLoading && !s.isInfiniteScrollMode,
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
// Column visibility hooks
|
||||||
|
export const useColumnVisibility = (): VisibilityState => {
|
||||||
|
const store = useContext(TanStackTableStateContext);
|
||||||
|
return useStore(store ?? defaultStore, (s) => s.columnVisibility);
|
||||||
|
};
|
||||||
|
|
||||||
|
export const useIsColumnVisible = (columnId: string): boolean => {
|
||||||
|
const store = useContext(TanStackTableStateContext);
|
||||||
|
return useStore(
|
||||||
|
store ?? defaultStore,
|
||||||
|
(s) => s.columnVisibility[columnId] !== false,
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export const useSetColumnVisibility = (): ((
|
||||||
|
visibility: VisibilityState,
|
||||||
|
) => void) => {
|
||||||
|
const store = useContext(TanStackTableStateContext);
|
||||||
|
return useCallback(
|
||||||
|
(visibility: VisibilityState) => {
|
||||||
|
if (store) {
|
||||||
|
store.getState().setColumnVisibility(visibility);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[store],
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
// Sync component to update column visibility from props
|
||||||
|
export function ColumnVisibilitySync({
|
||||||
|
visibility,
|
||||||
|
}: {
|
||||||
|
visibility: VisibilityState;
|
||||||
|
}): null {
|
||||||
|
const setVisibility = useSetColumnVisibility();
|
||||||
|
|
||||||
|
// Sync on mount and when visibility changes
|
||||||
|
useEffect(() => {
|
||||||
|
setVisibility(visibility);
|
||||||
|
}, [visibility, setVisibility]);
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default TanStackTableStateContext;
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
import type { ReactNode } from 'react';
|
||||||
|
import cx from 'classnames';
|
||||||
|
|
||||||
|
import tableStyles from './TanStackTable.module.scss';
|
||||||
|
|
||||||
|
export type TanStackTableTextProps = {
|
||||||
|
children?: ReactNode;
|
||||||
|
className?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
function TanStackTableText({
|
||||||
|
children,
|
||||||
|
className,
|
||||||
|
}: TanStackTableTextProps): JSX.Element {
|
||||||
|
return (
|
||||||
|
<span className={cx(tableStyles.tableCellText, className)}>{children}</span>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default TanStackTableText;
|
||||||
@@ -0,0 +1,152 @@
|
|||||||
|
.tanstackTableViewWrapper {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
flex: 1;
|
||||||
|
width: 100%;
|
||||||
|
position: relative;
|
||||||
|
min-height: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tanstackFixedCol {
|
||||||
|
width: 32px;
|
||||||
|
min-width: 32px;
|
||||||
|
max-width: 32px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tanstackFillerCol {
|
||||||
|
width: 100%;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tanstackActionsCol {
|
||||||
|
width: 0;
|
||||||
|
min-width: 0;
|
||||||
|
max-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tanstackLoadMoreContainer {
|
||||||
|
width: 100%;
|
||||||
|
min-height: 56px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
padding: 8px 0 12px;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tanstackTableVirtuoso {
|
||||||
|
width: 100%;
|
||||||
|
overflow-x: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tanstackTableFootLoaderCell {
|
||||||
|
text-align: center;
|
||||||
|
padding: 8px 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tanstackTableVirtuosoScroll {
|
||||||
|
flex: 1;
|
||||||
|
width: 100%;
|
||||||
|
overflow-x: auto;
|
||||||
|
scrollbar-width: thin;
|
||||||
|
scrollbar-color: var(--bg-slate-300) transparent;
|
||||||
|
|
||||||
|
&::-webkit-scrollbar {
|
||||||
|
width: 4px;
|
||||||
|
height: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
&::-webkit-scrollbar-corner {
|
||||||
|
background: transparent;
|
||||||
|
}
|
||||||
|
|
||||||
|
&::-webkit-scrollbar-track {
|
||||||
|
background: transparent;
|
||||||
|
}
|
||||||
|
|
||||||
|
&::-webkit-scrollbar-thumb {
|
||||||
|
background: var(--bg-slate-300);
|
||||||
|
border-radius: 9999px;
|
||||||
|
}
|
||||||
|
|
||||||
|
&::-webkit-scrollbar-thumb:hover {
|
||||||
|
background: var(--bg-slate-200);
|
||||||
|
}
|
||||||
|
|
||||||
|
&.cellTypographySmall {
|
||||||
|
--tanstack-plain-cell-font-size: 11px;
|
||||||
|
--tanstack-plain-cell-line-height: 16px;
|
||||||
|
|
||||||
|
:global(table tr td),
|
||||||
|
:global(table thead th) {
|
||||||
|
font-size: 11px;
|
||||||
|
line-height: 16px;
|
||||||
|
letter-spacing: -0.07px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
&.cellTypographyMedium {
|
||||||
|
--tanstack-plain-cell-font-size: 13px;
|
||||||
|
--tanstack-plain-cell-line-height: 20px;
|
||||||
|
|
||||||
|
:global(table tr td),
|
||||||
|
:global(table thead th) {
|
||||||
|
font-size: 13px;
|
||||||
|
line-height: 20px;
|
||||||
|
letter-spacing: -0.07px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
&.cellTypographyLarge {
|
||||||
|
--tanstack-plain-cell-font-size: 14px;
|
||||||
|
--tanstack-plain-cell-line-height: 24px;
|
||||||
|
|
||||||
|
:global(table tr td),
|
||||||
|
:global(table thead th) {
|
||||||
|
font-size: 14px;
|
||||||
|
line-height: 24px;
|
||||||
|
letter-spacing: -0.07px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.paginationContainer {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: flex-end;
|
||||||
|
gap: 12px;
|
||||||
|
margin-top: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.paginationPageSize {
|
||||||
|
width: 80px;
|
||||||
|
--combobox-trigger-height: 2rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tanstackLoadingOverlay {
|
||||||
|
position: absolute;
|
||||||
|
left: 50%;
|
||||||
|
bottom: 2rem;
|
||||||
|
transform: translateX(-50%);
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
pointer-events: none;
|
||||||
|
z-index: 3;
|
||||||
|
border-radius: 8px;
|
||||||
|
padding: 8px 16px;
|
||||||
|
background: var(--l1-background);
|
||||||
|
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.15);
|
||||||
|
}
|
||||||
|
|
||||||
|
:global(.lightMode) .tanstackTableVirtuosoScroll {
|
||||||
|
scrollbar-color: var(--bg-vanilla-300) transparent;
|
||||||
|
|
||||||
|
&::-webkit-scrollbar-thumb {
|
||||||
|
background: var(--bg-vanilla-300);
|
||||||
|
}
|
||||||
|
|
||||||
|
&::-webkit-scrollbar-thumb:hover {
|
||||||
|
background: var(--bg-vanilla-100);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,67 @@
|
|||||||
|
import type { Table } from '@tanstack/react-table';
|
||||||
|
|
||||||
|
import type { TableColumnDef } from './types';
|
||||||
|
|
||||||
|
export function VirtuosoTableColGroup<TData>({
|
||||||
|
columns,
|
||||||
|
table,
|
||||||
|
}: {
|
||||||
|
columns: TableColumnDef<TData>[];
|
||||||
|
table: Table<TData>;
|
||||||
|
}): JSX.Element {
|
||||||
|
const visibleTanstackColumns = table.getVisibleFlatColumns();
|
||||||
|
const columnDefsById = new Map(columns.map((c) => [c.id, c]));
|
||||||
|
const columnSizing = table.getState().columnSizing;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<colgroup>
|
||||||
|
{visibleTanstackColumns.map((tanstackCol) => {
|
||||||
|
const colDef = columnDefsById.get(tanstackCol.id);
|
||||||
|
const isFixedColumn = colDef?.width?.fixed != null;
|
||||||
|
const hasDefaultWidth = colDef?.width?.default != null;
|
||||||
|
const hasMinMax = colDef?.width?.min != null && colDef?.width?.max != null;
|
||||||
|
const hasPersistedWidth = columnSizing[tanstackCol.id] != null;
|
||||||
|
|
||||||
|
const computedSize = tanstackCol.getSize();
|
||||||
|
const minSize = tanstackCol.columnDef.minSize;
|
||||||
|
const maxSize = tanstackCol.columnDef.maxSize;
|
||||||
|
|
||||||
|
if (isFixedColumn) {
|
||||||
|
return (
|
||||||
|
<col
|
||||||
|
key={tanstackCol.id}
|
||||||
|
style={{
|
||||||
|
width: computedSize,
|
||||||
|
minWidth: computedSize,
|
||||||
|
maxWidth: computedSize,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (hasMinMax && !hasDefaultWidth && !hasPersistedWidth) {
|
||||||
|
return (
|
||||||
|
<col
|
||||||
|
key={tanstackCol.id}
|
||||||
|
style={{
|
||||||
|
minWidth: minSize,
|
||||||
|
maxWidth: maxSize,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<col
|
||||||
|
key={tanstackCol.id}
|
||||||
|
style={{
|
||||||
|
width: computedSize,
|
||||||
|
minWidth: minSize,
|
||||||
|
maxWidth: maxSize,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</colgroup>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,253 @@
|
|||||||
|
jest.mock('../TanStackTable.module.scss', () => ({
|
||||||
|
__esModule: true,
|
||||||
|
default: {
|
||||||
|
tableRow: 'tableRow',
|
||||||
|
tableRowActive: 'tableRowActive',
|
||||||
|
tableRowExpansion: 'tableRowExpansion',
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
|
||||||
|
jest.mock('../TanStackRow', () => ({
|
||||||
|
__esModule: true,
|
||||||
|
default: (): JSX.Element => (
|
||||||
|
<td data-testid="mocked-row-cells">mocked cells</td>
|
||||||
|
),
|
||||||
|
}));
|
||||||
|
|
||||||
|
const mockSetRowHovered = jest.fn();
|
||||||
|
const mockClearRowHovered = jest.fn();
|
||||||
|
|
||||||
|
jest.mock('../TanStackTableStateContext', () => ({
|
||||||
|
useSetRowHovered: (_rowId: string): (() => void) => mockSetRowHovered,
|
||||||
|
useClearRowHovered: (_rowId: string): (() => void) => mockClearRowHovered,
|
||||||
|
}));
|
||||||
|
|
||||||
|
import { fireEvent, render, screen } from '@testing-library/react';
|
||||||
|
|
||||||
|
import TanStackCustomTableRow from '../TanStackCustomTableRow';
|
||||||
|
import type { FlatItem, TableRowContext } from '../types';
|
||||||
|
|
||||||
|
const makeItem = (id: string): FlatItem<{ id: string }> => ({
|
||||||
|
kind: 'row',
|
||||||
|
row: { original: { id }, id } as never,
|
||||||
|
});
|
||||||
|
|
||||||
|
const virtuosoAttrs = {
|
||||||
|
'data-index': 0,
|
||||||
|
'data-item-index': 0,
|
||||||
|
'data-known-size': 40,
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
const baseContext: TableRowContext<{ id: string }> = {
|
||||||
|
colCount: 1,
|
||||||
|
hasSingleColumn: false,
|
||||||
|
columnOrderKey: 'col1',
|
||||||
|
columnVisibilityKey: 'col1',
|
||||||
|
};
|
||||||
|
|
||||||
|
describe('TanStackCustomTableRow', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
mockSetRowHovered.mockClear();
|
||||||
|
mockClearRowHovered.mockClear();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('renders cells via TanStackRowCells', async () => {
|
||||||
|
render(
|
||||||
|
<table>
|
||||||
|
<tbody>
|
||||||
|
<TanStackCustomTableRow
|
||||||
|
{...virtuosoAttrs}
|
||||||
|
item={makeItem('1')}
|
||||||
|
context={baseContext}
|
||||||
|
/>
|
||||||
|
</tbody>
|
||||||
|
</table>,
|
||||||
|
);
|
||||||
|
expect(await screen.findByTestId('mocked-row-cells')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('applies active class when isRowActive returns true', () => {
|
||||||
|
const ctx: TableRowContext<{ id: string }> = {
|
||||||
|
...baseContext,
|
||||||
|
isRowActive: (row) => row.id === '1',
|
||||||
|
};
|
||||||
|
const { container } = render(
|
||||||
|
<table>
|
||||||
|
<tbody>
|
||||||
|
<TanStackCustomTableRow
|
||||||
|
{...virtuosoAttrs}
|
||||||
|
item={makeItem('1')}
|
||||||
|
context={ctx}
|
||||||
|
/>
|
||||||
|
</tbody>
|
||||||
|
</table>,
|
||||||
|
);
|
||||||
|
expect(container.querySelector('tr')).toHaveClass('tableRowActive');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does not apply active class when isRowActive returns false', () => {
|
||||||
|
const ctx: TableRowContext<{ id: string }> = {
|
||||||
|
...baseContext,
|
||||||
|
isRowActive: (row) => row.id === 'other',
|
||||||
|
};
|
||||||
|
const { container } = render(
|
||||||
|
<table>
|
||||||
|
<tbody>
|
||||||
|
<TanStackCustomTableRow
|
||||||
|
{...virtuosoAttrs}
|
||||||
|
item={makeItem('1')}
|
||||||
|
context={ctx}
|
||||||
|
/>
|
||||||
|
</tbody>
|
||||||
|
</table>,
|
||||||
|
);
|
||||||
|
expect(container.querySelector('tr')).not.toHaveClass('tableRowActive');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('renders expansion row with expansion class', () => {
|
||||||
|
const item: FlatItem<{ id: string }> = {
|
||||||
|
kind: 'expansion',
|
||||||
|
row: { original: { id: '1' }, id: '1' } as never,
|
||||||
|
};
|
||||||
|
const { container } = render(
|
||||||
|
<table>
|
||||||
|
<tbody>
|
||||||
|
<TanStackCustomTableRow
|
||||||
|
{...virtuosoAttrs}
|
||||||
|
item={item}
|
||||||
|
context={baseContext}
|
||||||
|
/>
|
||||||
|
</tbody>
|
||||||
|
</table>,
|
||||||
|
);
|
||||||
|
expect(container.querySelector('tr')).toHaveClass('tableRowExpansion');
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('hover state management', () => {
|
||||||
|
it('calls setRowHovered on mouse enter', () => {
|
||||||
|
const { container } = render(
|
||||||
|
<table>
|
||||||
|
<tbody>
|
||||||
|
<TanStackCustomTableRow
|
||||||
|
{...virtuosoAttrs}
|
||||||
|
item={makeItem('1')}
|
||||||
|
context={baseContext}
|
||||||
|
/>
|
||||||
|
</tbody>
|
||||||
|
</table>,
|
||||||
|
);
|
||||||
|
const row = container.querySelector('tr')!;
|
||||||
|
fireEvent.mouseEnter(row);
|
||||||
|
expect(mockSetRowHovered).toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('calls clearRowHovered on mouse leave', () => {
|
||||||
|
const { container } = render(
|
||||||
|
<table>
|
||||||
|
<tbody>
|
||||||
|
<TanStackCustomTableRow
|
||||||
|
{...virtuosoAttrs}
|
||||||
|
item={makeItem('1')}
|
||||||
|
context={baseContext}
|
||||||
|
/>
|
||||||
|
</tbody>
|
||||||
|
</table>,
|
||||||
|
);
|
||||||
|
const row = container.querySelector('tr')!;
|
||||||
|
fireEvent.mouseLeave(row);
|
||||||
|
expect(mockClearRowHovered).toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('virtuoso integration', () => {
|
||||||
|
it('forwards data-index attribute to tr element', () => {
|
||||||
|
const { container } = render(
|
||||||
|
<table>
|
||||||
|
<tbody>
|
||||||
|
<TanStackCustomTableRow
|
||||||
|
{...virtuosoAttrs}
|
||||||
|
item={makeItem('1')}
|
||||||
|
context={baseContext}
|
||||||
|
/>
|
||||||
|
</tbody>
|
||||||
|
</table>,
|
||||||
|
);
|
||||||
|
const row = container.querySelector('tr')!;
|
||||||
|
expect(row).toHaveAttribute('data-index', '0');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('forwards data-item-index attribute to tr element', () => {
|
||||||
|
const { container } = render(
|
||||||
|
<table>
|
||||||
|
<tbody>
|
||||||
|
<TanStackCustomTableRow
|
||||||
|
{...virtuosoAttrs}
|
||||||
|
item={makeItem('1')}
|
||||||
|
context={baseContext}
|
||||||
|
/>
|
||||||
|
</tbody>
|
||||||
|
</table>,
|
||||||
|
);
|
||||||
|
const row = container.querySelector('tr')!;
|
||||||
|
expect(row).toHaveAttribute('data-item-index', '0');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('forwards data-known-size attribute to tr element', () => {
|
||||||
|
const { container } = render(
|
||||||
|
<table>
|
||||||
|
<tbody>
|
||||||
|
<TanStackCustomTableRow
|
||||||
|
{...virtuosoAttrs}
|
||||||
|
item={makeItem('1')}
|
||||||
|
context={baseContext}
|
||||||
|
/>
|
||||||
|
</tbody>
|
||||||
|
</table>,
|
||||||
|
);
|
||||||
|
const row = container.querySelector('tr')!;
|
||||||
|
expect(row).toHaveAttribute('data-known-size', '40');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('row interaction', () => {
|
||||||
|
it('applies custom style from getRowStyle in context', () => {
|
||||||
|
const ctx: TableRowContext<{ id: string }> = {
|
||||||
|
...baseContext,
|
||||||
|
getRowStyle: () => ({ backgroundColor: 'red' }),
|
||||||
|
};
|
||||||
|
const { container } = render(
|
||||||
|
<table>
|
||||||
|
<tbody>
|
||||||
|
<TanStackCustomTableRow
|
||||||
|
{...virtuosoAttrs}
|
||||||
|
item={makeItem('1')}
|
||||||
|
context={ctx}
|
||||||
|
/>
|
||||||
|
</tbody>
|
||||||
|
</table>,
|
||||||
|
);
|
||||||
|
const row = container.querySelector('tr')!;
|
||||||
|
expect(row).toHaveStyle({ backgroundColor: 'red' });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('applies custom className from getRowClassName in context', () => {
|
||||||
|
const ctx: TableRowContext<{ id: string }> = {
|
||||||
|
...baseContext,
|
||||||
|
getRowClassName: () => 'custom-row-class',
|
||||||
|
};
|
||||||
|
const { container } = render(
|
||||||
|
<table>
|
||||||
|
<tbody>
|
||||||
|
<TanStackCustomTableRow
|
||||||
|
{...virtuosoAttrs}
|
||||||
|
item={makeItem('1')}
|
||||||
|
context={ctx}
|
||||||
|
/>
|
||||||
|
</tbody>
|
||||||
|
</table>,
|
||||||
|
);
|
||||||
|
const row = container.querySelector('tr')!;
|
||||||
|
expect(row).toHaveClass('custom-row-class');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,368 @@
|
|||||||
|
import { render, screen } from '@testing-library/react';
|
||||||
|
import userEvent from '@testing-library/user-event';
|
||||||
|
|
||||||
|
import TanStackHeaderRow from '../TanStackHeaderRow';
|
||||||
|
import type { TableColumnDef } from '../types';
|
||||||
|
|
||||||
|
jest.mock('@dnd-kit/sortable', () => ({
|
||||||
|
useSortable: (): any => ({
|
||||||
|
attributes: {},
|
||||||
|
listeners: {},
|
||||||
|
setNodeRef: jest.fn(),
|
||||||
|
setActivatorNodeRef: jest.fn(),
|
||||||
|
transform: null,
|
||||||
|
transition: null,
|
||||||
|
isDragging: false,
|
||||||
|
}),
|
||||||
|
}));
|
||||||
|
|
||||||
|
const col = (
|
||||||
|
id: string,
|
||||||
|
overrides?: Partial<TableColumnDef<unknown>>,
|
||||||
|
): TableColumnDef<unknown> => ({
|
||||||
|
id,
|
||||||
|
header: id,
|
||||||
|
cell: (): null => null,
|
||||||
|
...overrides,
|
||||||
|
});
|
||||||
|
|
||||||
|
const header = {
|
||||||
|
id: 'col',
|
||||||
|
column: {
|
||||||
|
getCanResize: () => true,
|
||||||
|
getIsResizing: () => false,
|
||||||
|
columnDef: { header: 'col' },
|
||||||
|
},
|
||||||
|
getResizeHandler: () => jest.fn(),
|
||||||
|
getContext: () => ({}),
|
||||||
|
} as never;
|
||||||
|
|
||||||
|
describe('TanStackHeaderRow', () => {
|
||||||
|
it('renders column title', () => {
|
||||||
|
render(
|
||||||
|
<table>
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<TanStackHeaderRow
|
||||||
|
column={col('timestamp', { header: 'timestamp' })}
|
||||||
|
header={header}
|
||||||
|
isDarkMode={false}
|
||||||
|
hasSingleColumn={false}
|
||||||
|
/>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
</table>,
|
||||||
|
);
|
||||||
|
expect(screen.getByTitle('Timestamp')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('shows grip icon when enableMove is not false and pin is not set', () => {
|
||||||
|
render(
|
||||||
|
<table>
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<TanStackHeaderRow
|
||||||
|
column={col('body')}
|
||||||
|
header={header}
|
||||||
|
isDarkMode={false}
|
||||||
|
hasSingleColumn={false}
|
||||||
|
/>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
</table>,
|
||||||
|
);
|
||||||
|
expect(
|
||||||
|
screen.getByRole('button', { name: /drag body/i }),
|
||||||
|
).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does NOT show grip icon when pin is set', () => {
|
||||||
|
render(
|
||||||
|
<table>
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<TanStackHeaderRow
|
||||||
|
column={col('indicator', { pin: 'left' })}
|
||||||
|
header={header}
|
||||||
|
isDarkMode={false}
|
||||||
|
hasSingleColumn={false}
|
||||||
|
/>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
</table>,
|
||||||
|
);
|
||||||
|
expect(
|
||||||
|
screen.queryByRole('button', { name: /drag/i }),
|
||||||
|
).not.toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('shows remove button when enableRemove and canRemoveColumn are true', async () => {
|
||||||
|
const user = userEvent.setup();
|
||||||
|
const onRemoveColumn = jest.fn();
|
||||||
|
render(
|
||||||
|
<table>
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<TanStackHeaderRow
|
||||||
|
column={col('name', { enableRemove: true })}
|
||||||
|
header={header}
|
||||||
|
isDarkMode={false}
|
||||||
|
hasSingleColumn={false}
|
||||||
|
canRemoveColumn
|
||||||
|
onRemoveColumn={onRemoveColumn}
|
||||||
|
/>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
</table>,
|
||||||
|
);
|
||||||
|
await user.click(screen.getByRole('button', { name: /column actions/i }));
|
||||||
|
await user.click(await screen.findByText(/remove column/i));
|
||||||
|
expect(onRemoveColumn).toHaveBeenCalledWith('name');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does NOT show remove button when enableRemove is absent', () => {
|
||||||
|
render(
|
||||||
|
<table>
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<TanStackHeaderRow
|
||||||
|
column={col('name')}
|
||||||
|
header={header}
|
||||||
|
isDarkMode={false}
|
||||||
|
hasSingleColumn={false}
|
||||||
|
canRemoveColumn
|
||||||
|
onRemoveColumn={jest.fn()}
|
||||||
|
/>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
</table>,
|
||||||
|
);
|
||||||
|
expect(
|
||||||
|
screen.queryByRole('button', { name: /column actions/i }),
|
||||||
|
).not.toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('sorting', () => {
|
||||||
|
const sortableCol = col('sortable', { enableSort: true, header: 'Sortable' });
|
||||||
|
const sortableHeader = {
|
||||||
|
id: 'sortable',
|
||||||
|
column: {
|
||||||
|
id: 'sortable',
|
||||||
|
getCanResize: (): boolean => true,
|
||||||
|
getIsResizing: (): boolean => false,
|
||||||
|
columnDef: { header: 'Sortable', enableSort: true },
|
||||||
|
},
|
||||||
|
getResizeHandler: (): jest.Mock => jest.fn(),
|
||||||
|
getContext: (): Record<string, unknown> => ({}),
|
||||||
|
} as never;
|
||||||
|
|
||||||
|
it('calls onSort with asc when clicking unsorted column', async () => {
|
||||||
|
const user = userEvent.setup();
|
||||||
|
const onSort = jest.fn();
|
||||||
|
render(
|
||||||
|
<table>
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<TanStackHeaderRow
|
||||||
|
column={sortableCol}
|
||||||
|
header={sortableHeader}
|
||||||
|
isDarkMode={false}
|
||||||
|
hasSingleColumn={false}
|
||||||
|
onSort={onSort}
|
||||||
|
/>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
</table>,
|
||||||
|
);
|
||||||
|
// Sort button uses the column header as title
|
||||||
|
const sortButton = screen.getByTitle('Sortable');
|
||||||
|
await user.click(sortButton);
|
||||||
|
expect(onSort).toHaveBeenCalledWith({
|
||||||
|
columnName: 'sortable',
|
||||||
|
order: 'asc',
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('calls onSort with desc when clicking asc-sorted column', async () => {
|
||||||
|
const user = userEvent.setup();
|
||||||
|
const onSort = jest.fn();
|
||||||
|
render(
|
||||||
|
<table>
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<TanStackHeaderRow
|
||||||
|
column={sortableCol}
|
||||||
|
header={sortableHeader}
|
||||||
|
isDarkMode={false}
|
||||||
|
hasSingleColumn={false}
|
||||||
|
onSort={onSort}
|
||||||
|
orderBy={{ columnName: 'sortable', order: 'asc' }}
|
||||||
|
/>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
</table>,
|
||||||
|
);
|
||||||
|
const sortButton = screen.getByTitle('Sortable');
|
||||||
|
await user.click(sortButton);
|
||||||
|
expect(onSort).toHaveBeenCalledWith({
|
||||||
|
columnName: 'sortable',
|
||||||
|
order: 'desc',
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('calls onSort with null when clicking desc-sorted column', async () => {
|
||||||
|
const user = userEvent.setup();
|
||||||
|
const onSort = jest.fn();
|
||||||
|
render(
|
||||||
|
<table>
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<TanStackHeaderRow
|
||||||
|
column={sortableCol}
|
||||||
|
header={sortableHeader}
|
||||||
|
isDarkMode={false}
|
||||||
|
hasSingleColumn={false}
|
||||||
|
onSort={onSort}
|
||||||
|
orderBy={{ columnName: 'sortable', order: 'desc' }}
|
||||||
|
/>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
</table>,
|
||||||
|
);
|
||||||
|
const sortButton = screen.getByTitle('Sortable');
|
||||||
|
await user.click(sortButton);
|
||||||
|
expect(onSort).toHaveBeenCalledWith(null);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('shows ascending indicator when orderBy matches column with asc', () => {
|
||||||
|
render(
|
||||||
|
<table>
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<TanStackHeaderRow
|
||||||
|
column={sortableCol}
|
||||||
|
header={sortableHeader}
|
||||||
|
isDarkMode={false}
|
||||||
|
hasSingleColumn={false}
|
||||||
|
onSort={jest.fn()}
|
||||||
|
orderBy={{ columnName: 'sortable', order: 'asc' }}
|
||||||
|
/>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
</table>,
|
||||||
|
);
|
||||||
|
const sortButton = screen.getByTitle('Sortable');
|
||||||
|
expect(sortButton).toHaveAttribute('aria-sort', 'ascending');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('shows descending indicator when orderBy matches column with desc', () => {
|
||||||
|
render(
|
||||||
|
<table>
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<TanStackHeaderRow
|
||||||
|
column={sortableCol}
|
||||||
|
header={sortableHeader}
|
||||||
|
isDarkMode={false}
|
||||||
|
hasSingleColumn={false}
|
||||||
|
onSort={jest.fn()}
|
||||||
|
orderBy={{ columnName: 'sortable', order: 'desc' }}
|
||||||
|
/>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
</table>,
|
||||||
|
);
|
||||||
|
const sortButton = screen.getByTitle('Sortable');
|
||||||
|
expect(sortButton).toHaveAttribute('aria-sort', 'descending');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does not show sort button when enableSort is false', () => {
|
||||||
|
const nonSortableCol = col('nonsort', {
|
||||||
|
enableSort: false,
|
||||||
|
header: 'Nonsort',
|
||||||
|
});
|
||||||
|
render(
|
||||||
|
<table>
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<TanStackHeaderRow
|
||||||
|
column={nonSortableCol}
|
||||||
|
header={header}
|
||||||
|
isDarkMode={false}
|
||||||
|
hasSingleColumn={false}
|
||||||
|
/>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
</table>,
|
||||||
|
);
|
||||||
|
// When enableSort is false, the header text is rendered as a span, not a button
|
||||||
|
// The title 'Nonsort' exists on the span, not on a button
|
||||||
|
const titleElement = screen.getByTitle('Nonsort');
|
||||||
|
expect(titleElement.tagName.toLowerCase()).not.toBe('button');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('resizing', () => {
|
||||||
|
it('shows resize handle when enableResize is not false', () => {
|
||||||
|
const resizableCol = col('resizable', { enableResize: true });
|
||||||
|
render(
|
||||||
|
<table>
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<TanStackHeaderRow
|
||||||
|
column={resizableCol}
|
||||||
|
header={header}
|
||||||
|
isDarkMode={false}
|
||||||
|
hasSingleColumn={false}
|
||||||
|
/>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
</table>,
|
||||||
|
);
|
||||||
|
// Resize handle has title "Drag to resize column"
|
||||||
|
expect(screen.getByTitle('Drag to resize column')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('hides resize handle when enableResize is false', () => {
|
||||||
|
const nonResizableCol = col('noresize', { enableResize: false });
|
||||||
|
render(
|
||||||
|
<table>
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<TanStackHeaderRow
|
||||||
|
column={nonResizableCol}
|
||||||
|
header={header}
|
||||||
|
isDarkMode={false}
|
||||||
|
hasSingleColumn={false}
|
||||||
|
/>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
</table>,
|
||||||
|
);
|
||||||
|
expect(screen.queryByTitle('Drag to resize column')).not.toBeInTheDocument();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('column movement', () => {
|
||||||
|
it('does not show grip when enableMove is false', () => {
|
||||||
|
const noMoveCol = col('nomove', { enableMove: false });
|
||||||
|
render(
|
||||||
|
<table>
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<TanStackHeaderRow
|
||||||
|
column={noMoveCol}
|
||||||
|
header={header}
|
||||||
|
isDarkMode={false}
|
||||||
|
hasSingleColumn={false}
|
||||||
|
/>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
</table>,
|
||||||
|
);
|
||||||
|
expect(
|
||||||
|
screen.queryByRole('button', { name: /drag/i }),
|
||||||
|
).not.toBeInTheDocument();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,288 @@
|
|||||||
|
import { fireEvent, render, screen } from '@testing-library/react';
|
||||||
|
import userEvent from '@testing-library/user-event';
|
||||||
|
|
||||||
|
import TanStackRowCells from '../TanStackRow';
|
||||||
|
import type { TableRowContext } from '../types';
|
||||||
|
|
||||||
|
const flexRenderMock = jest.fn((def: unknown) =>
|
||||||
|
typeof def === 'function' ? def({}) : def,
|
||||||
|
);
|
||||||
|
jest.mock('@tanstack/react-table', () => ({
|
||||||
|
flexRender: (def: unknown, _ctx?: unknown): unknown => flexRenderMock(def),
|
||||||
|
}));
|
||||||
|
|
||||||
|
type Row = { id: string };
|
||||||
|
|
||||||
|
function buildMockRow(
|
||||||
|
cells: { id: string }[],
|
||||||
|
rowData: Row = { id: 'r1' },
|
||||||
|
): Parameters<typeof TanStackRowCells>[0]['row'] {
|
||||||
|
return {
|
||||||
|
original: rowData,
|
||||||
|
getVisibleCells: () =>
|
||||||
|
cells.map((c, i) => ({
|
||||||
|
id: `cell-${i}`,
|
||||||
|
column: {
|
||||||
|
id: c.id,
|
||||||
|
columnDef: { cell: (): string => `content-${c.id}` },
|
||||||
|
},
|
||||||
|
getContext: (): Record<string, unknown> => ({}),
|
||||||
|
getValue: (): string => `content-${c.id}`,
|
||||||
|
})),
|
||||||
|
} as never;
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('TanStackRowCells', () => {
|
||||||
|
beforeEach(() => flexRenderMock.mockClear());
|
||||||
|
|
||||||
|
it('renders a cell per visible column', () => {
|
||||||
|
const row = buildMockRow([{ id: 'col-a' }, { id: 'col-b' }]);
|
||||||
|
render(
|
||||||
|
<table>
|
||||||
|
<tbody>
|
||||||
|
<tr>
|
||||||
|
<TanStackRowCells<Row>
|
||||||
|
row={row as never}
|
||||||
|
context={undefined}
|
||||||
|
itemKind="row"
|
||||||
|
hasSingleColumn={false}
|
||||||
|
columnOrderKey=""
|
||||||
|
columnVisibilityKey=""
|
||||||
|
/>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</table>,
|
||||||
|
);
|
||||||
|
expect(screen.getAllByRole('cell')).toHaveLength(2);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('calls onRowClick when a cell is clicked', async () => {
|
||||||
|
const user = userEvent.setup();
|
||||||
|
const onRowClick = jest.fn();
|
||||||
|
const ctx: TableRowContext<Row> = {
|
||||||
|
colCount: 1,
|
||||||
|
onRowClick,
|
||||||
|
hasSingleColumn: false,
|
||||||
|
columnOrderKey: '',
|
||||||
|
columnVisibilityKey: '',
|
||||||
|
};
|
||||||
|
const row = buildMockRow([{ id: 'body' }]);
|
||||||
|
render(
|
||||||
|
<table>
|
||||||
|
<tbody>
|
||||||
|
<tr>
|
||||||
|
<TanStackRowCells<Row>
|
||||||
|
row={row as never}
|
||||||
|
context={ctx}
|
||||||
|
itemKind="row"
|
||||||
|
hasSingleColumn={false}
|
||||||
|
columnOrderKey=""
|
||||||
|
columnVisibilityKey=""
|
||||||
|
/>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</table>,
|
||||||
|
);
|
||||||
|
await user.click(screen.getAllByRole('cell')[0]);
|
||||||
|
// onRowClick receives (rowData, itemKey) - itemKey is empty when getRowKeyData not provided
|
||||||
|
expect(onRowClick).toHaveBeenCalledWith({ id: 'r1' }, '');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('calls onRowDeactivate instead of onRowClick when row is active', async () => {
|
||||||
|
const user = userEvent.setup();
|
||||||
|
const onRowClick = jest.fn();
|
||||||
|
const onRowDeactivate = jest.fn();
|
||||||
|
const ctx: TableRowContext<Row> = {
|
||||||
|
colCount: 1,
|
||||||
|
onRowClick,
|
||||||
|
onRowDeactivate,
|
||||||
|
isRowActive: () => true,
|
||||||
|
hasSingleColumn: false,
|
||||||
|
columnOrderKey: '',
|
||||||
|
columnVisibilityKey: '',
|
||||||
|
};
|
||||||
|
const row = buildMockRow([{ id: 'body' }]);
|
||||||
|
render(
|
||||||
|
<table>
|
||||||
|
<tbody>
|
||||||
|
<tr>
|
||||||
|
<TanStackRowCells<Row>
|
||||||
|
row={row as never}
|
||||||
|
context={ctx}
|
||||||
|
itemKind="row"
|
||||||
|
hasSingleColumn={false}
|
||||||
|
columnOrderKey=""
|
||||||
|
columnVisibilityKey=""
|
||||||
|
/>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</table>,
|
||||||
|
);
|
||||||
|
await user.click(screen.getAllByRole('cell')[0]);
|
||||||
|
expect(onRowDeactivate).toHaveBeenCalled();
|
||||||
|
expect(onRowClick).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does not render renderRowActions before hover', () => {
|
||||||
|
const ctx: TableRowContext<Row> = {
|
||||||
|
colCount: 1,
|
||||||
|
renderRowActions: () => <button type="button">action</button>,
|
||||||
|
hasSingleColumn: false,
|
||||||
|
columnOrderKey: '',
|
||||||
|
columnVisibilityKey: '',
|
||||||
|
};
|
||||||
|
const row = buildMockRow([{ id: 'body' }]);
|
||||||
|
|
||||||
|
render(
|
||||||
|
<table>
|
||||||
|
<tbody>
|
||||||
|
<tr>
|
||||||
|
<TanStackRowCells<Row>
|
||||||
|
row={row as never}
|
||||||
|
context={ctx}
|
||||||
|
itemKind="row"
|
||||||
|
hasSingleColumn={false}
|
||||||
|
columnOrderKey=""
|
||||||
|
columnVisibilityKey=""
|
||||||
|
/>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</table>,
|
||||||
|
);
|
||||||
|
// Row actions are not rendered until hover (useIsRowHovered returns false by default)
|
||||||
|
expect(
|
||||||
|
screen.queryByRole('button', { name: 'action' }),
|
||||||
|
).not.toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('renders expansion cell with renderExpandedRow content', async () => {
|
||||||
|
const row = {
|
||||||
|
original: { id: 'r1' },
|
||||||
|
getVisibleCells: () => [],
|
||||||
|
} as never;
|
||||||
|
const ctx: TableRowContext<Row> = {
|
||||||
|
colCount: 3,
|
||||||
|
renderExpandedRow: (r) => <div>expanded-{r.id}</div>,
|
||||||
|
hasSingleColumn: false,
|
||||||
|
columnOrderKey: '',
|
||||||
|
columnVisibilityKey: '',
|
||||||
|
};
|
||||||
|
render(
|
||||||
|
<table>
|
||||||
|
<tbody>
|
||||||
|
<tr>
|
||||||
|
<TanStackRowCells<Row>
|
||||||
|
row={row as never}
|
||||||
|
context={ctx}
|
||||||
|
itemKind="expansion"
|
||||||
|
hasSingleColumn={false}
|
||||||
|
columnOrderKey=""
|
||||||
|
columnVisibilityKey=""
|
||||||
|
/>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</table>,
|
||||||
|
);
|
||||||
|
expect(await screen.findByText('expanded-r1')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('new tab click', () => {
|
||||||
|
it('calls onRowClickNewTab on ctrl+click', () => {
|
||||||
|
const onRowClick = jest.fn();
|
||||||
|
const onRowClickNewTab = jest.fn();
|
||||||
|
const ctx: TableRowContext<Row> = {
|
||||||
|
colCount: 1,
|
||||||
|
onRowClick,
|
||||||
|
onRowClickNewTab,
|
||||||
|
hasSingleColumn: false,
|
||||||
|
columnOrderKey: '',
|
||||||
|
columnVisibilityKey: '',
|
||||||
|
};
|
||||||
|
const row = buildMockRow([{ id: 'body' }]);
|
||||||
|
render(
|
||||||
|
<table>
|
||||||
|
<tbody>
|
||||||
|
<tr>
|
||||||
|
<TanStackRowCells<Row>
|
||||||
|
row={row as never}
|
||||||
|
context={ctx}
|
||||||
|
itemKind="row"
|
||||||
|
hasSingleColumn={false}
|
||||||
|
columnOrderKey=""
|
||||||
|
columnVisibilityKey=""
|
||||||
|
/>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</table>,
|
||||||
|
);
|
||||||
|
fireEvent.click(screen.getAllByRole('cell')[0], { ctrlKey: true });
|
||||||
|
expect(onRowClickNewTab).toHaveBeenCalledWith({ id: 'r1' }, '');
|
||||||
|
expect(onRowClick).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('calls onRowClickNewTab on meta+click (cmd)', () => {
|
||||||
|
const onRowClick = jest.fn();
|
||||||
|
const onRowClickNewTab = jest.fn();
|
||||||
|
const ctx: TableRowContext<Row> = {
|
||||||
|
colCount: 1,
|
||||||
|
onRowClick,
|
||||||
|
onRowClickNewTab,
|
||||||
|
hasSingleColumn: false,
|
||||||
|
columnOrderKey: '',
|
||||||
|
columnVisibilityKey: '',
|
||||||
|
};
|
||||||
|
const row = buildMockRow([{ id: 'body' }]);
|
||||||
|
render(
|
||||||
|
<table>
|
||||||
|
<tbody>
|
||||||
|
<tr>
|
||||||
|
<TanStackRowCells<Row>
|
||||||
|
row={row as never}
|
||||||
|
context={ctx}
|
||||||
|
itemKind="row"
|
||||||
|
hasSingleColumn={false}
|
||||||
|
columnOrderKey=""
|
||||||
|
columnVisibilityKey=""
|
||||||
|
/>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</table>,
|
||||||
|
);
|
||||||
|
fireEvent.click(screen.getAllByRole('cell')[0], { metaKey: true });
|
||||||
|
expect(onRowClickNewTab).toHaveBeenCalledWith({ id: 'r1' }, '');
|
||||||
|
expect(onRowClick).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does not call onRowClick when modifier key is pressed', () => {
|
||||||
|
const onRowClick = jest.fn();
|
||||||
|
const onRowClickNewTab = jest.fn();
|
||||||
|
const ctx: TableRowContext<Row> = {
|
||||||
|
colCount: 1,
|
||||||
|
onRowClick,
|
||||||
|
onRowClickNewTab,
|
||||||
|
hasSingleColumn: false,
|
||||||
|
columnOrderKey: '',
|
||||||
|
columnVisibilityKey: '',
|
||||||
|
};
|
||||||
|
const row = buildMockRow([{ id: 'body' }]);
|
||||||
|
render(
|
||||||
|
<table>
|
||||||
|
<tbody>
|
||||||
|
<tr>
|
||||||
|
<TanStackRowCells<Row>
|
||||||
|
row={row as never}
|
||||||
|
context={ctx}
|
||||||
|
itemKind="row"
|
||||||
|
hasSingleColumn={false}
|
||||||
|
columnOrderKey=""
|
||||||
|
columnVisibilityKey=""
|
||||||
|
/>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</table>,
|
||||||
|
);
|
||||||
|
fireEvent.click(screen.getAllByRole('cell')[0], { ctrlKey: true });
|
||||||
|
expect(onRowClick).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,440 @@
|
|||||||
|
import { fireEvent, screen, waitFor } from '@testing-library/react';
|
||||||
|
import userEvent from '@testing-library/user-event';
|
||||||
|
import { UrlUpdateEvent } from 'nuqs/adapters/testing';
|
||||||
|
|
||||||
|
import { renderTanStackTable } from './testUtils';
|
||||||
|
|
||||||
|
jest.mock('hooks/useDarkMode', () => ({
|
||||||
|
useIsDarkMode: (): boolean => false,
|
||||||
|
}));
|
||||||
|
|
||||||
|
jest.mock('../TanStackTable.module.scss', () => ({
|
||||||
|
__esModule: true,
|
||||||
|
default: {
|
||||||
|
tanStackTable: 'tanStackTable',
|
||||||
|
tableRow: 'tableRow',
|
||||||
|
tableRowActive: 'tableRowActive',
|
||||||
|
tableRowExpansion: 'tableRowExpansion',
|
||||||
|
tableCell: 'tableCell',
|
||||||
|
tableCellExpansion: 'tableCellExpansion',
|
||||||
|
tableHeaderCell: 'tableHeaderCell',
|
||||||
|
tableCellText: 'tableCellText',
|
||||||
|
tableViewRowActions: 'tableViewRowActions',
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
|
||||||
|
describe('TanStackTableView Integration', () => {
|
||||||
|
describe('rendering', () => {
|
||||||
|
it('renders all data rows', async () => {
|
||||||
|
renderTanStackTable({});
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(screen.getByText('Item 1')).toBeInTheDocument();
|
||||||
|
expect(screen.getByText('Item 2')).toBeInTheDocument();
|
||||||
|
expect(screen.getByText('Item 3')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('renders column headers', async () => {
|
||||||
|
renderTanStackTable({});
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(screen.getByText('ID')).toBeInTheDocument();
|
||||||
|
expect(screen.getByText('Name')).toBeInTheDocument();
|
||||||
|
expect(screen.getByText('Value')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('renders empty state when data is empty and not loading', async () => {
|
||||||
|
renderTanStackTable({
|
||||||
|
props: { data: [], isLoading: false },
|
||||||
|
});
|
||||||
|
// Table should still render but with no data rows
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(screen.queryByText('Item 1')).not.toBeInTheDocument();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('renders table structure when loading with no previous data', async () => {
|
||||||
|
renderTanStackTable({
|
||||||
|
props: { data: [], isLoading: true },
|
||||||
|
});
|
||||||
|
// Table should render with skeleton rows
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(screen.getByRole('table')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('loading states', () => {
|
||||||
|
it('keeps table mounted when loading with no data', () => {
|
||||||
|
renderTanStackTable({
|
||||||
|
props: { data: [], isLoading: true },
|
||||||
|
});
|
||||||
|
// Table should still be in the DOM for skeleton rows
|
||||||
|
expect(screen.getByRole('table')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('shows loading spinner for infinite scroll when loading', () => {
|
||||||
|
renderTanStackTable({
|
||||||
|
props: { isLoading: true, onEndReached: jest.fn() },
|
||||||
|
});
|
||||||
|
expect(screen.getByTestId('tanstack-infinite-loader')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does not show loading spinner for infinite scroll when not loading', () => {
|
||||||
|
renderTanStackTable({
|
||||||
|
props: { isLoading: false, onEndReached: jest.fn() },
|
||||||
|
});
|
||||||
|
expect(
|
||||||
|
screen.queryByTestId('tanstack-infinite-loader'),
|
||||||
|
).not.toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does not show loading spinner when not in infinite scroll mode', () => {
|
||||||
|
renderTanStackTable({
|
||||||
|
props: { isLoading: true },
|
||||||
|
});
|
||||||
|
expect(
|
||||||
|
screen.queryByTestId('tanstack-infinite-loader'),
|
||||||
|
).not.toBeInTheDocument();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('pagination', () => {
|
||||||
|
it('renders pagination when pagination prop is provided', async () => {
|
||||||
|
renderTanStackTable({
|
||||||
|
props: {
|
||||||
|
pagination: { total: 100, defaultPage: 1, defaultLimit: 10 },
|
||||||
|
},
|
||||||
|
});
|
||||||
|
await waitFor(() => {
|
||||||
|
// Look for pagination navigation or page number text
|
||||||
|
expect(screen.getByRole('navigation')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('updates page when clicking page number', async () => {
|
||||||
|
const user = userEvent.setup();
|
||||||
|
const onUrlUpdate = jest.fn<void, [UrlUpdateEvent]>();
|
||||||
|
|
||||||
|
renderTanStackTable({
|
||||||
|
props: {
|
||||||
|
pagination: { total: 100, defaultPage: 1, defaultLimit: 10 },
|
||||||
|
enableQueryParams: true,
|
||||||
|
},
|
||||||
|
onUrlUpdate,
|
||||||
|
});
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(screen.getByRole('navigation')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
// Find page 2 button/link within pagination navigation
|
||||||
|
const nav = screen.getByRole('navigation');
|
||||||
|
const page2 = Array.from(nav.querySelectorAll('button')).find(
|
||||||
|
(btn) => btn.textContent?.trim() === '2',
|
||||||
|
);
|
||||||
|
if (!page2) {
|
||||||
|
throw new Error('Page 2 button not found in pagination');
|
||||||
|
}
|
||||||
|
await user.click(page2);
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
const lastPage = onUrlUpdate.mock.calls
|
||||||
|
.map((call) => call[0].searchParams.get('page'))
|
||||||
|
.filter(Boolean)
|
||||||
|
.pop();
|
||||||
|
expect(lastPage).toBe('2');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does not render pagination in infinite scroll mode', async () => {
|
||||||
|
renderTanStackTable({
|
||||||
|
props: {
|
||||||
|
pagination: { total: 100 },
|
||||||
|
onEndReached: jest.fn(), // This enables infinite scroll mode
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(screen.getByText('Item 1')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
// Pagination should not be visible in infinite scroll mode
|
||||||
|
expect(screen.queryByRole('navigation')).not.toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('renders prefixPaginationContent before pagination', async () => {
|
||||||
|
renderTanStackTable({
|
||||||
|
props: {
|
||||||
|
pagination: { total: 100 },
|
||||||
|
prefixPaginationContent: <span data-testid="prefix-content">Prefix</span>,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(screen.getByTestId('prefix-content')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('renders suffixPaginationContent after pagination', async () => {
|
||||||
|
renderTanStackTable({
|
||||||
|
props: {
|
||||||
|
pagination: { total: 100 },
|
||||||
|
suffixPaginationContent: <span data-testid="suffix-content">Suffix</span>,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(screen.getByTestId('suffix-content')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('sorting', () => {
|
||||||
|
it('updates orderBy URL param when clicking sortable header', async () => {
|
||||||
|
const user = userEvent.setup();
|
||||||
|
const onUrlUpdate = jest.fn<void, [UrlUpdateEvent]>();
|
||||||
|
|
||||||
|
renderTanStackTable({
|
||||||
|
props: { enableQueryParams: true },
|
||||||
|
onUrlUpdate,
|
||||||
|
});
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(screen.getByText('Item 1')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
// Find the sortable column header's sort button (ID column has enableSort: true)
|
||||||
|
const sortButton = screen.getByTitle('ID');
|
||||||
|
await user.click(sortButton);
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
const lastOrderBy = onUrlUpdate.mock.calls
|
||||||
|
.map((call) => call[0].searchParams.get('order_by'))
|
||||||
|
.filter(Boolean)
|
||||||
|
.pop();
|
||||||
|
expect(lastOrderBy).toBeDefined();
|
||||||
|
const parsed = JSON.parse(lastOrderBy!);
|
||||||
|
expect(parsed.columnName).toBe('id');
|
||||||
|
expect(parsed.order).toBe('asc');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('toggles sort order on subsequent clicks', async () => {
|
||||||
|
const user = userEvent.setup();
|
||||||
|
const onUrlUpdate = jest.fn<void, [UrlUpdateEvent]>();
|
||||||
|
|
||||||
|
renderTanStackTable({
|
||||||
|
props: { enableQueryParams: true },
|
||||||
|
onUrlUpdate,
|
||||||
|
});
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(screen.getByText('Item 1')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
const sortButton = screen.getByTitle('ID');
|
||||||
|
|
||||||
|
// First click - asc
|
||||||
|
await user.click(sortButton);
|
||||||
|
// Second click - desc
|
||||||
|
await user.click(sortButton);
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
const lastOrderBy = onUrlUpdate.mock.calls
|
||||||
|
.map((call) => call[0].searchParams.get('order_by'))
|
||||||
|
.filter(Boolean)
|
||||||
|
.pop();
|
||||||
|
if (lastOrderBy) {
|
||||||
|
const parsed = JSON.parse(lastOrderBy);
|
||||||
|
expect(parsed.order).toBe('desc');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('row selection', () => {
|
||||||
|
it('calls onRowClick with row data and itemKey', async () => {
|
||||||
|
const user = userEvent.setup();
|
||||||
|
const onRowClick = jest.fn();
|
||||||
|
|
||||||
|
renderTanStackTable({
|
||||||
|
props: {
|
||||||
|
onRowClick,
|
||||||
|
getRowKey: (row) => row.id,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(screen.getByText('Item 1')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
await user.click(screen.getByText('Item 1'));
|
||||||
|
|
||||||
|
expect(onRowClick).toHaveBeenCalledWith(
|
||||||
|
expect.objectContaining({ id: '1', name: 'Item 1' }),
|
||||||
|
'1',
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('applies active class when isRowActive returns true', async () => {
|
||||||
|
renderTanStackTable({
|
||||||
|
props: {
|
||||||
|
isRowActive: (row) => row.id === '1',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(screen.getByText('Item 1')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
// Find the row containing Item 1 and check for active class
|
||||||
|
const cell = screen.getByText('Item 1');
|
||||||
|
const row = cell.closest('tr');
|
||||||
|
expect(row).toHaveClass('tableRowActive');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('calls onRowDeactivate when clicking active row', async () => {
|
||||||
|
const user = userEvent.setup();
|
||||||
|
const onRowClick = jest.fn();
|
||||||
|
const onRowDeactivate = jest.fn();
|
||||||
|
|
||||||
|
renderTanStackTable({
|
||||||
|
props: {
|
||||||
|
onRowClick,
|
||||||
|
onRowDeactivate,
|
||||||
|
isRowActive: (row) => row.id === '1',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(screen.getByText('Item 1')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
await user.click(screen.getByText('Item 1'));
|
||||||
|
|
||||||
|
expect(onRowDeactivate).toHaveBeenCalled();
|
||||||
|
expect(onRowClick).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('opens in new tab on ctrl+click', async () => {
|
||||||
|
const onRowClick = jest.fn();
|
||||||
|
const onRowClickNewTab = jest.fn();
|
||||||
|
|
||||||
|
renderTanStackTable({
|
||||||
|
props: {
|
||||||
|
onRowClick,
|
||||||
|
onRowClickNewTab,
|
||||||
|
getRowKey: (row) => row.id,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(screen.getByText('Item 1')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
fireEvent.click(screen.getByText('Item 1'), { ctrlKey: true });
|
||||||
|
|
||||||
|
expect(onRowClickNewTab).toHaveBeenCalledWith(
|
||||||
|
expect.objectContaining({ id: '1' }),
|
||||||
|
'1',
|
||||||
|
);
|
||||||
|
expect(onRowClick).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('opens in new tab on meta+click', async () => {
|
||||||
|
const onRowClick = jest.fn();
|
||||||
|
const onRowClickNewTab = jest.fn();
|
||||||
|
|
||||||
|
renderTanStackTable({
|
||||||
|
props: {
|
||||||
|
onRowClick,
|
||||||
|
onRowClickNewTab,
|
||||||
|
getRowKey: (row) => row.id,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(screen.getByText('Item 1')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
fireEvent.click(screen.getByText('Item 1'), { metaKey: true });
|
||||||
|
|
||||||
|
expect(onRowClickNewTab).toHaveBeenCalledWith(
|
||||||
|
expect.objectContaining({ id: '1' }),
|
||||||
|
'1',
|
||||||
|
);
|
||||||
|
expect(onRowClick).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('row expansion', () => {
|
||||||
|
it('renders expanded content below the row when expanded', async () => {
|
||||||
|
renderTanStackTable({
|
||||||
|
props: {
|
||||||
|
renderExpandedRow: (row) => (
|
||||||
|
<div data-testid="expanded-content">Expanded: {row.name}</div>
|
||||||
|
),
|
||||||
|
getRowCanExpand: () => true,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(screen.getByText('Item 1')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
// Find and click expand button (if available in the row)
|
||||||
|
// The expansion is controlled by TanStack Table's expanded state
|
||||||
|
// For now, just verify the renderExpandedRow prop is wired correctly
|
||||||
|
// by checking the table renders without errors
|
||||||
|
expect(screen.getByRole('table')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('infinite scroll', () => {
|
||||||
|
it('calls onEndReached when provided', async () => {
|
||||||
|
const onEndReached = jest.fn();
|
||||||
|
|
||||||
|
renderTanStackTable({
|
||||||
|
props: {
|
||||||
|
onEndReached,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(screen.getByText('Item 1')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
// Virtuoso will call onEndReached based on scroll position
|
||||||
|
// In mock context, we verify the prop is wired correctly
|
||||||
|
expect(onEndReached).toBeDefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('shows loading spinner at bottom when loading in infinite scroll mode', () => {
|
||||||
|
renderTanStackTable({
|
||||||
|
props: {
|
||||||
|
isLoading: true,
|
||||||
|
onEndReached: jest.fn(),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(screen.getByTestId('tanstack-infinite-loader')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('hides pagination in infinite scroll mode', async () => {
|
||||||
|
renderTanStackTable({
|
||||||
|
props: {
|
||||||
|
pagination: { total: 100 },
|
||||||
|
onEndReached: jest.fn(),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(screen.getByText('Item 1')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
// When onEndReached is provided, pagination should not render
|
||||||
|
expect(screen.queryByRole('navigation')).not.toBeInTheDocument();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,94 @@
|
|||||||
|
import { ReactNode } from 'react';
|
||||||
|
import { VirtuosoMockContext } from 'react-virtuoso';
|
||||||
|
import { TooltipProvider } from '@signozhq/ui';
|
||||||
|
import { render, RenderResult } from '@testing-library/react';
|
||||||
|
import { NuqsTestingAdapter, OnUrlUpdateFunction } from 'nuqs/adapters/testing';
|
||||||
|
|
||||||
|
import TanStackTable from '../index';
|
||||||
|
import type { TableColumnDef, TanStackTableProps } from '../types';
|
||||||
|
|
||||||
|
// NOTE: Test files importing this utility must add this mock at the top of their file:
|
||||||
|
// jest.mock('hooks/useDarkMode', () => ({ useIsDarkMode: (): boolean => false }));
|
||||||
|
|
||||||
|
// Default test data types
|
||||||
|
export type TestRow = { id: string; name: string; value: number };
|
||||||
|
|
||||||
|
export const defaultColumns: TableColumnDef<TestRow>[] = [
|
||||||
|
{
|
||||||
|
id: 'id',
|
||||||
|
header: 'ID',
|
||||||
|
accessorKey: 'id',
|
||||||
|
enableSort: true,
|
||||||
|
cell: ({ value }): string => String(value),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'name',
|
||||||
|
header: 'Name',
|
||||||
|
accessorKey: 'name',
|
||||||
|
cell: ({ value }): string => String(value),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'value',
|
||||||
|
header: 'Value',
|
||||||
|
accessorKey: 'value',
|
||||||
|
enableSort: true,
|
||||||
|
cell: ({ value }): string => String(value),
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
export const defaultData: TestRow[] = [
|
||||||
|
{ id: '1', name: 'Item 1', value: 100 },
|
||||||
|
{ id: '2', name: 'Item 2', value: 200 },
|
||||||
|
{ id: '3', name: 'Item 3', value: 300 },
|
||||||
|
];
|
||||||
|
|
||||||
|
export type RenderTanStackTableOptions<T> = {
|
||||||
|
props?: Partial<TanStackTableProps<T>>;
|
||||||
|
queryParams?: Record<string, string>;
|
||||||
|
onUrlUpdate?: OnUrlUpdateFunction;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function renderTanStackTable<T = TestRow>(
|
||||||
|
options: RenderTanStackTableOptions<T> = {},
|
||||||
|
): RenderResult {
|
||||||
|
const { props = {}, queryParams, onUrlUpdate } = options;
|
||||||
|
|
||||||
|
const mergedProps = {
|
||||||
|
data: (defaultData as unknown) as T[],
|
||||||
|
columns: (defaultColumns as unknown) as TableColumnDef<T>[],
|
||||||
|
...props,
|
||||||
|
} as TanStackTableProps<T>;
|
||||||
|
|
||||||
|
return render(
|
||||||
|
<NuqsTestingAdapter searchParams={queryParams} onUrlUpdate={onUrlUpdate}>
|
||||||
|
<VirtuosoMockContext.Provider
|
||||||
|
value={{ viewportHeight: 500, itemHeight: 50 }}
|
||||||
|
>
|
||||||
|
<TooltipProvider>
|
||||||
|
<TanStackTable<T> {...mergedProps} />
|
||||||
|
</TooltipProvider>
|
||||||
|
</VirtuosoMockContext.Provider>
|
||||||
|
</NuqsTestingAdapter>,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Helper to wrap any component with test providers (for unit tests)
|
||||||
|
export function renderWithProviders(
|
||||||
|
ui: ReactNode,
|
||||||
|
options: {
|
||||||
|
queryParams?: Record<string, string>;
|
||||||
|
onUrlUpdate?: OnUrlUpdateFunction;
|
||||||
|
} = {},
|
||||||
|
): RenderResult {
|
||||||
|
const { queryParams, onUrlUpdate } = options;
|
||||||
|
|
||||||
|
return render(
|
||||||
|
<NuqsTestingAdapter searchParams={queryParams} onUrlUpdate={onUrlUpdate}>
|
||||||
|
<VirtuosoMockContext.Provider
|
||||||
|
value={{ viewportHeight: 500, itemHeight: 50 }}
|
||||||
|
>
|
||||||
|
<TooltipProvider>{ui}</TooltipProvider>
|
||||||
|
</VirtuosoMockContext.Provider>
|
||||||
|
</NuqsTestingAdapter>,
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,348 @@
|
|||||||
|
import { act, renderHook } from '@testing-library/react';
|
||||||
|
|
||||||
|
import type { TableColumnDef } from '../types';
|
||||||
|
import { useTableColumns } from '../useTableColumns';
|
||||||
|
|
||||||
|
const mockGet = jest.fn();
|
||||||
|
const mockSet = jest.fn();
|
||||||
|
|
||||||
|
jest.mock('api/browser/localstorage/get', () => ({
|
||||||
|
__esModule: true,
|
||||||
|
default: (key: string): string | null => mockGet(key),
|
||||||
|
}));
|
||||||
|
|
||||||
|
jest.mock('api/browser/localstorage/set', () => ({
|
||||||
|
__esModule: true,
|
||||||
|
default: (key: string, value: string): void => mockSet(key, value),
|
||||||
|
}));
|
||||||
|
|
||||||
|
type Row = { id: string; name: string };
|
||||||
|
|
||||||
|
const col = (id: string, pin?: 'left' | 'right'): TableColumnDef<Row> => ({
|
||||||
|
id,
|
||||||
|
header: id,
|
||||||
|
cell: ({ value }): string => String(value),
|
||||||
|
...(pin ? { pin } : {}),
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('useTableColumns', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
jest.clearAllMocks();
|
||||||
|
mockGet.mockReturnValue(null);
|
||||||
|
jest.useFakeTimers();
|
||||||
|
});
|
||||||
|
afterEach(() => {
|
||||||
|
jest.runOnlyPendingTimers();
|
||||||
|
jest.useRealTimers();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns definitions in original order when no persisted state', () => {
|
||||||
|
const defs = [col('timestamp'), col('body'), col('name')];
|
||||||
|
const { result } = renderHook(() => useTableColumns(defs));
|
||||||
|
expect(result.current.tableProps.columns.map((c) => c.id)).toEqual([
|
||||||
|
'timestamp',
|
||||||
|
'body',
|
||||||
|
'name',
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('restores column order from localStorage', () => {
|
||||||
|
mockGet.mockReturnValue(
|
||||||
|
JSON.stringify({
|
||||||
|
columnOrder: ['name', 'body', 'timestamp'],
|
||||||
|
columnSizing: {},
|
||||||
|
removedColumnIds: [],
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
const defs = [col('timestamp'), col('body'), col('name')];
|
||||||
|
const { result } = renderHook(() =>
|
||||||
|
useTableColumns(defs, { storageKey: 'test_table' }),
|
||||||
|
);
|
||||||
|
expect(result.current.tableProps.columns.map((c) => c.id)).toEqual([
|
||||||
|
'name',
|
||||||
|
'body',
|
||||||
|
'timestamp',
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('pinned columns always stay first regardless of persisted order', () => {
|
||||||
|
mockGet.mockReturnValue(
|
||||||
|
JSON.stringify({
|
||||||
|
columnOrder: ['body', 'indicator'],
|
||||||
|
columnSizing: {},
|
||||||
|
removedColumnIds: [],
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
const defs = [col('indicator', 'left'), col('body')];
|
||||||
|
const { result } = renderHook(() =>
|
||||||
|
useTableColumns(defs, { storageKey: 'test_table' }),
|
||||||
|
);
|
||||||
|
expect(result.current.tableProps.columns[0].id).toBe('indicator');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('excludes removed columns from tableProps.columns', () => {
|
||||||
|
mockGet.mockReturnValue(
|
||||||
|
JSON.stringify({
|
||||||
|
columnOrder: [],
|
||||||
|
columnSizing: {},
|
||||||
|
removedColumnIds: ['name'],
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
const defs = [col('body'), col('name')];
|
||||||
|
const { result } = renderHook(() =>
|
||||||
|
useTableColumns(defs, { storageKey: 'test_table' }),
|
||||||
|
);
|
||||||
|
expect(result.current.tableProps.columns.map((c) => c.id)).toEqual(['body']);
|
||||||
|
expect(result.current.activeColumnIds).toEqual(['body']);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('activeColumnIds reflects only currently visible columns', () => {
|
||||||
|
const defs = [col('body'), col('timestamp'), col('name')];
|
||||||
|
const { result } = renderHook(() => useTableColumns(defs));
|
||||||
|
expect(result.current.activeColumnIds).toEqual(['body', 'timestamp', 'name']);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('onRemoveColumn removes column and persists after debounce', () => {
|
||||||
|
const defs = [col('body'), col('name')];
|
||||||
|
const { result } = renderHook(() =>
|
||||||
|
useTableColumns(defs, { storageKey: 'test_table' }),
|
||||||
|
);
|
||||||
|
|
||||||
|
act(() => {
|
||||||
|
result.current.tableProps.onRemoveColumn('body');
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(result.current.tableProps.columns.map((c) => c.id)).toEqual(['name']);
|
||||||
|
|
||||||
|
act(() => {
|
||||||
|
jest.advanceTimersByTime(250);
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(mockSet).toHaveBeenCalledWith(
|
||||||
|
'test_table',
|
||||||
|
expect.stringContaining('"removedColumnIds":["body"]'),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('onColumnOrderChange updates column order', () => {
|
||||||
|
const defs = [col('a'), col('b'), col('c')];
|
||||||
|
const { result } = renderHook(() => useTableColumns(defs));
|
||||||
|
|
||||||
|
act(() => {
|
||||||
|
result.current.tableProps.onColumnOrderChange([
|
||||||
|
col('c'),
|
||||||
|
col('b'),
|
||||||
|
col('a'),
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(result.current.tableProps.columns.map((c) => c.id)).toEqual([
|
||||||
|
'c',
|
||||||
|
'b',
|
||||||
|
'a',
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('restores column sizing from localStorage', () => {
|
||||||
|
mockGet.mockReturnValue(
|
||||||
|
JSON.stringify({
|
||||||
|
columnOrder: [],
|
||||||
|
columnSizing: { body: 400 },
|
||||||
|
removedColumnIds: [],
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
const defs = [col('body')];
|
||||||
|
const { result } = renderHook(() =>
|
||||||
|
useTableColumns(defs, { storageKey: 'test_table' }),
|
||||||
|
);
|
||||||
|
expect(result.current.tableProps.columnSizing).toEqual({ body: 400 });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('debounces sizing writes to localStorage', () => {
|
||||||
|
const defs = [col('body')];
|
||||||
|
const { result } = renderHook(() =>
|
||||||
|
useTableColumns(defs, { storageKey: 'test_table' }),
|
||||||
|
);
|
||||||
|
|
||||||
|
act(() => {
|
||||||
|
result.current.tableProps.onColumnSizingChange({ body: 500 });
|
||||||
|
});
|
||||||
|
expect(mockSet).not.toHaveBeenCalled();
|
||||||
|
|
||||||
|
act(() => {
|
||||||
|
jest.advanceTimersByTime(250);
|
||||||
|
});
|
||||||
|
expect(mockSet).toHaveBeenCalledWith(
|
||||||
|
'test_table',
|
||||||
|
expect.stringContaining('"body":500'),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('falls back to definitions order when localStorage is corrupt', () => {
|
||||||
|
const spy = jest.spyOn(console, 'error').mockImplementation(() => {});
|
||||||
|
mockGet.mockReturnValue('not-json');
|
||||||
|
const defs = [col('a'), col('b')];
|
||||||
|
const { result } = renderHook(() =>
|
||||||
|
useTableColumns(defs, { storageKey: 'test_table' }),
|
||||||
|
);
|
||||||
|
expect(result.current.tableProps.columns.map((c) => c.id)).toEqual([
|
||||||
|
'a',
|
||||||
|
'b',
|
||||||
|
]);
|
||||||
|
spy.mockRestore();
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('column visibility', () => {
|
||||||
|
it('hides columns based on initial visibility state', () => {
|
||||||
|
mockGet.mockReturnValue(
|
||||||
|
JSON.stringify({
|
||||||
|
columnOrder: [],
|
||||||
|
columnSizing: {},
|
||||||
|
removedColumnIds: [],
|
||||||
|
columnVisibility: { name: false },
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
const defs = [col('body'), col('name')];
|
||||||
|
const { result } = renderHook(() =>
|
||||||
|
useTableColumns(defs, { storageKey: 'test_table' }),
|
||||||
|
);
|
||||||
|
// columnVisibility is not a supported field — only removedColumnIds hides columns.
|
||||||
|
// Both columns remain visible since removedColumnIds is empty.
|
||||||
|
expect(result.current.tableProps.columns.map((c) => c.id)).toContain('body');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('persists visibility changes to localStorage', () => {
|
||||||
|
const defs = [col('body'), col('name')];
|
||||||
|
const { result } = renderHook(() =>
|
||||||
|
useTableColumns(defs, { storageKey: 'test_table' }),
|
||||||
|
);
|
||||||
|
|
||||||
|
act(() => {
|
||||||
|
result.current.tableProps.onRemoveColumn('name');
|
||||||
|
});
|
||||||
|
|
||||||
|
act(() => {
|
||||||
|
jest.advanceTimersByTime(250);
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(mockSet).toHaveBeenCalledWith(
|
||||||
|
'test_table',
|
||||||
|
expect.stringContaining('removedColumnIds'),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('edge cases', () => {
|
||||||
|
it('handles columns added after initial render', () => {
|
||||||
|
const defs1 = [col('body')];
|
||||||
|
const { result, rerender } = renderHook(
|
||||||
|
({ defs }) => useTableColumns(defs),
|
||||||
|
{ initialProps: { defs: defs1 } },
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(result.current.tableProps.columns.map((c) => c.id)).toEqual(['body']);
|
||||||
|
|
||||||
|
const defs2 = [col('body'), col('name')];
|
||||||
|
rerender({ defs: defs2 });
|
||||||
|
|
||||||
|
expect(result.current.tableProps.columns.map((c) => c.id)).toContain('name');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('handles columns removed from definitions', () => {
|
||||||
|
const defs1 = [col('body'), col('name')];
|
||||||
|
const { result, rerender } = renderHook(
|
||||||
|
({ defs }) => useTableColumns(defs),
|
||||||
|
{ initialProps: { defs: defs1 } },
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(result.current.tableProps.columns.length).toBe(2);
|
||||||
|
|
||||||
|
const defs2 = [col('body')];
|
||||||
|
rerender({ defs: defs2 });
|
||||||
|
|
||||||
|
expect(result.current.tableProps.columns.map((c) => c.id)).toEqual(['body']);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('preserves order when new columns are added', () => {
|
||||||
|
mockGet.mockReturnValue(
|
||||||
|
JSON.stringify({
|
||||||
|
columnOrder: ['name', 'body'],
|
||||||
|
columnSizing: {},
|
||||||
|
removedColumnIds: [],
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
const defs = [col('body'), col('name'), col('timestamp')];
|
||||||
|
const { result } = renderHook(() =>
|
||||||
|
useTableColumns(defs, { storageKey: 'test_table' }),
|
||||||
|
);
|
||||||
|
// New column 'timestamp' has no entry in columnOrder so it gets Infinity — appended last.
|
||||||
|
// Existing order ['name', 'body'] is preserved.
|
||||||
|
const ids = result.current.tableProps.columns.map((c) => c.id);
|
||||||
|
expect(ids.indexOf('name')).toBeLessThan(ids.indexOf('body'));
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does not remove column when it is already absent', () => {
|
||||||
|
const defs = [col('body'), col('name')];
|
||||||
|
const { result } = renderHook(() => useTableColumns(defs));
|
||||||
|
|
||||||
|
act(() => {
|
||||||
|
result.current.tableProps.onRemoveColumn('name');
|
||||||
|
});
|
||||||
|
|
||||||
|
// 'name' is removed
|
||||||
|
expect(result.current.activeColumnIds).not.toContain('name');
|
||||||
|
|
||||||
|
// Calling remove again on an already-removed column is a no-op
|
||||||
|
act(() => {
|
||||||
|
result.current.tableProps.onRemoveColumn('name');
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(result.current.activeColumnIds).not.toContain('name');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('column definitions update', () => {
|
||||||
|
it('updates columns when definitions change', () => {
|
||||||
|
const defs1 = [col('a'), col('b')];
|
||||||
|
const { result, rerender } = renderHook(
|
||||||
|
({ defs }) => useTableColumns(defs),
|
||||||
|
{ initialProps: { defs: defs1 } },
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(result.current.tableProps.columns.map((c) => c.id)).toEqual([
|
||||||
|
'a',
|
||||||
|
'b',
|
||||||
|
]);
|
||||||
|
|
||||||
|
const defs2 = [col('x'), col('y')];
|
||||||
|
rerender({ defs: defs2 });
|
||||||
|
|
||||||
|
expect(result.current.tableProps.columns.map((c) => c.id)).toEqual([
|
||||||
|
'x',
|
||||||
|
'y',
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('preserves user customizations when definitions update with same columns', () => {
|
||||||
|
const defs = [col('a'), col('b'), col('c')];
|
||||||
|
const { result } = renderHook(() =>
|
||||||
|
useTableColumns(defs, { storageKey: 'test_table' }),
|
||||||
|
);
|
||||||
|
|
||||||
|
// Reorder columns
|
||||||
|
act(() => {
|
||||||
|
result.current.tableProps.onColumnOrderChange([
|
||||||
|
col('c'),
|
||||||
|
col('b'),
|
||||||
|
col('a'),
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(result.current.tableProps.columns.map((c) => c.id)).toEqual([
|
||||||
|
'c',
|
||||||
|
'b',
|
||||||
|
'a',
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,239 @@
|
|||||||
|
import { ReactNode } from 'react';
|
||||||
|
import { act, renderHook } from '@testing-library/react';
|
||||||
|
import {
|
||||||
|
NuqsTestingAdapter,
|
||||||
|
OnUrlUpdateFunction,
|
||||||
|
UrlUpdateEvent,
|
||||||
|
} from 'nuqs/adapters/testing';
|
||||||
|
|
||||||
|
import { useTableParams } from '../useTableParams';
|
||||||
|
|
||||||
|
function createNuqsWrapper(
|
||||||
|
queryParams?: Record<string, string>,
|
||||||
|
onUrlUpdate?: OnUrlUpdateFunction,
|
||||||
|
): ({ children }: { children: ReactNode }) => JSX.Element {
|
||||||
|
return function NuqsWrapper({
|
||||||
|
children,
|
||||||
|
}: {
|
||||||
|
children: ReactNode;
|
||||||
|
}): JSX.Element {
|
||||||
|
return (
|
||||||
|
<NuqsTestingAdapter
|
||||||
|
searchParams={queryParams}
|
||||||
|
onUrlUpdate={onUrlUpdate}
|
||||||
|
hasMemory
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</NuqsTestingAdapter>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('useTableParams (local mode — enableQueryParams not set)', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
jest.useFakeTimers();
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
jest.useRealTimers();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns default page=1 and limit=50', () => {
|
||||||
|
const wrapper = createNuqsWrapper();
|
||||||
|
const { result } = renderHook(() => useTableParams(), { wrapper });
|
||||||
|
expect(result.current.page).toBe(1);
|
||||||
|
expect(result.current.limit).toBe(50);
|
||||||
|
expect(result.current.orderBy).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('respects custom defaults', () => {
|
||||||
|
const wrapper = createNuqsWrapper();
|
||||||
|
const { result } = renderHook(
|
||||||
|
() => useTableParams(undefined, { page: 2, limit: 25 }),
|
||||||
|
{ wrapper },
|
||||||
|
);
|
||||||
|
expect(result.current.page).toBe(2);
|
||||||
|
expect(result.current.limit).toBe(25);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('setPage updates page', () => {
|
||||||
|
const wrapper = createNuqsWrapper();
|
||||||
|
const { result } = renderHook(() => useTableParams(), { wrapper });
|
||||||
|
act(() => {
|
||||||
|
result.current.setPage(3);
|
||||||
|
});
|
||||||
|
expect(result.current.page).toBe(3);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('setLimit updates limit', () => {
|
||||||
|
const wrapper = createNuqsWrapper();
|
||||||
|
const { result } = renderHook(() => useTableParams(), { wrapper });
|
||||||
|
act(() => {
|
||||||
|
result.current.setLimit(100);
|
||||||
|
});
|
||||||
|
expect(result.current.limit).toBe(100);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('setOrderBy updates orderBy', () => {
|
||||||
|
const wrapper = createNuqsWrapper();
|
||||||
|
const { result } = renderHook(() => useTableParams(), { wrapper });
|
||||||
|
act(() => {
|
||||||
|
result.current.setOrderBy({ columnName: 'cpu', order: 'desc' });
|
||||||
|
});
|
||||||
|
expect(result.current.orderBy).toEqual({ columnName: 'cpu', order: 'desc' });
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('useTableParams (URL mode — enableQueryParams set)', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
jest.useFakeTimers();
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
jest.useRealTimers();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('uses nuqs state when enableQueryParams=true', () => {
|
||||||
|
const wrapper = createNuqsWrapper();
|
||||||
|
const { result } = renderHook(() => useTableParams(true), { wrapper });
|
||||||
|
expect(result.current.page).toBe(1);
|
||||||
|
act(() => {
|
||||||
|
result.current.setPage(5);
|
||||||
|
jest.runAllTimers();
|
||||||
|
});
|
||||||
|
expect(result.current.page).toBe(5);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('uses prefixed keys when enableQueryParams is a string', () => {
|
||||||
|
const wrapper = createNuqsWrapper({ pods_page: '2' });
|
||||||
|
const { result } = renderHook(() => useTableParams('pods', { page: 2 }), {
|
||||||
|
wrapper,
|
||||||
|
});
|
||||||
|
expect(result.current.page).toBe(2);
|
||||||
|
act(() => {
|
||||||
|
result.current.setPage(4);
|
||||||
|
jest.runAllTimers();
|
||||||
|
});
|
||||||
|
expect(result.current.page).toBe(4);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('local state is ignored when enableQueryParams is set', () => {
|
||||||
|
const localWrapper = createNuqsWrapper();
|
||||||
|
const urlWrapper = createNuqsWrapper();
|
||||||
|
const { result: local } = renderHook(() => useTableParams(), {
|
||||||
|
wrapper: localWrapper,
|
||||||
|
});
|
||||||
|
const { result: url } = renderHook(() => useTableParams(true), {
|
||||||
|
wrapper: urlWrapper,
|
||||||
|
});
|
||||||
|
act(() => {
|
||||||
|
local.current.setPage(99);
|
||||||
|
});
|
||||||
|
// URL mode hook in a separate wrapper should still have its own state
|
||||||
|
expect(url.current.page).toBe(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('reads initial page from URL params', () => {
|
||||||
|
const wrapper = createNuqsWrapper({ page: '3' });
|
||||||
|
const { result } = renderHook(() => useTableParams(true), { wrapper });
|
||||||
|
expect(result.current.page).toBe(3);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('reads initial orderBy from URL params', () => {
|
||||||
|
const orderBy = JSON.stringify({ columnName: 'name', order: 'desc' });
|
||||||
|
const wrapper = createNuqsWrapper({ order_by: orderBy });
|
||||||
|
const { result } = renderHook(() => useTableParams(true), { wrapper });
|
||||||
|
expect(result.current.orderBy).toEqual({ columnName: 'name', order: 'desc' });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('updates URL when setPage is called', () => {
|
||||||
|
const onUrlUpdate = jest.fn<void, [UrlUpdateEvent]>();
|
||||||
|
const wrapper = createNuqsWrapper({}, onUrlUpdate);
|
||||||
|
const { result } = renderHook(() => useTableParams(true), { wrapper });
|
||||||
|
|
||||||
|
act(() => {
|
||||||
|
result.current.setPage(5);
|
||||||
|
jest.runAllTimers();
|
||||||
|
});
|
||||||
|
|
||||||
|
const lastPage = onUrlUpdate.mock.calls
|
||||||
|
.map((call) => call[0].searchParams.get('page'))
|
||||||
|
.filter(Boolean)
|
||||||
|
.pop();
|
||||||
|
expect(lastPage).toBe('5');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('updates URL when setOrderBy is called', () => {
|
||||||
|
const onUrlUpdate = jest.fn<void, [UrlUpdateEvent]>();
|
||||||
|
const wrapper = createNuqsWrapper({}, onUrlUpdate);
|
||||||
|
const { result } = renderHook(() => useTableParams(true), { wrapper });
|
||||||
|
|
||||||
|
act(() => {
|
||||||
|
result.current.setOrderBy({ columnName: 'value', order: 'asc' });
|
||||||
|
jest.runAllTimers();
|
||||||
|
});
|
||||||
|
|
||||||
|
const lastOrderBy = onUrlUpdate.mock.calls
|
||||||
|
.map((call) => call[0].searchParams.get('order_by'))
|
||||||
|
.filter(Boolean)
|
||||||
|
.pop();
|
||||||
|
expect(lastOrderBy).toBeDefined();
|
||||||
|
expect(JSON.parse(lastOrderBy!)).toEqual({
|
||||||
|
columnName: 'value',
|
||||||
|
order: 'asc',
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('uses custom param names from config object', () => {
|
||||||
|
const config = {
|
||||||
|
page: 'listPage',
|
||||||
|
limit: 'listLimit',
|
||||||
|
orderBy: 'listOrderBy',
|
||||||
|
expanded: 'listExpanded',
|
||||||
|
};
|
||||||
|
const wrapper = createNuqsWrapper({ listPage: '3' });
|
||||||
|
const { result } = renderHook(() => useTableParams(config, { page: 3 }), {
|
||||||
|
wrapper,
|
||||||
|
});
|
||||||
|
expect(result.current.page).toBe(3);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('manages expanded state for row expansion', () => {
|
||||||
|
const onUrlUpdate = jest.fn<void, [UrlUpdateEvent]>();
|
||||||
|
const wrapper = createNuqsWrapper({}, onUrlUpdate);
|
||||||
|
const { result } = renderHook(() => useTableParams(true), { wrapper });
|
||||||
|
|
||||||
|
act(() => {
|
||||||
|
result.current.setExpanded({ 'row-1': true });
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(result.current.expanded).toEqual({ 'row-1': true });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('toggles sort order correctly: null → asc → desc → null', () => {
|
||||||
|
const onUrlUpdate = jest.fn<void, [UrlUpdateEvent]>();
|
||||||
|
const wrapper = createNuqsWrapper({}, onUrlUpdate);
|
||||||
|
const { result } = renderHook(() => useTableParams(true), { wrapper });
|
||||||
|
|
||||||
|
// Initial state
|
||||||
|
expect(result.current.orderBy).toBeNull();
|
||||||
|
|
||||||
|
// First click: null → asc
|
||||||
|
act(() => {
|
||||||
|
result.current.setOrderBy({ columnName: 'id', order: 'asc' });
|
||||||
|
});
|
||||||
|
expect(result.current.orderBy).toEqual({ columnName: 'id', order: 'asc' });
|
||||||
|
|
||||||
|
// Second click: asc → desc
|
||||||
|
act(() => {
|
||||||
|
result.current.setOrderBy({ columnName: 'id', order: 'desc' });
|
||||||
|
});
|
||||||
|
expect(result.current.orderBy).toEqual({ columnName: 'id', order: 'desc' });
|
||||||
|
|
||||||
|
// Third click: desc → null
|
||||||
|
act(() => {
|
||||||
|
result.current.setOrderBy(null);
|
||||||
|
});
|
||||||
|
expect(result.current.orderBy).toBeNull();
|
||||||
|
});
|
||||||
|
});
|
||||||
185
frontend/src/components/TanStackTableView/index.tsx
Normal file
185
frontend/src/components/TanStackTableView/index.tsx
Normal file
@@ -0,0 +1,185 @@
|
|||||||
|
import { TanStackTableBase } from './TanStackTable';
|
||||||
|
import TanStackTableText from './TanStackTableText';
|
||||||
|
|
||||||
|
export * from './TanStackTableStateContext';
|
||||||
|
export * from './types';
|
||||||
|
export * from './useTableColumns';
|
||||||
|
export * from './useTableParams';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Virtualized data table built on TanStack Table and `react-virtuoso`: resizable and pinnable columns,
|
||||||
|
* optional drag-to-reorder headers, expandable rows, and pagination or infinite scroll.
|
||||||
|
*
|
||||||
|
* @example Minimal usage
|
||||||
|
* ```tsx
|
||||||
|
* import TanStackTable from 'components/TanStackTableView';
|
||||||
|
* import type { TableColumnDef } from 'components/TanStackTableView';
|
||||||
|
*
|
||||||
|
* type Row = { id: string; name: string };
|
||||||
|
*
|
||||||
|
* const columns: TableColumnDef<Row>[] = [
|
||||||
|
* {
|
||||||
|
* id: 'name',
|
||||||
|
* header: 'Name',
|
||||||
|
* accessorKey: 'name',
|
||||||
|
* cell: ({ value }) => <TanStackTable.Text>{String(value ?? '')}</TanStackTable.Text>,
|
||||||
|
* },
|
||||||
|
* ];
|
||||||
|
*
|
||||||
|
* function Example(): JSX.Element {
|
||||||
|
* return <TanStackTable<Row> data={rows} columns={columns} />;
|
||||||
|
* }
|
||||||
|
* ```
|
||||||
|
*
|
||||||
|
* @example Column definitions — `accessorFn`, custom header, pinned column, sortable
|
||||||
|
* ```tsx
|
||||||
|
* const columns: TableColumnDef<Row>[] = [
|
||||||
|
* {
|
||||||
|
* id: 'id',
|
||||||
|
* header: 'ID',
|
||||||
|
* accessorKey: 'id',
|
||||||
|
* pin: 'left',
|
||||||
|
* width: { min: 80, default: 120 },
|
||||||
|
* enableSort: true,
|
||||||
|
* cell: ({ value }) => <TanStackTable.Text>{String(value)}</TanStackTable.Text>,
|
||||||
|
* },
|
||||||
|
* {
|
||||||
|
* id: 'computed',
|
||||||
|
* header: () => <span>Computed</span>,
|
||||||
|
* accessorFn: (row) => row.first + row.last,
|
||||||
|
* enableMove: false,
|
||||||
|
* enableRemove: false,
|
||||||
|
* cell: ({ value }) => <TanStackTable.Text>{String(value)}</TanStackTable.Text>,
|
||||||
|
* },
|
||||||
|
* ];
|
||||||
|
* ```
|
||||||
|
*
|
||||||
|
* @example Controlled column sizing and reorder (persist in parent state)
|
||||||
|
* ```tsx
|
||||||
|
* import type { ColumnSizingState } from '@tanstack/react-table';
|
||||||
|
*
|
||||||
|
* const [columnSizing, setColumnSizing] = useState<ColumnSizingState>({});
|
||||||
|
*
|
||||||
|
* <TanStackTable
|
||||||
|
* data={data}
|
||||||
|
* columns={columns}
|
||||||
|
* columnSizing={columnSizing}
|
||||||
|
* onColumnSizingChange={setColumnSizing}
|
||||||
|
* onColumnOrderChange={setColumns}
|
||||||
|
* onRemoveColumn={(id) => setColumns((cols) => cols.filter((c) => c.id !== id))}
|
||||||
|
* />
|
||||||
|
* ```
|
||||||
|
*
|
||||||
|
* @example Pagination with query params. Use `enableQueryParams` object to customize param names.
|
||||||
|
* ```tsx
|
||||||
|
* <TanStackTable
|
||||||
|
* data={pageRows}
|
||||||
|
* columns={columns}
|
||||||
|
* pagination={{ total: totalCount, defaultPage: 1, defaultLimit: 20 }}
|
||||||
|
* enableQueryParams={{
|
||||||
|
* page: 'listPage',
|
||||||
|
* limit: 'listPageSize',
|
||||||
|
* orderBy: 'orderBy',
|
||||||
|
* expanded: 'listExpanded',
|
||||||
|
* }}
|
||||||
|
* prefixPaginationContent={<span>Custom prefix</span>}
|
||||||
|
* suffixPaginationContent={<span>Custom suffix</span>}
|
||||||
|
* />
|
||||||
|
* ```
|
||||||
|
*
|
||||||
|
* @example Infinite scroll — use `onEndReached` (pagination UI is hidden when set).
|
||||||
|
* ```tsx
|
||||||
|
* <TanStackTable
|
||||||
|
* data={accumulatedRows}
|
||||||
|
* columns={columns}
|
||||||
|
* onEndReached={(lastIndex) => fetchMore(lastIndex)}
|
||||||
|
* isLoading={isFetching}
|
||||||
|
* />
|
||||||
|
* ```
|
||||||
|
*
|
||||||
|
* @example Loading state and typography for plain string/number cells
|
||||||
|
* ```tsx
|
||||||
|
* <TanStackTable
|
||||||
|
* data={data}
|
||||||
|
* columns={columns}
|
||||||
|
* isLoading={isFetching}
|
||||||
|
* skeletonRowCount={15}
|
||||||
|
* cellTypographySize="small"
|
||||||
|
* plainTextCellLineClamp={2}
|
||||||
|
* />
|
||||||
|
* ```
|
||||||
|
*
|
||||||
|
* @example Row styling, selection, and actions. `onRowClick` receives `(row, itemKey)`.
|
||||||
|
* ```tsx
|
||||||
|
* <TanStackTable
|
||||||
|
* data={data}
|
||||||
|
* columns={columns}
|
||||||
|
* getRowKey={(row) => row.id}
|
||||||
|
* getItemKey={(row) => row.id}
|
||||||
|
* isRowActive={(row) => row.id === selectedId}
|
||||||
|
* activeRowIndex={selectedIndex}
|
||||||
|
* onRowClick={(row, itemKey) => setSelectedId(itemKey)}
|
||||||
|
* onRowClickNewTab={(row, itemKey) => openInNewTab(itemKey)}
|
||||||
|
* onRowDeactivate={() => setSelectedId(undefined)}
|
||||||
|
* getRowClassName={(row) => (row.severity === 'error' ? 'row-error' : '')}
|
||||||
|
* getRowStyle={(row) => (row.dimmed ? { opacity: 0.5 } : {})}
|
||||||
|
* renderRowActions={(row) => <Button size="small">Open</Button>}
|
||||||
|
* />
|
||||||
|
* ```
|
||||||
|
*
|
||||||
|
* @example Expandable rows. `renderExpandedRow` receives `(row, rowKey, groupMeta?)`.
|
||||||
|
* ```tsx
|
||||||
|
* <TanStackTable
|
||||||
|
* data={data}
|
||||||
|
* columns={columns}
|
||||||
|
* getRowKey={(row) => row.id}
|
||||||
|
* renderExpandedRow={(row, rowKey, groupMeta) => (
|
||||||
|
* <pre>{JSON.stringify({ rowKey, groupMeta, raw: row.raw }, null, 2)}</pre>
|
||||||
|
* )}
|
||||||
|
* getRowCanExpand={(row) => Boolean(row.raw)}
|
||||||
|
* />
|
||||||
|
* ```
|
||||||
|
*
|
||||||
|
* @example Grouped rows — use `groupBy` + `getGroupKey` for group-aware key generation.
|
||||||
|
* ```tsx
|
||||||
|
* <TanStackTable
|
||||||
|
* data={data}
|
||||||
|
* columns={columns}
|
||||||
|
* getRowKey={(row) => row.id}
|
||||||
|
* groupBy={[{ key: 'namespace' }, { key: 'cluster' }]}
|
||||||
|
* getGroupKey={(row) => row.meta ?? {}}
|
||||||
|
* renderExpandedRow={(row, rowKey, groupMeta) => (
|
||||||
|
* <ExpandedDetails groupMeta={groupMeta} />
|
||||||
|
* )}
|
||||||
|
* getRowCanExpand={() => true}
|
||||||
|
* />
|
||||||
|
* ```
|
||||||
|
*
|
||||||
|
* @example Imperative handle — `goToPage` plus Virtuoso methods (e.g. `scrollToIndex`)
|
||||||
|
* ```tsx
|
||||||
|
* import type { TanStackTableHandle } from 'components/TanStackTableView';
|
||||||
|
*
|
||||||
|
* const ref = useRef<TanStackTableHandle>(null);
|
||||||
|
*
|
||||||
|
* <TanStackTable ref={ref} data={data} columns={columns} pagination={{ total, defaultLimit: 20 }} />;
|
||||||
|
*
|
||||||
|
* ref.current?.goToPage(2);
|
||||||
|
* ref.current?.scrollToIndex({ index: 0, align: 'start' });
|
||||||
|
* ```
|
||||||
|
*
|
||||||
|
* @example Scroll container props and testing
|
||||||
|
* ```tsx
|
||||||
|
* <TanStackTable
|
||||||
|
* data={data}
|
||||||
|
* columns={columns}
|
||||||
|
* className="my-table-wrapper"
|
||||||
|
* testId="logs-table"
|
||||||
|
* tableScrollerProps={{ className: 'my-table-scroll', 'data-testid': 'logs-scroller' }}
|
||||||
|
* />
|
||||||
|
* ```
|
||||||
|
*/
|
||||||
|
const TanStackTable = Object.assign(TanStackTableBase, {
|
||||||
|
Text: TanStackTableText,
|
||||||
|
});
|
||||||
|
|
||||||
|
export default TanStackTable;
|
||||||
181
frontend/src/components/TanStackTableView/types.ts
Normal file
181
frontend/src/components/TanStackTableView/types.ts
Normal file
@@ -0,0 +1,181 @@
|
|||||||
|
import {
|
||||||
|
CSSProperties,
|
||||||
|
Dispatch,
|
||||||
|
HTMLAttributes,
|
||||||
|
ReactNode,
|
||||||
|
SetStateAction,
|
||||||
|
} from 'react';
|
||||||
|
import type { TableVirtuosoHandle } from 'react-virtuoso';
|
||||||
|
import type {
|
||||||
|
ColumnSizingState,
|
||||||
|
Row as TanStackRowType,
|
||||||
|
VisibilityState,
|
||||||
|
} from '@tanstack/react-table';
|
||||||
|
|
||||||
|
export type SortState = { columnName: string; order: 'asc' | 'desc' };
|
||||||
|
|
||||||
|
/** Sets `--tanstack-plain-cell-*` on the scroll root via CSS module classes (no data attributes). */
|
||||||
|
export type CellTypographySize = 'small' | 'medium' | 'large';
|
||||||
|
|
||||||
|
export type TableCellContext<TData, TValue> = {
|
||||||
|
row: TData;
|
||||||
|
value: TValue;
|
||||||
|
isActive: boolean;
|
||||||
|
rowIndex: number;
|
||||||
|
isExpanded: boolean;
|
||||||
|
canExpand: boolean;
|
||||||
|
toggleExpanded: () => void;
|
||||||
|
/** Business/selection key for the row */
|
||||||
|
itemKey: string;
|
||||||
|
/** Group metadata when row is part of a grouped view */
|
||||||
|
groupMeta?: Record<string, string>;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type RowKeyData = {
|
||||||
|
/** Final unique key (with duplicate suffix if needed) */
|
||||||
|
finalKey: string;
|
||||||
|
/** Business/selection key */
|
||||||
|
itemKey: string;
|
||||||
|
/** Group metadata */
|
||||||
|
groupMeta?: Record<string, string>;
|
||||||
|
};
|
||||||
|
|
||||||
|
/** Original column definition - compatible with existing code */
|
||||||
|
export type TableColumnDef<
|
||||||
|
TData,
|
||||||
|
TKey extends keyof TData = any,
|
||||||
|
TValue = TData[TKey]
|
||||||
|
> = {
|
||||||
|
id: string;
|
||||||
|
header: string | (() => ReactNode);
|
||||||
|
cell: (context: TableCellContext<TData, TValue>) => ReactNode;
|
||||||
|
accessorKey?: TKey;
|
||||||
|
accessorFn?: (row: TData) => TValue;
|
||||||
|
pin?: 'left' | 'right';
|
||||||
|
enableMove?: boolean;
|
||||||
|
enableResize?: boolean;
|
||||||
|
enableRemove?: boolean;
|
||||||
|
enableSort?: boolean;
|
||||||
|
width?: {
|
||||||
|
fixed?: number;
|
||||||
|
min?: number;
|
||||||
|
default?: number;
|
||||||
|
max?: number;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
export type FlatItem<TData> =
|
||||||
|
| { kind: 'row'; row: TanStackRowType<TData> }
|
||||||
|
| { kind: 'expansion'; row: TanStackRowType<TData> };
|
||||||
|
|
||||||
|
export type TableRowContext<TData> = {
|
||||||
|
getRowStyle?: (row: TData) => CSSProperties;
|
||||||
|
getRowClassName?: (row: TData) => string;
|
||||||
|
isRowActive?: (row: TData) => boolean;
|
||||||
|
renderRowActions?: (row: TData) => ReactNode;
|
||||||
|
onRowClick?: (row: TData, itemKey: string) => void;
|
||||||
|
/** Called when ctrl+click or cmd+click on a row */
|
||||||
|
onRowClickNewTab?: (row: TData, itemKey: string) => void;
|
||||||
|
onRowDeactivate?: () => void;
|
||||||
|
renderExpandedRow?: (
|
||||||
|
row: TData,
|
||||||
|
rowKey: string,
|
||||||
|
groupMeta?: Record<string, string>,
|
||||||
|
) => ReactNode;
|
||||||
|
/** Get key data for a row by index */
|
||||||
|
getRowKeyData?: (index: number) => RowKeyData | undefined;
|
||||||
|
colCount: number;
|
||||||
|
isDarkMode?: boolean;
|
||||||
|
/** When set, primitive cell output (string/number/boolean) is wrapped with typography + line-clamp (see `plainTextCellLineClamp` on the table). */
|
||||||
|
plainTextCellLineClamp?: number;
|
||||||
|
/** Whether there's only one non-pinned column that can be removed */
|
||||||
|
hasSingleColumn: boolean;
|
||||||
|
/** Column order key for memo invalidation on reorder */
|
||||||
|
columnOrderKey: string;
|
||||||
|
/** Column visibility key for memo invalidation on visibility change */
|
||||||
|
columnVisibilityKey: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type PaginationProps = {
|
||||||
|
total: number;
|
||||||
|
defaultPage?: number;
|
||||||
|
defaultLimit?: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type TanstackTableQueryParamsConfig = {
|
||||||
|
page: string;
|
||||||
|
limit: string;
|
||||||
|
orderBy: string;
|
||||||
|
expanded: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type TanStackTableProps<TData> = {
|
||||||
|
data: TData[];
|
||||||
|
columns: TableColumnDef<TData>[];
|
||||||
|
columnSizing?: ColumnSizingState;
|
||||||
|
onColumnSizingChange?: Dispatch<SetStateAction<ColumnSizingState>>;
|
||||||
|
columnVisibility?: VisibilityState;
|
||||||
|
onColumnVisibilityChange?: Dispatch<SetStateAction<VisibilityState>>;
|
||||||
|
onColumnOrderChange?: (cols: TableColumnDef<TData>[]) => void;
|
||||||
|
onRemoveColumn?: (id: string) => void;
|
||||||
|
isLoading?: boolean;
|
||||||
|
/** Number of skeleton rows to show when loading with no data. Default: 10 */
|
||||||
|
skeletonRowCount?: number;
|
||||||
|
enableQueryParams?: boolean | string | TanstackTableQueryParamsConfig;
|
||||||
|
pagination?: PaginationProps;
|
||||||
|
onEndReached?: (index: number) => void;
|
||||||
|
/** Custom function to get the unique ID for a row. Used for expansion state and TanStack's internal tracking.
|
||||||
|
* Defaults to `row.id` if present, otherwise falls back to the row index.
|
||||||
|
* @deprecated Use `getRowKey` instead for better key handling with duplicate detection. */
|
||||||
|
getRowId?: (row: TData, index: number) => string;
|
||||||
|
/** Function to get the unique key for a row (before duplicate handling).
|
||||||
|
* When set, enables automatic duplicate key detection and group-aware key composition. */
|
||||||
|
getRowKey?: (row: TData) => string;
|
||||||
|
/** Function to get the business/selection key. Defaults to getRowKey result. */
|
||||||
|
getItemKey?: (row: TData) => string;
|
||||||
|
/** When set, enables group-aware key generation (prefixes rowKey with group values). */
|
||||||
|
groupBy?: Array<{ key: string }>;
|
||||||
|
/** Extract group metadata from a row. Required when groupBy is set. */
|
||||||
|
getGroupKey?: (row: TData) => Record<string, string>;
|
||||||
|
getRowStyle?: (row: TData) => CSSProperties;
|
||||||
|
getRowClassName?: (row: TData) => string;
|
||||||
|
isRowActive?: (row: TData) => boolean;
|
||||||
|
renderRowActions?: (row: TData) => ReactNode;
|
||||||
|
onRowClick?: (row: TData, itemKey: string) => void;
|
||||||
|
/** Called when ctrl+click or cmd+click on a row */
|
||||||
|
onRowClickNewTab?: (row: TData, itemKey: string) => void;
|
||||||
|
onRowDeactivate?: () => void;
|
||||||
|
activeRowIndex?: number;
|
||||||
|
renderExpandedRow?: (
|
||||||
|
row: TData,
|
||||||
|
rowKey: string,
|
||||||
|
groupMeta?: Record<string, string>,
|
||||||
|
) => ReactNode;
|
||||||
|
getRowCanExpand?: (row: TData) => boolean;
|
||||||
|
/**
|
||||||
|
* Primitive cell values use `--tanstack-plain-cell-*` from the scroll container when `cellTypographySize` is set.
|
||||||
|
*/
|
||||||
|
plainTextCellLineClamp?: number;
|
||||||
|
/** Optional CSS-module typography tier for the scroll root (`--tanstack-plain-cell-font-size` / line-height + header `th`). */
|
||||||
|
cellTypographySize?: CellTypographySize;
|
||||||
|
/** Spread onto the Virtuoso scroll container. `data` is omitted — reserved by Virtuoso. */
|
||||||
|
tableScrollerProps?: Omit<HTMLAttributes<HTMLDivElement>, 'data'>;
|
||||||
|
className?: string;
|
||||||
|
testId?: string;
|
||||||
|
/** Content rendered before the pagination controls */
|
||||||
|
prefixPaginationContent?: ReactNode;
|
||||||
|
/** Content rendered after the pagination controls */
|
||||||
|
suffixPaginationContent?: ReactNode;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type TanStackTableHandle = TableVirtuosoHandle & {
|
||||||
|
goToPage: (page: number) => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type TableColumnsState<TData> = {
|
||||||
|
columns: TableColumnDef<TData>[];
|
||||||
|
columnSizing: ColumnSizingState;
|
||||||
|
onColumnSizingChange: Dispatch<SetStateAction<ColumnSizingState>>;
|
||||||
|
onColumnOrderChange: (cols: TableColumnDef<TData>[]) => void;
|
||||||
|
onRemoveColumn: (id: string) => void;
|
||||||
|
};
|
||||||
200
frontend/src/components/TanStackTableView/useTableColumns.ts
Normal file
200
frontend/src/components/TanStackTableView/useTableColumns.ts
Normal file
@@ -0,0 +1,200 @@
|
|||||||
|
import {
|
||||||
|
Dispatch,
|
||||||
|
SetStateAction,
|
||||||
|
useCallback,
|
||||||
|
useEffect,
|
||||||
|
useMemo,
|
||||||
|
useRef,
|
||||||
|
useState,
|
||||||
|
} from 'react';
|
||||||
|
import { ColumnSizingState } from '@tanstack/react-table';
|
||||||
|
import getFromLocalstorage from 'api/browser/localstorage/get';
|
||||||
|
import setToLocalstorage from 'api/browser/localstorage/set';
|
||||||
|
|
||||||
|
import { TableColumnDef, TableColumnsState } from './types';
|
||||||
|
|
||||||
|
const DEBOUNCE_MS = 250;
|
||||||
|
|
||||||
|
type PersistedState = {
|
||||||
|
columnOrder: string[];
|
||||||
|
columnSizing: ColumnSizingState;
|
||||||
|
removedColumnIds: string[];
|
||||||
|
};
|
||||||
|
|
||||||
|
const EMPTY: PersistedState = {
|
||||||
|
columnOrder: [],
|
||||||
|
columnSizing: {},
|
||||||
|
removedColumnIds: [],
|
||||||
|
};
|
||||||
|
|
||||||
|
function readStorage(storageKey: string): PersistedState {
|
||||||
|
const raw = getFromLocalstorage(storageKey);
|
||||||
|
if (!raw) {
|
||||||
|
return EMPTY;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const parsed = JSON.parse(raw) as PersistedState;
|
||||||
|
return {
|
||||||
|
columnOrder: Array.isArray(parsed.columnOrder) ? parsed.columnOrder : [],
|
||||||
|
columnSizing:
|
||||||
|
parsed.columnSizing && typeof parsed.columnSizing === 'object'
|
||||||
|
? Object.fromEntries(
|
||||||
|
Object.entries(parsed.columnSizing).filter(
|
||||||
|
([, v]) => typeof v === 'number' && Number.isFinite(v) && v > 0,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
: {},
|
||||||
|
removedColumnIds: Array.isArray(parsed.removedColumnIds)
|
||||||
|
? parsed.removedColumnIds
|
||||||
|
: [],
|
||||||
|
};
|
||||||
|
} catch (e) {
|
||||||
|
console.error('useTableColumns: failed to parse storage', e);
|
||||||
|
return EMPTY;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
type UseTableColumnsOptions = { storageKey?: string };
|
||||||
|
|
||||||
|
type UseTableColumnsResult<TData> = {
|
||||||
|
tableProps: TableColumnsState<TData>;
|
||||||
|
activeColumnIds: string[];
|
||||||
|
};
|
||||||
|
|
||||||
|
export function useTableColumns<TData>(
|
||||||
|
definitions: TableColumnDef<TData>[],
|
||||||
|
options?: UseTableColumnsOptions,
|
||||||
|
): UseTableColumnsResult<TData> {
|
||||||
|
const { storageKey } = options ?? {};
|
||||||
|
|
||||||
|
const [persisted, setPersisted] = useState<PersistedState>(() =>
|
||||||
|
storageKey ? readStorage(storageKey) : EMPTY,
|
||||||
|
);
|
||||||
|
|
||||||
|
const [columnSizing, setColumnSizing] = useState<ColumnSizingState>(
|
||||||
|
() => persisted.columnSizing,
|
||||||
|
);
|
||||||
|
|
||||||
|
const pendingRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||||
|
const persistedRef = useRef(persisted);
|
||||||
|
persistedRef.current = persisted;
|
||||||
|
const columnSizingRef = useRef(columnSizing);
|
||||||
|
columnSizingRef.current = columnSizing;
|
||||||
|
|
||||||
|
const scheduleWrite = useCallback(() => {
|
||||||
|
if (!storageKey) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (pendingRef.current !== null) {
|
||||||
|
clearTimeout(pendingRef.current);
|
||||||
|
}
|
||||||
|
pendingRef.current = setTimeout(() => {
|
||||||
|
setToLocalstorage(
|
||||||
|
storageKey,
|
||||||
|
JSON.stringify({
|
||||||
|
...persistedRef.current,
|
||||||
|
columnSizing: columnSizingRef.current,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
}, DEBOUNCE_MS);
|
||||||
|
}, [storageKey]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
scheduleWrite();
|
||||||
|
return (): void => {
|
||||||
|
if (pendingRef.current !== null) {
|
||||||
|
clearTimeout(pendingRef.current);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}, [columnSizing, scheduleWrite]);
|
||||||
|
|
||||||
|
const handleColumnSizingChange: Dispatch<
|
||||||
|
SetStateAction<ColumnSizingState>
|
||||||
|
> = useCallback((updater) => {
|
||||||
|
setColumnSizing((prev) =>
|
||||||
|
typeof updater === 'function' ? updater(prev) : updater,
|
||||||
|
);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const handleColumnOrderChange = useCallback(
|
||||||
|
(updated: TableColumnDef<TData>[]) => {
|
||||||
|
const newOrder = updated.map((c) => c.id);
|
||||||
|
setPersisted((prev) => {
|
||||||
|
const next = { ...prev, columnOrder: newOrder };
|
||||||
|
if (storageKey) {
|
||||||
|
setToLocalstorage(
|
||||||
|
storageKey,
|
||||||
|
JSON.stringify({
|
||||||
|
...next,
|
||||||
|
columnSizing: columnSizingRef.current,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return next;
|
||||||
|
});
|
||||||
|
},
|
||||||
|
[storageKey],
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleRemoveColumn = useCallback(
|
||||||
|
(id: string) => {
|
||||||
|
setPersisted((prev) => {
|
||||||
|
if (prev.removedColumnIds.includes(id)) {
|
||||||
|
return prev;
|
||||||
|
}
|
||||||
|
const next = {
|
||||||
|
...prev,
|
||||||
|
removedColumnIds: [...prev.removedColumnIds, id],
|
||||||
|
};
|
||||||
|
if (storageKey) {
|
||||||
|
if (pendingRef.current !== null) {
|
||||||
|
clearTimeout(pendingRef.current);
|
||||||
|
}
|
||||||
|
pendingRef.current = setTimeout(() => {
|
||||||
|
setToLocalstorage(
|
||||||
|
storageKey,
|
||||||
|
JSON.stringify({
|
||||||
|
...next,
|
||||||
|
columnSizing: columnSizingRef.current,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
}, DEBOUNCE_MS);
|
||||||
|
}
|
||||||
|
return next;
|
||||||
|
});
|
||||||
|
},
|
||||||
|
[storageKey],
|
||||||
|
);
|
||||||
|
|
||||||
|
const columns = useMemo<TableColumnDef<TData>[]>(() => {
|
||||||
|
const removedSet = new Set(persisted.removedColumnIds);
|
||||||
|
const active = definitions.filter((d) => !removedSet.has(d.id));
|
||||||
|
|
||||||
|
if (persisted.columnOrder.length === 0) {
|
||||||
|
return active;
|
||||||
|
}
|
||||||
|
|
||||||
|
const orderMap = new Map(persisted.columnOrder.map((id, i) => [id, i]));
|
||||||
|
const pinned = active.filter((c) => c.pin != null);
|
||||||
|
const rest = active.filter((c) => c.pin == null);
|
||||||
|
const sortedRest = [...rest].sort((a, b) => {
|
||||||
|
const ai = orderMap.get(a.id) ?? Infinity;
|
||||||
|
const bi = orderMap.get(b.id) ?? Infinity;
|
||||||
|
return ai - bi;
|
||||||
|
});
|
||||||
|
return [...pinned, ...sortedRest];
|
||||||
|
}, [definitions, persisted]);
|
||||||
|
|
||||||
|
const activeColumnIds = useMemo(() => columns.map((c) => c.id), [columns]);
|
||||||
|
|
||||||
|
return {
|
||||||
|
tableProps: {
|
||||||
|
columns,
|
||||||
|
columnSizing,
|
||||||
|
onColumnSizingChange: handleColumnSizingChange,
|
||||||
|
onColumnOrderChange: handleColumnOrderChange,
|
||||||
|
onRemoveColumn: handleRemoveColumn,
|
||||||
|
},
|
||||||
|
activeColumnIds,
|
||||||
|
};
|
||||||
|
}
|
||||||
194
frontend/src/components/TanStackTableView/useTableParams.ts
Normal file
194
frontend/src/components/TanStackTableView/useTableParams.ts
Normal file
@@ -0,0 +1,194 @@
|
|||||||
|
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||||
|
import type { ExpandedState, Updater } from '@tanstack/react-table';
|
||||||
|
import { parseAsInteger, useQueryState } from 'nuqs';
|
||||||
|
import { parseAsJsonNoValidate } from 'utils/nuqsParsers';
|
||||||
|
|
||||||
|
import { SortState, TanstackTableQueryParamsConfig } from './types';
|
||||||
|
|
||||||
|
const NUQS_OPTIONS = { history: 'push' as const };
|
||||||
|
const DEFAULT_PAGE = 1;
|
||||||
|
const DEFAULT_LIMIT = 50;
|
||||||
|
|
||||||
|
type Defaults = {
|
||||||
|
page?: number;
|
||||||
|
limit?: number;
|
||||||
|
orderBy?: SortState | null;
|
||||||
|
expanded?: ExpandedState;
|
||||||
|
};
|
||||||
|
|
||||||
|
type TableParamsResult = {
|
||||||
|
page: number;
|
||||||
|
limit: number;
|
||||||
|
orderBy: SortState | null;
|
||||||
|
expanded: ExpandedState;
|
||||||
|
setPage: (p: number) => void;
|
||||||
|
setLimit: (l: number) => void;
|
||||||
|
setOrderBy: (s: SortState | null) => void;
|
||||||
|
setExpanded: (updaterOrValue: Updater<ExpandedState>) => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
function expandedStateToArray(state: ExpandedState): string[] {
|
||||||
|
if (typeof state === 'boolean') {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
return Object.entries(state)
|
||||||
|
.filter(([, v]) => v)
|
||||||
|
.map(([k]) => k);
|
||||||
|
}
|
||||||
|
|
||||||
|
function arrayToExpandedState(arr: string[]): ExpandedState {
|
||||||
|
const result: Record<string, boolean> = {};
|
||||||
|
for (const id of arr) {
|
||||||
|
result[id] = true;
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
// eslint-disable-next-line sonarjs/cognitive-complexity
|
||||||
|
export function useTableParams(
|
||||||
|
enableQueryParams?: boolean | string | TanstackTableQueryParamsConfig,
|
||||||
|
defaults?: Defaults,
|
||||||
|
): TableParamsResult {
|
||||||
|
const pageQueryParam =
|
||||||
|
typeof enableQueryParams === 'string'
|
||||||
|
? `${enableQueryParams}_page`
|
||||||
|
: typeof enableQueryParams === 'object'
|
||||||
|
? enableQueryParams.page
|
||||||
|
: 'page';
|
||||||
|
const limitQueryParam =
|
||||||
|
typeof enableQueryParams === 'string'
|
||||||
|
? `${enableQueryParams}_limit`
|
||||||
|
: typeof enableQueryParams === 'object'
|
||||||
|
? enableQueryParams.limit
|
||||||
|
: 'limit';
|
||||||
|
const orderByQueryParam =
|
||||||
|
typeof enableQueryParams === 'string'
|
||||||
|
? `${enableQueryParams}_order_by`
|
||||||
|
: typeof enableQueryParams === 'object'
|
||||||
|
? enableQueryParams.orderBy
|
||||||
|
: 'order_by';
|
||||||
|
const expandedQueryParam =
|
||||||
|
typeof enableQueryParams === 'string'
|
||||||
|
? `${enableQueryParams}_expanded`
|
||||||
|
: typeof enableQueryParams === 'object'
|
||||||
|
? enableQueryParams.expanded
|
||||||
|
: 'expanded';
|
||||||
|
const pageDefault = defaults?.page ?? DEFAULT_PAGE;
|
||||||
|
const limitDefault = defaults?.limit ?? DEFAULT_LIMIT;
|
||||||
|
const orderByDefault = defaults?.orderBy ?? null;
|
||||||
|
const expandedDefault = defaults?.expanded ?? {};
|
||||||
|
const expandedDefaultArray = useMemo(
|
||||||
|
() => expandedStateToArray(expandedDefault),
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
[],
|
||||||
|
);
|
||||||
|
|
||||||
|
const [localPage, setLocalPage] = useState(pageDefault);
|
||||||
|
const [localLimit, setLocalLimit] = useState(limitDefault);
|
||||||
|
const [localOrderBy, setLocalOrderBy] = useState<SortState | null>(
|
||||||
|
orderByDefault,
|
||||||
|
);
|
||||||
|
const [localExpanded, setLocalExpanded] = useState<ExpandedState>(
|
||||||
|
expandedDefault,
|
||||||
|
);
|
||||||
|
|
||||||
|
const [urlPage, setUrlPage] = useQueryState(
|
||||||
|
pageQueryParam,
|
||||||
|
parseAsInteger.withDefault(pageDefault).withOptions(NUQS_OPTIONS),
|
||||||
|
);
|
||||||
|
const [urlLimit, setUrlLimit] = useQueryState(
|
||||||
|
limitQueryParam,
|
||||||
|
parseAsInteger.withDefault(limitDefault).withOptions(NUQS_OPTIONS),
|
||||||
|
);
|
||||||
|
const [urlOrderBy, setUrlOrderBy] = useQueryState(
|
||||||
|
orderByQueryParam,
|
||||||
|
parseAsJsonNoValidate<SortState | null>()
|
||||||
|
.withDefault(orderByDefault as never)
|
||||||
|
.withOptions(NUQS_OPTIONS),
|
||||||
|
);
|
||||||
|
const [urlExpandedArray, setUrlExpandedArray] = useQueryState(
|
||||||
|
expandedQueryParam,
|
||||||
|
parseAsJsonNoValidate<string[]>()
|
||||||
|
.withDefault(expandedDefaultArray as never)
|
||||||
|
.withOptions(NUQS_OPTIONS),
|
||||||
|
);
|
||||||
|
|
||||||
|
// Convert URL array to ExpandedState
|
||||||
|
const urlExpanded = useMemo(
|
||||||
|
() => arrayToExpandedState(urlExpandedArray ?? []),
|
||||||
|
[urlExpandedArray],
|
||||||
|
);
|
||||||
|
|
||||||
|
// Keep ref for updater function access
|
||||||
|
const urlExpandedRef = useRef(urlExpanded);
|
||||||
|
urlExpandedRef.current = urlExpanded;
|
||||||
|
|
||||||
|
// Wrapper to convert ExpandedState to array when setting URL state
|
||||||
|
// Supports both direct values and updater functions (TanStack pattern)
|
||||||
|
const setUrlExpanded = useCallback(
|
||||||
|
(updaterOrValue: Updater<ExpandedState>): void => {
|
||||||
|
const newState =
|
||||||
|
typeof updaterOrValue === 'function'
|
||||||
|
? updaterOrValue(urlExpandedRef.current)
|
||||||
|
: updaterOrValue;
|
||||||
|
setUrlExpandedArray(expandedStateToArray(newState));
|
||||||
|
},
|
||||||
|
[setUrlExpandedArray],
|
||||||
|
);
|
||||||
|
|
||||||
|
// Wrapper for local expanded to match TanStack's Updater pattern
|
||||||
|
const handleSetLocalExpanded = useCallback(
|
||||||
|
(updaterOrValue: Updater<ExpandedState>): void => {
|
||||||
|
setLocalExpanded((prev) =>
|
||||||
|
typeof updaterOrValue === 'function'
|
||||||
|
? updaterOrValue(prev)
|
||||||
|
: updaterOrValue,
|
||||||
|
);
|
||||||
|
},
|
||||||
|
[],
|
||||||
|
);
|
||||||
|
|
||||||
|
const orderByDefaultMemoKey = `${orderByDefault?.columnName}${orderByDefault?.order}`;
|
||||||
|
const orderByUrlMemoKey = `${urlOrderBy?.columnName}${urlOrderBy?.order}`;
|
||||||
|
const isEnabledQueryParams =
|
||||||
|
typeof enableQueryParams === 'string' ||
|
||||||
|
typeof enableQueryParams === 'object';
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (isEnabledQueryParams) {
|
||||||
|
setUrlPage(pageDefault);
|
||||||
|
} else {
|
||||||
|
setLocalPage(pageDefault);
|
||||||
|
}
|
||||||
|
}, [
|
||||||
|
isEnabledQueryParams,
|
||||||
|
orderByDefaultMemoKey,
|
||||||
|
orderByUrlMemoKey,
|
||||||
|
pageDefault,
|
||||||
|
setUrlPage,
|
||||||
|
]);
|
||||||
|
|
||||||
|
if (enableQueryParams) {
|
||||||
|
return {
|
||||||
|
page: urlPage,
|
||||||
|
limit: urlLimit,
|
||||||
|
orderBy: urlOrderBy as SortState | null,
|
||||||
|
expanded: urlExpanded,
|
||||||
|
setPage: setUrlPage,
|
||||||
|
setLimit: setUrlLimit,
|
||||||
|
setOrderBy: setUrlOrderBy,
|
||||||
|
setExpanded: setUrlExpanded,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
page: localPage,
|
||||||
|
limit: localLimit,
|
||||||
|
orderBy: localOrderBy,
|
||||||
|
expanded: localExpanded,
|
||||||
|
setPage: setLocalPage,
|
||||||
|
setLimit: setLocalLimit,
|
||||||
|
setOrderBy: setLocalOrderBy,
|
||||||
|
setExpanded: handleSetLocalExpanded,
|
||||||
|
};
|
||||||
|
}
|
||||||
98
frontend/src/components/TanStackTableView/utils.ts
Normal file
98
frontend/src/components/TanStackTableView/utils.ts
Normal file
@@ -0,0 +1,98 @@
|
|||||||
|
import type { ReactNode } from 'react';
|
||||||
|
import type { ColumnDef } from '@tanstack/react-table';
|
||||||
|
|
||||||
|
import { RowKeyData, TableColumnDef } from './types';
|
||||||
|
|
||||||
|
export const getColumnId = <TData>(column: TableColumnDef<TData>): string =>
|
||||||
|
column.id;
|
||||||
|
|
||||||
|
const REM_PX = 16;
|
||||||
|
const MIN_WIDTH_DEFAULT_REM = 12;
|
||||||
|
|
||||||
|
export const getColumnMinWidthPx = <TData>(
|
||||||
|
column: TableColumnDef<TData>,
|
||||||
|
): number => {
|
||||||
|
if (column.width?.fixed != null) {
|
||||||
|
return column.width.fixed;
|
||||||
|
}
|
||||||
|
if (column.width?.min != null) {
|
||||||
|
return column.width.min;
|
||||||
|
}
|
||||||
|
return MIN_WIDTH_DEFAULT_REM * REM_PX;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the initial column size from a column definition.
|
||||||
|
* Matches the logic used by TanStack Table's size property.
|
||||||
|
*/
|
||||||
|
export const getColumnInitialSize = <TData>(
|
||||||
|
column: TableColumnDef<TData>,
|
||||||
|
): number => {
|
||||||
|
const minWidthPx = getColumnMinWidthPx(column);
|
||||||
|
if (column.width?.fixed != null) {
|
||||||
|
return column.width.fixed;
|
||||||
|
}
|
||||||
|
return column.width?.default ?? column.width?.min ?? minWidthPx;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the max width for a column, if any.
|
||||||
|
*/
|
||||||
|
export const getColumnMaxWidth = <TData>(
|
||||||
|
column: TableColumnDef<TData>,
|
||||||
|
): number | undefined => {
|
||||||
|
if (column.width?.fixed != null) {
|
||||||
|
return column.width.fixed;
|
||||||
|
}
|
||||||
|
return column.width?.max;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function buildTanstackColumnDef<TData>(
|
||||||
|
colDef: TableColumnDef<TData>,
|
||||||
|
isRowActive?: (row: TData) => boolean,
|
||||||
|
getRowKeyData?: (index: number) => RowKeyData | undefined,
|
||||||
|
): ColumnDef<TData> {
|
||||||
|
const isFixed = colDef.width?.fixed != null;
|
||||||
|
const fixedWidth = colDef.width?.fixed;
|
||||||
|
const minWidthPx = getColumnMinWidthPx(colDef);
|
||||||
|
return {
|
||||||
|
id: colDef.id,
|
||||||
|
header:
|
||||||
|
typeof colDef.header === 'string'
|
||||||
|
? colDef.header
|
||||||
|
: (): ReactNode =>
|
||||||
|
typeof colDef.header === 'function' ? colDef.header() : null,
|
||||||
|
accessorFn: (row: TData): unknown => {
|
||||||
|
if (colDef.accessorFn) {
|
||||||
|
return colDef.accessorFn(row);
|
||||||
|
}
|
||||||
|
if (colDef.accessorKey) {
|
||||||
|
return (row as Record<string, unknown>)[colDef.accessorKey];
|
||||||
|
}
|
||||||
|
return undefined;
|
||||||
|
},
|
||||||
|
enableResizing: colDef.enableResize !== false && !isFixed,
|
||||||
|
enableSorting: colDef.enableSort === true,
|
||||||
|
// TanStack Table uses these to compute column.getSize()
|
||||||
|
minSize: fixedWidth ?? minWidthPx,
|
||||||
|
size: fixedWidth ?? colDef.width?.default ?? colDef.width?.min ?? minWidthPx,
|
||||||
|
maxSize: fixedWidth ?? colDef.width?.max,
|
||||||
|
cell: ({ row, getValue }): ReactNode => {
|
||||||
|
const rowData = row.original;
|
||||||
|
const keyData = getRowKeyData?.(row.index);
|
||||||
|
return colDef.cell({
|
||||||
|
row: rowData,
|
||||||
|
value: getValue() as TData[any],
|
||||||
|
isActive: isRowActive?.(rowData) ?? false,
|
||||||
|
rowIndex: row.index,
|
||||||
|
isExpanded: row.getIsExpanded(),
|
||||||
|
canExpand: row.getCanExpand(),
|
||||||
|
toggleExpanded: (): void => {
|
||||||
|
row.toggleExpanded();
|
||||||
|
},
|
||||||
|
itemKey: keyData?.itemKey ?? '',
|
||||||
|
groupMeta: keyData?.groupMeta,
|
||||||
|
});
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -1,21 +1,33 @@
|
|||||||
|
import type { CSSProperties, MouseEvent, ReactNode } from 'react';
|
||||||
import { memo, useCallback, useEffect, useMemo, useRef } from 'react';
|
import { memo, useCallback, useEffect, useMemo, useRef } from 'react';
|
||||||
|
import { useLocation } from 'react-router-dom';
|
||||||
|
import { useCopyToClipboard } from 'react-use';
|
||||||
import { Virtuoso, VirtuosoHandle } from 'react-virtuoso';
|
import { Virtuoso, VirtuosoHandle } from 'react-virtuoso';
|
||||||
|
import { toast } from '@signozhq/sonner';
|
||||||
import { Card, Typography } from 'antd';
|
import { Card, Typography } from 'antd';
|
||||||
import LogDetail from 'components/LogDetail';
|
import LogDetail from 'components/LogDetail';
|
||||||
|
import { VIEW_TYPES } from 'components/LogDetail/constants';
|
||||||
import ListLogView from 'components/Logs/ListLogView';
|
import ListLogView from 'components/Logs/ListLogView';
|
||||||
|
import LogLinesActionButtons from 'components/Logs/LogLinesActionButtons/LogLinesActionButtons';
|
||||||
|
import { getRowBackgroundColor } from 'components/Logs/LogStateIndicator/getRowBackgroundColor';
|
||||||
|
import { getLogIndicatorType } from 'components/Logs/LogStateIndicator/utils';
|
||||||
import RawLogView from 'components/Logs/RawLogView';
|
import RawLogView from 'components/Logs/RawLogView';
|
||||||
|
import { useLogsTableColumns } from 'components/Logs/TableView/useLogsTableColumns';
|
||||||
import OverlayScrollbar from 'components/OverlayScrollbar/OverlayScrollbar';
|
import OverlayScrollbar from 'components/OverlayScrollbar/OverlayScrollbar';
|
||||||
|
import type { TanStackTableHandle } from 'components/TanStackTableView';
|
||||||
|
import TanStackTable, { useTableColumns } from 'components/TanStackTableView';
|
||||||
import { CARD_BODY_STYLE } from 'constants/card';
|
import { CARD_BODY_STYLE } from 'constants/card';
|
||||||
import { LOCALSTORAGE } from 'constants/localStorage';
|
import { LOCALSTORAGE } from 'constants/localStorage';
|
||||||
import { OptionFormatTypes } from 'constants/optionsFormatTypes';
|
import { OptionFormatTypes } from 'constants/optionsFormatTypes';
|
||||||
|
import { QueryParams } from 'constants/query';
|
||||||
import { InfinityWrapperStyled } from 'container/LogsExplorerList/styles';
|
import { InfinityWrapperStyled } from 'container/LogsExplorerList/styles';
|
||||||
import TanStackTableView from 'container/LogsExplorerList/TanStackTableView';
|
|
||||||
import { convertKeysToColumnFields } from 'container/LogsExplorerList/utils';
|
import { convertKeysToColumnFields } from 'container/LogsExplorerList/utils';
|
||||||
import { useOptionsMenu } from 'container/OptionsMenu';
|
import { useOptionsMenu } from 'container/OptionsMenu';
|
||||||
import { defaultLogsSelectedColumns } from 'container/OptionsMenu/constants';
|
import { defaultLogsSelectedColumns } from 'container/OptionsMenu/constants';
|
||||||
import { useCopyLogLink } from 'hooks/logs/useCopyLogLink';
|
import { useCopyLogLink } from 'hooks/logs/useCopyLogLink';
|
||||||
import useLogDetailHandlers from 'hooks/logs/useLogDetailHandlers';
|
import useLogDetailHandlers from 'hooks/logs/useLogDetailHandlers';
|
||||||
import useScrollToLog from 'hooks/logs/useScrollToLog';
|
import useScrollToLog from 'hooks/logs/useScrollToLog';
|
||||||
|
import { useIsDarkMode } from 'hooks/useDarkMode';
|
||||||
import { useEventSource } from 'providers/EventSource';
|
import { useEventSource } from 'providers/EventSource';
|
||||||
// interfaces
|
// interfaces
|
||||||
import { ILog } from 'types/api/logs/log';
|
import { ILog } from 'types/api/logs/log';
|
||||||
@@ -30,7 +42,10 @@ function LiveLogsList({
|
|||||||
isLoading,
|
isLoading,
|
||||||
handleChangeSelectedView,
|
handleChangeSelectedView,
|
||||||
}: LiveLogsListProps): JSX.Element {
|
}: LiveLogsListProps): JSX.Element {
|
||||||
const ref = useRef<VirtuosoHandle>(null);
|
const ref = useRef<TanStackTableHandle | VirtuosoHandle | null>(null);
|
||||||
|
const { pathname } = useLocation();
|
||||||
|
const [, setCopy] = useCopyToClipboard();
|
||||||
|
const isDarkMode = useIsDarkMode();
|
||||||
|
|
||||||
const { isConnectionLoading } = useEventSource();
|
const { isConnectionLoading } = useEventSource();
|
||||||
|
|
||||||
@@ -66,9 +81,46 @@ function LiveLogsList({
|
|||||||
...options.selectColumns,
|
...options.selectColumns,
|
||||||
]);
|
]);
|
||||||
|
|
||||||
|
const logsColumns = useLogsTableColumns({
|
||||||
|
fields: selectedFields,
|
||||||
|
linesPerRow: options.maxLines,
|
||||||
|
fontSize: options.fontSize,
|
||||||
|
appendTo: 'end',
|
||||||
|
});
|
||||||
|
|
||||||
|
const { tableProps } = useTableColumns(logsColumns, {
|
||||||
|
storageKey: LOCALSTORAGE.LOGS_LIST_COLUMNS,
|
||||||
|
});
|
||||||
|
|
||||||
|
const handleRemoveColumn = useCallback(
|
||||||
|
(columnId: string): void => {
|
||||||
|
tableProps.onRemoveColumn(columnId);
|
||||||
|
config.addColumn?.onRemove?.(columnId);
|
||||||
|
},
|
||||||
|
[tableProps, config.addColumn],
|
||||||
|
);
|
||||||
|
|
||||||
|
const makeOnLogCopy = useCallback(
|
||||||
|
(log: ILog) => (event: MouseEvent<HTMLElement>): void => {
|
||||||
|
event.preventDefault();
|
||||||
|
event.stopPropagation();
|
||||||
|
const urlQuery = new URLSearchParams(window.location.search);
|
||||||
|
urlQuery.delete(QueryParams.activeLogId);
|
||||||
|
urlQuery.delete(QueryParams.relativeTime);
|
||||||
|
urlQuery.set(QueryParams.activeLogId, `"${log.id}"`);
|
||||||
|
const link = `${window.location.origin}${pathname}?${urlQuery.toString()}`;
|
||||||
|
setCopy(link);
|
||||||
|
toast.success('Copied to clipboard', { position: 'top-right' });
|
||||||
|
},
|
||||||
|
[pathname, setCopy],
|
||||||
|
);
|
||||||
|
|
||||||
const handleScrollToLog = useScrollToLog({
|
const handleScrollToLog = useScrollToLog({
|
||||||
logs: formattedLogs,
|
logs: formattedLogs,
|
||||||
virtuosoRef: ref,
|
virtuosoRef: ref as React.RefObject<Pick<
|
||||||
|
VirtuosoHandle,
|
||||||
|
'scrollToIndex'
|
||||||
|
> | null>,
|
||||||
});
|
});
|
||||||
|
|
||||||
const getItemContent = useCallback(
|
const getItemContent = useCallback(
|
||||||
@@ -158,29 +210,48 @@ function LiveLogsList({
|
|||||||
{formattedLogs.length !== 0 && (
|
{formattedLogs.length !== 0 && (
|
||||||
<InfinityWrapperStyled>
|
<InfinityWrapperStyled>
|
||||||
{options.format === OptionFormatTypes.TABLE ? (
|
{options.format === OptionFormatTypes.TABLE ? (
|
||||||
<TanStackTableView
|
<TanStackTable
|
||||||
ref={ref}
|
ref={ref as React.Ref<TanStackTableHandle>}
|
||||||
|
{...tableProps}
|
||||||
|
plainTextCellLineClamp={options.maxLines}
|
||||||
|
cellTypographySize={options.fontSize}
|
||||||
|
onRemoveColumn={handleRemoveColumn}
|
||||||
|
data={formattedLogs}
|
||||||
isLoading={false}
|
isLoading={false}
|
||||||
tableViewProps={{
|
isRowActive={(log): boolean => log.id === activeLog?.id}
|
||||||
logs: formattedLogs,
|
getRowStyle={(log): CSSProperties =>
|
||||||
fields: selectedFields,
|
({
|
||||||
linesPerRow: options.maxLines,
|
'--row-active-bg': getRowBackgroundColor(
|
||||||
fontSize: options.fontSize,
|
isDarkMode,
|
||||||
appendTo: 'end',
|
getLogIndicatorType(log),
|
||||||
activeLogIndex,
|
),
|
||||||
|
'--row-hover-bg': getRowBackgroundColor(
|
||||||
|
isDarkMode,
|
||||||
|
getLogIndicatorType(log),
|
||||||
|
),
|
||||||
|
} as CSSProperties)
|
||||||
|
}
|
||||||
|
onRowClick={(log): void => {
|
||||||
|
handleSetActiveLog(log);
|
||||||
}}
|
}}
|
||||||
handleChangeSelectedView={handleChangeSelectedView}
|
onRowDeactivate={handleCloseLogDetail}
|
||||||
logs={formattedLogs}
|
activeRowIndex={activeLogIndex}
|
||||||
onSetActiveLog={handleSetActiveLog}
|
renderRowActions={(log): ReactNode => (
|
||||||
onClearActiveLog={handleCloseLogDetail}
|
<LogLinesActionButtons
|
||||||
activeLog={activeLog}
|
handleShowContext={(e): void => {
|
||||||
onRemoveColumn={config.addColumn?.onRemove}
|
e.preventDefault();
|
||||||
|
e.stopPropagation();
|
||||||
|
handleSetActiveLog(log, VIEW_TYPES.CONTEXT);
|
||||||
|
}}
|
||||||
|
onLogCopy={makeOnLogCopy(log)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
/>
|
/>
|
||||||
) : (
|
) : (
|
||||||
<Card style={{ width: '100%' }} bodyStyle={CARD_BODY_STYLE}>
|
<Card style={{ width: '100%' }} bodyStyle={CARD_BODY_STYLE}>
|
||||||
<OverlayScrollbar isVirtuoso>
|
<OverlayScrollbar isVirtuoso>
|
||||||
<Virtuoso
|
<Virtuoso
|
||||||
ref={ref}
|
ref={ref as React.Ref<VirtuosoHandle>}
|
||||||
initialTopMostItemIndex={activeLogIndex !== -1 ? activeLogIndex : 0}
|
initialTopMostItemIndex={activeLogIndex !== -1 ? activeLogIndex : 0}
|
||||||
data={formattedLogs}
|
data={formattedLogs}
|
||||||
totalCount={formattedLogs.length}
|
totalCount={formattedLogs.length}
|
||||||
|
|||||||
@@ -221,7 +221,7 @@ function ColumnView({
|
|||||||
onColumnOrderChange(formattedColumns);
|
onColumnOrderChange(formattedColumns);
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleRowClick = (row: Row<Record<string, unknown>>): void => {
|
const handleRowClick = (row: Row<Record<string, string>>): void => {
|
||||||
const currentLog = logs.find(({ id }) => id === row.original.id);
|
const currentLog = logs.find(({ id }) => id === row.original.id);
|
||||||
|
|
||||||
setShowActiveLog(true);
|
setShowActiveLog(true);
|
||||||
|
|||||||
@@ -1,8 +0,0 @@
|
|||||||
// eslint-disable-next-line no-restricted-imports
|
|
||||||
import { createContext, useContext } from 'react';
|
|
||||||
|
|
||||||
const RowHoverContext = createContext(false);
|
|
||||||
|
|
||||||
export const useRowHover = (): boolean => useContext(RowHoverContext);
|
|
||||||
|
|
||||||
export default RowHoverContext;
|
|
||||||
@@ -1,84 +0,0 @@
|
|||||||
import { ComponentProps, memo, useCallback, useState } from 'react';
|
|
||||||
import { TableComponents } from 'react-virtuoso';
|
|
||||||
import {
|
|
||||||
getLogIndicatorType,
|
|
||||||
getLogIndicatorTypeForTable,
|
|
||||||
} from 'components/Logs/LogStateIndicator/utils';
|
|
||||||
import { useCopyLogLink } from 'hooks/logs/useCopyLogLink';
|
|
||||||
import { useIsDarkMode } from 'hooks/useDarkMode';
|
|
||||||
import { ILog } from 'types/api/logs/log';
|
|
||||||
|
|
||||||
import { TableRowStyled } from '../InfinityTableView/styles';
|
|
||||||
import RowHoverContext from '../RowHoverContext';
|
|
||||||
import { TanStackTableRowData } from './types';
|
|
||||||
|
|
||||||
export type TableRowContext = {
|
|
||||||
activeLog?: ILog | null;
|
|
||||||
activeContextLog?: ILog | null;
|
|
||||||
logsById: Map<string, ILog>;
|
|
||||||
};
|
|
||||||
|
|
||||||
type VirtuosoTableRowProps = ComponentProps<
|
|
||||||
NonNullable<TableComponents<TanStackTableRowData, TableRowContext>['TableRow']>
|
|
||||||
>;
|
|
||||||
|
|
||||||
type TanStackCustomTableRowProps = VirtuosoTableRowProps;
|
|
||||||
|
|
||||||
function TanStackCustomTableRow({
|
|
||||||
children,
|
|
||||||
item,
|
|
||||||
context,
|
|
||||||
...props
|
|
||||||
}: TanStackCustomTableRowProps): JSX.Element {
|
|
||||||
const { isHighlighted } = useCopyLogLink(item.currentLog.id);
|
|
||||||
const isDarkMode = useIsDarkMode();
|
|
||||||
const [hasHovered, setHasHovered] = useState(false);
|
|
||||||
const rowId = String(item.currentLog.id ?? '');
|
|
||||||
const activeLog = context?.activeLog;
|
|
||||||
const activeContextLog = context?.activeContextLog;
|
|
||||||
const logsById = context?.logsById;
|
|
||||||
const rowLog = logsById?.get(rowId) || item.currentLog;
|
|
||||||
const logType = rowLog
|
|
||||||
? getLogIndicatorType(rowLog)
|
|
||||||
: getLogIndicatorTypeForTable(item.log);
|
|
||||||
|
|
||||||
const handleMouseEnter = useCallback(() => {
|
|
||||||
if (!hasHovered) {
|
|
||||||
setHasHovered(true);
|
|
||||||
}
|
|
||||||
}, [hasHovered]);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<RowHoverContext.Provider value={hasHovered}>
|
|
||||||
<TableRowStyled
|
|
||||||
{...props}
|
|
||||||
$isDarkMode={isDarkMode}
|
|
||||||
$isActiveLog={
|
|
||||||
isHighlighted ||
|
|
||||||
rowId === String(activeLog?.id ?? '') ||
|
|
||||||
rowId === String(activeContextLog?.id ?? '')
|
|
||||||
}
|
|
||||||
$logType={logType}
|
|
||||||
onMouseEnter={handleMouseEnter}
|
|
||||||
>
|
|
||||||
{children}
|
|
||||||
</TableRowStyled>
|
|
||||||
</RowHoverContext.Provider>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
export default memo(TanStackCustomTableRow, (prev, next) => {
|
|
||||||
const prevId = String(prev.item.currentLog.id ?? '');
|
|
||||||
const nextId = String(next.item.currentLog.id ?? '');
|
|
||||||
if (prevId !== nextId) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
const prevIsActive =
|
|
||||||
prevId === String(prev.context?.activeLog?.id ?? '') ||
|
|
||||||
prevId === String(prev.context?.activeContextLog?.id ?? '');
|
|
||||||
const nextIsActive =
|
|
||||||
nextId === String(next.context?.activeLog?.id ?? '') ||
|
|
||||||
nextId === String(next.context?.activeContextLog?.id ?? '');
|
|
||||||
return prevIsActive === nextIsActive;
|
|
||||||
});
|
|
||||||
@@ -1,193 +0,0 @@
|
|||||||
import type {
|
|
||||||
CSSProperties,
|
|
||||||
MouseEvent as ReactMouseEvent,
|
|
||||||
TouchEvent as ReactTouchEvent,
|
|
||||||
} from 'react';
|
|
||||||
import { useMemo } from 'react';
|
|
||||||
import { CloseOutlined, MoreOutlined } from '@ant-design/icons';
|
|
||||||
import { useSortable } from '@dnd-kit/sortable';
|
|
||||||
import { Popover, PopoverContent, PopoverTrigger } from '@signozhq/popover';
|
|
||||||
import { flexRender, Header as TanStackHeader } from '@tanstack/react-table';
|
|
||||||
import { GripVertical } from 'lucide-react';
|
|
||||||
|
|
||||||
import { TableHeaderCellStyled } from '../InfinityTableView/styles';
|
|
||||||
import { InfinityTableProps } from '../InfinityTableView/types';
|
|
||||||
import { OrderedColumn, TanStackTableRowData } from './types';
|
|
||||||
import { getColumnId } from './utils';
|
|
||||||
|
|
||||||
import './styles/TanStackHeaderRow.styles.scss';
|
|
||||||
|
|
||||||
type TanStackHeaderRowProps = {
|
|
||||||
column: OrderedColumn;
|
|
||||||
header?: TanStackHeader<TanStackTableRowData, unknown>;
|
|
||||||
isDarkMode: boolean;
|
|
||||||
fontSize: InfinityTableProps['tableViewProps']['fontSize'];
|
|
||||||
hasSingleColumn: boolean;
|
|
||||||
canRemoveColumn?: boolean;
|
|
||||||
onRemoveColumn?: (columnKey: string) => void;
|
|
||||||
};
|
|
||||||
|
|
||||||
const GRIP_ICON_SIZE = 12;
|
|
||||||
// eslint-disable-next-line sonarjs/cognitive-complexity
|
|
||||||
function TanStackHeaderRow({
|
|
||||||
column,
|
|
||||||
header,
|
|
||||||
isDarkMode,
|
|
||||||
fontSize,
|
|
||||||
hasSingleColumn,
|
|
||||||
canRemoveColumn = false,
|
|
||||||
onRemoveColumn,
|
|
||||||
}: TanStackHeaderRowProps): JSX.Element {
|
|
||||||
const columnId = getColumnId(column);
|
|
||||||
const isDragColumn =
|
|
||||||
column.key !== 'expand' && column.key !== 'state-indicator';
|
|
||||||
const isResizableColumn = Boolean(header?.column.getCanResize());
|
|
||||||
const isColumnRemovable = Boolean(
|
|
||||||
canRemoveColumn &&
|
|
||||||
onRemoveColumn &&
|
|
||||||
column.key !== 'expand' &&
|
|
||||||
column.key !== 'state-indicator',
|
|
||||||
);
|
|
||||||
const isResizing = Boolean(header?.column.getIsResizing());
|
|
||||||
const resizeHandler = header?.getResizeHandler();
|
|
||||||
const headerText =
|
|
||||||
typeof column.title === 'string' && column.title
|
|
||||||
? column.title
|
|
||||||
: String(header?.id ?? columnId);
|
|
||||||
const headerTitleAttr = headerText.replace(/^\w/, (c) => c.toUpperCase());
|
|
||||||
const handleResizeStart = (
|
|
||||||
event: ReactMouseEvent<HTMLElement> | ReactTouchEvent<HTMLElement>,
|
|
||||||
): void => {
|
|
||||||
event.preventDefault();
|
|
||||||
event.stopPropagation();
|
|
||||||
resizeHandler?.(event);
|
|
||||||
};
|
|
||||||
const {
|
|
||||||
attributes,
|
|
||||||
listeners,
|
|
||||||
setNodeRef,
|
|
||||||
setActivatorNodeRef,
|
|
||||||
transform,
|
|
||||||
transition,
|
|
||||||
isDragging,
|
|
||||||
} = useSortable({
|
|
||||||
id: columnId,
|
|
||||||
disabled: !isDragColumn,
|
|
||||||
});
|
|
||||||
const headerCellStyle = useMemo(
|
|
||||||
() =>
|
|
||||||
({
|
|
||||||
'--tanstack-header-translate-x': `${Math.round(transform?.x ?? 0)}px`,
|
|
||||||
'--tanstack-header-translate-y': `${Math.round(transform?.y ?? 0)}px`,
|
|
||||||
'--tanstack-header-transition': isResizing ? 'none' : transition || 'none',
|
|
||||||
} as CSSProperties),
|
|
||||||
[isResizing, transform?.x, transform?.y, transition],
|
|
||||||
);
|
|
||||||
const headerCellClassName = [
|
|
||||||
'tanstack-header-cell',
|
|
||||||
isDragging ? 'is-dragging' : '',
|
|
||||||
isResizing ? 'is-resizing' : '',
|
|
||||||
]
|
|
||||||
.filter(Boolean)
|
|
||||||
.join(' ');
|
|
||||||
const headerContentClassName = [
|
|
||||||
'tanstack-header-content',
|
|
||||||
isResizableColumn ? 'has-resize-control' : '',
|
|
||||||
isColumnRemovable ? 'has-action-control' : '',
|
|
||||||
]
|
|
||||||
.filter(Boolean)
|
|
||||||
.join(' ');
|
|
||||||
|
|
||||||
return (
|
|
||||||
<TableHeaderCellStyled
|
|
||||||
ref={setNodeRef}
|
|
||||||
$isLogIndicator={column.key === 'state-indicator'}
|
|
||||||
$isDarkMode={isDarkMode}
|
|
||||||
$isDragColumn={false}
|
|
||||||
className={headerCellClassName}
|
|
||||||
key={columnId}
|
|
||||||
fontSize={fontSize}
|
|
||||||
$hasSingleColumn={hasSingleColumn}
|
|
||||||
style={headerCellStyle}
|
|
||||||
>
|
|
||||||
<span className={headerContentClassName}>
|
|
||||||
{isDragColumn ? (
|
|
||||||
<span className="tanstack-grip-slot">
|
|
||||||
<span
|
|
||||||
ref={setActivatorNodeRef}
|
|
||||||
{...attributes}
|
|
||||||
{...listeners}
|
|
||||||
role="button"
|
|
||||||
aria-label={`Drag ${String(
|
|
||||||
column.title || header?.id || columnId,
|
|
||||||
)} column`}
|
|
||||||
className="tanstack-grip-activator"
|
|
||||||
>
|
|
||||||
<GripVertical size={GRIP_ICON_SIZE} />
|
|
||||||
</span>
|
|
||||||
</span>
|
|
||||||
) : null}
|
|
||||||
<span className="tanstack-header-title" title={headerTitleAttr}>
|
|
||||||
{header
|
|
||||||
? flexRender(header.column.columnDef.header, header.getContext())
|
|
||||||
: String(column.title || '').replace(/^\w/, (c) => c.toUpperCase())}
|
|
||||||
</span>
|
|
||||||
{isColumnRemovable && (
|
|
||||||
<Popover>
|
|
||||||
<PopoverTrigger asChild>
|
|
||||||
<span
|
|
||||||
role="button"
|
|
||||||
aria-label={`Column actions for ${headerTitleAttr}`}
|
|
||||||
className="tanstack-header-action-trigger"
|
|
||||||
onMouseDown={(event): void => {
|
|
||||||
event.stopPropagation();
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<MoreOutlined />
|
|
||||||
</span>
|
|
||||||
</PopoverTrigger>
|
|
||||||
<PopoverContent
|
|
||||||
align="end"
|
|
||||||
sideOffset={6}
|
|
||||||
className="tanstack-column-actions-content"
|
|
||||||
>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
className="tanstack-remove-column-action"
|
|
||||||
onClick={(event): void => {
|
|
||||||
event.preventDefault();
|
|
||||||
event.stopPropagation();
|
|
||||||
onRemoveColumn?.(String(column.key));
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<CloseOutlined className="tanstack-remove-column-action-icon" />
|
|
||||||
Remove column
|
|
||||||
</button>
|
|
||||||
</PopoverContent>
|
|
||||||
</Popover>
|
|
||||||
)}
|
|
||||||
</span>
|
|
||||||
{isResizableColumn && (
|
|
||||||
<span
|
|
||||||
role="presentation"
|
|
||||||
className="cursor-col-resize"
|
|
||||||
title="Drag to resize column"
|
|
||||||
onClick={(event): void => {
|
|
||||||
event.preventDefault();
|
|
||||||
event.stopPropagation();
|
|
||||||
}}
|
|
||||||
onMouseDown={(event): void => {
|
|
||||||
handleResizeStart(event);
|
|
||||||
}}
|
|
||||||
onTouchStart={(event): void => {
|
|
||||||
handleResizeStart(event);
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<span className="tanstack-resize-handle-line" />
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
</TableHeaderCellStyled>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
export default TanStackHeaderRow;
|
|
||||||
@@ -1,95 +0,0 @@
|
|||||||
import {
|
|
||||||
MouseEvent as ReactMouseEvent,
|
|
||||||
MouseEventHandler,
|
|
||||||
useCallback,
|
|
||||||
} from 'react';
|
|
||||||
import { flexRender, Row as TanStackRowModel } from '@tanstack/react-table';
|
|
||||||
import { VIEW_TYPES } from 'components/LogDetail/constants';
|
|
||||||
import LogLinesActionButtons from 'components/Logs/LogLinesActionButtons/LogLinesActionButtons';
|
|
||||||
|
|
||||||
import { TableCellStyled } from '../InfinityTableView/styles';
|
|
||||||
import { InfinityTableProps } from '../InfinityTableView/types';
|
|
||||||
import { useRowHover } from '../RowHoverContext';
|
|
||||||
import { TanStackTableRowData } from './types';
|
|
||||||
|
|
||||||
type TanStackRowCellsProps = {
|
|
||||||
row: TanStackRowModel<TanStackTableRowData>;
|
|
||||||
fontSize: InfinityTableProps['tableViewProps']['fontSize'];
|
|
||||||
onSetActiveLog?: InfinityTableProps['onSetActiveLog'];
|
|
||||||
onClearActiveLog?: InfinityTableProps['onClearActiveLog'];
|
|
||||||
isActiveLog?: boolean;
|
|
||||||
isDarkMode: boolean;
|
|
||||||
onLogCopy: (logId: string, event: ReactMouseEvent<HTMLElement>) => void;
|
|
||||||
isLogsExplorerPage: boolean;
|
|
||||||
};
|
|
||||||
|
|
||||||
function TanStackRowCells({
|
|
||||||
row,
|
|
||||||
fontSize,
|
|
||||||
onSetActiveLog,
|
|
||||||
onClearActiveLog,
|
|
||||||
isActiveLog = false,
|
|
||||||
isDarkMode,
|
|
||||||
onLogCopy,
|
|
||||||
isLogsExplorerPage,
|
|
||||||
}: TanStackRowCellsProps): JSX.Element {
|
|
||||||
const { currentLog } = row.original;
|
|
||||||
const hasHovered = useRowHover();
|
|
||||||
|
|
||||||
const handleShowContext: MouseEventHandler<HTMLElement> = useCallback(
|
|
||||||
(event) => {
|
|
||||||
event.preventDefault();
|
|
||||||
event.stopPropagation();
|
|
||||||
onSetActiveLog?.(currentLog, VIEW_TYPES.CONTEXT);
|
|
||||||
},
|
|
||||||
[currentLog, onSetActiveLog],
|
|
||||||
);
|
|
||||||
|
|
||||||
const handleShowLogDetails = useCallback(() => {
|
|
||||||
if (!currentLog) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (isActiveLog && onClearActiveLog) {
|
|
||||||
onClearActiveLog();
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
onSetActiveLog?.(currentLog);
|
|
||||||
}, [currentLog, isActiveLog, onClearActiveLog, onSetActiveLog]);
|
|
||||||
|
|
||||||
const visibleCells = row.getVisibleCells();
|
|
||||||
const lastCellIndex = visibleCells.length - 1;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<>
|
|
||||||
{visibleCells.map((cell, index) => {
|
|
||||||
const columnKey = cell.column.id;
|
|
||||||
const isLastCell = index === lastCellIndex;
|
|
||||||
return (
|
|
||||||
<TableCellStyled
|
|
||||||
$isDragColumn={false}
|
|
||||||
$isLogIndicator={columnKey === 'state-indicator'}
|
|
||||||
$hasSingleColumn={visibleCells.length <= 2}
|
|
||||||
$isDarkMode={isDarkMode}
|
|
||||||
key={cell.id}
|
|
||||||
fontSize={fontSize}
|
|
||||||
className={columnKey}
|
|
||||||
onClick={handleShowLogDetails}
|
|
||||||
>
|
|
||||||
{flexRender(cell.column.columnDef.cell, cell.getContext())}
|
|
||||||
{isLastCell && isLogsExplorerPage && hasHovered && (
|
|
||||||
<LogLinesActionButtons
|
|
||||||
handleShowContext={handleShowContext}
|
|
||||||
onLogCopy={(event): void => onLogCopy(currentLog.id, event)}
|
|
||||||
customClassName="table-view-log-actions"
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
</TableCellStyled>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
export default TanStackRowCells;
|
|
||||||
@@ -1,105 +0,0 @@
|
|||||||
import { render, screen } from '@testing-library/react';
|
|
||||||
|
|
||||||
import TanStackCustomTableRow, {
|
|
||||||
TableRowContext,
|
|
||||||
} from '../TanStackCustomTableRow';
|
|
||||||
import type { TanStackTableRowData } from '../types';
|
|
||||||
|
|
||||||
jest.mock('../../InfinityTableView/styles', () => ({
|
|
||||||
TableRowStyled: 'tr',
|
|
||||||
}));
|
|
||||||
|
|
||||||
jest.mock('hooks/logs/useCopyLogLink', () => ({
|
|
||||||
useCopyLogLink: (): { isHighlighted: boolean } => ({ isHighlighted: false }),
|
|
||||||
}));
|
|
||||||
|
|
||||||
jest.mock('hooks/useDarkMode', () => ({
|
|
||||||
useIsDarkMode: (): boolean => false,
|
|
||||||
}));
|
|
||||||
|
|
||||||
jest.mock('components/Logs/LogStateIndicator/utils', () => ({
|
|
||||||
getLogIndicatorType: (): string => 'info',
|
|
||||||
getLogIndicatorTypeForTable: (): string => 'info',
|
|
||||||
}));
|
|
||||||
|
|
||||||
const item: TanStackTableRowData = {
|
|
||||||
log: {},
|
|
||||||
currentLog: { id: 'row-1' } as TanStackTableRowData['currentLog'],
|
|
||||||
rowIndex: 0,
|
|
||||||
};
|
|
||||||
|
|
||||||
const virtuosoTableRowAttrs = {
|
|
||||||
'data-index': 0,
|
|
||||||
'data-item-index': 0,
|
|
||||||
'data-known-size': 40,
|
|
||||||
} as const;
|
|
||||||
|
|
||||||
const defaultContext: TableRowContext = {
|
|
||||||
activeLog: null,
|
|
||||||
activeContextLog: null,
|
|
||||||
logsById: new Map(),
|
|
||||||
};
|
|
||||||
|
|
||||||
describe('TanStackCustomTableRow', () => {
|
|
||||||
it('renders children inside TableRowStyled', () => {
|
|
||||||
render(
|
|
||||||
<table>
|
|
||||||
<tbody>
|
|
||||||
<TanStackCustomTableRow
|
|
||||||
{...virtuosoTableRowAttrs}
|
|
||||||
item={item}
|
|
||||||
context={defaultContext}
|
|
||||||
>
|
|
||||||
<td>cell</td>
|
|
||||||
</TanStackCustomTableRow>
|
|
||||||
</tbody>
|
|
||||||
</table>,
|
|
||||||
);
|
|
||||||
|
|
||||||
expect(screen.getByText('cell')).toBeInTheDocument();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('marks row active when activeLog matches item id', () => {
|
|
||||||
const { container } = render(
|
|
||||||
<table>
|
|
||||||
<tbody>
|
|
||||||
<TanStackCustomTableRow
|
|
||||||
{...virtuosoTableRowAttrs}
|
|
||||||
item={item}
|
|
||||||
context={{
|
|
||||||
...defaultContext,
|
|
||||||
activeLog: { id: 'row-1' } as never,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<td>x</td>
|
|
||||||
</TanStackCustomTableRow>
|
|
||||||
</tbody>
|
|
||||||
</table>,
|
|
||||||
);
|
|
||||||
|
|
||||||
const row = container.querySelector('tr');
|
|
||||||
expect(row).toBeTruthy();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('uses logsById entry when present for indicator type', () => {
|
|
||||||
const logFromMap = { id: 'row-1', severity_text: 'error' } as never;
|
|
||||||
render(
|
|
||||||
<table>
|
|
||||||
<tbody>
|
|
||||||
<TanStackCustomTableRow
|
|
||||||
{...virtuosoTableRowAttrs}
|
|
||||||
item={item}
|
|
||||||
context={{
|
|
||||||
...defaultContext,
|
|
||||||
logsById: new Map([['row-1', logFromMap]]),
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<td>x</td>
|
|
||||||
</TanStackCustomTableRow>
|
|
||||||
</tbody>
|
|
||||||
</table>,
|
|
||||||
);
|
|
||||||
|
|
||||||
expect(screen.getByText('x')).toBeInTheDocument();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -1,152 +0,0 @@
|
|||||||
import type { Header } from '@tanstack/react-table';
|
|
||||||
import { render, screen } from '@testing-library/react';
|
|
||||||
import { FontSize } from 'container/OptionsMenu/types';
|
|
||||||
|
|
||||||
import TanStackHeaderRow from '../TanStackHeaderRow';
|
|
||||||
import type { OrderedColumn, TanStackTableRowData } from '../types';
|
|
||||||
|
|
||||||
jest.mock('../../InfinityTableView/styles', () => ({
|
|
||||||
TableHeaderCellStyled: 'th',
|
|
||||||
}));
|
|
||||||
|
|
||||||
const mockUseSortable = jest.fn((_args?: unknown) => ({
|
|
||||||
attributes: {},
|
|
||||||
listeners: {},
|
|
||||||
setNodeRef: jest.fn(),
|
|
||||||
setActivatorNodeRef: jest.fn(),
|
|
||||||
transform: null,
|
|
||||||
transition: undefined,
|
|
||||||
isDragging: false,
|
|
||||||
}));
|
|
||||||
|
|
||||||
jest.mock('@dnd-kit/sortable', () => ({
|
|
||||||
useSortable: (args: unknown): ReturnType<typeof mockUseSortable> =>
|
|
||||||
mockUseSortable(args),
|
|
||||||
}));
|
|
||||||
|
|
||||||
jest.mock('@tanstack/react-table', () => ({
|
|
||||||
flexRender: (def: unknown, ctx: unknown): unknown => {
|
|
||||||
if (typeof def === 'string') {
|
|
||||||
return def;
|
|
||||||
}
|
|
||||||
if (typeof def === 'function') {
|
|
||||||
return (def as (c: unknown) => unknown)(ctx);
|
|
||||||
}
|
|
||||||
return def;
|
|
||||||
},
|
|
||||||
}));
|
|
||||||
|
|
||||||
const column = (key: string): OrderedColumn =>
|
|
||||||
({ key, title: key } as OrderedColumn);
|
|
||||||
|
|
||||||
const mockHeader = (
|
|
||||||
id: string,
|
|
||||||
canResize = true,
|
|
||||||
): Header<TanStackTableRowData, unknown> =>
|
|
||||||
(({
|
|
||||||
id,
|
|
||||||
column: {
|
|
||||||
getCanResize: (): boolean => canResize,
|
|
||||||
getIsResizing: (): boolean => false,
|
|
||||||
columnDef: { header: id },
|
|
||||||
},
|
|
||||||
getContext: (): unknown => ({}),
|
|
||||||
getResizeHandler: (): (() => void) => jest.fn(),
|
|
||||||
flexRender: undefined,
|
|
||||||
} as unknown) as Header<TanStackTableRowData, unknown>);
|
|
||||||
|
|
||||||
describe('TanStackHeaderRow', () => {
|
|
||||||
beforeEach(() => {
|
|
||||||
mockUseSortable.mockClear();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('renders column title when header is undefined', () => {
|
|
||||||
render(
|
|
||||||
<table>
|
|
||||||
<thead>
|
|
||||||
<tr>
|
|
||||||
<TanStackHeaderRow
|
|
||||||
column={column('timestamp')}
|
|
||||||
isDarkMode={false}
|
|
||||||
fontSize={FontSize.SMALL}
|
|
||||||
hasSingleColumn={false}
|
|
||||||
/>
|
|
||||||
</tr>
|
|
||||||
</thead>
|
|
||||||
</table>,
|
|
||||||
);
|
|
||||||
|
|
||||||
expect(screen.getByText('Timestamp')).toBeInTheDocument();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('enables useSortable for draggable columns', () => {
|
|
||||||
render(
|
|
||||||
<table>
|
|
||||||
<thead>
|
|
||||||
<tr>
|
|
||||||
<TanStackHeaderRow
|
|
||||||
column={column('body')}
|
|
||||||
header={mockHeader('body')}
|
|
||||||
isDarkMode={false}
|
|
||||||
fontSize={FontSize.SMALL}
|
|
||||||
hasSingleColumn={false}
|
|
||||||
/>
|
|
||||||
</tr>
|
|
||||||
</thead>
|
|
||||||
</table>,
|
|
||||||
);
|
|
||||||
|
|
||||||
expect(mockUseSortable).toHaveBeenCalledWith(
|
|
||||||
expect.objectContaining({
|
|
||||||
id: 'body',
|
|
||||||
disabled: false,
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('disables sortable for expand column', () => {
|
|
||||||
render(
|
|
||||||
<table>
|
|
||||||
<thead>
|
|
||||||
<tr>
|
|
||||||
<TanStackHeaderRow
|
|
||||||
column={column('expand')}
|
|
||||||
header={mockHeader('expand', false)}
|
|
||||||
isDarkMode={false}
|
|
||||||
fontSize={FontSize.SMALL}
|
|
||||||
hasSingleColumn={false}
|
|
||||||
/>
|
|
||||||
</tr>
|
|
||||||
</thead>
|
|
||||||
</table>,
|
|
||||||
);
|
|
||||||
|
|
||||||
expect(mockUseSortable).toHaveBeenCalledWith(
|
|
||||||
expect.objectContaining({
|
|
||||||
disabled: true,
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('shows drag grip for draggable columns', () => {
|
|
||||||
render(
|
|
||||||
<table>
|
|
||||||
<thead>
|
|
||||||
<tr>
|
|
||||||
<TanStackHeaderRow
|
|
||||||
column={column('body')}
|
|
||||||
header={mockHeader('body')}
|
|
||||||
isDarkMode={false}
|
|
||||||
fontSize={FontSize.SMALL}
|
|
||||||
hasSingleColumn={false}
|
|
||||||
/>
|
|
||||||
</tr>
|
|
||||||
</thead>
|
|
||||||
</table>,
|
|
||||||
);
|
|
||||||
|
|
||||||
expect(
|
|
||||||
screen.getByRole('button', { name: /Drag body column/i }),
|
|
||||||
).toBeInTheDocument();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -1,193 +0,0 @@
|
|||||||
import { fireEvent, render, screen } from '@testing-library/react';
|
|
||||||
import RowHoverContext from 'container/LogsExplorerList/RowHoverContext';
|
|
||||||
import { FontSize } from 'container/OptionsMenu/types';
|
|
||||||
|
|
||||||
import TanStackRowCells from '../TanStackRow';
|
|
||||||
import type { TanStackTableRowData } from '../types';
|
|
||||||
|
|
||||||
jest.mock('../../InfinityTableView/styles', () => ({
|
|
||||||
TableCellStyled: 'td',
|
|
||||||
}));
|
|
||||||
|
|
||||||
jest.mock(
|
|
||||||
'components/Logs/LogLinesActionButtons/LogLinesActionButtons',
|
|
||||||
() => ({
|
|
||||||
__esModule: true,
|
|
||||||
default: ({
|
|
||||||
onLogCopy,
|
|
||||||
}: {
|
|
||||||
onLogCopy: (e: React.MouseEvent) => void;
|
|
||||||
}): JSX.Element => (
|
|
||||||
<button type="button" data-testid="copy-btn" onClick={onLogCopy}>
|
|
||||||
copy
|
|
||||||
</button>
|
|
||||||
),
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
|
|
||||||
const flexRenderMock = jest.fn((def: unknown, _ctx?: unknown) =>
|
|
||||||
typeof def === 'function' ? def({}) : def,
|
|
||||||
);
|
|
||||||
|
|
||||||
jest.mock('@tanstack/react-table', () => ({
|
|
||||||
flexRender: (def: unknown, ctx: unknown): unknown => flexRenderMock(def, ctx),
|
|
||||||
}));
|
|
||||||
|
|
||||||
function buildMockRow(
|
|
||||||
visibleCells: Array<{ columnId: string }>,
|
|
||||||
): Parameters<typeof TanStackRowCells>[0]['row'] {
|
|
||||||
return {
|
|
||||||
original: {
|
|
||||||
currentLog: { id: 'log-1' } as TanStackTableRowData['currentLog'],
|
|
||||||
log: {},
|
|
||||||
rowIndex: 0,
|
|
||||||
},
|
|
||||||
getVisibleCells: () =>
|
|
||||||
visibleCells.map((cell, index) => ({
|
|
||||||
id: `cell-${index}`,
|
|
||||||
column: {
|
|
||||||
id: cell.columnId,
|
|
||||||
columnDef: {
|
|
||||||
cell: (): string => `content-${cell.columnId}`,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
getContext: (): Record<string, unknown> => ({}),
|
|
||||||
})),
|
|
||||||
} as never;
|
|
||||||
}
|
|
||||||
|
|
||||||
describe('TanStackRowCells', () => {
|
|
||||||
beforeEach(() => {
|
|
||||||
flexRenderMock.mockClear();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('renders a cell per visible column and calls flexRender', () => {
|
|
||||||
const row = buildMockRow([
|
|
||||||
{ columnId: 'state-indicator' },
|
|
||||||
{ columnId: 'body' },
|
|
||||||
]);
|
|
||||||
|
|
||||||
render(
|
|
||||||
<table>
|
|
||||||
<tbody>
|
|
||||||
<tr>
|
|
||||||
<TanStackRowCells
|
|
||||||
row={row}
|
|
||||||
fontSize={FontSize.SMALL}
|
|
||||||
isDarkMode={false}
|
|
||||||
onLogCopy={jest.fn()}
|
|
||||||
isLogsExplorerPage={false}
|
|
||||||
/>
|
|
||||||
</tr>
|
|
||||||
</tbody>
|
|
||||||
</table>,
|
|
||||||
);
|
|
||||||
|
|
||||||
expect(screen.getAllByRole('cell')).toHaveLength(2);
|
|
||||||
expect(flexRenderMock).toHaveBeenCalled();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('applies state-indicator styling class on the indicator cell', () => {
|
|
||||||
const row = buildMockRow([{ columnId: 'state-indicator' }]);
|
|
||||||
|
|
||||||
const { container } = render(
|
|
||||||
<table>
|
|
||||||
<tbody>
|
|
||||||
<tr>
|
|
||||||
<TanStackRowCells
|
|
||||||
row={row}
|
|
||||||
fontSize={FontSize.SMALL}
|
|
||||||
isDarkMode={false}
|
|
||||||
onLogCopy={jest.fn()}
|
|
||||||
isLogsExplorerPage={false}
|
|
||||||
/>
|
|
||||||
</tr>
|
|
||||||
</tbody>
|
|
||||||
</table>,
|
|
||||||
);
|
|
||||||
|
|
||||||
expect(container.querySelector('td.state-indicator')).toBeInTheDocument();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('renders row actions on logs explorer page after hover', () => {
|
|
||||||
const row = buildMockRow([{ columnId: 'body' }]);
|
|
||||||
|
|
||||||
render(
|
|
||||||
<RowHoverContext.Provider value>
|
|
||||||
<table>
|
|
||||||
<tbody>
|
|
||||||
<tr>
|
|
||||||
<TanStackRowCells
|
|
||||||
row={row}
|
|
||||||
fontSize={FontSize.SMALL}
|
|
||||||
isDarkMode={false}
|
|
||||||
onLogCopy={jest.fn()}
|
|
||||||
isLogsExplorerPage
|
|
||||||
/>
|
|
||||||
</tr>
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
</RowHoverContext.Provider>,
|
|
||||||
);
|
|
||||||
|
|
||||||
expect(screen.getByTestId('copy-btn')).toBeInTheDocument();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('click on a data cell calls onSetActiveLog with current log', () => {
|
|
||||||
const onSetActiveLog = jest.fn();
|
|
||||||
const row = buildMockRow([{ columnId: 'body' }]);
|
|
||||||
|
|
||||||
render(
|
|
||||||
<table>
|
|
||||||
<tbody>
|
|
||||||
<tr>
|
|
||||||
<TanStackRowCells
|
|
||||||
row={row}
|
|
||||||
fontSize={FontSize.SMALL}
|
|
||||||
isDarkMode={false}
|
|
||||||
onSetActiveLog={onSetActiveLog}
|
|
||||||
onLogCopy={jest.fn()}
|
|
||||||
isLogsExplorerPage={false}
|
|
||||||
/>
|
|
||||||
</tr>
|
|
||||||
</tbody>
|
|
||||||
</table>,
|
|
||||||
);
|
|
||||||
|
|
||||||
fireEvent.click(screen.getAllByRole('cell')[0]);
|
|
||||||
|
|
||||||
expect(onSetActiveLog).toHaveBeenCalledWith(
|
|
||||||
expect.objectContaining({ id: 'log-1' }),
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('when row is active log, click on cell clears active log', () => {
|
|
||||||
const onSetActiveLog = jest.fn();
|
|
||||||
const onClearActiveLog = jest.fn();
|
|
||||||
const row = buildMockRow([{ columnId: 'body' }]);
|
|
||||||
|
|
||||||
render(
|
|
||||||
<table>
|
|
||||||
<tbody>
|
|
||||||
<tr>
|
|
||||||
<TanStackRowCells
|
|
||||||
row={row}
|
|
||||||
fontSize={FontSize.SMALL}
|
|
||||||
isDarkMode={false}
|
|
||||||
isActiveLog
|
|
||||||
onSetActiveLog={onSetActiveLog}
|
|
||||||
onClearActiveLog={onClearActiveLog}
|
|
||||||
onLogCopy={jest.fn()}
|
|
||||||
isLogsExplorerPage={false}
|
|
||||||
/>
|
|
||||||
</tr>
|
|
||||||
</tbody>
|
|
||||||
</table>,
|
|
||||||
);
|
|
||||||
|
|
||||||
fireEvent.click(screen.getAllByRole('cell')[0]);
|
|
||||||
|
|
||||||
expect(onClearActiveLog).toHaveBeenCalled();
|
|
||||||
expect(onSetActiveLog).not.toHaveBeenCalled();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -1,104 +0,0 @@
|
|||||||
import { forwardRef } from 'react';
|
|
||||||
import { render, screen } from '@testing-library/react';
|
|
||||||
import { FontSize } from 'container/OptionsMenu/types';
|
|
||||||
|
|
||||||
import type { InfinityTableProps } from '../../InfinityTableView/types';
|
|
||||||
import TanStackTableView from '../index';
|
|
||||||
|
|
||||||
jest.mock('react-virtuoso', () => ({
|
|
||||||
TableVirtuoso: forwardRef<
|
|
||||||
unknown,
|
|
||||||
{
|
|
||||||
fixedHeaderContent?: () => JSX.Element;
|
|
||||||
itemContent: (i: number) => JSX.Element;
|
|
||||||
}
|
|
||||||
>(function MockVirtuoso({ fixedHeaderContent, itemContent }, _ref) {
|
|
||||||
return (
|
|
||||||
<div data-testid="virtuoso">
|
|
||||||
{fixedHeaderContent?.()}
|
|
||||||
{itemContent(0)}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}),
|
|
||||||
}));
|
|
||||||
|
|
||||||
jest.mock('components/Logs/TableView/useTableView', () => ({
|
|
||||||
useTableView: (): {
|
|
||||||
dataSource: Record<string, string>[];
|
|
||||||
columns: unknown[];
|
|
||||||
} => ({
|
|
||||||
dataSource: [{ id: '1' }],
|
|
||||||
columns: [
|
|
||||||
{ key: 'body', title: 'body', render: (): string => 'x' },
|
|
||||||
{ key: 'state-indicator', title: 's', render: (): string => 'y' },
|
|
||||||
],
|
|
||||||
}),
|
|
||||||
}));
|
|
||||||
|
|
||||||
jest.mock('hooks/useDragColumns', () => ({
|
|
||||||
__esModule: true,
|
|
||||||
default: (): {
|
|
||||||
draggedColumns: unknown[];
|
|
||||||
onColumnOrderChange: () => void;
|
|
||||||
} => ({
|
|
||||||
draggedColumns: [],
|
|
||||||
onColumnOrderChange: jest.fn(),
|
|
||||||
}),
|
|
||||||
}));
|
|
||||||
|
|
||||||
jest.mock('hooks/logs/useActiveLog', () => ({
|
|
||||||
useActiveLog: (): { activeLog: null } => ({ activeLog: null }),
|
|
||||||
}));
|
|
||||||
|
|
||||||
jest.mock('hooks/logs/useCopyLogLink', () => ({
|
|
||||||
useCopyLogLink: (): { activeLogId: null } => ({ activeLogId: null }),
|
|
||||||
}));
|
|
||||||
|
|
||||||
jest.mock('hooks/useDarkMode', () => ({
|
|
||||||
useIsDarkMode: (): boolean => false,
|
|
||||||
}));
|
|
||||||
|
|
||||||
jest.mock('react-router-dom', () => ({
|
|
||||||
useLocation: (): { pathname: string } => ({ pathname: '/logs' }),
|
|
||||||
}));
|
|
||||||
|
|
||||||
jest.mock('react-use', () => ({
|
|
||||||
useCopyToClipboard: (): [unknown, () => void] => [null, jest.fn()],
|
|
||||||
}));
|
|
||||||
|
|
||||||
jest.mock('@signozhq/sonner', () => ({
|
|
||||||
toast: { success: jest.fn() },
|
|
||||||
}));
|
|
||||||
|
|
||||||
jest.mock('components/Spinner', () => ({
|
|
||||||
__esModule: true,
|
|
||||||
default: ({ tip }: { tip: string }): JSX.Element => (
|
|
||||||
<div data-testid="spinner">{tip}</div>
|
|
||||||
),
|
|
||||||
}));
|
|
||||||
|
|
||||||
const baseProps: InfinityTableProps = {
|
|
||||||
isLoading: false,
|
|
||||||
tableViewProps: {
|
|
||||||
logs: [{ id: '1' } as never],
|
|
||||||
fields: [],
|
|
||||||
linesPerRow: 3,
|
|
||||||
fontSize: FontSize.SMALL,
|
|
||||||
appendTo: 'end',
|
|
||||||
activeLogIndex: 0,
|
|
||||||
},
|
|
||||||
};
|
|
||||||
|
|
||||||
describe('TanStackTableView', () => {
|
|
||||||
it('shows spinner while loading', () => {
|
|
||||||
render(<TanStackTableView {...baseProps} isLoading />);
|
|
||||||
|
|
||||||
expect(screen.getByTestId('spinner')).toHaveTextContent('Getting Logs');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('renders virtuoso when not loading', () => {
|
|
||||||
render(<TanStackTableView {...baseProps} />);
|
|
||||||
|
|
||||||
expect(screen.getByTestId('virtuoso')).toBeInTheDocument();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -1,173 +0,0 @@
|
|||||||
import { act, renderHook } from '@testing-library/react';
|
|
||||||
import { LOCALSTORAGE } from 'constants/localStorage';
|
|
||||||
|
|
||||||
import type { OrderedColumn } from '../types';
|
|
||||||
import { useColumnSizingPersistence } from '../useColumnSizingPersistence';
|
|
||||||
|
|
||||||
const mockGet = jest.fn();
|
|
||||||
const mockSet = jest.fn();
|
|
||||||
|
|
||||||
jest.mock('api/browser/localstorage/get', () => ({
|
|
||||||
__esModule: true,
|
|
||||||
default: (key: string): string | null => mockGet(key),
|
|
||||||
}));
|
|
||||||
|
|
||||||
jest.mock('api/browser/localstorage/set', () => ({
|
|
||||||
__esModule: true,
|
|
||||||
default: (key: string, value: string): void => {
|
|
||||||
mockSet(key, value);
|
|
||||||
},
|
|
||||||
}));
|
|
||||||
|
|
||||||
const col = (key: string): OrderedColumn =>
|
|
||||||
({ key, title: key } as OrderedColumn);
|
|
||||||
|
|
||||||
describe('useColumnSizingPersistence', () => {
|
|
||||||
beforeEach(() => {
|
|
||||||
jest.clearAllMocks();
|
|
||||||
mockGet.mockReturnValue(null);
|
|
||||||
jest.useFakeTimers();
|
|
||||||
});
|
|
||||||
|
|
||||||
afterEach(() => {
|
|
||||||
jest.runOnlyPendingTimers();
|
|
||||||
jest.useRealTimers();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('initializes with empty sizing when localStorage is empty', () => {
|
|
||||||
const { result } = renderHook(() =>
|
|
||||||
useColumnSizingPersistence([col('body'), col('timestamp')]),
|
|
||||||
);
|
|
||||||
|
|
||||||
expect(result.current.columnSizing).toEqual({});
|
|
||||||
});
|
|
||||||
|
|
||||||
it('parses flat ColumnSizingState from localStorage', () => {
|
|
||||||
mockGet.mockReturnValue(JSON.stringify({ body: 400, timestamp: 180 }));
|
|
||||||
|
|
||||||
const { result } = renderHook(() =>
|
|
||||||
useColumnSizingPersistence([col('body'), col('timestamp')]),
|
|
||||||
);
|
|
||||||
|
|
||||||
expect(result.current.columnSizing).toEqual({ body: 400, timestamp: 180 });
|
|
||||||
});
|
|
||||||
|
|
||||||
it('parses PersistedColumnSizing wrapper with sizing + columnIdsSignature', () => {
|
|
||||||
mockGet.mockReturnValue(
|
|
||||||
JSON.stringify({
|
|
||||||
version: 1,
|
|
||||||
columnIdsSignature: 'body|timestamp',
|
|
||||||
sizing: { body: 300 },
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
|
|
||||||
const { result } = renderHook(() =>
|
|
||||||
useColumnSizingPersistence([col('body'), col('timestamp')]),
|
|
||||||
);
|
|
||||||
|
|
||||||
expect(result.current.columnSizing).toEqual({ body: 300 });
|
|
||||||
});
|
|
||||||
|
|
||||||
it('drops invalid numeric entries when reading from localStorage', () => {
|
|
||||||
mockGet.mockReturnValue(
|
|
||||||
JSON.stringify({
|
|
||||||
body: 200,
|
|
||||||
bad: NaN,
|
|
||||||
zero: 0,
|
|
||||||
neg: -1,
|
|
||||||
str: 'wide',
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
|
|
||||||
const { result } = renderHook(() =>
|
|
||||||
useColumnSizingPersistence([col('body'), col('bad'), col('zero')]),
|
|
||||||
);
|
|
||||||
|
|
||||||
expect(result.current.columnSizing).toEqual({ body: 200 });
|
|
||||||
});
|
|
||||||
|
|
||||||
it('returns empty sizing when JSON is invalid', () => {
|
|
||||||
const spy = jest.spyOn(console, 'error').mockImplementation(() => {});
|
|
||||||
mockGet.mockReturnValue('not-json');
|
|
||||||
|
|
||||||
const { result } = renderHook(() =>
|
|
||||||
useColumnSizingPersistence([col('body')]),
|
|
||||||
);
|
|
||||||
|
|
||||||
expect(result.current.columnSizing).toEqual({});
|
|
||||||
spy.mockRestore();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('prunes sizing for columns not in orderedColumns and strips fixed columns', () => {
|
|
||||||
mockGet.mockReturnValue(JSON.stringify({ body: 400, expand: 32, gone: 100 }));
|
|
||||||
|
|
||||||
const { result, rerender } = renderHook(
|
|
||||||
({ columns }: { columns: OrderedColumn[] }) =>
|
|
||||||
useColumnSizingPersistence(columns),
|
|
||||||
{
|
|
||||||
initialProps: {
|
|
||||||
columns: [
|
|
||||||
col('body'),
|
|
||||||
col('expand'),
|
|
||||||
col('state-indicator'),
|
|
||||||
] as OrderedColumn[],
|
|
||||||
},
|
|
||||||
},
|
|
||||||
);
|
|
||||||
|
|
||||||
expect(result.current.columnSizing).toEqual({ body: 400 });
|
|
||||||
|
|
||||||
act(() => {
|
|
||||||
rerender({
|
|
||||||
columns: [col('body'), col('expand'), col('state-indicator')],
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
expect(result.current.columnSizing).toEqual({ body: 400 });
|
|
||||||
});
|
|
||||||
|
|
||||||
it('updates setColumnSizing manually', () => {
|
|
||||||
const { result } = renderHook(() =>
|
|
||||||
useColumnSizingPersistence([col('body')]),
|
|
||||||
);
|
|
||||||
|
|
||||||
act(() => {
|
|
||||||
result.current.setColumnSizing({ body: 500 });
|
|
||||||
});
|
|
||||||
|
|
||||||
expect(result.current.columnSizing).toEqual({ body: 500 });
|
|
||||||
});
|
|
||||||
|
|
||||||
it('debounces writes to localStorage', () => {
|
|
||||||
const { result } = renderHook(() =>
|
|
||||||
useColumnSizingPersistence([col('body')]),
|
|
||||||
);
|
|
||||||
|
|
||||||
act(() => {
|
|
||||||
result.current.setColumnSizing({ body: 600 });
|
|
||||||
});
|
|
||||||
|
|
||||||
expect(mockSet).not.toHaveBeenCalled();
|
|
||||||
|
|
||||||
act(() => {
|
|
||||||
jest.advanceTimersByTime(250);
|
|
||||||
});
|
|
||||||
|
|
||||||
expect(mockSet).toHaveBeenCalledWith(
|
|
||||||
LOCALSTORAGE.LOGS_LIST_COLUMN_SIZING,
|
|
||||||
expect.stringContaining('"body":600'),
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('does not persist when ordered columns signature effect runs with empty ids early — still debounces empty sizing', () => {
|
|
||||||
const { result } = renderHook(() => useColumnSizingPersistence([]));
|
|
||||||
|
|
||||||
expect(result.current.columnSizing).toEqual({});
|
|
||||||
|
|
||||||
act(() => {
|
|
||||||
jest.advanceTimersByTime(250);
|
|
||||||
});
|
|
||||||
|
|
||||||
expect(mockSet).toHaveBeenCalled();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -1,222 +0,0 @@
|
|||||||
import { act, renderHook } from '@testing-library/react';
|
|
||||||
|
|
||||||
import type { OrderedColumn } from '../types';
|
|
||||||
import { useOrderedColumns } from '../useOrderedColumns';
|
|
||||||
|
|
||||||
const mockGetDraggedColumns = jest.fn();
|
|
||||||
|
|
||||||
jest.mock('hooks/useDragColumns/utils', () => ({
|
|
||||||
getDraggedColumns: <T,>(current: unknown[], dragged: unknown[]): T[] =>
|
|
||||||
mockGetDraggedColumns(current, dragged) as T[],
|
|
||||||
}));
|
|
||||||
|
|
||||||
const col = (key: string, title?: string): OrderedColumn =>
|
|
||||||
({ key, title: title ?? key } as OrderedColumn);
|
|
||||||
|
|
||||||
describe('useOrderedColumns', () => {
|
|
||||||
beforeEach(() => {
|
|
||||||
jest.clearAllMocks();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('returns columns from getDraggedColumns filtered to keys with string or number', () => {
|
|
||||||
mockGetDraggedColumns.mockReturnValue([
|
|
||||||
col('body'),
|
|
||||||
col('timestamp'),
|
|
||||||
{ title: 'no-key' },
|
|
||||||
]);
|
|
||||||
|
|
||||||
const { result } = renderHook(() =>
|
|
||||||
useOrderedColumns({
|
|
||||||
columns: [],
|
|
||||||
draggedColumns: [],
|
|
||||||
onColumnOrderChange: jest.fn(),
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
|
|
||||||
expect(result.current.orderedColumns).toEqual([
|
|
||||||
col('body'),
|
|
||||||
col('timestamp'),
|
|
||||||
]);
|
|
||||||
expect(result.current.orderedColumnIds).toEqual(['body', 'timestamp']);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('hasSingleColumn is true when exactly one column is not state-indicator', () => {
|
|
||||||
mockGetDraggedColumns.mockReturnValue([col('state-indicator'), col('body')]);
|
|
||||||
|
|
||||||
const { result } = renderHook(() =>
|
|
||||||
useOrderedColumns({
|
|
||||||
columns: [],
|
|
||||||
draggedColumns: [],
|
|
||||||
onColumnOrderChange: jest.fn(),
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
|
|
||||||
expect(result.current.hasSingleColumn).toBe(true);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('hasSingleColumn is false when more than one non-state-indicator column exists', () => {
|
|
||||||
mockGetDraggedColumns.mockReturnValue([
|
|
||||||
col('state-indicator'),
|
|
||||||
col('body'),
|
|
||||||
col('timestamp'),
|
|
||||||
]);
|
|
||||||
|
|
||||||
const { result } = renderHook(() =>
|
|
||||||
useOrderedColumns({
|
|
||||||
columns: [],
|
|
||||||
draggedColumns: [],
|
|
||||||
onColumnOrderChange: jest.fn(),
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
|
|
||||||
expect(result.current.hasSingleColumn).toBe(false);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('handleDragEnd reorders columns and calls onColumnOrderChange', () => {
|
|
||||||
const onColumnOrderChange = jest.fn();
|
|
||||||
mockGetDraggedColumns.mockReturnValue([col('a'), col('b'), col('c')]);
|
|
||||||
|
|
||||||
const { result } = renderHook(() =>
|
|
||||||
useOrderedColumns({
|
|
||||||
columns: [],
|
|
||||||
draggedColumns: [],
|
|
||||||
onColumnOrderChange,
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
|
|
||||||
act(() => {
|
|
||||||
result.current.handleDragEnd({
|
|
||||||
active: { id: 'a' },
|
|
||||||
over: { id: 'c' },
|
|
||||||
} as never);
|
|
||||||
});
|
|
||||||
|
|
||||||
expect(onColumnOrderChange).toHaveBeenCalledWith([
|
|
||||||
expect.objectContaining({ key: 'b' }),
|
|
||||||
expect.objectContaining({ key: 'c' }),
|
|
||||||
expect.objectContaining({ key: 'a' }),
|
|
||||||
]);
|
|
||||||
|
|
||||||
// Derived-only: orderedColumns should remain until draggedColumns (URL/localStorage) updates.
|
|
||||||
expect(result.current.orderedColumns.map((c) => c.key)).toEqual([
|
|
||||||
'a',
|
|
||||||
'b',
|
|
||||||
'c',
|
|
||||||
]);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('handleDragEnd no-ops when over is null', () => {
|
|
||||||
const onColumnOrderChange = jest.fn();
|
|
||||||
mockGetDraggedColumns.mockReturnValue([col('a'), col('b')]);
|
|
||||||
|
|
||||||
const { result } = renderHook(() =>
|
|
||||||
useOrderedColumns({
|
|
||||||
columns: [],
|
|
||||||
draggedColumns: [],
|
|
||||||
onColumnOrderChange,
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
|
|
||||||
const before = result.current.orderedColumns;
|
|
||||||
|
|
||||||
act(() => {
|
|
||||||
result.current.handleDragEnd({
|
|
||||||
active: { id: 'a' },
|
|
||||||
over: null,
|
|
||||||
} as never);
|
|
||||||
});
|
|
||||||
|
|
||||||
expect(result.current.orderedColumns).toBe(before);
|
|
||||||
expect(onColumnOrderChange).not.toHaveBeenCalled();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('handleDragEnd no-ops when active.id equals over.id', () => {
|
|
||||||
const onColumnOrderChange = jest.fn();
|
|
||||||
mockGetDraggedColumns.mockReturnValue([col('a'), col('b')]);
|
|
||||||
|
|
||||||
const { result } = renderHook(() =>
|
|
||||||
useOrderedColumns({
|
|
||||||
columns: [],
|
|
||||||
draggedColumns: [],
|
|
||||||
onColumnOrderChange,
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
|
|
||||||
act(() => {
|
|
||||||
result.current.handleDragEnd({
|
|
||||||
active: { id: 'a' },
|
|
||||||
over: { id: 'a' },
|
|
||||||
} as never);
|
|
||||||
});
|
|
||||||
|
|
||||||
expect(onColumnOrderChange).not.toHaveBeenCalled();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('handleDragEnd no-ops when indices cannot be resolved', () => {
|
|
||||||
const onColumnOrderChange = jest.fn();
|
|
||||||
mockGetDraggedColumns.mockReturnValue([col('a'), col('b')]);
|
|
||||||
|
|
||||||
const { result } = renderHook(() =>
|
|
||||||
useOrderedColumns({
|
|
||||||
columns: [],
|
|
||||||
draggedColumns: [],
|
|
||||||
onColumnOrderChange,
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
|
|
||||||
act(() => {
|
|
||||||
result.current.handleDragEnd({
|
|
||||||
active: { id: 'missing' },
|
|
||||||
over: { id: 'a' },
|
|
||||||
} as never);
|
|
||||||
});
|
|
||||||
|
|
||||||
expect(onColumnOrderChange).not.toHaveBeenCalled();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('exposes sensors from useSensors', () => {
|
|
||||||
mockGetDraggedColumns.mockReturnValue([col('a')]);
|
|
||||||
|
|
||||||
const { result } = renderHook(() =>
|
|
||||||
useOrderedColumns({
|
|
||||||
columns: [],
|
|
||||||
draggedColumns: [],
|
|
||||||
onColumnOrderChange: jest.fn(),
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
|
|
||||||
expect(result.current.sensors).toBeDefined();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('syncs ordered columns when base order changes externally (e.g. URL / localStorage)', () => {
|
|
||||||
mockGetDraggedColumns.mockReturnValue([col('a'), col('b'), col('c')]);
|
|
||||||
|
|
||||||
const { result, rerender } = renderHook(
|
|
||||||
({ draggedColumns }: { draggedColumns: unknown[] }) =>
|
|
||||||
useOrderedColumns({
|
|
||||||
columns: [],
|
|
||||||
draggedColumns,
|
|
||||||
onColumnOrderChange: jest.fn(),
|
|
||||||
}),
|
|
||||||
{ initialProps: { draggedColumns: [] as unknown[] } },
|
|
||||||
);
|
|
||||||
|
|
||||||
expect(result.current.orderedColumns.map((column) => column.key)).toEqual([
|
|
||||||
'a',
|
|
||||||
'b',
|
|
||||||
'c',
|
|
||||||
]);
|
|
||||||
|
|
||||||
mockGetDraggedColumns.mockReturnValue([col('c'), col('b'), col('a')]);
|
|
||||||
|
|
||||||
act(() => {
|
|
||||||
rerender({ draggedColumns: [{ title: 'from-url' }] as unknown[] });
|
|
||||||
});
|
|
||||||
|
|
||||||
expect(result.current.orderedColumns.map((column) => column.key)).toEqual([
|
|
||||||
'c',
|
|
||||||
'b',
|
|
||||||
'a',
|
|
||||||
]);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -1,433 +0,0 @@
|
|||||||
import {
|
|
||||||
forwardRef,
|
|
||||||
memo,
|
|
||||||
MouseEvent as ReactMouseEvent,
|
|
||||||
ReactElement,
|
|
||||||
useCallback,
|
|
||||||
useEffect,
|
|
||||||
useImperativeHandle,
|
|
||||||
useMemo,
|
|
||||||
useRef,
|
|
||||||
useState,
|
|
||||||
} from 'react';
|
|
||||||
import { useLocation } from 'react-router-dom';
|
|
||||||
import { useCopyToClipboard } from 'react-use';
|
|
||||||
import { TableVirtuoso, TableVirtuosoHandle } from 'react-virtuoso';
|
|
||||||
import { DndContext, pointerWithin } from '@dnd-kit/core';
|
|
||||||
import {
|
|
||||||
horizontalListSortingStrategy,
|
|
||||||
SortableContext,
|
|
||||||
} from '@dnd-kit/sortable';
|
|
||||||
import { toast } from '@signozhq/sonner';
|
|
||||||
import {
|
|
||||||
ColumnDef,
|
|
||||||
getCoreRowModel,
|
|
||||||
useReactTable,
|
|
||||||
} from '@tanstack/react-table';
|
|
||||||
import { VIEW_TYPES } from 'components/LogDetail/constants';
|
|
||||||
import { ColumnTypeRender } from 'components/Logs/TableView/types';
|
|
||||||
import { useTableView } from 'components/Logs/TableView/useTableView';
|
|
||||||
import Spinner from 'components/Spinner';
|
|
||||||
import { LOCALSTORAGE } from 'constants/localStorage';
|
|
||||||
import { QueryParams } from 'constants/query';
|
|
||||||
import ROUTES from 'constants/routes';
|
|
||||||
import { useActiveLog } from 'hooks/logs/useActiveLog';
|
|
||||||
import { useIsDarkMode } from 'hooks/useDarkMode';
|
|
||||||
import useDragColumns from 'hooks/useDragColumns';
|
|
||||||
|
|
||||||
import { infinityDefaultStyles } from '../InfinityTableView/config';
|
|
||||||
import { TanStackTableStyled } from '../InfinityTableView/styles';
|
|
||||||
import { InfinityTableProps } from '../InfinityTableView/types';
|
|
||||||
import TanStackCustomTableRow from './TanStackCustomTableRow';
|
|
||||||
import TanStackHeaderRow from './TanStackHeaderRow';
|
|
||||||
import TanStackRowCells from './TanStackRow';
|
|
||||||
import { TableRecord, TanStackTableRowData } from './types';
|
|
||||||
import { useColumnSizingPersistence } from './useColumnSizingPersistence';
|
|
||||||
import { useOrderedColumns } from './useOrderedColumns';
|
|
||||||
import {
|
|
||||||
getColumnId,
|
|
||||||
getColumnMinWidthPx,
|
|
||||||
resolveColumnTypeRender,
|
|
||||||
} from './utils';
|
|
||||||
|
|
||||||
import '../logsTableVirtuosoScrollbar.scss';
|
|
||||||
import './styles/TanStackTableView.styles.scss';
|
|
||||||
|
|
||||||
const COLUMN_DND_AUTO_SCROLL = {
|
|
||||||
layoutShiftCompensation: false as const,
|
|
||||||
threshold: { x: 0.2, y: 0 },
|
|
||||||
};
|
|
||||||
|
|
||||||
const TanStackTableView = forwardRef<TableVirtuosoHandle, InfinityTableProps>(
|
|
||||||
function TanStackTableView(
|
|
||||||
{
|
|
||||||
isLoading,
|
|
||||||
isFetching,
|
|
||||||
onRemoveColumn,
|
|
||||||
tableViewProps,
|
|
||||||
infitiyTableProps,
|
|
||||||
onSetActiveLog,
|
|
||||||
onClearActiveLog,
|
|
||||||
activeLog,
|
|
||||||
}: InfinityTableProps,
|
|
||||||
forwardedRef,
|
|
||||||
): JSX.Element {
|
|
||||||
const { pathname } = useLocation();
|
|
||||||
const virtuosoRef = useRef<TableVirtuosoHandle | null>(null);
|
|
||||||
// could avoid this if directly use forwardedRef in TableVirtuoso, but need to verify if it causes any issue with react-virtuoso internal ref handling
|
|
||||||
useImperativeHandle(
|
|
||||||
forwardedRef,
|
|
||||||
() => virtuosoRef.current as TableVirtuosoHandle,
|
|
||||||
[],
|
|
||||||
);
|
|
||||||
const [, setCopy] = useCopyToClipboard();
|
|
||||||
const isDarkMode = useIsDarkMode();
|
|
||||||
const isLogsExplorerPage = pathname === ROUTES.LOGS_EXPLORER;
|
|
||||||
const { activeLog: activeContextLog } = useActiveLog();
|
|
||||||
|
|
||||||
// Column definitions (shared with existing logs table)
|
|
||||||
const { dataSource, columns } = useTableView({
|
|
||||||
...tableViewProps,
|
|
||||||
onClickExpand: onSetActiveLog,
|
|
||||||
onOpenLogsContext: (log): void => onSetActiveLog?.(log, VIEW_TYPES.CONTEXT),
|
|
||||||
});
|
|
||||||
|
|
||||||
// Column order (drag + persisted order)
|
|
||||||
const { draggedColumns, onColumnOrderChange } = useDragColumns<TableRecord>(
|
|
||||||
LOCALSTORAGE.LOGS_LIST_COLUMNS,
|
|
||||||
);
|
|
||||||
const {
|
|
||||||
orderedColumns,
|
|
||||||
orderedColumnIds,
|
|
||||||
hasSingleColumn,
|
|
||||||
handleDragEnd,
|
|
||||||
sensors,
|
|
||||||
} = useOrderedColumns({
|
|
||||||
columns,
|
|
||||||
draggedColumns,
|
|
||||||
onColumnOrderChange: onColumnOrderChange as (columns: unknown[]) => void,
|
|
||||||
});
|
|
||||||
|
|
||||||
// Column sizing (persisted). stored to localStorage.
|
|
||||||
const { columnSizing, setColumnSizing } = useColumnSizingPersistence(
|
|
||||||
orderedColumns,
|
|
||||||
);
|
|
||||||
|
|
||||||
// don't allow "remove column" when only state-indicator + one data col remain
|
|
||||||
const isAtMinimumRemovableColumns = useMemo(
|
|
||||||
() =>
|
|
||||||
orderedColumns.filter(
|
|
||||||
(column) => column.key !== 'state-indicator' && column.key !== 'expand',
|
|
||||||
).length <= 1,
|
|
||||||
[orderedColumns],
|
|
||||||
);
|
|
||||||
|
|
||||||
// Table data (TanStack row data shape)
|
|
||||||
// useTableView sends flattened log data. this would not be needed once we move to new log details view
|
|
||||||
const tableData = useMemo<TanStackTableRowData[]>(
|
|
||||||
() =>
|
|
||||||
dataSource
|
|
||||||
.map((log, rowIndex) => {
|
|
||||||
const currentLog = tableViewProps.logs[rowIndex];
|
|
||||||
if (!currentLog) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
return { log, currentLog, rowIndex };
|
|
||||||
})
|
|
||||||
.filter(Boolean) as TanStackTableRowData[],
|
|
||||||
[dataSource, tableViewProps.logs],
|
|
||||||
);
|
|
||||||
|
|
||||||
// TanStack columns + table instance
|
|
||||||
const tanstackColumns = useMemo<ColumnDef<TanStackTableRowData>[]>(
|
|
||||||
() =>
|
|
||||||
orderedColumns.map((column, index) => {
|
|
||||||
const isStateIndicator = column.key === 'state-indicator';
|
|
||||||
const isExpand = column.key === 'expand';
|
|
||||||
const isFixedColumn = isStateIndicator || isExpand;
|
|
||||||
const fixedWidth = isFixedColumn ? 32 : undefined;
|
|
||||||
const minWidthPx = getColumnMinWidthPx(column, orderedColumns);
|
|
||||||
const headerTitle = String(column.title || '');
|
|
||||||
|
|
||||||
return {
|
|
||||||
id: getColumnId(column),
|
|
||||||
header: headerTitle.replace(/^\w/, (character) =>
|
|
||||||
character.toUpperCase(),
|
|
||||||
),
|
|
||||||
accessorFn: (row): unknown => row.log[column.key as keyof TableRecord],
|
|
||||||
enableResizing: !isFixedColumn && index !== orderedColumns.length - 1,
|
|
||||||
minSize: fixedWidth ?? minWidthPx,
|
|
||||||
size: fixedWidth, // last column gets remaining space, so don't set initial size to avoid conflict with resizing
|
|
||||||
maxSize: fixedWidth,
|
|
||||||
cell: ({ row, getValue }): ReactElement | string | number | null => {
|
|
||||||
if (!column.render) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
return resolveColumnTypeRender(
|
|
||||||
column.render(
|
|
||||||
getValue(),
|
|
||||||
row.original.log,
|
|
||||||
row.original.rowIndex,
|
|
||||||
) as ColumnTypeRender<Record<string, unknown>>,
|
|
||||||
);
|
|
||||||
},
|
|
||||||
};
|
|
||||||
}),
|
|
||||||
[orderedColumns],
|
|
||||||
);
|
|
||||||
const table = useReactTable({
|
|
||||||
data: tableData,
|
|
||||||
columns: tanstackColumns,
|
|
||||||
enableColumnResizing: true,
|
|
||||||
getCoreRowModel: getCoreRowModel(),
|
|
||||||
columnResizeMode: 'onChange',
|
|
||||||
onColumnSizingChange: setColumnSizing,
|
|
||||||
state: {
|
|
||||||
columnSizing,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
const tableRows = table.getRowModel().rows;
|
|
||||||
|
|
||||||
// Infinite-scroll footer UI state
|
|
||||||
const [loadMoreState, setLoadMoreState] = useState<{
|
|
||||||
active: boolean;
|
|
||||||
startCount: number;
|
|
||||||
}>({
|
|
||||||
active: false,
|
|
||||||
startCount: 0,
|
|
||||||
});
|
|
||||||
|
|
||||||
// Map to resolve full log object by id (row highlighting + indicator)
|
|
||||||
const logsById = useMemo(
|
|
||||||
() => new Map(tableViewProps.logs.map((log) => [String(log.id), log])),
|
|
||||||
[tableViewProps.logs],
|
|
||||||
);
|
|
||||||
|
|
||||||
// this is already written in parent. Check if this is needed.
|
|
||||||
useEffect(() => {
|
|
||||||
const activeLogIndex = tableViewProps.activeLogIndex ?? -1;
|
|
||||||
if (activeLogIndex < 0 || activeLogIndex >= tableRows.length) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
virtuosoRef.current?.scrollToIndex({
|
|
||||||
index: activeLogIndex,
|
|
||||||
align: 'center',
|
|
||||||
behavior: 'auto',
|
|
||||||
});
|
|
||||||
}, [tableRows.length, tableViewProps.activeLogIndex]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (!loadMoreState.active) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!isFetching || tableRows.length > loadMoreState.startCount) {
|
|
||||||
setLoadMoreState((prev) =>
|
|
||||||
prev.active ? { active: false, startCount: prev.startCount } : prev,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}, [isFetching, loadMoreState, tableRows.length]);
|
|
||||||
|
|
||||||
const handleLogCopy = useCallback(
|
|
||||||
(logId: string, event: ReactMouseEvent<HTMLElement>): void => {
|
|
||||||
event.preventDefault();
|
|
||||||
event.stopPropagation();
|
|
||||||
|
|
||||||
const urlQuery = new URLSearchParams(window.location.search);
|
|
||||||
urlQuery.delete(QueryParams.activeLogId);
|
|
||||||
urlQuery.delete(QueryParams.relativeTime);
|
|
||||||
urlQuery.set(QueryParams.activeLogId, `"${logId}"`);
|
|
||||||
const link = `${window.location.origin}${pathname}?${urlQuery.toString()}`;
|
|
||||||
|
|
||||||
setCopy(link);
|
|
||||||
toast.success('Copied to clipboard', { position: 'top-right' });
|
|
||||||
},
|
|
||||||
[pathname, setCopy],
|
|
||||||
);
|
|
||||||
|
|
||||||
const itemContent = useCallback(
|
|
||||||
(index: number): JSX.Element | null => {
|
|
||||||
const row = tableRows[index];
|
|
||||||
if (!row) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<TanStackRowCells
|
|
||||||
row={row}
|
|
||||||
fontSize={tableViewProps.fontSize}
|
|
||||||
onSetActiveLog={onSetActiveLog}
|
|
||||||
onClearActiveLog={onClearActiveLog}
|
|
||||||
isActiveLog={
|
|
||||||
String(activeLog?.id ?? '') === String(row.original.currentLog.id ?? '')
|
|
||||||
}
|
|
||||||
isDarkMode={isDarkMode}
|
|
||||||
onLogCopy={handleLogCopy}
|
|
||||||
isLogsExplorerPage={isLogsExplorerPage}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
},
|
|
||||||
[
|
|
||||||
activeLog?.id,
|
|
||||||
handleLogCopy,
|
|
||||||
isDarkMode,
|
|
||||||
isLogsExplorerPage,
|
|
||||||
onClearActiveLog,
|
|
||||||
onSetActiveLog,
|
|
||||||
tableRows,
|
|
||||||
tableViewProps.fontSize,
|
|
||||||
],
|
|
||||||
);
|
|
||||||
|
|
||||||
const flatHeaders = useMemo(
|
|
||||||
() => table.getFlatHeaders().filter((header) => !header.isPlaceholder),
|
|
||||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
||||||
[tanstackColumns],
|
|
||||||
);
|
|
||||||
|
|
||||||
const tableHeader = useCallback(() => {
|
|
||||||
const orderedColumnsById = new Map(
|
|
||||||
orderedColumns.map((column) => [getColumnId(column), column] as const),
|
|
||||||
);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<DndContext
|
|
||||||
sensors={sensors}
|
|
||||||
collisionDetection={pointerWithin}
|
|
||||||
onDragEnd={handleDragEnd}
|
|
||||||
autoScroll={COLUMN_DND_AUTO_SCROLL}
|
|
||||||
>
|
|
||||||
<SortableContext
|
|
||||||
items={orderedColumnIds}
|
|
||||||
strategy={horizontalListSortingStrategy}
|
|
||||||
>
|
|
||||||
<tr>
|
|
||||||
{flatHeaders.map((header) => {
|
|
||||||
const column = orderedColumnsById.get(header.id);
|
|
||||||
if (!column) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<TanStackHeaderRow
|
|
||||||
key={header.id}
|
|
||||||
column={column}
|
|
||||||
header={header}
|
|
||||||
isDarkMode={isDarkMode}
|
|
||||||
fontSize={tableViewProps.fontSize}
|
|
||||||
hasSingleColumn={hasSingleColumn}
|
|
||||||
onRemoveColumn={onRemoveColumn}
|
|
||||||
canRemoveColumn={!isAtMinimumRemovableColumns}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</tr>
|
|
||||||
</SortableContext>
|
|
||||||
</DndContext>
|
|
||||||
);
|
|
||||||
}, [
|
|
||||||
flatHeaders,
|
|
||||||
handleDragEnd,
|
|
||||||
hasSingleColumn,
|
|
||||||
isDarkMode,
|
|
||||||
orderedColumnIds,
|
|
||||||
orderedColumns,
|
|
||||||
onRemoveColumn,
|
|
||||||
isAtMinimumRemovableColumns,
|
|
||||||
sensors,
|
|
||||||
tableViewProps.fontSize,
|
|
||||||
]);
|
|
||||||
|
|
||||||
const handleEndReached = useCallback(
|
|
||||||
(index: number): void => {
|
|
||||||
if (!infitiyTableProps?.onEndReached) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
setLoadMoreState({
|
|
||||||
active: true,
|
|
||||||
startCount: tableRows.length,
|
|
||||||
});
|
|
||||||
infitiyTableProps.onEndReached(index);
|
|
||||||
},
|
|
||||||
[infitiyTableProps, tableRows.length],
|
|
||||||
);
|
|
||||||
|
|
||||||
if (isLoading) {
|
|
||||||
return <Spinner height="35px" tip="Getting Logs" />;
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="tanstack-table-view-wrapper">
|
|
||||||
<TableVirtuoso
|
|
||||||
className="logs-table-virtuoso-scroll"
|
|
||||||
ref={virtuosoRef}
|
|
||||||
style={infinityDefaultStyles}
|
|
||||||
data={tableData}
|
|
||||||
totalCount={tableRows.length}
|
|
||||||
increaseViewportBy={{ top: 500, bottom: 500 }}
|
|
||||||
initialTopMostItemIndex={
|
|
||||||
tableViewProps.activeLogIndex !== -1 ? tableViewProps.activeLogIndex : 0
|
|
||||||
}
|
|
||||||
context={{ activeLog, activeContextLog, logsById }}
|
|
||||||
fixedHeaderContent={tableHeader}
|
|
||||||
itemContent={itemContent}
|
|
||||||
components={{
|
|
||||||
Table: ({ style, children }): JSX.Element => (
|
|
||||||
<TanStackTableStyled style={style}>
|
|
||||||
<colgroup>
|
|
||||||
{orderedColumns.map((column, colIndex) => {
|
|
||||||
const columnId = getColumnId(column);
|
|
||||||
const isFixedColumn =
|
|
||||||
column.key === 'expand' || column.key === 'state-indicator';
|
|
||||||
const minWidthPx = getColumnMinWidthPx(column, orderedColumns);
|
|
||||||
const persistedWidth = columnSizing[columnId];
|
|
||||||
const computedWidth = table.getColumn(columnId)?.getSize();
|
|
||||||
const effectiveWidth = persistedWidth ?? computedWidth;
|
|
||||||
if (isFixedColumn) {
|
|
||||||
return <col key={columnId} className="tanstack-fixed-col" />;
|
|
||||||
}
|
|
||||||
// Last data column should stretch to fill remaining space
|
|
||||||
const isLastColumn = colIndex === orderedColumns.length - 1;
|
|
||||||
if (isLastColumn) {
|
|
||||||
return (
|
|
||||||
<col
|
|
||||||
key={columnId}
|
|
||||||
style={{ width: '100%', minWidth: `${minWidthPx}px` }}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
const widthPx =
|
|
||||||
effectiveWidth != null
|
|
||||||
? Math.max(effectiveWidth, minWidthPx)
|
|
||||||
: minWidthPx;
|
|
||||||
return (
|
|
||||||
<col
|
|
||||||
key={columnId}
|
|
||||||
style={{ width: `${widthPx}px`, minWidth: `${minWidthPx}px` }}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</colgroup>
|
|
||||||
{children}
|
|
||||||
</TanStackTableStyled>
|
|
||||||
),
|
|
||||||
TableRow: TanStackCustomTableRow,
|
|
||||||
}}
|
|
||||||
{...(infitiyTableProps?.onEndReached
|
|
||||||
? { endReached: handleEndReached }
|
|
||||||
: {})}
|
|
||||||
/>
|
|
||||||
{loadMoreState.active && (
|
|
||||||
<div className="tanstack-load-more-container">
|
|
||||||
<Spinner height="20px" tip="Getting Logs" />
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
},
|
|
||||||
);
|
|
||||||
|
|
||||||
export default memo(TanStackTableView);
|
|
||||||
@@ -1,55 +0,0 @@
|
|||||||
.tanstack-table-view-wrapper {
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
width: 100%;
|
|
||||||
min-height: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.tanstack-fixed-col {
|
|
||||||
width: 32px;
|
|
||||||
min-width: 32px;
|
|
||||||
max-width: 32px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.tanstack-filler-col {
|
|
||||||
width: 100%;
|
|
||||||
min-width: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.tanstack-actions-col {
|
|
||||||
width: 0;
|
|
||||||
min-width: 0;
|
|
||||||
max-width: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.tanstack-load-more-container {
|
|
||||||
width: 100%;
|
|
||||||
min-height: 56px;
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
padding: 8px 0 12px;
|
|
||||||
flex-shrink: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.tanstack-table-virtuoso {
|
|
||||||
width: 100%;
|
|
||||||
overflow-x: scroll;
|
|
||||||
}
|
|
||||||
|
|
||||||
.tanstack-fontSize-small {
|
|
||||||
font-size: 11px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.tanstack-fontSize-medium {
|
|
||||||
font-size: 13px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.tanstack-fontSize-large {
|
|
||||||
font-size: 14px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.tanstack-table-foot-loader-cell {
|
|
||||||
text-align: center;
|
|
||||||
padding: 8px 0;
|
|
||||||
}
|
|
||||||
@@ -1,29 +0,0 @@
|
|||||||
import { ColumnSizingState } from '@tanstack/react-table';
|
|
||||||
import { ColumnTypeRender } from 'components/Logs/TableView/types';
|
|
||||||
import { ILog } from 'types/api/logs/log';
|
|
||||||
|
|
||||||
export type TableRecord = Record<string, unknown>;
|
|
||||||
|
|
||||||
export type LogsTableColumnDef = {
|
|
||||||
key?: string | number;
|
|
||||||
title?: string;
|
|
||||||
render?: (
|
|
||||||
value: unknown,
|
|
||||||
record: TableRecord,
|
|
||||||
index: number,
|
|
||||||
) => ColumnTypeRender<Record<string, unknown>>;
|
|
||||||
};
|
|
||||||
|
|
||||||
export type OrderedColumn = LogsTableColumnDef & {
|
|
||||||
key: string | number;
|
|
||||||
};
|
|
||||||
|
|
||||||
export type TanStackTableRowData = {
|
|
||||||
log: TableRecord;
|
|
||||||
currentLog: ILog;
|
|
||||||
rowIndex: number;
|
|
||||||
};
|
|
||||||
|
|
||||||
export type PersistedColumnSizing = {
|
|
||||||
sizing: ColumnSizingState;
|
|
||||||
};
|
|
||||||
@@ -1,111 +0,0 @@
|
|||||||
import { Dispatch, SetStateAction, useEffect, useMemo, useState } from 'react';
|
|
||||||
import { ColumnSizingState } from '@tanstack/react-table';
|
|
||||||
import getFromLocalstorage from 'api/browser/localstorage/get';
|
|
||||||
import setToLocalstorage from 'api/browser/localstorage/set';
|
|
||||||
import { LOCALSTORAGE } from 'constants/localStorage';
|
|
||||||
|
|
||||||
import { OrderedColumn, PersistedColumnSizing } from './types';
|
|
||||||
import { getColumnId } from './utils';
|
|
||||||
|
|
||||||
const COLUMN_SIZING_PERSIST_DEBOUNCE_MS = 250;
|
|
||||||
|
|
||||||
const sanitizeSizing = (input: unknown): ColumnSizingState => {
|
|
||||||
if (!input || typeof input !== 'object') {
|
|
||||||
return {};
|
|
||||||
}
|
|
||||||
return Object.entries(
|
|
||||||
input as Record<string, unknown>,
|
|
||||||
).reduce<ColumnSizingState>((acc, [key, value]) => {
|
|
||||||
if (typeof value !== 'number' || !Number.isFinite(value) || value <= 0) {
|
|
||||||
return acc;
|
|
||||||
}
|
|
||||||
acc[key] = value;
|
|
||||||
return acc;
|
|
||||||
}, {});
|
|
||||||
};
|
|
||||||
|
|
||||||
const readPersistedColumnSizing = (): ColumnSizingState => {
|
|
||||||
const rawSizing = getFromLocalstorage(LOCALSTORAGE.LOGS_LIST_COLUMN_SIZING);
|
|
||||||
if (!rawSizing) {
|
|
||||||
return {};
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
const parsed = JSON.parse(rawSizing) as
|
|
||||||
| PersistedColumnSizing
|
|
||||||
| ColumnSizingState;
|
|
||||||
const sizing = ('sizing' in parsed
|
|
||||||
? parsed.sizing
|
|
||||||
: parsed) as ColumnSizingState;
|
|
||||||
return sanitizeSizing(sizing);
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Failed to parse persisted log column sizing', error);
|
|
||||||
return {};
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
type UseColumnSizingPersistenceResult = {
|
|
||||||
columnSizing: ColumnSizingState;
|
|
||||||
setColumnSizing: Dispatch<SetStateAction<ColumnSizingState>>;
|
|
||||||
};
|
|
||||||
|
|
||||||
export const useColumnSizingPersistence = (
|
|
||||||
orderedColumns: OrderedColumn[],
|
|
||||||
): UseColumnSizingPersistenceResult => {
|
|
||||||
const [columnSizing, setColumnSizing] = useState<ColumnSizingState>(() =>
|
|
||||||
readPersistedColumnSizing(),
|
|
||||||
);
|
|
||||||
const orderedColumnIds = useMemo(
|
|
||||||
() => orderedColumns.map((column) => getColumnId(column)),
|
|
||||||
[orderedColumns],
|
|
||||||
);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (orderedColumnIds.length === 0) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const validColumnIds = new Set(orderedColumnIds);
|
|
||||||
const nonResizableColumnIds = new Set(
|
|
||||||
orderedColumns
|
|
||||||
.filter(
|
|
||||||
(column) => column.key === 'expand' || column.key === 'state-indicator',
|
|
||||||
)
|
|
||||||
.map((column) => getColumnId(column)),
|
|
||||||
);
|
|
||||||
|
|
||||||
setColumnSizing((previousSizing) => {
|
|
||||||
const nextSizing = Object.entries(previousSizing).reduce<ColumnSizingState>(
|
|
||||||
(acc, [columnId, size]) => {
|
|
||||||
if (!validColumnIds.has(columnId) || nonResizableColumnIds.has(columnId)) {
|
|
||||||
return acc;
|
|
||||||
}
|
|
||||||
acc[columnId] = size;
|
|
||||||
return acc;
|
|
||||||
},
|
|
||||||
{},
|
|
||||||
);
|
|
||||||
const hasChanged =
|
|
||||||
Object.keys(nextSizing).length !== Object.keys(previousSizing).length ||
|
|
||||||
Object.entries(nextSizing).some(
|
|
||||||
([columnId, size]) => previousSizing[columnId] !== size,
|
|
||||||
);
|
|
||||||
|
|
||||||
return hasChanged ? nextSizing : previousSizing;
|
|
||||||
});
|
|
||||||
}, [orderedColumnIds, orderedColumns]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
const timeoutId = window.setTimeout(() => {
|
|
||||||
const persistedSizing = { sizing: columnSizing };
|
|
||||||
setToLocalstorage(
|
|
||||||
LOCALSTORAGE.LOGS_LIST_COLUMN_SIZING,
|
|
||||||
JSON.stringify(persistedSizing),
|
|
||||||
);
|
|
||||||
}, COLUMN_SIZING_PERSIST_DEBOUNCE_MS);
|
|
||||||
|
|
||||||
return (): void => window.clearTimeout(timeoutId);
|
|
||||||
}, [columnSizing]);
|
|
||||||
|
|
||||||
return { columnSizing, setColumnSizing };
|
|
||||||
};
|
|
||||||
@@ -1,108 +0,0 @@
|
|||||||
import { useCallback, useMemo } from 'react';
|
|
||||||
import {
|
|
||||||
DragEndEvent,
|
|
||||||
PointerSensor,
|
|
||||||
useSensor,
|
|
||||||
useSensors,
|
|
||||||
} from '@dnd-kit/core';
|
|
||||||
import { arrayMove } from '@dnd-kit/sortable';
|
|
||||||
import { getDraggedColumns } from 'hooks/useDragColumns/utils';
|
|
||||||
|
|
||||||
import { OrderedColumn, TableRecord } from './types';
|
|
||||||
import { getColumnId } from './utils';
|
|
||||||
|
|
||||||
type UseOrderedColumnsProps = {
|
|
||||||
columns: unknown[];
|
|
||||||
draggedColumns: unknown[];
|
|
||||||
onColumnOrderChange: (columns: unknown[]) => void;
|
|
||||||
};
|
|
||||||
|
|
||||||
type UseOrderedColumnsResult = {
|
|
||||||
orderedColumns: OrderedColumn[];
|
|
||||||
orderedColumnIds: string[];
|
|
||||||
hasSingleColumn: boolean;
|
|
||||||
handleDragEnd: (event: DragEndEvent) => void;
|
|
||||||
sensors: ReturnType<typeof useSensors>;
|
|
||||||
};
|
|
||||||
|
|
||||||
export const useOrderedColumns = ({
|
|
||||||
columns,
|
|
||||||
draggedColumns,
|
|
||||||
onColumnOrderChange,
|
|
||||||
}: UseOrderedColumnsProps): UseOrderedColumnsResult => {
|
|
||||||
const baseColumns = useMemo<OrderedColumn[]>(
|
|
||||||
() =>
|
|
||||||
getDraggedColumns<TableRecord>(
|
|
||||||
columns as never[],
|
|
||||||
draggedColumns as never[],
|
|
||||||
).filter(
|
|
||||||
(column): column is OrderedColumn =>
|
|
||||||
typeof column.key === 'string' || typeof column.key === 'number',
|
|
||||||
),
|
|
||||||
[columns, draggedColumns],
|
|
||||||
);
|
|
||||||
|
|
||||||
const orderedColumns = useMemo(() => {
|
|
||||||
const stateIndicatorIndex = baseColumns.findIndex(
|
|
||||||
(column) => column.key === 'state-indicator',
|
|
||||||
);
|
|
||||||
if (stateIndicatorIndex <= 0) {
|
|
||||||
return baseColumns;
|
|
||||||
}
|
|
||||||
const pinned = baseColumns[stateIndicatorIndex];
|
|
||||||
const rest = baseColumns.filter((_, i) => i !== stateIndicatorIndex);
|
|
||||||
return [pinned, ...rest];
|
|
||||||
}, [baseColumns]);
|
|
||||||
|
|
||||||
const handleDragEnd = useCallback(
|
|
||||||
(event: DragEndEvent): void => {
|
|
||||||
const { active, over } = event;
|
|
||||||
if (!over || active.id === over.id) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Don't allow moving the state-indicator column
|
|
||||||
if (String(active.id) === 'state-indicator') {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const oldIndex = orderedColumns.findIndex(
|
|
||||||
(column) => getColumnId(column) === String(active.id),
|
|
||||||
);
|
|
||||||
const newIndex = orderedColumns.findIndex(
|
|
||||||
(column) => getColumnId(column) === String(over.id),
|
|
||||||
);
|
|
||||||
if (oldIndex === -1 || newIndex === -1) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const nextColumns = arrayMove(orderedColumns, oldIndex, newIndex);
|
|
||||||
onColumnOrderChange(nextColumns as unknown[]);
|
|
||||||
},
|
|
||||||
[onColumnOrderChange, orderedColumns],
|
|
||||||
);
|
|
||||||
|
|
||||||
const orderedColumnIds = useMemo(
|
|
||||||
() => orderedColumns.map((column) => getColumnId(column)),
|
|
||||||
[orderedColumns],
|
|
||||||
);
|
|
||||||
const hasSingleColumn = useMemo(
|
|
||||||
() =>
|
|
||||||
orderedColumns.filter((column) => column.key !== 'state-indicator')
|
|
||||||
.length === 1,
|
|
||||||
[orderedColumns],
|
|
||||||
);
|
|
||||||
const sensors = useSensors(
|
|
||||||
useSensor(PointerSensor, {
|
|
||||||
activationConstraint: { distance: 4 },
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
|
|
||||||
return {
|
|
||||||
orderedColumns,
|
|
||||||
orderedColumnIds,
|
|
||||||
hasSingleColumn,
|
|
||||||
handleDragEnd,
|
|
||||||
sensors,
|
|
||||||
};
|
|
||||||
};
|
|
||||||
@@ -1,61 +0,0 @@
|
|||||||
import { cloneElement, isValidElement, ReactElement } from 'react';
|
|
||||||
import { ColumnTypeRender } from 'components/Logs/TableView/types';
|
|
||||||
|
|
||||||
import { OrderedColumn } from './types';
|
|
||||||
|
|
||||||
export const getColumnId = (column: OrderedColumn): string =>
|
|
||||||
String(column.key);
|
|
||||||
|
|
||||||
/** Browser default root font size; TanStack column sizing uses px. */
|
|
||||||
const REM_PX = 16;
|
|
||||||
const MIN_WIDTH_OTHER_REM = 12;
|
|
||||||
const MIN_WIDTH_BODY_REM = 40;
|
|
||||||
|
|
||||||
/** When total column count is below this, body column min width is doubled (more horizontal space for few columns). */
|
|
||||||
export const FEW_COLUMNS_BODY_MIN_WIDTH_THRESHOLD = 4;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Minimum width (px) for TanStack column defs + colgroup.
|
|
||||||
* Design: state/expand 32px; body min 40rem (doubled when fewer than
|
|
||||||
* {@link FEW_COLUMNS_BODY_MIN_WIDTH_THRESHOLD} total columns); other columns use rem→px (16px root).
|
|
||||||
*/
|
|
||||||
export const getColumnMinWidthPx = (
|
|
||||||
column: OrderedColumn,
|
|
||||||
orderedColumns?: OrderedColumn[],
|
|
||||||
): number => {
|
|
||||||
const key = String(column.key);
|
|
||||||
if (key === 'state-indicator' || key === 'expand') {
|
|
||||||
return 32;
|
|
||||||
}
|
|
||||||
if (key === 'body') {
|
|
||||||
const base = MIN_WIDTH_BODY_REM * REM_PX;
|
|
||||||
const fewColumns =
|
|
||||||
orderedColumns != null &&
|
|
||||||
orderedColumns.length < FEW_COLUMNS_BODY_MIN_WIDTH_THRESHOLD;
|
|
||||||
return fewColumns ? base * 1.5 : base;
|
|
||||||
}
|
|
||||||
return MIN_WIDTH_OTHER_REM * REM_PX;
|
|
||||||
};
|
|
||||||
|
|
||||||
export const resolveColumnTypeRender = (
|
|
||||||
rendered: ColumnTypeRender<Record<string, unknown>>,
|
|
||||||
): ReactElement | string | number | null => {
|
|
||||||
if (
|
|
||||||
rendered &&
|
|
||||||
typeof rendered === 'object' &&
|
|
||||||
'children' in rendered &&
|
|
||||||
isValidElement(rendered.children)
|
|
||||||
) {
|
|
||||||
const { children, props } = rendered as {
|
|
||||||
children: ReactElement;
|
|
||||||
props?: Record<string, unknown>;
|
|
||||||
};
|
|
||||||
return cloneElement(children, props || {});
|
|
||||||
}
|
|
||||||
if (rendered && typeof rendered === 'object' && isValidElement(rendered)) {
|
|
||||||
return rendered;
|
|
||||||
}
|
|
||||||
return typeof rendered === 'string' || typeof rendered === 'number'
|
|
||||||
? rendered
|
|
||||||
: null;
|
|
||||||
};
|
|
||||||
@@ -1,16 +1,27 @@
|
|||||||
|
import type { CSSProperties, MouseEvent, ReactNode } from 'react';
|
||||||
import { memo, useCallback, useEffect, useMemo, useRef } from 'react';
|
import { memo, useCallback, useEffect, useMemo, useRef } from 'react';
|
||||||
|
import { useLocation } from 'react-router-dom';
|
||||||
|
import { useCopyToClipboard } from 'react-use';
|
||||||
import { Virtuoso, VirtuosoHandle } from 'react-virtuoso';
|
import { Virtuoso, VirtuosoHandle } from 'react-virtuoso';
|
||||||
|
import { toast } from '@signozhq/sonner';
|
||||||
import { Card } from 'antd';
|
import { Card } from 'antd';
|
||||||
import logEvent from 'api/common/logEvent';
|
import logEvent from 'api/common/logEvent';
|
||||||
import ErrorInPlace from 'components/ErrorInPlace/ErrorInPlace';
|
import ErrorInPlace from 'components/ErrorInPlace/ErrorInPlace';
|
||||||
import LogDetail from 'components/LogDetail';
|
import LogDetail from 'components/LogDetail';
|
||||||
// components
|
import { VIEW_TYPES } from 'components/LogDetail/constants';
|
||||||
import ListLogView from 'components/Logs/ListLogView';
|
import ListLogView from 'components/Logs/ListLogView';
|
||||||
|
import LogLinesActionButtons from 'components/Logs/LogLinesActionButtons/LogLinesActionButtons';
|
||||||
|
import { getRowBackgroundColor } from 'components/Logs/LogStateIndicator/getRowBackgroundColor';
|
||||||
|
import { getLogIndicatorType } from 'components/Logs/LogStateIndicator/utils';
|
||||||
import RawLogView from 'components/Logs/RawLogView';
|
import RawLogView from 'components/Logs/RawLogView';
|
||||||
|
import { useLogsTableColumns } from 'components/Logs/TableView/useLogsTableColumns';
|
||||||
import OverlayScrollbar from 'components/OverlayScrollbar/OverlayScrollbar';
|
import OverlayScrollbar from 'components/OverlayScrollbar/OverlayScrollbar';
|
||||||
import Spinner from 'components/Spinner';
|
import Spinner from 'components/Spinner';
|
||||||
|
import type { TanStackTableHandle } from 'components/TanStackTableView';
|
||||||
|
import TanStackTable, { useTableColumns } from 'components/TanStackTableView';
|
||||||
import { CARD_BODY_STYLE } from 'constants/card';
|
import { CARD_BODY_STYLE } from 'constants/card';
|
||||||
import { LOCALSTORAGE } from 'constants/localStorage';
|
import { LOCALSTORAGE } from 'constants/localStorage';
|
||||||
|
import { QueryParams } from 'constants/query';
|
||||||
import EmptyLogsSearch from 'container/EmptyLogsSearch/EmptyLogsSearch';
|
import EmptyLogsSearch from 'container/EmptyLogsSearch/EmptyLogsSearch';
|
||||||
import { LogsLoading } from 'container/LogsLoading/LogsLoading';
|
import { LogsLoading } from 'container/LogsLoading/LogsLoading';
|
||||||
import { useOptionsMenu } from 'container/OptionsMenu';
|
import { useOptionsMenu } from 'container/OptionsMenu';
|
||||||
@@ -19,6 +30,7 @@ import { useCopyLogLink } from 'hooks/logs/useCopyLogLink';
|
|||||||
import useLogDetailHandlers from 'hooks/logs/useLogDetailHandlers';
|
import useLogDetailHandlers from 'hooks/logs/useLogDetailHandlers';
|
||||||
import useScrollToLog from 'hooks/logs/useScrollToLog';
|
import useScrollToLog from 'hooks/logs/useScrollToLog';
|
||||||
import { useQueryBuilder } from 'hooks/queryBuilder/useQueryBuilder';
|
import { useQueryBuilder } from 'hooks/queryBuilder/useQueryBuilder';
|
||||||
|
import { useIsDarkMode } from 'hooks/useDarkMode';
|
||||||
import APIError from 'types/api/error';
|
import APIError from 'types/api/error';
|
||||||
// interfaces
|
// interfaces
|
||||||
import { ILog } from 'types/api/logs/log';
|
import { ILog } from 'types/api/logs/log';
|
||||||
@@ -27,7 +39,6 @@ import { DataSource, StringOperators } from 'types/common/queryBuilder';
|
|||||||
import NoLogs from '../NoLogs/NoLogs';
|
import NoLogs from '../NoLogs/NoLogs';
|
||||||
import { LogsExplorerListProps } from './LogsExplorerList.interfaces';
|
import { LogsExplorerListProps } from './LogsExplorerList.interfaces';
|
||||||
import { InfinityWrapperStyled } from './styles';
|
import { InfinityWrapperStyled } from './styles';
|
||||||
import TanStackTableView from './TanStackTableView';
|
|
||||||
import {
|
import {
|
||||||
convertKeysToColumnFields,
|
convertKeysToColumnFields,
|
||||||
getEmptyLogsListConfig,
|
getEmptyLogsListConfig,
|
||||||
@@ -50,7 +61,10 @@ function LogsExplorerList({
|
|||||||
isFilterApplied,
|
isFilterApplied,
|
||||||
handleChangeSelectedView,
|
handleChangeSelectedView,
|
||||||
}: LogsExplorerListProps): JSX.Element {
|
}: LogsExplorerListProps): JSX.Element {
|
||||||
const ref = useRef<VirtuosoHandle>(null);
|
const ref = useRef<TanStackTableHandle | VirtuosoHandle | null>(null);
|
||||||
|
const { pathname } = useLocation();
|
||||||
|
const [, setCopy] = useCopyToClipboard();
|
||||||
|
const isDarkMode = useIsDarkMode();
|
||||||
const { activeLogId } = useCopyLogLink();
|
const { activeLogId } = useCopyLogLink();
|
||||||
|
|
||||||
const {
|
const {
|
||||||
@@ -84,9 +98,46 @@ function LogsExplorerList({
|
|||||||
[options],
|
[options],
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const logsColumns = useLogsTableColumns({
|
||||||
|
fields: selectedFields,
|
||||||
|
linesPerRow: options.maxLines,
|
||||||
|
fontSize: options.fontSize,
|
||||||
|
appendTo: 'end',
|
||||||
|
});
|
||||||
|
|
||||||
|
const { tableProps } = useTableColumns(logsColumns, {
|
||||||
|
storageKey: LOCALSTORAGE.LOGS_LIST_COLUMNS,
|
||||||
|
});
|
||||||
|
|
||||||
|
const handleRemoveColumn = useCallback(
|
||||||
|
(columnId: string): void => {
|
||||||
|
tableProps.onRemoveColumn(columnId);
|
||||||
|
config.addColumn?.onRemove?.(columnId);
|
||||||
|
},
|
||||||
|
[tableProps, config.addColumn],
|
||||||
|
);
|
||||||
|
|
||||||
|
const makeOnLogCopy = useCallback(
|
||||||
|
(log: ILog) => (event: MouseEvent<HTMLElement>): void => {
|
||||||
|
event.preventDefault();
|
||||||
|
event.stopPropagation();
|
||||||
|
const urlQuery = new URLSearchParams(window.location.search);
|
||||||
|
urlQuery.delete(QueryParams.activeLogId);
|
||||||
|
urlQuery.delete(QueryParams.relativeTime);
|
||||||
|
urlQuery.set(QueryParams.activeLogId, `"${log.id}"`);
|
||||||
|
const link = `${window.location.origin}${pathname}?${urlQuery.toString()}`;
|
||||||
|
setCopy(link);
|
||||||
|
toast.success('Copied to clipboard', { position: 'top-right' });
|
||||||
|
},
|
||||||
|
[pathname, setCopy],
|
||||||
|
);
|
||||||
|
|
||||||
const handleScrollToLog = useScrollToLog({
|
const handleScrollToLog = useScrollToLog({
|
||||||
logs,
|
logs,
|
||||||
virtuosoRef: ref,
|
virtuosoRef: ref as React.RefObject<Pick<
|
||||||
|
VirtuosoHandle,
|
||||||
|
'scrollToIndex'
|
||||||
|
> | null>,
|
||||||
});
|
});
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -155,25 +206,45 @@ function LogsExplorerList({
|
|||||||
|
|
||||||
if (options.format === 'table') {
|
if (options.format === 'table') {
|
||||||
return (
|
return (
|
||||||
<TanStackTableView
|
<TanStackTable
|
||||||
ref={ref}
|
ref={ref as React.Ref<TanStackTableHandle>}
|
||||||
isLoading={isLoading}
|
{...tableProps}
|
||||||
isFetching={isFetching}
|
plainTextCellLineClamp={options.maxLines}
|
||||||
tableViewProps={{
|
cellTypographySize={options.fontSize}
|
||||||
logs,
|
onRemoveColumn={handleRemoveColumn}
|
||||||
fields: selectedFields,
|
data={logs}
|
||||||
linesPerRow: options.maxLines,
|
isLoading={isLoading || isFetching}
|
||||||
fontSize: options.fontSize,
|
onEndReached={onEndReached}
|
||||||
appendTo: 'end',
|
isRowActive={(log): boolean =>
|
||||||
activeLogIndex,
|
log.id === activeLog?.id || log.id === activeLogId
|
||||||
|
}
|
||||||
|
getRowStyle={(log): CSSProperties =>
|
||||||
|
({
|
||||||
|
'--row-active-bg': getRowBackgroundColor(
|
||||||
|
isDarkMode,
|
||||||
|
getLogIndicatorType(log),
|
||||||
|
),
|
||||||
|
'--row-hover-bg': getRowBackgroundColor(
|
||||||
|
isDarkMode,
|
||||||
|
getLogIndicatorType(log),
|
||||||
|
),
|
||||||
|
} as CSSProperties)
|
||||||
|
}
|
||||||
|
onRowClick={(log): void => {
|
||||||
|
handleSetActiveLog(log);
|
||||||
}}
|
}}
|
||||||
infitiyTableProps={{ onEndReached }}
|
onRowDeactivate={handleCloseLogDetail}
|
||||||
handleChangeSelectedView={handleChangeSelectedView}
|
activeRowIndex={activeLogIndex}
|
||||||
logs={logs}
|
renderRowActions={(log): ReactNode => (
|
||||||
onSetActiveLog={handleSetActiveLog}
|
<LogLinesActionButtons
|
||||||
onClearActiveLog={handleCloseLogDetail}
|
handleShowContext={(e): void => {
|
||||||
activeLog={activeLog}
|
e.preventDefault();
|
||||||
onRemoveColumn={config.addColumn?.onRemove}
|
e.stopPropagation();
|
||||||
|
handleSetActiveLog(log, VIEW_TYPES.CONTEXT);
|
||||||
|
}}
|
||||||
|
onLogCopy={makeOnLogCopy(log)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -198,7 +269,7 @@ function LogsExplorerList({
|
|||||||
<OverlayScrollbar isVirtuoso>
|
<OverlayScrollbar isVirtuoso>
|
||||||
<Virtuoso
|
<Virtuoso
|
||||||
key={activeLogIndex || 'logs-virtuoso'}
|
key={activeLogIndex || 'logs-virtuoso'}
|
||||||
ref={ref}
|
ref={ref as React.Ref<VirtuosoHandle>}
|
||||||
initialTopMostItemIndex={activeLogIndex !== -1 ? activeLogIndex : 0}
|
initialTopMostItemIndex={activeLogIndex !== -1 ? activeLogIndex : 0}
|
||||||
data={logs}
|
data={logs}
|
||||||
endReached={onEndReached}
|
endReached={onEndReached}
|
||||||
@@ -219,12 +290,13 @@ function LogsExplorerList({
|
|||||||
onEndReached,
|
onEndReached,
|
||||||
getItemContent,
|
getItemContent,
|
||||||
isFetching,
|
isFetching,
|
||||||
selectedFields,
|
|
||||||
handleChangeSelectedView,
|
|
||||||
handleSetActiveLog,
|
handleSetActiveLog,
|
||||||
handleCloseLogDetail,
|
handleCloseLogDetail,
|
||||||
activeLog,
|
activeLog,
|
||||||
config.addColumn?.onRemove,
|
tableProps,
|
||||||
|
handleRemoveColumn,
|
||||||
|
isDarkMode,
|
||||||
|
makeOnLogCopy,
|
||||||
]);
|
]);
|
||||||
|
|
||||||
const isTraceToLogsNavigation = useMemo(() => {
|
const isTraceToLogsNavigation = useMemo(() => {
|
||||||
|
|||||||
@@ -1,38 +0,0 @@
|
|||||||
.logs-table-virtuoso-scroll {
|
|
||||||
scrollbar-width: thin;
|
|
||||||
scrollbar-color: var(--bg-slate-300) transparent;
|
|
||||||
|
|
||||||
&::-webkit-scrollbar {
|
|
||||||
width: 4px;
|
|
||||||
height: 4px;
|
|
||||||
}
|
|
||||||
|
|
||||||
&::-webkit-scrollbar-corner {
|
|
||||||
background: transparent;
|
|
||||||
}
|
|
||||||
|
|
||||||
&::-webkit-scrollbar-track {
|
|
||||||
background: transparent;
|
|
||||||
}
|
|
||||||
|
|
||||||
&::-webkit-scrollbar-thumb {
|
|
||||||
background: var(--bg-slate-300);
|
|
||||||
border-radius: 9999px;
|
|
||||||
}
|
|
||||||
|
|
||||||
&::-webkit-scrollbar-thumb:hover {
|
|
||||||
background: var(--bg-slate-200);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.lightMode .logs-table-virtuoso-scroll {
|
|
||||||
scrollbar-color: var(--bg-vanilla-300) transparent;
|
|
||||||
|
|
||||||
&::-webkit-scrollbar-thumb {
|
|
||||||
background: var(--bg-vanilla-300);
|
|
||||||
}
|
|
||||||
|
|
||||||
&::-webkit-scrollbar-thumb:hover {
|
|
||||||
background: var(--bg-vanilla-100);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,9 +1,11 @@
|
|||||||
import { useCallback } from 'react';
|
import { useCallback } from 'react';
|
||||||
import type { VirtuosoHandle } from 'react-virtuoso';
|
import type { VirtuosoHandle } from 'react-virtuoso';
|
||||||
|
|
||||||
|
type ScrollToIndexHandle = Pick<VirtuosoHandle, 'scrollToIndex'>;
|
||||||
|
|
||||||
type UseScrollToLogParams = {
|
type UseScrollToLogParams = {
|
||||||
logs: Array<{ id: string }>;
|
logs: Array<{ id: string }>;
|
||||||
virtuosoRef: React.RefObject<VirtuosoHandle | null>;
|
virtuosoRef: React.RefObject<ScrollToIndexHandle | null>;
|
||||||
};
|
};
|
||||||
|
|
||||||
function useScrollToLog({
|
function useScrollToLog({
|
||||||
|
|||||||
Reference in New Issue
Block a user