TableNG: Support managed sortBy (#117953)

* feat(TableNG): support managed sortBy
This commit is contained in:
Galen Kistler 2026-02-13 07:14:25 -06:00 committed by GitHub
parent 65c290a12f
commit cb140a5e62
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
13 changed files with 375 additions and 37 deletions

View file

@ -155,7 +155,7 @@ test.describe('Panels test: LogsTable', { tag: ['@panels', '@logstable'] }, () =
page.getByLabel('Drawer title Inspect value').locator('.view-lines'),
'Drawer contains correct log line'
).toContainText(
`level=info ts=2026-02-06T18:42:42.083508023Z caller=flush.go:253 msg="completing block" userid=29 blockID=73zco`
`level=info ts=2026-02-06T18:42:46.211051027Z caller=poller.go:133 msg="blocklist poll complete" seconds=526`
);
});
});

View file

@ -394,7 +394,7 @@ describe('TableNG', () => {
async (desc) => {
const { container } = render(
<TableNG
initialSortBy={[
sortBy={[
{
displayName: 'Category',
desc,
@ -418,6 +418,104 @@ describe('TableNG', () => {
);
});
describe('TableNG::sortBy', () => {
it.each([true, false])('should set initial sort', async (desc) => {
render(
<TableNG
sortBy={[
{
displayName: 'Column B',
desc,
},
]}
enableVirtualization={false}
data={createBasicDataFrame()}
width={800}
height={600}
/>
);
expect(screen.getByTitle('Column B')).toBeVisible();
expect(screen.getByTestId(desc ? 'icon-arrow-down' : 'icon-arrow-up')).toBeVisible();
});
it('should not update sort on rerender if not managed', async () => {
const { rerender } = render(
<TableNG
sortBy={[
{
displayName: 'Column B',
desc: true,
},
]}
enableVirtualization={false}
data={createBasicDataFrame()}
width={800}
height={600}
/>
);
expect(screen.getByTitle('Column B')).toBeVisible();
expect(screen.getByTestId('icon-arrow-down')).toBeVisible();
rerender(
<TableNG
sortBy={[
{
displayName: 'Column B',
desc: false,
},
]}
enableVirtualization={false}
data={createBasicDataFrame()}
width={800}
height={600}
/>
);
expect(screen.getByTitle('Column B')).toBeVisible();
expect(screen.getByTestId('icon-arrow-down')).toBeVisible();
});
it('should manage sort', async () => {
const { rerender } = render(
<TableNG
sortBy={[
{
displayName: 'Column B',
desc: true,
},
]}
sortByBehavior={'managed'}
enableVirtualization={false}
data={createBasicDataFrame()}
width={800}
height={600}
/>
);
expect(screen.getByTitle('Column B')).toBeVisible();
expect(screen.getByTestId('icon-arrow-down')).toBeVisible();
rerender(
<TableNG
sortBy={[
{
displayName: 'Column B',
desc: false,
},
]}
sortByBehavior={'managed'}
enableVirtualization={false}
data={createBasicDataFrame()}
width={800}
height={600}
/>
);
expect(screen.getByTitle('Column B')).toBeVisible();
expect(screen.getByTestId('icon-arrow-up')).toBeVisible();
});
});
describe('Basic TableNG rendering', () => {
it('renders a simple table with columns and rows', async () => {
const { container } = render(

View file

@ -2,7 +2,7 @@ import 'react-data-grid/lib/styles.css';
import { clsx } from 'clsx';
import memoize from 'micro-memoize';
import { CSSProperties, Key, ReactNode, useCallback, useMemo, useRef, useState, useEffect, type JSX } from 'react';
import { CSSProperties, type JSX, Key, ReactNode, useCallback, useEffect, useMemo, useRef, useState } from 'react';
import {
Cell,
CellRendererProps,
@ -48,6 +48,7 @@ import {
useColWidths,
useFilteredRows,
useHeaderHeight,
useManagedSort,
usePaginatedRows,
useRowHeight,
useScrollbarWidth,
@ -63,19 +64,19 @@ import {
getTooltipStyles,
} from './styles';
import {
CellRootRenderer,
FromFieldsResult,
InspectCellProps,
TableCellStyleOptions,
TableColumn,
TableNGProps,
TableRow,
TableSummaryRow,
TableColumn,
InspectCellProps,
TableCellStyleOptions,
FromFieldsResult,
CellRootRenderer,
} from './types';
import {
applySort,
canFieldBeColorized,
calculateFooterHeight,
canFieldBeColorized,
createTypographyContext,
displayJsonValue,
extractPixelValue,
@ -89,15 +90,15 @@ import {
getDisplayName,
getIsNestedTable,
getJustifyContent,
getSummaryCellTextAlign,
getVisibleFields,
IS_SAFARI_26,
isCellInspectEnabled,
parseStyleJson,
predicateByName,
rowKeyGetter,
shouldTextOverflow,
shouldTextWrap,
getSummaryCellTextAlign,
parseStyleJson,
IS_SAFARI_26,
rowKeyGetter,
} from './utils';
const EXPANDED_COLUMN_KEY = 'expanded';
@ -114,7 +115,6 @@ export function TableNG(props: TableNGProps) {
frozenColumns = 0,
getActions = () => [],
height,
initialSortBy,
maxRowHeight: _maxRowHeight,
noHeader,
onCellFilterAdded,
@ -126,8 +126,9 @@ export function TableNG(props: TableNGProps) {
transparent,
width,
initialRowIndex,
sortBy,
sortByBehavior = 'initial',
} = props;
const theme = useTheme2();
const styles = useStyles2(getGridStyles, enablePagination, transparent);
const panelContext = usePanelContext();
@ -182,7 +183,9 @@ export function TableNG(props: TableNGProps) {
rows: sortedRows,
sortColumns,
setSortColumns,
} = useSortedRows(filteredRows, data.fields, { hasNestedFrames, initialSortBy });
} = useSortedRows(filteredRows, data.fields, { hasNestedFrames, initialSortBy: sortBy });
useManagedSort({ sortByBehavior, setSortColumns, sortBy });
const [inspectCell, setInspectCell] = useState<InspectCellProps | null>(null);
const [tooltipState, setTooltipState] = useState<DataLinksActionsTooltipState>();

View file

@ -11,6 +11,7 @@ import {
useHeaderHeight,
useRowHeight,
useReducerEntries,
useManagedSort,
} from './hooks';
import { TableRow } from './types';
import { createTypographyContext } from './utils';
@ -94,6 +95,63 @@ describe('TableNG hooks', () => {
it.todo('should handle nested frames');
});
describe('useManagedSort', () => {
it('Should not update if sortBy is undefined', () => {
const setSortColumns = jest.fn();
renderHook(() =>
useManagedSort({
sortBy: undefined,
sortByBehavior: 'managed',
setSortColumns,
})
);
expect(setSortColumns).toHaveBeenCalledTimes(0);
});
it.each([true, false])('Should not update if behavior is managed', (desc) => {
const setSortColumns = jest.fn();
renderHook(() =>
useManagedSort({
sortBy: [
{
displayName: 'Alice',
desc,
},
],
sortByBehavior: 'managed',
setSortColumns,
})
);
expect(setSortColumns).toHaveBeenCalledTimes(1);
expect(setSortColumns).toHaveBeenCalledWith([
{
columnKey: 'Alice',
direction: desc ? 'DESC' : 'ASC',
},
]);
});
it.each([true, false])('Should not update if behavior is initial', (desc) => {
const setSortColumns = jest.fn();
renderHook(() =>
useManagedSort({
sortBy: [
{
displayName: 'Alice',
desc,
},
],
sortByBehavior: 'initial',
setSortColumns,
})
);
expect(setSortColumns).toHaveBeenCalledTimes(0);
});
});
describe('useSortedRows', () => {
it('should correctly set up the table with an initial sort', () => {
const { fields, rows } = setupData();

View file

@ -6,7 +6,15 @@ import { compareArrayValues, Field, FieldType, formattedValueToString, reduceFie
import { TableColumnResizeActionCallback } from '../types';
import { TABLE } from './constants';
import { FilterType, FooterFieldState, TableRow, TableSortByFieldState, TableSummaryRow, TypographyCtx } from './types';
import {
FilterType,
FooterFieldState,
SortByBehavior,
TableRow,
TableSortByFieldState,
TableSummaryRow,
TypographyCtx,
} from './types';
import {
getDisplayName,
processNestedTableRows,
@ -98,6 +106,25 @@ export interface SortedRowsResult {
setSortColumns: React.Dispatch<React.SetStateAction<SortColumn[]>>;
}
interface ManagedSortProps {
sortByBehavior: SortByBehavior;
setSortColumns: React.Dispatch<React.SetStateAction<SortColumn[]>>;
sortBy?: TableSortByFieldState[];
}
export function useManagedSort({ sortByBehavior, setSortColumns, sortBy }: ManagedSortProps) {
useEffect(() => {
if (sortByBehavior === 'managed' && sortBy) {
setSortColumns(
sortBy.map(({ displayName, desc }) => ({
columnKey: displayName,
direction: desc === true ? 'DESC' : 'ASC',
}))
);
}
}, [setSortColumns, sortBy, sortByBehavior]);
}
export function useSortedRows(
rows: TableRow[],
fields: Field[],

View file

@ -115,6 +115,13 @@ export interface TableSortByFieldState {
desc?: boolean;
}
/**
* Controls how the `sortBy` prop is applied.
* `initial` will only read from the options on initial render,
* while `managed` will update the sort order whenever the sortBy array is changed.
*/
export type SortByBehavior = 'initial' | 'managed';
export interface BaseTableProps {
ariaLabel?: string;
data: DataFrame;
@ -126,7 +133,8 @@ export interface BaseTableProps {
noHeader?: boolean;
showTypeIcons?: boolean;
resizable?: boolean;
initialSortBy?: TableSortByFieldState[];
sortBy?: TableSortByFieldState[];
sortByBehavior?: SortByBehavior;
onColumnResize?: TableColumnResizeActionCallback;
onSortByChange?: TableSortByActionCallback;
onCellFilterAdded?: TableFilterActionCallback;

View file

@ -11,7 +11,6 @@ import {
PanelData,
PanelProps,
} from '@grafana/data';
import type { Options as TableOptions } from '@grafana/schema/dist/esm/raw/composable/table/panelcfg/x/TablePanelCfg_types.gen';
import { useStyles2 } from '@grafana/ui';
import { SETTING_KEY_ROOT } from 'app/features/explore/Logs/utils/logs';
import { getDefaultFieldSelectorWidth } from 'app/features/logs/components/fieldSelector/FieldSelector';
@ -30,8 +29,10 @@ import { useExtractFields } from './hooks/useExtractFields';
import { useOrganizeFields } from './hooks/useOrganizeFields';
import { copyLogsTableDashboardUrl } from './links/copyDashboardUrl';
import { getDisplayedFields } from './options/getDisplayedFields';
import { onSortOrderChange } from './options/onSortOrderChange';
import { Options } from './options/types';
import { defaultOptions, Options as LogsTableOptions } from './panelcfg.gen';
import { Options as LogsTableOptions } from './panelcfg.gen';
import { getInitialRowIndex } from './props/getInitialRowIndex';
import { BuildLinkToLogLine, isOnLogsTableOptionsChange, OnLogsTableOptionsChange } from './types';
interface LogsTablePanelProps extends PanelProps<Options> {}
@ -66,9 +67,7 @@ export const LogsTable = ({
const timeFieldName = logsFrame?.timeField.name ?? LOGS_DATAPLANE_TIMESTAMP_NAME;
const bodyFieldName = logsFrame?.bodyField.name ?? LOGS_DATAPLANE_BODY_NAME;
const permalinkedLogId = getLogsPanelState()?.logs?.id ?? undefined;
const initialRowIndex = permalinkedLogId
? logsFrame?.idField?.values?.findIndex((id) => id === permalinkedLogId)
: undefined;
const initialRowIndex = getInitialRowIndex(permalinkedLogId, logsFrame);
const onLogsTableOptionsChange: OnLogsTableOptionsChange | undefined = isOnLogsTableOptionsChange(onOptionsChange)
? onOptionsChange
@ -78,10 +77,11 @@ export const LogsTable = ({
// Callbacks
const handleTableOptionsChange = useCallback(
(options: TableOptions) => {
onLogsTableOptionsChange?.(options);
(newOptions: Options) => {
const pendingOptions = onSortOrderChange(newOptions, options.sortOrder, timeFieldName);
onLogsTableOptionsChange?.(pendingOptions);
},
[onLogsTableOptionsChange]
[onLogsTableOptionsChange, options.sortOrder, timeFieldName]
);
const handleLogsTableOptionsChange = useCallback(
@ -185,7 +185,11 @@ export const LogsTable = ({
id={id}
timeRange={timeRange}
timeZone={timeZone}
options={options}
options={{
sortOrder: LogsSortOrder.Descending,
sortBy: [{ displayName: timeFieldName, desc: true }],
...options,
}}
transparent={transparent}
fieldConfig={fieldConfig}
renderCounter={renderCounter}
@ -197,7 +201,6 @@ export const LogsTable = ({
onChangeTimeRange={onChangeTimeRange}
logOptionsStorageKey={SETTING_KEY_ROOT}
fieldSelectorWidth={fieldSelectorWidth}
sortOrder={options.sortOrder ?? defaultOptions.sortOrder ?? LogsSortOrder.Descending}
/>
</>
)}

View file

@ -12,12 +12,12 @@ import {
import { getAppEvents } from '@grafana/runtime';
import type { Options as TableOptions } from '@grafana/schema/dist/esm/raw/composable/table/panelcfg/x/TablePanelCfg_types.gen';
import { useStyles2 } from '@grafana/ui';
import { SETTING_KEY_ROOT } from 'app/features/explore/Logs/utils/logs';
import { getDefaultControlsExpandedMode } from 'app/features/logs/components/panel/LogListContext';
import { CONTROLS_WIDTH_EXPANDED } from 'app/features/logs/components/panel/LogListControls';
import { LogTableControls } from 'app/features/logs/components/panel/LogTableControls';
import { LOG_LIST_CONTROLS_WIDTH } from 'app/features/logs/components/panel/virtualization';
import { SETTING_KEY_ROOT } from '../../../features/explore/Logs/utils/logs';
import { TablePanel } from '../table/TablePanel';
import { Options } from './options/types';
@ -28,7 +28,6 @@ interface Props extends PanelProps<Options> {
logOptionsStorageKey: string;
containerElement: HTMLDivElement;
fieldSelectorWidth: number;
sortOrder: LogsSortOrder;
}
export function TableNGWrap({
@ -52,7 +51,6 @@ export function TableNGWrap({
initialRowIndex,
logOptionsStorageKey,
containerElement,
sortOrder,
}: Props) {
const showControls = options.showControls ?? defaultOptions.showControls ?? true;
const controlsExpandedFromStore = store.getBool(
@ -74,12 +72,12 @@ export function TableNGWrap({
const handleSortOrderChange = useCallback(
(sortOrder: LogsSortOrder) => {
onOptionsChange({ ...options, sortOrder });
getAppEvents().publish(
new LogSortOrderChangeEvent({
order: sortOrder,
})
);
onOptionsChange({ ...options, sortOrder });
},
[onOptionsChange, options]
);
@ -99,13 +97,14 @@ export function TableNGWrap({
logOptionsStorageKey={SETTING_KEY_ROOT}
controlsExpanded={controlsExpanded}
setControlsExpanded={setControlsExpanded}
sortOrder={sortOrder}
sortOrder={options.sortOrder ?? LogsSortOrder.Descending}
setSortOrder={handleSortOrderChange}
/>
</div>
)}
<TablePanel
sortByBehavior={'managed'}
initialRowIndex={initialRowIndex}
data={data}
width={Math.max(tableWidth - fieldSelectorWidth - controlsWidth, 0)}
@ -113,7 +112,7 @@ export function TableNGWrap({
id={id}
timeRange={timeRange}
timeZone={timeZone}
options={options}
options={{ ...options }}
transparent={transparent}
fieldConfig={fieldConfig}
renderCounter={renderCounter}

View file

@ -0,0 +1,47 @@
import { LogsSortOrder } from '@grafana/data';
import { onSortOrderChange } from './onSortOrderChange';
import { Options } from './types';
describe('onSortOrderChange', () => {
const timeFieldName = 'timestamp';
it('should update', () => {
const options = { sortBy: [], sortOrder: LogsSortOrder.Ascending } as unknown as Options;
expect(onSortOrderChange(options, LogsSortOrder.Descending, timeFieldName)).toEqual({
sortBy: [{ desc: false, displayName: timeFieldName }],
sortOrder: LogsSortOrder.Ascending,
});
});
it('should handle undefined sortOrder', () => {
const options = { sortBy: [], sortOrder: undefined } as unknown as Options;
expect(onSortOrderChange(options, undefined, timeFieldName)).toEqual({
sortBy: [],
});
});
it('should not update', () => {
const options = {
sortBy: [{ desc: false, displayName: timeFieldName }],
sortOrder: LogsSortOrder.Ascending,
} as unknown as Options;
expect(onSortOrderChange(options, LogsSortOrder.Descending, timeFieldName)).toEqual({
sortBy: [{ desc: false, displayName: timeFieldName }],
sortOrder: LogsSortOrder.Ascending,
});
});
it('should create new sortBy array', () => {
const options = {
sortBy: [{ desc: false, displayName: timeFieldName }],
sortOrder: LogsSortOrder.Ascending,
} as unknown as Options;
const result = onSortOrderChange(options, LogsSortOrder.Descending, timeFieldName);
// Assert that sort is still ascending
expect(result.sortBy).toEqual(options.sortBy);
// Assert that return is new reference: if the array is the same reference, the useEffect will not re-render unless the length of the array has changed!
expect(result.sortBy).not.toBe(options.sortBy);
});
});

View file

@ -0,0 +1,27 @@
import { LogsSortOrder } from '@grafana/data';
import { Options } from './types';
export const onSortOrderChange = (
pendingOptions: Options,
currentSortOrder: LogsSortOrder | undefined,
timeFieldName: string
) => {
if (pendingOptions.sortOrder && pendingOptions.sortOrder !== currentSortOrder) {
const newSortBy = {
desc: pendingOptions.sortOrder === LogsSortOrder.Descending,
displayName: timeFieldName,
};
let sortBy = pendingOptions.sortBy ? [...pendingOptions.sortBy] : undefined;
const existingSortByIdx = sortBy?.findIndex((option) => option.displayName === timeFieldName);
if (sortBy && existingSortByIdx !== undefined && existingSortByIdx !== -1) {
sortBy[existingSortByIdx] = newSortBy;
} else {
sortBy = [{ ...newSortBy }, ...(sortBy ?? [])];
}
return { ...pendingOptions, sortBy };
}
return pendingOptions;
};

View file

@ -0,0 +1,46 @@
import { DataFrameType, FieldType, toDataFrame } from '@grafana/data';
import {
LOGS_DATAPLANE_BODY_NAME,
LOGS_DATAPLANE_TIMESTAMP_NAME,
parseLogsFrame,
} from '../../../../features/logs/logsFrame';
import { getInitialRowIndex } from './getInitialRowIndex';
const testLogsDataFrame = [
toDataFrame({
meta: {
type: DataFrameType.LogLines,
},
fields: [
{ name: LOGS_DATAPLANE_TIMESTAMP_NAME, type: FieldType.time, values: [1, 2] },
{ name: LOGS_DATAPLANE_BODY_NAME, type: FieldType.string, values: ['log 1', 'log 2'] },
{ name: 'id', type: FieldType.string, values: ['abc_123', 'def_456'] },
{
name: 'labels',
type: FieldType.other,
values: [
{ service: 'frontend', level: 'info' },
{ service: 'backend', level: 'error' },
],
},
],
}),
];
const testLogsFrame = parseLogsFrame(testLogsDataFrame[0]);
describe('getInitialRowIndex', () => {
it('should return undefined', () => {
expect(getInitialRowIndex(undefined, null)).toBeUndefined();
expect(getInitialRowIndex(undefined, testLogsFrame)).toBeUndefined();
expect(getInitialRowIndex('abc_123', null)).toBeUndefined();
});
it('should return correct index', () => {
expect(getInitialRowIndex('abc_123', testLogsFrame)).toEqual(0);
expect(getInitialRowIndex('def_456', testLogsFrame)).toEqual(1);
});
it('should not return -1', () => {
expect(getInitialRowIndex('nope', testLogsFrame)).toBeUndefined();
});
});

View file

@ -0,0 +1,9 @@
import { LogsFrame } from 'app/features/logs/logsFrame';
export const getInitialRowIndex = (permalinkedLogId: string | undefined, logsFrame: LogsFrame | null) => {
if (!permalinkedLogId || !logsFrame) {
return undefined;
}
const initialRowIndex = logsFrame.idField?.values.findIndex((id) => id === permalinkedLogId);
return initialRowIndex !== undefined && initialRowIndex > -1 ? initialRowIndex : undefined;
};

View file

@ -25,11 +25,23 @@ import { Options } from './panelcfg.gen';
interface Props extends PanelProps<Options> {
initialRowIndex?: number;
sortByBehavior?: 'initial' | 'managed';
}
export function TablePanel(props: Props) {
const { data, height, width, options, fieldConfig, id, timeRange, replaceVariables, transparent, initialRowIndex } =
props;
const {
data,
height,
width,
options,
fieldConfig,
id,
timeRange,
replaceVariables,
transparent,
initialRowIndex,
sortByBehavior = 'initial',
} = props;
useMemo(() => {
cacheFieldDisplayNames(data.series);
@ -77,7 +89,8 @@ export function TablePanel(props: Props) {
noHeader={!options.showHeader}
showTypeIcons={options.showTypeIcons}
resizable={true}
initialSortBy={options.sortBy}
sortByBehavior={sortByBehavior}
sortBy={options.sortBy}
onSortByChange={(sortBy) => onSortByChange(sortBy, props)}
onColumnResize={(displayName, resizedWidth) => onColumnResize(displayName, resizedWidth, props)}
onCellFilterAdded={panelContext.onAddAdHocFilter}