Skip to content
Open
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
11 changes: 11 additions & 0 deletions .changeset/table-tile-column-color.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
---
"@hyperdx/common-utils": minor
"@hyperdx/app": minor
---

Add per-column color to dashboard table tiles. On builder table tiles you can
now set a static color on a column and layer ordered conditional rules (for
example `> 500` turns the cell red), the table-cell counterpart of the
number-tile color. Rules are authored from the column editor and applied per
cell at render, reusing the existing palette tokens so colors reflow across
light and dark themes.
65 changes: 63 additions & 2 deletions packages/app/src/HDXMultiSeriesTableChart.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,11 @@
import { useCallback, useMemo, useRef, useState } from 'react';
import Link from 'next/link';
import cx from 'classnames';
import {
ChartPaletteToken,
ColorCondition,
isChartPaletteToken,
} from '@hyperdx/common-utils/dist/types';
import { Tooltip, UnstyledButton } from '@mantine/core';
import {
IconArrowUpRight,
Expand Down Expand Up @@ -29,7 +34,11 @@ import type { RowAction } from './hooks/useOnClickLinkBuilder';
import { useBrandDisplayName } from './theme/ThemeProvider';
import { UNDEFINED_WIDTH } from './tableUtils';
import type { NumberFormat } from './types';
import { formatNumber } from './utils';
import {
formatNumber,
getColorFromCSSToken,
resolveConditionalColor,
} from './utils';

import styles from './HDXMultiSeriesTableChart.module.scss';
import focusStyles from '@styles/focus.module.scss';
Expand Down Expand Up @@ -61,6 +70,12 @@ export const Table = ({
columnWidthPercent?: number;
visible?: boolean;
sortingFn?: SortingFnOption<any>;
// Per-column static palette-token color (table tiles). Applied to the
// cell text; falls back through `colorRules` then the default color.
color?: ChartPaletteToken;
// Ordered conditional color rules evaluated against each cell's value
// (last match wins). Resolves to `color` when no rule matches.
colorRules?: ColorCondition[];
}[];
groupColumnName?: string;
// Returns the row click destination + a hover-hint description. When
Expand Down Expand Up @@ -136,6 +151,8 @@ export const Table = ({
numberFormat,
columnWidthPercent,
sortingFn,
color,
colorRules,
},
i,
) =>
Expand All @@ -162,6 +179,43 @@ export const Table = ({
formattedValue = formatNumber(value, numberFormat);
}

// Resolve this cell's color from the column config: ordered
// rules first (last match wins), then the column's static
// color, else no override. ClickHouse serializes numeric
// aggregates (count is UInt64, sums, etc.) as strings, so coerce
// a numeric-looking value to a number first; otherwise the
// numeric operators (gt / lt / between) never match. Genuine
// strings (group-by labels, status values) stay as-is so the
// equality / string-match rules still work. Mirrors the value
// coercion in DBNumberChart.
//
// react-table types the getter as `number`, but the runtime
// value can be a string (see above), so read it through
// `unknown` to narrow honestly without an unsafe cast.
const cellValue: unknown = value;
const primitiveValue =
typeof cellValue === 'number' || typeof cellValue === 'string'
? cellValue
: null;
const colorValue =
typeof primitiveValue === 'string' &&
primitiveValue.trim() !== '' &&
Number.isFinite(Number(primitiveValue))
? Number(primitiveValue)
: primitiveValue;
const resolvedColorToken = resolveConditionalColor(
colorValue,
colorRules,
color,
);
// Guard the CSS resolver: it throws on an unrecognized token,
// so an unknown / legacy token (e.g. a hand-edited config)
// renders with the default color instead of crashing the cell.
const colorStyle =
resolvedColorToken && isChartPaletteToken(resolvedColorToken)
? { color: getColorFromCSSToken(resolvedColorToken) }
: undefined;

const className = cx('align-top overflow-hidden py-1 pe-3', {
'text-break': wrapLinesEnabled,
'text-truncate': !wrapLinesEnabled,
Expand Down Expand Up @@ -191,6 +245,7 @@ export const Table = ({
href={action.url}
prefetch={false}
className={interactiveClassName}
style={colorStyle}
data-testid="dashboard-table-row-action"
data-shape="link"
>
Expand All @@ -211,6 +266,7 @@ export const Table = ({
<button
type="button"
className={cx(interactiveClassName, focusStyles.cellButton)}
style={colorStyle}
onClick={action.onClickError}
data-testid="dashboard-table-row-action"
data-shape="button"
Expand All @@ -228,6 +284,7 @@ export const Table = ({
href={url}
prefetch={false}
className={interactiveClassName}
style={colorStyle}
data-testid="dashboard-table-row-action"
data-shape="link"
>
Expand All @@ -237,7 +294,11 @@ export const Table = ({
}
}

return <div className={className}>{formattedValue}</div>;
return (
<div className={className} style={colorStyle}>
{formattedValue}
</div>
);
},
size:
i === numColumns - 2
Expand Down
133 changes: 133 additions & 0 deletions packages/app/src/__tests__/HDXMultiSeriesTableChart.test.tsx
Original file line number Diff line number Diff line change
@@ -1,8 +1,10 @@
import React from 'react';
import type { ChartPaletteToken } from '@hyperdx/common-utils/dist/types';
import { screen, waitFor } from '@testing-library/react';
import userEvent from '@testing-library/user-event';

import { Table } from '@/HDXMultiSeriesTableChart';
import { getColorFromCSSToken } from '@/utils';

// Next.js Link needs a router context; the router isn't exercised
// because we never trigger client-side navigation in these tests.
Expand Down Expand Up @@ -325,4 +327,135 @@ describe('HDXMultiSeriesTableChart <Table>', () => {
expect(screen.queryByTestId('row-action-hint')).toBeNull();
});
});

describe('per-column cell color', () => {
const serviceCol = {
id: 'service',
dataKey: 'ServiceName',
displayName: 'Service',
};
const colorData = [
{ ServiceName: 'web', Count: 600 },
{ ServiceName: 'api', Count: 10 },
];

// Normalize an expected token through the same CSSOM the rendered cell
// uses, so the assertion is exact without depending on hex-vs-rgb
// serialization details.
const normalizedColor = (token: ChartPaletteToken) => {
const probe = document.createElement('span');
probe.style.color = getColorFromCSSToken(token);
return probe.style.color;
};

it('applies a static palette-token color to every cell in the column', () => {
renderWithMantine(
<Table
data={colorData}
columns={[
serviceCol,
{
id: 'count',
dataKey: 'Count',
displayName: 'Count',
color: 'chart-blue',
},
]}
sorting={[]}
onSortingChange={() => {}}
/>,
);

const expected = normalizedColor('chart-blue');
expect(expected).not.toBe('');
expect((screen.getByText('600') as HTMLElement).style.color).toBe(
expected,
);
expect((screen.getByText('10') as HTMLElement).style.color).toBe(
expected,
);
// The uncolored group-by-style column stays default.
expect((screen.getByText('web') as HTMLElement).style.color).toBe('');
});

it('applies a conditional rule only to cells whose value matches', () => {
renderWithMantine(
<Table
data={colorData}
columns={[
serviceCol,
{
id: 'count',
dataKey: 'Count',
displayName: 'Count',
colorRules: [
{ operator: 'gt', value: 100, color: 'chart-error' },
],
},
]}
sorting={[]}
onSortingChange={() => {}}
/>,
);

// 600 > 100 matches the rule; 10 does not, so it falls back to the
// default color (no static color set).
expect((screen.getByText('600') as HTMLElement).style.color).not.toBe('');
expect((screen.getByText('10') as HTMLElement).style.color).toBe('');
});

it('matches numeric rules against string-serialized values (ClickHouse counts)', () => {
// ClickHouse serializes count() (UInt64) and other numeric aggregates as
// strings, so the cell value arrives as "600" / "10", not 600 / 10. The
// numeric `gt` rule must still match after coercion.
renderWithMantine(
<Table
data={[
{ ServiceName: 'web', Count: '600' },
{ ServiceName: 'api', Count: '10' },
]}
columns={[
serviceCol,
{
id: 'count',
dataKey: 'Count',
displayName: 'Count',
colorRules: [
{ operator: 'gt', value: 100, color: 'chart-error' },
],
},
]}
sorting={[]}
onSortingChange={() => {}}
/>,
);

expect((screen.getByText('600') as HTMLElement).style.color).not.toBe('');
expect((screen.getByText('10') as HTMLElement).style.color).toBe('');
});

it('renders the default color for an unknown token without throwing', () => {
expect(() =>
renderWithMantine(
<Table
data={[{ ServiceName: 'web', Count: 10 }]}
columns={[
serviceCol,
{
id: 'count',
dataKey: 'Count',
displayName: 'Count',
// Simulates a legacy / hand-edited token absent from the
// current palette; the renderer must not crash.
color: 'chart-1' as unknown as ChartPaletteToken,
},
]}
sorting={[]}
onSortingChange={() => {}}
/>,
),
).not.toThrow();
expect((screen.getByText('10') as HTMLElement).style.color).toBe('');
});
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -176,6 +176,7 @@ export function ChartEditorControls({
fields.length === 1 && displayType === DisplayType.Table
}
showDuplicate={canAddSeries}
showColor={displayType === DisplayType.Table}
tableName={tableName ?? ''}
tableSource={tableSource}
errors={
Expand Down
Loading
Loading