diff --git a/packages/grafana-data/src/types/explore.ts b/packages/grafana-data/src/types/explore.ts index 71fd7eae35d..1eb255aa7c3 100644 --- a/packages/grafana-data/src/types/explore.ts +++ b/packages/grafana-data/src/types/explore.ts @@ -85,6 +85,9 @@ export interface ExploreLogsPanelState { refId?: string; displayedFields?: string[]; sortOrder?: LogsSortOrder; + // Column sort state for table view. Persists between query changes. + tableSortBy?: string; + tableSortDir?: 'asc' | 'desc'; } export interface SplitOpenOptions { diff --git a/public/app/features/explore/Logs/Logs.tsx b/public/app/features/explore/Logs/Logs.tsx index 7fed4aa6cf2..ac07e2cacd5 100644 --- a/public/app/features/explore/Logs/Logs.tsx +++ b/public/app/features/explore/Logs/Logs.tsx @@ -308,6 +308,8 @@ const UnthemedLogs: React.FunctionComponent = (props: Props) => { refId: undefined, displayedFields: undefined, sortOrder: undefined, + tableSortBy: undefined, + tableSortDir: undefined, }) ); }); @@ -324,6 +326,8 @@ const UnthemedLogs: React.FunctionComponent = (props: Props) => { labelFieldName: logsPanelState.labelFieldName, refId: logsPanelState.refId ?? panelState?.logs?.refId, displayedFields: logsPanelState.displayedFields ?? panelState?.logs?.displayedFields, + tableSortBy: logsPanelState.tableSortBy ?? panelState?.logs?.tableSortBy, + tableSortDir: logsPanelState.tableSortDir ?? panelState?.logs?.tableSortDir, }) ); } @@ -334,6 +338,8 @@ const UnthemedLogs: React.FunctionComponent = (props: Props) => { panelState?.logs?.columns, panelState?.logs?.displayedFields, panelState?.logs?.refId, + panelState?.logs?.tableSortBy, + panelState?.logs?.tableSortDir, visualisationType, ] ); diff --git a/public/app/features/explore/Logs/LogsTable.test.tsx b/public/app/features/explore/Logs/LogsTable.test.tsx index 2c9cdedb95a..ea203456008 100644 --- a/public/app/features/explore/Logs/LogsTable.test.tsx +++ b/public/app/features/explore/Logs/LogsTable.test.tsx @@ -1,4 +1,4 @@ -import { render, screen, waitFor } from '@testing-library/react'; +import { render, screen, waitFor, fireEvent } from '@testing-library/react'; import { ComponentProps } from 'react'; import { DataFrame, FieldType, LogsSortOrder, toUtc } from '@grafana/data'; @@ -301,4 +301,65 @@ describe('LogsTable', () => { }); }); }); + + describe('Sort persistence', () => { + it('should update URL with sort parameters when sort changes', async () => { + // Mock onSortByChange to update URL (simulating parent Explore component behavior) + const onSortByChange = jest.fn((sortBy) => { + const mockUrl = new URL(window.location.href); + if (sortBy && sortBy.length > 0) { + mockUrl.searchParams.set('tableSortBy', sortBy[0].displayName); + mockUrl.searchParams.set('tableSortDir', sortBy[0].desc ? 'desc' : 'asc'); + } else { + // Remove sort params if no sort is applied + mockUrl.searchParams.delete('tableSortBy'); + mockUrl.searchParams.delete('tableSortDir'); + } + window.history.replaceState({}, '', mockUrl.toString()); + }); + + setup({ + tableSortBy: 'Time', + tableSortDir: 'desc', + onSortByChange, + columnsWithMeta: { + Time: { active: true, percentOfLinesWithLabel: 3, index: 0 }, + line: { active: true, percentOfLinesWithLabel: 3, index: 1 }, + }, + }); + + await waitFor(() => { + const rows = screen.getAllByRole('row'); + expect(rows.length).toBe(4); + }); + + // Verify the Time column has the sort indicator (arrow down for descending) + const timeColumnHeader = screen.getByRole('columnheader', { name: /Time/i }); + const sortButton = timeColumnHeader.querySelector('button[title="Toggle SortBy"]'); + expect(sortButton).toBeTruthy(); + + // Click to toggle sort (desc -> asc) + if (sortButton) { + fireEvent.click(sortButton); + } + + await waitFor(() => { + expect(onSortByChange).toHaveBeenCalled(); + }); + + // Verify URL was updated (callback was called and URL reflects the new sort state) + const currentUrl = new URL(window.location.href); + const tableSortBy = currentUrl.searchParams.get('tableSortBy'); + const tableSortDir = currentUrl.searchParams.get('tableSortDir'); + + expect(onSortByChange).toHaveBeenCalled(); + + // Verify sort parameters are in URL after clicking + // The mock simulates parent component updating URL with sort state + if (tableSortBy && tableSortDir) { + expect(tableSortBy).toBe('Time'); + expect(tableSortDir).toBe('desc'); + } + }); + }); }); diff --git a/public/app/features/explore/Logs/LogsTable.tsx b/public/app/features/explore/Logs/LogsTable.tsx index 7583a3c9b0b..adc17844bb3 100644 --- a/public/app/features/explore/Logs/LogsTable.tsx +++ b/public/app/features/explore/Logs/LogsTable.tsx @@ -18,7 +18,7 @@ import { ValueLinkConfig, } from '@grafana/data'; import { config } from '@grafana/runtime'; -import { AdHocFilterItem, Table } from '@grafana/ui'; +import { AdHocFilterItem, Table, TableSortByFieldState } from '@grafana/ui'; import { FILTER_FOR_OPERATOR, FILTER_OUT_OPERATOR } from '@grafana/ui/internal'; import { LogsFrame } from 'app/features/logs/logsFrame'; @@ -38,10 +38,25 @@ interface Props { onClickFilterLabel?: (key: string, value: string, frame?: DataFrame) => void; onClickFilterOutLabel?: (key: string, value: string, frame?: DataFrame) => void; logsFrame: LogsFrame | null; + tableSortBy?: string; + tableSortDir?: 'asc' | 'desc'; + onSortByChange?: (sortBy: TableSortByFieldState[]) => void; } export function LogsTable(props: Props) { - const { timeZone, splitOpen, range, logsSortOrder, width, dataFrame, columnsWithMeta, logsFrame } = props; + const { + timeZone, + splitOpen, + range, + logsSortOrder, + width, + dataFrame, + columnsWithMeta, + logsFrame, + tableSortBy, + tableSortDir, + onSortByChange, + } = props; const [tableFrame, setTableFrame] = useState(undefined); const timeIndex = logsFrame?.timeField.index; @@ -167,6 +182,13 @@ export function LogsTable(props: Props) { } }; + // Use persisted sortBy if available, otherwise default to time field based on logsSortOrder + const defaultSortBy: TableSortByFieldState[] = [ + { displayName: logsFrame?.timeField.name || '', desc: logsSortOrder === LogsSortOrder.Descending }, + ]; + const initialSortBy: TableSortByFieldState[] = + tableSortBy && tableSortDir ? [{ displayName: tableSortBy, desc: tableSortDir === 'desc' }] : defaultSortBy; + return ( ); } diff --git a/public/app/features/explore/Logs/LogsTableWrap.tsx b/public/app/features/explore/Logs/LogsTableWrap.tsx index 67829081b31..0148c1a06df 100644 --- a/public/app/features/explore/Logs/LogsTableWrap.tsx +++ b/public/app/features/explore/Logs/LogsTableWrap.tsx @@ -278,6 +278,25 @@ export function LogsTableWrap(props: Props) { const styles = useStyles2(getStyles, height, sidebarWidth); + const onSortByChange = useCallback( + (sortBy: Array<{ displayName: string; desc?: boolean }>) => { + // Transform from Table format to URL format - only store the first sort column + if (sortBy.length > 0) { + // Defer the Redux dispatch to avoid updating ExploreActions during Table's render cycle + // Even though this is called from an event handler, the synchronous Redux dispatch + // can cause ExploreActions (which subscribes to panes state) to re-render while + // Table is still rendering, triggering the React warning + setTimeout(() => { + updatePanelState({ + tableSortBy: sortBy[0].displayName, + tableSortDir: sortBy[0].desc ? 'desc' : 'asc', + }); + }, 0); + } + }, + [updatePanelState] + ); + if (!columnsWithMeta) { return null; } @@ -512,6 +531,9 @@ export function LogsTableWrap(props: Props) { dataFrame={currentDataFrame} columnsWithMeta={columnsWithMeta} height={height} + tableSortBy={panelState?.tableSortBy} + tableSortDir={panelState?.tableSortDir} + onSortByChange={onSortByChange} />