From e5b2148a5c85a35095ff8dc6e0074fab3d4f7932 Mon Sep 17 00:00:00 2001 From: Alex Fedotyev <61838744+alex-fedotyev@users.noreply.github.com> Date: Wed, 24 Jun 2026 19:01:47 +0000 Subject: [PATCH 1/6] feat(dashboard): table tile header separator and optional alternate row background Add an always-on 1px separator between a table tile's sticky header and its rows, and a new "Alternate Row Background" display setting (off by default) that zebra-stripes builder table tiles. Both work in light and dark color modes. - Separator: inset box-shadow on the sticky thead using `--color-border`, so it stays pinned under `border-collapse: collapse` where a collapsed border would scroll away with the cell content. - Striping: new optional `alternateRowBackground` boolean on the builder chart config, gated in Display Settings to builder table tiles (mirrors `groupByColumnsOnLeft`). Renders via a new `--color-bg-table-stripe` token defined in both the hyperdx and clickstack themes. Striped rows use the stronger `--color-bg-highlighted` on hover so the row under the cursor stays visible over a stripe. HDX-4601 Co-Authored-By: Claude Opus 4.8 --- .changeset/table-tile-alternate-rows.md | 8 ++ .../app/src/HDXMultiSeriesTable.stories.tsx | 63 +++++++++++++++ .../src/HDXMultiSeriesTableChart.module.scss | 27 +++++++ packages/app/src/HDXMultiSeriesTableChart.tsx | 12 +++ .../HDXMultiSeriesTableChart.test.tsx | 49 ++++++++++++ .../components/ChartDisplaySettingsDrawer.tsx | 8 ++ .../DBEditTimeChartForm/EditTimeChartForm.tsx | 6 ++ packages/app/src/components/DBTableChart.tsx | 5 ++ .../ChartDisplaySettingsDrawer.test.tsx | 79 +++++++++++++++++++ .../__tests__/DBTableChart.test.tsx | 45 +++++++++++ .../src/theme/themes/clickstack/_tokens.scss | 4 +- .../app/src/theme/themes/hyperdx/_tokens.scss | 4 +- packages/common-utils/src/types.ts | 5 ++ 13 files changed, 313 insertions(+), 2 deletions(-) create mode 100644 .changeset/table-tile-alternate-rows.md create mode 100644 packages/app/src/HDXMultiSeriesTable.stories.tsx diff --git a/.changeset/table-tile-alternate-rows.md b/.changeset/table-tile-alternate-rows.md new file mode 100644 index 0000000000..8bc9c39aea --- /dev/null +++ b/.changeset/table-tile-alternate-rows.md @@ -0,0 +1,8 @@ +--- +"@hyperdx/app": patch +"@hyperdx/common-utils": patch +--- + +feat(dashboard): table tile header separator and optional alternate row background + +Add an always-on separator between a table tile's sticky header and its rows so the boundary stays clear as rows scroll underneath. Add a new **Alternate Row Background** display setting (off by default) that zebra-stripes builder table tiles for easier scanning on wide tables. Both work in light and dark color modes. diff --git a/packages/app/src/HDXMultiSeriesTable.stories.tsx b/packages/app/src/HDXMultiSeriesTable.stories.tsx new file mode 100644 index 0000000000..5289957df8 --- /dev/null +++ b/packages/app/src/HDXMultiSeriesTable.stories.tsx @@ -0,0 +1,63 @@ +import React from 'react'; +import type { Meta, StoryObj } from '@storybook/nextjs'; + +import { Table } from './HDXMultiSeriesTableChart'; + +const columns = [ + { id: 'service', dataKey: 'ServiceName', displayName: 'Service' }, + { id: 'count', dataKey: 'Count', displayName: 'Count' }, + { id: 'p95', dataKey: 'P95', displayName: 'p95 (ms)' }, +]; + +const data = Array.from({ length: 18 }, (_, i) => ({ + ServiceName: `service-${i + 1}`, + Count: (i + 1) * 137, + P95: 40 + i * 11, +})); + +// Fixed-size frame so the virtual list renders rows and the sticky header +// has content to scroll over, surfacing the header separator. +const meta: Meta = { + title: 'HDXMultiSeriesTableChart', + component: Table, + parameters: { layout: 'padded' }, + decorators: [ + Story => ( +
+ +
+ ), + ], + args: { + data, + columns, + sorting: [], + onSortingChange: () => {}, + }, +}; + +export default meta; + +type Story = StoryObj; + +// Header separator only; rows share the body background. +export const Plain: Story = { + args: { alternateRowBackground: false }, +}; + +// Zebra striping on odd rows plus the always-on header separator. +export const Striped: Story = { + args: { alternateRowBackground: true }, +}; + +// Striped rows that also resolve to a click destination, so the stronger +// hover background can be checked over a stripe. +export const StripedWithRowActions: Story = { + args: { + alternateRowBackground: true, + getRowAction: (row: { ServiceName: string }) => ({ + url: `/search?service=${row.ServiceName}`, + description: `Search ${row.ServiceName}`, + }), + }, +}; diff --git a/packages/app/src/HDXMultiSeriesTableChart.module.scss b/packages/app/src/HDXMultiSeriesTableChart.module.scss index 416ffe0642..bd8559a884 100644 --- a/packages/app/src/HDXMultiSeriesTableChart.module.scss +++ b/packages/app/src/HDXMultiSeriesTableChart.module.scss @@ -4,6 +4,16 @@ // .rowButtons pattern from LogTable.module.scss (lines 156-172), // scoped down to a single icon. See HDX-4405. +.tableHeader { + // Always-on 1px separator between the sticky header and the rows. An + // inset box-shadow rather than a border because the table sets + // `border-collapse: collapse`, under which a collapsed `border-bottom` + // on a `position: sticky` header renders unreliably (it can scroll away + // with the cell content). The shadow paints on the sticky element + // itself, so it stays pinned as rows scroll underneath. + box-shadow: inset 0 -1px 0 0 var(--color-border); +} + .tableRow { // Row hover is the visibility trigger for the trailing-icon hint. // We rely on a descendant selector rather than wrapping a @@ -27,6 +37,23 @@ } } +.stripedRow { + // Opt-in zebra striping for table tiles, applied to odd rows. + background-color: var(--color-bg-table-stripe); +} + +.tableRow.stripedRow:hover { + // A striped row already sits a few percent off the body background, close + // enough to `--color-bg-muted` that the shared `.bg-muted-hover` hover + // would be nearly invisible on it (the stripe and the muted hover land on + // the same luminance in both light and dark). Promote the hover on striped + // rows to the stronger `--color-bg-highlighted` so the row under the cursor + // stays obvious. The compound `.tableRow.stripedRow` selector (0,3,0) + // outranks both `.bg-muted-hover:hover` and `.actionableRow:hover` (0,2,0), + // so it wins deterministically regardless of stylesheet order. + background-color: var(--color-bg-highlighted); +} + .lastCell { // Containing block for the absolute-positioned .rowActionHint. // does not reliably form a positioning context across diff --git a/packages/app/src/HDXMultiSeriesTableChart.tsx b/packages/app/src/HDXMultiSeriesTableChart.tsx index 23544d5e17..543d615962 100644 --- a/packages/app/src/HDXMultiSeriesTableChart.tsx +++ b/packages/app/src/HDXMultiSeriesTableChart.tsx @@ -50,6 +50,7 @@ export const Table = ({ sorting, onSortingChange, variant = 'default', + alternateRowBackground = false, }: { data: any[]; columns: { @@ -80,6 +81,11 @@ export const Table = ({ sorting: SortingState; onSortingChange: (sorting: SortingState) => void; variant?: TableVariant; + // Zebra striping for dashboard table tiles. When true, odd-indexed rows + // get a subtle `--color-bg-table-stripe` background. Off by default. A + // dedicated `.stripedRow` hover rule keeps row hover visible over a stripe + // (see the CSS module). + alternateRowBackground?: boolean; }) => { const brandName = useBrandDisplayName(); const MIN_COLUMN_WIDTH_PX = 100; @@ -332,6 +338,7 @@ export const Table = ({ }} > ', () => { expect(screen.queryByTestId('row-action-hint')).toBeNull(); }); }); + + describe('alternate row background', () => { + const stripeData = [ + { ServiceName: 'web', Count: 10 }, + { ServiceName: 'api', Count: 20 }, + { ServiceName: 'db', Count: 30 }, + { ServiceName: 'cache', Count: 40 }, + ]; + + it('stripes odd-index rows when alternateRowBackground is true', () => { + const { container } = renderWithMantine( + {}} + />, + ); + + const rows = container.querySelectorAll('tbody tr[data-index]'); + expect(rows.length).toBe(stripeData.length); + rows.forEach(row => { + const index = Number(row.getAttribute('data-index')); + if (index % 2 === 1) { + expect(row.className).toContain('stripedRow'); + } else { + expect(row.className).not.toContain('stripedRow'); + } + }); + }); + + it('does not stripe any row when alternateRowBackground is omitted', () => { + const { container } = renderWithMantine( +
{}} + />, + ); + + const rows = container.querySelectorAll('tbody tr[data-index]'); + expect(rows.length).toBe(stripeData.length); + rows.forEach(row => { + expect(row.className).not.toContain('stripedRow'); + }); + }); + }); }); diff --git a/packages/app/src/components/ChartDisplaySettingsDrawer.tsx b/packages/app/src/components/ChartDisplaySettingsDrawer.tsx index 6c6cab067c..9a76e7886f 100644 --- a/packages/app/src/components/ChartDisplaySettingsDrawer.tsx +++ b/packages/app/src/components/ChartDisplaySettingsDrawer.tsx @@ -45,6 +45,7 @@ export type ChartConfigDisplaySettings = Pick< | 'backgroundChart' > & { groupByColumnsOnLeft?: boolean; + alternateRowBackground?: boolean; // Per-tile cap on the number of series fetched for a group-by time chart. // null/undefined = disabled (no __hdx_series_limit CTE; every series is // fetched). The editor clears to `null` (not `undefined`) so the cleared @@ -90,6 +91,7 @@ function applyDefaultSettings( compareToPreviousPeriod: settings.compareToPreviousPeriod ?? false, fitYAxisToData: settings.fitYAxisToData ?? false, groupByColumnsOnLeft: settings.groupByColumnsOnLeft ?? false, + alternateRowBackground: settings.alternateRowBackground ?? false, // Coerce to null so `reset` clears the input; undefined leaves the // previously registered field value in place. seriesLimit: settings.seriesLimit ?? null, @@ -278,6 +280,12 @@ export default function ChartDisplaySettingsDrawer({ size="xs" label="Display Group By Columns on Left" /> + )} diff --git a/packages/app/src/components/DBEditTimeChartForm/EditTimeChartForm.tsx b/packages/app/src/components/DBEditTimeChartForm/EditTimeChartForm.tsx index e31ae52c3b..b0facb648a 100644 --- a/packages/app/src/components/DBEditTimeChartForm/EditTimeChartForm.tsx +++ b/packages/app/src/components/DBEditTimeChartForm/EditTimeChartForm.tsx @@ -244,6 +244,7 @@ export default function EditTimeChartForm({ fitYAxisToData, numberFormat, groupByColumnsOnLeft, + alternateRowBackground, seriesLimit, color, colorRules, @@ -257,6 +258,7 @@ export default function EditTimeChartForm({ 'fitYAxisToData', 'numberFormat', 'groupByColumnsOnLeft', + 'alternateRowBackground', 'seriesLimit', 'color', 'colorRules', @@ -286,6 +288,7 @@ export default function EditTimeChartForm({ fitYAxisToData, numberFormat, groupByColumnsOnLeft, + alternateRowBackground, seriesLimit, color, colorRules, @@ -298,6 +301,7 @@ export default function EditTimeChartForm({ fitYAxisToData, numberFormat, groupByColumnsOnLeft, + alternateRowBackground, seriesLimit, color, colorRules, @@ -593,6 +597,7 @@ export default function EditTimeChartForm({ compareToPreviousPeriod, fitYAxisToData, groupByColumnsOnLeft, + alternateRowBackground, seriesLimit, color, colorRules, @@ -609,6 +614,7 @@ export default function EditTimeChartForm({ setValue('compareToPreviousPeriod', compareToPreviousPeriod); setValue('fitYAxisToData', fitYAxisToData); setValue('groupByColumnsOnLeft', groupByColumnsOnLeft); + setValue('alternateRowBackground', alternateRowBackground); // Persist `null` (not undefined) when cleared so the disabled state // survives JSON round-tripping through the URL query state; otherwise // the dropped key lets RHF's `values` sync restore the stale value. diff --git a/packages/app/src/components/DBTableChart.tsx b/packages/app/src/components/DBTableChart.tsx index 41bd3f0f5b..060b4050ef 100644 --- a/packages/app/src/components/DBTableChart.tsx +++ b/packages/app/src/components/DBTableChart.tsx @@ -248,6 +248,11 @@ export default function DBTableChart({ enableClientSideSorting={isRawSqlChartConfig(config)} onSortingChange={handleSortingChange} variant={variant} + alternateRowBackground={ + isBuilderChartConfig(queriedConfig) + ? !!queriedConfig.alternateRowBackground + : false + } tableBottom={ hasNextPage && ( diff --git a/packages/app/src/components/__tests__/ChartDisplaySettingsDrawer.test.tsx b/packages/app/src/components/__tests__/ChartDisplaySettingsDrawer.test.tsx index 58dc9dbe4a..2b4cb5ca2f 100644 --- a/packages/app/src/components/__tests__/ChartDisplaySettingsDrawer.test.tsx +++ b/packages/app/src/components/__tests__/ChartDisplaySettingsDrawer.test.tsx @@ -264,6 +264,85 @@ describe('ChartDisplaySettingsDrawer', () => { }); }); + describe('alternate row background setting', () => { + const builderProps = { ...baseProps, configType: 'builder' as const }; + + it('shows the toggle for builder table charts', () => { + renderWithMantine( + , + ); + + expect( + screen.getByRole('checkbox', { name: /alternate row background/i }), + ).toBeInTheDocument(); + }); + + it('does not show the toggle for raw SQL table charts', () => { + renderWithMantine( + , + ); + + expect( + screen.queryByRole('checkbox', { name: /alternate row background/i }), + ).not.toBeInTheDocument(); + }); + + it('does not show the toggle for line charts', () => { + renderWithMantine( + , + ); + + expect( + screen.queryByRole('checkbox', { name: /alternate row background/i }), + ).not.toBeInTheDocument(); + }); + + it('does not show the toggle for number tiles', () => { + renderWithMantine( + , + ); + + expect( + screen.queryByRole('checkbox', { name: /alternate row background/i }), + ).not.toBeInTheDocument(); + }); + + it('calls onChange with alternateRowBackground = true when enabled and applied', async () => { + const onChange = jest.fn(); + const user = userEvent.setup(); + + renderWithMantine( + , + ); + + await user.click( + screen.getByRole('checkbox', { name: /alternate row background/i }), + ); + await user.click(screen.getByRole('button', { name: /apply/i })); + + expect(onChange).toHaveBeenCalledTimes(1); + expect(onChange.mock.calls[0][0]).toMatchObject({ + alternateRowBackground: true, + }); + }); + }); + describe('number format persistence', () => { // A duration number tile (e.g. p95 Duration from a trace source) auto-detects // a duration format from the datasource; the drawer receives it as diff --git a/packages/app/src/components/__tests__/DBTableChart.test.tsx b/packages/app/src/components/__tests__/DBTableChart.test.tsx index 9ead54919f..31bd1923d3 100644 --- a/packages/app/src/components/__tests__/DBTableChart.test.tsx +++ b/packages/app/src/components/__tests__/DBTableChart.test.tsx @@ -343,6 +343,51 @@ describe('DBTableChart', () => { }); }); + describe('alternateRowBackground', () => { + const builderConfig = { + ...baseTestConfig, + select: [ + { aggFn: 'count' as const, valueExpression: '', alias: 'Count' }, + ], + }; + + it('threads alternateRowBackground to the Table for builder configs', () => { + renderWithMantine( + , + ); + + expect( + jest.mocked(Table).mock.calls.at(-1)![0].alternateRowBackground, + ).toBe(true); + }); + + it('passes alternateRowBackground=false when the builder config omits it', () => { + renderWithMantine(); + + expect( + jest.mocked(Table).mock.calls.at(-1)![0].alternateRowBackground, + ).toBe(false); + }); + + it('passes alternateRowBackground=false for raw SQL configs even when set', () => { + const rawSqlConfig = { + configType: 'sql' as const, + dateRange: [new Date(), new Date()] as [Date, Date], + connection: 'test-connection', + sqlTemplate: 'SELECT count() AS Count FROM t', + alternateRowBackground: true, + }; + + renderWithMantine(); + + expect( + jest.mocked(Table).mock.calls.at(-1)![0].alternateRowBackground, + ).toBe(false); + }); + }); + 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/src/theme/themes/clickstack/_tokens.scss b/packages/app/src/theme/themes/clickstack/_tokens.scss index 72b36cca71..09c125388e 100644 --- a/packages/app/src/theme/themes/clickstack/_tokens.scss +++ b/packages/app/src/theme/themes/clickstack/_tokens.scss @@ -8,7 +8,7 @@ * `chart-tokens` is split out so the chart palette is defined once, * not duplicated across the dark/light blocks. Both scheme selectors * @include it so specificity stays identical to declaring the vars - * inline — Sass inlines the body of the mixin at each call site, so + * inline; Sass inlines the body of the mixin at each call site, so * the resulting CSS is byte-identical to the previous hand-duplicated * shape. Categorical and semantic chart tokens come from the shared * `chart-categorical-tokens` partial (two mixins: hues, then semantics). @@ -111,6 +111,7 @@ --color-bg-inverted: var(--mantine-color-dark-1); --color-bg-muted: var(--click-global-color-background-muted); --color-bg-highlighted: rgb(80 80 80 / 70%); + --color-bg-table-stripe: rgb(255 255 255 / 4%); --color-bg-sidenav: var(--click-global-color-background-default); --color-bg-sidenav-link-active: var(--palette-neutral-712); --color-bg-sidenav-link-active-hover: var(--mantine-color-gray-7); @@ -303,6 +304,7 @@ --color-bg-inverted: var(--mantine-color-dark-3); --color-bg-muted: var(--click-global-color-background-muted); --color-bg-highlighted: rgb(241 243 245 / 80%); + --color-bg-table-stripe: rgb(0 0 0 / 3%); --color-bg-sidenav: var(--click-global-color-background-default); --color-bg-sidenav-link-active: var(--click-global-color-stroke-default); --color-bg-sidenav-link-active-hover: var(--mantine-color-gray-3); diff --git a/packages/app/src/theme/themes/hyperdx/_tokens.scss b/packages/app/src/theme/themes/hyperdx/_tokens.scss index 3ec5280fe0..8d76be4ed0 100644 --- a/packages/app/src/theme/themes/hyperdx/_tokens.scss +++ b/packages/app/src/theme/themes/hyperdx/_tokens.scss @@ -10,7 +10,7 @@ * `chart-tokens` is split out so the chart palette is defined once, * not duplicated across the dark/light blocks. Both dark and light * @include it so specificity stays identical to declaring the vars - * inline — Sass inlines the body of the mixin at each call site, so + * inline; Sass inlines the body of the mixin at each call site, so * the resulting CSS is byte-identical to the previous hand-duplicated * shape. Categorical and semantic chart tokens come from the shared * `chart-categorical-tokens` partial (two mixins: hues, then semantics). @@ -30,6 +30,7 @@ --color-bg-inverted: var(--mantine-color-dark-1); --color-bg-muted: var(--mantine-color-dark-7); --color-bg-highlighted: rgb(55 58 64 / 70%); + --color-bg-table-stripe: rgb(255 255 255 / 4%); --color-bg-sidenav: var(--mantine-color-dark-9); --color-bg-sidenav-link-active: var(--mantine-color-dark-7); --color-bg-sidenav-link-active-hover: var(--mantine-color-dark-6); @@ -132,6 +133,7 @@ --color-bg-inverted: var(--mantine-color-dark-3); --color-bg-muted: #f6f6fa; --color-bg-highlighted: rgb(230 232 237 / 70%); + --color-bg-table-stripe: rgb(0 0 0 / 3%); --color-bg-sidenav: var(--mantine-color-white); --color-bg-sidenav-link-active: #ebebef; --color-bg-sidenav-link-active-hover: var(--mantine-color-gray-2); diff --git a/packages/common-utils/src/types.ts b/packages/common-utils/src/types.ts index 8513e6e802..25da824283 100644 --- a/packages/common-utils/src/types.ts +++ b/packages/common-utils/src/types.ts @@ -1194,6 +1194,11 @@ export const _ChartConfigSchema = SharedChartSettingsSchema.extend({ eventTableSelect: z.string().optional(), source: z.string().optional(), groupByColumnsOnLeft: z.boolean().optional(), + // Zebra striping for table tiles: when true, the renderer tints + // alternating rows so wide tables are easier to scan across. Builder + // table tiles only (gated in `ChartDisplaySettingsDrawer`); other display + // types ignore the field. Off by default, so existing tiles are unchanged. + alternateRowBackground: z.boolean().optional(), }); // This is a ChartConfig type without the `with` CTE clause included. From 39a99ac269c0702d31184fd4a6c3b5abd0fda3cd Mon Sep 17 00:00:00 2001 From: Alex Fedotyev <61838744+alex-fedotyev@users.noreply.github.com> Date: Wed, 24 Jun 2026 19:18:11 +0000 Subject: [PATCH 2/6] refactor(dashboard): rename table display-options gate for clarity The builder-table-only Display Settings block now holds two options (Group By column ordering and Alternate Row Background) under one gate, so `showGroupByColumnsOnLeft` no longer describes what it controls. Rename it to `showBuilderTableOptions`. No behavior change. Co-Authored-By: Claude Opus 4.8 --- .../app/src/components/ChartDisplaySettingsDrawer.tsx | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/packages/app/src/components/ChartDisplaySettingsDrawer.tsx b/packages/app/src/components/ChartDisplaySettingsDrawer.tsx index 9a76e7886f..d9788f66b1 100644 --- a/packages/app/src/components/ChartDisplaySettingsDrawer.tsx +++ b/packages/app/src/components/ChartDisplaySettingsDrawer.tsx @@ -179,9 +179,10 @@ export default function ChartDisplaySettingsDrawer({ // raw SQL configs author their own LIMIT logic directly. const showSeriesLimit = isTimeChart && configType !== 'sql'; - // 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 = + // Builder-table-only display options (Group By column ordering, alternate + // row background). Raw SQL table configs author their own layout directly, + // so these are hidden there. + const showBuilderTableOptions = displayType === DisplayType.Table && configType !== 'sql'; // Tile-level color is only meaningful for number tiles today. @@ -272,7 +273,7 @@ export default function ChartDisplaySettingsDrawer({ )} - {showGroupByColumnsOnLeft && ( + {showBuilderTableOptions && ( <> Date: Wed, 8 Jul 2026 20:15:49 +0000 Subject: [PATCH 3/6] feat(dashboard): enable Alternate Row Background on SQL table tiles The zebra striping is purely presentational (it tints alternating rendered rows), so it applies to any table tile regardless of how the data was produced. Move alternateRowBackground from the builder config to SharedChartSettingsSchema so raw SQL table configs persist it too, and split the display-settings gate: Group By column ordering stays builder-only (it needs the builder select structure) while Alternate Row Background shows for every table tile. --- .changeset/table-tile-alternate-rows.md | 2 +- .../components/ChartDisplaySettingsDrawer.tsx | 27 ++++---- packages/app/src/components/DBTableChart.tsx | 6 +- .../ChartDisplaySettingsDrawer.test.tsx | 65 ++++++++++++++++++- .../__tests__/DBTableChart.test.tsx | 17 ++++- .../common-utils/src/__tests__/types.test.ts | 39 ++++++++++- packages/common-utils/src/types.ts | 16 +++-- 7 files changed, 144 insertions(+), 28 deletions(-) diff --git a/.changeset/table-tile-alternate-rows.md b/.changeset/table-tile-alternate-rows.md index 8bc9c39aea..e4e4c388f4 100644 --- a/.changeset/table-tile-alternate-rows.md +++ b/.changeset/table-tile-alternate-rows.md @@ -5,4 +5,4 @@ feat(dashboard): table tile header separator and optional alternate row background -Add an always-on separator between a table tile's sticky header and its rows so the boundary stays clear as rows scroll underneath. Add a new **Alternate Row Background** display setting (off by default) that zebra-stripes builder table tiles for easier scanning on wide tables. Both work in light and dark color modes. +Add an always-on separator between a table tile's sticky header and its rows so the boundary stays clear as rows scroll underneath. Add a new **Alternate Row Background** display setting (off by default) that zebra-stripes table tiles for easier scanning on wide tables. Both work in light and dark color modes. diff --git a/packages/app/src/components/ChartDisplaySettingsDrawer.tsx b/packages/app/src/components/ChartDisplaySettingsDrawer.tsx index 951d3d2bb9..82e7a8541d 100644 --- a/packages/app/src/components/ChartDisplaySettingsDrawer.tsx +++ b/packages/app/src/components/ChartDisplaySettingsDrawer.tsx @@ -184,11 +184,12 @@ export default function ChartDisplaySettingsDrawer({ const showSeriesLimit = isTimeChart && configType !== 'sql' && configType !== 'promql'; - // Builder-table-only display options (Group By column ordering, alternate - // row background). Raw SQL table configs author their own layout directly, - // so these are hidden there. - const showBuilderTableOptions = - displayType === DisplayType.Table && configType !== 'sql'; + // Table display options. Alternate Row Background is purely presentational + // (it stripes rendered rows), so it applies to any table tile. Group By + // column ordering needs the builder `select` structure to know which columns + // are group-by keys, so it stays builder-only. + const showTableOptions = displayType === DisplayType.Table; + const showBuilderTableOptions = showTableOptions && configType !== 'sql'; // Tile-level color is only meaningful for number tiles today. // Per-series colors on line / bar / pie ship in a follow-up PR via @@ -278,14 +279,16 @@ export default function ChartDisplaySettingsDrawer({ )} - {showBuilderTableOptions && ( + {showTableOptions && ( <> - + {showBuilderTableOptions && ( + + )} diff --git a/packages/app/src/components/__tests__/ChartDisplaySettingsDrawer.test.tsx b/packages/app/src/components/__tests__/ChartDisplaySettingsDrawer.test.tsx index 2b4cb5ca2f..430ac65589 100644 --- a/packages/app/src/components/__tests__/ChartDisplaySettingsDrawer.test.tsx +++ b/packages/app/src/components/__tests__/ChartDisplaySettingsDrawer.test.tsx @@ -280,7 +280,7 @@ describe('ChartDisplaySettingsDrawer', () => { ).toBeInTheDocument(); }); - it('does not show the toggle for raw SQL table charts', () => { + it('shows the toggle for raw SQL table charts', () => { renderWithMantine( { ); expect( - screen.queryByRole('checkbox', { name: /alternate row background/i }), - ).not.toBeInTheDocument(); + screen.getByRole('checkbox', { name: /alternate row background/i }), + ).toBeInTheDocument(); }); it('does not show the toggle for line charts', () => { @@ -341,6 +341,65 @@ describe('ChartDisplaySettingsDrawer', () => { alternateRowBackground: true, }); }); + + it('calls onChange with alternateRowBackground = true for raw SQL table charts', async () => { + const onChange = jest.fn(); + const user = userEvent.setup(); + + renderWithMantine( + , + ); + + await user.click( + screen.getByRole('checkbox', { name: /alternate row background/i }), + ); + await user.click(screen.getByRole('button', { name: /apply/i })); + + expect(onChange).toHaveBeenCalledTimes(1); + expect(onChange.mock.calls[0][0]).toMatchObject({ + alternateRowBackground: true, + }); + }); + }); + + describe('display group by columns on left setting', () => { + const builderProps = { ...baseProps, configType: 'builder' as const }; + + it('shows the toggle for builder table charts', () => { + renderWithMantine( + , + ); + + expect( + screen.getByRole('checkbox', { + name: /display group by columns on left/i, + }), + ).toBeInTheDocument(); + }); + + it('does not show the toggle for raw SQL table charts', () => { + renderWithMantine( + , + ); + + // Group By ordering needs the builder select structure, so it stays + // builder-only even though Alternate Row Background is shown for SQL. + expect( + screen.queryByRole('checkbox', { + name: /display group by columns on left/i, + }), + ).not.toBeInTheDocument(); + }); }); describe('number format persistence', () => { diff --git a/packages/app/src/components/__tests__/DBTableChart.test.tsx b/packages/app/src/components/__tests__/DBTableChart.test.tsx index 31bd1923d3..242184556e 100644 --- a/packages/app/src/components/__tests__/DBTableChart.test.tsx +++ b/packages/app/src/components/__tests__/DBTableChart.test.tsx @@ -371,7 +371,7 @@ describe('DBTableChart', () => { ).toBe(false); }); - it('passes alternateRowBackground=false for raw SQL configs even when set', () => { + it('threads alternateRowBackground to the Table for raw SQL configs', () => { const rawSqlConfig = { configType: 'sql' as const, dateRange: [new Date(), new Date()] as [Date, Date], @@ -382,6 +382,21 @@ describe('DBTableChart', () => { renderWithMantine(); + expect( + jest.mocked(Table).mock.calls.at(-1)![0].alternateRowBackground, + ).toBe(true); + }); + + it('passes alternateRowBackground=false when a raw SQL config omits it', () => { + const rawSqlConfig = { + configType: 'sql' as const, + dateRange: [new Date(), new Date()] as [Date, Date], + connection: 'test-connection', + sqlTemplate: 'SELECT count() AS Count FROM t', + }; + + renderWithMantine(); + expect( jest.mocked(Table).mock.calls.at(-1)![0].alternateRowBackground, ).toBe(false); diff --git a/packages/common-utils/src/__tests__/types.test.ts b/packages/common-utils/src/__tests__/types.test.ts index eaab3c41af..502c9551d5 100644 --- a/packages/common-utils/src/__tests__/types.test.ts +++ b/packages/common-utils/src/__tests__/types.test.ts @@ -1,6 +1,10 @@ import { z } from 'zod'; -import { BackgroundChartSchema, ColorConditionSchema } from '@/types'; +import { + BackgroundChartSchema, + ColorConditionSchema, + SavedChartConfigSchema, +} from '@/types'; describe('ColorConditionSchema', () => { // ─── Positive cases ───────────────────────────────────────────────────────── @@ -321,3 +325,36 @@ describe('BackgroundChartSchema', () => { ).toBe(false); }); }); + +describe('alternateRowBackground on saved chart configs', () => { + // The field lives on SharedChartSettingsSchema, so both builder and raw SQL + // saved configs carry it (the zebra striping is purely presentational and + // renders the same way regardless of how the rows were produced). A schema + // that only declared it on the builder config would silently strip it from a + // raw SQL tile on save. + + it('retains alternateRowBackground on a raw SQL table saved config', () => { + const parsed = SavedChartConfigSchema.parse({ + configType: 'sql', + sqlTemplate: 'SELECT count() AS Count FROM t', + connection: 'test-connection', + displayType: 'table', + alternateRowBackground: true, + }); + + expect(parsed).toMatchObject({ alternateRowBackground: true }); + }); + + it('retains alternateRowBackground on a builder table saved config', () => { + const parsed = SavedChartConfigSchema.parse({ + source: 'test-source', + timestampValueExpression: 'Timestamp', + displayType: 'table', + select: [{ aggFn: 'count', valueExpression: '', alias: 'Count' }], + where: '', + alternateRowBackground: true, + }); + + expect(parsed).toMatchObject({ alternateRowBackground: true }); + }); +}); diff --git a/packages/common-utils/src/types.ts b/packages/common-utils/src/types.ts index ddb87e5d40..e9d03e27d8 100644 --- a/packages/common-utils/src/types.ts +++ b/packages/common-utils/src/types.ts @@ -1188,6 +1188,14 @@ const SharedChartSettingsSchema = z.object({ // number tiles have no time dimension to bucket). Other display types // ignore the field. Kept at shared level mirroring `color` / `colorRules`. backgroundChart: BackgroundChartSchema.optional(), + // Zebra striping for table tiles: when true, the renderer tints alternating + // rows so wide tables are easier to scan across. Applies to any table tile + // (builder or raw SQL); the striping is purely presentational and keys off + // the rendered row index, so it does not depend on the config kind. The UI + // gates the control on `displayType === DisplayType.Table`. Other display + // types ignore the field. Off by default, so existing tiles are unchanged. + // Kept at shared level mirroring `color` / `colorRules` / `backgroundChart`. + alternateRowBackground: z.boolean().optional(), }); export const _ChartConfigSchema = SharedChartSettingsSchema.extend({ @@ -1209,12 +1217,10 @@ export const _ChartConfigSchema = SharedChartSettingsSchema.extend({ // Used to preserve original table select string when chart overrides it (e.g., histograms) eventTableSelect: z.string().optional(), source: z.string().optional(), + // Builder-only: render group-by columns to the left of series columns. + // Needs the builder `select` structure to know which columns are group-by + // keys, so unlike `alternateRowBackground` this stays on the builder config. groupByColumnsOnLeft: z.boolean().optional(), - // Zebra striping for table tiles: when true, the renderer tints - // alternating rows so wide tables are easier to scan across. Builder - // table tiles only (gated in `ChartDisplaySettingsDrawer`); other display - // types ignore the field. Off by default, so existing tiles are unchanged. - alternateRowBackground: z.boolean().optional(), }); // This is a ChartConfig type without the `with` CTE clause included. From 450d6d6eff2b69f13dd83613e92f63f7002294fc Mon Sep 17 00:00:00 2001 From: Alex Fedotyev <61838744+alex-fedotyev@users.noreply.github.com> Date: Wed, 8 Jul 2026 21:46:22 +0000 Subject: [PATCH 4/6] fix(dashboard): persist alternateRowBackground on SQL and PromQL table tiles The form-state converters assemble raw SQL and PromQL configs from an explicit field whitelist, and alternateRowBackground was not in it, so toggling it on a SQL table tile did nothing and the setting was dropped on save. Builder configs were unaffected because they spread the whole form state. Add the field to the save and render pick lists so the toggle persists and renders for every table tile. --- .../ChartEditor/__tests__/utils.test.ts | 29 +++++++++++++++++++ .../app/src/components/ChartEditor/utils.ts | 4 +++ 2 files changed, 33 insertions(+) diff --git a/packages/app/src/components/ChartEditor/__tests__/utils.test.ts b/packages/app/src/components/ChartEditor/__tests__/utils.test.ts index 9ca5183393..e5f96bfbea 100644 --- a/packages/app/src/components/ChartEditor/__tests__/utils.test.ts +++ b/packages/app/src/components/ChartEditor/__tests__/utils.test.ts @@ -105,6 +105,22 @@ describe('convertFormStateToSavedChartConfig', () => { }); }); + it('persists alternateRowBackground for a sql+table config', () => { + const form: ChartEditorFormState = { + configType: 'sql', + displayType: DisplayType.Table, + sqlTemplate: 'SELECT 1', + connection: 'conn-1', + alternateRowBackground: true, + series: [], + }; + const result = convertFormStateToSavedChartConfig( + form, + undefined, + ) as RawSqlSavedChartConfig; + expect(result.alternateRowBackground).toBe(true); + }); + it('returns a raw SQL config for Line displayType', () => { const form: ChartEditorFormState = { configType: 'sql', @@ -355,6 +371,19 @@ describe('convertFormStateToChartConfig', () => { }); }); + it('threads alternateRowBackground into the rendered sql+table config', () => { + const form: ChartEditorFormState = { + configType: 'sql', + displayType: DisplayType.Table, + sqlTemplate: 'SELECT now()', + connection: 'conn-1', + alternateRowBackground: true, + series: [], + }; + const result = convertFormStateToChartConfig(form, dateRange, undefined); + expect(result).toMatchObject({ alternateRowBackground: true }); + }); + it('returns builder config with source fields merged', () => { const form: ChartEditorFormState = { displayType: DisplayType.Line, diff --git a/packages/app/src/components/ChartEditor/utils.ts b/packages/app/src/components/ChartEditor/utils.ts index acbf52f8f8..46856b52e8 100644 --- a/packages/app/src/components/ChartEditor/utils.ts +++ b/packages/app/src/components/ChartEditor/utils.ts @@ -110,6 +110,7 @@ export function convertFormStateToSavedChartConfig( 'compareToPreviousPeriod', 'fillNulls', 'alignDateRangeToGranularity', + 'alternateRowBackground', // 'alert', // TODO: Support alerts on PromQL (HDX-4636) ]), promqlExpression: form.promqlExpression ?? '', @@ -132,6 +133,7 @@ export function convertFormStateToSavedChartConfig( 'compareToPreviousPeriod', 'fillNulls', 'alignDateRangeToGranularity', + 'alternateRowBackground', 'alert', 'onClick', ]), @@ -185,6 +187,7 @@ export function convertFormStateToChartConfig( 'compareToPreviousPeriod', 'fillNulls', 'alignDateRangeToGranularity', + 'alternateRowBackground', ]), promqlExpression: form.promqlExpression ?? '', connection: source?.connection ?? form.connection ?? '', @@ -207,6 +210,7 @@ export function convertFormStateToChartConfig( 'compareToPreviousPeriod', 'fillNulls', 'alignDateRangeToGranularity', + 'alternateRowBackground', 'onClick', ]), sqlTemplate: form.sqlTemplate ?? '', From 58e3d35b1cc1a8f0c739bbe39c821fb16e59e10e Mon Sep 17 00:00:00 2001 From: Alex Fedotyev <61838744+alex-fedotyev@users.noreply.github.com> Date: Sun, 19 Jul 2026 17:29:04 +0000 Subject: [PATCH 5/6] test(dashboard): cover alternateRowBackground on the PromQL table converter path The save and render form-state converters whitelist alternateRowBackground per configType. The SQL table path has regression tests, but the PromQL table path (reachable, since isPromqlDisplayType includes Table) did not, so dropping the field from the PromQL whitelist would go uncaught. Add save and render tests for a promql+table config. --- .../ChartEditor/__tests__/utils.test.ts | 26 +++++++++++++++++++ 1 file changed, 26 insertions(+) diff --git a/packages/app/src/components/ChartEditor/__tests__/utils.test.ts b/packages/app/src/components/ChartEditor/__tests__/utils.test.ts index cadc2c3da6..5002145e0e 100644 --- a/packages/app/src/components/ChartEditor/__tests__/utils.test.ts +++ b/packages/app/src/components/ChartEditor/__tests__/utils.test.ts @@ -121,6 +121,19 @@ describe('convertFormStateToSavedChartConfig', () => { expect(result.alternateRowBackground).toBe(true); }); + it('persists alternateRowBackground for a promql+table config', () => { + const form: ChartEditorFormState = { + configType: 'promql', + displayType: DisplayType.Table, + promqlExpression: 'up', + connection: 'conn-1', + alternateRowBackground: true, + series: [], + }; + const result = convertFormStateToSavedChartConfig(form, undefined); + expect(result).toMatchObject({ alternateRowBackground: true }); + }); + it('returns a raw SQL config for Line displayType', () => { const form: ChartEditorFormState = { configType: 'sql', @@ -384,6 +397,19 @@ describe('convertFormStateToChartConfig', () => { expect(result).toMatchObject({ alternateRowBackground: true }); }); + it('threads alternateRowBackground into the rendered promql+table config', () => { + const form: ChartEditorFormState = { + configType: 'promql', + displayType: DisplayType.Table, + promqlExpression: 'up', + connection: 'conn-1', + alternateRowBackground: true, + series: [], + }; + const result = convertFormStateToChartConfig(form, dateRange, undefined); + expect(result).toMatchObject({ alternateRowBackground: true }); + }); + it('returns builder config with source fields merged', () => { const form: ChartEditorFormState = { displayType: DisplayType.Line, From ead992495959ef395db9f7788e9387a0455bd195 Mon Sep 17 00:00:00 2001 From: Alex Fedotyev <61838744+alex-fedotyev@users.noreply.github.com> Date: Sun, 19 Jul 2026 18:01:54 +0000 Subject: [PATCH 6/6] test(dashboard): keep app lint under the warning ceiling Adding the optional alternateRowBackground field to the shared chart settings type introduces one extra type-aware no-unsafe-type-assertion warning in existing code, which pushed the app package over its 740 warning ceiling and failed lint. Convert a few as-casts in the ChartEditor converter tests to type-safe toMatchObject assertions, and replace two empty-function onSortingChange mocks in the table chart test with no-op returns, bringing the app back under the ceiling. --- .../HDXMultiSeriesTableChart.test.tsx | 4 +-- .../ChartEditor/__tests__/utils.test.ts | 29 +++++++------------ 2 files changed, 12 insertions(+), 21 deletions(-) diff --git a/packages/app/src/__tests__/HDXMultiSeriesTableChart.test.tsx b/packages/app/src/__tests__/HDXMultiSeriesTableChart.test.tsx index 94c891566a..06a74ca5ab 100644 --- a/packages/app/src/__tests__/HDXMultiSeriesTableChart.test.tsx +++ b/packages/app/src/__tests__/HDXMultiSeriesTableChart.test.tsx @@ -399,7 +399,7 @@ describe('HDXMultiSeriesTableChart
', () => { columns={baseColumns} alternateRowBackground sorting={[]} - onSortingChange={() => {}} + onSortingChange={() => undefined} />, ); @@ -421,7 +421,7 @@ describe('HDXMultiSeriesTableChart
', () => { data={stripeData} columns={baseColumns} sorting={[]} - onSortingChange={() => {}} + onSortingChange={() => undefined} />, ); diff --git a/packages/app/src/components/ChartEditor/__tests__/utils.test.ts b/packages/app/src/components/ChartEditor/__tests__/utils.test.ts index 5002145e0e..aed30696f9 100644 --- a/packages/app/src/components/ChartEditor/__tests__/utils.test.ts +++ b/packages/app/src/components/ChartEditor/__tests__/utils.test.ts @@ -114,11 +114,8 @@ describe('convertFormStateToSavedChartConfig', () => { alternateRowBackground: true, series: [], }; - const result = convertFormStateToSavedChartConfig( - form, - undefined, - ) as RawSqlSavedChartConfig; - expect(result.alternateRowBackground).toBe(true); + const result = convertFormStateToSavedChartConfig(form, undefined); + expect(result).toMatchObject({ alternateRowBackground: true }); }); it('persists alternateRowBackground for a promql+table config', () => { @@ -160,14 +157,12 @@ describe('convertFormStateToSavedChartConfig', () => { connection: 'conn-1', series: [], }; - const result = convertFormStateToSavedChartConfig( - form, - undefined, - ) as BuilderSavedChartConfig; - expect(result).toBeDefined(); - expect(result.displayType).toBe(DisplayType.Markdown); - expect(result.markdown).toBe('## Note'); - expect(result.select).toEqual([]); + const result = convertFormStateToSavedChartConfig(form, undefined); + expect(result).toMatchObject({ + displayType: DisplayType.Markdown, + markdown: '## Note', + select: [], + }); }); it('uses sqlTemplate empty string as default when undefined', () => { @@ -176,12 +171,8 @@ describe('convertFormStateToSavedChartConfig', () => { displayType: DisplayType.Table, series: [], }; - const result = convertFormStateToSavedChartConfig( - form, - undefined, - ) as RawSqlSavedChartConfig; - expect(result.sqlTemplate).toBe(''); - expect(result.connection).toBe(''); + const result = convertFormStateToSavedChartConfig(form, undefined); + expect(result).toMatchObject({ sqlTemplate: '', connection: '' }); }); it('maps series to select for builder config', () => {