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; `;