diff --git a/.changeset/table-tile-alternate-rows.md b/.changeset/table-tile-alternate-rows.md new file mode 100644 index 0000000000..e4e4c388f4 --- /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 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 9c32d17eea..d8d2bb99bd 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; @@ -346,6 +352,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( + undefined} + />, + ); + + 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( +
undefined} + />, + ); + + 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 0c4bb278cb..ba38dac8d1 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. On group-by time charts it // drives the __hdx_series_limit CTE; on pie/bar builder charts it becomes a // plain SQL LIMIT. @@ -92,6 +93,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, @@ -191,10 +193,12 @@ export default function ChartDisplaySettingsDrawer({ const showCategoricalLimit = isCategoricalChart && configType !== 'sql' && configType !== 'promql'; - // 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'; + // 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 showGroupByColumnsOnLeft = 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 @@ -310,13 +314,21 @@ export default function ChartDisplaySettingsDrawer({ )} - {showGroupByColumnsOnLeft && ( + {showTableOptions && ( <> + {showGroupByColumnsOnLeft && ( + + )} diff --git a/packages/app/src/components/ChartEditor/__tests__/utils.test.ts b/packages/app/src/components/ChartEditor/__tests__/utils.test.ts index 5be1324294..aed30696f9 100644 --- a/packages/app/src/components/ChartEditor/__tests__/utils.test.ts +++ b/packages/app/src/components/ChartEditor/__tests__/utils.test.ts @@ -105,6 +105,32 @@ 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); + expect(result).toMatchObject({ alternateRowBackground: 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', @@ -131,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', () => { @@ -147,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', () => { @@ -355,6 +375,32 @@ 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('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, diff --git a/packages/app/src/components/ChartEditor/utils.ts b/packages/app/src/components/ChartEditor/utils.ts index 8c322ae9e9..5a3d0f49b5 100644 --- a/packages/app/src/components/ChartEditor/utils.ts +++ b/packages/app/src/components/ChartEditor/utils.ts @@ -122,6 +122,7 @@ export function convertFormStateToSavedChartConfig( 'compareToPreviousPeriod', 'fillNulls', 'alignDateRangeToGranularity', + 'alternateRowBackground', // 'alert', // TODO: Support alerts on PromQL (HDX-4636) ]), promqlExpression: form.promqlExpression ?? '', @@ -144,6 +145,7 @@ export function convertFormStateToSavedChartConfig( 'compareToPreviousPeriod', 'fillNulls', 'alignDateRangeToGranularity', + 'alternateRowBackground', 'alert', 'onClick', ]), @@ -197,6 +199,7 @@ export function convertFormStateToChartConfig( 'compareToPreviousPeriod', 'fillNulls', 'alignDateRangeToGranularity', + 'alternateRowBackground', ]), promqlExpression: form.promqlExpression ?? '', connection: source?.connection ?? form.connection ?? '', @@ -219,6 +222,7 @@ export function convertFormStateToChartConfig( 'compareToPreviousPeriod', 'fillNulls', 'alignDateRangeToGranularity', + 'alternateRowBackground', 'onClick', ]), sqlTemplate: form.sqlTemplate ?? '', diff --git a/packages/app/src/components/DBEditTimeChartForm/EditTimeChartForm.tsx b/packages/app/src/components/DBEditTimeChartForm/EditTimeChartForm.tsx index 3af1e2f2a9..625a61025a 100644 --- a/packages/app/src/components/DBEditTimeChartForm/EditTimeChartForm.tsx +++ b/packages/app/src/components/DBEditTimeChartForm/EditTimeChartForm.tsx @@ -271,6 +271,7 @@ export default function EditTimeChartForm({ fitYAxisToData, numberFormat, groupByColumnsOnLeft, + alternateRowBackground, seriesLimit, color, colorRules, @@ -284,6 +285,7 @@ export default function EditTimeChartForm({ 'fitYAxisToData', 'numberFormat', 'groupByColumnsOnLeft', + 'alternateRowBackground', 'seriesLimit', 'color', 'colorRules', @@ -313,6 +315,7 @@ export default function EditTimeChartForm({ fitYAxisToData, numberFormat, groupByColumnsOnLeft, + alternateRowBackground, seriesLimit, color, colorRules, @@ -325,6 +328,7 @@ export default function EditTimeChartForm({ fitYAxisToData, numberFormat, groupByColumnsOnLeft, + alternateRowBackground, seriesLimit, color, colorRules, @@ -624,6 +628,7 @@ export default function EditTimeChartForm({ compareToPreviousPeriod, fitYAxisToData, groupByColumnsOnLeft, + alternateRowBackground, seriesLimit, color, colorRules, @@ -642,6 +647,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..ef9876fe89 100644 --- a/packages/app/src/components/DBTableChart.tsx +++ b/packages/app/src/components/DBTableChart.tsx @@ -248,6 +248,7 @@ export default function DBTableChart({ enableClientSideSorting={isRawSqlChartConfig(config)} onSortingChange={handleSortingChange} variant={variant} + alternateRowBackground={!!queriedConfig.alternateRowBackground} tableBottom={ hasNextPage && ( diff --git a/packages/app/src/components/__tests__/ChartDisplaySettingsDrawer.test.tsx b/packages/app/src/components/__tests__/ChartDisplaySettingsDrawer.test.tsx index 5badb0bfa5..e07419a2b5 100644 --- a/packages/app/src/components/__tests__/ChartDisplaySettingsDrawer.test.tsx +++ b/packages/app/src/components/__tests__/ChartDisplaySettingsDrawer.test.tsx @@ -341,6 +341,144 @@ 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('shows the toggle for raw SQL table charts', () => { + renderWithMantine( + , + ); + + expect( + screen.getByRole('checkbox', { name: /alternate row background/i }), + ).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, + }); + }); + + 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', () => { // 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..242184556e 100644 --- a/packages/app/src/components/__tests__/DBTableChart.test.tsx +++ b/packages/app/src/components/__tests__/DBTableChart.test.tsx @@ -343,6 +343,66 @@ 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('threads alternateRowBackground to the Table for raw SQL configs', () => { + 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(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); + }); + }); + 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 73a3f6eb8e..d9eb364877 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). @@ -120,6 +120,7 @@ #000 20% ); --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); @@ -326,6 +327,7 @@ #fff ); --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 dfe46378aa..161d8360eb 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). @@ -35,6 +35,7 @@ visually pop. It is intentionally darker/greyer than the body itself. */ --color-bg-sunken: color-mix(in srgb, var(--mantine-color-dark-9), #000 50%); --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); @@ -147,6 +148,7 @@ visually pop. It is intentionally slightly darker than the body itself. */ --color-bg-sunken: #fafafc; --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/__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 78c0711211..9339300a78 100644 --- a/packages/common-utils/src/types.ts +++ b/packages/common-utils/src/types.ts @@ -1215,6 +1215,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(), }); // How a grouped ratio divides once split into numerator/denominator series: @@ -1250,6 +1258,9 @@ 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(), });