Skip to content

Commit fce307c

Browse files
authored
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
1 parent 9da2d32 commit fce307c

File tree

5 files changed

+216
-6
lines changed

5 files changed

+216
-6
lines changed

.changeset/ten-baboons-leave.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
"@hyperdx/app": patch
3+
---
4+
5+
feat: Allow specifying persistent order by in chart table

packages/app/src/__tests__/utils.test.ts

Lines changed: 96 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import { TSource } from '@hyperdx/common-utils/dist/types';
2+
import { SortingState } from '@tanstack/react-table';
23
import { act, renderHook } from '@testing-library/react';
34

45
import { MetricsDataType, NumberFormat } from '../types';
@@ -7,6 +8,8 @@ import {
78
formatAttributeClause,
89
formatNumber,
910
getMetricTableName,
11+
orderByStringToSortingState,
12+
sortingStateToOrderByString,
1013
stripTrailingSlash,
1114
useQueryHistory,
1215
} from '../utils';
@@ -534,3 +537,96 @@ describe('useQueryHistory', () => {
534537
expect(mockSetItem).not.toHaveBeenCalled();
535538
});
536539
});
540+
541+
describe('sortingStateToOrderByString', () => {
542+
it('returns undefined for null input', () => {
543+
expect(sortingStateToOrderByString(null)).toBeUndefined();
544+
});
545+
546+
it('returns undefined for empty array', () => {
547+
const sortingState: SortingState = [];
548+
expect(sortingStateToOrderByString(sortingState)).toBeUndefined();
549+
});
550+
551+
it('converts sorting state with desc: false to ASC order', () => {
552+
const sortingState: SortingState = [{ id: 'timestamp', desc: false }];
553+
expect(sortingStateToOrderByString(sortingState)).toBe('timestamp ASC');
554+
});
555+
556+
it('converts sorting state with desc: true to DESC order', () => {
557+
const sortingState: SortingState = [{ id: 'timestamp', desc: true }];
558+
expect(sortingStateToOrderByString(sortingState)).toBe('timestamp DESC');
559+
});
560+
561+
it('handles column names with special characters', () => {
562+
const sortingState: SortingState = [{ id: 'user_count', desc: false }];
563+
expect(sortingStateToOrderByString(sortingState)).toBe('user_count ASC');
564+
});
565+
});
566+
567+
describe('orderByStringToSortingState', () => {
568+
it('returns undefined for undefined input', () => {
569+
expect(orderByStringToSortingState(undefined)).toBeUndefined();
570+
});
571+
572+
it('returns undefined for empty string', () => {
573+
expect(orderByStringToSortingState('')).toBeUndefined();
574+
});
575+
576+
it('converts "column ASC" to sorting state with desc: false', () => {
577+
const result = orderByStringToSortingState('timestamp ASC');
578+
expect(result).toEqual([{ id: 'timestamp', desc: false }]);
579+
});
580+
581+
it('converts "column DESC" to sorting state with desc: true', () => {
582+
const result = orderByStringToSortingState('timestamp DESC');
583+
expect(result).toEqual([{ id: 'timestamp', desc: true }]);
584+
});
585+
586+
it('handles case insensitive direction keywords', () => {
587+
expect(orderByStringToSortingState('col asc')).toEqual([
588+
{ id: 'col', desc: false },
589+
]);
590+
expect(orderByStringToSortingState('col Asc')).toEqual([
591+
{ id: 'col', desc: false },
592+
]);
593+
expect(orderByStringToSortingState('col desc')).toEqual([
594+
{ id: 'col', desc: true },
595+
]);
596+
expect(orderByStringToSortingState('col Desc')).toEqual([
597+
{ id: 'col', desc: true },
598+
]);
599+
expect(orderByStringToSortingState('col DESC')).toEqual([
600+
{ id: 'col', desc: true },
601+
]);
602+
});
603+
604+
it('returns undefined for invalid format without direction', () => {
605+
expect(orderByStringToSortingState('timestamp')).toBeUndefined();
606+
});
607+
608+
it('returns undefined for invalid format with wrong number of parts', () => {
609+
expect(orderByStringToSortingState('col name ASC')).toBeUndefined();
610+
});
611+
612+
it('returns undefined for invalid direction keyword', () => {
613+
expect(orderByStringToSortingState('col INVALID')).toBeUndefined();
614+
});
615+
616+
it('handles column names with underscores', () => {
617+
const result = orderByStringToSortingState('user_count DESC');
618+
expect(result).toEqual([{ id: 'user_count', desc: true }]);
619+
});
620+
621+
it('handles column names with numbers', () => {
622+
const result = orderByStringToSortingState('col123 ASC');
623+
expect(result).toEqual([{ id: 'col123', desc: false }]);
624+
});
625+
626+
it('round-trips correctly with sortingStateToOrderByString', () => {
627+
const originalSort: SortingState = [{ id: 'service_name', desc: true }];
628+
const orderByString = sortingStateToOrderByString(originalSort);
629+
const roundTripSort = orderByStringToSortingState(orderByString);
630+
expect(roundTripSort).toEqual(originalSort);
631+
});
632+
});

