Compare commits

...

10 Commits

Author SHA1 Message Date
Vinícius Lourenço
bf49db3501 refactor(table): cleanup and fixes related to column and old preferences of logs 2026-04-15 17:35:59 -03:00
Vinícius Lourenço
0acb1e0bed docs(tanstacktable): add more docs about usage 2026-04-14 22:29:58 -03:00
Vinícius Lourenço
5861718972 refactor(tanstack-table): refine component based on infra monitoring 2026-04-14 22:23:42 -03:00
Vinícius Lourenço
7d8c8e3d7d fix(row): missing active styles 2026-04-13 19:37:13 -03:00
Vinícius Lourenço
5eae936ab4 fix(tests): use find by text 2026-04-13 19:17:34 -03:00
Vinícius Lourenço
091d61045a fix(styles): minor fixes on styles 2026-04-13 19:15:43 -03:00
Vinícius Lourenço
ed1217e5d0 chore(tests): cleanup tests and components 2026-04-13 18:57:30 -03:00
Vinícius Lourenço
0389b46836 refactor(table): optimize 2026-04-13 18:36:45 -03:00
Vinícius Lourenço
284d6f72d4 refactor(table): extract table to own component 2026-04-13 15:49:33 -03:00
Vinícius Lourenço
66abfa3be4 refactor(tanstack): move table to components & convert to css modules 2026-04-08 15:27:59 -03:00
52 changed files with 5905 additions and 2247 deletions

View File

@@ -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 {
.log-line-action-buttons {
border: 1px solid var(--bg-vanilla-400);

View File

@@ -1,6 +1,4 @@
.log-state-indicator {
padding-left: 8px;
.line {
margin: 0 8px;
min-height: 24px;

View File

@@ -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;
}
}

View File

@@ -0,0 +1,115 @@
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';
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,
canBeHidden: 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: { default: 170, min: 170 },
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,
canBeHidden: false,
width: { default: '100%', min: 640 },
cell: ({ value, isActive }): ReactElement => (
<TanStackTable.Text
dangerouslySetInnerHTML={{
__html: getSanitizedLogBody(value as string, {
shouldEscapeHtml: true,
}),
}}
data-active={isActive}
/>
),
}
: null;
return [
stateIndicatorCol,
...(timestampCol ? [timestampCol] : []),
...(appendTo === 'center' ? fieldColumns : []),
...(bodyCol ? [bodyCol] : []),
...(appendTo === 'end' ? fieldColumns : []),
];
}, [fields, appendTo, fontSize, formatTimezoneAdjustedTimestamp]);
}

View File

@@ -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;

View File

@@ -1,8 +1,8 @@
.tanstack-header-cell {
.tanstackHeaderCell {
position: sticky;
top: 0;
z-index: 2;
padding: 0;
padding: 0.3rem;
transform: translate3d(
var(--tanstack-header-translate-x, 0px),
var(--tanstack-header-translate-y, 0px),
@@ -10,16 +10,16 @@
);
transition: var(--tanstack-header-transition, none);
&.is-dragging {
&.isDragging {
opacity: 0.85;
}
&.is-resizing {
&.isResizing {
background: var(--l2-background-hover);
}
}
.tanstack-header-content {
.tanstackHeaderContent {
display: flex;
align-items: center;
height: 100%;
@@ -28,20 +28,20 @@
cursor: default;
max-width: 100%;
&.has-resize-control {
&.hasResizeControl {
max-width: calc(100% - 5px);
}
&.has-action-control {
&.hasActionControl {
max-width: calc(100% - 5px);
}
&.has-resize-control.has-action-control {
&.hasResizeControl.hasActionControl {
max-width: calc(100% - 10px);
}
}
.tanstack-grip-slot {
.tanstackGripSlot {
display: inline-flex;
align-items: center;
justify-content: center;
@@ -51,7 +51,7 @@
flex-shrink: 0;
}
.tanstack-grip-activator {
.tanstackGripActivator {
display: inline-flex;
align-items: center;
justify-content: center;
@@ -63,7 +63,7 @@
touch-action: none;
}
.tanstack-header-action-trigger {
.tanstackHeaderActionTrigger {
display: inline-flex;
align-items: center;
justify-content: center;
@@ -72,9 +72,11 @@
cursor: pointer;
flex-shrink: 0;
color: var(--l2-foreground);
margin-left: auto;
}
.tanstack-column-actions-content {
.tanstackColumnActionsContent {
width: 140px;
padding: 0;
background: var(--l2-background);
@@ -83,7 +85,7 @@
box-shadow: none;
}
.tanstack-remove-column-action {
.tanstackRemoveColumnAction {
display: flex;
align-items: center;
gap: 8px;
@@ -105,19 +107,19 @@
background: var(--l2-background-hover);
color: var(--l2-foreground);
.tanstack-remove-column-action-icon {
.tanstackRemoveColumnActionIcon {
color: var(--l2-foreground);
}
}
}
.tanstack-remove-column-action-icon {
.tanstackRemoveColumnActionIcon {
font-size: 11px;
color: var(--l2-foreground);
opacity: 0.95;
}
.tanstack-header-cell .cursor-col-resize {
.tanstackHeaderCell .cursorColResize {
position: absolute;
top: 0;
right: 0;
@@ -129,25 +131,84 @@
background: transparent;
}
.tanstack-header-cell.is-resizing .cursor-col-resize {
.tanstackHeaderCell.isResizing .cursorColResize {
background: var(--bg-robin-300);
}
.tanstack-resize-handle-line {
.tanstackResizeHandleLine {
position: absolute;
top: 0;
bottom: 0;
left: 50%;
width: 4px;
transform: translateX(-50%);
background: var(--l2-border);
opacity: 1;
pointer-events: none;
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;
background: var(--bg-robin-500);
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;
}

View File

@@ -0,0 +1,260 @@
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;
/** Last column cannot be resized */
isLastColumn?: boolean;
};
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,
isLastColumn = false,
}: TanStackHeaderRowProps<TData>): JSX.Element {
const columnId = column.id;
const isDragColumn = column.enableMove !== false && column.pin == null;
const isResizableColumn =
!isLastColumn &&
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;

View 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;

View File

@@ -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;

View File

@@ -0,0 +1,100 @@
.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;
overflow-anchor: none;
&: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;
}

View File

@@ -0,0 +1,706 @@
import type { ComponentProps, CSSProperties, SetStateAction } 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,
ColumnSizingState,
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,
TableColumnDef,
TableRowContext,
TanStackTableHandle,
TanStackTableProps,
} from './types';
import { useColumnState } from './useColumnState';
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 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,
columnStorageKey,
columnSizing: columnSizingProp,
onColumnSizingChange,
onColumnOrderChange,
onColumnRemove,
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,
});
const isGrouped = (groupBy?.length ?? 0) > 0;
const {
columnVisibility: storeVisibility,
columnSizing: storeSizing,
sortedColumns,
hideColumn,
setColumnSizing: storeSetSizing,
setColumnOrder: storeSetOrder,
} = useColumnState({
storageKey: columnStorageKey,
columns,
isGrouped,
});
// Use store values when columnStorageKey is provided, otherwise fall back to props/defaults
const effectiveColumns = columnStorageKey ? sortedColumns : columns;
const effectiveVisibility = columnStorageKey ? storeVisibility : {};
const effectiveSizing = columnStorageKey
? storeSizing
: columnSizingProp ?? {};
// 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 handleColumnSizingChange = useCallback(
(updater: SetStateAction<ColumnSizingState>) => {
const next =
typeof updater === 'function' ? updater(effectiveSizing) : updater;
if (columnStorageKey) {
storeSetSizing(next);
}
onColumnSizingChange?.(next);
},
[columnStorageKey, effectiveSizing, storeSetSizing, onColumnSizingChange],
);
const handleColumnOrderChange = useCallback(
(cols: TableColumnDef<TData>[]) => {
if (columnStorageKey) {
storeSetOrder(cols);
}
onColumnOrderChange?.(cols);
},
[columnStorageKey, storeSetOrder, onColumnOrderChange],
);
const handleRemoveColumn = useCallback(
(columnId: string) => {
if (columnStorageKey) {
hideColumn(columnId);
}
onColumnRemove?.(columnId);
},
[columnStorageKey, hideColumn, onColumnRemove],
);
const columnPinning = useMemo<ColumnPinningState>(
() => ({
left: effectiveColumns.filter((c) => c.pin === 'left').map((c) => c.id),
right: effectiveColumns.filter((c) => c.pin === 'right').map((c) => c.id),
}),
[effectiveColumns],
);
const tanstackColumns = useMemo<ColumnDef<TData>[]>(
() =>
effectiveColumns.map((colDef) =>
buildTanstackColumnDef(colDef, isRowActive, getRowKeyData),
),
[effectiveColumns, 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: handleColumnSizingChange,
onColumnVisibilityChange: noopColumnVisibility,
onExpandedChange: setExpanded,
state: {
columnSizing: effectiveSizing,
columnVisibility: effectiveVisibility,
columnPinning,
expanded,
},
});
// Keep refs to avoid recreating virtuosoComponents on every resize/render
const tableRef = useRef(table);
tableRef.current = table;
const columnsRef = useRef(effectiveColumns);
columnsRef.current = effectiveColumns;
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(() => effectiveColumns.map((c) => c.id), [
effectiveColumns,
]);
const handleDragEnd = useCallback(
(event: DragEndEvent): void => {
const { active, over } = event;
if (!over || active.id === over.id) {
return;
}
const activeCol = effectiveColumns.find((c) => c.id === String(active.id));
const overCol = effectiveColumns.find((c) => c.id === String(over.id));
if (
!activeCol ||
!overCol ||
activeCol.pin != null ||
overCol.pin != null ||
activeCol.enableMove === false
) {
return;
}
const oldIndex = effectiveColumns.findIndex(
(c) => c.id === String(active.id),
);
const newIndex = effectiveColumns.findIndex((c) => c.id === String(over.id));
if (oldIndex === -1 || newIndex === -1) {
return;
}
handleColumnOrderChange(arrayMove(effectiveColumns, oldIndex, newIndex));
},
[effectiveColumns, handleColumnOrderChange],
);
const hasSingleColumn = useMemo(
() =>
effectiveColumns.filter((c) => !c.pin && c.enableRemove !== false).length <=
1,
[effectiveColumns],
);
const canRemoveColumn = !hasSingleColumn;
const flatHeaders = useMemo(
() => table.getFlatHeaders().filter((header) => !header.isPlaceholder),
// eslint-disable-next-line react-hooks/exhaustive-deps
[tanstackColumns, columnPinning, effectiveVisibility],
);
const columnsById = useMemo(
() => new Map(effectiveColumns.map((c) => [c.id, c] as const)),
[effectiveColumns],
);
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
[effectiveVisibility, 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, index) => {
const column = columnsById.get(header.id);
if (!column) {
return null;
}
return (
<TanStackHeaderRow
key={header.id}
column={column}
header={header}
isDarkMode={isDarkMode}
hasSingleColumn={hasSingleColumn}
onRemoveColumn={handleRemoveColumn}
canRemoveColumn={canRemoveColumn}
orderBy={orderBy}
onSort={setOrderBy}
isLastColumn={index === flatHeaders.length - 1}
/>
);
})}
</tr>
</SortableContext>
</DndContext>
);
}, [
sensors,
handleDragEnd,
columnIds,
flatHeaders,
columnsById,
isDarkMode,
hasSingleColumn,
handleRemoveColumn,
canRemoveColumn,
orderBy,
setOrderBy,
]);
const handleEndReached = useCallback(
(index: number): void => {
onEndReached?.(index);
},
[onEndReached],
);
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={effectiveVisibility} />
<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;

View File

@@ -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;
}
}

View File

@@ -0,0 +1,72 @@
import { useMemo } from 'react';
import type { ColumnSizingState } from '@tanstack/react-table';
import { Skeleton } from 'antd';
import { TableColumnDef } from './types';
import { getColumnWidthStyle } 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, index) => (
<col
key={column.id}
style={getColumnWidthStyle(
column,
columnSizing?.[column.id],
index === columns.length - 1,
)}
/>
))}
</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>
);
}

