Skip to content

Commit 4c997de

Browse files
authored
ref(dashboards): add a new table widget visualization component (#93902)
### Changes Related to this PR: #93810. This is part 1 of the change, which is pulling out the new component and just adding it to the repo. Also includes some simplification of the logic in the base component. Part 2 will be replacing tables in widgets. ### Before/After There is no UI change as the table is not being used yet. There is a new story page for the component.
1 parent 02695f9 commit 4c997de

File tree

9 files changed

+588
-22
lines changed

9 files changed

+588
-22
lines changed

static/app/views/dashboards/widgetCard/chart.tsx

Lines changed: 34 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -60,6 +60,7 @@ import {getBucketSize} from 'sentry/views/dashboards/utils/getBucketSize';
6060
import WidgetLegendNameEncoderDecoder from 'sentry/views/dashboards/widgetLegendNameEncoderDecoder';
6161
import type WidgetLegendSelectionState from 'sentry/views/dashboards/widgetLegendSelectionState';
6262
import {BigNumberWidgetVisualization} from 'sentry/views/dashboards/widgets/bigNumberWidget/bigNumberWidgetVisualization';
63+
import {TableWidgetVisualization} from 'sentry/views/dashboards/widgets/tableWidget/tableWidgetVisualization';
6364
import {ConfidenceFooter} from 'sentry/views/explore/charts/confidenceFooter';
6465

6566
import type {GenericWidgetQueriesChildrenProps} from './genericWidgetQueries';
@@ -137,7 +138,7 @@ class WidgetCardChart extends Component<WidgetCardChartProps> {
137138
}
138139

139140
tableResultComponent({loading, tableResults}: TableResultProps): React.ReactNode {
140-
const {location, widget, selection, minTableColumnWidth} = this.props;
141+
const {location, widget, selection, minTableColumnWidth, organization} = this.props;
141142
if (typeof tableResults === 'undefined') {
142143
// Align height to other charts.
143144
return <LoadingPlaceholder />;
@@ -148,11 +149,9 @@ class WidgetCardChart extends Component<WidgetCardChartProps> {
148149
const getCustomFieldRenderer = (
149150
field: string,
150151
meta: MetaType,
151-
organization?: Organization
152+
org?: Organization
152153
) => {
153-
return (
154-
datasetConfig.getCustomFieldRenderer?.(field, meta, widget, organization) || null
155-
);
154+
return datasetConfig.getCustomFieldRenderer?.(field, meta, widget, org) || null;
156155
};
157156

158157
return tableResults.map((result, i) => {
@@ -162,23 +161,36 @@ class WidgetCardChart extends Component<WidgetCardChartProps> {
162161

163162
return (
164163
<TableWrapper key={`table:${result.title}`}>
165-
<StyledSimpleTableChart
166-
eventView={eventView}
167-
fieldAliases={fieldAliases}
168-
location={location}
169-
fields={fields}
170-
title={tableResults.length > 1 ? result.title : ''}
171-
// Bypass the loading state for span widgets because this renders the loading placeholder
172-
// and we want to show the underlying data during preflight instead
173-
loading={widget.widgetType === WidgetType.SPANS ? false : loading}
174-
loader={<LoadingPlaceholder />}
175-
metadata={result.meta}
176-
data={result.data}
177-
stickyHeaders
178-
fieldHeaderMap={datasetConfig.getFieldHeaderMap?.(widget.queries[i])}
179-
getCustomFieldRenderer={getCustomFieldRenderer}
180-
minColumnWidth={minTableColumnWidth}
181-
/>
164+
{organization.features.includes('use-table-widget-visualization') ? (
165+
<TableWidgetVisualization
166+
columns={[]}
167+
tableData={{
168+
data: [],
169+
meta: {
170+
fields: {},
171+
units: {},
172+
},
173+
}}
174+
/>
175+
) : (
176+
<StyledSimpleTableChart
177+
eventView={eventView}
178+
fieldAliases={fieldAliases}
179+
location={location}
180+
fields={fields}
181+
title={tableResults.length > 1 ? result.title : ''}
182+
// Bypass the loading state for span widgets because this renders the loading placeholder
183+
// and we want to show the underlying data during preflight instead
184+
loading={widget.widgetType === WidgetType.SPANS ? false : loading}
185+
loader={<LoadingPlaceholder />}
186+
metadata={result.meta}
187+
data={result.data}
188+
stickyHeaders
189+
fieldHeaderMap={datasetConfig.getFieldHeaderMap?.(widget.queries[i])}
190+
getCustomFieldRenderer={getCustomFieldRenderer}
191+
minColumnWidth={minTableColumnWidth}
192+
/>
193+
)}
182194
</TableWrapper>
183195
);
184196
});

static/app/views/dashboards/widgets/common/types.tsx

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -73,6 +73,13 @@ export type TabularData<TFields extends string = string> = {
7373
meta: TabularMeta<TFields>;
7474
};
7575

76+
export type TabularColumn<TFields extends string = string> = {
77+
key: TFields;
78+
name: TFields;
79+
type?: AttributeValueType;
80+
width?: number;
81+
};
82+
7683
type ErrorProp = Error | string;
7784
export interface ErrorPropWithResponseJSON extends Error {
7885
responseJSON?: {detail: string};
Lines changed: 104 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,104 @@
1+
import type {Theme} from '@emotion/react';
2+
import styled from '@emotion/styled';
3+
import type {Location} from 'history';
4+
5+
import {Tooltip} from 'sentry/components/core/tooltip';
6+
import type {Alignments} from 'sentry/components/gridEditable/sortLink';
7+
import type {Organization} from 'sentry/types/organization';
8+
import {getFieldRenderer} from 'sentry/utils/discover/fieldRenderers';
9+
import type {ColumnValueType} from 'sentry/utils/discover/fields';
10+
import {fieldAlignment} from 'sentry/utils/discover/fields';
11+
import type {
12+
TabularColumn,
13+
TabularData,
14+
TabularRow,
15+
} from 'sentry/views/dashboards/widgets/common/types';
16+
17+
interface DefaultHeadCellRenderProps {
18+
renderTableHeadCell?: (
19+
column: TabularColumn,
20+
columnIndex: number
21+
) => React.ReactNode | undefined;
22+
}
23+
24+
export const renderDefaultHeadCell = ({
25+
renderTableHeadCell,
26+
}: DefaultHeadCellRenderProps) =>
27+
function (
28+
column: TabularColumn<keyof TabularRow>,
29+
_columnIndex: number
30+
): React.ReactNode {
31+
const cell = renderTableHeadCell?.(column, _columnIndex);
32+
if (cell) {
33+
return cell;
34+
}
35+
const align = fieldAlignment(column.name, column.type as ColumnValueType);
36+
37+
return (
38+
<CellWrapper align={align}>
39+
<StyledTooltip title={column.name}>{column.name}</StyledTooltip>
40+
</CellWrapper>
41+
);
42+
};
43+
44+
interface DefaultBodyCellRenderProps {
45+
location: Location;
46+
organization: Organization;
47+
theme: Theme;
48+
renderTableBodyCell?: (
49+
column: TabularColumn,
50+
dataRow: TabularRow,
51+
rowIndex: number,
52+
columnIndex: number
53+
) => React.ReactNode | undefined;
54+
tableData?: TabularData;
55+
}
56+
57+
export const renderDefaultBodyCell = ({
58+
tableData,
59+
location,
60+
organization,
61+
theme,
62+
renderTableBodyCell,
63+
}: DefaultBodyCellRenderProps) =>
64+
function (
65+
column: TabularColumn,
66+
dataRow: TabularRow,
67+
rowIndex: number,
68+
columnIndex: number
69+
): React.ReactNode {
70+
const cell = renderTableBodyCell?.(column, dataRow, rowIndex, columnIndex);
71+
if (cell) {
72+
return cell;
73+
}
74+
75+
const columnKey = String(column.key);
76+
if (!tableData?.meta) {
77+
return dataRow[column.key];
78+
}
79+
80+
const fieldRenderer = getFieldRenderer(columnKey, tableData.meta.fields, false);
81+
const unit = tableData.meta.units?.[columnKey] as string;
82+
83+
return (
84+
<div key={`${rowIndex}-${columnIndex}:${column.name}`}>
85+
{fieldRenderer(dataRow, {
86+
organization,
87+
location,
88+
unit,
89+
theme,
90+
})}
91+
</div>
92+
);
93+
};
94+
95+
const StyledTooltip = styled(Tooltip)`
96+
display: initial;
97+
`;
98+
99+
const CellWrapper = styled('div')<{align: Alignments}>`
100+
display: block;
101+
width: 100%;
102+
white-space: nowrap;
103+
${(p: {align: Alignments}) => (p.align ? `text-align: ${p.align};` : '')}
104+
`;
Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,41 @@
1+
import type {TabularData} from 'sentry/views/dashboards/widgets/common/types';
2+
3+
export const sampleHTTPRequestTableData: TabularData = {
4+
data: [
5+
{
6+
'http.request_method': 'PATCH',
7+
'count(span.duration)': 14105,
8+
id: '',
9+
},
10+
{
11+
'http.request_method': 'HEAD',
12+
'count(span.duration)': 9494,
13+
id: '',
14+
},
15+
{
16+
'http.request_method': 'GET',
17+
'count(span.duration)': 38583495,
18+
id: '',
19+
},
20+
{
21+
'http.request_method': 'DELETE',
22+
'count(span.duration)': 123,
23+
id: '',
24+
},
25+
{
26+
'http.request_method': 'POST',
27+
'count(span.duration)': 21313,
28+
id: '',
29+
},
30+
],
31+
meta: {
32+
fields: {
33+
'http.request_method': 'string',
34+
'count(span.duration)': 'integer',
35+
},
36+
units: {
37+
'http.request_method': null,
38+
'count(span.duration)': null,
39+
},
40+
},
41+
};
Lines changed: 107 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,107 @@
1+
import {TabularColumnsFixture} from 'sentry-fixture/tabularColumns';
2+
3+
import {render, screen} from 'sentry-test/reactTestingLibrary';
4+
5+
import {DurationUnit, RateUnit} from 'sentry/utils/discover/fields';
6+
import type {
7+
TabularColumn,
8+
TabularData,
9+
TabularRow,
10+
} from 'sentry/views/dashboards/widgets/common/types';
11+
import {sampleHTTPRequestTableData} from 'sentry/views/dashboards/widgets/tableWidget/fixtures/sampleHTTPRequestTableData';
12+
import {TableWidgetVisualization} from 'sentry/views/dashboards/widgets/tableWidget/tableWidgetVisualization';
13+
14+
describe('TableWidgetVisualization', function () {
15+
it('Basic table renders correctly', async function () {
16+
render(<TableWidgetVisualization tableData={sampleHTTPRequestTableData} />);
17+
18+
expect(await screen.findByText('http.request_method')).toBeInTheDocument();
19+
expect(await screen.findByText('count(span.duration)')).toBeInTheDocument();
20+
});
21+
22+
it('Table applies custom order and column name if provided', function () {
23+
const columns: Array<Partial<TabularColumn>> = [
24+
{
25+
key: 'count(span.duration)',
26+
name: 'Count of Span Duration',
27+
},
28+
{
29+
key: 'http.request_method',
30+
name: 'HTTP Request Method',
31+
},
32+
];
33+
34+
render(
35+
<TableWidgetVisualization
36+
tableData={sampleHTTPRequestTableData}
37+
columns={TabularColumnsFixture(columns)}
38+
/>
39+
);
40+
41+
const headers = screen.getAllByTestId('grid-head-cell');
42+
expect(headers[0]?.children[0]?.textContent).toEqual(columns[0]?.name);
43+
expect(headers[1]?.children[0]?.textContent).toEqual(columns[1]?.name);
44+
});
45+
46+
it('Table renders unique number fields correctly', async function () {
47+
const tableData: TabularData = {
48+
data: [{'span.duration': 123, failure_rate: 0.1, epm: 6}],
49+
meta: {
50+
fields: {'span.duration': 'duration', failure_rate: 'percentage', epm: 'rate'},
51+
units: {
52+
'span.duration': DurationUnit.MILLISECOND,
53+
failure_rate: null,
54+
epm: RateUnit.PER_MINUTE,
55+
},
56+
},
57+
};
58+
render(<TableWidgetVisualization tableData={tableData} />);
59+
60+
expect(await screen.findByText('span.duration')).toBeInTheDocument();
61+
expect(await screen.findByText('failure_rate')).toBeInTheDocument();
62+
expect(await screen.findByText('epm')).toBeInTheDocument();
63+
64+
expect(await screen.findByText('123.00ms')).toBeInTheDocument();
65+
expect(await screen.findByText('10%')).toBeInTheDocument();
66+
expect(await screen.findByText('6.00/min')).toBeInTheDocument();
67+
});
68+
69+
it('Table uses custom renderer over fallback renderer correctly', async function () {
70+
const tableData: TabularData = {
71+
data: [{date: '2025-06-20T15:14:52+00:00'}],
72+
meta: {
73+
fields: {date: 'date'},
74+
units: {
75+
date: null,
76+
},
77+
},
78+
};
79+
80+
function customDateHeadRenderer(
81+
column: TabularColumn<keyof TabularRow>,
82+
_columnIndex: number
83+
) {
84+
return <div>{column.name + ' column'}</div>;
85+
}
86+
87+
function customDateBodyRenderer(
88+
column: TabularColumn,
89+
dataRow: TabularRow,
90+
_rowIndex: number,
91+
_columnIndex: number
92+
) {
93+
return <div>{dataRow[column.key]}</div>;
94+
}
95+
96+
render(
97+
<TableWidgetVisualization
98+
tableData={tableData}
99+
renderTableHeadCell={customDateHeadRenderer}
100+
renderTableBodyCell={customDateBodyRenderer}
101+
/>
102+
);
103+
104+
expect(await screen.findByText('date column')).toBeInTheDocument();
105+
expect(await screen.findByText('2025-06-20T15:14:52+00:00')).toBeInTheDocument();
106+
});
107+
});

0 commit comments

Comments
 (0)