From bdbe8d16026eb2eeea810c30075adb0a1f2ee62f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?F=C3=A1bio=20Silva?= Date: Wed, 24 Jun 2026 18:48:39 +0100 Subject: [PATCH 1/8] feat: import useTableUrlState --- .../src/hooks/utils/useTableUrlState.test.ts | 283 ++++++++++++++++++ .../pmm/src/hooks/utils/useTableUrlState.ts | 126 ++++++++ .../pages/rta/overview/RealtimeOverview.tsx | 7 +- .../table/OverviewTable.constants.tsx | 5 +- .../rta/overview/table/OverviewTable.tsx | 35 +-- .../sessions/sessions-table/SessionsTable.tsx | 15 +- ui/apps/pmm/src/utils/testStubs.ts | 13 + 7 files changed, 456 insertions(+), 28 deletions(-) create mode 100644 ui/apps/pmm/src/hooks/utils/useTableUrlState.test.ts create mode 100644 ui/apps/pmm/src/hooks/utils/useTableUrlState.ts diff --git a/ui/apps/pmm/src/hooks/utils/useTableUrlState.test.ts b/ui/apps/pmm/src/hooks/utils/useTableUrlState.test.ts new file mode 100644 index 00000000000..50b0e7b8571 --- /dev/null +++ b/ui/apps/pmm/src/hooks/utils/useTableUrlState.test.ts @@ -0,0 +1,283 @@ +import { renderHook } from '@testing-library/react'; +import { describe, expect, it, vi } from 'vitest'; +import { usePerconaTableUrlState } from '@percona/percona-ui'; +import reactRouter from 'react-router-dom'; +import { cloneColumnFilters, useTableUrlState } from './useTableUrlState'; + +vi.mock('@percona/percona-ui', () => ({ + usePerconaTableUrlState: vi.fn(), +})); + +const setup = (params: string) => { + const searchParams = new URLSearchParams(params); + const setSearchParams = vi.fn(); + + vi.spyOn(reactRouter, 'useSearchParams').mockReturnValue([searchParams, setSearchParams]); + vi.mocked(usePerconaTableUrlState).mockReturnValue({ + tableState: { + state: { + columnFilters: [], + globalFilter: '', + sorting: [], + pagination: { pageIndex: 0, pageSize: 10 }, + }, + onColumnFiltersChange: vi.fn(), + onGlobalFilterChange: vi.fn(), + onSortingChange: vi.fn(), + onPaginationChange: vi.fn(), + }, + tableProps: { + state: { + columnFilters: [], + globalFilter: '', + sorting: [], + pagination: { pageIndex: 0, pageSize: 10 }, + showColumnFilters: false, + showGlobalFilter: false, + }, + onColumnFiltersChange: vi.fn(), + onGlobalFilterChange: vi.fn(), + onSortingChange: vi.fn(), + onPaginationChange: vi.fn(), + onShowColumnFiltersChange: vi.fn(), + onShowGlobalFilterChange: vi.fn(), + }, + }); + + return { searchParams, setSearchParams }; +}; + +describe('cloneColumnFilters', () => { + it('clones range filter value tuples', () => { + const filters = [{ id: 'queryExecutionDurationMs', value: ['1', ''] }]; + const cloned = cloneColumnFilters(filters); + + expect(cloned).toEqual(filters); + expect(cloned[0].value).not.toBe(filters[0].value); + }); +}); + +describe('useTableUrlState', () => { + it('passes react-router search params to percona useTableUrlState', () => { + const { searchParams, setSearchParams } = setup( + 'serviceIds=123&overview.sort=queryText:desc' + ); + + renderHook(() => + useTableUrlState({ + paramPrefix: 'overview', + }) + ); + + expect(usePerconaTableUrlState).toHaveBeenCalledWith({ + searchParams, + setSearchParams, + paramPrefix: 'overview', + }); + }); + + it('forwards optional hook configuration', () => { + const { searchParams, setSearchParams } = setup(''); + + renderHook(() => + useTableUrlState({ + paramPrefix: 'sessions', + debounceMs: 500, + replace: false, + sync: { pagination: false }, + }) + ); + + expect(usePerconaTableUrlState).toHaveBeenCalledWith({ + searchParams, + setSearchParams, + paramPrefix: 'sessions', + debounceMs: 500, + replace: false, + sync: { pagination: false }, + }); + }); + + it('passes cloned range filter values to the table', () => { + const rangeFilters = [{ id: 'queryExecutionDurationMs', value: ['2', ''] }]; + setup(''); + + vi.mocked(usePerconaTableUrlState).mockReturnValue({ + tableState: { + state: { + columnFilters: rangeFilters, + globalFilter: '', + sorting: [], + pagination: { pageIndex: 0, pageSize: 10 }, + }, + onColumnFiltersChange: vi.fn(), + onGlobalFilterChange: vi.fn(), + onSortingChange: vi.fn(), + onPaginationChange: vi.fn(), + }, + tableProps: { + state: { + columnFilters: rangeFilters, + globalFilter: '', + sorting: [], + pagination: { pageIndex: 0, pageSize: 10 }, + showColumnFilters: false, + showGlobalFilter: false, + }, + onColumnFiltersChange: vi.fn(), + onGlobalFilterChange: vi.fn(), + onSortingChange: vi.fn(), + onPaginationChange: vi.fn(), + onShowColumnFiltersChange: vi.fn(), + onShowGlobalFilterChange: vi.fn(), + }, + }); + + const { result } = renderHook(() => useTableUrlState({ paramPrefix: 'overview' })); + + expect(result.current.tableProps.state.columnFilters).toEqual(rangeFilters); + expect(result.current.tableProps.state.columnFilters[0].value).not.toBe( + rangeFilters[0].value + ); + }); + + it('preserves additionalState keys such as rowSelection in tableProps.state', () => { + const rowSelection = { 'session-1': true }; + setup(''); + + vi.mocked(usePerconaTableUrlState).mockReturnValue({ + tableState: { + state: { + columnFilters: [], + globalFilter: '', + sorting: [], + pagination: { pageIndex: 0, pageSize: 10 }, + }, + onColumnFiltersChange: vi.fn(), + onGlobalFilterChange: vi.fn(), + onSortingChange: vi.fn(), + onPaginationChange: vi.fn(), + }, + tableProps: { + state: { + columnFilters: [], + globalFilter: '', + sorting: [], + pagination: { pageIndex: 0, pageSize: 10 }, + showColumnFilters: false, + showGlobalFilter: false, + rowSelection, + }, + onColumnFiltersChange: vi.fn(), + onGlobalFilterChange: vi.fn(), + onSortingChange: vi.fn(), + onPaginationChange: vi.fn(), + onShowColumnFiltersChange: vi.fn(), + onShowGlobalFilterChange: vi.fn(), + }, + }); + + const { result } = renderHook(() => useTableUrlState({ paramPrefix: 'sessions' })); + + expect(result.current.tableProps.state.rowSelection).toEqual(rowSelection); + }); + + it('updates rowSelection in tableProps.state when additionalState changes', () => { + setup(''); + + const baseReturn = { + tableState: { + state: { + columnFilters: [], + globalFilter: '', + sorting: [], + pagination: { pageIndex: 0, pageSize: 10 }, + }, + onColumnFiltersChange: vi.fn(), + onGlobalFilterChange: vi.fn(), + onSortingChange: vi.fn(), + onPaginationChange: vi.fn(), + }, + tableProps: { + state: { + columnFilters: [], + globalFilter: '', + sorting: [], + pagination: { pageIndex: 0, pageSize: 10 }, + showColumnFilters: false, + showGlobalFilter: false, + rowSelection: {} as Record, + }, + onColumnFiltersChange: vi.fn(), + onGlobalFilterChange: vi.fn(), + onSortingChange: vi.fn(), + onPaginationChange: vi.fn(), + onShowColumnFiltersChange: vi.fn(), + onShowGlobalFilterChange: vi.fn(), + }, + }; + + vi.mocked(usePerconaTableUrlState).mockReturnValue(baseReturn); + + const { result, rerender } = renderHook(() => useTableUrlState({ paramPrefix: 'sessions' })); + + expect(result.current.tableProps.state.rowSelection).toEqual({}); + + vi.mocked(usePerconaTableUrlState).mockReturnValue({ + ...baseReturn, + tableProps: { + ...baseReturn.tableProps, + state: { + ...baseReturn.tableProps.state, + rowSelection: { 'session-1': true }, + }, + }, + }); + rerender(); + + expect(result.current.tableProps.state.rowSelection).toEqual({ 'session-1': true }); + }); + + it('keeps the same columnFilters reference when filter values are unchanged', () => { + const rangeFilters = [{ id: 'queryExecutionDurationMs', value: ['2', ''] }]; + setup(''); + + vi.mocked(usePerconaTableUrlState).mockReturnValue({ + tableState: { + state: { + columnFilters: rangeFilters, + globalFilter: '', + sorting: [], + pagination: { pageIndex: 0, pageSize: 10 }, + }, + onColumnFiltersChange: vi.fn(), + onGlobalFilterChange: vi.fn(), + onSortingChange: vi.fn(), + onPaginationChange: vi.fn(), + }, + tableProps: { + state: { + columnFilters: rangeFilters, + globalFilter: '', + sorting: [], + pagination: { pageIndex: 0, pageSize: 10 }, + showColumnFilters: false, + showGlobalFilter: false, + }, + onColumnFiltersChange: vi.fn(), + onGlobalFilterChange: vi.fn(), + onSortingChange: vi.fn(), + onPaginationChange: vi.fn(), + onShowColumnFiltersChange: vi.fn(), + onShowGlobalFilterChange: vi.fn(), + }, + }); + + const { result, rerender } = renderHook(() => useTableUrlState({ paramPrefix: 'overview' })); + const firstReference = result.current.tableProps.state.columnFilters; + + rerender(); + + expect(result.current.tableProps.state.columnFilters).toBe(firstReference); + }); +}); diff --git a/ui/apps/pmm/src/hooks/utils/useTableUrlState.ts b/ui/apps/pmm/src/hooks/utils/useTableUrlState.ts new file mode 100644 index 00000000000..19d8c1b4814 --- /dev/null +++ b/ui/apps/pmm/src/hooks/utils/useTableUrlState.ts @@ -0,0 +1,126 @@ +import { useCallback, useMemo } from 'react'; +import { useSearchParams } from 'react-router-dom'; +import { + stableDependencyKey, + usePerconaTableUrlState, + type MRT_ColumnFiltersState, + type MRT_Updater, + type UsePerconaTableUrlStateOptions, + type UsePerconaTableUrlStateResult, +} from '@percona/percona-ui'; + +export type UseTableUrlStateOptions = Omit< + UsePerconaTableUrlStateOptions, + 'searchParams' | 'setSearchParams' +>; + +export type UseTableUrlStateResult = UsePerconaTableUrlStateResult; + +// MRT range filters mutate filter value arrays in place. With controlled URL +// state that can mutate React state before setState runs, so isSameTableState +// sees no change and the table skips re-filtering until another filter updates. +export const cloneColumnFilters = ( + filters: MRT_ColumnFiltersState +): MRT_ColumnFiltersState => + filters.map(({ id, value }) => ({ + id, + value: Array.isArray(value) ? [...value] : value, + })); + +export const useTableUrlState = ( + options: UseTableUrlStateOptions = {} +): UseTableUrlStateResult => { + const [searchParams, setSearchParams] = useSearchParams(); + + const { tableState, tableProps } = usePerconaTableUrlState({ + searchParams, + setSearchParams, + ...options, + }); + + const { + state: { + columnFilters, + globalFilter, + sorting, + pagination, + showColumnFilters, + showGlobalFilter, + ...additionalTableState + }, + onGlobalFilterChange, + onSortingChange, + onPaginationChange, + onShowColumnFiltersChange, + onShowGlobalFilterChange, + onColumnFiltersChange: onPerconaColumnFiltersChange, + } = tableProps; + + const columnFiltersKey = stableDependencyKey(columnFilters); + const additionalTableStateKey = stableDependencyKey(additionalTableState); + + // Keep a stable clone reference while filter values are unchanged. MRT + // re-syncs range inputs from column.getFilterValue() whenever that array + // reference changes, which causes visible flicker during typing/refetches. + const columnFiltersForTable = useMemo( + () => cloneColumnFilters(columnFilters), + // columnFiltersKey tracks columnFilters by value so the clone reference stays + // stable across parent re-renders (e.g. query refetches) with unchanged filters. + // eslint-disable-next-line react-hooks/exhaustive-deps + [columnFiltersKey] + ); + + const onColumnFiltersChange = useCallback( + (updater: MRT_Updater) => { + onPerconaColumnFiltersChange((prev) => { + const resolved = + updater instanceof Function + ? updater(cloneColumnFilters(prev)) + : updater; + + return cloneColumnFilters(resolved); + }); + }, + [onPerconaColumnFiltersChange] + ); + + const tablePropsWithImmutableFilters = useMemo( + () => ({ + state: { + columnFilters: columnFiltersForTable, + globalFilter, + sorting, + pagination, + showColumnFilters, + showGlobalFilter, + ...additionalTableState, + }, + onColumnFiltersChange, + onGlobalFilterChange, + onSortingChange, + onPaginationChange, + onShowColumnFiltersChange, + onShowGlobalFilterChange, + }), + [ + columnFiltersForTable, + globalFilter, + sorting, + pagination, + showColumnFilters, + showGlobalFilter, + additionalTableStateKey, + onColumnFiltersChange, + onGlobalFilterChange, + onSortingChange, + onPaginationChange, + onShowColumnFiltersChange, + onShowGlobalFilterChange, + ] + ); + + return { + tableState, + tableProps: tablePropsWithImmutableFilters, + }; +}; diff --git a/ui/apps/pmm/src/pages/rta/overview/RealtimeOverview.tsx b/ui/apps/pmm/src/pages/rta/overview/RealtimeOverview.tsx index 38c8329f91f..fb7ff60bf01 100644 --- a/ui/apps/pmm/src/pages/rta/overview/RealtimeOverview.tsx +++ b/ui/apps/pmm/src/pages/rta/overview/RealtimeOverview.tsx @@ -82,7 +82,12 @@ const RealtimeOverviewPage: FC = () => { }); } - setSearchParams({ serviceIds: newServiceIds }); + setSearchParams((prev) => { + const next = new URLSearchParams(prev); + next.delete('serviceIds'); + newServiceIds.forEach((id) => next.append('serviceIds', id)); + return next; + }); }; if (isLoading) { diff --git a/ui/apps/pmm/src/pages/rta/overview/table/OverviewTable.constants.tsx b/ui/apps/pmm/src/pages/rta/overview/table/OverviewTable.constants.tsx index 4065b474244..0e4b6b12afd 100644 --- a/ui/apps/pmm/src/pages/rta/overview/table/OverviewTable.constants.tsx +++ b/ui/apps/pmm/src/pages/rta/overview/table/OverviewTable.constants.tsx @@ -41,8 +41,9 @@ export const OVERVIEW_TABLE_COLUMNS: MRT_ColumnDef[] = [ accessorKey: 'queryExecutionDurationMs', filterVariant: 'range', filterFn: 'timeRangeFilterFn', - muiTableHeadCellFilterTextFieldProps: { - inputProps: { step: 0.25, type: 'number' }, + muiFilterTextFieldProps: { + type: 'text', + inputProps: { inputMode: 'decimal' }, }, Cell: ({ cell }) => cell.getValue() ? ( 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 cdf9e27348c..f2e1be4f29a 100644 --- a/ui/apps/pmm/src/pages/rta/overview/table/OverviewTable.tsx +++ b/ui/apps/pmm/src/pages/rta/overview/table/OverviewTable.tsx @@ -1,18 +1,20 @@ -import { - type MRT_ColumnFiltersState, - type MRT_Row, - type MRT_SortingState, - type MRT_TableInstance, - MaterialReactTableProps, -} from 'material-react-table'; +import { FC, useCallback, useEffect, useRef } from 'react'; +import { type MRT_Row, type MRT_TableInstance, MaterialReactTableProps } from 'material-react-table'; import { Table } from '@percona/percona-ui'; -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'; import { boxClasses } from '@mui/material/Box'; import { Messages } from './OverviewTable.messages'; import { filterElapsedTime } from './OverviewTable.utils'; +import { useTableUrlState } from 'hooks/utils/useTableUrlState'; + +const OVERVIEW_TABLE_URL_STATE_OPTIONS = { + paramPrefix: 'overview', + defaults: { + pagination: { pageIndex: 0, pageSize: 25 }, + } +}; interface Props { queries: QueryData[]; @@ -30,9 +32,8 @@ const OverviewTable: FC = ({ onRowHover, }) => { 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([]); + const { tableProps } = useTableUrlState(OVERVIEW_TABLE_URL_STATE_OPTIONS); + const { columnFilters, sorting } = tableProps.state; // Pre-pagination so navigation covers all filtered rows, not only the current page. const getNavigableQueries = useCallback( @@ -54,12 +55,7 @@ const OverviewTable: FC = ({ = ({ }, }, }} - state={{ columnFilters, sorting }} - onColumnFiltersChange={setColumnFilters} - onSortingChange={setSorting} enableStickyHeader enableGlobalFilter={false} enableHiding={false} @@ -82,7 +75,7 @@ const OverviewTable: FC = ({ tableInstanceRef={tableRef} rowHoverAction={(row) => { syncNavigableQueries(); - onQuerySelected(row.original); + onQuerySelected(row.original as QueryData); }} renderTopToolbarCustomActions={actions} filterFns={{ diff --git a/ui/apps/pmm/src/pages/rta/sessions/sessions-table/SessionsTable.tsx b/ui/apps/pmm/src/pages/rta/sessions/sessions-table/SessionsTable.tsx index 88f60b88bd6..deaca7b1b82 100644 --- a/ui/apps/pmm/src/pages/rta/sessions/sessions-table/SessionsTable.tsx +++ b/ui/apps/pmm/src/pages/rta/sessions/sessions-table/SessionsTable.tsx @@ -21,6 +21,11 @@ import { enqueueSnackbar } from 'notistack'; import { RealtimeTableWrapper } from 'pages/rta/components/rta-table-wrapper'; import { useUser } from 'contexts/user'; import { Navigate } from 'react-router-dom'; +import { useTableUrlState } from 'hooks/utils/useTableUrlState'; + +const SESSIONS_TABLE_URL_STATE_OPTIONS = { + paramPrefix: 'sessions', +}; const SessionsTable: FC = () => { const { user } = useUser(); @@ -38,6 +43,10 @@ const SessionsTable: FC = () => { [rowSelection, rows] ); const { mutateAsync: stopSessions } = useStopSessions(); + const { tableProps } = useTableUrlState({ + ...SESSIONS_TABLE_URL_STATE_OPTIONS, + additionalState: { rowSelection }, + }); const closeModal = () => { setModal(null); @@ -129,9 +138,6 @@ const SessionsTable: FC = () => { ...(user?.isPMMAdmin ? ['mrt-row-actions'] : []), ], }} - state={{ - rowSelection, - }} positionToolbarAlertBanner="none" getRowId={(row) => row.sessionId} noDataMessage={Messages.empty} @@ -163,7 +169,7 @@ const SessionsTable: FC = () => { color="inherit" size="small" data-testid="open-stop-modal" - onClick={() => openStopModal(row.original)} + onClick={() => openStopModal(row.original as SessionRow)} > {Messages.stop} @@ -223,6 +229,7 @@ const SessionsTable: FC = () => { ) } + {...tableProps} /> Date: Wed, 24 Jun 2026 19:01:23 +0100 Subject: [PATCH 2/8] chore: lint --- ui/apps/pmm/src/hooks/utils/useTableUrlState.ts | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/ui/apps/pmm/src/hooks/utils/useTableUrlState.ts b/ui/apps/pmm/src/hooks/utils/useTableUrlState.ts index 19d8c1b4814..dc11cb37bf6 100644 --- a/ui/apps/pmm/src/hooks/utils/useTableUrlState.ts +++ b/ui/apps/pmm/src/hooks/utils/useTableUrlState.ts @@ -57,7 +57,6 @@ export const useTableUrlState = ( } = tableProps; const columnFiltersKey = stableDependencyKey(columnFilters); - const additionalTableStateKey = stableDependencyKey(additionalTableState); // Keep a stable clone reference while filter values are unchanged. MRT // re-syncs range inputs from column.getFilterValue() whenever that array @@ -109,7 +108,7 @@ export const useTableUrlState = ( pagination, showColumnFilters, showGlobalFilter, - additionalTableStateKey, + additionalTableState, onColumnFiltersChange, onGlobalFilterChange, onSortingChange, From e4eeb57f17d6c2d85052d9a1ff0eb464ce6bcc05 Mon Sep 17 00:00:00 2001 From: Fabio Silva Date: Mon, 6 Jul 2026 15:42:32 +0100 Subject: [PATCH 3/8] chore: simplify code --- .../src/hooks/utils/useTableUrlState.test.ts | 206 +----------------- .../pmm/src/hooks/utils/useTableUrlState.ts | 102 +-------- 2 files changed, 11 insertions(+), 297 deletions(-) diff --git a/ui/apps/pmm/src/hooks/utils/useTableUrlState.test.ts b/ui/apps/pmm/src/hooks/utils/useTableUrlState.test.ts index 50b0e7b8571..f49a7d0a405 100644 --- a/ui/apps/pmm/src/hooks/utils/useTableUrlState.test.ts +++ b/ui/apps/pmm/src/hooks/utils/useTableUrlState.test.ts @@ -1,13 +1,20 @@ import { renderHook } from '@testing-library/react'; import { describe, expect, it, vi } from 'vitest'; -import { usePerconaTableUrlState } from '@percona/percona-ui'; import reactRouter from 'react-router-dom'; -import { cloneColumnFilters, useTableUrlState } from './useTableUrlState'; +import { useTableUrlState } from './useTableUrlState'; -vi.mock('@percona/percona-ui', () => ({ +const { usePerconaTableUrlState } = vi.hoisted(() => ({ usePerconaTableUrlState: vi.fn(), })); +vi.mock('@percona/percona-ui', async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + usePerconaTableUrlState, + }; +}); + const setup = (params: string) => { const searchParams = new URLSearchParams(params); const setSearchParams = vi.fn(); @@ -47,16 +54,6 @@ const setup = (params: string) => { return { searchParams, setSearchParams }; }; -describe('cloneColumnFilters', () => { - it('clones range filter value tuples', () => { - const filters = [{ id: 'queryExecutionDurationMs', value: ['1', ''] }]; - const cloned = cloneColumnFilters(filters); - - expect(cloned).toEqual(filters); - expect(cloned[0].value).not.toBe(filters[0].value); - }); -}); - describe('useTableUrlState', () => { it('passes react-router search params to percona useTableUrlState', () => { const { searchParams, setSearchParams } = setup( @@ -97,187 +94,4 @@ describe('useTableUrlState', () => { sync: { pagination: false }, }); }); - - it('passes cloned range filter values to the table', () => { - const rangeFilters = [{ id: 'queryExecutionDurationMs', value: ['2', ''] }]; - setup(''); - - vi.mocked(usePerconaTableUrlState).mockReturnValue({ - tableState: { - state: { - columnFilters: rangeFilters, - globalFilter: '', - sorting: [], - pagination: { pageIndex: 0, pageSize: 10 }, - }, - onColumnFiltersChange: vi.fn(), - onGlobalFilterChange: vi.fn(), - onSortingChange: vi.fn(), - onPaginationChange: vi.fn(), - }, - tableProps: { - state: { - columnFilters: rangeFilters, - globalFilter: '', - sorting: [], - pagination: { pageIndex: 0, pageSize: 10 }, - showColumnFilters: false, - showGlobalFilter: false, - }, - onColumnFiltersChange: vi.fn(), - onGlobalFilterChange: vi.fn(), - onSortingChange: vi.fn(), - onPaginationChange: vi.fn(), - onShowColumnFiltersChange: vi.fn(), - onShowGlobalFilterChange: vi.fn(), - }, - }); - - const { result } = renderHook(() => useTableUrlState({ paramPrefix: 'overview' })); - - expect(result.current.tableProps.state.columnFilters).toEqual(rangeFilters); - expect(result.current.tableProps.state.columnFilters[0].value).not.toBe( - rangeFilters[0].value - ); - }); - - it('preserves additionalState keys such as rowSelection in tableProps.state', () => { - const rowSelection = { 'session-1': true }; - setup(''); - - vi.mocked(usePerconaTableUrlState).mockReturnValue({ - tableState: { - state: { - columnFilters: [], - globalFilter: '', - sorting: [], - pagination: { pageIndex: 0, pageSize: 10 }, - }, - onColumnFiltersChange: vi.fn(), - onGlobalFilterChange: vi.fn(), - onSortingChange: vi.fn(), - onPaginationChange: vi.fn(), - }, - tableProps: { - state: { - columnFilters: [], - globalFilter: '', - sorting: [], - pagination: { pageIndex: 0, pageSize: 10 }, - showColumnFilters: false, - showGlobalFilter: false, - rowSelection, - }, - onColumnFiltersChange: vi.fn(), - onGlobalFilterChange: vi.fn(), - onSortingChange: vi.fn(), - onPaginationChange: vi.fn(), - onShowColumnFiltersChange: vi.fn(), - onShowGlobalFilterChange: vi.fn(), - }, - }); - - const { result } = renderHook(() => useTableUrlState({ paramPrefix: 'sessions' })); - - expect(result.current.tableProps.state.rowSelection).toEqual(rowSelection); - }); - - it('updates rowSelection in tableProps.state when additionalState changes', () => { - setup(''); - - const baseReturn = { - tableState: { - state: { - columnFilters: [], - globalFilter: '', - sorting: [], - pagination: { pageIndex: 0, pageSize: 10 }, - }, - onColumnFiltersChange: vi.fn(), - onGlobalFilterChange: vi.fn(), - onSortingChange: vi.fn(), - onPaginationChange: vi.fn(), - }, - tableProps: { - state: { - columnFilters: [], - globalFilter: '', - sorting: [], - pagination: { pageIndex: 0, pageSize: 10 }, - showColumnFilters: false, - showGlobalFilter: false, - rowSelection: {} as Record, - }, - onColumnFiltersChange: vi.fn(), - onGlobalFilterChange: vi.fn(), - onSortingChange: vi.fn(), - onPaginationChange: vi.fn(), - onShowColumnFiltersChange: vi.fn(), - onShowGlobalFilterChange: vi.fn(), - }, - }; - - vi.mocked(usePerconaTableUrlState).mockReturnValue(baseReturn); - - const { result, rerender } = renderHook(() => useTableUrlState({ paramPrefix: 'sessions' })); - - expect(result.current.tableProps.state.rowSelection).toEqual({}); - - vi.mocked(usePerconaTableUrlState).mockReturnValue({ - ...baseReturn, - tableProps: { - ...baseReturn.tableProps, - state: { - ...baseReturn.tableProps.state, - rowSelection: { 'session-1': true }, - }, - }, - }); - rerender(); - - expect(result.current.tableProps.state.rowSelection).toEqual({ 'session-1': true }); - }); - - it('keeps the same columnFilters reference when filter values are unchanged', () => { - const rangeFilters = [{ id: 'queryExecutionDurationMs', value: ['2', ''] }]; - setup(''); - - vi.mocked(usePerconaTableUrlState).mockReturnValue({ - tableState: { - state: { - columnFilters: rangeFilters, - globalFilter: '', - sorting: [], - pagination: { pageIndex: 0, pageSize: 10 }, - }, - onColumnFiltersChange: vi.fn(), - onGlobalFilterChange: vi.fn(), - onSortingChange: vi.fn(), - onPaginationChange: vi.fn(), - }, - tableProps: { - state: { - columnFilters: rangeFilters, - globalFilter: '', - sorting: [], - pagination: { pageIndex: 0, pageSize: 10 }, - showColumnFilters: false, - showGlobalFilter: false, - }, - onColumnFiltersChange: vi.fn(), - onGlobalFilterChange: vi.fn(), - onSortingChange: vi.fn(), - onPaginationChange: vi.fn(), - onShowColumnFiltersChange: vi.fn(), - onShowGlobalFilterChange: vi.fn(), - }, - }); - - const { result, rerender } = renderHook(() => useTableUrlState({ paramPrefix: 'overview' })); - const firstReference = result.current.tableProps.state.columnFilters; - - rerender(); - - expect(result.current.tableProps.state.columnFilters).toBe(firstReference); - }); }); diff --git a/ui/apps/pmm/src/hooks/utils/useTableUrlState.ts b/ui/apps/pmm/src/hooks/utils/useTableUrlState.ts index dc11cb37bf6..42a2ed72886 100644 --- a/ui/apps/pmm/src/hooks/utils/useTableUrlState.ts +++ b/ui/apps/pmm/src/hooks/utils/useTableUrlState.ts @@ -1,10 +1,6 @@ -import { useCallback, useMemo } from 'react'; import { useSearchParams } from 'react-router-dom'; import { - stableDependencyKey, usePerconaTableUrlState, - type MRT_ColumnFiltersState, - type MRT_Updater, type UsePerconaTableUrlStateOptions, type UsePerconaTableUrlStateResult, } from '@percona/percona-ui'; @@ -16,110 +12,14 @@ export type UseTableUrlStateOptions = Omit< export type UseTableUrlStateResult = UsePerconaTableUrlStateResult; -// MRT range filters mutate filter value arrays in place. With controlled URL -// state that can mutate React state before setState runs, so isSameTableState -// sees no change and the table skips re-filtering until another filter updates. -export const cloneColumnFilters = ( - filters: MRT_ColumnFiltersState -): MRT_ColumnFiltersState => - filters.map(({ id, value }) => ({ - id, - value: Array.isArray(value) ? [...value] : value, - })); - export const useTableUrlState = ( options: UseTableUrlStateOptions = {} ): UseTableUrlStateResult => { const [searchParams, setSearchParams] = useSearchParams(); - const { tableState, tableProps } = usePerconaTableUrlState({ + return usePerconaTableUrlState({ searchParams, setSearchParams, ...options, }); - - const { - state: { - columnFilters, - globalFilter, - sorting, - pagination, - showColumnFilters, - showGlobalFilter, - ...additionalTableState - }, - onGlobalFilterChange, - onSortingChange, - onPaginationChange, - onShowColumnFiltersChange, - onShowGlobalFilterChange, - onColumnFiltersChange: onPerconaColumnFiltersChange, - } = tableProps; - - const columnFiltersKey = stableDependencyKey(columnFilters); - - // Keep a stable clone reference while filter values are unchanged. MRT - // re-syncs range inputs from column.getFilterValue() whenever that array - // reference changes, which causes visible flicker during typing/refetches. - const columnFiltersForTable = useMemo( - () => cloneColumnFilters(columnFilters), - // columnFiltersKey tracks columnFilters by value so the clone reference stays - // stable across parent re-renders (e.g. query refetches) with unchanged filters. - // eslint-disable-next-line react-hooks/exhaustive-deps - [columnFiltersKey] - ); - - const onColumnFiltersChange = useCallback( - (updater: MRT_Updater) => { - onPerconaColumnFiltersChange((prev) => { - const resolved = - updater instanceof Function - ? updater(cloneColumnFilters(prev)) - : updater; - - return cloneColumnFilters(resolved); - }); - }, - [onPerconaColumnFiltersChange] - ); - - const tablePropsWithImmutableFilters = useMemo( - () => ({ - state: { - columnFilters: columnFiltersForTable, - globalFilter, - sorting, - pagination, - showColumnFilters, - showGlobalFilter, - ...additionalTableState, - }, - onColumnFiltersChange, - onGlobalFilterChange, - onSortingChange, - onPaginationChange, - onShowColumnFiltersChange, - onShowGlobalFilterChange, - }), - [ - columnFiltersForTable, - globalFilter, - sorting, - pagination, - showColumnFilters, - showGlobalFilter, - additionalTableState, - onColumnFiltersChange, - onGlobalFilterChange, - onSortingChange, - onPaginationChange, - onShowColumnFiltersChange, - onShowGlobalFilterChange, - ] - ); - - return { - tableState, - tableProps: tablePropsWithImmutableFilters, - }; }; From a5ef9be53536dc243904a721d00f495f7a5dceff Mon Sep 17 00:00:00 2001 From: Fabio Silva Date: Tue, 7 Jul 2026 12:25:21 +0100 Subject: [PATCH 4/8] chore: update @percona/percona-ui --- ui/apps/pmm/package.json | 2 +- ui/yarn.lock | 43 ++++++++++++++++++++++++---------------- 2 files changed, 27 insertions(+), 18 deletions(-) diff --git a/ui/apps/pmm/package.json b/ui/apps/pmm/package.json index 039f46442bf..b97aeab48f4 100644 --- a/ui/apps/pmm/package.json +++ b/ui/apps/pmm/package.json @@ -26,7 +26,7 @@ "@mui/icons-material": "^7.3.7", "@mui/material": "^7.3.7", "@mui/x-date-pickers": "^7.5.0", - "@percona/percona-ui": "1.0.22", + "@percona/percona-ui": "1.0.23", "@pmm/shared": "*", "@reactour/tour": "^3.8.0", "@tanstack/react-query": "^5.45.1", diff --git a/ui/yarn.lock b/ui/yarn.lock index e3a6496636d..816058b6aa4 100644 --- a/ui/yarn.lock +++ b/ui/yarn.lock @@ -1620,15 +1620,16 @@ resolved "https://registry.yarnpkg.com/@percona/eslint-config-react/-/eslint-config-react-1.1.0.tgz#db69fcf5d6bb43e6c842f462e2c1c84d5f3eb281" integrity sha512-Tk9mNYslXrxW/gdS/rzxHbLuEXoNCw8r2t/pB8CXZjBl46VLMGspljbyI6525uNNfyO5H0iJQKCJu0Ckf+5erA== -"@percona/percona-ui@1.0.22": - version "1.0.22" - resolved "https://registry.yarnpkg.com/@percona/percona-ui/-/percona-ui-1.0.22.tgz#e9adf3cf7ff91ba11d2c25cf338816d7ea8fccfd" - integrity sha512-9sTo4J+H9zlGJMT6cKWiK3kZbD4KfrzKn0UFOWjNqMSXwNGWp8/vwls2KMeN9FJDTJfUhYg0fLxWU5SRAO4Crg== +"@percona/percona-ui@1.0.23": + version "1.0.23" + resolved "https://registry.yarnpkg.com/@percona/percona-ui/-/percona-ui-1.0.23.tgz#aa390867cfa1a002924982822462ba688bd18094" + integrity sha512-C2DGyxLJrPdSLDK7YtxFA6UfwPgxfmeQhx+UKyZnAPbOO/mYgKi3RLxFJhZdYCpnb6efrYKIAyqBXpfxCjFshg== dependencies: "@fontsource/poppins" "^5.2.7" "@fontsource/roboto" "^5.2.9" "@fontsource/roboto-mono" "^5.2.8" material-react-table "^3.2.1" + prism-react-renderer "^2.4.1" react-is "18.3.1" "@percona/prettier-config@^1.1.0": @@ -2825,7 +2826,7 @@ resolved "https://registry.yarnpkg.com/@types/parse-json/-/parse-json-4.0.2.tgz#5950e50960793055845e956c427fc2b0d70c5239" integrity sha512-dISoDXWWQwUquiKsyZ4Ng+HX2KsPL7LyHKHQwgGFEA3IaKac4Obd+h2a/a6waisAoepJlBcx9paWqjA8/HVjCw== -"@types/prismjs@^1.0.0": +"@types/prismjs@^1.0.0", "@types/prismjs@^1.26.0": version "1.26.6" resolved "https://registry.yarnpkg.com/@types/prismjs/-/prismjs-1.26.6.tgz#6ea27c126d645319ae4f7055eda63a9e835c0187" integrity sha512-vqlvI7qlMvcCBbVe0AKAb4f97//Hy0EBTaiW8AalRnG/xAN5zOiWWyrNqNXeq8+KAuvRewjCVY1+IPxk4RdNYw== @@ -6450,9 +6451,9 @@ ignore@^5.2.0, ignore@^5.2.4, ignore@^5.3.1: integrity sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g== immutable@5.1.4, immutable@^4.0.0, immutable@^5.1.5: - version "5.1.6" - resolved "https://registry.yarnpkg.com/immutable/-/immutable-5.1.6.tgz#21639bc80f9a0713e141a5f5a154ef9fdabf36dd" - integrity sha512-q1swsS8K7L8usSHuOqF2TAoCCkonYz0SG38wLAggaa4Wml70zixIvt2ql4coQ2C2B3hTjltJry4r6bULwgAXLQ== + version "5.1.9" + resolved "https://registry.yarnpkg.com/immutable/-/immutable-5.1.9.tgz#ac23c3a01992ab665e14ac9ffff298f28cd74a0c" + integrity sha512-m8nVez3rwrgmWxtLMt1ZYXB2Lv7OKYn/disyxAlSDYAlKSlFoPPfIAmAM/M5xqL4m4C/wAPw7S2/CNaUii1Hxg== import-cwd@^3.0.0: version "3.0.0" @@ -8385,9 +8386,9 @@ nano-css@^5.6.2: stylis "^4.3.0" nanoid@^3.3.11, nanoid@~3.3.8: - version "3.3.12" - resolved "https://registry.yarnpkg.com/nanoid/-/nanoid-3.3.12.tgz#ab3d912e217a6d0a514f00a72a16543a28982c05" - integrity sha512-ZB9RH/39qpq5Vu6Y+NmUaFhQR6pp+M2Xt76XBnEwDaGcVAqhlvxrl3B2bKS5D3NH3QR76v3aSrKaF/Kiy7lEtQ== + version "3.3.15" + resolved "https://registry.yarnpkg.com/nanoid/-/nanoid-3.3.15.tgz#36c490fad8c6e86c824c940dfdde999b69ed4316" + integrity sha512-y7Wygv/7mEOvxTuEQDB8StXdMRBWf1kR/tlhAzBRUFkB2jfcLOAxO/SHmOO2zgz1pVgK29/kyupn059/bCHdjA== natural-compare@^1.4.0: version "1.4.0" @@ -8749,9 +8750,9 @@ picocolors@1.1.1, picocolors@^1.0.0, picocolors@^1.1.1: integrity sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA== picomatch@^2.0.4, picomatch@^2.2.1, picomatch@^2.2.3, picomatch@^2.3.1, picomatch@^4.0.2, picomatch@^4.0.3, picomatch@^4.0.4: - version "4.0.4" - resolved "https://registry.yarnpkg.com/picomatch/-/picomatch-4.0.4.tgz#fd6f5e00a143086e074dffe4c924b8fb293b0589" - integrity sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A== + version "4.0.5" + resolved "https://registry.yarnpkg.com/picomatch/-/picomatch-4.0.5.tgz#51ea57a17d86f605f81039595fbc40ed06a55fab" + integrity sha512-RvwwcruNjI1ncT5xRakeyS9Lf8lcItv34KD+aif+VH9kduAyfYBipGh12274xtenIPZ119/R9BdTBa8gAwSh0A== pify@^5.0.0: version "5.0.0" @@ -9107,6 +9108,14 @@ pretty-format@^29.0.0, pretty-format@^29.7.0: ansi-styles "^5.0.0" react-is "^18.0.0" +prism-react-renderer@^2.4.1: + version "2.4.1" + resolved "https://registry.yarnpkg.com/prism-react-renderer/-/prism-react-renderer-2.4.1.tgz#ac63b7f78e56c8f2b5e76e823a976d5ede77e35f" + integrity sha512-ey8Ls/+Di31eqzUxC46h8MksNuGx/n0AAC8uKpwFau4RPDYLuE3EXTp8N8G2vX2N7UC/+IXeNUnlWBGGcAG+Ig== + dependencies: + "@types/prismjs" "^1.26.0" + clsx "^2.0.0" + prismjs@1.30.0, prismjs@^1.30.0: version "1.30.0" resolved "https://registry.yarnpkg.com/prismjs/-/prismjs-1.30.0.tgz#d9709969d9d4e16403f6f348c63553b19f0975a9" @@ -9978,9 +9987,9 @@ semver@^7.7.4: integrity sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA== serialize-javascript@^6.0.0, serialize-javascript@^6.0.1, serialize-javascript@^6.0.2, serialize-javascript@^7.0.3: - version "7.0.6" - resolved "https://registry.yarnpkg.com/serialize-javascript/-/serialize-javascript-7.0.6.tgz#f2f20c8af0757e4d8fa329d0210636da0682ddef" - integrity sha512-ATTK5Q4gFVg0YDp1my2vqygyvhcklD/UV5GIlYHooGTn/NogJqIzpetkD6E5kmuVULqz/S9inUL25XcAgDRJQg== + version "7.0.7" + resolved "https://registry.yarnpkg.com/serialize-javascript/-/serialize-javascript-7.0.7.tgz#06ec40576d4cea96d68010a534520bff1f948a72" + integrity sha512-YAy8Od6KV+uuwUuU50np8fGB/Aues6Y0nAhA9y/hId74PlKUcme4pXcBD46NWKr1Q4osN/iseZ17YqO1XfmI8g== set-function-length@^1.2.2: version "1.2.2" From 1c25d84d2f43205fa08a2c345dcddfc8418c8733 Mon Sep 17 00:00:00 2001 From: Fabio Silva Date: Tue, 7 Jul 2026 12:30:36 +0100 Subject: [PATCH 5/8] chore: remove duplicate const --- ui/apps/pmm/src/utils/testStubs.ts | 13 ------------- 1 file changed, 13 deletions(-) diff --git a/ui/apps/pmm/src/utils/testStubs.ts b/ui/apps/pmm/src/utils/testStubs.ts index 7b5b9156dc2..88cf481ecd2 100644 --- a/ui/apps/pmm/src/utils/testStubs.ts +++ b/ui/apps/pmm/src/utils/testStubs.ts @@ -179,16 +179,3 @@ export const TEST_MONGO_DB_QUERY_DATA: QueryData = { username: 'username', }, }; - -export const TEST_USER_ANONYMOUS: User = { - ...TEST_USER_VIEWER, - id: 0, - login: 'anonymous', - name: 'Anonymous', - isAnonymous: true, - isPMMAdmin: false, - info: { - ...TEST_USER_ADMIN.info, - userId: 0, - }, -}; From c4a58e13c586f6b33e8666f694020ae89a4bbfb3 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Mon, 13 Jul 2026 09:31:57 +0000 Subject: [PATCH 6/8] PMM-14848 move sessions URL state constant MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: Fábio Silva --- .../rta/sessions/sessions-table/SessionsTable.constants.ts | 3 +++ .../pages/rta/sessions/sessions-table/SessionsTable.tsx | 7 ++----- 2 files changed, 5 insertions(+), 5 deletions(-) create mode 100644 ui/apps/pmm/src/pages/rta/sessions/sessions-table/SessionsTable.constants.ts diff --git a/ui/apps/pmm/src/pages/rta/sessions/sessions-table/SessionsTable.constants.ts b/ui/apps/pmm/src/pages/rta/sessions/sessions-table/SessionsTable.constants.ts new file mode 100644 index 00000000000..f3d27c66dee --- /dev/null +++ b/ui/apps/pmm/src/pages/rta/sessions/sessions-table/SessionsTable.constants.ts @@ -0,0 +1,3 @@ +export const SESSIONS_TABLE_URL_STATE_OPTIONS = { + paramPrefix: 'sessions', +}; diff --git a/ui/apps/pmm/src/pages/rta/sessions/sessions-table/SessionsTable.tsx b/ui/apps/pmm/src/pages/rta/sessions/sessions-table/SessionsTable.tsx index deaca7b1b82..9c5fd1a7083 100644 --- a/ui/apps/pmm/src/pages/rta/sessions/sessions-table/SessionsTable.tsx +++ b/ui/apps/pmm/src/pages/rta/sessions/sessions-table/SessionsTable.tsx @@ -6,7 +6,8 @@ import StopCircleOutlinedIcon from '@mui/icons-material/StopCircleOutlined'; import AddOutlinedIcon from '@mui/icons-material/AddOutlined'; import { Table } from '@percona/percona-ui'; import { boxClasses, Skeleton, Typography } from '@mui/material'; -import { SESSIONS_TABLE_COLUMNS } from './SessionsTable.constants'; +import { SESSIONS_TABLE_URL_STATE_OPTIONS } from './SessionsTable.constants.ts'; +import { SESSIONS_TABLE_COLUMNS } from './SessionsTable.constants.tsx'; import { useRealtimeSessions, useStopSessions } from 'hooks/api/useRealtime'; import { getAllSessions, @@ -23,10 +24,6 @@ import { useUser } from 'contexts/user'; import { Navigate } from 'react-router-dom'; import { useTableUrlState } from 'hooks/utils/useTableUrlState'; -const SESSIONS_TABLE_URL_STATE_OPTIONS = { - paramPrefix: 'sessions', -}; - const SessionsTable: FC = () => { const { user } = useUser(); const { data: sessions = [], isLoading } = useRealtimeSessions({ From c119f2293fc86fa4a499791210d13696c233d287 Mon Sep 17 00:00:00 2001 From: Fabio Silva Date: Mon, 13 Jul 2026 10:42:27 +0100 Subject: [PATCH 7/8] chore: format --- .../pmm/src/hooks/utils/useTableUrlState.test.ts | 5 ++++- .../pages/rta/overview/table/OverviewTable.tsx | 15 +++++++++------ 2 files changed, 13 insertions(+), 7 deletions(-) diff --git a/ui/apps/pmm/src/hooks/utils/useTableUrlState.test.ts b/ui/apps/pmm/src/hooks/utils/useTableUrlState.test.ts index f49a7d0a405..782d29f9995 100644 --- a/ui/apps/pmm/src/hooks/utils/useTableUrlState.test.ts +++ b/ui/apps/pmm/src/hooks/utils/useTableUrlState.test.ts @@ -19,7 +19,10 @@ const setup = (params: string) => { const searchParams = new URLSearchParams(params); const setSearchParams = vi.fn(); - vi.spyOn(reactRouter, 'useSearchParams').mockReturnValue([searchParams, setSearchParams]); + vi.spyOn(reactRouter, 'useSearchParams').mockReturnValue([ + searchParams, + setSearchParams, + ]); vi.mocked(usePerconaTableUrlState).mockReturnValue({ tableState: { state: { 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 619f707326b..e37ca0ed963 100644 --- a/ui/apps/pmm/src/pages/rta/overview/table/OverviewTable.tsx +++ b/ui/apps/pmm/src/pages/rta/overview/table/OverviewTable.tsx @@ -13,7 +13,7 @@ const OVERVIEW_TABLE_URL_STATE_OPTIONS = { paramPrefix: 'overview', defaults: { pagination: { pageIndex: 0, pageSize: 25 }, - } + }, }; interface Props { @@ -31,11 +31,14 @@ const OverviewTable: FC = ({ actions, onRowHover, }) => { - const { tableProps: navigableTableProps, refresh } = useNavigableRows({ - data: queries, - onChange: onNavigableQueriesChange, - }); - const { tableProps: urlStateTableProps } = useTableUrlState(OVERVIEW_TABLE_URL_STATE_OPTIONS); + const { tableProps: navigableTableProps, refresh } = + useNavigableRows({ + data: queries, + onChange: onNavigableQueriesChange, + }); + const { tableProps: urlStateTableProps } = useTableUrlState( + OVERVIEW_TABLE_URL_STATE_OPTIONS + ); return ( From 7e08bec4ec5ecd3e41f543bbd7468771103d2c39 Mon Sep 17 00:00:00 2001 From: Fabio Silva Date: Thu, 30 Jul 2026 15:30:45 +0100 Subject: [PATCH 8/8] fix: allow search on QAN while typing --- .../components/Search/Search.test.tsx | 64 ++++++++++++++++++- .../Overview/components/Search/Search.tsx | 40 ++++++++++-- .../pmm-qan/panel/provider/provider.tools.ts | 7 +- 3 files changed, 102 insertions(+), 9 deletions(-) diff --git a/dashboards/pmm-app/src/pmm-qan/panel/components/Overview/components/Search/Search.test.tsx b/dashboards/pmm-app/src/pmm-qan/panel/components/Overview/components/Search/Search.test.tsx index 8e264311abd..d49be059ee4 100644 --- a/dashboards/pmm-app/src/pmm-qan/panel/components/Overview/components/Search/Search.test.tsx +++ b/dashboards/pmm-app/src/pmm-qan/panel/components/Overview/components/Search/Search.test.tsx @@ -1,19 +1,30 @@ import React from 'react'; -import { render, fireEvent } from '@testing-library/react'; +import { act, fireEvent, render } from '@testing-library/react'; import { Search } from './Search'; describe('Search::', () => { + beforeEach(() => { + jest.useFakeTimers(); + }); + + afterEach(() => { + jest.runOnlyPendingTimers(); + jest.useRealTimers(); + }); + it('renders correctly', () => { const { container } = render( {}} />); const form = container.querySelector('form'); expect(form?.children.length).toBe(2); }); + it('renders correctly with initial value', () => { const { container } = render( {}} initialValue="Test value" />); expect(container.querySelector('input')?.value).toEqual('Test value'); }); + it('submits correctly', () => { const handleSearch = jest.fn(); const { container } = render(); @@ -26,4 +37,55 @@ describe('Search::', () => { expect(handleSearch).toHaveBeenCalled(); }); + + it('searches after debounce when the input changes', () => { + const handleSearch = jest.fn(); + const { container } = render(); + const input = container.querySelector('input'); + + fireEvent.change(input!, { target: { value: 'select' } }); + + expect(handleSearch).not.toHaveBeenCalled(); + + act(() => { + jest.advanceTimersByTime(300); + }); + + expect(handleSearch).toHaveBeenCalledWith({ search: 'select' }); + }); + + it('clears search when the input is emptied', () => { + const handleSearch = jest.fn(); + const { container } = render( + , + ); + const input = container.querySelector('input'); + + fireEvent.change(input!, { target: { value: '' } }); + + act(() => { + jest.advanceTimersByTime(300); + }); + + expect(handleSearch).toHaveBeenCalledWith({ search: '' }); + }); + + it('submits immediately and cancels pending debounced search', () => { + const handleSearch = jest.fn(); + const { container } = render(); + const input = container.querySelector('input'); + const form = container.querySelector('form'); + + fireEvent.change(input!, { target: { value: 'pending' } }); + fireEvent.submit(form!); + + expect(handleSearch).toHaveBeenCalledTimes(1); + expect(handleSearch).toHaveBeenCalledWith({ search: 'pending' }); + + act(() => { + jest.advanceTimersByTime(300); + }); + + expect(handleSearch).toHaveBeenCalledTimes(1); + }); }); diff --git a/dashboards/pmm-app/src/pmm-qan/panel/components/Overview/components/Search/Search.tsx b/dashboards/pmm-app/src/pmm-qan/panel/components/Overview/components/Search/Search.tsx index c60eb37eb53..01ba6c261b5 100644 --- a/dashboards/pmm-app/src/pmm-qan/panel/components/Overview/components/Search/Search.tsx +++ b/dashboards/pmm-app/src/pmm-qan/panel/components/Overview/components/Search/Search.tsx @@ -1,11 +1,16 @@ -import React, { FC } from 'react'; +import React, { + FC, useEffect, useMemo, useRef, +} from 'react'; import { Field, Form } from 'react-final-form'; import { Button, Icon, Input, useTheme, } from '@grafana/ui'; +import { debounce } from 'lodash'; import { Messages } from 'pmm-qan/panel/QueryAnalytics.messages'; import { getStyles } from './Search.styles'; -import { SearchProps } from './Search.types'; +import { SearchProps, SearchValues } from './Search.types'; + +const SEARCH_DEBOUNCE_MS = 300; export const Search: FC = ({ dataTestId, initialValue, handleSearch }) => { const theme = useTheme(); @@ -13,17 +18,44 @@ export const Search: FC = ({ dataTestId, initialValue, handleSearch const { search: { placeholder }, } = Messages; + const handleSearchRef = useRef(handleSearch); + + handleSearchRef.current = handleSearch; + + const debouncedSearch = useMemo( + () => debounce((search: string) => { + handleSearchRef.current({ search }); + }, SEARCH_DEBOUNCE_MS), + [], + ); + + useEffect(() => () => { + debouncedSearch.cancel(); + }, [debouncedSearch]); + + const onSubmit = (values: SearchValues) => { + debouncedSearch.cancel(); + handleSearch(values); + }; return (
( ( - + { + input.onChange(event); + debouncedSearch(event.currentTarget.value); + }} + /> )} />