mirror of
https://github.com/hyperdxio/hyperdx
synced 2026-04-21 13:37:15 +00:00
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:
parent
9da2d32fa2
commit
fce307c8ce
5 changed files with 216 additions and 6 deletions
5
.changeset/ten-baboons-leave.md
Normal file
5
.changeset/ten-baboons-leave.md
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
---
|
||||
"@hyperdx/app": patch
|
||||
---
|
||||
|
||||
feat: Allow specifying persistent order by in chart table
|
||||
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
)}
|
||||
|
|
|
|||
|
|
@ -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">
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
},
|
||||
];
|
||||
};
|
||||
|
|
|
|||
Loading…
Reference in a new issue