From d87458c01449ba0be867a255afa8e1cdb255f1fc Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Mon, 14 Sep 2026 06:14:29 +0000 Subject: [PATCH 1/5] feat(app): let the row side panel choose property key order The JSON viewer always sorted keys alphabetically, which hides the stored column order for ClickHouse Map columns. Add A-Z / Z-A / original order to the properties view options menu, persisted alongside the other options. Co-authored-by: Mike Shi --- .changeset/json-viewer-key-order.md | 11 +++ .../app/src/components/DBRowJsonViewer.tsx | 57 ++++++++++- packages/app/src/components/HyperJson.tsx | 35 +++++-- .../DBRowJsonViewer.keyOrder.test.tsx | 96 +++++++++++++++++++ .../__tests__/DBRowJsonViewer.test.ts | 3 + .../components/__tests__/HyperJson.test.tsx | 25 +++++ 6 files changed, 219 insertions(+), 8 deletions(-) create mode 100644 .changeset/json-viewer-key-order.md create mode 100644 packages/app/src/components/__tests__/DBRowJsonViewer.keyOrder.test.tsx diff --git a/.changeset/json-viewer-key-order.md b/.changeset/json-viewer-key-order.md new file mode 100644 index 0000000000..944552550a --- /dev/null +++ b/.changeset/json-viewer-key-order.md @@ -0,0 +1,11 @@ +--- +'@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. diff --git a/packages/app/src/components/DBRowJsonViewer.tsx b/packages/app/src/components/DBRowJsonViewer.tsx index e5c5b6faa4..bb0c6612b3 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 { useFormatTime } from '@/useFormatTime'; @@ -132,6 +137,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'; @@ -141,8 +149,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. * @@ -230,6 +261,7 @@ const viewerOptionsAtom = atomWithStorage( function HyperJsonMenu({ rowData }: { rowData: any }) { const [jsonOptions, setJsonOptions] = useAtom(viewerOptionsAtom); const effectiveWhiteSpace = jsonOptions.whiteSpace ?? 'pre-wrap'; + const effectiveKeyOrder = jsonOptions.keyOrder ?? DEFAULT_KEY_ORDER; return ( @@ -276,7 +308,7 @@ function HyperJsonMenu({ rowData }: { rowData: any }) { - + @@ -335,6 +367,29 @@ function HyperJsonMenu({ rowData }: { rowData: any }) { > Hide blank values + + + 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..9e83d486d8 100644 --- a/packages/app/src/components/HyperJson.tsx +++ b/packages/app/src/components/HyperJson.tsx @@ -40,15 +40,27 @@ export type FormatLeafValue = (arg0: { value: unknown; }) => React.ReactNode | undefined; +/** + * How object keys are ordered at every level of the tree. + * + * `original` keeps whatever order the data arrived in — for ClickHouse + * `Map(...)` columns that is physical storage order. + */ +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( @@ -358,6 +370,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 +380,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 +446,7 @@ type HyperJsonProps = { normallyExpanded?: boolean; tabulate?: boolean; whiteSpace?: 'pre' | 'pre-wrap'; + keyOrder?: KeyOrder; getLineActions?: GetLineActions; formatLeafValue?: FormatLeafValue; }; @@ -439,16 +456,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. +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; + +// The viewer options atom lives at module scope, so its value carries across +// the tests in this file — seed localStorage once, before the first render +// reads it, and let the cases run in order. +describe('DBRowJsonViewer key order', () => { + beforeAll(() => { + localStorage.setItem( + VIEWER_OPTIONS_KEY, + JSON.stringify(LEGACY_STORED_OPTIONS), + ); + }); + + it('sorts alphabetically when stored options predate the setting', async () => { + 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('switches between the stored order and reverse alphabetical', async () => { + const { container } = renderViewer(); + + 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( , From 3661b9e1feb56a2ec4af58f2d0385158ee9bd0df Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Mon, 14 Sep 2026 06:31:08 +0000 Subject: [PATCH 2/5] test(e2e): add JSON viewer key-order test Co-authored-by: Mike Shi --- .../e2e/components/SidePanelComponent.ts | 67 +++++++++++++++++++ .../search/json-viewer-key-order.spec.ts | 58 ++++++++++++++++ 2 files changed, 125 insertions(+) create mode 100644 packages/app/tests/e2e/features/search/json-viewer-key-order.spec.ts diff --git a/packages/app/tests/e2e/components/SidePanelComponent.ts b/packages/app/tests/e2e/components/SidePanelComponent.ts index 854111514e..8caebd186f 100644 --- a/packages/app/tests/e2e/components/SidePanelComponent.ts +++ b/packages/app/tests/e2e/components/SidePanelComponent.ts @@ -237,6 +237,73 @@ export class SidePanelComponent { return this.panelContainer.getByTestId('linked-from-open-span'); } + /** + * All top-level (depth-0) key labels in the JSON viewer. + * The component sets `style="margin-left: px"` on each line. + * Depth-0 rows have 0px; nested rows have a positive value. The + * `nestedLine` CSS-module class has no entry in HyperJson.module.scss so + * it resolves to `undefined` at runtime and cannot be used as a selector. + */ + get jsonViewerTopLevelKeys() { + // Rows with style="margin-left: 0px;" are at depth 0. + return this.panelContainer.locator( + '[data-testid="json-viewer-line"][style="margin-left: 0px;"] [class*="keyContainer"] > [class*="key"]', + ); + } + + /** Collect the visible top-level key name strings from the JSON viewer. */ + async getJsonViewerTopLevelKeys(): Promise { + const allLines = this.panelContainer.locator( + '[data-testid="json-viewer-line"]', + ); + await allLines.first().waitFor({ state: 'visible', timeout: 10_000 }); + + // evaluateAll runs in the browser so the margin comparison is exact. + return allLines.evaluateAll(lines => + lines + .filter( + line => getComputedStyle(line as HTMLElement).marginLeft === '0px', + ) + .map(line => { + const keyEl = (line as HTMLElement).querySelector( + '[class*="keyContainer"] > [class*="key"]', + ); + return ((keyEl as HTMLElement)?.innerText ?? '').trim(); + }) + .filter(Boolean), + ); + } + + /** + * Sort strings using the browser's localeCompare with { numeric: true } — + * the same collation the JSON viewer uses — so test comparisons are + * consistent with what the viewer itself renders. + */ + async sortKeysInBrowser(keys: string[]): Promise { + return this.page.evaluate( + (ks: string[]) => + [...ks].sort((a, b) => + a.localeCompare(b, undefined, { numeric: true }), + ), + keys, + ); + } + + /** Open the gear options menu on the JSON viewer toolbar. */ + async openJsonViewerOptionsMenu() { + await this.panelContainer + .getByTestId('json-viewer-options-menu') + .click({ timeout: this.defaultTimeout }); + } + + /** Pick a key-order option from the JSON viewer options menu. */ + async selectJsonKeyOrder(order: 'asc' | 'desc' | 'original') { + await this.openJsonViewerOptionsMenu(); + 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..8a91f49779 --- /dev/null +++ b/packages/app/tests/e2e/features/search/json-viewer-key-order.spec.ts @@ -0,0 +1,58 @@ +import { SearchPage } from '../../page-objects/SearchPage'; +import { expect, test } from '../../utils/base-test'; + +const VIEWER_OPTIONS_KEY = 'hdx_json_viewer_options'; + +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); + await openParsedTab(searchPage); + + // Default is A–Z. Assert keys equal their own sorted copy to pin this. + // Sort is evaluated in the browser so the collation matches the viewer. + const ascKeys = await searchPage.sidePanel.getJsonViewerTopLevelKeys(); + const sorted = await searchPage.sidePanel.sortKeysInBrowser(ascKeys); + expect(ascKeys).toEqual(sorted); + + // Switch to Z–A and poll until the viewer re-renders. + await searchPage.sidePanel.selectJsonKeyOrder('desc'); + await expect + .poll(() => searchPage.sidePanel.getJsonViewerTopLevelKeys(), { + timeout: 5000, + }) + .toEqual([...ascKeys].reverse()); + + // Switch to original order and wait for the view to update. + await searchPage.sidePanel.selectJsonKeyOrder('original'); + await expect + .poll(() => searchPage.sidePanel.getJsonViewerTopLevelKeys(), { + timeout: 5000, + }) + .not.toEqual([...ascKeys].reverse()); + + const originalKeys = await searchPage.sidePanel.getJsonViewerTopLevelKeys(); + + // Same set of keys, but the seeded column order (Timestamp, TraceId, + // SpanId, …) is not alphabetical, so original != sorted. + expect(new Set(originalKeys)).toEqual(new Set(ascKeys)); + expect(originalKeys).not.toEqual(sorted); + + // Confirm the choice was persisted. + const stored = await page.evaluate( + key => localStorage.getItem(key), + VIEWER_OPTIONS_KEY, + ); + const parsedOptions = JSON.parse(stored!); + expect(parsedOptions.keyOrder).toBe('original'); + }); +}); From f9b7fa44ff272fe0fe3fa03219db0f242ec85b00 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Mon, 14 Sep 2026 07:13:19 +0000 Subject: [PATCH 3/5] test(e2e): target JSON viewer rows by depth instead of inline margin Emit the nesting level as data-depth on each viewer line so the E2E test can select depth-0 rows directly; the nestedLine CSS-module class it would have used has no SCSS definition and resolves to undefined. Co-authored-by: Mike Shi --- packages/app/src/components/HyperJson.tsx | 1 + .../e2e/components/SidePanelComponent.ts | 54 ++++++------------- .../search/json-viewer-key-order.spec.ts | 38 ++++++------- 3 files changed, 32 insertions(+), 61 deletions(-) diff --git a/packages/app/src/components/HyperJson.tsx b/packages/app/src/components/HyperJson.tsx index 9e83d486d8..19d8710064 100644 --- a/packages/app/src/components/HyperJson.tsx +++ b/packages/app/src/components/HyperJson.tsx @@ -285,6 +285,7 @@ const Line = React.memo(
0, diff --git a/packages/app/tests/e2e/components/SidePanelComponent.ts b/packages/app/tests/e2e/components/SidePanelComponent.ts index 8caebd186f..2a35382224 100644 --- a/packages/app/tests/e2e/components/SidePanelComponent.ts +++ b/packages/app/tests/e2e/components/SidePanelComponent.ts @@ -238,48 +238,31 @@ export class SidePanelComponent { } /** - * All top-level (depth-0) key labels in the JSON viewer. - * The component sets `style="margin-left: px"` on each line. - * Depth-0 rows have 0px; nested rows have a positive value. The - * `nestedLine` CSS-module class has no entry in HyperJson.module.scss so - * it resolves to `undefined` at runtime and cannot be used as a selector. + * 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() { - // Rows with style="margin-left: 0px;" are at depth 0. return this.panelContainer.locator( - '[data-testid="json-viewer-line"][style="margin-left: 0px;"] [class*="keyContainer"] > [class*="key"]', + '[data-testid="json-viewer-line"][data-depth="0"] [class*="keyContainer"] > [class*="key"]', ); } - /** Collect the visible top-level key name strings from the JSON viewer. */ async getJsonViewerTopLevelKeys(): Promise { - const allLines = this.panelContainer.locator( - '[data-testid="json-viewer-line"]', - ); - await allLines.first().waitFor({ state: 'visible', timeout: 10_000 }); - - // evaluateAll runs in the browser so the margin comparison is exact. - return allLines.evaluateAll(lines => - lines - .filter( - line => getComputedStyle(line as HTMLElement).marginLeft === '0px', - ) - .map(line => { - const keyEl = (line as HTMLElement).querySelector( - '[class*="keyContainer"] > [class*="key"]', - ); - return ((keyEl as HTMLElement)?.innerText ?? '').trim(); - }) - .filter(Boolean), - ); + 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 strings using the browser's localeCompare with { numeric: true } — - * the same collation the JSON viewer uses — so test comparisons are - * consistent with what the viewer itself renders. + * 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 sortKeysInBrowser(keys: string[]): Promise { + async sortKeysLikeViewer(keys: string[]): Promise { return this.page.evaluate( (ks: string[]) => [...ks].sort((a, b) => @@ -289,16 +272,11 @@ export class SidePanelComponent { ); } - /** Open the gear options menu on the JSON viewer toolbar. */ - async openJsonViewerOptionsMenu() { + /** 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 }); - } - - /** Pick a key-order option from the JSON viewer options menu. */ - async selectJsonKeyOrder(order: 'asc' | 'desc' | 'original') { - await this.openJsonViewerOptionsMenu(); await this.panelContainer .getByTestId(`json-viewer-key-order-${order}`) .click({ timeout: this.defaultTimeout }); 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 index 8a91f49779..57638cf986 100644 --- 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 @@ -3,6 +3,9 @@ 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(); @@ -16,43 +19,32 @@ test.describe('JSON viewer key order', { tag: ['@search'] }, () => { page, }) => { const searchPage = new SearchPage(page); + const { sidePanel } = searchPage; await openParsedTab(searchPage); - // Default is A–Z. Assert keys equal their own sorted copy to pin this. - // Sort is evaluated in the browser so the collation matches the viewer. - const ascKeys = await searchPage.sidePanel.getJsonViewerTopLevelKeys(); - const sorted = await searchPage.sidePanel.sortKeysInBrowser(ascKeys); - expect(ascKeys).toEqual(sorted); + const ascKeys = await sidePanel.getJsonViewerTopLevelKeys(); + expect(ascKeys).toEqual(await sidePanel.sortKeysLikeViewer(ascKeys)); - // Switch to Z–A and poll until the viewer re-renders. - await searchPage.sidePanel.selectJsonKeyOrder('desc'); + await sidePanel.selectJsonKeyOrder('desc'); await expect - .poll(() => searchPage.sidePanel.getJsonViewerTopLevelKeys(), { - timeout: 5000, - }) + .poll(() => sidePanel.getJsonViewerTopLevelKeys(), { timeout: 5000 }) .toEqual([...ascKeys].reverse()); - // Switch to original order and wait for the view to update. - await searchPage.sidePanel.selectJsonKeyOrder('original'); + await sidePanel.selectJsonKeyOrder('original'); await expect - .poll(() => searchPage.sidePanel.getJsonViewerTopLevelKeys(), { - timeout: 5000, - }) + .poll(() => sidePanel.getJsonViewerTopLevelKeys(), { timeout: 5000 }) .not.toEqual([...ascKeys].reverse()); - const originalKeys = await searchPage.sidePanel.getJsonViewerTopLevelKeys(); - - // Same set of keys, but the seeded column order (Timestamp, TraceId, - // SpanId, …) is not alphabetical, so original != sorted. + // 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(sorted); + expect(originalKeys).not.toEqual(ascKeys); - // Confirm the choice was persisted. const stored = await page.evaluate( key => localStorage.getItem(key), VIEWER_OPTIONS_KEY, ); - const parsedOptions = JSON.parse(stored!); - expect(parsedOptions.keyOrder).toBe('original'); + expect(JSON.parse(stored!).keyOrder).toBe('original'); }); }); From 0c757f11bedc59bb19561f80efcd90f86515cb06 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Thu, 8 Oct 2026 03:05:21 +0000 Subject: [PATCH 4/5] test(app): isolate JSON viewer key-order cases from each other The cases shared the module-scoped viewer options atom, so the legacy-options case only held when it ran first. Give each render its own jotai store and seed localStorage per case, and add a case that restores a stored order so the storage read is actually exercised. Co-authored-by: Mike Shi --- .../DBRowJsonViewer.keyOrder.test.tsx | 41 +++++++++++++------ 1 file changed, 28 insertions(+), 13 deletions(-) diff --git a/packages/app/src/components/__tests__/DBRowJsonViewer.keyOrder.test.tsx b/packages/app/src/components/__tests__/DBRowJsonViewer.keyOrder.test.tsx index 68e2983d3d..4166837a08 100644 --- a/packages/app/src/components/__tests__/DBRowJsonViewer.keyOrder.test.tsx +++ b/packages/app/src/components/__tests__/DBRowJsonViewer.keyOrder.test.tsx @@ -1,4 +1,5 @@ import React from 'react'; +import { Provider } from 'jotai'; import { MantineProvider } from '@mantine/core'; import { render, screen } from '@testing-library/react'; import userEvent from '@testing-library/user-event'; @@ -28,14 +29,18 @@ const LEGACY_STORED_OPTIONS = { const data = { zebra: 'z', alpha: 'a', Mango: 'm' }; -// env="test" mounts the menu dropdown without a transition to wait on. +// 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) => @@ -56,18 +61,16 @@ const selectKeyOrder = async (value: 'asc' | 'desc' | 'original') => { const storedKeyOrder = () => JSON.parse(localStorage.getItem(VIEWER_OPTIONS_KEY) ?? '{}').keyOrder; -// The viewer options atom lives at module scope, so its value carries across -// the tests in this file — seed localStorage once, before the first render -// reads it, and let the cases run in order. describe('DBRowJsonViewer key order', () => { - beforeAll(() => { + beforeEach(() => { + localStorage.clear(); + }); + + it('sorts alphabetically when stored options predate the setting', async () => { localStorage.setItem( VIEWER_OPTIONS_KEY, JSON.stringify(LEGACY_STORED_OPTIONS), ); - }); - - it('sorts alphabetically when stored options predate the setting', async () => { const { container } = renderViewer(); expect(renderedKeys(container)).toEqual(['alpha', 'Mango', 'zebra']); @@ -78,9 +81,21 @@ describe('DBRowJsonViewer key order', () => { ).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'); From 3a0f1e98b7a840361764529920bb87f9843624b6 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Thu, 8 Oct 2026 03:08:46 +0000 Subject: [PATCH 5/5] docs(app): note that integer-like keys lead in original key order JS objects list integer-like keys first in ascending order, so for maps keyed by numbers 'Original order' cannot reproduce ClickHouse storage order. Say so in the KeyOrder doc comment and the changeset instead of promising it. Co-authored-by: Mike Shi --- .changeset/json-viewer-key-order.md | 6 ++++-- packages/app/src/components/HyperJson.tsx | 7 +++++-- 2 files changed, 9 insertions(+), 4 deletions(-) diff --git a/.changeset/json-viewer-key-order.md b/.changeset/json-viewer-key-order.md index 944552550a..3c57bed530 100644 --- a/.changeset/json-viewer-key-order.md +++ b/.changeset/json-viewer-key-order.md @@ -7,5 +7,7 @@ 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. +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/HyperJson.tsx b/packages/app/src/components/HyperJson.tsx index 19d8710064..d56a93df01 100644 --- a/packages/app/src/components/HyperJson.tsx +++ b/packages/app/src/components/HyperJson.tsx @@ -43,8 +43,11 @@ export type FormatLeafValue = (arg0: { /** * How object keys are ordered at every level of the tree. * - * `original` keeps whatever order the data arrived in — for ClickHouse - * `Map(...)` columns that is physical storage order. + * `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';