View File

@@ -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;

View File

@@ -0,0 +1,27 @@
import type { ReactNode } from 'react';
import cx from 'classnames';
import tableStyles from './TanStackTable.module.scss';
export type TanStackTableTextProps = {
children?: ReactNode;
className?: string;
dangerouslySetInnerHTML?: { __html: string };
};
function TanStackTableText({
children,
className,
dangerouslySetInnerHTML,
}: TanStackTableTextProps): JSX.Element {
return (
<span
className={cx(tableStyles.tableCellText, className)}
dangerouslySetInnerHTML={dangerouslySetInnerHTML}
>
{children}
</span>
);
}
export default TanStackTableText;

View File

@@ -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);
}
}

View File

@@ -0,0 +1,35 @@
import type { Table } from '@tanstack/react-table';
import type { TableColumnDef } from './types';
import { getColumnWidthStyle } from './utils';
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, index) => {
const colDef = columnDefsById.get(tanstackCol.id);
if (!colDef) {
return <col key={tanstackCol.id} />;
}
const persistedWidth = columnSizing[tanstackCol.id];
const isLastColumn = index === visibleTanstackColumns.length - 1;
return (
<col
key={tanstackCol.id}
style={getColumnWidthStyle(colDef, persistedWidth, isLastColumn)}
/>
);
})}
</colgroup>
);
}

View File

@@ -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');
});
});
});

View File

@@ -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();
});
});
});

View File

@@ -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();
});
});
});

View File

@@ -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();
});
});
});

View File

@@ -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>,
);
}

View File