packages/app/src/components/DBEditTimeChartForm.tsx

Lines changed: 46 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -40,6 +40,7 @@ import {
4040
Textarea,
4141
} from '@mantine/core';
4242
import { IconPlayerPlay } from '@tabler/icons-react';
43+
import { SortingState } from '@tanstack/react-table';
4344

4445
import {
4546
AGG_FNS,
@@ -59,7 +60,12 @@ import SearchInputV2 from '@/SearchInputV2';
5960
import { getFirstTimestampValueExpression, useSource } from '@/source';
6061
import { parseTimeQuery } from '@/timeQuery';
6162
import { FormatTime } from '@/useFormatTime';
62-
import { getMetricTableName, optionsToSelectData } from '@/utils';
63+
import {
64+
getMetricTableName,
65+
optionsToSelectData,
66+
orderByStringToSortingState,
67+
sortingStateToOrderByString,
68+
} from '@/utils';
6369
import {
6470
ALERT_CHANNEL_OPTIONS,
6571
DEFAULT_TILE_ALERT,
@@ -458,6 +464,7 @@ export default function EditTimeChartForm({
458464
const alert = watch('alert');
459465
const seriesReturnType = watch('seriesReturnType');
460466
const compareToPreviousPeriod = watch('compareToPreviousPeriod');
467+
const groupBy = watch('groupBy');
461468

462469
const { data: tableSource } = useSource({ id: sourceId });
463470
const databaseName = tableSource?.from.databaseName;
@@ -535,6 +542,11 @@ export default function EditTimeChartForm({
535542
select: isSelectEmpty
536543
? tableSource.defaultTableSelectExpression || ''
537544
: config.select,
545+
// Order By can only be set by the user for table charts
546+
orderBy:
547+
config.displayType === DisplayType.Table
548+
? config.orderBy
549+
: undefined,
538550
};
539551
setQueriedConfig(
540552
// WARNING: DON'T JUST ASSIGN OBJECTS OR DO SPREAD OPERATOR STUFF WHEN
@@ -547,6 +559,22 @@ export default function EditTimeChartForm({
547559
})();
548560
}, [handleSubmit, setChartConfig, setQueriedConfig, tableSource, dateRange]);
549561

562+
const onTableSortingChange = useCallback(
563+
(sortState: SortingState | null) => {
564+
setValue('orderBy', sortingStateToOrderByString(sortState) ?? '');
565+
onSubmit();
566+
},
567+
[setValue, onSubmit],
568+
);
569+
570+
const tableSortState = useMemo(
571+
() =>
572+
queriedConfig?.orderBy && typeof queriedConfig.orderBy === 'string'
573+
? orderByStringToSortingState(queriedConfig.orderBy)
574+
: undefined,
575+
[queriedConfig],
576+
);
577+
550578
useEffect(() => {
551579
if (submitRef) {
552580
submitRef.current = onSubmit;
@@ -990,6 +1018,21 @@ export default function EditTimeChartForm({
9901018
)}
9911019
</Flex>
9921020
<Flex gap="sm" my="sm" align="center" justify="end">
1021+
{activeTab === 'table' && (
1022+
<div style={{ minWidth: 300 }}>
1023+
<SQLInlineEditorControlled
1024+
parentRef={parentRef}
1025+
tableConnection={tcFromSource(tableSource)}
1026+
// The default order by is the current group by value
1027+
placeholder={typeof groupBy === 'string' ? groupBy : ''}
1028+
control={control}
1029+
name={`orderBy`}
1030+
disableKeywordAutocomplete
1031+
onSubmit={onSubmit}
1032+
label="ORDER BY"
1033+
/>
1034+
</div>
1035+
)}
9931036
{activeTab !== 'markdown' &&
9941037
setDisplayedTimeInputValue != null &&
9951038
displayedTimeInputValue != null &&
@@ -1067,6 +1110,8 @@ export default function EditTimeChartForm({
10671110
dateRange: queriedConfig.dateRange,
10681111
})
10691112
}
1113+
onSortingChange={onTableSortingChange}
1114+
sort={tableSortState}
10701115
/>
10711116
</div>
10721117
)}

packages/app/src/components/DBTableChart.tsx

Lines changed: 24 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import { useMemo, useState } from 'react';
1+
import { useCallback, useMemo, useState } from 'react';
22
import { ClickHouseQueryError } from '@hyperdx/common-utils/dist/clickhouse';
33
import {
44
ChartConfigWithDateRange,
@@ -19,16 +19,35 @@ export default function DBTableChart({
1919
getRowSearchLink,
2020
enabled = true,
2121
queryKeyPrefix,
22+
onSortingChange,
23+
sort: controlledSort,
2224
hiddenColumns = [],
2325
}: {
2426
config: ChartConfigWithOptTimestamp;
2527
getRowSearchLink?: (row: any) => string | null;
2628
queryKeyPrefix?: string;
2729
enabled?: boolean;
30+
onSortingChange?: (sort: SortingState) => void;
31+
sort?: SortingState;
2832
hiddenColumns?: string[];
2933
}) {
3034
const [sort, setSort] = useState<SortingState>([]);
3135

36+
const effectiveSort = useMemo(
37+
() => controlledSort || sort,
38+
[controlledSort, sort],
39+
);
40+
41+
const handleSortingChange = useCallback(
42+
(newSort: SortingState) => {
43+
setSort(newSort);
44+
if (onSortingChange) {
45+
onSortingChange(newSort);
46+
}
47+
},
48+
[onSortingChange],
49+
);
50+
3251
const queriedConfig = (() => {
3352
const _config = omit(config, ['granularity']);
3453
if (!_config.limit) {
@@ -45,8 +64,8 @@ export default function DBTableChart({
4564
_config.orderBy = _config.groupBy;
4665
}
4766

48-
if (sort.length) {
49-
_config.orderBy = sort?.map(o => {
67+
if (effectiveSort.length) {
68+
_config.orderBy = effectiveSort.map(o => {
5069
return {
5170
valueExpression: o.id,
5271
ordering: o.desc ? 'DESC' : 'ASC',
@@ -148,8 +167,8 @@ export default function DBTableChart({
148167
data={data?.data ?? []}
149168
columns={columns}
150169
getRowSearchLink={getRowSearchLink}
151-
sorting={sort}
152-
onSortingChange={setSort}
170+
sorting={effectiveSort}
171+
onSortingChange={handleSortingChange}
153172
tableBottom={
154173
hasNextPage && (
155174
<Text ref={fetchMoreRef} ta="center">

packages/app/src/utils.ts

Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import { formatDistanceToNowStrict } from 'date-fns';
44
import numbro from 'numbro';
55
import type { MutableRefObject, SetStateAction } from 'react';
66
import { TSource } from '@hyperdx/common-utils/dist/types';
7+
import { SortingState } from '@tanstack/react-table';
78

89
import { dateRangeToString } from './timeQuery';
910
import { MetricsDataType, NumberFormat } from './types';
@@ -711,3 +712,47 @@ export const stripTrailingSlash = (url: string | undefined | null): string => {
711712
}
712713
return url.endsWith('/') ? url.slice(0, -1) : url;
713714
};
715+
716+
/**
717+
* Converts the given SortingState into a SQL Order By string
718+
* Note, only the first element of the SortingState is used. Returns
719+
* undefined if the input is null or empty.
720+
*
721+
* Output format: "<column> <ASC|DESC>"
722+
* */
723+
export const sortingStateToOrderByString = (
724+
sort: SortingState | null,
725+
): string | undefined => {
726+
const firstSort = sort?.at(0);
727+
return firstSort
728+
? `${firstSort.id} ${firstSort.desc ? 'DESC' : 'ASC'}`
729+
: undefined;
730+
};
731+
732+
/**
733+
* Converts the given SQL Order By string into a SortingState.
734+
*
735+
* Expects format matching the output of sortingStateToOrderByString
736+
* ("<column> <ASC|DESC>"). Returns undefined if the input is invalid.
737+
*/
738+
export const orderByStringToSortingState = (
739+
orderBy: string | undefined,
740+
): SortingState | undefined => {
741+
if (!orderBy) {
742+
return undefined;
743+
}
744+
745+
const orderByParts = orderBy.split(' ');
746+
const endsWithDirection = orderBy.toLowerCase().match(/ (asc|desc)$/i);
747+
748+
if (orderByParts.length !== 2 || !endsWithDirection) {
749+
return undefined;
750+
}
751+
752+
return [
753+
{
754+
id: orderByParts[0].trim(),
755+
desc: orderByParts[1].trim().toUpperCase() === 'DESC',
756+
},
757+
];
758+
};

0 commit comments

Comments
 (0)