Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 7 additions & 4 deletions table/src/components/TablePanel.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -37,12 +37,15 @@ import {
import { TablePanel } from './TablePanel';

/* mock all variables */
const MOCK_VARIABLE_STATE_MAP: VariableStateMap = {
myproject: { loading: false, value: 'my_project' },
__range: { loading: false, value: '1h' },
};
jest.mock('@perses-dev/plugin-system', () => ({
...jest.requireActual('@perses-dev/plugin-system'),
useAllVariableValues: (): VariableStateMap => ({
myproject: { loading: false, value: 'my_project' },
__range: { loading: false, value: '1h' },
}),
// Return a stable reference (like the real hook, which memoizes) so consumers
// that depend on it in a useMemo/useEffect dependency array don't recompute/rerun on every render.
useAllVariableValues: (): VariableStateMap => MOCK_VARIABLE_STATE_MAP,
}));

const TEST_TIMEOUT = 15000; // Github Actions is slow
Expand Down
260 changes: 182 additions & 78 deletions table/src/components/TablePanel.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,7 @@ import {
import { ColumnFiltersState, PaginationState, RowSelectionState, SortingState } from '@tanstack/react-table';
import { ReactElement, useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { QueryDataType, TimeSeriesData } from '@perses-dev/spec';
import { createPortal } from 'react-dom';
import { CellSettings, ColumnSettings, evaluateConditionalFormatting, TableOptions } from '../models';
import { buildRawTableData, getTablePanelQueryMode } from '../table-data-utils';
import { EmbeddedPanel } from './EmbeddedPanel';
Expand Down Expand Up @@ -220,7 +221,7 @@ function ColumnFilterDropdown({
const [searchTerm, setSearchTerm] = useState('');
const values = [...new Set(allValues)].filter((v) => v !== null).sort();
const filteredValues = searchTerm
? values.filter((v) => String(v).toLowerCase().includes(searchTerm.toLowerCase()))
? values.filter((v) => String(v.formatted).toLowerCase().includes(searchTerm.toLowerCase()))
: values;
if (values.length === 0) {
return (
Expand Down Expand Up @@ -401,6 +402,10 @@ export function TablePanel({ contentDimensions, spec, queryResults }: TableProps
});

const filteredDataRef = useRef<Array<Record<string, unknown>>>([]);
// Refs used to keep the filter row in sync with the table's horizontal
const panelContainerRef = useRef<HTMLDivElement>(null);
const filterRowInnerRef = useRef<HTMLDivElement>(null);
const filterCellRefs = useRef<Array<HTMLDivElement | null>>([]);

// Convert selectionMap to TanStack's RowSelectionState format
const rowSelection = useMemo((): RowSelectionState => {
Expand Down Expand Up @@ -682,6 +687,12 @@ export function TablePanel({ contentDimensions, spec, queryResults }: TableProps
const handleFilterClick = (event: React.MouseEvent<HTMLButtonElement>, columnId: string): void => {
event.preventDefault();
event.stopPropagation();

if (openFilterColumn === columnId) {
handleFilterClose();
return;
}

setFilterAnchorEl({ ...filterAnchorEl, [columnId]: event.currentTarget });
setOpenFilterColumn(columnId);
};
Expand Down Expand Up @@ -728,6 +739,80 @@ export function TablePanel({ contentDimensions, spec, queryResults }: TableProps
}
}, [spec.pagination, pagination]);

// Sync the filter row's horizontal position with the table scroll.
useEffect(() => {
if (!spec.enableFiltering) {
return;
}

const scrollContainer = panelContainerRef.current?.querySelector<HTMLElement>('.MuiTableContainer-root');
const filterRowInner = filterRowInnerRef.current;

if (!scrollContainer || !filterRowInner) {
return;
}

const syncFilterRowScroll = (): void => {
filterRowInner.style.transform = `translateX(-${scrollContainer.scrollLeft}px)`;

setOpenFilterColumn((current) => (current === null ? current : null));
Comment thread
shahrokni marked this conversation as resolved.
setFilterAnchorEl((current) => (Object.keys(current).length === 0 ? current : {}));
};

syncFilterRowScroll();

scrollContainer.addEventListener('scroll', syncFilterRowScroll, { passive: true });
return (): void => {
scrollContainer.removeEventListener('scroll', syncFilterRowScroll);
};
}, [spec.enableFiltering, columns, contentDimensions]);

// Sync filter cell widths with the actual rendered table column widths to keep them aligned.
useEffect(() => {
if (!spec.enableFiltering) {
return;
}

const scrollContainer = panelContainerRef.current?.querySelector<HTMLElement>('.MuiTableContainer-root');
if (!scrollContainer) {
return;
}

const headerRow = scrollContainer.querySelector('thead tr');
if (!headerRow) {
return;
}

const syncColumnWidths = (): void => {
const headerCells = scrollContainer.querySelectorAll<HTMLElement>('thead tr th');

columns.forEach((_, idx) => {
const headerCell = headerCells[idx];
const filterCell = filterCellRefs.current[idx];
if (!headerCell || !filterCell) {
return;
}

const width = `${headerCell.getBoundingClientRect().width}px`;
filterCell.style.width = width;
filterCell.style.minWidth = width;
filterCell.style.maxWidth = width;
});
};

syncColumnWidths();

// Re-sync whenever the header row's size changes
const resizeObserver = new ResizeObserver(syncColumnWidths);
if (headerRow) {
resizeObserver.observe(headerRow);
}

return (): void => {
resizeObserver.disconnect();
};
}, [spec.enableFiltering, columns, contentDimensions, selectionEnabled, actionButtons]);

if (contentDimensions === undefined) {
return null;
}
Expand All @@ -748,99 +833,118 @@ export function TablePanel({ contentDimensions, spec, queryResults }: TableProps
}

return (
<>
<div ref={panelContainerRef} style={{ display: 'contents' }}>
{confirmDialog}
{spec.enableFiltering && (
<div
style={{
display: 'flex',
overflow: 'hidden',
background: theme.palette.background.default,
borderBottom: `1px solid ${theme.palette.divider}`,
width: contentDimensions.width,
boxSizing: 'border-box',
}}
>
{columns.map((column, idx) => {
const filters = getSelectedFilterValues(column.accessorKey as string);
const columnWidth = column.width || spec.defaultColumnWidth;
return (
<div
key={`filter-${idx}`}
style={{
padding: '8px',
borderRight: idx < columns.length - 1 ? `1px solid ${theme.palette.divider}` : 'none',
width: columnWidth,
minWidth: columnWidth,
maxWidth: columnWidth,
display: 'flex',
alignItems: 'center',
position: 'relative',
boxSizing: 'border-box',
flex: typeof columnWidth === 'number' ? 'none' : '1 1 auto',
}}
>
<span
style={{
marginRight: 8,
fontSize: '12px',
color: theme.palette.text.secondary,
flex: 1,
overflow: 'hidden',
textOverflow: 'ellipsis',
whiteSpace: 'nowrap',
}}
>
{filters.length ? `${filters.length} items` : 'All'}
</span>
<button
onClick={(e) => {
handleFilterClick(e, column.accessorKey as string);
<div
ref={filterRowInnerRef}
style={{
display: 'flex',
width: 'max-content',
willChange: 'transform',
}}
>
{columns.map((column, idx) => {
const filters = getSelectedFilterValues(column.accessorKey as string);
const columnWidth = column.width || spec.defaultColumnWidth;
const anchorEl = filterAnchorEl[column.accessorKey as string];

return (
<div
key={`column-${column.accessorKey}`}
ref={(el) => {
filterCellRefs.current[idx] = el;
}}
style={{
border: `1px solid ${theme.palette.divider}`,
background: theme.palette.background.paper,
cursor: 'pointer',
fontSize: '12px',
color: filters.length ? theme.palette.primary.main : theme.palette.text.secondary,
padding: '4px 8px',
borderRadius: '4px',
minWidth: '20px',
height: '24px',
flexShrink: 0,
transition: 'all 0.2s ease',
}}
onMouseEnter={(e) => {
e.currentTarget.style.background = theme.palette.action.hover;
padding: '8px',
borderRight: idx < columns.length - 1 ? `1px solid ${theme.palette.divider}` : 'none',
// These are just a starting size for the first paint —
// the width-sync effect above overwrites them with the
// table's actual rendered column widths.
width: columnWidth,
minWidth: columnWidth,
maxWidth: columnWidth,
display: 'flex',
alignItems: 'center',
position: 'relative',
boxSizing: 'border-box',
flex: 'none',
}}
onMouseLeave={(e) => {
e.currentTarget.style.background = theme.palette.background.paper;
}}
type="button"
>
</button>

{openFilterColumn === column.accessorKey && (
<div
<span
style={{
position: 'absolute',
top: '100%',
left: 0,
zIndex: 1000,
marginTop: 4,
marginRight: 8,
fontSize: '12px',
color: theme.palette.text.secondary,
flex: 1,
overflow: 'hidden',
textOverflow: 'ellipsis',
whiteSpace: 'nowrap',
}}
>
<ColumnFilterDropdown
allValues={columnUniqueValues[column.accessorKey as string] || []}
selectedValues={filters}
onFilterChange={(values) => updateColumnFilter(column.accessorKey as string, values)}
theme={theme}
/>
</div>
)}
</div>
);
})}
{filters.length ? `${filters.length} items` : 'All'}
</span>
<button
onClick={(e) => {
handleFilterClick(e, column.accessorKey as string);
}}
style={{
border: `1px solid ${theme.palette.divider}`,
background: theme.palette.background.paper,
cursor: 'pointer',
fontSize: '12px',
color: filters.length ? theme.palette.primary.main : theme.palette.text.secondary,
padding: '4px 8px',
borderRadius: '4px',
minWidth: '20px',
height: '24px',
flexShrink: 0,
transition: 'all 0.2s ease',
}}
onMouseEnter={(e) => {
e.currentTarget.style.background = theme.palette.action.hover;
}}
onMouseLeave={(e) => {
e.currentTarget.style.background = theme.palette.background.paper;
}}
type="button"
>
</button>

{openFilterColumn === column.accessorKey &&
anchorEl &&
createPortal(
<div
style={{
position: 'fixed',
top: anchorEl.getBoundingClientRect().bottom + 4,
left: anchorEl.getBoundingClientRect().left,
zIndex: theme.zIndex.modal,
}}
>
<ColumnFilterDropdown
allValues={columnUniqueValues[column.accessorKey as string] || []}
selectedValues={filters}
onFilterChange={(values) => updateColumnFilter(column.accessorKey as string, values)}
theme={theme}
/>
</div>,
document.body
)}
</div>
);
})}
</div>
</div>
)}
<Table
Expand All @@ -862,6 +966,6 @@ export function TablePanel({ contentDimensions, spec, queryResults }: TableProps
getItemActions={({ id, data }) => getItemActionButtons({ id, data: data as Record<string, unknown> })}
hasItemActions={actionButtons && actionButtons.length > 0}
/>
</>
</div>
);
}
7 changes: 7 additions & 0 deletions table/src/setup-tests.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,3 +15,10 @@ import '@testing-library/jest-dom';

// Always mock e-charts during tests since we don't have a proper canvas in jsdom
jest.mock('echarts/core');

// jsdom does not implement ResizeObserver
globalThis.ResizeObserver = class ResizeObserver {
observe(): void {}
unobserve(): void {}
disconnect(): void {}
};
Loading