@@ -0,0 +1,247 @@
/* eslint-disable no-restricted-syntax */
import { act, renderHook } from '@testing-library/react';
import { TableColumnDef } from '../types';
import { useColumnState } from '../useColumnState';
import { useColumnStore } from '../useColumnStore';
const TEST_KEY = 'test-state';
type TestRow = { id: string; name: string };
const col = (
id: string,
overrides: Partial<TableColumnDef<TestRow>> = {},
): TableColumnDef<TestRow> => ({
id,
header: id,
cell: (): null => null,
...overrides,
});
describe('useColumnState', () => {
beforeEach(() => {
useColumnStore.setState({ tables: {} });
localStorage.clear();
});
describe('initialization', () => {
it('initializes store from column defaults on mount', () => {
const columns = [
col('a', { defaultVisibility: true }),
col('b', { defaultVisibility: false }),
col('c'),
];
renderHook(() => useColumnState({ storageKey: TEST_KEY, columns }));
const state = useColumnStore.getState().tables[TEST_KEY];
expect(state.hiddenColumnIds).toEqual(['b']);
});
it('does not initialize without storageKey', () => {
const columns = [col('a', { defaultVisibility: false })];
renderHook(() => useColumnState({ columns }));
expect(useColumnStore.getState().tables[TEST_KEY]).toBeUndefined();
});
});
describe('columnVisibility', () => {
it('returns visibility state from hidden columns', () => {
const columns = [col('a'), col('b'), col('c')];
act(() => {
useColumnStore.getState().initializeFromDefaults(TEST_KEY, columns);
useColumnStore.getState().hideColumn(TEST_KEY, 'b');
});
const { result } = renderHook(() =>
useColumnState({ storageKey: TEST_KEY, columns }),
);
expect(result.current.columnVisibility).toEqual({ b: false });
});
it('applies visibilityBehavior for grouped state', () => {
const columns = [
col('ungrouped', { visibilityBehavior: 'hidden-on-expand' }),
col('grouped', { visibilityBehavior: 'hidden-on-collapse' }),
col('always'),
];
act(() => {
useColumnStore.getState().initializeFromDefaults(TEST_KEY, columns);
});
// Not grouped
const { result: notGrouped } = renderHook(() =>
useColumnState({ storageKey: TEST_KEY, columns, isGrouped: false }),
);
expect(notGrouped.current.columnVisibility).toEqual({ grouped: false });
// Grouped
const { result: grouped } = renderHook(() =>
useColumnState({ storageKey: TEST_KEY, columns, isGrouped: true }),
);
expect(grouped.current.columnVisibility).toEqual({ ungrouped: false });
});
it('combines store hidden + visibilityBehavior', () => {
const columns = [
col('a', { visibilityBehavior: 'hidden-on-expand' }),
col('b'),
];
act(() => {
useColumnStore.getState().initializeFromDefaults(TEST_KEY, columns);
useColumnStore.getState().hideColumn(TEST_KEY, 'b');
});
const { result } = renderHook(() =>
useColumnState({ storageKey: TEST_KEY, columns, isGrouped: true }),
);
expect(result.current.columnVisibility).toEqual({ a: false, b: false });
});
});
describe('sortedColumns', () => {
it('returns columns in original order when no order set', () => {
const columns = [col('a'), col('b'), col('c')];
act(() => {
useColumnStore.getState().initializeFromDefaults(TEST_KEY, columns);
});
const { result } = renderHook(() =>
useColumnState({ storageKey: TEST_KEY, columns }),
);
expect(result.current.sortedColumns.map((c) => c.id)).toEqual([
'a',
'b',
'c',
]);
});
it('returns columns sorted by stored order', () => {
const columns = [col('a'), col('b'), col('c')];
act(() => {
useColumnStore.getState().initializeFromDefaults(TEST_KEY, columns);
useColumnStore.getState().setColumnOrder(TEST_KEY, ['c', 'a', 'b']);
});
const { result } = renderHook(() =>
useColumnState({ storageKey: TEST_KEY, columns }),
);
expect(result.current.sortedColumns.map((c) => c.id)).toEqual([
'c',
'a',
'b',
]);
});
it('keeps pinned columns at the start', () => {
const columns = [col('a'), col('pinned', { pin: 'left' }), col('b')];
act(() => {
useColumnStore.getState().initializeFromDefaults(TEST_KEY, columns);
useColumnStore.getState().setColumnOrder(TEST_KEY, ['b', 'a']);
});
const { result } = renderHook(() =>
useColumnState({ storageKey: TEST_KEY, columns }),
);
expect(result.current.sortedColumns.map((c) => c.id)).toEqual([
'pinned',
'b',
'a',
]);
});
});
describe('actions', () => {
it('hideColumn hides a column', () => {
const columns = [col('a'), col('b')];
act(() => {
useColumnStore.getState().initializeFromDefaults(TEST_KEY, columns);
});
const { result } = renderHook(() =>
useColumnState({ storageKey: TEST_KEY, columns }),
);
act(() => {
result.current.hideColumn('a');
});
expect(result.current.columnVisibility).toEqual({ a: false });
});
it('showColumn shows a column', () => {
const columns = [col('a', { defaultVisibility: false })];
act(() => {
useColumnStore.getState().initializeFromDefaults(TEST_KEY, columns);
});
const { result } = renderHook(() =>
useColumnState({ storageKey: TEST_KEY, columns }),
);
expect(result.current.columnVisibility).toEqual({ a: false });
act(() => {
result.current.showColumn('a');
});
expect(result.current.columnVisibility).toEqual({});
});
it('setColumnSizing updates sizing', () => {
const columns = [col('a')];
act(() => {
useColumnStore.getState().initializeFromDefaults(TEST_KEY, columns);
});
const { result } = renderHook(() =>
useColumnState({ storageKey: TEST_KEY, columns }),
);
act(() => {
result.current.setColumnSizing({ a: 200 });
});
expect(result.current.columnSizing).toEqual({ a: 200 });
});
it('setColumnOrder updates order from column array', () => {
const columns = [col('a'), col('b'), col('c')];
act(() => {
useColumnStore.getState().initializeFromDefaults(TEST_KEY, columns);
});
const { result } = renderHook(() =>
useColumnState({ storageKey: TEST_KEY, columns }),
);
act(() => {
result.current.setColumnOrder([col('c'), col('a'), col('b')]);
});
expect(result.current.sortedColumns.map((c) => c.id)).toEqual([
'c',
'a',
'b',
]);
});
});
});

View File

