feat: Allow specifying persistent order by in chart table (#1438)

Closes HDX-2845

# Summary

This PR adds support for specifying a persistent Order By in table charts. Previously, the user could sort by clicking a column, but this was not persisted to the saved chart config. Now, we show an input that allows the user to specify an ordering other than the default, and this order is persisted in the saved chart config.

## Demo

https://github.com/user-attachments/assets/960642fd-9749-4e54-9b84-ab82eb5af3d8
This commit is contained in:
Drew Davis 2025-12-05 10:21:26 -05:00 committed by GitHub
parent 9da2d32fa2
commit fce307c8ce
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
5 changed files with 216 additions and 6 deletions

View file

@ -0,0 +1,5 @@
---
"@hyperdx/app": patch
---
feat: Allow specifying persistent order by in chart table

View file

@ -1,4 +1,5 @@
import { TSource } from '@hyperdx/common-utils/dist/types';
import { SortingState } from '@tanstack/react-table';
import { act, renderHook } from '@testing-library/react';
import { MetricsDataType, NumberFormat } from '../types';
@ -7,6 +8,8 @@ import {
formatAttributeClause,
formatNumber,
getMetricTableName,
orderByStringToSortingState,
sortingStateToOrderByString,
stripTrailingSlash,
useQueryHistory,
} from '../utils';
@ -534,3 +537,96 @@ describe('useQueryHistory', () => {
expect(mockSetItem).not.toHaveBeenCalled();
});
});
describe('sortingStateToOrderByString', () => {
it('returns undefined for null input', () => {
expect(sortingStateToOrderByString(null)).toBeUndefined();
});
it('returns undefined for empty array', () => {
const sortingState: SortingState = [];
expect(sortingStateToOrderByString(sortingState)).toBeUndefined();
});
it('converts sorting state with desc: false to ASC order', () => {
const sortingState: SortingState = [{ id: 'timestamp', desc: false }];
expect(sortingStateToOrderByString(sortingState)).toBe('timestamp ASC');
});
it('converts sorting state with desc: true to DESC order', () => {
const sortingState: SortingState = [{ id: 'timestamp', desc: true }];
expect(sortingStateToOrderByString(sortingState)).toBe('timestamp DESC');
});
it('handles column names with special characters', () => {
const sortingState: SortingState = [{ id: 'user_count', desc: false }];
expect(sortingStateToOrderByString(sortingState)).toBe('user_count ASC');
});
});
describe('orderByStringToSortingState', () => {
it('returns undefined for undefined input', () => {
expect(orderByStringToSortingState(undefined)).toBeUndefined();
});
it('returns undefined for empty string', () => {
expect(orderByStringToSortingState('')).toBeUndefined();
});
it('converts "column ASC" to sorting state with desc: false', () => {
const result = orderByStringToSortingState('timestamp ASC');
expect(result).toEqual([{ id: 'timestamp', desc: false }]);
});
it('converts "column DESC" to sorting state with desc: true', () => {
const result = orderByStringToSortingState('timestamp DESC');
expect(result).toEqual([{ id: 'timestamp', desc: true }]);
});
it('handles case insensitive direction keywords', () => {
expect(orderByStringToSortingState('col asc')).toEqual([
{ id: 'col', desc: false },
]);
expect(orderByStringToSortingState('col Asc')).toEqual([
{ id: 'col', desc: false },
]);
expect(orderByStringToSortingState('col desc')).toEqual([
{ id: 'col', desc: true },
]);
expect(orderByStringToSortingState('col Desc')).toEqual([
{ id: 'col', desc: true },
]);
expect(orderByStringToSortingState('col DESC')).toEqual([
{ id: 'col', desc: true },
]);
});
it('returns undefined for invalid format without direction', () => {
expect(orderByStringToSortingState('timestamp')).toBeUndefined();
});
it('returns undefined for invalid format with wrong number of parts', () => {
expect(orderByStringToSortingState('col name ASC')).toBeUndefined();
});
it('returns undefined for invalid direction keyword', () => {
expect(orderByStringToSortingState('col INVALID')).toBeUndefined();
});
it('handles column names with underscores', () => {
const result = orderByStringToSortingState('user_count DESC');
expect(result).toEqual([{ id: 'user_count', desc: true }]);
});
it('handles column names with numbers', () => {
const result = orderByStringToSortingState('col123 ASC');
expect(result).toEqual([{ id: 'col123', desc: false }]);
});
it('round-trips correctly with sortingStateToOrderByString', () => {
const originalSort: SortingState = [{ id: 'service_name', desc: true }];
const orderByString = sortingStateToOrderByString(originalSort);
const roundTripSort = orderByStringToSortingState(orderByString);
expect(roundTripSort).toEqual(originalSort);
});
});

