Logs in Explore: Persist table sorting in the url (#114060)

This commit is contained in:
Liza Detrick
2025-12-03 08:03:36 -08:00
committed by GitHub
parent 157cab192c
commit c847f1fa4b
5 changed files with 119 additions and 6 deletions
@@ -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<T extends AnyQuery = AnyQuery> {
@@ -308,6 +308,8 @@ const UnthemedLogs: React.FunctionComponent<Props> = (props: Props) => {
refId: undefined,
displayedFields: undefined,
sortOrder: undefined,
tableSortBy: undefined,
tableSortDir: undefined,
})
);
});
@@ -324,6 +326,8 @@ const UnthemedLogs: React.FunctionComponent<Props> = (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: Props) => {
panelState?.logs?.columns,
panelState?.logs?.displayedFields,
panelState?.logs?.refId,
panelState?.logs?.tableSortBy,
panelState?.logs?.tableSortDir,
visualisationType,
]
);
@@ -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');
}
});
});
});
+26 -5
View File
@@ -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<DataFrame | undefined>(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 (
<Table
data={tableFrame}
@@ -174,9 +196,8 @@ export function LogsTable(props: Props) {
onCellFilterAdded={props.onClickFilterLabel && props.onClickFilterOutLabel ? onCellFilterAdded : undefined}
height={props.height}
footerOptions={{ show: true, reducer: ['count'], countRows: true }}
initialSortBy={[
{ displayName: logsFrame?.timeField.name || '', desc: logsSortOrder === LogsSortOrder.Descending },
]}
initialSortBy={initialSortBy}
onSortByChange={onSortByChange}
/>
);
}
@@ -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}
/>
</div>
</div>