@@ -0,0 +1,296 @@
/* eslint-disable no-restricted-syntax */
import { act, renderHook } from '@testing-library/react';
import {
useColumnOrder,
useColumnSizing,
useColumnStore,
useHiddenColumnIds,
} from '../useColumnStore';
const TEST_KEY = 'test-table';
describe('useColumnStore', () => {
beforeEach(() => {
useColumnStore.getState().tables = {};
localStorage.clear();
});
describe('initializeFromDefaults', () => {
it('initializes hidden columns from defaultVisibility: false', () => {
const columns = [
{ id: 'a', defaultVisibility: true },
{ id: 'b', defaultVisibility: false },
{ id: 'c' }, // defaults to visible
];
act(() => {
useColumnStore.getState().initializeFromDefaults(TEST_KEY, columns as any);
});
const state = useColumnStore.getState().tables[TEST_KEY];
expect(state.hiddenColumnIds).toEqual(['b']);
expect(state.columnOrder).toEqual([]);
expect(state.columnSizing).toEqual({});
});
it('does not reinitialize if already exists', () => {
act(() => {
useColumnStore
.getState()
.initializeFromDefaults(TEST_KEY, [
{ id: 'a', defaultVisibility: false },
] as any);
useColumnStore.getState().hideColumn(TEST_KEY, 'x');
useColumnStore
.getState()
.initializeFromDefaults(TEST_KEY, [
{ id: 'b', defaultVisibility: false },
] as any);
});
const state = useColumnStore.getState().tables[TEST_KEY];
expect(state.hiddenColumnIds).toContain('a');
expect(state.hiddenColumnIds).toContain('x');
expect(state.hiddenColumnIds).not.toContain('b');
});
});
describe('hideColumn / showColumn / toggleColumn', () => {
beforeEach(() => {
act(() => {
useColumnStore.getState().initializeFromDefaults(TEST_KEY, []);
});
});
it('hideColumn adds to hiddenColumnIds', () => {
act(() => {
useColumnStore.getState().hideColumn(TEST_KEY, 'col1');
});
expect(useColumnStore.getState().tables[TEST_KEY].hiddenColumnIds).toContain(
'col1',
);
});
it('hideColumn is idempotent', () => {
act(() => {
useColumnStore.getState().hideColumn(TEST_KEY, 'col1');
useColumnStore.getState().hideColumn(TEST_KEY, 'col1');
});
expect(
useColumnStore
.getState()
.tables[TEST_KEY].hiddenColumnIds.filter((id) => id === 'col1'),
).toHaveLength(1);
});
it('showColumn removes from hiddenColumnIds', () => {
act(() => {
useColumnStore.getState().hideColumn(TEST_KEY, 'col1');
useColumnStore.getState().showColumn(TEST_KEY, 'col1');
});
expect(
useColumnStore.getState().tables[TEST_KEY].hiddenColumnIds,
).not.toContain('col1');
});
it('toggleColumn toggles visibility', () => {
act(() => {
useColumnStore.getState().toggleColumn(TEST_KEY, 'col1');
});
expect(useColumnStore.getState().tables[TEST_KEY].hiddenColumnIds).toContain(
'col1',
);
act(() => {
useColumnStore.getState().toggleColumn(TEST_KEY, 'col1');
});
expect(
useColumnStore.getState().tables[TEST_KEY].hiddenColumnIds,
).not.toContain('col1');
});
});
describe('setColumnSizing', () => {
beforeEach(() => {
act(() => {
useColumnStore.getState().initializeFromDefaults(TEST_KEY, []);
});
});
it('updates column sizing', () => {
act(() => {
useColumnStore
.getState()
.setColumnSizing(TEST_KEY, { col1: 200, col2: 300 });
});
expect(useColumnStore.getState().tables[TEST_KEY].columnSizing).toEqual({
col1: 200,
col2: 300,
});
});
});
describe('setColumnOrder', () => {
beforeEach(() => {
act(() => {
useColumnStore.getState().initializeFromDefaults(TEST_KEY, []);
});
});
it('updates column order', () => {
act(() => {
useColumnStore
.getState()
.setColumnOrder(TEST_KEY, ['col2', 'col1', 'col3']);
});
expect(useColumnStore.getState().tables[TEST_KEY].columnOrder).toEqual([
'col2',
'col1',
'col3',
]);
});
});
describe('resetToDefaults', () => {
it('resets to column defaults', () => {
const columns = [
{ id: 'a', defaultVisibility: false },
{ id: 'b', defaultVisibility: true },
];
act(() => {
useColumnStore.getState().initializeFromDefaults(TEST_KEY, columns as any);
useColumnStore.getState().showColumn(TEST_KEY, 'a');
useColumnStore.getState().hideColumn(TEST_KEY, 'b');
useColumnStore.getState().setColumnOrder(TEST_KEY, ['b', 'a']);
useColumnStore.getState().setColumnSizing(TEST_KEY, { a: 100 });
});
act(() => {
useColumnStore.getState().resetToDefaults(TEST_KEY, columns as any);
});
const state = useColumnStore.getState().tables[TEST_KEY];
expect(state.hiddenColumnIds).toEqual(['a']);
expect(state.columnOrder).toEqual([]);
expect(state.columnSizing).toEqual({});
});
});
describe('cleanupStaleHiddenColumns', () => {
it('removes hidden column IDs that are not in validColumnIds', () => {
act(() => {
useColumnStore.getState().initializeFromDefaults(TEST_KEY, []);
useColumnStore.getState().hideColumn(TEST_KEY, 'col1');
useColumnStore.getState().hideColumn(TEST_KEY, 'col2');
useColumnStore.getState().hideColumn(TEST_KEY, 'col3');
});
// Only col1 and col3 are valid now
act(() => {
useColumnStore
.getState()
.cleanupStaleHiddenColumns(TEST_KEY, new Set(['col1', 'col3']));
});
const state = useColumnStore.getState().tables[TEST_KEY];
expect(state.hiddenColumnIds).toEqual(['col1', 'col3']);
expect(state.hiddenColumnIds).not.toContain('col2');
});
it('does nothing when all hidden columns are valid', () => {
act(() => {
useColumnStore.getState().initializeFromDefaults(TEST_KEY, []);
useColumnStore.getState().hideColumn(TEST_KEY, 'col1');
useColumnStore.getState().hideColumn(TEST_KEY, 'col2');
});
const stateBefore = useColumnStore.getState().tables[TEST_KEY];
const hiddenBefore = [...stateBefore.hiddenColumnIds];
act(() => {
useColumnStore
.getState()
.cleanupStaleHiddenColumns(TEST_KEY, new Set(['col1', 'col2', 'col3']));
});
const stateAfter = useColumnStore.getState().tables[TEST_KEY];
expect(stateAfter.hiddenColumnIds).toEqual(hiddenBefore);
});
it('does nothing for unknown storage key', () => {
act(() => {
useColumnStore
.getState()
.cleanupStaleHiddenColumns('unknown-key', new Set(['col1']));
});
// Should not throw or create state
expect(useColumnStore.getState().tables['unknown-key']).toBeUndefined();
});
});
describe('selector hooks', () => {
it('useHiddenColumnIds returns hidden columns', () => {
act(() => {
useColumnStore
.getState()
.initializeFromDefaults(TEST_KEY, [
{ id: 'a', defaultVisibility: false },
] as any);
});
const { result } = renderHook(() => useHiddenColumnIds(TEST_KEY));
expect(result.current).toEqual(['a']);
});
it('useHiddenColumnIds returns a stable snapshot for persisted state', () => {
localStorage.setItem(
'@signoz/table-columns/test-table',
JSON.stringify({
hiddenColumnIds: ['persisted'],
columnOrder: [],
columnSizing: {},
}),
);
const { result, rerender } = renderHook(() => useHiddenColumnIds(TEST_KEY));
const firstSnapshot = result.current;
rerender();
expect(result.current).toBe(firstSnapshot);
});
it('useColumnSizing returns sizing', () => {
act(() => {
useColumnStore.getState().initializeFromDefaults(TEST_KEY, []);
useColumnStore.getState().setColumnSizing(TEST_KEY, { col1: 150 });
});
const { result } = renderHook(() => useColumnSizing(TEST_KEY));
expect(result.current).toEqual({ col1: 150 });
});
it('useColumnOrder returns order', () => {
act(() => {
useColumnStore.getState().initializeFromDefaults(TEST_KEY, []);
useColumnStore.getState().setColumnOrder(TEST_KEY, ['c', 'b', 'a']);
});
const { result } = renderHook(() => useColumnOrder(TEST_KEY));
expect(result.current).toEqual(['c', 'b', 'a']);
});
it('returns empty defaults for unknown storageKey', () => {
const { result: hidden } = renderHook(() => useHiddenColumnIds('unknown'));
const { result: sizing } = renderHook(() => useColumnSizing('unknown'));
const { result: order } = renderHook(() => useColumnOrder('unknown'));
expect(hidden.current).toEqual([]);
expect(sizing.current).toEqual({});
expect(order.current).toEqual([]);
});
});
});

View File

@@ -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();
});
});

View File

@@ -0,0 +1,179 @@
import { TanStackTableBase } from './TanStackTable';
import TanStackTableText from './TanStackTableText';
export * from './TanStackTableStateContext';
export * from './types';
export * from './useColumnState';
export * from './useColumnStore';
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 Column state persistence with store (recommended)
* ```tsx
* <TanStackTable
* data={data}
* columns={columns}
* columnStorageKey="my-table-columns"
* />
* ```
*
* @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;

View File

@@ -0,0 +1,196 @@
import {
CSSProperties,
Dispatch,
HTMLAttributes,
ReactNode,
SetStateAction,
} from 'react';
import type { TableVirtuosoHandle } from 'react-virtuoso';
import type {
ColumnSizingState,
Row as TanStackRowType,
} 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;
/** Default visibility when no persisted state exists. Default: true */
defaultVisibility?: boolean;
/** Whether user can hide this column. Default: true */
canBeHidden?: boolean;
/**
* Visibility behavior for grouped views:
* - 'hidden-on-expand': Hide when rows are expanded (grouped view)
* - 'hidden-on-collapse': Hide when rows are collapsed (ungrouped view)
* - 'always-visible': Always show regardless of grouping
* Default: 'always-visible'
*/
visibilityBehavior?:
| 'hidden-on-expand'
| 'hidden-on-collapse'
| 'always-visible';
width?: {
fixed?: number | string;
min?: number | string;
default?: number | string;
max?: number | string;
};
};
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>[];
/** Storage key for column state persistence (visibility, sizing, ordering). When set, enables unified column management. */
columnStorageKey?: string;
columnSizing?: ColumnSizingState;
onColumnSizingChange?: Dispatch<SetStateAction<ColumnSizingState>>;
onColumnOrderChange?: (cols: TableColumnDef<TData>[]) => void;
/** Called when a column is removed via the header menu. Use this to sync with external column preferences. */
onColumnRemove?: (columnId: 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;
};

View File

