diff --git a/static/app/views/dashboards/dashboard.tsx b/static/app/views/dashboards/dashboard.tsx index 15b80380254dff..73d84d5741376a 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,24 @@ 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' && widget.widgetType !== WidgetType.ISSUE ? '-' : ''; + 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 +410,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..a2b9e79808608e 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.key) : true, })); const aliases = decodeColumnAliases(columns, fieldAliases, fieldHeaderMap); const tableData = convertTableDataToTabularData(tableResults?.[0]); + const sort = decodeSorts(widget.queries[0]?.orderby)[0]; return ( @@ -189,6 +202,8 @@ class WidgetCardChart extends Component { scrollable fit="max-content" aliases={aliases} + onSortChange={onTableColumnSort} + sort={sort} 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..88932f7902d321 100644 --- a/static/app/views/dashboards/widgetCard/issueWidgetCard.tsx +++ b/static/app/views/dashboards/widgetCard/issueWidgetCard.tsx @@ -10,9 +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 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'; @@ -31,6 +34,7 @@ type Props = { theme: Theme; widget: Widget; errorMessage?: string; + onTableColumnSort?: (sort: Sort) => void; tableResults?: TableData[]; }; @@ -43,6 +47,7 @@ export function IssueWidgetCard({ organization, location, theme, + onTableColumnSort, }: Props) { const datasetConfig = getDatasetConfig(WidgetType.ISSUE); @@ -70,6 +75,7 @@ export function IssueWidgetCard({ name: column.name, width: column.width, type: column.type === 'never' ? null : column.type, + sortable: !!getSortField(column.key), })); const aliases = decodeColumnAliases(columns, fieldAliases, fieldHeaderMap); const tableData = convertTableDataToTabularData(tableResults?.[0]); @@ -78,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') ? ( @@ -88,6 +99,8 @@ export function IssueWidgetCard({ scrollable fit="max-content" aliases={aliases} + onSortChange={onTableColumnSort} + sort={sort} 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.stories.tsx b/static/app/views/dashboards/widgets/tableWidget/tableWidgetVisualization.stories.tsx index 6b51f37df1442e..5d0775870f97f7 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, @@ -14,6 +18,21 @@ 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, + }, + ]; + const initParams = qs.parse(location.search); story('Getting Started', () => { return ( @@ -40,20 +59,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', @@ -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.

@@ -112,6 +117,114 @@ ${JSON.stringify(aliases)} ); }); + story('Sorting by Column', () => { + const sortableColumns = customColumns.map(column => ({ + ...column, + 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. + e.g., +

+ + {` +columns={[{ + key: 'count(span.duration)', + name: 'count(span.duration)', + type: 'number', + sortable: true +}, +{ + key: 'http.request_method', + name: 'http.request_method', + type: 'string', +}]} + `} + +

+ 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 column headers below and pay attention to the + parameter in the URL. Use the button to reset the parameter. +

+ + 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 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. +

+ + {` + + `} + +
+ + +

+ The default action when a sortable column header is clicked is to update the + 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: +

+ + {` +const [curSort, setSort] = useState(undefined); + + setSort(sort)} +/> + `} + +

+ Try clicking the column headers below!{' '} + + Current sort is + {curSort?.field ?? 'undefined'} by + {curSort?.kind ?? 'undefined'} order + +

+ setSort(sort)} + /> +
+ ); + }); + story('Using Custom Cell Rendering', () => { function getRenderer(fieldName: string) { if (fieldName === 'http.request_method') { @@ -189,3 +302,9 @@ function getRenderer(fieldName: string) { ); }); }); + +const ButtonContainer = styled('div')` + display: flex; + justify-content: center; + margin: 20px; +`; diff --git a/static/app/views/dashboards/widgets/tableWidget/tableWidgetVisualization.tsx b/static/app/views/dashboards/widgets/tableWidget/tableWidgetVisualization.tsx index 0044dab2891604..59e07d487af776 100644 --- a/static/app/views/dashboards/widgets/tableWidget/tableWidgetVisualization.tsx +++ b/static/app/views/dashboards/widgets/tableWidget/tableWidgetVisualization.tsx @@ -3,12 +3,14 @@ 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 {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} from 'sentry/utils/discover/fields'; +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 { @@ -73,10 +75,19 @@ interface TableWidgetVisualizationProps { * @param meta The full table metadata */ makeBaggage?: BaggageMaker; + /** + * 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') + */ + onSortChange?: (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 +110,8 @@ export function TableWidgetVisualization(props: TableWidgetVisualizationProps) { scrollable, fit, aliases, + onSortChange, + sort, } = props; const theme = useTheme(); @@ -136,6 +149,7 @@ export function TableWidgetVisualization(props: TableWidgetVisualizationProps) { })); const {data, meta} = tableData; + const locationSort = decodeSorts(location?.query?.sort)[0]; return ( - {name} - + { + const nextDirection = direction === 'desc' ? 'asc' : 'desc'; + + onSortChange?.({ + field: sortColumn, + kind: nextDirection, + }); + }} + title={{name}} + direction={direction} + generateSortLink={() => { + return onSortChange + ? location + : { + ...location, + query: { + ...location.query, + sort: (direction === 'desc' ? '' : '-') + sortColumn, + }, + }; + }} + /> ); }, renderBodyCell: (tableColumn, dataRow, rowIndex, columnIndex) => { @@ -185,10 +229,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};` : '')} -`;