diff --git a/e2e-playwright/panels-suite/logstable.spec.ts b/e2e-playwright/panels-suite/logstable.spec.ts index 06b7f9f4ded..448c31db45e 100644 --- a/e2e-playwright/panels-suite/logstable.spec.ts +++ b/e2e-playwright/panels-suite/logstable.spec.ts @@ -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` ); }); }); diff --git a/packages/grafana-ui/src/components/Table/TableNG/TableNG.test.tsx b/packages/grafana-ui/src/components/Table/TableNG/TableNG.test.tsx index bddaa3d411d..917b5fa1d4b 100644 --- a/packages/grafana-ui/src/components/Table/TableNG/TableNG.test.tsx +++ b/packages/grafana-ui/src/components/Table/TableNG/TableNG.test.tsx @@ -394,7 +394,7 @@ describe('TableNG', () => { async (desc) => { const { container } = render( { ); }); + describe('TableNG::sortBy', () => { + it.each([true, false])('should set initial sort', async (desc) => { + render( + + ); + + 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( + + ); + + expect(screen.getByTitle('Column B')).toBeVisible(); + expect(screen.getByTestId('icon-arrow-down')).toBeVisible(); + + rerender( + + ); + + expect(screen.getByTitle('Column B')).toBeVisible(); + expect(screen.getByTestId('icon-arrow-down')).toBeVisible(); + }); + it('should manage sort', async () => { + const { rerender } = render( + + ); + + expect(screen.getByTitle('Column B')).toBeVisible(); + expect(screen.getByTestId('icon-arrow-down')).toBeVisible(); + + rerender( + + ); + + 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( diff --git a/packages/grafana-ui/src/components/Table/TableNG/TableNG.tsx b/packages/grafana-ui/src/components/Table/TableNG/TableNG.tsx index 453544bede5..ab0cab06f0d 100644 --- a/packages/grafana-ui/src/components/Table/TableNG/TableNG.tsx +++ b/packages/grafana-ui/src/components/Table/TableNG/TableNG.tsx @@ -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(null); const [tooltipState, setTooltipState] = useState(); diff --git a/packages/grafana-ui/src/components/Table/TableNG/hooks.test.ts b/packages/grafana-ui/src/components/Table/TableNG/hooks.test.ts index 4460f6c924b..aeacccc2ba2 100644 --- a/packages/grafana-ui/src/components/Table/TableNG/hooks.test.ts +++ b/packages/grafana-ui/src/components/Table/TableNG/hooks.test.ts @@ -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(); diff --git a/packages/grafana-ui/src/components/Table/TableNG/hooks.ts b/packages/grafana-ui/src/components/Table/TableNG/hooks.ts index 2a7a096e639..b468771e546 100644 --- a/packages/grafana-ui/src/components/Table/TableNG/hooks.ts +++ b/packages/grafana-ui/src/components/Table/TableNG/hooks.ts @@ -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>; } +interface ManagedSortProps { + sortByBehavior: SortByBehavior; + setSortColumns: React.Dispatch>; + 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[], diff --git a/packages/grafana-ui/src/components/Table/TableNG/types.ts b/packages/grafana-ui/src/components/Table/TableNG/types.ts index 2a3f4924272..e604b6d347f 100644 --- a/packages/grafana-ui/src/components/Table/TableNG/types.ts +++ b/packages/grafana-ui/src/components/Table/TableNG/types.ts @@ -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; diff --git a/public/app/plugins/panel/logstable/LogsTable.tsx b/public/app/plugins/panel/logstable/LogsTable.tsx index 187b586f3f9..2d5f8c833c5 100644 --- a/public/app/plugins/panel/logstable/LogsTable.tsx +++ b/public/app/plugins/panel/logstable/LogsTable.tsx @@ -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 {} @@ -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} /> )} diff --git a/public/app/plugins/panel/logstable/TableNGWrap.tsx b/public/app/plugins/panel/logstable/TableNGWrap.tsx index 61bc2e27b80..55f7358648d 100644 --- a/public/app/plugins/panel/logstable/TableNGWrap.tsx +++ b/public/app/plugins/panel/logstable/TableNGWrap.tsx @@ -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 { 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} /> )} { + 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); + }); +}); diff --git a/public/app/plugins/panel/logstable/options/onSortOrderChange.ts b/public/app/plugins/panel/logstable/options/onSortOrderChange.ts new file mode 100644 index 00000000000..648b07ec619 --- /dev/null +++ b/public/app/plugins/panel/logstable/options/onSortOrderChange.ts @@ -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; +}; diff --git a/public/app/plugins/panel/logstable/props/getInitialRowIndex.test.ts b/public/app/plugins/panel/logstable/props/getInitialRowIndex.test.ts new file mode 100644 index 00000000000..0069606c1d8 --- /dev/null +++ b/public/app/plugins/panel/logstable/props/getInitialRowIndex.test.ts @@ -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(); + }); +}); diff --git a/public/app/plugins/panel/logstable/props/getInitialRowIndex.ts b/public/app/plugins/panel/logstable/props/getInitialRowIndex.ts new file mode 100644 index 00000000000..174d7f88f2e --- /dev/null +++ b/public/app/plugins/panel/logstable/props/getInitialRowIndex.ts @@ -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; +}; diff --git a/public/app/plugins/panel/table/TablePanel.tsx b/public/app/plugins/panel/table/TablePanel.tsx index 24db2adae2b..78f9cbb9e46 100644 --- a/public/app/plugins/panel/table/TablePanel.tsx +++ b/public/app/plugins/panel/table/TablePanel.tsx @@ -25,11 +25,23 @@ import { Options } from './panelcfg.gen'; interface Props extends PanelProps { 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}