View file

@ -40,6 +40,7 @@ import {
Textarea,
} from '@mantine/core';
import { IconPlayerPlay } from '@tabler/icons-react';
import { SortingState } from '@tanstack/react-table';
import {
AGG_FNS,
@ -59,7 +60,12 @@ import SearchInputV2 from '@/SearchInputV2';
import { getFirstTimestampValueExpression, useSource } from '@/source';
import { parseTimeQuery } from '@/timeQuery';
import { FormatTime } from '@/useFormatTime';
import { getMetricTableName, optionsToSelectData } from '@/utils';
import {
getMetricTableName,
optionsToSelectData,
orderByStringToSortingState,
sortingStateToOrderByString,
} from '@/utils';
import {
ALERT_CHANNEL_OPTIONS,
DEFAULT_TILE_ALERT,
@ -458,6 +464,7 @@ export default function EditTimeChartForm({
const alert = watch('alert');
const seriesReturnType = watch('seriesReturnType');
const compareToPreviousPeriod = watch('compareToPreviousPeriod');
const groupBy = watch('groupBy');
const { data: tableSource } = useSource({ id: sourceId });
const databaseName = tableSource?.from.databaseName;
@ -535,6 +542,11 @@ export default function EditTimeChartForm({
select: isSelectEmpty
? tableSource.defaultTableSelectExpression || ''
: config.select,
// Order By can only be set by the user for table charts
orderBy:
config.displayType === DisplayType.Table
? config.orderBy
: undefined,
};
setQueriedConfig(
// WARNING: DON'T JUST ASSIGN OBJECTS OR DO SPREAD OPERATOR STUFF WHEN
@ -547,6 +559,22 @@ export default function EditTimeChartForm({
})();
}, [handleSubmit, setChartConfig, setQueriedConfig, tableSource, dateRange]);
const onTableSortingChange = useCallback(
(sortState: SortingState | null) => {
setValue('orderBy', sortingStateToOrderByString(sortState) ?? '');
onSubmit();
},
[setValue, onSubmit],
);
const tableSortState = useMemo(
() =>
queriedConfig?.orderBy && typeof queriedConfig.orderBy === 'string'
? orderByStringToSortingState(queriedConfig.orderBy)
: undefined,
[queriedConfig],
);
useEffect(() => {
if (submitRef) {
submitRef.current = onSubmit;
@ -990,6 +1018,21 @@ export default function EditTimeChartForm({
)}
</Flex>
<Flex gap="sm" my="sm" align="center" justify="end">
{activeTab === 'table' && (
<div style={{ minWidth: 300 }}>
<SQLInlineEditorControlled
parentRef={parentRef}
tableConnection={tcFromSource(tableSource)}
// The default order by is the current group by value
placeholder={typeof groupBy === 'string' ? groupBy : ''}
control={control}
name={`orderBy`}
disableKeywordAutocomplete
onSubmit={onSubmit}
label="ORDER BY"
/>
</div>
)}
{activeTab !== 'markdown' &&
setDisplayedTimeInputValue != null &&
displayedTimeInputValue != null &&
@ -1067,6 +1110,8 @@ export default function EditTimeChartForm({
dateRange: queriedConfig.dateRange,
})
}
onSortingChange={onTableSortingChange}
sort={tableSortState}
/>
</div>
)}

View file

@ -1,4 +1,4 @@
import { useMemo, useState } from 'react';
import { useCallback, useMemo, useState } from 'react';
import { ClickHouseQueryError } from '@hyperdx/common-utils/dist/clickhouse';
import {
ChartConfigWithDateRange,
@ -19,16 +19,35 @@ export default function DBTableChart({
getRowSearchLink,
enabled = true,
queryKeyPrefix,
onSortingChange,
sort: controlledSort,
hiddenColumns = [],
}: {
config: ChartConfigWithOptTimestamp;
getRowSearchLink?: (row: any) => string | null;
queryKeyPrefix?: string;
enabled?: boolean;
onSortingChange?: (sort: SortingState) => void;
sort?: SortingState;
hiddenColumns?: string[];
}) {
const [sort, setSort] = useState<SortingState>([]);
const effectiveSort = useMemo(
() => controlledSort || sort,
[controlledSort, sort],
);
const handleSortingChange = useCallback(
(newSort: SortingState) => {
setSort(newSort);
if (onSortingChange) {
onSortingChange(newSort);
}
},
[onSortingChange],
);
const queriedConfig = (() => {
const _config = omit(config, ['granularity']);
if (!_config.limit) {
@ -45,8 +64,8 @@ export default function DBTableChart({
_config.orderBy = _config.groupBy;
}
if (sort.length) {
_config.orderBy = sort?.map(o => {
if (effectiveSort.length) {
_config.orderBy = effectiveSort.map(o => {
return {
valueExpression: o.id,
ordering: o.desc ? 'DESC' : 'ASC',
@ -148,8 +167,8 @@ export default function DBTableChart({
data={data?.data ?? []}
columns={columns}
getRowSearchLink={getRowSearchLink}
sorting={sort}
onSortingChange={setSort}
sorting={effectiveSort}
onSortingChange={handleSortingChange}
tableBottom={
hasNextPage && (
<Text ref={fetchMoreRef} ta="center">

View file

@ -4,6 +4,7 @@ import { formatDistanceToNowStrict } from 'date-fns';
import numbro from 'numbro';
import type { MutableRefObject, SetStateAction } from 'react';
import { TSource } from '@hyperdx/common-utils/dist/types';
import { SortingState } from '@tanstack/react-table';
import { dateRangeToString } from './timeQuery';
import { MetricsDataType, NumberFormat } from './types';
@ -711,3 +712,47 @@ export const stripTrailingSlash = (url: string | undefined | null): string => {
}
return url.endsWith('/') ? url.slice(0, -1) : url;
};
/**
* Converts the given SortingState into a SQL Order By string
* Note, only the first element of the SortingState is used. Returns
* undefined if the input is null or empty.
*
* Output format: "<column> <ASC|DESC>"
* */
export const sortingStateToOrderByString = (
sort: SortingState | null,
): string | undefined => {
const firstSort = sort?.at(0);
return firstSort
? `${firstSort.id} ${firstSort.desc ? 'DESC' : 'ASC'}`
: undefined;
};
/**
* Converts the given SQL Order By string into a SortingState.
*
* Expects format matching the output of sortingStateToOrderByString
* ("<column> <ASC|DESC>"). Returns undefined if the input is invalid.
*/
export const orderByStringToSortingState = (
orderBy: string | undefined,
): SortingState | undefined => {
if (!orderBy) {
return undefined;
}
const orderByParts = orderBy.split(' ');
const endsWithDirection = orderBy.toLowerCase().match(/ (asc|desc)$/i);
if (orderByParts.length !== 2 || !endsWithDirection) {
return undefined;
}
return [
{
id: orderByParts[0].trim(),
desc: orderByParts[1].trim().toUpperCase() === 'DESC',
},
];
};