From 31904692ab2766a4ba3323b958cc40ff85131348 Mon Sep 17 00:00:00 2001 From: mattiasimonato Date: Thu, 21 May 2026 14:55:31 +0200 Subject: [PATCH 01/12] fix: RTA details pane arrows follow filtered table rows --- .../pages/rta/overview/RealtimeOverview.tsx | 38 +++--- .../rta/overview/table/OverviewTable.tsx | 110 +++++++++++------- 2 files changed, 86 insertions(+), 62 deletions(-) diff --git a/ui/apps/pmm/src/pages/rta/overview/RealtimeOverview.tsx b/ui/apps/pmm/src/pages/rta/overview/RealtimeOverview.tsx index 7549e0162c2..7e9137c7dc6 100644 --- a/ui/apps/pmm/src/pages/rta/overview/RealtimeOverview.tsx +++ b/ui/apps/pmm/src/pages/rta/overview/RealtimeOverview.tsx @@ -29,39 +29,36 @@ const RealtimeOverviewPage: FC = () => { refetchInterval: refreshInterval, } ); - const [selectedQueryIndex, setSelectedQueryIndex] = useState(); + // Synced from the table after filters; details-pane arrows use this list, not the full API result. + const [navigableQueries, setNavigableQueries] = useState([]); const [selectedQuery, setSelectedQuery] = useState(); // We need to store the previous fetching state to restore it when the details pane is closed const previousFetchingState = useRef(fetching); const { data: sessions = [], isLoading } = useRealtimeSessions(); - const handleQueryChange = (query: QueryData, index: number) => { + const selectedQueryIndex = selectedQuery + ? navigableQueries.findIndex( + (query) => query.queryId === selectedQuery.queryId + ) + : -1; + + const handleQuerySelected = (query: QueryData) => { setSelectedQuery(query); - setSelectedQueryIndex(index); previousFetchingState.current = fetching; setFetching(false); }; const handleCloseDetails = () => { setSelectedQuery(undefined); - setSelectedQueryIndex(undefined); setFetching(previousFetchingState.current); }; - const handleNextQuery = () => { - const idx = (selectedQueryIndex || 0) + 1; - if (idx >= queries.length) { - return; - } - handleQueryChange(queries[idx], idx); - }; - - const handlePreviousQuery = () => { - const idx = (selectedQueryIndex || 0) - 1; - if (idx < 0) { + const handleAdjacentQuery = (offset: -1 | 1) => { + const nextIndex = selectedQueryIndex + offset; + if (nextIndex < 0 || nextIndex >= navigableQueries.length) { return; } - handleQueryChange(queries[idx], idx); + handleQuerySelected(navigableQueries[nextIndex]); }; const handleServiceIdsChange = (newServiceIds: string[]) => { @@ -94,7 +91,8 @@ const RealtimeOverviewPage: FC = () => { ( { query={selectedQuery} onClose={handleCloseDetails} isFirstQuery={selectedQueryIndex === 0} - isLastQuery={selectedQueryIndex === queries.length - 1} - onNext={handleNextQuery} - onPrevious={handlePreviousQuery} + isLastQuery={selectedQueryIndex === navigableQueries.length - 1} + onNext={() => handleAdjacentQuery(1)} + onPrevious={() => handleAdjacentQuery(-1)} /> ); diff --git a/ui/apps/pmm/src/pages/rta/overview/table/OverviewTable.tsx b/ui/apps/pmm/src/pages/rta/overview/table/OverviewTable.tsx index 006a961d9c2..1387e0f9928 100644 --- a/ui/apps/pmm/src/pages/rta/overview/table/OverviewTable.tsx +++ b/ui/apps/pmm/src/pages/rta/overview/table/OverviewTable.tsx @@ -1,7 +1,11 @@ -import { type MRT_Row } from 'material-react-table'; -import { MaterialReactTableProps } from 'material-react-table'; +import { + type MRT_ColumnFiltersState, + type MRT_Row, + type MRT_TableInstance, + MaterialReactTableProps, +} from 'material-react-table'; import { Table } from '@percona/percona-ui'; -import { FC } from 'react'; +import { FC, useEffect, useRef, useState } from 'react'; import { QueryData } from 'types/rta.types'; import { OVERVIEW_TABLE_COLUMNS } from './OverviewTable.constants'; import { RealtimeTableWrapper } from 'pages/rta/components/rta-table-wrapper'; @@ -11,7 +15,8 @@ import { filterElapsedTime } from './OverviewTable.utils'; interface Props { queries: QueryData[]; - onQuerySelected: (query: QueryData, idx: number) => void; + onQuerySelected: (query: QueryData) => void; + onNavigableQueriesChange: (queries: QueryData[]) => void; actions?: MaterialReactTableProps['renderTopToolbarCustomActions']; onRowHover?: () => void; } @@ -19,46 +24,67 @@ interface Props { const OverviewTable: FC = ({ queries, onQuerySelected, + onNavigableQueriesChange, actions, onRowHover, -}) => ( - - .${boxClasses.root}`]: { - alignItems: 'center', - flexDirection: 'row-reverse', +}) => { + const tableRef = useRef | null>(null); + // Controlled filter state is required to read the filtered row model via tableInstanceRef. + const [columnFilters, setColumnFilters] = useState([]); + const [globalFilter, setGlobalFilter] = useState(''); + + // Pre-pagination so navigation covers all filtered rows, not only the current page. + const getNavigableQueries = () => + tableRef.current?.getPrePaginationRowModel().rows.map((row) => row.original) ?? + queries; + + useEffect(() => { + onNavigableQueriesChange(getNavigableQueries()); + }, [queries, columnFilters, globalFilter, onNavigableQueriesChange]); + + return ( + +
.${boxClasses.root}`]: { + alignItems: 'center', + flexDirection: 'row-reverse', + }, }, - }, - }} - enableGlobalFilter={false} - enableHiding={false} - enableRowHoverAction - rowHoverAction={(row) => onQuerySelected(row.original, row.index)} - renderTopToolbarCustomActions={actions} - filterFns={{ - // default 'betweenInclusive' filter fails on values like '1.50', discarding the row that has 1.5 seconds - timeRangeFilterFn: (row, id, filterValue) => - filterElapsedTime(row as MRT_Row, id, filterValue), - }} - muiTableBodyRowProps={({ row }) => ({ - onMouseEnter: onRowHover, - 'data-testid': `query-${row.original.queryId}-row`, - })} - /> - -); + }} + state={{ columnFilters, globalFilter }} + onColumnFiltersChange={setColumnFilters} + onGlobalFilterChange={setGlobalFilter} + enableGlobalFilter={true} + enableHiding={false} + enableRowHoverAction + tableInstanceRef={tableRef} + rowHoverAction={(row) => onQuerySelected(row.original)} + renderTopToolbarCustomActions={actions} + filterFns={{ + // default 'betweenInclusive' filter fails on values like '1.50', discarding the row that has 1.5 seconds + timeRangeFilterFn: (row, id, filterValue) => + filterElapsedTime(row as MRT_Row, id, filterValue), + }} + muiTableBodyRowProps={({ row }) => ({ + onMouseEnter: onRowHover, + 'data-testid': `query-${row.original.queryId}-row`, + })} + /> + + ); +}; export default OverviewTable; From 808f58e8565e5274ffea51d7c1a6873cc6e5216b Mon Sep 17 00:00:00 2001 From: mattiasimonato Date: Thu, 21 May 2026 14:56:29 +0200 Subject: [PATCH 02/12] test: add RTA overview filtered navigation tests --- .../RealtimeOverview.navigation.test.tsx | 146 ++++++++++++++++++ 1 file changed, 146 insertions(+) create mode 100644 ui/apps/pmm/src/pages/rta/overview/RealtimeOverview.navigation.test.tsx diff --git a/ui/apps/pmm/src/pages/rta/overview/RealtimeOverview.navigation.test.tsx b/ui/apps/pmm/src/pages/rta/overview/RealtimeOverview.navigation.test.tsx new file mode 100644 index 00000000000..75ba19723cf --- /dev/null +++ b/ui/apps/pmm/src/pages/rta/overview/RealtimeOverview.navigation.test.tsx @@ -0,0 +1,146 @@ +/** + * Details-pane prev/next must follow the table's filtered rows (navigableQueries), + * not the full list returned by the API. + * + * In the app, OverviewTable syncs navigableQueries from Material React Table after + * filters are applied. RealtimeOverview uses that list for arrow navigation. + * + * We mock OverviewTable here so we can fix navigableQueries (query-1, query-2) while + * the API still returns an extra query (query-3) that would be next if we used the + * unfiltered array. RealtimeOverview.test.tsx keeps the real table for other behavior. + */ +import { fireEvent, render, screen, waitFor } from '@testing-library/react'; +import { FC, useEffect } from 'react'; +import { MemoryRouter, Route, Routes } from 'react-router-dom'; +import { wrapWithQueryProvider } from 'utils/testUtils'; +import { TEST_MONGO_DB_QUERY_DATA, TEST_REAL_TIME_SESSION } from 'utils/testStubs'; +import { QueryData } from 'types/rta.types'; +import RealtimeOverview from './RealtimeOverview'; + +const queryOne: QueryData = { + ...TEST_MONGO_DB_QUERY_DATA, + queryId: 'query-1', +}; + +const queryTwo: QueryData = { + ...TEST_MONGO_DB_QUERY_DATA, + queryId: 'query-2', +}; + +const queryFilteredOut: QueryData = { + ...TEST_MONGO_DB_QUERY_DATA, + queryId: 'query-3', +}; + +const navigableQueries = [queryOne, queryTwo]; + +const { searchQueries, getRunningSessions } = vi.hoisted(() => ({ + searchQueries: vi.fn(), + getRunningSessions: vi.fn(), +})); + +vi.mock('api/rta', () => ({ + searchQueries, + getRunningSessions, +})); + +vi.mock('./table/OverviewTable', () => { + const MockOverviewTable: FC<{ + onQuerySelected: (query: QueryData) => void; + onNavigableQueriesChange: (queries: QueryData[]) => void; + }> = ({ onQuerySelected, onNavigableQueriesChange }) => { + useEffect(() => { + onNavigableQueriesChange(navigableQueries); + }, [onNavigableQueriesChange]); + + return ( + + ); + }; + + return { default: MockOverviewTable }; +}); + +const renderComponent = () => + render( + wrapWithQueryProvider( + + + } /> + + + ) + ); + +const openDetailsPaneOnFirstQuery = async () => { + await waitFor(() => + expect(screen.getByTestId('mock-select-first-query')).toBeInTheDocument() + ); + fireEvent.click(screen.getByTestId('mock-select-first-query')); + await waitFor(() => + expect(screen.getByTestId('query-details-pane')).toHaveAttribute( + 'aria-hidden', + 'false' + ) + ); +}; + +const getOperationId = () => screen.getByTestId('operation-id-value'); + +describe('RealtimeOverview details pane navigation', () => { + beforeEach(() => { + vi.clearAllMocks(); + + searchQueries.mockResolvedValue({ + queries: [...navigableQueries, queryFilteredOut], + }); + + getRunningSessions.mockResolvedValue([TEST_REAL_TIME_SESSION]); + }); + + it('navigates to the next visible query, not the next API query', async () => { + renderComponent(); + await openDetailsPaneOnFirstQuery(); + + expect(getOperationId()).toHaveTextContent('query-1'); + + fireEvent.click(screen.getByTestId('details-pane-next-button')); + + expect(getOperationId()).toHaveTextContent('query-2'); + expect(getOperationId()).not.toHaveTextContent('query-3'); + }); + + it('navigates to the previous visible query', async () => { + renderComponent(); + await openDetailsPaneOnFirstQuery(); + + fireEvent.click(screen.getByTestId('details-pane-next-button')); + expect(getOperationId()).toHaveTextContent('query-2'); + + fireEvent.click(screen.getByTestId('details-pane-prev-button')); + expect(getOperationId()).toHaveTextContent('query-1'); + }); + + it('disables prev on the first visible query and next on the last', async () => { + renderComponent(); + await openDetailsPaneOnFirstQuery(); + + expect(screen.getByTestId('details-pane-prev-button')).toBeDisabled(); + expect(screen.getByTestId('details-pane-next-button')).not.toBeDisabled(); + + fireEvent.click(screen.getByTestId('details-pane-next-button')); + + expect(screen.getByTestId('details-pane-prev-button')).not.toBeDisabled(); + expect(screen.getByTestId('details-pane-next-button')).toBeDisabled(); + }); +}); From c4b59545cd63278ccff763d734b3e317eeea56cd Mon Sep 17 00:00:00 2001 From: mattiasimonato Date: Thu, 21 May 2026 15:36:06 +0200 Subject: [PATCH 03/12] fix: lint warnings --- .../pages/rta/overview/table/OverviewTable.tsx | 18 +++++++++++++----- 1 file changed, 13 insertions(+), 5 deletions(-) diff --git a/ui/apps/pmm/src/pages/rta/overview/table/OverviewTable.tsx b/ui/apps/pmm/src/pages/rta/overview/table/OverviewTable.tsx index 1387e0f9928..cb52b3a8eaa 100644 --- a/ui/apps/pmm/src/pages/rta/overview/table/OverviewTable.tsx +++ b/ui/apps/pmm/src/pages/rta/overview/table/OverviewTable.tsx @@ -5,7 +5,7 @@ import { MaterialReactTableProps, } from 'material-react-table'; import { Table } from '@percona/percona-ui'; -import { FC, useEffect, useRef, useState } from 'react'; +import { FC, useCallback, useEffect, useRef, useState } from 'react'; import { QueryData } from 'types/rta.types'; import { OVERVIEW_TABLE_COLUMNS } from './OverviewTable.constants'; import { RealtimeTableWrapper } from 'pages/rta/components/rta-table-wrapper'; @@ -34,13 +34,21 @@ const OverviewTable: FC = ({ const [globalFilter, setGlobalFilter] = useState(''); // Pre-pagination so navigation covers all filtered rows, not only the current page. - const getNavigableQueries = () => - tableRef.current?.getPrePaginationRowModel().rows.map((row) => row.original) ?? - queries; + const getNavigableQueries = useCallback( + () => + tableRef.current?.getPrePaginationRowModel().rows.map((row) => row.original) ?? + queries, + [queries] + ); useEffect(() => { onNavigableQueriesChange(getNavigableQueries()); - }, [queries, columnFilters, globalFilter, onNavigableQueriesChange]); + }, [ + columnFilters, + getNavigableQueries, + globalFilter, + onNavigableQueriesChange, + ]); return ( From 64567bbfd4a263c05470855da9dc5977a728bbb5 Mon Sep 17 00:00:00 2001 From: mattiasimonato Date: Fri, 22 May 2026 08:49:26 +0200 Subject: [PATCH 04/12] fix: sync RTA details navigation with table sort order --- .../src/pages/rta/overview/table/OverviewTable.tsx | 13 ++++++++++--- 1 file changed, 10 insertions(+), 3 deletions(-) diff --git a/ui/apps/pmm/src/pages/rta/overview/table/OverviewTable.tsx b/ui/apps/pmm/src/pages/rta/overview/table/OverviewTable.tsx index cb52b3a8eaa..eb56159fc49 100644 --- a/ui/apps/pmm/src/pages/rta/overview/table/OverviewTable.tsx +++ b/ui/apps/pmm/src/pages/rta/overview/table/OverviewTable.tsx @@ -1,6 +1,7 @@ import { type MRT_ColumnFiltersState, type MRT_Row, + type MRT_SortingState, type MRT_TableInstance, MaterialReactTableProps, } from 'material-react-table'; @@ -29,9 +30,10 @@ const OverviewTable: FC = ({ onRowHover, }) => { const tableRef = useRef | null>(null); - // Controlled filter state is required to read the filtered row model via tableInstanceRef. + // Controlled table state is required to read the filtered/sorted row model via tableInstanceRef. const [columnFilters, setColumnFilters] = useState([]); const [globalFilter, setGlobalFilter] = useState(''); + const [sorting, setSorting] = useState([]); // Pre-pagination so navigation covers all filtered rows, not only the current page. const getNavigableQueries = useCallback( @@ -48,6 +50,7 @@ const OverviewTable: FC = ({ getNavigableQueries, globalFilter, onNavigableQueriesChange, + sorting, ]); return ( @@ -72,14 +75,18 @@ const OverviewTable: FC = ({ }, }, }} - state={{ columnFilters, globalFilter }} + state={{ columnFilters, globalFilter, sorting }} onColumnFiltersChange={setColumnFilters} onGlobalFilterChange={setGlobalFilter} + onSortingChange={setSorting} enableGlobalFilter={true} enableHiding={false} enableRowHoverAction tableInstanceRef={tableRef} - rowHoverAction={(row) => onQuerySelected(row.original)} + rowHoverAction={(row) => { + onNavigableQueriesChange(getNavigableQueries()); + onQuerySelected(row.original); + }} renderTopToolbarCustomActions={actions} filterFns={{ // default 'betweenInclusive' filter fails on values like '1.50', discarding the row that has 1.5 seconds From f914530349d159fbfd701d71a006ba93dfabeff1 Mon Sep 17 00:00:00 2001 From: mattiasimonato Date: Fri, 22 May 2026 09:02:46 +0200 Subject: [PATCH 05/12] fix: disable RTA details navigation when selection is not navigable --- .../RealtimeOverview.navigation.test.tsx | 63 +++++++++++++++---- .../pages/rta/overview/RealtimeOverview.tsx | 10 ++- 2 files changed, 59 insertions(+), 14 deletions(-) diff --git a/ui/apps/pmm/src/pages/rta/overview/RealtimeOverview.navigation.test.tsx b/ui/apps/pmm/src/pages/rta/overview/RealtimeOverview.navigation.test.tsx index 75ba19723cf..6d65a8aa7af 100644 --- a/ui/apps/pmm/src/pages/rta/overview/RealtimeOverview.navigation.test.tsx +++ b/ui/apps/pmm/src/pages/rta/overview/RealtimeOverview.navigation.test.tsx @@ -34,10 +34,20 @@ const queryFilteredOut: QueryData = { const navigableQueries = [queryOne, queryTwo]; -const { searchQueries, getRunningSessions } = vi.hoisted(() => ({ - searchQueries: vi.fn(), - getRunningSessions: vi.fn(), -})); +const { searchQueries, getRunningSessions, mockNavigableQueries } = vi.hoisted(() => { + let navigable: QueryData[] = []; + + return { + searchQueries: vi.fn(), + getRunningSessions: vi.fn(), + mockNavigableQueries: { + get: () => navigable, + set: (queries: QueryData[]) => { + navigable = queries; + }, + }, + }; +}); vi.mock('api/rta', () => ({ searchQueries, @@ -50,17 +60,29 @@ vi.mock('./table/OverviewTable', () => { onNavigableQueriesChange: (queries: QueryData[]) => void; }> = ({ onQuerySelected, onNavigableQueriesChange }) => { useEffect(() => { - onNavigableQueriesChange(navigableQueries); + onNavigableQueriesChange(mockNavigableQueries.get()); }, [onNavigableQueriesChange]); return ( - + <> + + + ); }; @@ -100,6 +122,7 @@ const getOperationId = () => screen.getByTestId('operation-id-value'); describe('RealtimeOverview details pane navigation', () => { beforeEach(() => { vi.clearAllMocks(); + mockNavigableQueries.set(navigableQueries); searchQueries.mockResolvedValue({ queries: [...navigableQueries, queryFilteredOut], @@ -143,4 +166,20 @@ describe('RealtimeOverview details pane navigation', () => { expect(screen.getByTestId('details-pane-prev-button')).not.toBeDisabled(); expect(screen.getByTestId('details-pane-next-button')).toBeDisabled(); }); + + it('disables navigation when the selected query is not in navigableQueries', async () => { + renderComponent(); + await openDetailsPaneOnFirstQuery(); + + expect(getOperationId()).toHaveTextContent('query-1'); + + fireEvent.click(screen.getByTestId('mock-drop-selected-from-navigable')); + + expect(screen.getByTestId('details-pane-prev-button')).toBeDisabled(); + expect(screen.getByTestId('details-pane-next-button')).toBeDisabled(); + + fireEvent.click(screen.getByTestId('details-pane-next-button')); + + expect(getOperationId()).toHaveTextContent('query-1'); + }); }); diff --git a/ui/apps/pmm/src/pages/rta/overview/RealtimeOverview.tsx b/ui/apps/pmm/src/pages/rta/overview/RealtimeOverview.tsx index 7e9137c7dc6..64f5e8ceb9c 100644 --- a/ui/apps/pmm/src/pages/rta/overview/RealtimeOverview.tsx +++ b/ui/apps/pmm/src/pages/rta/overview/RealtimeOverview.tsx @@ -54,6 +54,9 @@ const RealtimeOverviewPage: FC = () => { }; const handleAdjacentQuery = (offset: -1 | 1) => { + if (selectedQueryIndex < 0) { + return; + } const nextIndex = selectedQueryIndex + offset; if (nextIndex < 0 || nextIndex >= navigableQueries.length) { return; @@ -174,8 +177,11 @@ const RealtimeOverviewPage: FC = () => { = navigableQueries.length - 1 + } onNext={() => handleAdjacentQuery(1)} onPrevious={() => handleAdjacentQuery(-1)} /> From 5aa136a8ded1add2c2267bd5e02515d3a3a74cc4 Mon Sep 17 00:00:00 2001 From: mattiasimonato Date: Fri, 22 May 2026 09:13:51 +0200 Subject: [PATCH 06/12] fix: keep RTA overview column-only filtering --- .../src/pages/rta/overview/table/OverviewTable.tsx | 14 +++----------- 1 file changed, 3 insertions(+), 11 deletions(-) diff --git a/ui/apps/pmm/src/pages/rta/overview/table/OverviewTable.tsx b/ui/apps/pmm/src/pages/rta/overview/table/OverviewTable.tsx index eb56159fc49..3e15c33a5d7 100644 --- a/ui/apps/pmm/src/pages/rta/overview/table/OverviewTable.tsx +++ b/ui/apps/pmm/src/pages/rta/overview/table/OverviewTable.tsx @@ -32,7 +32,6 @@ const OverviewTable: FC = ({ const tableRef = useRef | null>(null); // Controlled table state is required to read the filtered/sorted row model via tableInstanceRef. const [columnFilters, setColumnFilters] = useState([]); - const [globalFilter, setGlobalFilter] = useState(''); const [sorting, setSorting] = useState([]); // Pre-pagination so navigation covers all filtered rows, not only the current page. @@ -45,13 +44,7 @@ const OverviewTable: FC = ({ useEffect(() => { onNavigableQueriesChange(getNavigableQueries()); - }, [ - columnFilters, - getNavigableQueries, - globalFilter, - onNavigableQueriesChange, - sorting, - ]); + }, [columnFilters, getNavigableQueries, onNavigableQueriesChange, sorting]); return ( @@ -75,11 +68,10 @@ const OverviewTable: FC = ({ }, }, }} - state={{ columnFilters, globalFilter, sorting }} + state={{ columnFilters, sorting }} onColumnFiltersChange={setColumnFilters} - onGlobalFilterChange={setGlobalFilter} onSortingChange={setSorting} - enableGlobalFilter={true} + enableGlobalFilter={false} enableHiding={false} enableRowHoverAction tableInstanceRef={tableRef} From 4ed774d24c15cf9c28c12b5f76cf9780214ffdef Mon Sep 17 00:00:00 2001 From: mattiasimonato Date: Fri, 22 May 2026 09:16:35 +0200 Subject: [PATCH 07/12] test: wait for RTA navigation assertions after pane updates --- .../RealtimeOverview.navigation.test.tsx | 28 +++++++++++++------ 1 file changed, 20 insertions(+), 8 deletions(-) diff --git a/ui/apps/pmm/src/pages/rta/overview/RealtimeOverview.navigation.test.tsx b/ui/apps/pmm/src/pages/rta/overview/RealtimeOverview.navigation.test.tsx index 6d65a8aa7af..ebc960f3521 100644 --- a/ui/apps/pmm/src/pages/rta/overview/RealtimeOverview.navigation.test.tsx +++ b/ui/apps/pmm/src/pages/rta/overview/RealtimeOverview.navigation.test.tsx @@ -139,7 +139,9 @@ describe('RealtimeOverview details pane navigation', () => { fireEvent.click(screen.getByTestId('details-pane-next-button')); - expect(getOperationId()).toHaveTextContent('query-2'); + await waitFor(() => { + expect(getOperationId()).toHaveTextContent('query-2'); + }); expect(getOperationId()).not.toHaveTextContent('query-3'); }); @@ -148,10 +150,14 @@ describe('RealtimeOverview details pane navigation', () => { await openDetailsPaneOnFirstQuery(); fireEvent.click(screen.getByTestId('details-pane-next-button')); - expect(getOperationId()).toHaveTextContent('query-2'); + await waitFor(() => { + expect(getOperationId()).toHaveTextContent('query-2'); + }); fireEvent.click(screen.getByTestId('details-pane-prev-button')); - expect(getOperationId()).toHaveTextContent('query-1'); + await waitFor(() => { + expect(getOperationId()).toHaveTextContent('query-1'); + }); }); it('disables prev on the first visible query and next on the last', async () => { @@ -163,8 +169,10 @@ describe('RealtimeOverview details pane navigation', () => { fireEvent.click(screen.getByTestId('details-pane-next-button')); - expect(screen.getByTestId('details-pane-prev-button')).not.toBeDisabled(); - expect(screen.getByTestId('details-pane-next-button')).toBeDisabled(); + await waitFor(() => { + expect(screen.getByTestId('details-pane-prev-button')).not.toBeDisabled(); + expect(screen.getByTestId('details-pane-next-button')).toBeDisabled(); + }); }); it('disables navigation when the selected query is not in navigableQueries', async () => { @@ -175,11 +183,15 @@ describe('RealtimeOverview details pane navigation', () => { fireEvent.click(screen.getByTestId('mock-drop-selected-from-navigable')); - expect(screen.getByTestId('details-pane-prev-button')).toBeDisabled(); - expect(screen.getByTestId('details-pane-next-button')).toBeDisabled(); + await waitFor(() => { + expect(screen.getByTestId('details-pane-prev-button')).toBeDisabled(); + expect(screen.getByTestId('details-pane-next-button')).toBeDisabled(); + }); fireEvent.click(screen.getByTestId('details-pane-next-button')); - expect(getOperationId()).toHaveTextContent('query-1'); + await waitFor(() => { + expect(getOperationId()).toHaveTextContent('query-1'); + }); }); }); From f84b62cf09c9e83b043d18b7b480e91cc8df5ed8 Mon Sep 17 00:00:00 2001 From: mattiasimonato Date: Fri, 22 May 2026 10:35:41 +0200 Subject: [PATCH 08/12] fix: prevent RTA overview navigable-sync render loop --- .../rta/overview/RealtimeOverview.test.tsx | 6 +-- .../pages/rta/overview/RealtimeOverview.tsx | 7 ++- .../rta/overview/table/OverviewTable.tsx | 48 ++++++++++++++++--- .../rta/overview/table/OverviewTable.utils.ts | 17 ++++++- 4 files changed, 66 insertions(+), 12 deletions(-) diff --git a/ui/apps/pmm/src/pages/rta/overview/RealtimeOverview.test.tsx b/ui/apps/pmm/src/pages/rta/overview/RealtimeOverview.test.tsx index ece1a0fc88a..1fdd9eda4b2 100644 --- a/ui/apps/pmm/src/pages/rta/overview/RealtimeOverview.test.tsx +++ b/ui/apps/pmm/src/pages/rta/overview/RealtimeOverview.test.tsx @@ -167,7 +167,7 @@ describe('RealtimeOverview', () => { expect(screen.getByTestId('auto-refresh-button')).not.toBeDisabled(); - const clearButton = await waitFor(() => screen.findByTitle('Clear')); + const clearButton = await screen.findByTitle('Clear'); fireEvent.click(clearButton); expect(screen.getByTestId('auto-refresh-button')).toBeDisabled(); @@ -234,7 +234,7 @@ describe('RealtimeOverview', () => { expect(screen.getByTestId('auto-refresh-button')).toBeDisabled(); - const openButton = await waitFor(() => screen.findByTitle('Open')); + const openButton = await screen.findByTitle('Open'); fireEvent.click(openButton); const serviceOptionId = @@ -265,7 +265,7 @@ describe('RealtimeOverview', () => { expect(screen.getByTestId('auto-refresh-button')).toBeDisabled(); - const openButton = await waitFor(() => screen.findByTitle('Open')); + const openButton = await screen.findByTitle('Open'); fireEvent.click(openButton); const serviceOptionId = diff --git a/ui/apps/pmm/src/pages/rta/overview/RealtimeOverview.tsx b/ui/apps/pmm/src/pages/rta/overview/RealtimeOverview.tsx index 64f5e8ceb9c..38c8329f91f 100644 --- a/ui/apps/pmm/src/pages/rta/overview/RealtimeOverview.tsx +++ b/ui/apps/pmm/src/pages/rta/overview/RealtimeOverview.tsx @@ -17,18 +17,21 @@ import Button from '@mui/material/Button'; import { ServicesAutocompleteInput } from '../components/services-autocomplete-input'; import { AutoRefreshSelect } from './auto-refresh-select'; +const EMPTY_QUERIES: QueryData[] = []; + const RealtimeOverviewPage: FC = () => { const [searchParams, setSearchParams] = useSearchParams(); const serviceIds = searchParams.getAll('serviceIds'); const [fetching, setFetching] = useState(serviceIds.length > 0); const [refreshInterval, setRefreshInterval] = useState(2000); - const { data: queries = [], refetch } = useRealtimeQueries( + const { data: queries, refetch } = useRealtimeQueries( { serviceIds }, { enabled: fetching, refetchInterval: refreshInterval, } ); + const tableQueries = queries ?? EMPTY_QUERIES; // Synced from the table after filters; details-pane arrows use this list, not the full API result. const [navigableQueries, setNavigableQueries] = useState([]); const [selectedQuery, setSelectedQuery] = useState(); @@ -93,7 +96,7 @@ const RealtimeOverviewPage: FC = () => { return ( ( diff --git a/ui/apps/pmm/src/pages/rta/overview/table/OverviewTable.tsx b/ui/apps/pmm/src/pages/rta/overview/table/OverviewTable.tsx index 3e15c33a5d7..410a61c8687 100644 --- a/ui/apps/pmm/src/pages/rta/overview/table/OverviewTable.tsx +++ b/ui/apps/pmm/src/pages/rta/overview/table/OverviewTable.tsx @@ -12,7 +12,12 @@ import { OVERVIEW_TABLE_COLUMNS } from './OverviewTable.constants'; import { RealtimeTableWrapper } from 'pages/rta/components/rta-table-wrapper'; import { boxClasses } from '@mui/material/Box'; import { Messages } from './OverviewTable.messages'; -import { filterElapsedTime } from './OverviewTable.utils'; +import { + filterElapsedTime, + getNavigableQueryIdsKey, + isSameTableState, + resolveTableStateUpdate, +} from './OverviewTable.utils'; interface Props { queries: QueryData[]; @@ -30,6 +35,7 @@ const OverviewTable: FC = ({ onRowHover, }) => { const tableRef = useRef | null>(null); + const navigableQueryIdsKeyRef = useRef(''); // Controlled table state is required to read the filtered/sorted row model via tableInstanceRef. const [columnFilters, setColumnFilters] = useState([]); const [sorting, setSorting] = useState([]); @@ -42,9 +48,39 @@ const OverviewTable: FC = ({ [queries] ); + const syncNavigableQueries = useCallback(() => { + const navigableQueries = getNavigableQueries(); + const nextKey = getNavigableQueryIdsKey(navigableQueries); + if (navigableQueryIdsKeyRef.current === nextKey) { + return; + } + navigableQueryIdsKeyRef.current = nextKey; + onNavigableQueriesChange(navigableQueries); + }, [getNavigableQueries, onNavigableQueriesChange]); + + const handleColumnFiltersChange = useCallback( + (updater: MRT_ColumnFiltersState | ((old: MRT_ColumnFiltersState) => MRT_ColumnFiltersState)) => { + setColumnFilters((previous) => { + const next = resolveTableStateUpdate(previous, updater); + return isSameTableState(previous, next) ? previous : next; + }); + }, + [] + ); + + const handleSortingChange = useCallback( + (updater: MRT_SortingState | ((old: MRT_SortingState) => MRT_SortingState)) => { + setSorting((previous) => { + const next = resolveTableStateUpdate(previous, updater); + return isSameTableState(previous, next) ? previous : next; + }); + }, + [] + ); + useEffect(() => { - onNavigableQueriesChange(getNavigableQueries()); - }, [columnFilters, getNavigableQueries, onNavigableQueriesChange, sorting]); + syncNavigableQueries(); + }, [columnFilters, sorting, syncNavigableQueries]); return ( @@ -69,14 +105,14 @@ const OverviewTable: FC = ({ }, }} state={{ columnFilters, sorting }} - onColumnFiltersChange={setColumnFilters} - onSortingChange={setSorting} + onColumnFiltersChange={handleColumnFiltersChange} + onSortingChange={handleSortingChange} enableGlobalFilter={false} enableHiding={false} enableRowHoverAction tableInstanceRef={tableRef} rowHoverAction={(row) => { - onNavigableQueriesChange(getNavigableQueries()); + syncNavigableQueries(); onQuerySelected(row.original); }} renderTopToolbarCustomActions={actions} diff --git a/ui/apps/pmm/src/pages/rta/overview/table/OverviewTable.utils.ts b/ui/apps/pmm/src/pages/rta/overview/table/OverviewTable.utils.ts index 3e4b9bc9061..74425800ceb 100644 --- a/ui/apps/pmm/src/pages/rta/overview/table/OverviewTable.utils.ts +++ b/ui/apps/pmm/src/pages/rta/overview/table/OverviewTable.utils.ts @@ -1,6 +1,21 @@ -import { type MRT_Row } from 'material-react-table'; +import { + type MRT_ColumnFiltersState, + type MRT_Row, + type MRT_SortingState, +} from 'material-react-table'; import { QueryData } from 'types/rta.types'; +export const getNavigableQueryIdsKey = (queries: QueryData[]) => + queries.map((query) => query.queryId).join('\0'); + +export const isSameTableState = (previous: T, next: T) => + JSON.stringify(previous) === JSON.stringify(next); + +export const resolveTableStateUpdate = ( + previous: T, + updater: T | ((old: T) => T) +) => (typeof updater === 'function' ? updater(previous) : updater); + export const filterElapsedTime = ( row: MRT_Row, id: string, From 2fcd4ad4c34441c1dd60ffe5ef028b28b8c5c9e9 Mon Sep 17 00:00:00 2001 From: mattiasimonato Date: Fri, 22 May 2026 10:59:44 +0200 Subject: [PATCH 09/12] fix: remove unused imports in OverviewTable utils --- .../pmm/src/pages/rta/overview/table/OverviewTable.utils.ts | 6 +----- 1 file changed, 1 insertion(+), 5 deletions(-) diff --git a/ui/apps/pmm/src/pages/rta/overview/table/OverviewTable.utils.ts b/ui/apps/pmm/src/pages/rta/overview/table/OverviewTable.utils.ts index 74425800ceb..76452180f22 100644 --- a/ui/apps/pmm/src/pages/rta/overview/table/OverviewTable.utils.ts +++ b/ui/apps/pmm/src/pages/rta/overview/table/OverviewTable.utils.ts @@ -1,8 +1,4 @@ -import { - type MRT_ColumnFiltersState, - type MRT_Row, - type MRT_SortingState, -} from 'material-react-table'; +import { type MRT_Row } from 'material-react-table'; import { QueryData } from 'types/rta.types'; export const getNavigableQueryIdsKey = (queries: QueryData[]) => From 5f5035b0ed26839c45163a6fceccd43ca718208f Mon Sep 17 00:00:00 2001 From: mattiasimonato Date: Fri, 22 May 2026 11:04:46 +0200 Subject: [PATCH 10/12] fix: type resolveTableStateUpdate for tsc build --- .../src/pages/rta/overview/table/OverviewTable.utils.ts | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/ui/apps/pmm/src/pages/rta/overview/table/OverviewTable.utils.ts b/ui/apps/pmm/src/pages/rta/overview/table/OverviewTable.utils.ts index 76452180f22..db4b8d681d4 100644 --- a/ui/apps/pmm/src/pages/rta/overview/table/OverviewTable.utils.ts +++ b/ui/apps/pmm/src/pages/rta/overview/table/OverviewTable.utils.ts @@ -10,7 +10,12 @@ export const isSameTableState = (previous: T, next: T) => export const resolveTableStateUpdate = ( previous: T, updater: T | ((old: T) => T) -) => (typeof updater === 'function' ? updater(previous) : updater); +): T => { + if (typeof updater === 'function') { + return (updater as (old: T) => T)(previous); + } + return updater; +}; export const filterElapsedTime = ( row: MRT_Row, From 854939257c5a5022b9f8a1644b1844aee5d613f6 Mon Sep 17 00:00:00 2001 From: mattiasimonato Date: Fri, 22 May 2026 11:54:46 +0200 Subject: [PATCH 11/12] refactor: use plain MRT setters for RTA overview table --- .../rta/overview/table/OverviewTable.tsx | 31 ++----------------- .../rta/overview/table/OverviewTable.utils.ts | 13 -------- 2 files changed, 3 insertions(+), 41 deletions(-) diff --git a/ui/apps/pmm/src/pages/rta/overview/table/OverviewTable.tsx b/ui/apps/pmm/src/pages/rta/overview/table/OverviewTable.tsx index 410a61c8687..732a4283962 100644 --- a/ui/apps/pmm/src/pages/rta/overview/table/OverviewTable.tsx +++ b/ui/apps/pmm/src/pages/rta/overview/table/OverviewTable.tsx @@ -12,12 +12,7 @@ import { OVERVIEW_TABLE_COLUMNS } from './OverviewTable.constants'; import { RealtimeTableWrapper } from 'pages/rta/components/rta-table-wrapper'; import { boxClasses } from '@mui/material/Box'; import { Messages } from './OverviewTable.messages'; -import { - filterElapsedTime, - getNavigableQueryIdsKey, - isSameTableState, - resolveTableStateUpdate, -} from './OverviewTable.utils'; +import { filterElapsedTime, getNavigableQueryIdsKey } from './OverviewTable.utils'; interface Props { queries: QueryData[]; @@ -58,26 +53,6 @@ const OverviewTable: FC = ({ onNavigableQueriesChange(navigableQueries); }, [getNavigableQueries, onNavigableQueriesChange]); - const handleColumnFiltersChange = useCallback( - (updater: MRT_ColumnFiltersState | ((old: MRT_ColumnFiltersState) => MRT_ColumnFiltersState)) => { - setColumnFilters((previous) => { - const next = resolveTableStateUpdate(previous, updater); - return isSameTableState(previous, next) ? previous : next; - }); - }, - [] - ); - - const handleSortingChange = useCallback( - (updater: MRT_SortingState | ((old: MRT_SortingState) => MRT_SortingState)) => { - setSorting((previous) => { - const next = resolveTableStateUpdate(previous, updater); - return isSameTableState(previous, next) ? previous : next; - }); - }, - [] - ); - useEffect(() => { syncNavigableQueries(); }, [columnFilters, sorting, syncNavigableQueries]); @@ -105,8 +80,8 @@ const OverviewTable: FC = ({ }, }} state={{ columnFilters, sorting }} - onColumnFiltersChange={handleColumnFiltersChange} - onSortingChange={handleSortingChange} + onColumnFiltersChange={setColumnFilters} + onSortingChange={setSorting} enableGlobalFilter={false} enableHiding={false} enableRowHoverAction diff --git a/ui/apps/pmm/src/pages/rta/overview/table/OverviewTable.utils.ts b/ui/apps/pmm/src/pages/rta/overview/table/OverviewTable.utils.ts index db4b8d681d4..7fc3176d81e 100644 --- a/ui/apps/pmm/src/pages/rta/overview/table/OverviewTable.utils.ts +++ b/ui/apps/pmm/src/pages/rta/overview/table/OverviewTable.utils.ts @@ -4,19 +4,6 @@ import { QueryData } from 'types/rta.types'; export const getNavigableQueryIdsKey = (queries: QueryData[]) => queries.map((query) => query.queryId).join('\0'); -export const isSameTableState = (previous: T, next: T) => - JSON.stringify(previous) === JSON.stringify(next); - -export const resolveTableStateUpdate = ( - previous: T, - updater: T | ((old: T) => T) -): T => { - if (typeof updater === 'function') { - return (updater as (old: T) => T)(previous); - } - return updater; -}; - export const filterElapsedTime = ( row: MRT_Row, id: string, From a280c7a867b87ab7ef3b2b73d97799e0d418ac26 Mon Sep 17 00:00:00 2001 From: mattiasimonato Date: Fri, 22 May 2026 15:05:42 +0200 Subject: [PATCH 12/12] fix: always sync RTA navigable queries on table updates --- .../src/pages/rta/overview/table/OverviewTable.tsx | 11 ++--------- .../pages/rta/overview/table/OverviewTable.utils.ts | 3 --- 2 files changed, 2 insertions(+), 12 deletions(-) diff --git a/ui/apps/pmm/src/pages/rta/overview/table/OverviewTable.tsx b/ui/apps/pmm/src/pages/rta/overview/table/OverviewTable.tsx index 732a4283962..e850965bc5b 100644 --- a/ui/apps/pmm/src/pages/rta/overview/table/OverviewTable.tsx +++ b/ui/apps/pmm/src/pages/rta/overview/table/OverviewTable.tsx @@ -12,7 +12,7 @@ import { OVERVIEW_TABLE_COLUMNS } from './OverviewTable.constants'; import { RealtimeTableWrapper } from 'pages/rta/components/rta-table-wrapper'; import { boxClasses } from '@mui/material/Box'; import { Messages } from './OverviewTable.messages'; -import { filterElapsedTime, getNavigableQueryIdsKey } from './OverviewTable.utils'; +import { filterElapsedTime } from './OverviewTable.utils'; interface Props { queries: QueryData[]; @@ -30,7 +30,6 @@ const OverviewTable: FC = ({ onRowHover, }) => { const tableRef = useRef | null>(null); - const navigableQueryIdsKeyRef = useRef(''); // Controlled table state is required to read the filtered/sorted row model via tableInstanceRef. const [columnFilters, setColumnFilters] = useState([]); const [sorting, setSorting] = useState([]); @@ -44,13 +43,7 @@ const OverviewTable: FC = ({ ); const syncNavigableQueries = useCallback(() => { - const navigableQueries = getNavigableQueries(); - const nextKey = getNavigableQueryIdsKey(navigableQueries); - if (navigableQueryIdsKeyRef.current === nextKey) { - return; - } - navigableQueryIdsKeyRef.current = nextKey; - onNavigableQueriesChange(navigableQueries); + onNavigableQueriesChange(getNavigableQueries()); }, [getNavigableQueries, onNavigableQueriesChange]); useEffect(() => { diff --git a/ui/apps/pmm/src/pages/rta/overview/table/OverviewTable.utils.ts b/ui/apps/pmm/src/pages/rta/overview/table/OverviewTable.utils.ts index 7fc3176d81e..3e4b9bc9061 100644 --- a/ui/apps/pmm/src/pages/rta/overview/table/OverviewTable.utils.ts +++ b/ui/apps/pmm/src/pages/rta/overview/table/OverviewTable.utils.ts @@ -1,9 +1,6 @@ import { type MRT_Row } from 'material-react-table'; import { QueryData } from 'types/rta.types'; -export const getNavigableQueryIdsKey = (queries: QueryData[]) => - queries.map((query) => query.queryId).join('\0'); - export const filterElapsedTime = ( row: MRT_Row, id: string,