@@ -0,0 +1,231 @@
import { useCallback, useEffect, useMemo, useRef } from 'react';
import { ColumnSizingState, VisibilityState } from '@tanstack/react-table';
import { TableColumnDef } from './types';
import {
cleanupStaleHiddenColumns as storeCleanupStaleHiddenColumns,
hideColumn as storeHideColumn,
initializeFromDefaults as storeInitializeFromDefaults,
resetToDefaults as storeResetToDefaults,
setColumnOrder as storeSetColumnOrder,
setColumnSizing as storeSetColumnSizing,
showColumn as storeShowColumn,
toggleColumn as storeToggleColumn,
useColumnOrder as useStoreOrder,
useColumnSizing as useStoreSizing,
useHiddenColumnIds,
} from './useColumnStore';
type UseColumnStateOptions<TData> = {
storageKey?: string;
columns: TableColumnDef<TData>[];
isGrouped?: boolean;
};
type UseColumnStateResult<TData> = {
/** Computed visibility state for TanStack Table */
columnVisibility: VisibilityState;
/** Column sizing state */
columnSizing: ColumnSizingState;
/** Columns sorted by persisted order (pinned first) */
sortedColumns: TableColumnDef<TData>[];
/** List of currently hidden column IDs */
hiddenColumnIds: string[];
/** Hide a column (persisted) */
hideColumn: (columnId: string) => void;
/** Show a column (persisted) */
showColumn: (columnId: string) => void;
/** Toggle column visibility */
toggleColumn: (columnId: string) => void;
/** Update column sizing */
setColumnSizing: (sizing: ColumnSizingState) => void;
/** Update column order from reordered columns array */
setColumnOrder: (columns: TableColumnDef<TData>[]) => void;
/** Reset all to defaults from column definitions */
resetToDefaults: () => void;
};
export function useColumnState<TData>({
storageKey,
columns,
isGrouped = false,
}: UseColumnStateOptions<TData>): UseColumnStateResult<TData> {
useEffect(() => {
if (storageKey) {
storeInitializeFromDefaults(storageKey, columns);
}
// Only run on mount, not when columns change
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [storageKey]);
const rawHiddenColumnIds = useHiddenColumnIds(storageKey ?? '');
// Cleanup stale hidden column IDs that no longer exist in column definitions
useEffect(() => {
if (!storageKey) {
return;
}
const validColumnIds = new Set(columns.map((c) => c.id));
storeCleanupStaleHiddenColumns(storageKey, validColumnIds);
}, [storageKey, columns]);
const columnSizing = useStoreSizing(storageKey ?? '');
const prevColumnIdsRef = useRef<Set<string> | null>(null);
// Auto-show newly added columns (e.g., when user adds via Options Menu)
useEffect(() => {
if (!storageKey) {
return;
}
const currentIds = new Set(columns.map((c) => c.id));
// Skip first render - just record the initial columns
if (prevColumnIdsRef.current === null) {
prevColumnIdsRef.current = currentIds;
return;
}
const prevIds = prevColumnIdsRef.current;
// Find columns that are new (in current but not in previous)
for (const id of currentIds) {
if (!prevIds.has(id) && rawHiddenColumnIds.includes(id)) {
// Column was just added and is hidden - show it
storeShowColumn(storageKey, id);
}
}
prevColumnIdsRef.current = currentIds;
}, [storageKey, columns, rawHiddenColumnIds]);
const columnOrder = useStoreOrder(storageKey ?? '');
const columnMap = useMemo(() => new Map(columns.map((c) => [c.id, c])), [
columns,
]);
const hiddenColumnIds = useMemo(
() =>
rawHiddenColumnIds.filter((id) => {
const col = columnMap.get(id);
return col && col.canBeHidden !== false;
}),
[rawHiddenColumnIds, columnMap],
);
const columnVisibility = useMemo((): VisibilityState => {
const visibility: VisibilityState = {};
for (const id of hiddenColumnIds) {
visibility[id] = false;
}
for (const column of columns) {
if (column.visibilityBehavior === 'hidden-on-expand' && isGrouped) {
visibility[column.id] = false;
}
if (column.visibilityBehavior === 'hidden-on-collapse' && !isGrouped) {
visibility[column.id] = false;
}
}
return visibility;
}, [hiddenColumnIds, columns, isGrouped]);
const sortedColumns = useMemo((): TableColumnDef<TData>[] => {
if (columnOrder.length === 0) {
return columns;
}
const orderMap = new Map(columnOrder.map((id, i) => [id, i]));
const pinned = columns.filter((c) => c.pin != null);
const rest = columns.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];
}, [columns, columnOrder]);
const hideColumn = useCallback(
(columnId: string) => {
if (!storageKey) {
return;
}
// Prevent hiding columns with canBeHidden: false
const col = columnMap.get(columnId);
if (col && col.canBeHidden === false) {
return;
}
storeHideColumn(storageKey, columnId);
},
[storageKey, columnMap],
);
const showColumn = useCallback(
(columnId: string) => {
if (storageKey) {
storeShowColumn(storageKey, columnId);
}
},
[storageKey],
);
const toggleColumn = useCallback(
(columnId: string) => {
if (!storageKey) {
return;
}
const col = columnMap.get(columnId);
const isCurrentlyHidden = hiddenColumnIds.includes(columnId);
if (col && col.canBeHidden === false && !isCurrentlyHidden) {
return;
}
storeToggleColumn(storageKey, columnId);
},
[storageKey, columnMap, hiddenColumnIds],
);
const setColumnSizing = useCallback(
(sizing: ColumnSizingState) => {
if (storageKey) {
storeSetColumnSizing(storageKey, sizing);
}
},
[storageKey],
);
const setColumnOrder = useCallback(
(cols: TableColumnDef<TData>[]) => {
if (storageKey) {
storeSetColumnOrder(
storageKey,
cols.map((c) => c.id),
);
}
},
[storageKey],
);
const resetToDefaults = useCallback(() => {
if (storageKey) {
storeResetToDefaults(storageKey, columns);
}
}, [storageKey, columns]);
return {
columnVisibility,
columnSizing,
sortedColumns,
hiddenColumnIds,
hideColumn,
showColumn,
toggleColumn,
setColumnSizing,
setColumnOrder,
resetToDefaults,
};
}

View File

