diff --git a/table/src/components/TablePanel.test.tsx b/table/src/components/TablePanel.test.tsx index 654cff1da..beee35a81 100644 --- a/table/src/components/TablePanel.test.tsx +++ b/table/src/components/TablePanel.test.tsx @@ -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 diff --git a/table/src/components/TablePanel.tsx b/table/src/components/TablePanel.tsx index 75106f32f..9f9744ff1 100644 --- a/table/src/components/TablePanel.tsx +++ b/table/src/components/TablePanel.tsx @@ -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'; @@ -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 ( @@ -401,6 +402,10 @@ export function TablePanel({ contentDimensions, spec, queryResults }: TableProps }); const filteredDataRef = useRef>>([]); + // Refs used to keep the filter row in sync with the table's horizontal + const panelContainerRef = useRef(null); + const filterRowInnerRef = useRef(null); + const filterCellRefs = useRef>([]); // Convert selectionMap to TanStack's RowSelectionState format const rowSelection = useMemo((): RowSelectionState => { @@ -682,6 +687,12 @@ export function TablePanel({ contentDimensions, spec, queryResults }: TableProps const handleFilterClick = (event: React.MouseEvent, columnId: string): void => { event.preventDefault(); event.stopPropagation(); + + if (openFilterColumn === columnId) { + handleFilterClose(); + return; + } + setFilterAnchorEl({ ...filterAnchorEl, [columnId]: event.currentTarget }); setOpenFilterColumn(columnId); }; @@ -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('.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)); + 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('.MuiTableContainer-root'); + if (!scrollContainer) { + return; + } + + const headerRow = scrollContainer.querySelector('thead tr'); + if (!headerRow) { + return; + } + + const syncColumnWidths = (): void => { + const headerCells = scrollContainer.querySelectorAll('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; } @@ -748,99 +833,118 @@ export function TablePanel({ contentDimensions, spec, queryResults }: TableProps } return ( - <> +
{confirmDialog} {spec.enableFiltering && (
- {columns.map((column, idx) => { - const filters = getSelectedFilterValues(column.accessorKey as string); - const columnWidth = column.width || spec.defaultColumnWidth; - return ( -
- - {filters.length ? `${filters.length} items` : 'All'} - - - - {openFilterColumn === column.accessorKey && ( -
- updateColumnFilter(column.accessorKey as string, values)} - theme={theme} - /> -
- )} -
- ); - })} + {filters.length ? `${filters.length} items` : 'All'} + + + + {openFilterColumn === column.accessorKey && + anchorEl && + createPortal( +
+ updateColumnFilter(column.accessorKey as string, values)} + theme={theme} + /> +
, + document.body + )} +
+ ); + })} +
)} getItemActionButtons({ id, data: data as Record })} hasItemActions={actionButtons && actionButtons.length > 0} /> - + ); } diff --git a/table/src/setup-tests.ts b/table/src/setup-tests.ts index d43de32e0..c20266bf1 100644 --- a/table/src/setup-tests.ts +++ b/table/src/setup-tests.ts @@ -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 {} +};