From 1eb958553cb34558d648bbaee3c77220a5d5c05c Mon Sep 17 00:00:00 2001 From: Drew Davis Date: Wed, 22 Apr 2026 14:16:35 -0400 Subject: [PATCH] feat: Allow displaying group-by columns on LHS of table --- .changeset/selfish-toes-smoke.md | 7 + packages/api/openapi.json | 6 + .../external-api/__tests__/dashboards.test.ts | 2 + .../src/routers/external-api/v2/dashboards.ts | 7 + .../external-api/v2/utils/dashboards.ts | 3 +- packages/api/src/utils/zod.ts | 1 + .../components/ChartDisplaySettingsDrawer.tsx | 38 +++- .../DBEditTimeChartForm/EditTimeChartForm.tsx | 7 + packages/app/src/components/DBTableChart.tsx | 29 ++- .../__tests__/DBTableChart.test.tsx | 183 ++++++++++++++++++ .../e2e/components/ChartEditorComponent.ts | 70 +++++++ .../app/tests/e2e/features/dashboard.spec.ts | 170 ++++++++++++++++ .../tests/e2e/page-objects/DashboardPage.ts | 12 ++ .../src/core/renderChartConfig.ts | 10 +- packages/common-utils/src/types.ts | 1 + 15 files changed, 533 insertions(+), 13 deletions(-) create mode 100644 .changeset/selfish-toes-smoke.md diff --git a/.changeset/selfish-toes-smoke.md b/.changeset/selfish-toes-smoke.md new file mode 100644 index 0000000000..6d9526de33 --- /dev/null +++ b/.changeset/selfish-toes-smoke.md @@ -0,0 +1,7 @@ +--- +"@hyperdx/common-utils": patch +"@hyperdx/api": patch +"@hyperdx/app": patch +--- + +feat: Allow displaying group-by columns on LHS of table diff --git a/packages/api/openapi.json b/packages/api/openapi.json index 1cac9c86b6..bfc26a5650 100644 --- a/packages/api/openapi.json +++ b/packages/api/openapi.json @@ -1348,6 +1348,12 @@ "numberFormat": { "$ref": "#/components/schemas/NumberFormat", "description": "Number formatting options for displayed values." + }, + "groupByColumnsOnLeft": { + "type": "boolean", + "description": "When true, render Group By columns to the left of series columns in the table. Defaults to false (Group By columns on the right).\n", + "default": false, + "example": false } } }, diff --git a/packages/api/src/routers/external-api/__tests__/dashboards.test.ts b/packages/api/src/routers/external-api/__tests__/dashboards.test.ts index d7f791737e..be2cd5f869 100644 --- a/packages/api/src/routers/external-api/__tests__/dashboards.test.ts +++ b/packages/api/src/routers/external-api/__tests__/dashboards.test.ts @@ -2255,6 +2255,7 @@ describe('External API v2 Dashboards - new format', () => { thousandSeparated: true, average: true, }, + groupByColumnsOnLeft: true, }, }; @@ -3091,6 +3092,7 @@ describe('External API v2 Dashboards - new format', () => { thousandSeparated: true, average: true, }, + groupByColumnsOnLeft: true, }, }; diff --git a/packages/api/src/routers/external-api/v2/dashboards.ts b/packages/api/src/routers/external-api/v2/dashboards.ts index ca8b8ebd5b..22dcdd9e78 100644 --- a/packages/api/src/routers/external-api/v2/dashboards.ts +++ b/packages/api/src/routers/external-api/v2/dashboards.ts @@ -591,6 +591,13 @@ async function getSourceConnectionMismatches( * numberFormat: * $ref: '#/components/schemas/NumberFormat' * description: Number formatting options for displayed values. + * groupByColumnsOnLeft: + * type: boolean + * description: > + * When true, render Group By columns to the left of series columns + * in the table. Defaults to false (Group By columns on the right). + * default: false + * example: false * * NumberBuilderChartConfig: * type: object diff --git a/packages/api/src/routers/external-api/v2/utils/dashboards.ts b/packages/api/src/routers/external-api/v2/utils/dashboards.ts index f51dda6f66..504cf85cbe 100644 --- a/packages/api/src/routers/external-api/v2/utils/dashboards.ts +++ b/packages/api/src/routers/external-api/v2/utils/dashboards.ts @@ -241,7 +241,7 @@ const convertToExternalTileChartConfig = ( }; case DisplayType.Table: return { - ...pick(config, ['having', 'numberFormat']), + ...pick(config, ['having', 'numberFormat', 'groupByColumnsOnLeft']), displayType: config.displayType, sourceId, asRatio: @@ -423,6 +423,7 @@ export function convertToInternalTileConfig( 'numberFormat', 'having', 'orderBy', + 'groupByColumnsOnLeft', ]), displayType: DisplayType.Table, select: externalConfig.select.map(convertToInternalSelectItem), diff --git a/packages/api/src/utils/zod.ts b/packages/api/src/utils/zod.ts index 5eba7d5e3e..84181e5844 100644 --- a/packages/api/src/utils/zod.ts +++ b/packages/api/src/utils/zod.ts @@ -263,6 +263,7 @@ const externalDashboardTableChartConfigSchema = z.object({ orderBy: z.string().max(10000).optional(), asRatio: z.boolean().optional(), numberFormat: NumberFormatSchema.optional(), + groupByColumnsOnLeft: z.boolean().optional(), }); const externalDashboardTableRawSqlChartConfigSchema = diff --git a/packages/app/src/components/ChartDisplaySettingsDrawer.tsx b/packages/app/src/components/ChartDisplaySettingsDrawer.tsx index ff0ba5e0bb..b5b3b5b853 100644 --- a/packages/app/src/components/ChartDisplaySettingsDrawer.tsx +++ b/packages/app/src/components/ChartDisplaySettingsDrawer.tsx @@ -18,6 +18,7 @@ import { import { shouldFillNullsWithZero } from '@/ChartUtils'; import { FormatTime } from '@/useFormatTime'; +import { CheckBoxControlled } from './InputControlled'; import { DEFAULT_NUMBER_FORMAT, NumberFormatForm } from './NumberFormat'; export type ChartConfigDisplaySettings = Pick< @@ -26,7 +27,9 @@ export type ChartConfigDisplaySettings = Pick< | 'alignDateRangeToGranularity' | 'fillNulls' | 'compareToPreviousPeriod' ->; +> & { + groupByColumnsOnLeft?: boolean; +}; interface ChartDisplaySettingsDrawerProps { opened: boolean; @@ -35,6 +38,8 @@ interface ChartDisplaySettingsDrawerProps { * Used as the default when no explicit numberFormat is set. */ defaultNumberFormat?: NumberFormat; displayType: DisplayType; + /** 'sql' for raw SQL chart configs; anything else is treated as a builder config. */ + configType?: 'sql' | 'builder'; previousDateRange?: [Date, Date]; onChange: (settings: ChartConfigDisplaySettings) => void; onClose: () => void; @@ -53,6 +58,7 @@ function applyDefaultSettings( : settings.alignDateRangeToGranularity, fillNulls: settings.fillNulls ?? 0, compareToPreviousPeriod: settings.compareToPreviousPeriod ?? false, + groupByColumnsOnLeft: settings.groupByColumnsOnLeft ?? false, }; } @@ -60,6 +66,7 @@ export default function ChartDisplaySettingsDrawer({ settings, opened, displayType, + configType, defaultNumberFormat, onChange, onClose, @@ -70,7 +77,7 @@ export default function ChartDisplaySettingsDrawer({ [settings, defaultNumberFormat], ); - const { control, handleSubmit, register, reset, setValue } = + const { control, handleSubmit, reset, setValue } = useForm({ defaultValues: appliedDefaults, }); @@ -99,6 +106,11 @@ export default function ChartDisplaySettingsDrawer({ const isTimeChart = displayType === DisplayType.Line || displayType === DisplayType.StackedBar; + // Group By column ordering only applies to builder table charts; raw SQL + // configs let the user author whatever column order they want directly. + const showGroupByColumnsOnLeft = + displayType === DisplayType.Table && configType !== 'sql'; + return ( {isTimeChart && ( <> - - ) } - {...register('compareToPreviousPeriod')} + /> + + + )} + + {showGroupByColumnsOnLeft && ( + <> + diff --git a/packages/app/src/components/DBEditTimeChartForm/EditTimeChartForm.tsx b/packages/app/src/components/DBEditTimeChartForm/EditTimeChartForm.tsx index c0b893f5af..be498db46b 100644 --- a/packages/app/src/components/DBEditTimeChartForm/EditTimeChartForm.tsx +++ b/packages/app/src/components/DBEditTimeChartForm/EditTimeChartForm.tsx @@ -184,6 +184,7 @@ export default function EditTimeChartForm({ fillNulls, compareToPreviousPeriod, numberFormat, + groupByColumnsOnLeft, ] = useWatch({ control, name: [ @@ -191,6 +192,7 @@ export default function EditTimeChartForm({ 'fillNulls', 'compareToPreviousPeriod', 'numberFormat', + 'groupByColumnsOnLeft', ], }); @@ -209,12 +211,14 @@ export default function EditTimeChartForm({ fillNulls, compareToPreviousPeriod, numberFormat, + groupByColumnsOnLeft, }), [ alignDateRangeToGranularity, fillNulls, compareToPreviousPeriod, numberFormat, + groupByColumnsOnLeft, ], ); @@ -457,11 +461,13 @@ export default function EditTimeChartForm({ alignDateRangeToGranularity, fillNulls, compareToPreviousPeriod, + groupByColumnsOnLeft, }: ChartConfigDisplaySettings) => { setValue('numberFormat', numberFormat); setValue('alignDateRangeToGranularity', alignDateRangeToGranularity); setValue('fillNulls', fillNulls); setValue('compareToPreviousPeriod', compareToPreviousPeriod); + setValue('groupByColumnsOnLeft', groupByColumnsOnLeft); onSubmit(); }, [setValue, onSubmit], @@ -661,6 +667,7 @@ export default function EditTimeChartForm({ defaultNumberFormat={autoDetectedNumberFormat} previousDateRange={!dashboardId ? previousDateRange : undefined} displayType={displayType} + configType={configType} onChange={handleUpdateDisplaySettings} onClose={closeDisplaySettings} /> diff --git a/packages/app/src/components/DBTableChart.tsx b/packages/app/src/components/DBTableChart.tsx index cdbade2f20..6ec406d657 100644 --- a/packages/app/src/components/DBTableChart.tsx +++ b/packages/app/src/components/DBTableChart.tsx @@ -1,5 +1,6 @@ import { useCallback, useMemo, useState } from 'react'; import { ClickHouseQueryError } from '@hyperdx/common-utils/dist/clickhouse'; +import { isRatioChartConfig } from '@hyperdx/common-utils/dist/core/renderChartConfig'; import { isBuilderChartConfig, isRawSqlChartConfig, @@ -127,16 +128,36 @@ export default function DBTableChart({ return []; } + const firstRow = rows.at(0); + const allKeys = firstRow ? Object.keys(firstRow) : []; + + // We extract groupBy keys by counting the series columns to avoid parsing + // the groupBy string, which may have complex expressions and aliases, making + // it difficult to reliably parse out the individual group by keys. let groupByKeys: string[] = []; if ( isBuilderChartConfig(queriedConfig) && - queriedConfig.groupBy && - typeof queriedConfig.groupBy === 'string' + Array.isArray(queriedConfig.select) + ) { + const isRatio = isRatioChartConfig(queriedConfig.select, queriedConfig); + const seriesCount = isRatio ? 1 : queriedConfig.select.length; + const groupByCount = allKeys.length - seriesCount; + groupByKeys = groupByCount > 0 ? allKeys.slice(-groupByCount) : []; + } + + // Builder table configs may opt to render Group By columns + // to the left of series columns. + let orderedKeys = [...allKeys]; + if ( + isBuilderChartConfig(queriedConfig) && + queriedConfig.groupByColumnsOnLeft && + Array.isArray(queriedConfig.select) ) { - groupByKeys = queriedConfig.groupBy.split(',').map(v => v.trim()); + const seriesKeys = allKeys.filter(key => !groupByKeys.includes(key)); + orderedKeys = [...groupByKeys, ...seriesKeys]; } - return Object.keys(rows?.[0]) + return orderedKeys .filter(key => !hiddenColumns?.includes(key)) .map(key => ({ // If it's an alias, wrap in quotes to support a variety of formats (ex "Time (ms)", "Req/s", etc) diff --git a/packages/app/src/components/__tests__/DBTableChart.test.tsx b/packages/app/src/components/__tests__/DBTableChart.test.tsx index 34e979dab4..aff74a2f65 100644 --- a/packages/app/src/components/__tests__/DBTableChart.test.tsx +++ b/packages/app/src/components/__tests__/DBTableChart.test.tsx @@ -1,5 +1,6 @@ import React from 'react'; +import { Table } from '@/HDXMultiSeriesTableChart'; import { useMVOptimizationExplanation } from '@/hooks/useMVOptimizationExplanation'; import useOffsetPaginatedQuery from '@/hooks/useOffsetPaginatedQuery'; import { useSource } from '@/source'; @@ -27,6 +28,11 @@ jest.mock('@/source', () => ({ useResolvedNumberFormat: jest.fn().mockReturnValue(undefined), })); +jest.mock('@/HDXMultiSeriesTableChart', () => ({ + __esModule: true, + Table: jest.fn(() => null), +})); + jest.mock('../MaterializedViews/MVOptimizationIndicator', () => jest.fn(() => null), ); @@ -150,6 +156,183 @@ describe('DBTableChart', () => { expect(dateRangeIndicatorCall.mvGranularity).toBe('1 minute'); }); + describe('groupByColumnsOnLeft', () => { + // Emulates how the ClickHouse query returns rows for a builder table chart: + // series columns are produced before groupBy columns. + beforeEach(() => { + jest.mocked(useOffsetPaginatedQuery).mockReturnValue({ + data: { + data: [ + { + Count: 10, + AvgDuration: 42, + ServiceName: 'web', + SpanName: 'GET /', + }, + ], + meta: [], + chSql: { sql: '', params: {} }, + window: { + startTime: new Date(), + endTime: new Date(), + windowIndex: 0, + direction: 'DESC' as const, + }, + }, + fetchNextPage: jest.fn(), + hasNextPage: false, + isLoading: false, + isFetching: false, + isError: false, + error: null, + } as any); + }); + + const configWithGroupBy = { + ...baseTestConfig, + select: [ + { aggFn: 'count' as const, valueExpression: '', alias: 'Count' }, + { + aggFn: 'avg' as const, + valueExpression: 'Duration', + alias: 'AvgDuration', + }, + ], + groupBy: 'ServiceName, SpanName', + }; + + it('preserves the row key order (series, then groupBy) by default', () => { + renderWithMantine(); + + const columns = jest.mocked(Table).mock.calls.at(-1)![0].columns; + expect(columns.map(c => c.dataKey)).toEqual([ + 'Count', + 'AvgDuration', + 'ServiceName', + 'SpanName', + ]); + }); + + it('moves groupBy columns to the left when groupByColumnsOnLeft is true', () => { + renderWithMantine( + , + ); + + const columns = jest.mocked(Table).mock.calls.at(-1)![0].columns; + expect(columns.map(c => c.dataKey)).toEqual([ + 'ServiceName', + 'SpanName', + 'Count', + 'AvgDuration', + ]); + }); + + it('treats ratio configs as a single series column when moving groupBy columns left', () => { + // With seriesReturnType === 'ratio' and two selects, ClickHouse returns a + // single computed column for the ratio — not one column per select. The + // row shape reflects this: 1 series column followed by the groupBy + // columns. + jest.mocked(useOffsetPaginatedQuery).mockReturnValue({ + data: { + data: [ + { + 'divide(count(), count())': 0.5, + ServiceName: 'web', + SpanName: 'GET /', + }, + ], + meta: [], + chSql: { sql: '', params: {} }, + window: { + startTime: new Date(), + endTime: new Date(), + windowIndex: 0, + direction: 'DESC' as const, + }, + }, + fetchNextPage: jest.fn(), + hasNextPage: false, + isLoading: false, + isFetching: false, + isError: false, + error: null, + } as any); + + const ratioConfig = { + ...baseTestConfig, + select: [ + { aggFn: 'count' as const, valueExpression: '', alias: 'Numerator' }, + { + aggFn: 'count' as const, + valueExpression: '', + alias: 'Denominator', + }, + ], + groupBy: 'ServiceName, SpanName', + seriesReturnType: 'ratio' as const, + groupByColumnsOnLeft: true, + }; + + renderWithMantine(); + + const columns = jest.mocked(Table).mock.calls.at(-1)![0].columns; + expect(columns.map(c => c.dataKey)).toEqual([ + 'ServiceName', + 'SpanName', + 'divide(count(), count())', + ]); + }); + + it('does not reorder columns for raw SQL configs even when the flag is set', () => { + const rawSqlConfig = { + configType: 'sql' as const, + dateRange: [new Date(), new Date()] as [Date, Date], + connection: 'test-connection', + sqlTemplate: 'SELECT Count, AvgDuration, ServiceName, SpanName FROM t', + groupByColumnsOnLeft: true, + }; + + jest.mocked(useOffsetPaginatedQuery).mockReturnValue({ + data: { + data: [ + { + Count: 10, + AvgDuration: 42, + ServiceName: 'web', + SpanName: 'GET /', + }, + ], + meta: [], + chSql: { sql: '', params: {} }, + window: { + startTime: new Date(), + endTime: new Date(), + windowIndex: 0, + direction: 'DESC' as const, + }, + }, + fetchNextPage: jest.fn(), + hasNextPage: false, + isLoading: false, + isFetching: false, + isError: false, + error: null, + } as any); + + renderWithMantine(); + + const columns = jest.mocked(Table).mock.calls.at(-1)![0].columns; + expect(columns.map(c => c.dataKey)).toEqual([ + 'Count', + 'AvgDuration', + 'ServiceName', + 'SpanName', + ]); + }); + }); + it('does not render DateRangeIndicator when MV optimization has no optimized date range', () => { // Mock useMVOptimizationExplanation to return data without an optimized config jest.mocked(useMVOptimizationExplanation).mockReturnValue({ diff --git a/packages/app/tests/e2e/components/ChartEditorComponent.ts b/packages/app/tests/e2e/components/ChartEditorComponent.ts index fb836bce94..ae2e4bc17e 100644 --- a/packages/app/tests/e2e/components/ChartEditorComponent.ts +++ b/packages/app/tests/e2e/components/ChartEditorComponent.ts @@ -305,6 +305,76 @@ export class ChartEditorComponent { await input.blur(); } + /** + * Open the Display Settings drawer and wait for it to become visible. + */ + async openDisplaySettings() { + await this.page + .getByRole('button', { name: 'Display Settings', exact: true }) + .click(); + const drawer = this.page.getByRole('dialog', { name: 'Display Settings' }); + await drawer.waitFor({ state: 'visible', timeout: 5000 }); + } + + /** + * Toggle the "Display Group By Columns on Left" checkbox in the open + * Display Settings drawer to the given state. + */ + async setGroupByColumnsOnLeft(checked: boolean) { + const drawer = this.page.getByRole('dialog', { name: 'Display Settings' }); + const checkbox = drawer.getByLabel('Display Group By Columns on Left'); + const isChecked = await checkbox.isChecked(); + if (isChecked !== checked) { + await checkbox.click(); + } + } + + /** + * Click Apply in the open Display Settings drawer and wait for it to close. + */ + async applyDisplaySettings() { + const drawer = this.page.getByRole('dialog', { name: 'Display Settings' }); + await drawer.getByRole('button', { name: 'Apply', exact: true }).click(); + await drawer.waitFor({ state: 'hidden', timeout: 5000 }); + } + + /** + * Click the "Add Series" button to add a new series to the chart. + */ + async addSeries() { + await this.page + .getByRole('button', { name: 'Add Series', exact: true }) + .click(); + } + + /** + * Toggle the "As Ratio" switch. Only visible when the chart has exactly + * two series. + */ + async toggleAsRatio() { + await this.page.getByRole('switch', { name: 'As Ratio' }).click(); + } + + /** + * Set the alias for a series by zero-based index. Useful for giving two + * default `count()` series distinct column names in a multi-series table. + */ + async setSeriesAlias(index: number, alias: string) { + await this.page.getByTestId('series-alias-input').nth(index).fill(alias); + } + + /** + * Read the column header texts from the first in the tile editor + * preview panel. Waits for the table to be visible before reading. + */ + async getPreviewTableHeaders(): Promise { + const modalBody = this.page.locator('.mantine-Modal-body'); + const table = modalBody.locator('table').first(); + await table.waitFor({ state: 'visible', timeout: 15000 }); + const headers = await table.locator('thead tr th').allTextContents(); + return headers.map(h => h.trim()); + } + // Getters for assertions get nameInput() { diff --git a/packages/app/tests/e2e/features/dashboard.spec.ts b/packages/app/tests/e2e/features/dashboard.spec.ts index 1c90b95ad1..a3984fab45 100644 --- a/packages/app/tests/e2e/features/dashboard.spec.ts +++ b/packages/app/tests/e2e/features/dashboard.spec.ts @@ -969,4 +969,174 @@ test.describe('Dashboard', { tag: ['@dashboard'] }, () => { }); }, ); + + test.describe( + 'Table chart - Display Group By Columns on Left', + { tag: ['@full-stack', '@dashboard'] }, + () => { + test.beforeEach(async () => { + await dashboardPage.createNewDashboard(); + }); + + test('should move multiple group-by columns to the left when enabled', async () => { + test.setTimeout(60000); + const ts = Date.now(); + const chartName = `E2E GroupBy LHS Multi ${ts}`; + let defaultHeaders: string[] = []; + + await test.step('Configure a Table chart with two group-by columns', async () => { + await dashboardPage.addTile(); + await expect(dashboardPage.chartEditor.nameInput).toBeVisible(); + await dashboardPage.chartEditor.waitForDataToLoad(); + await dashboardPage.chartEditor.setChartType(DisplayType.Table); + await dashboardPage.chartEditor.selectSource( + DEFAULT_LOGS_SOURCE_NAME, + ); + await dashboardPage.chartEditor.setChartName(chartName); + await dashboardPage.chartEditor.setGroupBy( + 'ServiceName, SeverityText', + ); + }); + + await test.step('Default order: series column first, group-by columns after', async () => { + await dashboardPage.chartEditor.runQuery(false); + defaultHeaders = + await dashboardPage.chartEditor.getPreviewTableHeaders(); + + const svcIdx = defaultHeaders.indexOf('ServiceName'); + const sevIdx = defaultHeaders.indexOf('SeverityText'); + expect(svcIdx).toBeGreaterThan(-1); + expect(sevIdx).toBeGreaterThan(-1); + const seriesIdx = defaultHeaders.findIndex( + h => h !== 'ServiceName' && h !== 'SeverityText', + ); + expect(seriesIdx).toBeGreaterThan(-1); + expect(seriesIdx).toBeLessThan(svcIdx); + expect(seriesIdx).toBeLessThan(sevIdx); + }); + + await test.step('Enable "Display Group By Columns on Left" and verify reorder', async () => { + await dashboardPage.chartEditor.openDisplaySettings(); + await dashboardPage.chartEditor.setGroupByColumnsOnLeft(true); + await dashboardPage.chartEditor.applyDisplaySettings(); + + const headersAfter = + await dashboardPage.chartEditor.getPreviewTableHeaders(); + expect(headersAfter.length).toBe(defaultHeaders.length); + expect(headersAfter[0]).toBe('ServiceName'); + expect(headersAfter[1]).toBe('SeverityText'); + expect(['ServiceName', 'SeverityText']).not.toContain( + headersAfter[headersAfter.length - 1], + ); + }); + + await test.step('Save the tile and verify it renders on the dashboard', async () => { + await dashboardPage.saveTile(); + const tile = dashboardPage.getTiles().filter({ hasText: chartName }); + await expect(tile.locator('table')).toBeVisible({ timeout: 15000 }); + }); + }); + + test('should move a single group-by column to the left when multiple series are present', async () => { + test.setTimeout(60000); + const ts = Date.now(); + const chartName = `E2E GroupBy LHS MultiSeries ${ts}`; + let defaultHeaders: string[] = []; + + await test.step('Configure a Table chart with one group-by and two series', async () => { + await dashboardPage.addTile(); + await expect(dashboardPage.chartEditor.nameInput).toBeVisible(); + await dashboardPage.chartEditor.waitForDataToLoad(); + await dashboardPage.chartEditor.setChartType(DisplayType.Table); + await dashboardPage.chartEditor.selectSource( + DEFAULT_LOGS_SOURCE_NAME, + ); + await dashboardPage.chartEditor.setChartName(chartName); + await dashboardPage.chartEditor.setGroupBy('ServiceName'); + await dashboardPage.chartEditor.addSeries(); + // Distinct aliases so the two count() series render as two columns + // instead of colliding into one. + await dashboardPage.chartEditor.setSeriesAlias(0, 'SeriesOne'); + await dashboardPage.chartEditor.setSeriesAlias(1, 'SeriesTwo'); + }); + + await test.step('Default order: series columns first, group-by column last', async () => { + await dashboardPage.chartEditor.runQuery(false); + defaultHeaders = + await dashboardPage.chartEditor.getPreviewTableHeaders(); + expect(defaultHeaders).toEqual([ + 'SeriesOne', + 'SeriesTwo', + 'ServiceName', + ]); + }); + + await test.step('Enable "Display Group By Columns on Left" and verify reorder', async () => { + await dashboardPage.chartEditor.openDisplaySettings(); + await dashboardPage.chartEditor.setGroupByColumnsOnLeft(true); + await dashboardPage.chartEditor.applyDisplaySettings(); + + const headersAfter = + await dashboardPage.chartEditor.getPreviewTableHeaders(); + expect(headersAfter).toEqual([ + 'ServiceName', + 'SeriesOne', + 'SeriesTwo', + ]); + }); + + await test.step('Save the tile and verify it renders on the dashboard', async () => { + await dashboardPage.saveTile(); + const tile = dashboardPage.getTiles().filter({ hasText: chartName }); + await expect(tile.locator('table')).toBeVisible({ timeout: 15000 }); + }); + }); + + test('should move the group-by column to the left for ratio series return type', async () => { + test.setTimeout(60000); + const ts = Date.now(); + const chartName = `E2E GroupBy LHS Ratio ${ts}`; + let defaultHeaders: string[] = []; + + await test.step('Configure a Table chart with ratio series and one group-by', async () => { + await dashboardPage.addTile(); + await expect(dashboardPage.chartEditor.nameInput).toBeVisible(); + await dashboardPage.chartEditor.waitForDataToLoad(); + await dashboardPage.chartEditor.setChartType(DisplayType.Table); + await dashboardPage.chartEditor.selectSource( + DEFAULT_LOGS_SOURCE_NAME, + ); + await dashboardPage.chartEditor.setChartName(chartName); + await dashboardPage.chartEditor.setGroupBy('ServiceName'); + await dashboardPage.chartEditor.addSeries(); + await dashboardPage.chartEditor.toggleAsRatio(); + }); + + await test.step('Default order: single ratio column first, group-by column last', async () => { + await dashboardPage.chartEditor.runQuery(false); + defaultHeaders = + await dashboardPage.chartEditor.getPreviewTableHeaders(); + expect(defaultHeaders.length).toBe(2); + expect(defaultHeaders[defaultHeaders.length - 1]).toBe('ServiceName'); + }); + + await test.step('Enable "Display Group By Columns on Left" and verify reorder', async () => { + await dashboardPage.chartEditor.openDisplaySettings(); + await dashboardPage.chartEditor.setGroupByColumnsOnLeft(true); + await dashboardPage.chartEditor.applyDisplaySettings(); + + const headersAfter = + await dashboardPage.chartEditor.getPreviewTableHeaders(); + expect(headersAfter.length).toBe(2); + expect(headersAfter[0]).toBe('ServiceName'); + }); + + await test.step('Save the tile and verify it renders on the dashboard', async () => { + await dashboardPage.saveTile(); + const tile = dashboardPage.getTiles().filter({ hasText: chartName }); + await expect(tile.locator('table')).toBeVisible({ timeout: 15000 }); + }); + }); + }, + ); }); diff --git a/packages/app/tests/e2e/page-objects/DashboardPage.ts b/packages/app/tests/e2e/page-objects/DashboardPage.ts index c21b470956..8d41eed34e 100644 --- a/packages/app/tests/e2e/page-objects/DashboardPage.ts +++ b/packages/app/tests/e2e/page-objects/DashboardPage.ts @@ -369,6 +369,18 @@ export class DashboardPage { return this.page.locator('.recharts-responsive-container'); } + /** + * Read the trimmed text of all th cells in the first thead tr of the + * given tile's rendered table. Waits for the table to be visible first. + */ + async getTileTableHeaders(tileIndex: number): Promise { + const tile = this.getTile(tileIndex); + const table = tile.locator('table').first(); + await table.waitFor({ state: 'visible', timeout: 15000 }); + const headers = await table.locator('thead tr th').allTextContents(); + return headers.map(h => h.trim()); + } + /** Open the Edit Filters Modal */ async openEditFiltersModal() { await this.editFiltersButton.click(); diff --git a/packages/common-utils/src/core/renderChartConfig.ts b/packages/common-utils/src/core/renderChartConfig.ts index 72b24d5d5e..681dfb4099 100644 --- a/packages/common-utils/src/core/renderChartConfig.ts +++ b/packages/common-utils/src/core/renderChartConfig.ts @@ -491,6 +491,13 @@ const aggFnExpr = ({ } }; +export function isRatioChartConfig( + selectList: SelectList, + chartConfig: BuilderChartConfigWithOptDateRangeEx, +): boolean { + return chartConfig.seriesReturnType === 'ratio' && selectList.length === 2; +} + async function renderSelectList( selectList: SelectList, chartConfig: BuilderChartConfigWithOptDateRangeEx, @@ -520,8 +527,7 @@ async function renderSelectList( // ignore } - const isRatio = - chartConfig.seriesReturnType === 'ratio' && selectList.length === 2; + const isRatio = isRatioChartConfig(selectList, chartConfig); const selectsSQL = await Promise.all( selectList.map(async select => { diff --git a/packages/common-utils/src/types.ts b/packages/common-utils/src/types.ts index f0a27b8b0d..e596655ceb 100644 --- a/packages/common-utils/src/types.ts +++ b/packages/common-utils/src/types.ts @@ -697,6 +697,7 @@ export const _ChartConfigSchema = SharedChartDisplaySettingsSchema.extend({ // Used to preserve original table select string when chart overrides it (e.g., histograms) eventTableSelect: z.string().optional(), source: z.string().optional(), + groupByColumnsOnLeft: z.boolean().optional(), }); // This is a ChartConfig type without the `with` CTE clause included.