@@ -0,0 +1,328 @@
import { ColumnSizingState } from '@tanstack/react-table';
import { create } from 'zustand';
import { TableColumnDef } from './types';
const STORAGE_PREFIX = '@signoz/table-columns/';
const persistedTableCache = new Map<
string,
{ raw: string; parsed: ColumnState }
>();
type ColumnState = {
hiddenColumnIds: string[];
columnOrder: string[];
columnSizing: ColumnSizingState;
};
const EMPTY_STATE: ColumnState = {
hiddenColumnIds: [],
columnOrder: [],
columnSizing: {},
};
type ColumnStoreState = {
tables: Record<string, ColumnState>;
hideColumn: (storageKey: string, columnId: string) => void;
showColumn: (storageKey: string, columnId: string) => void;
toggleColumn: (storageKey: string, columnId: string) => void;
setColumnSizing: (storageKey: string, sizing: ColumnSizingState) => void;
setColumnOrder: (storageKey: string, order: string[]) => void;
initializeFromDefaults: <TData>(
storageKey: string,
columns: TableColumnDef<TData>[],
) => void;
resetToDefaults: <TData>(
storageKey: string,
columns: TableColumnDef<TData>[],
) => void;
cleanupStaleHiddenColumns: (
storageKey: string,
validColumnIds: Set<string>,
) => void;
};
const getDefaultHiddenIds = <TData>(
columns: TableColumnDef<TData>[],
): string[] =>
columns.filter((c) => c.defaultVisibility === false).map((c) => c.id);
const getStorageKeyForTable = (tableKey: string): string =>
`${STORAGE_PREFIX}${tableKey}`;
const loadTableFromStorage = (tableKey: string): ColumnState | null => {
try {
const raw = localStorage.getItem(getStorageKeyForTable(tableKey));
if (!raw) {
persistedTableCache.delete(tableKey);
return null;
}
const cached = persistedTableCache.get(tableKey);
if (cached && cached.raw === raw) {
return cached.parsed;
}
const parsed = JSON.parse(raw) as ColumnState;
persistedTableCache.set(tableKey, { raw, parsed });
return parsed;
} catch {
persistedTableCache.delete(tableKey);
return null;
}
};
const saveTableToStorage = (tableKey: string, state: ColumnState): void => {
try {
const raw = JSON.stringify(state);
localStorage.setItem(getStorageKeyForTable(tableKey), raw);
persistedTableCache.set(tableKey, { raw, parsed: state });
} catch {
// Ignore storage errors (e.g., private browsing quota exceeded)
}
};
export const useColumnStore = create<ColumnStoreState>()((set, get) => {
return {
tables: {},
hideColumn: (storageKey, columnId): void => {
const state = get();
let table = state.tables[storageKey];
// Lazy load from storage if not in memory
if (!table) {
const persisted = loadTableFromStorage(storageKey);
if (persisted) {
table = persisted;
set({ tables: { ...state.tables, [storageKey]: table } });
} else {
return;
}
}
if (table.hiddenColumnIds.includes(columnId)) {
return;
}
const nextTable = {
...table,
hiddenColumnIds: [...table.hiddenColumnIds, columnId],
};
set({ tables: { ...get().tables, [storageKey]: nextTable } });
saveTableToStorage(storageKey, nextTable);
},
showColumn: (storageKey, columnId): void => {
const state = get();
let table = state.tables[storageKey];
if (!table) {
const persisted = loadTableFromStorage(storageKey);
if (persisted) {
table = persisted;
set({ tables: { ...state.tables, [storageKey]: table } });
} else {
return;
}
}
if (!table.hiddenColumnIds.includes(columnId)) {
return;
}
const nextTable = {
...table,
hiddenColumnIds: table.hiddenColumnIds.filter((id) => id !== columnId),
};
set({ tables: { ...get().tables, [storageKey]: nextTable } });
saveTableToStorage(storageKey, nextTable);
},
toggleColumn: (storageKey, columnId): void => {
const state = get();
let table = state.tables[storageKey];
if (!table) {
const persisted = loadTableFromStorage(storageKey);
if (persisted) {
table = persisted;
set({ tables: { ...state.tables, [storageKey]: table } });
}
}
if (!table) {
return;
}
const isHidden = table.hiddenColumnIds.includes(columnId);
if (isHidden) {
get().showColumn(storageKey, columnId);
} else {
get().hideColumn(storageKey, columnId);
}
},
setColumnSizing: (storageKey, sizing): void => {
const state = get();
let table = state.tables[storageKey];
if (!table) {
const persisted = loadTableFromStorage(storageKey);
table = persisted ?? { ...EMPTY_STATE };
}
const nextTable = {
...table,
columnSizing: sizing,
};
set({ tables: { ...get().tables, [storageKey]: nextTable } });
saveTableToStorage(storageKey, nextTable);
},
setColumnOrder: (storageKey, order): void => {
const state = get();
let table = state.tables[storageKey];
if (!table) {
const persisted = loadTableFromStorage(storageKey);
table = persisted ?? { ...EMPTY_STATE };
}
const nextTable = {
...table,
columnOrder: order,
};
set({ tables: { ...get().tables, [storageKey]: nextTable } });
saveTableToStorage(storageKey, nextTable);
},
initializeFromDefaults: (storageKey, columns): void => {
const state = get();
if (state.tables[storageKey]) {
return;
}
const persisted = loadTableFromStorage(storageKey);
if (persisted) {
set({ tables: { ...state.tables, [storageKey]: persisted } });
return;
}
const newTable: ColumnState = {
hiddenColumnIds: getDefaultHiddenIds(columns),
columnOrder: [],
columnSizing: {},
};
set({ tables: { ...state.tables, [storageKey]: newTable } });
saveTableToStorage(storageKey, newTable);
},
resetToDefaults: (storageKey, columns): void => {
const newTable: ColumnState = {
hiddenColumnIds: getDefaultHiddenIds(columns),
columnOrder: [],
columnSizing: {},
};
set({ tables: { ...get().tables, [storageKey]: newTable } });
saveTableToStorage(storageKey, newTable);
},
cleanupStaleHiddenColumns: (storageKey, validColumnIds): void => {
const state = get();
let table = state.tables[storageKey];
if (!table) {
const persisted = loadTableFromStorage(storageKey);
if (!persisted) {
return;
}
table = persisted;
}
const filteredHiddenIds = table.hiddenColumnIds.filter((id) =>
validColumnIds.has(id),
);
// Only update if something changed
if (filteredHiddenIds.length === table.hiddenColumnIds.length) {
return;
}
const nextTable = {
...table,
hiddenColumnIds: filteredHiddenIds,
};
set({ tables: { ...get().tables, [storageKey]: nextTable } });
saveTableToStorage(storageKey, nextTable);
},
};
});
// Stable empty references to avoid `Object.is` false-negatives when a key
// does not exist yet (returning a new `[]` / `{}` on every selector call
// would trigger React's useSyncExternalStore tearing detection).
const EMPTY_ARRAY: string[] = [];
const EMPTY_SIZING: ColumnSizingState = {};
export const useHiddenColumnIds = (storageKey: string): string[] =>
useColumnStore((s) => {
const table = s.tables[storageKey];
if (table) {
return table.hiddenColumnIds;
}
const persisted = loadTableFromStorage(storageKey);
return persisted?.hiddenColumnIds ?? EMPTY_ARRAY;
});
export const useColumnSizing = (storageKey: string): ColumnSizingState =>
useColumnStore((s) => {
const table = s.tables[storageKey];
if (table) {
return table.columnSizing;
}
const persisted = loadTableFromStorage(storageKey);
return persisted?.columnSizing ?? EMPTY_SIZING;
});
export const useColumnOrder = (storageKey: string): string[] =>
useColumnStore((s) => {
const table = s.tables[storageKey];
if (table) {
return table.columnOrder;
}
const persisted = loadTableFromStorage(storageKey);
return persisted?.columnOrder ?? EMPTY_ARRAY;
});
export const initializeFromDefaults = <TData>(
storageKey: string,
columns: TableColumnDef<TData>[],
): void =>
useColumnStore.getState().initializeFromDefaults(storageKey, columns);
export const hideColumn = (storageKey: string, columnId: string): void =>
useColumnStore.getState().hideColumn(storageKey, columnId);
export const showColumn = (storageKey: string, columnId: string): void =>
useColumnStore.getState().showColumn(storageKey, columnId);
export const toggleColumn = (storageKey: string, columnId: string): void =>
useColumnStore.getState().toggleColumn(storageKey, columnId);
export const setColumnSizing = (
storageKey: string,
sizing: ColumnSizingState,
): void => useColumnStore.getState().setColumnSizing(storageKey, sizing);
export const setColumnOrder = (storageKey: string, order: string[]): void =>
useColumnStore.getState().setColumnOrder(storageKey, order);
export const resetToDefaults = <TData>(
storageKey: string,
columns: TableColumnDef<TData>[],
): void => useColumnStore.getState().resetToDefaults(storageKey, columns);
export const cleanupStaleHiddenColumns = (
storageKey: string,
validColumnIds: Set<string>,
): void =>
useColumnStore
.getState()
.cleanupStaleHiddenColumns(storageKey, validColumnIds);

View 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,
};
}

View File

