diff --git a/.changeset/json-viewer-key-order.md b/.changeset/json-viewer-key-order.md new file mode 100644 index 0000000000..3c57bed530 --- /dev/null +++ b/.changeset/json-viewer-key-order.md @@ -0,0 +1,13 @@ +--- +'@hyperdx/app': patch +--- + +feat: choose how property keys are ordered in the row side panel + +The JSON viewer sorts keys alphabetically, which is the right default for wide +ClickHouse `Map(...)` columns like `ProfileEvents` but hides the stored column +order. The properties view options menu now offers "Sort keys A–Z", "Sort keys +Z–A", and "Original order"; the choice persists with the viewer's other options. +Array elements keep their index order in every mode. In "Original order", +integer-like keys (e.g. HTTP status codes) still list first in ascending order, +since parsed JavaScript objects order keys that way. diff --git a/packages/app/src/components/DBRowJsonViewer.tsx b/packages/app/src/components/DBRowJsonViewer.tsx index 70a3ae6ada..3c3ed4852b 100644 --- a/packages/app/src/components/DBRowJsonViewer.tsx +++ b/packages/app/src/components/DBRowJsonViewer.tsx @@ -20,16 +20,21 @@ import { IconCheck, IconCopy, IconFilter, + IconList, IconMinus, IconPlus, IconSearch, IconSettings, + IconSortAscendingLetters, + IconSortDescendingLetters, IconTextWrap, } from '@tabler/icons-react'; import HyperJson, { + DEFAULT_KEY_ORDER, FormatLeafValue, GetLineActions, + KeyOrder, LineAction, } from '@/components/HyperJson'; import { useMaterializedAliasColumnsOption } from '@/hooks/useMaterializedAliasColumnsOption'; @@ -133,6 +138,9 @@ type ViewerOptions = { whiteSpace?: 'pre' | 'pre-wrap'; tabulate: boolean; filterBlanks: boolean; + // Absent for options stored before sorting was configurable; falls back to + // the alphabetical default. + keyOrder?: KeyOrder; }; const VIEWER_OPTIONS_KEY = 'hdx_json_viewer_options'; @@ -142,8 +150,31 @@ const DEFAULT_VIEWER_OPTIONS: ViewerOptions = { whiteSpace: 'pre-wrap', tabulate: true, filterBlanks: false, + keyOrder: DEFAULT_KEY_ORDER, }; +const KEY_ORDER_OPTIONS: { + value: KeyOrder; + label: string; + icon: React.ReactNode; +}[] = [ + { + value: 'asc', + label: 'Sort keys A–Z', + icon: , + }, + { + value: 'desc', + label: 'Sort keys Z–A', + icon: , + }, + { + value: 'original', + label: 'Original order', + icon: , + }, +]; + /** * Migrates old `lineWrap` boolean to `whiteSpace` enum. * @@ -239,6 +270,7 @@ function HyperJsonMenu({ const [showMaterializedAliasColumns, setShowMaterializedAliasColumns] = useMaterializedAliasColumnsOption(); const effectiveWhiteSpace = jsonOptions.whiteSpace ?? 'pre-wrap'; + const effectiveKeyOrder = jsonOptions.keyOrder ?? DEFAULT_KEY_ORDER; return ( @@ -285,7 +317,7 @@ function HyperJsonMenu({ - + @@ -361,6 +393,29 @@ function HyperJsonMenu({ Show materialized and alias columns )} + + + Property order + + {KEY_ORDER_OPTIONS.map(({ value, label, icon }) => ( + + ) : null + } + onClick={() => + setJsonOptions({ ...jsonOptions, keyOrder: value }) + } + > + {label} + + ))} diff --git a/packages/app/src/components/HyperJson.tsx b/packages/app/src/components/HyperJson.tsx index e936f6f3a4..d56a93df01 100644 --- a/packages/app/src/components/HyperJson.tsx +++ b/packages/app/src/components/HyperJson.tsx @@ -40,15 +40,30 @@ export type FormatLeafValue = (arg0: { value: unknown; }) => React.ReactNode | undefined; +/** + * How object keys are ordered at every level of the tree. + * + * `original` keeps the object's own key order — for ClickHouse `Map(...)` + * columns, physical storage order — except that integer-like keys (`"200"`, + * `Map(UInt64, …)` keys) always come first in ascending numeric order. JS + * objects order keys that way, so the true order is already gone once the + * query result is parsed. + */ +export type KeyOrder = 'asc' | 'desc' | 'original'; + +export const DEFAULT_KEY_ORDER: KeyOrder = 'asc'; + // Store common state in an atom so that it can be shared between components // to avoid prop drilling type HyperJsonAtom = { normallyExpanded: boolean; + keyOrder: KeyOrder; getLineActions?: GetLineActions; formatLeafValue?: FormatLeafValue; }; const hyperJsonAtom = atom({ normallyExpanded: false, + keyOrder: DEFAULT_KEY_ORDER, }); const ValueRenderer = React.memo( @@ -273,6 +288,7 @@ const Line = React.memo(
0, @@ -358,6 +374,8 @@ function TreeNode({ const originalLength = React.useMemo(() => Object.keys(data).length, [data]); + const { keyOrder } = useAtomValue(hyperJsonAtom); + // ClickHouse hands back `Map(...)` keys in physical storage order, which reads // as random for wide maps like `ProfileEvents`. Sorting here (rather than // upstream) covers every nesting level for free, since TreeNode recurses, and @@ -366,13 +384,15 @@ function TreeNode({ const entries = React.useMemo(() => { const raw = Object.entries(data); // Arrays are index-keyed — reordering them would change the data. - if (isArray(data)) { + if (isArray(data) || keyOrder === 'original') { return raw; } - return raw.sort(([a], [b]) => - a.localeCompare(b, undefined, { numeric: true }), + const direction = keyOrder === 'desc' ? -1 : 1; + return raw.sort( + ([a], [b]) => + direction * a.localeCompare(b, undefined, { numeric: true }), ); - }, [data]); + }, [data, keyOrder]); const visibleLines = React.useMemo(() => { return isExpanded ? entries : entries.slice(0, MAX_TREE_NODE_ITEMS); @@ -430,6 +450,7 @@ type HyperJsonProps = { normallyExpanded?: boolean; tabulate?: boolean; whiteSpace?: 'pre' | 'pre-wrap'; + keyOrder?: KeyOrder; getLineActions?: GetLineActions; formatLeafValue?: FormatLeafValue; }; @@ -439,16 +460,20 @@ const HyperJson = ({ normallyExpanded = false, tabulate = false, whiteSpace = 'pre-wrap', + keyOrder = DEFAULT_KEY_ORDER, getLineActions, formatLeafValue, }: HyperJsonProps) => { const isEmpty = React.useMemo(() => Object.keys(data).length === 0, [data]); + const initialValues = React.useMemo( + () => ({ normallyExpanded, keyOrder, getLineActions, formatLeafValue }), + [normallyExpanded, keyOrder, getLineActions, formatLeafValue], + ); + return ( - +
({ + __esModule: true, + default: { push: jest.fn() }, +})); + +jest.mock('@/useFormatTime', () => ({ + useFormatTime: () => (time: unknown) => String(time), + FormatTime: jest.fn(() => null), +})); + +const VIEWER_OPTIONS_KEY = 'hdx_json_viewer_options'; + +// Options written before the sort setting existed — no `keyOrder` key. +const LEGACY_STORED_OPTIONS = { + normallyExpanded: true, + whiteSpace: 'pre-wrap', + tabulate: true, + filterBlanks: false, +}; + +const data = { zebra: 'z', alpha: 'a', Mango: 'm' }; + +// env="test" mounts the menu dropdown without a transition to wait on. The +// viewer options atom is module-scoped, so each render gets its own jotai store; +// the atom then reads localStorage when it mounts in that store. +const renderViewer = () => + render( + + + + + + + , + ); + +const renderedKeys = (container: HTMLElement) => + Array.from(container.querySelectorAll('.keyContainer > .key')).map(el => + el.textContent?.trim(), + ); + +const openOptionsMenu = async () => { + await userEvent.click(screen.getByTestId('json-viewer-options-menu')); + await screen.findByRole('menu'); +}; + +const selectKeyOrder = async (value: 'asc' | 'desc' | 'original') => { + await openOptionsMenu(); + await userEvent.click(screen.getByTestId(`json-viewer-key-order-${value}`)); +}; + +const storedKeyOrder = () => + JSON.parse(localStorage.getItem(VIEWER_OPTIONS_KEY) ?? '{}').keyOrder; + +describe('DBRowJsonViewer key order', () => { + beforeEach(() => { + localStorage.clear(); + }); + + it('sorts alphabetically when stored options predate the setting', async () => { + localStorage.setItem( + VIEWER_OPTIONS_KEY, + JSON.stringify(LEGACY_STORED_OPTIONS), + ); + const { container } = renderViewer(); + + expect(renderedKeys(container)).toEqual(['alpha', 'Mango', 'zebra']); + + await openOptionsMenu(); + expect( + screen.getByTestId('json-viewer-key-order-asc').querySelector('.ps-2'), + ).toBeInTheDocument(); + }); + + it('restores a stored order on render', async () => { + localStorage.setItem( + VIEWER_OPTIONS_KEY, + JSON.stringify({ ...LEGACY_STORED_OPTIONS, keyOrder: 'original' }), + ); + const { container } = renderViewer(); + + expect(renderedKeys(container)).toEqual(['zebra', 'alpha', 'Mango']); + }); + + it('switches between the stored order and reverse alphabetical', async () => { + const { container } = renderViewer(); + + expect(renderedKeys(container)).toEqual(['alpha', 'Mango', 'zebra']); + + await selectKeyOrder('original'); + expect(renderedKeys(container)).toEqual(['zebra', 'alpha', 'Mango']); + expect(storedKeyOrder()).toBe('original'); + + await selectKeyOrder('desc'); + expect(renderedKeys(container)).toEqual(['zebra', 'Mango', 'alpha']); + expect(storedKeyOrder()).toBe('desc'); + + await selectKeyOrder('asc'); + expect(renderedKeys(container)).toEqual(['alpha', 'Mango', 'zebra']); + expect(storedKeyOrder()).toBe('asc'); + }); +}); diff --git a/packages/app/src/components/__tests__/DBRowJsonViewer.test.ts b/packages/app/src/components/__tests__/DBRowJsonViewer.test.ts index bdd2c9fb88..fc1552c341 100644 --- a/packages/app/src/components/__tests__/DBRowJsonViewer.test.ts +++ b/packages/app/src/components/__tests__/DBRowJsonViewer.test.ts @@ -32,6 +32,7 @@ describe('migrateViewerOptions', () => { whiteSpace: undefined, tabulate: true, filterBlanks: false, + keyOrder: 'asc', }); }); @@ -48,6 +49,8 @@ describe('migrateViewerOptions', () => { whiteSpace: 'pre-wrap', tabulate: true, filterBlanks: false, + // Migration fills in the defaults for options added later. + keyOrder: 'asc', }); }); diff --git a/packages/app/src/components/__tests__/HyperJson.test.tsx b/packages/app/src/components/__tests__/HyperJson.test.tsx index 8710d9d83e..366d871ee4 100644 --- a/packages/app/src/components/__tests__/HyperJson.test.tsx +++ b/packages/app/src/components/__tests__/HyperJson.test.tsx @@ -76,6 +76,31 @@ describe('HyperJson key ordering', () => { ]); }); + it('reverses the order with keyOrder="desc"', () => { + const { container } = renderWithMantine( + , + ); + + expect(renderedKeys(container)).toEqual(['zebra', 'Mango', 'alpha']); + }); + + it('keeps the incoming order with keyOrder="original"', () => { + const { container } = renderWithMantine( + , + ); + + expect(renderedKeys(container)).toEqual([ + 'ProfileEvents', + 'Seek', + 'FileOpen', + 'Query', + ]); + }); + it('orders numeric suffixes naturally rather than lexicographically', () => { const { container } = renderWithMantine( , diff --git a/packages/app/tests/e2e/components/SidePanelComponent.ts b/packages/app/tests/e2e/components/SidePanelComponent.ts index b64077fb04..d36ca993bc 100644 --- a/packages/app/tests/e2e/components/SidePanelComponent.ts +++ b/packages/app/tests/e2e/components/SidePanelComponent.ts @@ -244,6 +244,51 @@ export class SidePanelComponent { return this.panelContainer.getByTestId('linked-from-open-span'); } + /** + * Key labels of the JSON viewer's depth-0 rows. Nested rows are excluded so + * the list is a flat sequence that reverses cleanly when the order flips — + * nested children stay grouped under their parent, so a full-tree list does + * not. + */ + get jsonViewerTopLevelKeys() { + return this.panelContainer.locator( + '[data-testid="json-viewer-line"][data-depth="0"] [class*="keyContainer"] > [class*="key"]', + ); + } + + async getJsonViewerTopLevelKeys(): Promise { + await this.jsonViewerTopLevelKeys + .first() + .waitFor({ state: 'visible', timeout: 10_000 }); + const keys = await this.jsonViewerTopLevelKeys.allInnerTexts(); + return keys.map(key => key.trim()).filter(Boolean); + } + + /** + * Sort in the browser, where the viewer's own `localeCompare` runs — Node's + * ICU orders mixed-case keys differently, so sorting test-side would compare + * against a different collation than the one under test. + */ + async sortKeysLikeViewer(keys: string[]): Promise { + return this.page.evaluate( + (ks: string[]) => + [...ks].sort((a, b) => + a.localeCompare(b, undefined, { numeric: true }), + ), + keys, + ); + } + + /** Pick a key-order option from the JSON viewer's view options menu. */ + async selectJsonKeyOrder(order: 'asc' | 'desc' | 'original') { + await this.panelContainer + .getByTestId('json-viewer-options-menu') + .click({ timeout: this.defaultTimeout }); + await this.panelContainer + .getByTestId(`json-viewer-key-order-${order}`) + .click({ timeout: this.defaultTimeout }); + } + /** * Close the side panel (if it has a close button) */ diff --git a/packages/app/tests/e2e/features/search/json-viewer-key-order.spec.ts b/packages/app/tests/e2e/features/search/json-viewer-key-order.spec.ts new file mode 100644 index 0000000000..57638cf986 --- /dev/null +++ b/packages/app/tests/e2e/features/search/json-viewer-key-order.spec.ts @@ -0,0 +1,50 @@ +import { SearchPage } from '../../page-objects/SearchPage'; +import { expect, test } from '../../utils/base-test'; + +const VIEWER_OPTIONS_KEY = 'hdx_json_viewer_options'; + +// HDX-5115: the viewer sorts keys alphabetically, which is the right default +// for wide ClickHouse Map columns but hides the stored column order. The view +// options menu makes the order a choice. +test.describe('JSON viewer key order', { tag: ['@search'] }, () => { + async function openParsedTab(searchPage: SearchPage) { + await searchPage.goto(); + await searchPage.submitEmptySearch(); + await expect(searchPage.table.firstRow).toBeVisible({ timeout: 10000 }); + await searchPage.table.clickFirstRow(); + await searchPage.sidePanel.clickTab('parsed'); + } + + test('changes the property order from the view options menu', async ({ + page, + }) => { + const searchPage = new SearchPage(page); + const { sidePanel } = searchPage; + await openParsedTab(searchPage); + + const ascKeys = await sidePanel.getJsonViewerTopLevelKeys(); + expect(ascKeys).toEqual(await sidePanel.sortKeysLikeViewer(ascKeys)); + + await sidePanel.selectJsonKeyOrder('desc'); + await expect + .poll(() => sidePanel.getJsonViewerTopLevelKeys(), { timeout: 5000 }) + .toEqual([...ascKeys].reverse()); + + await sidePanel.selectJsonKeyOrder('original'); + await expect + .poll(() => sidePanel.getJsonViewerTopLevelKeys(), { timeout: 5000 }) + .not.toEqual([...ascKeys].reverse()); + + // The seeded logs table's column order (Timestamp, TraceId, SpanId, …) is + // not alphabetical, so the same keys come back in a different sequence. + const originalKeys = await sidePanel.getJsonViewerTopLevelKeys(); + expect(new Set(originalKeys)).toEqual(new Set(ascKeys)); + expect(originalKeys).not.toEqual(ascKeys); + + const stored = await page.evaluate( + key => localStorage.getItem(key), + VIEWER_OPTIONS_KEY, + ); + expect(JSON.parse(stored!).keyOrder).toBe('original'); + }); +});