({
+ __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');
+ });
+});