Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions .changeset/table-tile-alternate-rows.md
Original file line number Diff line number Diff line change
@@ -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.
63 changes: 63 additions & 0 deletions packages/app/src/HDXMultiSeriesTable.stories.tsx
Original file line number Diff line number Diff line change
@@ -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<typeof Table> = {
title: 'HDXMultiSeriesTableChart',
component: Table,
parameters: { layout: 'padded' },
decorators: [
Story => (
<div style={{ height: 320, width: 640 }}>
<Story />
</div>
),
],
args: {
data,
columns,
sorting: [],
onSortingChange: () => {},
},
};

export default meta;

type Story = StoryObj<typeof Table>;

// 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}`,
}),
},
};
27 changes: 27 additions & 0 deletions packages/app/src/HDXMultiSeriesTableChart.module.scss
Original file line number Diff line number Diff line change
Expand Up @@ -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 <tbody>
Expand All @@ -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.
// <tr> does not reliably form a positioning context across
Expand Down
12 changes: 12 additions & 0 deletions packages/app/src/HDXMultiSeriesTableChart.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,7 @@ export const Table = ({
sorting,
onSortingChange,
variant = 'default',
alternateRowBackground = false,
}: {
data: any[];
columns: {
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -346,6 +352,7 @@ export const Table = ({
}}
>
<thead
className={styles.tableHeader}
style={{
position: 'sticky',
top: 0,
Expand Down Expand Up @@ -429,6 +436,11 @@ export const Table = ({
// to the global `bg-muted-hover` utility.
[styles.actionableRow]: isActionable,
'bg-muted-hover': !isActionable,
// Zebra striping (opt-in): tint odd rows. The CSS module
// gives `.stripedRow` its own hover rule so the hover stays
// visible over a stripe.
[styles.stripedRow]:
alternateRowBackground && virtualRow.index % 2 === 1,
})}
data-index={virtualRow.index}
ref={rowVirtualizer.measureElement}
Expand Down
49 changes: 49 additions & 0 deletions packages/app/src/__tests__/HDXMultiSeriesTableChart.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -383,4 +383,53 @@ describe('HDXMultiSeriesTableChart <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(
<Table
data={stripeData}
columns={baseColumns}
alternateRowBackground
sorting={[]}
onSortingChange={() => 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(
<Table
data={stripeData}
columns={baseColumns}
sorting={[]}
onSortingChange={() => undefined}
/>,
);

const rows = container.querySelectorAll('tbody tr[data-index]');
expect(rows.length).toBe(stripeData.length);
rows.forEach(row => {
expect(row.className).not.toContain('stripedRow');
});
});
});
});
26 changes: 19 additions & 7 deletions packages/app/src/components/ChartDisplaySettingsDrawer.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -310,13 +314,21 @@ export default function ChartDisplaySettingsDrawer({
</>
)}

{showGroupByColumnsOnLeft && (
{showTableOptions && (
<>
{showGroupByColumnsOnLeft && (
<CheckBoxControlled
control={control}
name="groupByColumnsOnLeft"
size="xs"
label="Display Group By Columns on Left"
/>
)}
<CheckBoxControlled
control={control}
name="groupByColumnsOnLeft"
name="alternateRowBackground"
size="xs"
label="Display Group By Columns on Left"
label="Alternate Row Background"
/>
Comment thread
pulpdrew marked this conversation as resolved.
<Divider />
</>
Expand Down
74 changes: 60 additions & 14 deletions packages/app/src/components/ChartEditor/__tests__/utils.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand All @@ -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', () => {
Expand All @@ -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', () => {
Expand Down Expand Up @@ -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,
Expand Down
4 changes: 4 additions & 0 deletions packages/app/src/components/ChartEditor/utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -122,6 +122,7 @@ export function convertFormStateToSavedChartConfig(
'compareToPreviousPeriod',
'fillNulls',
'alignDateRangeToGranularity',
'alternateRowBackground',
// 'alert', // TODO: Support alerts on PromQL (HDX-4636)
]),
promqlExpression: form.promqlExpression ?? '',
Expand All @@ -144,6 +145,7 @@ export function convertFormStateToSavedChartConfig(
'compareToPreviousPeriod',
'fillNulls',
'alignDateRangeToGranularity',
'alternateRowBackground',
'alert',
'onClick',
]),
Expand Down Expand Up @@ -197,6 +199,7 @@ export function convertFormStateToChartConfig(
'compareToPreviousPeriod',
'fillNulls',
'alignDateRangeToGranularity',
'alternateRowBackground',
]),
promqlExpression: form.promqlExpression ?? '',
connection: source?.connection ?? form.connection ?? '',
Expand All @@ -219,6 +222,7 @@ export function convertFormStateToChartConfig(
'compareToPreviousPeriod',
'fillNulls',
'alignDateRangeToGranularity',
'alternateRowBackground',
'onClick',
]),
sqlTemplate: form.sqlTemplate ?? '',
Expand Down
Loading
Loading