@@ -0,0 +1,98 @@
import type { CSSProperties, 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 DEFAULT_MIN_WIDTH = 192; // 12rem * 16px
/** Get CSS style properties for column width */
export const getColumnWidthStyle = <TData>(
column: TableColumnDef<TData>,
/** Persisted width from user resizing (overrides defined width) */
persistedWidth?: number,
/** Last column always gets width: 100% and ignores other width settings */
isLastColumn?: boolean,
): CSSProperties => {
// Last column always fills remaining space
if (isLastColumn) {
return { width: '100%' };
}
const { width } = column;
if (!width) {
return {
width: persistedWidth ?? DEFAULT_MIN_WIDTH,
minWidth: DEFAULT_MIN_WIDTH,
};
}
// Fixed: all three properties set to the same value (ignore persisted)
if (width.fixed != null) {
return {
width: width.fixed,
minWidth: width.fixed,
maxWidth: width.fixed,
};
}
// Use persisted width if available, otherwise use defined width, fallback to min
return {
width: persistedWidth ?? width.default ?? width.min,
minWidth: width.min ?? DEFAULT_MIN_WIDTH,
maxWidth: width.max,
};
};
/** Helper to build accessor function */
const buildAccessorFn = <TData>(
colDef: TableColumnDef<TData>,
): ((row: TData) => unknown) => {
return (row: TData): unknown => {
if (colDef.accessorFn) {
return colDef.accessorFn(row);
}
if (colDef.accessorKey) {
return (row as Record<string, unknown>)[colDef.accessorKey];
}
return undefined;
};
};
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 headerFn =
typeof colDef.header === 'function' ? colDef.header : undefined;
return {
id: colDef.id,
header:
typeof colDef.header === 'string'
? colDef.header
: (): ReactNode => headerFn?.() ?? null,
accessorFn: buildAccessorFn(colDef),
enableResizing: colDef.enableResize !== false && !isFixed,
enableSorting: colDef.enableSort === true,
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,
});
},
};
}

View File

@@ -1,22 +1,36 @@
import type { CSSProperties, MouseEvent, ReactNode } 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 { toast } from '@signozhq/sonner';
import { Card, Typography } from 'antd';
import LogDetail from 'components/LogDetail';
import { VIEW_TYPES } from 'components/LogDetail/constants';
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 { useLogsTableColumns } from 'components/Logs/TableView/useLogsTableColumns';
import OverlayScrollbar from 'components/OverlayScrollbar/OverlayScrollbar';
import type { TanStackTableHandle } from 'components/TanStackTableView';
import TanStackTable from 'components/TanStackTableView';
import { useHiddenColumnIds } from 'components/TanStackTableView/useColumnStore';
import { CARD_BODY_STYLE } from 'constants/card';
import { LOCALSTORAGE } from 'constants/localStorage';
import { OptionFormatTypes } from 'constants/optionsFormatTypes';
import { QueryParams } from 'constants/query';
import { InfinityWrapperStyled } from 'container/LogsExplorerList/styles';
import TanStackTableView from 'container/LogsExplorerList/TanStackTableView';
import { convertKeysToColumnFields } from 'container/LogsExplorerList/utils';
import { useOptionsMenu } from 'container/OptionsMenu';
import { defaultLogsSelectedColumns } from 'container/OptionsMenu/constants';
import { useCopyLogLink } from 'hooks/logs/useCopyLogLink';
import useLogDetailHandlers from 'hooks/logs/useLogDetailHandlers';
import useScrollToLog from 'hooks/logs/useScrollToLog';
import { useIsDarkMode } from 'hooks/useDarkMode';
import { useEventSource } from 'providers/EventSource';
import { usePreferenceContext } from 'providers/preferences/context/PreferenceContextProvider';
// interfaces
import { ILog } from 'types/api/logs/log';
import { DataSource, StringOperators } from 'types/common/queryBuilder';
@@ -30,11 +44,17 @@ function LiveLogsList({
isLoading,
handleChangeSelectedView,
}: 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 { activeLogId } = useCopyLogLink();
const { logs: logsPreferences } = usePreferenceContext();
const hiddenColumnIds = useHiddenColumnIds(LOCALSTORAGE.LOGS_LIST_COLUMNS);
const hasReconciledHiddenColumnsRef = useRef(false);
const {
activeLog,
@@ -50,7 +70,7 @@ function LiveLogsList({
[logs],
);
const { options, config } = useOptionsMenu({
const { options } = useOptionsMenu({
storageKey: LOCALSTORAGE.LOGS_LIST_OPTIONS,
dataSource: DataSource.LOGS,
aggregateOperator: StringOperators.NOOP,
@@ -66,9 +86,64 @@ function LiveLogsList({
...options.selectColumns,
]);
const syncedSelectedColumns = useMemo(
() =>
options.selectColumns.filter(({ name }) => !hiddenColumnIds.includes(name)),
[options.selectColumns, hiddenColumnIds],
);
const logsColumns = useLogsTableColumns({
fields: selectedFields,
linesPerRow: options.maxLines,
fontSize: options.fontSize,
appendTo: 'end',
});
useEffect(() => {
if (hasReconciledHiddenColumnsRef.current) {
return;
}
hasReconciledHiddenColumnsRef.current = true;
if (syncedSelectedColumns.length === options.selectColumns.length) {
return;
}
logsPreferences.updateColumns(syncedSelectedColumns);
}, [logsPreferences, options.selectColumns.length, syncedSelectedColumns]);
const handleColumnRemove = useCallback(
(columnId: string) => {
const updatedColumns = options.selectColumns.filter(
({ name }) => name !== columnId,
);
logsPreferences.updateColumns(updatedColumns);
},
[options.selectColumns, logsPreferences],
);
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({
logs: formattedLogs,
virtuosoRef: ref,
virtuosoRef: ref as React.RefObject<Pick<
VirtuosoHandle,
'scrollToIndex'
> | null>,
});
const getItemContent = useCallback(
@@ -158,29 +233,49 @@ function LiveLogsList({
{formattedLogs.length !== 0 && (
<InfinityWrapperStyled>
{options.format === OptionFormatTypes.TABLE ? (
<TanStackTableView
ref={ref}
<TanStackTable<ILog>
ref={ref as React.Ref<TanStackTableHandle>}
columns={logsColumns}
columnStorageKey={LOCALSTORAGE.LOGS_LIST_COLUMNS}
onColumnRemove={handleColumnRemove}
plainTextCellLineClamp={options.maxLines}
cellTypographySize={options.fontSize}
data={formattedLogs}
isLoading={false}
tableViewProps={{
logs: formattedLogs,
fields: selectedFields,
linesPerRow: options.maxLines,
fontSize: options.fontSize,
appendTo: 'end',
activeLogIndex,
isRowActive={(log): boolean => log.id === activeLog?.id}
getRowStyle={(log): CSSProperties =>
({
'--row-active-bg': getRowBackgroundColor(
isDarkMode,
getLogIndicatorType(log),
),
'--row-hover-bg': getRowBackgroundColor(
isDarkMode,
getLogIndicatorType(log),
),
} as CSSProperties)
}
onRowClick={(log): void => {
handleSetActiveLog(log);
}}
handleChangeSelectedView={handleChangeSelectedView}
logs={formattedLogs}
onSetActiveLog={handleSetActiveLog}
onClearActiveLog={handleCloseLogDetail}
activeLog={activeLog}
onRemoveColumn={config.addColumn?.onRemove}
onRowDeactivate={handleCloseLogDetail}
activeRowIndex={activeLogIndex}
renderRowActions={(log): ReactNode => (
<LogLinesActionButtons
handleShowContext={(e): void => {
e.preventDefault();
e.stopPropagation();
handleSetActiveLog(log, VIEW_TYPES.CONTEXT);
}}
onLogCopy={makeOnLogCopy(log)}
/>
)}
/>
) : (
<Card style={{ width: '100%' }} bodyStyle={CARD_BODY_STYLE}>
<OverlayScrollbar isVirtuoso>
<Virtuoso
ref={ref}
ref={ref as React.Ref<VirtuosoHandle>}
initialTopMostItemIndex={activeLogIndex !== -1 ? activeLogIndex : 0}
data={formattedLogs}
totalCount={formattedLogs.length}

View File

@@ -221,7 +221,7 @@ function ColumnView({
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);
setShowActiveLog(true);

View File

@@ -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;

View File

@@ -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;
});

View File

@@ -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;

View File

@@ -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;

View File

@@ -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();
});
});

View File

@@ -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();
});
});

View File

@@ -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();
});
});

View File

@@ -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();
});
});

View File

