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..ebc960f3521 --- /dev/null +++ b/ui/apps/pmm/src/pages/rta/overview/RealtimeOverview.navigation.test.tsx @@ -0,0 +1,197 @@ +/** + * 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, 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, + getRunningSessions, +})); + +vi.mock('./table/OverviewTable', () => { + const MockOverviewTable: FC<{ + onQuerySelected: (query: QueryData) => void; + onNavigableQueriesChange: (queries: QueryData[]) => void; + }> = ({ onQuerySelected, onNavigableQueriesChange }) => { + useEffect(() => { + onNavigableQueriesChange(mockNavigableQueries.get()); + }, [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(); + mockNavigableQueries.set(navigableQueries); + + 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')); + + await waitFor(() => { + 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')); + await waitFor(() => { + expect(getOperationId()).toHaveTextContent('query-2'); + }); + + fireEvent.click(screen.getByTestId('details-pane-prev-button')); + await waitFor(() => { + 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')); + + 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 () => { + renderComponent(); + await openDetailsPaneOnFirstQuery(); + + expect(getOperationId()).toHaveTextContent('query-1'); + + fireEvent.click(screen.getByTestId('mock-drop-selected-from-navigable')); + + 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')); + + await waitFor(() => { + expect(getOperationId()).toHaveTextContent('query-1'); + }); + }); +}); 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 7549e0162c2..38c8329f91f 100644 --- a/ui/apps/pmm/src/pages/rta/overview/RealtimeOverview.tsx +++ b/ui/apps/pmm/src/pages/rta/overview/RealtimeOverview.tsx @@ -17,51 +17,54 @@ 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 [selectedQueryIndex, setSelectedQueryIndex] = useState(); + 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(); // 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) { + const handleAdjacentQuery = (offset: -1 | 1) => { + if (selectedQueryIndex < 0) { return; } - handleQueryChange(queries[idx], idx); - }; - - const handlePreviousQuery = () => { - const idx = (selectedQueryIndex || 0) - 1; - if (idx < 0) { + const nextIndex = selectedQueryIndex + offset; + if (nextIndex < 0 || nextIndex >= navigableQueries.length) { return; } - handleQueryChange(queries[idx], idx); + handleQuerySelected(navigableQueries[nextIndex]); }; const handleServiceIdsChange = (newServiceIds: string[]) => { @@ -93,8 +96,9 @@ const RealtimeOverviewPage: FC = () => { return ( ( { = 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..e850965bc5b 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,12 @@ -import { type MRT_Row } from 'material-react-table'; -import { MaterialReactTableProps } from 'material-react-table'; +import { + type MRT_ColumnFiltersState, + type MRT_Row, + type MRT_SortingState, + type MRT_TableInstance, + MaterialReactTableProps, +} from 'material-react-table'; import { Table } from '@percona/percona-ui'; -import { FC } 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'; @@ -11,7 +16,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 +25,77 @@ interface Props { const OverviewTable: FC = ({ queries, onQuerySelected, + onNavigableQueriesChange, actions, onRowHover, -}) => ( - - .${boxClasses.root}`]: { - alignItems: 'center', - flexDirection: 'row-reverse', +}) => { + const tableRef = useRef | null>(null); + // Controlled table state is required to read the filtered/sorted row model via tableInstanceRef. + const [columnFilters, setColumnFilters] = useState([]); + const [sorting, setSorting] = useState([]); + + // Pre-pagination so navigation covers all filtered rows, not only the current page. + const getNavigableQueries = useCallback( + () => + tableRef.current?.getPrePaginationRowModel().rows.map((row) => row.original) ?? + queries, + [queries] + ); + + const syncNavigableQueries = useCallback(() => { + onNavigableQueriesChange(getNavigableQueries()); + }, [getNavigableQueries, onNavigableQueriesChange]); + + useEffect(() => { + syncNavigableQueries(); + }, [columnFilters, sorting, syncNavigableQueries]); + + 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, sorting }} + onColumnFiltersChange={setColumnFilters} + onSortingChange={setSorting} + enableGlobalFilter={false} + enableHiding={false} + enableRowHoverAction + tableInstanceRef={tableRef} + rowHoverAction={(row) => { + syncNavigableQueries(); + 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;