From 3e246c8ce1ba09ed02f3d2762d5f6fea4b9b4b07 Mon Sep 17 00:00:00 2001
From: lzhao-sentry
Date: Fri, 27 Jun 2025 16:44:11 -0400
Subject: [PATCH 1/6] feat(dashboards): sorting for table widget visualization
---
static/app/views/dashboards/dashboard.tsx | 21 ++++++++++
.../app/views/dashboards/sortableWidget.tsx | 4 ++
.../components/widgetPreview.tsx | 12 +++++-
.../app/views/dashboards/widgetCard/chart.tsx | 21 ++++++++--
.../app/views/dashboards/widgetCard/index.tsx | 5 ++-
.../dashboards/widgetCard/issueWidgetCard.tsx | 10 ++++-
.../widgetCard/widgetCardChartContainer.tsx | 6 ++-
.../views/dashboards/widgets/common/types.tsx | 1 +
.../tableWidget/tableWidgetVisualization.tsx | 40 +++++++++++++------
9 files changed, 101 insertions(+), 19 deletions(-)
diff --git a/static/app/views/dashboards/dashboard.tsx b/static/app/views/dashboards/dashboard.tsx
index 15b80380254dff..5d37dbf06f29ea 100644
--- a/static/app/views/dashboards/dashboard.tsx
+++ b/static/app/views/dashboards/dashboard.tsx
@@ -25,6 +25,7 @@ import type {PageFilters} from 'sentry/types/core';
import type {InjectedRouter} from 'sentry/types/legacyReactRouter';
import type {Organization} from 'sentry/types/organization';
import {trackAnalytics} from 'sentry/utils/analytics';
+import type {Sort} from 'sentry/utils/discover/fields';
import {DatasetSource} from 'sentry/utils/discover/types';
import withApi from 'sentry/utils/withApi';
import withPageFilters from 'sentry/utils/withPageFilters';
@@ -351,6 +352,25 @@ class Dashboard extends Component {
];
}
+ handleWidgetColumnTableSort(index: number) {
+ const {dashboard, onUpdate} = this.props;
+ return function (sort: Sort) {
+ const widget = dashboard.widgets[index]!;
+ const widgetCopy = cloneDeep({
+ ...widget,
+ });
+ if (widgetCopy.queries[0]) {
+ const direction = sort.kind === 'desc' ? '-' : '';
+ widgetCopy.queries[0].orderby = `${direction}${sort.field}`;
+ }
+
+ const nextList = [...dashboard.widgets];
+ nextList[index] = widgetCopy;
+
+ onUpdate(nextList);
+ };
+ }
+
renderWidget(widget: Widget, index: number) {
const {isMobile, windowWidth} = this.state;
const {
@@ -391,6 +411,7 @@ class Dashboard extends Component {
index={String(index)}
newlyAddedWidget={newlyAddedWidget}
onNewWidgetScrollComplete={onNewWidgetScrollComplete}
+ onTableColumnSort={this.handleWidgetColumnTableSort(index)}
/>
);
diff --git a/static/app/views/dashboards/sortableWidget.tsx b/static/app/views/dashboards/sortableWidget.tsx
index 6a997a8fb46559..a85bb843867387 100644
--- a/static/app/views/dashboards/sortableWidget.tsx
+++ b/static/app/views/dashboards/sortableWidget.tsx
@@ -4,6 +4,7 @@ import styled from '@emotion/styled';
import {LazyRender} from 'sentry/components/lazyRender';
import PanelAlert from 'sentry/components/panels/panelAlert';
import type {User} from 'sentry/types/user';
+import type {Sort} from 'sentry/utils/discover/fields';
import useOrganization from 'sentry/utils/useOrganization';
import {useUser} from 'sentry/utils/useUser';
import {useUserTeams} from 'sentry/utils/useUserTeams';
@@ -34,6 +35,7 @@ type Props = {
isPreview?: boolean;
newlyAddedWidget?: Widget;
onNewWidgetScrollComplete?: () => void;
+ onTableColumnSort?: (sort: Sort) => void;
windowWidth?: number;
};
@@ -57,6 +59,7 @@ function SortableWidget(props: Props) {
dashboardCreator,
newlyAddedWidget,
onNewWidgetScrollComplete,
+ onTableColumnSort,
} = props;
const organization = useOrganization();
@@ -104,6 +107,7 @@ function SortableWidget(props: Props) {
isMobile,
windowWidth,
tableItemLimit: TABLE_ITEM_LIMIT,
+ onTableColumnSort,
};
return (
diff --git a/static/app/views/dashboards/widgetBuilder/components/widgetPreview.tsx b/static/app/views/dashboards/widgetBuilder/components/widgetPreview.tsx
index 741f32b615856c..2c9152fca93e9a 100644
--- a/static/app/views/dashboards/widgetBuilder/components/widgetPreview.tsx
+++ b/static/app/views/dashboards/widgetBuilder/components/widgetPreview.tsx
@@ -1,6 +1,7 @@
import PanelAlert from 'sentry/components/panels/panelAlert';
import {dedupeArray} from 'sentry/utils/dedupeArray';
import type {TableDataWithTitle} from 'sentry/utils/discover/discoverQuery';
+import type {Sort} from 'sentry/utils/discover/fields';
import {useLocation} from 'sentry/utils/useLocation';
import {useNavigate} from 'sentry/utils/useNavigate';
import useOrganization from 'sentry/utils/useOrganization';
@@ -12,6 +13,7 @@ import {
WidgetType,
} from 'sentry/views/dashboards/types';
import {useWidgetBuilderContext} from 'sentry/views/dashboards/widgetBuilder/contexts/widgetBuilderContext';
+import {BuilderStateAction} from 'sentry/views/dashboards/widgetBuilder/hooks/useWidgetBuilderState';
import {convertBuilderStateToWidget} from 'sentry/views/dashboards/widgetBuilder/utils/convertBuilderStateToWidget';
import WidgetCard from 'sentry/views/dashboards/widgetCard';
import WidgetLegendNameEncoderDecoder from 'sentry/views/dashboards/widgetLegendNameEncoderDecoder';
@@ -39,7 +41,7 @@ function WidgetPreview({
const navigate = useNavigate();
const pageFilters = usePageFilters();
- const {state} = useWidgetBuilderContext();
+ const {state, dispatch} = useWidgetBuilderContext();
const widget = convertBuilderStateToWidget(state);
@@ -75,6 +77,13 @@ function WidgetPreview({
}),
};
+ function onTableColumnSort(sort: Sort) {
+ dispatch({
+ payload: [sort],
+ type: BuilderStateAction.SET_SORT,
+ });
+ }
+
return (
);
}
diff --git a/static/app/views/dashboards/widgetCard/chart.tsx b/static/app/views/dashboards/widgetCard/chart.tsx
index 2470be8414017c..7da81a62c55953 100644
--- a/static/app/views/dashboards/widgetCard/chart.tsx
+++ b/static/app/views/dashboards/widgetCard/chart.tsx
@@ -41,18 +41,20 @@ import {
} from 'sentry/utils/discover/charts';
import type {EventsMetaType, MetaType} from 'sentry/utils/discover/eventView';
import type {RenderFunctionBaggage} from 'sentry/utils/discover/fieldRenderers';
-import type {AggregationOutputType, DataUnit} from 'sentry/utils/discover/fields';
+import type {AggregationOutputType, DataUnit, Sort} from 'sentry/utils/discover/fields';
import {
aggregateOutputType,
getAggregateArg,
getEquation,
getMeasurementSlug,
+ isAggregateField,
isEquation,
maybeEquationAlias,
stripDerivedMetricsPrefix,
stripEquationPrefix,
} from 'sentry/utils/discover/fields';
import getDynamicText from 'sentry/utils/getDynamicText';
+import {decodeSorts} from 'sentry/utils/queryString';
import {getDatasetConfig} from 'sentry/views/dashboards/datasetConfig/base';
import type {Widget} from 'sentry/views/dashboards/types';
import {DisplayType, WidgetType} from 'sentry/views/dashboards/types';
@@ -103,6 +105,7 @@ type WidgetCardChartProps = Pick<
selected: Record;
type: 'legendselectchanged';
}>;
+ onTableColumnSort?: (sort: Sort) => void;
onZoom?: EChartDataZoomHandler;
sampleCount?: number;
shouldResize?: boolean;
@@ -144,8 +147,15 @@ class WidgetCardChart extends Component {
}
tableResultComponent({loading, tableResults}: TableResultProps): React.ReactNode {
- const {widget, selection, minTableColumnWidth, location, organization, theme} =
- this.props;
+ const {
+ widget,
+ selection,
+ minTableColumnWidth,
+ location,
+ organization,
+ theme,
+ onTableColumnSort,
+ } = this.props;
if (loading || !tableResults?.[0]) {
// Align height to other charts.
return ;
@@ -175,9 +185,12 @@ class WidgetCardChart extends Component {
name: column.name,
width: minTableColumnWidth ?? column.width,
type: column.type === 'never' ? null : column.type,
+ sortable:
+ widget.widgetType === WidgetType.RELEASE ? isAggregateField(column.name) : true,
}));
const aliases = decodeColumnAliases(columns, fieldAliases, fieldHeaderMap);
const tableData = convertTableDataToTabularData(tableResults?.[0]);
+ const sort = decodeSorts(widget.queries[0]?.orderby);
return (
@@ -189,6 +202,8 @@ class WidgetCardChart extends Component {
scrollable
fit="max-content"
aliases={aliases}
+ onColumnSortChange={onTableColumnSort}
+ sort={sort.length > 0 ? sort[0] : undefined}
getRenderer={(field, _dataRow, meta) => {
const customRenderer = datasetConfig.getCustomFieldRenderer?.(
field,
diff --git a/static/app/views/dashboards/widgetCard/index.tsx b/static/app/views/dashboards/widgetCard/index.tsx
index 048589bf6f63e5..1b8b7018acb32f 100644
--- a/static/app/views/dashboards/widgetCard/index.tsx
+++ b/static/app/views/dashboards/widgetCard/index.tsx
@@ -16,7 +16,7 @@ import type {Series} from 'sentry/types/echarts';
import type {WithRouterProps} from 'sentry/types/legacyReactRouter';
import type {Confidence, Organization} from 'sentry/types/organization';
import type {TableDataWithTitle} from 'sentry/utils/discover/discoverQuery';
-import type {AggregationOutputType} from 'sentry/utils/discover/fields';
+import type {AggregationOutputType, Sort} from 'sentry/utils/discover/fields';
import {statsPeriodToDays} from 'sentry/utils/duration/statsPeriodToDays';
import {hasOnDemandMetricWidgetFeature} from 'sentry/utils/onDemandMetrics/features';
import {useExtractionStatus} from 'sentry/utils/performance/contexts/metricsEnhancedPerformanceDataContext';
@@ -87,6 +87,7 @@ type Props = WithRouterProps & {
onEdit?: () => void;
onLegendSelectChanged?: () => void;
onSetTransactionsDataset?: () => void;
+ onTableColumnSort?: (sort: Sort) => void;
onUpdate?: (widget: Widget | null) => void;
onWidgetSplitDecision?: (splitDecision: WidgetType) => void;
renderErrorMessage?: (errorMessage?: string) => React.ReactNode;
@@ -156,6 +157,7 @@ function WidgetCard(props: Props) {
disableZoom,
showLoadingText,
router,
+ onTableColumnSort,
} = props;
if (widget.displayType === DisplayType.TOP_N) {
@@ -322,6 +324,7 @@ function WidgetCard(props: Props) {
disableZoom={disableZoom}
onDataFetchStart={onDataFetchStart}
showLoadingText={showLoadingText && isLoadingTextVisible}
+ onTableColumnSort={onTableColumnSort}
/>
diff --git a/static/app/views/dashboards/widgetCard/issueWidgetCard.tsx b/static/app/views/dashboards/widgetCard/issueWidgetCard.tsx
index d4346de8f450d5..f53222b02003be 100644
--- a/static/app/views/dashboards/widgetCard/issueWidgetCard.tsx
+++ b/static/app/views/dashboards/widgetCard/issueWidgetCard.tsx
@@ -12,7 +12,11 @@ import type {Organization} from 'sentry/types/organization';
import {defined} from 'sentry/utils';
import type {TableData} from 'sentry/utils/discover/discoverQuery';
import type {MetaType} from 'sentry/utils/discover/eventView';
-import type {RenderFunctionBaggage} from 'sentry/utils/discover/fieldRenderers';
+import {
+ getSortField,
+ type RenderFunctionBaggage,
+} from 'sentry/utils/discover/fieldRenderers';
+import type {Sort} from 'sentry/utils/discover/fields';
import {getDatasetConfig} from 'sentry/views/dashboards/datasetConfig/base';
import {type Widget, WidgetType} from 'sentry/views/dashboards/types';
import {eventViewFromWidget} from 'sentry/views/dashboards/utils';
@@ -31,6 +35,7 @@ type Props = {
theme: Theme;
widget: Widget;
errorMessage?: string;
+ onTableColumnSort?: (sort: Sort) => void;
tableResults?: TableData[];
};
@@ -43,6 +48,7 @@ export function IssueWidgetCard({
organization,
location,
theme,
+ onTableColumnSort,
}: Props) {
const datasetConfig = getDatasetConfig(WidgetType.ISSUE);
@@ -70,6 +76,7 @@ export function IssueWidgetCard({
name: column.name,
width: column.width,
type: column.type === 'never' ? null : column.type,
+ sortable: !!getSortField(column.key, tableResults?.[0]?.meta),
}));
const aliases = decodeColumnAliases(columns, fieldAliases, fieldHeaderMap);
const tableData = convertTableDataToTabularData(tableResults?.[0]);
@@ -88,6 +95,7 @@ export function IssueWidgetCard({
scrollable
fit="max-content"
aliases={aliases}
+ onColumnSortChange={onTableColumnSort}
getRenderer={(field, _dataRow, meta) => {
const customRenderer = datasetConfig.getCustomFieldRenderer?.(
field,
diff --git a/static/app/views/dashboards/widgetCard/widgetCardChartContainer.tsx b/static/app/views/dashboards/widgetCard/widgetCardChartContainer.tsx
index 4c2b8be125b915..f6d7efa4659f51 100644
--- a/static/app/views/dashboards/widgetCard/widgetCardChartContainer.tsx
+++ b/static/app/views/dashboards/widgetCard/widgetCardChartContainer.tsx
@@ -17,7 +17,7 @@ import type {
} from 'sentry/types/echarts';
import type {Organization} from 'sentry/types/organization';
import type {TableDataWithTitle} from 'sentry/utils/discover/discoverQuery';
-import type {AggregationOutputType} from 'sentry/utils/discover/fields';
+import type {AggregationOutputType, Sort} from 'sentry/utils/discover/fields';
import {useLocation} from 'sentry/utils/useLocation';
import type {DashboardFilters, Widget} from 'sentry/views/dashboards/types';
import {DisplayType, WidgetType} from 'sentry/views/dashboards/types';
@@ -56,6 +56,7 @@ type Props = {
selected: Record;
type: 'legendselectchanged';
}>;
+ onTableColumnSort?: (sort: Sort) => void;
onWidgetSplitDecision?: (splitDecision: WidgetType) => void;
onZoom?: EChartDataZoomHandler;
renderErrorMessage?: (errorMessage?: string) => React.ReactNode;
@@ -90,6 +91,7 @@ export function WidgetCardChartContainer({
onDataFetchStart,
disableZoom,
showLoadingText,
+ onTableColumnSort,
}: Props) {
const location = useLocation();
const theme = useTheme();
@@ -171,6 +173,7 @@ export function WidgetCardChartContainer({
selection={selection}
theme={theme}
organization={organization}
+ onTableColumnSort={onTableColumnSort}
/>
);
@@ -215,6 +218,7 @@ export function WidgetCardChartContainer({
isSampled={isSampled}
showLoadingText={showLoadingText}
theme={theme}
+ onTableColumnSort={onTableColumnSort}
/>
);
diff --git a/static/app/views/dashboards/widgets/common/types.tsx b/static/app/views/dashboards/widgets/common/types.tsx
index 796ef017b1b277..4bf593c147f563 100644
--- a/static/app/views/dashboards/widgets/common/types.tsx
+++ b/static/app/views/dashboards/widgets/common/types.tsx
@@ -76,6 +76,7 @@ export type TabularData = {
export type TabularColumn = {
key: TFields;
name: TFields;
+ sortable?: boolean;
type?: AttributeValueType;
width?: number;
};
diff --git a/static/app/views/dashboards/widgets/tableWidget/tableWidgetVisualization.tsx b/static/app/views/dashboards/widgets/tableWidget/tableWidgetVisualization.tsx
index 0044dab2891604..2b9da343af5b14 100644
--- a/static/app/views/dashboards/widgets/tableWidget/tableWidgetVisualization.tsx
+++ b/static/app/views/dashboards/widgets/tableWidget/tableWidgetVisualization.tsx
@@ -3,11 +3,11 @@ import styled from '@emotion/styled';
import {Tooltip} from 'sentry/components/core/tooltip';
import GridEditable from 'sentry/components/tables/gridEditable';
-import type {Alignments} from 'sentry/components/tables/gridEditable/sortLink';
+import SortLink from 'sentry/components/tables/gridEditable/sortLink';
import type {MetaType} from 'sentry/utils/discover/eventView';
import type {RenderFunctionBaggage} from 'sentry/utils/discover/fieldRenderers';
import {getFieldRenderer} from 'sentry/utils/discover/fieldRenderers';
-import type {ColumnValueType} from 'sentry/utils/discover/fields';
+import type {ColumnValueType, Sort} from 'sentry/utils/discover/fields';
import {fieldAlignment} from 'sentry/utils/discover/fields';
import {useLocation} from 'sentry/utils/useLocation';
import useOrganization from 'sentry/utils/useOrganization';
@@ -73,10 +73,20 @@ interface TableWidgetVisualizationProps {
* @param meta The full table metadata
*/
makeBaggage?: BaggageMaker;
+ /**
+ * A callback function that is invoked after a user clicks a sortable column header
+ * @param sortBy the field that should be sorted
+ * @param sortDirection whether to sort by 'asc' or 'desc'
+ */
+ onColumnSortChange?: (sort: Sort) => void;
/**
* If true, the table will scroll on overflow. Note that the table headers will also be sticky
*/
scrollable?: boolean;
+ /**
+ * The current sort order to display
+ */
+ sort?: Sort;
}
const FRAMELESS_STYLES = {
@@ -99,6 +109,8 @@ export function TableWidgetVisualization(props: TableWidgetVisualizationProps) {
scrollable,
fit,
aliases,
+ onColumnSortChange,
+ sort,
} = props;
const theme = useTheme();
@@ -147,11 +159,22 @@ export function TableWidgetVisualization(props: TableWidgetVisualizationProps) {
const column = columnOrder[columnIndex]!;
const align = fieldAlignment(column.name, column.type as ColumnValueType);
const name = aliases?.[column.key] || column.name;
+ const direction = sort?.field === column.key ? sort?.kind : undefined;
return (
-
- {name}
-
+
+ onColumnSortChange?.({
+ field: column.key,
+ kind: direction === 'desc' ? 'asc' : 'desc',
+ })
+ }
+ title={{name}}
+ direction={direction}
+ generateSortLink={() => location}
+ />
);
},
renderBodyCell: (tableColumn, dataRow, rowIndex, columnIndex) => {
@@ -185,10 +208,3 @@ TableWidgetVisualization.LoadingPlaceholder = function () {
const StyledTooltip = styled(Tooltip)`
display: initial;
`;
-
-const CellWrapper = styled('div')<{align: Alignments}>`
- display: block;
- width: 100%;
- white-space: nowrap;
- ${(p: {align: Alignments}) => (p.align ? `text-align: ${p.align};` : '')}
-`;
From 2d4d6c19e2317932c61fbc7c9ecc28deb1d05633 Mon Sep 17 00:00:00 2001
From: lzhao-sentry
Date: Wed, 2 Jul 2025 09:50:29 -0400
Subject: [PATCH 2/6] add default sort arrow parsing
---
static/app/views/dashboards/dashboard.tsx | 3 +-
.../dashboards/widgetCard/issueWidgetCard.tsx | 15 ++-
.../tableWidgetVisualization.stories.tsx | 96 ++++++++++++++++---
.../tableWidget/tableWidgetVisualization.tsx | 36 +++++--
4 files changed, 122 insertions(+), 28 deletions(-)
diff --git a/static/app/views/dashboards/dashboard.tsx b/static/app/views/dashboards/dashboard.tsx
index 5d37dbf06f29ea..415e0225485408 100644
--- a/static/app/views/dashboards/dashboard.tsx
+++ b/static/app/views/dashboards/dashboard.tsx
@@ -360,7 +360,8 @@ class Dashboard extends Component {
...widget,
});
if (widgetCopy.queries[0]) {
- const direction = sort.kind === 'desc' ? '-' : '';
+ const direction =
+ sort.kind === 'desc' && widget.widgetType !== WidgetType.ISSUE ? '-' : '';
widgetCopy.queries[0].orderby = `${direction}${sort.field}`;
}
diff --git a/static/app/views/dashboards/widgetCard/issueWidgetCard.tsx b/static/app/views/dashboards/widgetCard/issueWidgetCard.tsx
index f53222b02003be..e37b24eddf4ef0 100644
--- a/static/app/views/dashboards/widgetCard/issueWidgetCard.tsx
+++ b/static/app/views/dashboards/widgetCard/issueWidgetCard.tsx
@@ -10,13 +10,12 @@ import {space} from 'sentry/styles/space';
import type {PageFilters} from 'sentry/types/core';
import type {Organization} from 'sentry/types/organization';
import {defined} from 'sentry/utils';
+import {getSortField} from 'sentry/utils/dashboards/issueFieldRenderers';
import type {TableData} from 'sentry/utils/discover/discoverQuery';
import type {MetaType} from 'sentry/utils/discover/eventView';
-import {
- getSortField,
- type RenderFunctionBaggage,
-} from 'sentry/utils/discover/fieldRenderers';
+import {type RenderFunctionBaggage} from 'sentry/utils/discover/fieldRenderers';
import type {Sort} from 'sentry/utils/discover/fields';
+import {decodeSorts} from 'sentry/utils/queryString';
import {getDatasetConfig} from 'sentry/views/dashboards/datasetConfig/base';
import {type Widget, WidgetType} from 'sentry/views/dashboards/types';
import {eventViewFromWidget} from 'sentry/views/dashboards/utils';
@@ -76,7 +75,7 @@ export function IssueWidgetCard({
name: column.name,
width: column.width,
type: column.type === 'never' ? null : column.type,
- sortable: !!getSortField(column.key, tableResults?.[0]?.meta),
+ sortable: !!getSortField(column.key),
}));
const aliases = decodeColumnAliases(columns, fieldAliases, fieldHeaderMap);
const tableData = convertTableDataToTabularData(tableResults?.[0]);
@@ -85,6 +84,11 @@ export function IssueWidgetCard({
const getCustomFieldRenderer = (field: string, meta: MetaType, org?: Organization) => {
return datasetConfig.getCustomFieldRenderer?.(field, meta, widget, org) || null;
};
+ // This is to match the widget viewer modal, which always displays the arrow pointing down
+ const sort: Sort = {
+ field: decodeSorts(widget.queries[0]?.orderby)[0]?.field || '',
+ kind: 'desc',
+ };
return organization.features.includes('dashboards-use-widget-table-visualization') ? (
@@ -96,6 +100,7 @@ export function IssueWidgetCard({
fit="max-content"
aliases={aliases}
onColumnSortChange={onTableColumnSort}
+ sort={sort}
getRenderer={(field, _dataRow, meta) => {
const customRenderer = datasetConfig.getCustomFieldRenderer?.(
field,
diff --git a/static/app/views/dashboards/widgets/tableWidget/tableWidgetVisualization.stories.tsx b/static/app/views/dashboards/widgets/tableWidget/tableWidgetVisualization.stories.tsx
index 6b51f37df1442e..184e65aba9065f 100644
--- a/static/app/views/dashboards/widgets/tableWidget/tableWidgetVisualization.stories.tsx
+++ b/static/app/views/dashboards/widgets/tableWidget/tableWidgetVisualization.stories.tsx
@@ -14,6 +14,20 @@ import {sampleHTTPRequestTableData} from 'sentry/views/dashboards/widgets/tableW
import {TableWidgetVisualization} from 'sentry/views/dashboards/widgets/tableWidget/tableWidgetVisualization';
export default Storybook.story('TableWidgetVisualization', story => {
+ const customColumns: TabularColumn[] = [
+ {
+ key: 'count(span.duration)',
+ name: 'count(span.duration)',
+ type: 'number',
+ width: 200,
+ },
+ {
+ key: 'http.request_method',
+ name: 'http.request_method',
+ type: 'string',
+ width: -1,
+ },
+ ];
story('Getting Started', () => {
return (
@@ -40,20 +54,6 @@ export default Storybook.story('TableWidgetVisualization', story => {
...sampleHTTPRequestTableData,
data: [],
};
- const customColumns: TabularColumn[] = [
- {
- key: 'count(span.duration)',
- name: 'count(span.duration)',
- type: 'number',
- width: 200,
- },
- {
- key: 'http.request_method',
- name: 'http.request_method',
- type: 'string',
- width: -1,
- },
- ];
const aliases = {
'count(span.duration)': 'Count of Span Duration',
'http.request_method': 'HTTP Request Method',
@@ -112,6 +112,74 @@ ${JSON.stringify(aliases)}
);
});
+ story('Sorting by Column', () => {
+ const sortableColumns = customColumns.map(column => ({
+ ...column,
+ sortable: true,
+ width: -1,
+ }));
+ return (
+
+
+ By default, column fields are assumed to be not sortable. To enable sorting,
+ pass the
+ columns
prop with the field sortable
set to true. Ex.
+
+
+ {`
+columns={[{
+ key: 'count(span.duration)',
+ name: 'count(span.duration)',
+ type: 'number',
+ sortable: true
+},
+{
+ key: 'http.request_method',
+ name: 'http.request_method',
+ type: 'string',
+}]}
+ `}
+
+
+ The default action when a sortable column header is clicked is to update the
+ sort
location query parameter in the URL. If you wish to override
+ the URL update, you can pass onColumnSortChange
which accepts a
+ Sort
object that represents the newly selected sort. This is useful
+ if you need to manage internal state:
+
+
+ {`
+// The Sort type
+export type Sort = {
+ field: string;
+ kind: 'asc' | 'desc';
+};
+
+// Basic Example
+function onColumnSortChange(sort: Sort) {
+ setSort(sort)
+}
+ `}
+
+
+ The table will try to automatically parse out the direction from the location
+ query parameter and apply the sort direction arrow to the sorted column.
+ However, if sorting does not rely on this, or custom sort needs to be used, then
+ pass the sort
prop to correcly display the sort arrow direction:
+
+
+ {`sort={{field: 'count(span.duration)', kind: 'desc'}}`}
+
+
+
+
+ );
+ });
+
story('Using Custom Cell Rendering', () => {
function getRenderer(fieldName: string) {
if (fieldName === 'http.request_method') {
diff --git a/static/app/views/dashboards/widgets/tableWidget/tableWidgetVisualization.tsx b/static/app/views/dashboards/widgets/tableWidget/tableWidgetVisualization.tsx
index 2b9da343af5b14..948dc0409bd334 100644
--- a/static/app/views/dashboards/widgets/tableWidget/tableWidgetVisualization.tsx
+++ b/static/app/views/dashboards/widgets/tableWidget/tableWidgetVisualization.tsx
@@ -4,11 +4,13 @@ import styled from '@emotion/styled';
import {Tooltip} from 'sentry/components/core/tooltip';
import GridEditable from 'sentry/components/tables/gridEditable';
import SortLink from 'sentry/components/tables/gridEditable/sortLink';
+import {getSortField} from 'sentry/utils/dashboards/issueFieldRenderers';
import type {MetaType} from 'sentry/utils/discover/eventView';
import type {RenderFunctionBaggage} from 'sentry/utils/discover/fieldRenderers';
import {getFieldRenderer} from 'sentry/utils/discover/fieldRenderers';
import type {ColumnValueType, Sort} from 'sentry/utils/discover/fields';
import {fieldAlignment} from 'sentry/utils/discover/fields';
+import {decodeSorts} from 'sentry/utils/queryString';
import {useLocation} from 'sentry/utils/useLocation';
import useOrganization from 'sentry/utils/useOrganization';
import type {
@@ -74,9 +76,8 @@ interface TableWidgetVisualizationProps {
*/
makeBaggage?: BaggageMaker;
/**
- * A callback function that is invoked after a user clicks a sortable column header
- * @param sortBy the field that should be sorted
- * @param sortDirection whether to sort by 'asc' or 'desc'
+ * A callback function that is invoked after a user clicks a sortable column header and overrides default behaviour of navigating
+ * @param sort `Sort` object contain the `field` and `kind` ('asc' or 'desc')
*/
onColumnSortChange?: (sort: Sort) => void;
/**
@@ -148,6 +149,7 @@ export function TableWidgetVisualization(props: TableWidgetVisualizationProps) {
}));
const {data, meta} = tableData;
+ const locationSort = decodeSorts(location?.query?.sort)[0];
return (
onColumnSortChange?.({
- field: column.key,
- kind: direction === 'desc' ? 'asc' : 'desc',
+ field: sortColumn,
+ kind: nextDirection,
})
}
title={{name}}
direction={direction}
- generateSortLink={() => location}
+ generateSortLink={() => {
+ return onColumnSortChange
+ ? location
+ : {
+ ...location,
+ query: {
+ ...location.query,
+ sort: (nextDirection === 'desc' ? '-' : '') + sortColumn,
+ },
+ };
+ }}
/>
);
},
From 191d5d9ee9c547894da13883805a85f43c2cbc67 Mon Sep 17 00:00:00 2001
From: lzhao-sentry
Date: Wed, 2 Jul 2025 09:53:15 -0400
Subject: [PATCH 3/6] use key over name in release widget sorting
---
static/app/views/dashboards/widgetCard/chart.tsx | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/static/app/views/dashboards/widgetCard/chart.tsx b/static/app/views/dashboards/widgetCard/chart.tsx
index 7da81a62c55953..786c91159b92da 100644
--- a/static/app/views/dashboards/widgetCard/chart.tsx
+++ b/static/app/views/dashboards/widgetCard/chart.tsx
@@ -186,7 +186,7 @@ class WidgetCardChart extends Component {
width: minTableColumnWidth ?? column.width,
type: column.type === 'never' ? null : column.type,
sortable:
- widget.widgetType === WidgetType.RELEASE ? isAggregateField(column.name) : true,
+ widget.widgetType === WidgetType.RELEASE ? isAggregateField(column.key) : true,
}));
const aliases = decodeColumnAliases(columns, fieldAliases, fieldHeaderMap);
const tableData = convertTableDataToTabularData(tableResults?.[0]);
From 4f48d3e32109fffb9584b4c54c3981e3be4cc2f9 Mon Sep 17 00:00:00 2001
From: lzhao-sentry
Date: Wed, 2 Jul 2025 13:42:23 -0400
Subject: [PATCH 4/6] simplify getting sort in chart.tsx
---
static/app/views/dashboards/widgetCard/chart.tsx | 4 ++--
1 file changed, 2 insertions(+), 2 deletions(-)
diff --git a/static/app/views/dashboards/widgetCard/chart.tsx b/static/app/views/dashboards/widgetCard/chart.tsx
index 786c91159b92da..f5631029ff431a 100644
--- a/static/app/views/dashboards/widgetCard/chart.tsx
+++ b/static/app/views/dashboards/widgetCard/chart.tsx
@@ -190,7 +190,7 @@ class WidgetCardChart extends Component {
}));
const aliases = decodeColumnAliases(columns, fieldAliases, fieldHeaderMap);
const tableData = convertTableDataToTabularData(tableResults?.[0]);
- const sort = decodeSorts(widget.queries[0]?.orderby);
+ const sort = decodeSorts(widget.queries[0]?.orderby)[0];
return (
@@ -203,7 +203,7 @@ class WidgetCardChart extends Component {
fit="max-content"
aliases={aliases}
onColumnSortChange={onTableColumnSort}
- sort={sort.length > 0 ? sort[0] : undefined}
+ sort={sort}
getRenderer={(field, _dataRow, meta) => {
const customRenderer = datasetConfig.getCustomFieldRenderer?.(
field,
From 8fef9d4c56d28afc6816f06955b5eb33c5887ae8 Mon Sep 17 00:00:00 2001
From: lzhao-sentry
Date: Wed, 2 Jul 2025 17:51:30 -0400
Subject: [PATCH 5/6] upgrade stories, address comments
---
static/app/views/dashboards/dashboard.tsx | 4 +-
.../app/views/dashboards/widgetCard/chart.tsx | 2 +-
.../dashboards/widgetCard/issueWidgetCard.tsx | 2 +-
.../tableWidgetVisualization.stories.tsx | 110 +++++++++++++-----
.../tableWidget/tableWidgetVisualization.tsx | 23 ++--
5 files changed, 99 insertions(+), 42 deletions(-)
diff --git a/static/app/views/dashboards/dashboard.tsx b/static/app/views/dashboards/dashboard.tsx
index 415e0225485408..73d84d5741376a 100644
--- a/static/app/views/dashboards/dashboard.tsx
+++ b/static/app/views/dashboards/dashboard.tsx
@@ -356,9 +356,7 @@ class Dashboard extends Component {
const {dashboard, onUpdate} = this.props;
return function (sort: Sort) {
const widget = dashboard.widgets[index]!;
- const widgetCopy = cloneDeep({
- ...widget,
- });
+ const widgetCopy = cloneDeep(widget);
if (widgetCopy.queries[0]) {
const direction =
sort.kind === 'desc' && widget.widgetType !== WidgetType.ISSUE ? '-' : '';
diff --git a/static/app/views/dashboards/widgetCard/chart.tsx b/static/app/views/dashboards/widgetCard/chart.tsx
index f5631029ff431a..a2b9e79808608e 100644
--- a/static/app/views/dashboards/widgetCard/chart.tsx
+++ b/static/app/views/dashboards/widgetCard/chart.tsx
@@ -202,7 +202,7 @@ class WidgetCardChart extends Component {
scrollable
fit="max-content"
aliases={aliases}
- onColumnSortChange={onTableColumnSort}
+ onSortChange={onTableColumnSort}
sort={sort}
getRenderer={(field, _dataRow, meta) => {
const customRenderer = datasetConfig.getCustomFieldRenderer?.(
diff --git a/static/app/views/dashboards/widgetCard/issueWidgetCard.tsx b/static/app/views/dashboards/widgetCard/issueWidgetCard.tsx
index e37b24eddf4ef0..88932f7902d321 100644
--- a/static/app/views/dashboards/widgetCard/issueWidgetCard.tsx
+++ b/static/app/views/dashboards/widgetCard/issueWidgetCard.tsx
@@ -99,7 +99,7 @@ export function IssueWidgetCard({
scrollable
fit="max-content"
aliases={aliases}
- onColumnSortChange={onTableColumnSort}
+ onSortChange={onTableColumnSort}
sort={sort}
getRenderer={(field, _dataRow, meta) => {
const customRenderer = datasetConfig.getCustomFieldRenderer?.(
diff --git a/static/app/views/dashboards/widgets/tableWidget/tableWidgetVisualization.stories.tsx b/static/app/views/dashboards/widgets/tableWidget/tableWidgetVisualization.stories.tsx
index 184e65aba9065f..14b6df6c7d09cb 100644
--- a/static/app/views/dashboards/widgets/tableWidget/tableWidgetVisualization.stories.tsx
+++ b/static/app/views/dashboards/widgets/tableWidget/tableWidgetVisualization.stories.tsx
@@ -1,10 +1,14 @@
-import {Fragment} from 'react';
+import {Fragment, useState} from 'react';
+import styled from '@emotion/styled';
+import * as qs from 'query-string';
import {CodeSnippet} from 'sentry/components/codeSnippet';
import {Tag} from 'sentry/components/core/badge/tag';
+import {LinkButton} from 'sentry/components/core/button/linkButton';
import * as Storybook from 'sentry/stories';
import type {MetaType} from 'sentry/utils/discover/eventView';
import {getFieldRenderer} from 'sentry/utils/discover/fieldRenderers';
+import type {Sort} from 'sentry/utils/discover/fields';
import type {
TabularColumn,
TabularData,
@@ -28,6 +32,7 @@ export default Storybook.story('TableWidgetVisualization', story => {
width: -1,
},
];
+ const initParams = qs.parse(location.search);
story('Getting Started', () => {
return (
@@ -91,7 +96,7 @@ ${JSON.stringify(customColumns)}
To pass custom names for a column header, provide the prop aliases
{' '}
which maps column key to the alias. In some cases you may have both field
- aliases set by user (ex. in dashboards) as well as a static mapping. The util
+ aliases set by user (e.g., in dashboards) as well as a static mapping. The util
function decodeColumnAliases
is provided to consolidate them, with
priority given to user field aliases.
@@ -118,12 +123,14 @@ ${JSON.stringify(aliases)}
sortable: true,
width: -1,
}));
+ const [curSort, setSort] = useState(undefined);
return (
By default, column fields are assumed to be not sortable. To enable sorting,
pass the
- columns
prop with the field sortable
set to true. Ex.
+ columns
prop with the field sortable
set to true.
+ e.g.,
{`
@@ -140,41 +147,85 @@ columns={[{
}]}
`}
+
+ Sorting may require the display of a directional arrow. The table will try to
+ automatically determine the direction based on the sort
location
+ query parameter. Note that the table only supports sorting by one column at a
+ time, so if multiple sort
parameters are provided, it will choose
+ the first one.
+
+
+ For an interactive example, click the buttons below to see how the table
+ updates:
+
+
+
+ Apply sort=-count(span.duration)
+
+
+ Apply sort=count(span.duration)
+
+ Clear sort parameter
+
+
+
+ If the sort is not stored in the parameter, then pass the sort
prop
+ to correcly display the sort arrow direction. Similarly to the default
+ behaviour, only one sort is allowed. If both the prop and parameter are
+ available, the table will prioritize the prop (you can test this by using the
+ buttons from the previous example to also add the parameter).
+
+
+ {`
+
+ `}
+
+
+
+
The default action when a sortable column header is clicked is to update the
- sort
location query parameter in the URL. If you wish to override
- the URL update, you can pass onColumnSortChange
which accepts a
- Sort
object that represents the newly selected sort. This is useful
- if you need to manage internal state:
+ sort
location query parameter. If you wish to override the URL
+ update, you can pass onSortChange
which accepts aSort
+ object that represents the newly selected sort. This and the sort
+ prop are useful if you need to manage internal state:
{`
-// The Sort type
-export type Sort = {
- field: string;
- kind: 'asc' | 'desc';
-};
+const [curSort, setSort] = useState(undefined);
-// Basic Example
-function onColumnSortChange(sort: Sort) {
- setSort(sort)
-}
+ setSort(sort)}
+/>
`}
- The table will try to automatically parse out the direction from the location
- query parameter and apply the sort direction arrow to the sorted column.
- However, if sorting does not rely on this, or custom sort needs to be used, then
- pass the sort
prop to correcly display the sort arrow direction:
+ Try clicking the column headers below!{' '}
+
+ Current sort is
+ {curSort?.field ?? 'undefined'}
by
+ {curSort?.kind ?? 'undefined'}
order
+
-
- {`sort={{field: 'count(span.duration)', kind: 'desc'}}`}
-
-
setSort(sort)}
/>
);
@@ -257,3 +308,10 @@ function getRenderer(fieldName: string) {
);
});
});
+
+const ButtonContainer = styled('div')`
+ display: flex;
+ justify-content: center;
+ margin: 20px;
+ gap: 20px;
+`;
diff --git a/static/app/views/dashboards/widgets/tableWidget/tableWidgetVisualization.tsx b/static/app/views/dashboards/widgets/tableWidget/tableWidgetVisualization.tsx
index 948dc0409bd334..59e07d487af776 100644
--- a/static/app/views/dashboards/widgets/tableWidget/tableWidgetVisualization.tsx
+++ b/static/app/views/dashboards/widgets/tableWidget/tableWidgetVisualization.tsx
@@ -76,10 +76,10 @@ interface TableWidgetVisualizationProps {
*/
makeBaggage?: BaggageMaker;
/**
- * A callback function that is invoked after a user clicks a sortable column header and overrides default behaviour of navigating
+ * A callback function that is invoked after a user clicks a sortable column header. If omitted, clicking a column header updates the sort in the URL
* @param sort `Sort` object contain the `field` and `kind` ('asc' or 'desc')
*/
- onColumnSortChange?: (sort: Sort) => void;
+ onSortChange?: (sort: Sort) => void;
/**
* If true, the table will scroll on overflow. Note that the table headers will also be sticky
*/
@@ -110,7 +110,7 @@ export function TableWidgetVisualization(props: TableWidgetVisualizationProps) {
scrollable,
fit,
aliases,
- onColumnSortChange,
+ onSortChange,
sort,
} = props;
@@ -166,31 +166,32 @@ export function TableWidgetVisualization(props: TableWidgetVisualizationProps) {
let direction = undefined;
if (sort?.field === sortColumn) {
direction = sort.kind;
- } else if (locationSort?.field === sortColumn) {
+ } else if (locationSort?.field === sortColumn && !sort) {
direction = locationSort.kind;
}
- const nextDirection = direction === 'desc' ? 'asc' : 'desc';
return (
- onColumnSortChange?.({
+ onClick={() => {
+ const nextDirection = direction === 'desc' ? 'asc' : 'desc';
+
+ onSortChange?.({
field: sortColumn,
kind: nextDirection,
- })
- }
+ });
+ }}
title={{name}}
direction={direction}
generateSortLink={() => {
- return onColumnSortChange
+ return onSortChange
? location
: {
...location,
query: {
...location.query,
- sort: (nextDirection === 'desc' ? '-' : '') + sortColumn,
+ sort: (direction === 'desc' ? '' : '-') + sortColumn,
},
};
}}
From cd6f9022d024ee2495a63ed4a7dd74acea3458ab Mon Sep 17 00:00:00 2001
From: lzhao-sentry
Date: Thu, 3 Jul 2025 09:39:04 -0400
Subject: [PATCH 6/6] simplify story
---
.../tableWidgetVisualization.stories.tsx | 17 +++++------------
1 file changed, 5 insertions(+), 12 deletions(-)
diff --git a/static/app/views/dashboards/widgets/tableWidget/tableWidgetVisualization.stories.tsx b/static/app/views/dashboards/widgets/tableWidget/tableWidgetVisualization.stories.tsx
index 14b6df6c7d09cb..5d0775870f97f7 100644
--- a/static/app/views/dashboards/widgets/tableWidget/tableWidgetVisualization.stories.tsx
+++ b/static/app/views/dashboards/widgets/tableWidget/tableWidgetVisualization.stories.tsx
@@ -155,16 +155,10 @@ columns={[{
the first one.
- For an interactive example, click the buttons below to see how the table
- updates:
+ For an interactive example, click column headers below and pay attention to the
+ parameter in the URL. Use the button to reset the parameter.
-
- Apply sort=-count(span.duration)
-
-
- Apply sort=count(span.duration)
-
Clear sort parameter
If the sort is not stored in the parameter, then pass the sort
prop
to correcly display the sort arrow direction. Similarly to the default
- behaviour, only one sort is allowed. If both the prop and parameter are
- available, the table will prioritize the prop (you can test this by using the
- buttons from the previous example to also add the parameter).
+ behaviour, only one sort is allowed. If both the prop and parameter are defined,
+ the table will prioritize the prop. You can test this by clicking column headers
+ and note how the arrow doesn't change in the table below.
{`
@@ -313,5 +307,4 @@ const ButtonContainer = styled('div')`
display: flex;
justify-content: center;
margin: 20px;
- gap: 20px;
`;