@@ -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();
});
});

View File

@@ -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',
]);
});
});

View File

@@ -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);

View File

@@ -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;
}

View File

@@ -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;
};

View File

@@ -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 };
};

View File

@@ -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,
};
};

View File

@@ -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;
};

View File

@@ -1,24 +1,39 @@
import type { CSSProperties, MouseEvent, ReactNode } 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 { toast } from '@signozhq/sonner';
import { Card } from 'antd';
import logEvent from 'api/common/logEvent';
import ErrorInPlace from 'components/ErrorInPlace/ErrorInPlace';
import LogDetail from 'components/LogDetail';
// components
import { VIEW_TYPES } from 'components/LogDetail/constants';
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 { useLogsTableColumns } from 'components/Logs/TableView/useLogsTableColumns';
import OverlayScrollbar from 'components/OverlayScrollbar/OverlayScrollbar';
import Spinner from 'components/Spinner';
import type { TanStackTableHandle } from 'components/TanStackTableView';
import TanStackTable from 'components/TanStackTableView';
import { useHiddenColumnIds } from 'components/TanStackTableView/useColumnStore';
import { CARD_BODY_STYLE } from 'constants/card';
import { LOCALSTORAGE } from 'constants/localStorage';
import { QueryParams } from 'constants/query';
import EmptyLogsSearch from 'container/EmptyLogsSearch/EmptyLogsSearch';
import { LogsLoading } from 'container/LogsLoading/LogsLoading';
import { useOptionsMenu } from 'container/OptionsMenu';
import { defaultLogsSelectedColumns } from 'container/OptionsMenu/constants';
import { FontSize } from 'container/OptionsMenu/types';
import { useCopyLogLink } from 'hooks/logs/useCopyLogLink';
import useLogDetailHandlers from 'hooks/logs/useLogDetailHandlers';
import useScrollToLog from 'hooks/logs/useScrollToLog';
import { useQueryBuilder } from 'hooks/queryBuilder/useQueryBuilder';
import { useIsDarkMode } from 'hooks/useDarkMode';
import { usePreferenceContext } from 'providers/preferences/context/PreferenceContextProvider';
import APIError from 'types/api/error';
// interfaces
import { ILog } from 'types/api/logs/log';
@@ -27,7 +42,6 @@ import { DataSource, StringOperators } from 'types/common/queryBuilder';
import NoLogs from '../NoLogs/NoLogs';
import { LogsExplorerListProps } from './LogsExplorerList.interfaces';
import { InfinityWrapperStyled } from './styles';
import TanStackTableView from './TanStackTableView';
import {
convertKeysToColumnFields,
getEmptyLogsListConfig,
@@ -50,8 +64,14 @@ function LogsExplorerList({
isFilterApplied,
handleChangeSelectedView,
}: 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 { logs: logsPreferences } = usePreferenceContext();
const hiddenColumnIds = useHiddenColumnIds(LOCALSTORAGE.LOGS_LIST_COLUMNS);
const hasReconciledHiddenColumnsRef = useRef(false);
const {
activeLog,
@@ -61,7 +81,7 @@ function LogsExplorerList({
handleCloseLogDetail,
} = useLogDetailHandlers();
const { options, config } = useOptionsMenu({
const { options } = useOptionsMenu({
storageKey: LOCALSTORAGE.LOGS_LIST_OPTIONS,
dataSource: DataSource.LOGS,
aggregateOperator:
@@ -80,13 +100,58 @@ function LogsExplorerList({
);
const selectedFields = useMemo(
() => convertKeysToColumnFields(options.selectColumns),
() =>
convertKeysToColumnFields([
...defaultLogsSelectedColumns,
...options.selectColumns,
]),
[options],
);
const syncedSelectedColumns = useMemo(
() =>
options.selectColumns.filter(({ name }) => !hiddenColumnIds.includes(name)),
[options.selectColumns, hiddenColumnIds],
);
const handleColumnRemove = useCallback(
(columnId: string) => {
const updatedColumns = options.selectColumns.filter(
({ name }) => name !== columnId,
);
logsPreferences.updateColumns(updatedColumns);
},
[options.selectColumns, logsPreferences],
);
const logsColumns = useLogsTableColumns({
fields: selectedFields,
linesPerRow: options.maxLines,
fontSize: options.fontSize,
appendTo: 'end',
});
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({
logs,
virtuosoRef: ref,
virtuosoRef: ref as React.RefObject<Pick<
VirtuosoHandle,
'scrollToIndex'
> | null>,
});
useEffect(() => {
@@ -97,6 +162,20 @@ function LogsExplorerList({
}
}, [isLoading, isFetching, isError, logs.length]);
useEffect(() => {
if (hasReconciledHiddenColumnsRef.current) {
return;
}
hasReconciledHiddenColumnsRef.current = true;
if (syncedSelectedColumns.length === options.selectColumns.length) {
return;
}
logsPreferences.updateColumns(syncedSelectedColumns);
}, [logsPreferences, options.selectColumns.length, syncedSelectedColumns]);
const getItemContent = useCallback(
(_: number, log: ILog): JSX.Element => {
if (options.format === 'raw') {
@@ -155,25 +234,46 @@ function LogsExplorerList({
if (options.format === 'table') {
return (
<TanStackTableView
ref={ref}
isLoading={isLoading}
isFetching={isFetching}
tableViewProps={{
logs,
fields: selectedFields,
linesPerRow: options.maxLines,
fontSize: options.fontSize,
appendTo: 'end',
activeLogIndex,
<TanStackTable<ILog>
ref={ref as React.Ref<TanStackTableHandle>}
columns={logsColumns}
columnStorageKey={LOCALSTORAGE.LOGS_LIST_COLUMNS}
onColumnRemove={handleColumnRemove}
plainTextCellLineClamp={options.maxLines}
cellTypographySize={options.fontSize}
data={logs}
isLoading={isLoading || isFetching}
onEndReached={onEndReached}
isRowActive={(log): boolean =>
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 }}
handleChangeSelectedView={handleChangeSelectedView}
logs={logs}
onSetActiveLog={handleSetActiveLog}
onClearActiveLog={handleCloseLogDetail}
activeLog={activeLog}
onRemoveColumn={config.addColumn?.onRemove}
onRowDeactivate={handleCloseLogDetail}
activeRowIndex={activeLogIndex}
renderRowActions={(log): ReactNode => (
<LogLinesActionButtons
handleShowContext={(e): void => {
e.preventDefault();
e.stopPropagation();
handleSetActiveLog(log, VIEW_TYPES.CONTEXT);
}}
onLogCopy={makeOnLogCopy(log)}
/>
)}
/>
);
}
@@ -198,7 +298,7 @@ function LogsExplorerList({
<OverlayScrollbar isVirtuoso>
<Virtuoso
key={activeLogIndex || 'logs-virtuoso'}
ref={ref}
ref={ref as React.Ref<VirtuosoHandle>}
initialTopMostItemIndex={activeLogIndex !== -1 ? activeLogIndex : 0}
data={logs}
endReached={onEndReached}
@@ -219,12 +319,11 @@ function LogsExplorerList({
onEndReached,
getItemContent,
isFetching,
selectedFields,
handleChangeSelectedView,
handleSetActiveLog,
handleCloseLogDetail,
activeLog,
config.addColumn?.onRemove,
isDarkMode,
makeOnLogCopy,
]);
const isTraceToLogsNavigation = useMemo(() => {

View File

@@ -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);
}
}

View File

@@ -1,9 +1,11 @@
import { useCallback } from 'react';
import type { VirtuosoHandle } from 'react-virtuoso';
type ScrollToIndexHandle = Pick<VirtuosoHandle, 'scrollToIndex'>;
type UseScrollToLogParams = {
logs: Array<{ id: string }>;
virtuosoRef: React.RefObject<VirtuosoHandle | null>;
virtuosoRef: React.RefObject<ScrollToIndexHandle | null>;
};
function useScrollToLog({