diff --git a/static/app/views/dashboards/datasetConfig/spans.tsx b/static/app/views/dashboards/datasetConfig/spans.tsx
index b44b8421f800de..8d568605ae96c5 100644
--- a/static/app/views/dashboards/datasetConfig/spans.tsx
+++ b/static/app/views/dashboards/datasetConfig/spans.tsx
@@ -193,6 +193,7 @@ export const SpansConfig: DatasetConfig<
DisplayType.LINE,
DisplayType.TABLE,
DisplayType.TOP_N,
+ DisplayType.DETAILS,
],
getTableRequest: (
api: Client,
diff --git a/static/app/views/dashboards/types.tsx b/static/app/views/dashboards/types.tsx
index a99d11f29b6dd1..0e975b89834091 100644
--- a/static/app/views/dashboards/types.tsx
+++ b/static/app/views/dashboards/types.tsx
@@ -24,6 +24,7 @@ export enum DisplayType {
LINE = 'line',
TABLE = 'table',
BIG_NUMBER = 'big_number',
+ DETAILS = 'details',
TOP_N = 'top_n',
}
diff --git a/static/app/views/dashboards/utils.tsx b/static/app/views/dashboards/utils.tsx
index 07a9933a03a64f..76434714fc2ca2 100644
--- a/static/app/views/dashboards/utils.tsx
+++ b/static/app/views/dashboards/utils.tsx
@@ -702,3 +702,12 @@ export function applyDashboardFilters(
}
return baseQuery;
}
+
+export const isChartDisplayType = (displayType?: DisplayType) => {
+ if (!displayType) {
+ return true;
+ }
+ return ![DisplayType.BIG_NUMBER, DisplayType.TABLE, DisplayType.DETAILS].includes(
+ displayType
+ );
+};
diff --git a/static/app/views/dashboards/widgetBuilder/components/sortBySelectors.tsx b/static/app/views/dashboards/widgetBuilder/components/sortBySelectors.tsx
index 47a729a8a42914..55c931ef2adcab 100644
--- a/static/app/views/dashboards/widgetBuilder/components/sortBySelectors.tsx
+++ b/static/app/views/dashboards/widgetBuilder/components/sortBySelectors.tsx
@@ -144,7 +144,7 @@ export function SortBySelectors({
title={disableSortReason}
disabled={!disableSort || (disableSortDirection && disableSort)}
>
- {displayType === DisplayType.TABLE ? (
+ {displayType === DisplayType.TABLE || displayType === DisplayType.DETAILS ? (
,
[DisplayType.TABLE]: ,
[DisplayType.BIG_NUMBER]: ,
+ [DisplayType.DETAILS]: ,
};
-const displayTypes = {
+const BASE_DISPLAY_TYPES: Partial> = {
[DisplayType.AREA]: t('Area'),
[DisplayType.BAR]: t('Bar'),
[DisplayType.LINE]: t('Line'),
[DisplayType.TABLE]: t('Table'),
[DisplayType.BIG_NUMBER]: t('Big Number'),
-};
+} as const;
interface WidgetBuilderTypeSelectorProps {
error?: Record;
@@ -46,6 +47,11 @@ function WidgetBuilderTypeSelector({error, setError}: WidgetBuilderTypeSelectorP
const isEditing = useIsEditingWidget();
const organization = useOrganization();
+ const displayTypes = {...BASE_DISPLAY_TYPES};
+ if (organization.features.includes('dashboards-details-widget')) {
+ displayTypes[DisplayType.DETAILS] = t('Details');
+ }
+
return (
(null);
- const isChartWidget =
- state.displayType !== DisplayType.TABLE &&
- state.displayType !== DisplayType.BIG_NUMBER;
+ const isChartWidget = isChartDisplayType(state.displayType);
const updateAction = isChartWidget
? BuilderStateAction.SET_Y_AXIS
diff --git a/static/app/views/dashboards/widgetBuilder/components/widgetBuilderSlideout.tsx b/static/app/views/dashboards/widgetBuilder/components/widgetBuilderSlideout.tsx
index be705c81d11166..24686e26844012 100644
--- a/static/app/views/dashboards/widgetBuilder/components/widgetBuilderSlideout.tsx
+++ b/static/app/views/dashboards/widgetBuilder/components/widgetBuilderSlideout.tsx
@@ -28,6 +28,7 @@ import {
type DashboardFilters,
type Widget,
} from 'sentry/views/dashboards/types';
+import {isChartDisplayType} from 'sentry/views/dashboards/utils';
import {animationTransitionSettings} from 'sentry/views/dashboards/widgetBuilder/components/common/animationSettings';
import WidgetBuilderDatasetSelector from 'sentry/views/dashboards/widgetBuilder/components/datasetSelector';
import WidgetBuilderFilterBar from 'sentry/views/dashboards/widgetBuilder/components/filtersBar';
@@ -122,9 +123,9 @@ function WidgetBuilderSlideout({
: isEditing
? t('Edit Widget')
: t('Custom Widget Builder');
- const isChartWidget =
- state.displayType !== DisplayType.BIG_NUMBER &&
- state.displayType !== DisplayType.TABLE;
+ const isChartWidget = isChartDisplayType(state.displayType);
+
+ const showVisualizeSection = state.displayType !== DisplayType.DETAILS;
const customPreviewRef = useRef(null);
const templatesPreviewRef = useRef(null);
@@ -340,9 +341,11 @@ function WidgetBuilderSlideout({
)}
-
+ {showVisualizeSection && (
+
+ )}
({field, kind: FieldValueKind.FIELD})),
+ options
+ );
+ setQuery(query?.slice(0, 1), options);
} else {
setFields(columnsWithoutAlias, options);
const nextAggregates = [
@@ -344,10 +365,16 @@ function useWidgetBuilderState(): {
config.defaultWidgetQuery.fields?.map(field => explodeField({field})),
options
);
- if (
- nextDisplayType === DisplayType.TABLE ||
- nextDisplayType === DisplayType.BIG_NUMBER
- ) {
+ if (isChartDisplayType(nextDisplayType)) {
+ setFields([], options);
+ setYAxis(
+ config.defaultWidgetQuery.aggregates?.map(aggregate =>
+ explodeField({field: aggregate})
+ ),
+ options
+ );
+ setSort(decodeSorts(config.defaultWidgetQuery.orderby), options);
+ } else {
setYAxis([], options);
setFields(
config.defaultWidgetQuery.fields?.map(field => explodeField({field})),
@@ -359,15 +386,6 @@ function useWidgetBuilderState(): {
: decodeSorts(config.defaultWidgetQuery.orderby),
options
);
- } else {
- setFields([], options);
- setYAxis(
- config.defaultWidgetQuery.aggregates?.map(aggregate =>
- explodeField({field: aggregate})
- ),
- options
- );
- setSort(decodeSorts(config.defaultWidgetQuery.orderby), options);
}
setThresholds(undefined, options);
diff --git a/static/app/views/dashboards/widgetBuilder/utils/convertBuilderStateToWidget.ts b/static/app/views/dashboards/widgetBuilder/utils/convertBuilderStateToWidget.ts
index b2a6e6932aea2d..6f3439fd2de78b 100644
--- a/static/app/views/dashboards/widgetBuilder/utils/convertBuilderStateToWidget.ts
+++ b/static/app/views/dashboards/widgetBuilder/utils/convertBuilderStateToWidget.ts
@@ -7,6 +7,7 @@ import {
type Widget,
type WidgetQuery,
} from 'sentry/views/dashboards/types';
+import {isChartDisplayType} from 'sentry/views/dashboards/utils';
import {
serializeSorts,
type WidgetBuilderState,
@@ -39,7 +40,7 @@ export function convertBuilderStateToWidget(state: WidgetBuilderState): Widget {
.filter(Boolean);
const fields =
- state.displayType === DisplayType.TABLE
+ state.displayType === DisplayType.TABLE || state.displayType === DisplayType.DETAILS
? state.fields?.map(generateFieldAsString)
: [...(columns ?? []), ...(aggregates ?? [])];
@@ -69,12 +70,7 @@ export function convertBuilderStateToWidget(state: WidgetBuilderState): Widget {
};
});
- const limit = [DisplayType.BIG_NUMBER, DisplayType.TABLE].includes(
- state.displayType ?? DisplayType.TABLE
- )
- ? undefined
- : state.limit;
-
+ const limit = isChartDisplayType(state.displayType) ? state.limit : undefined;
return {
title: state.title ?? '',
description: state.description,
diff --git a/static/app/views/dashboards/widgetBuilder/utils/convertWidgetToBuilderStateParams.ts b/static/app/views/dashboards/widgetBuilder/utils/convertWidgetToBuilderStateParams.ts
index 13e285615c57b8..de8e205e5109eb 100644
--- a/static/app/views/dashboards/widgetBuilder/utils/convertWidgetToBuilderStateParams.ts
+++ b/static/app/views/dashboards/widgetBuilder/utils/convertWidgetToBuilderStateParams.ts
@@ -5,6 +5,7 @@ import {
type Widget,
type WidgetQuery,
} from 'sentry/views/dashboards/types';
+import {isChartDisplayType} from 'sentry/views/dashboards/utils';
import {
serializeFields,
serializeThresholds,
@@ -37,15 +38,12 @@ export function convertWidgetToBuilderStateParams(
const firstWidgetQuery = widget.queries[0];
let yAxis = firstWidgetQuery ? stringifyFields(firstWidgetQuery, 'aggregates') : [];
let field: string[] = [];
- if (
- widget.displayType === DisplayType.TABLE ||
- widget.displayType === DisplayType.BIG_NUMBER
- ) {
+ if (isChartDisplayType(widget.displayType)) {
+ field = firstWidgetQuery ? stringifyFields(firstWidgetQuery, 'columns') : [];
+ } else {
field = firstWidgetQuery ? stringifyFields(firstWidgetQuery, 'fields') : [];
yAxis = [];
legendAlias = [];
- } else {
- field = firstWidgetQuery ? stringifyFields(firstWidgetQuery, 'columns') : [];
}
return {
diff --git a/static/app/views/dashboards/widgetCard/chart.tsx b/static/app/views/dashboards/widgetCard/chart.tsx
index f15ab842346359..4fee8760a373e1 100644
--- a/static/app/views/dashboards/widgetCard/chart.tsx
+++ b/static/app/views/dashboards/widgetCard/chart.tsx
@@ -70,6 +70,8 @@ import type WidgetLegendSelectionState from 'sentry/views/dashboards/widgetLegen
import {BigNumberWidgetVisualization} from 'sentry/views/dashboards/widgets/bigNumberWidget/bigNumberWidgetVisualization';
import {ALLOWED_CELL_ACTIONS} from 'sentry/views/dashboards/widgets/common/settings';
import type {TabularColumn} from 'sentry/views/dashboards/widgets/common/types';
+import {DetailsWidgetVisualization} from 'sentry/views/dashboards/widgets/detailsWidget/detailsWidgetVisualization';
+import type {DefaultDetailWidgetFields} from 'sentry/views/dashboards/widgets/detailsWidget/types';
import {TableWidgetVisualization} from 'sentry/views/dashboards/widgets/tableWidget/tableWidgetVisualization';
import {
convertTableDataToTabularData,
@@ -78,6 +80,7 @@ import {
import {Actions} from 'sentry/views/discover/table/cellAction';
import {decodeColumnOrder} from 'sentry/views/discover/utils';
import {ConfidenceFooter} from 'sentry/views/explore/spans/charts/confidenceFooter';
+import {type SpanResponse} from 'sentry/views/insights/types';
import type {GenericWidgetQueriesChildrenProps} from './genericWidgetQueries';
@@ -198,6 +201,15 @@ function WidgetCardChart(props: WidgetCardChartProps) {
);
}
+ if (widget.displayType === DisplayType.DETAILS) {
+ return (
+
+
+
+
+ );
+ }
+
const {start, end, period, utc} = selection.datetime;
const {projects, environments} = selection;
@@ -658,6 +670,21 @@ function BigNumberComponent({
});
}
+function DetailsComponent(props: TableComponentProps): React.ReactNode {
+ const {tableResults} = props;
+
+ const singleSpan = tableResults?.[0]?.data?.[0] as
+ | Pick
+ | undefined;
+
+ // TODO: Handle this case gracefully
+ if (!singleSpan) {
+ return null;
+ }
+
+ return ;
+}
+
function getChartComponent(chartProps: any, widget: Widget): React.ReactNode {
const stacked = widget.queries[0]?.columns.length! > 0;
diff --git a/static/app/views/dashboards/widgetCard/genericWidgetQueries.tsx b/static/app/views/dashboards/widgetCard/genericWidgetQueries.tsx
index eebfb67c3c7fe5..01ee3c0242dee4 100644
--- a/static/app/views/dashboards/widgetCard/genericWidgetQueries.tsx
+++ b/static/app/views/dashboards/widgetCard/genericWidgetQueries.tsx
@@ -16,7 +16,10 @@ import type {OnDemandControlContext} from 'sentry/utils/performance/contexts/onD
import type {DatasetConfig} from 'sentry/views/dashboards/datasetConfig/base';
import type {DashboardFilters, Widget, WidgetQuery} from 'sentry/views/dashboards/types';
import {DEFAULT_TABLE_LIMIT, DisplayType} from 'sentry/views/dashboards/types';
-import {dashboardFiltersToString} from 'sentry/views/dashboards/utils';
+import {
+ dashboardFiltersToString,
+ isChartDisplayType,
+} from 'sentry/views/dashboards/utils';
import type {WidgetQueryQueue} from 'sentry/views/dashboards/utils/widgetQueryQueue';
import type {SamplingMode} from 'sentry/views/explore/hooks/useProgressiveQuery';
@@ -419,10 +422,10 @@ class GenericWidgetQueries extends Component<
onDataFetchStart?.();
try {
- if ([DisplayType.TABLE, DisplayType.BIG_NUMBER].includes(widget.displayType)) {
- await this.fetchTableData(queryFetchID);
- } else {
+ if (isChartDisplayType(widget.displayType)) {
await this.fetchSeriesData(queryFetchID);
+ } else {
+ await this.fetchTableData(queryFetchID);
}
} catch (err: any) {
if (this._isMounted) {
diff --git a/static/app/views/dashboards/widgetCard/widgetCardChartContainer.tsx b/static/app/views/dashboards/widgetCard/widgetCardChartContainer.tsx
index d768365378d80f..08c3c20c8b9f68 100644
--- a/static/app/views/dashboards/widgetCard/widgetCardChartContainer.tsx
+++ b/static/app/views/dashboards/widgetCard/widgetCardChartContainer.tsx
@@ -14,6 +14,7 @@ import type {TableDataWithTitle} from 'sentry/utils/discover/discoverQuery';
import type {AggregationOutputType, Sort} from 'sentry/utils/discover/fields';
import type {DashboardFilters, Widget} from 'sentry/views/dashboards/types';
import {DisplayType, WidgetType} from 'sentry/views/dashboards/types';
+import {isChartDisplayType} from 'sentry/views/dashboards/utils';
import WidgetLegendNameEncoderDecoder from 'sentry/views/dashboards/widgetLegendNameEncoderDecoder';
import type WidgetLegendSelectionState from 'sentry/views/dashboards/widgetLegendSelectionState';
import type {TabularColumn} from 'sentry/views/dashboards/widgets/common/types';
@@ -96,10 +97,7 @@ export function WidgetCardChartContainer({
widgetType: DisplayType
) {
// non-chart widgets need to look at tableResults
- const results =
- widgetType === DisplayType.BIG_NUMBER || widgetType === DisplayType.TABLE
- ? tableResults
- : timeseriesResults;
+ const results = isChartDisplayType(widgetType) ? timeseriesResults : tableResults;
return errorMessage
? errorMessage
diff --git a/static/app/views/dashboards/widgets/detailsWidget/detailsWidgetVisualization.spec.tsx b/static/app/views/dashboards/widgets/detailsWidget/detailsWidgetVisualization.spec.tsx
new file mode 100644
index 00000000000000..e1c16fcd619364
--- /dev/null
+++ b/static/app/views/dashboards/widgets/detailsWidget/detailsWidgetVisualization.spec.tsx
@@ -0,0 +1,63 @@
+// write some tests for the DetailsWidgetVisualization component
+
+import {OrganizationFixture} from 'sentry-fixture/organization';
+import {ProjectFixture} from 'sentry-fixture/project';
+
+import {render, screen, waitForElementToBeRemoved} from 'sentry-test/reactTestingLibrary';
+
+import {DetailsWidgetVisualization} from 'sentry/views/dashboards/widgets/detailsWidget/detailsWidgetVisualization';
+
+describe('DetailsWidgetVisualization', () => {
+ beforeEach(() => {
+ const organization = OrganizationFixture();
+ const project = ProjectFixture();
+
+ MockApiClient.addMockResponse({
+ url: `/organizations/${organization.slug}/events/`,
+ body: {
+ data: [
+ {
+ project: project.slug,
+ span_id: '123',
+ 'span.description': 'SELECT * FROM users',
+ },
+ ],
+ },
+ });
+ });
+
+ afterEach(() => {
+ MockApiClient.clearMockResponses();
+ });
+
+ it('renders a span', () => {
+ const span = {
+ id: '123',
+ ['span.op']: 'span_op',
+ ['span.description']: 'span_description',
+ ['span.group']: 'span_group',
+ ['span.category']: 'span_category',
+ };
+ render();
+ expect(
+ screen.getByText(`${span['span.op']} - ${span['span.description']}`)
+ ).toBeInTheDocument();
+ });
+
+ it('renders a db span', async () => {
+ const span = {
+ id: '123',
+ ['span.op']: 'db',
+ ['span.description']: 'SELECT * FROM users',
+ ['span.group']: 'span_group',
+ ['span.category']: 'db',
+ };
+ render();
+
+ await waitForElementToBeRemoved(() => screen.queryByTestId('loading-indicator'));
+
+ const queryCodeSnippet = await screen.findByText(/select \* from users/i);
+ expect(queryCodeSnippet).toBeInTheDocument();
+ expect(queryCodeSnippet).toHaveClass('language-sql');
+ });
+});
diff --git a/static/app/views/dashboards/widgets/detailsWidget/detailsWidgetVisualization.tsx b/static/app/views/dashboards/widgets/detailsWidget/detailsWidgetVisualization.tsx
new file mode 100644
index 00000000000000..3cfc548165596d
--- /dev/null
+++ b/static/app/views/dashboards/widgets/detailsWidget/detailsWidgetVisualization.tsx
@@ -0,0 +1,84 @@
+import styled from '@emotion/styled';
+
+import {AutoSizedText} from 'sentry/views/dashboards/widgetCard/autoSizedText';
+import type {DefaultDetailWidgetFields} from 'sentry/views/dashboards/widgets/detailsWidget/types';
+import {FullSpanDescription} from 'sentry/views/insights/common/components/fullSpanDescription';
+import {resolveSpanModule} from 'sentry/views/insights/common/utils/resolveSpanModule';
+import {SpanFields, type SpanResponse} from 'sentry/views/insights/types';
+
+import {DEEMPHASIS_COLOR_NAME, LOADING_PLACEHOLDER} from './settings';
+
+interface DetailsWidgetVisualizationProps {
+ span: Pick;
+}
+
+export function DetailsWidgetVisualization(props: DetailsWidgetVisualizationProps) {
+ const {span} = props;
+
+ const spanOp = span[SpanFields.SPAN_OP];
+ const spanDescription = span[SpanFields.SPAN_DESCRIPTION];
+ const spanGroup = span[SpanFields.SPAN_GROUP];
+ const spanCategory = span[SpanFields.SPAN_CATEGORY];
+
+ const moduleName = resolveSpanModule(spanOp, spanCategory);
+
+ if (spanOp === 'db') {
+ return (
+
+ );
+ }
+
+ // String values don't support differences, thresholds, max values, or anything else.
+ return (
+
+ {spanOp} - {spanDescription}
+
+ );
+}
+
+function Wrapper({children}: any) {
+ return (
+
+
+ {children}
+
+
+ );
+}
+
+// Takes up 100% of the parent. If within flex context, grows to fill.
+// Otherwise, takes up 100% horizontally and vertically
+const GrowingWrapper = styled('div')`
+ position: relative;
+ flex-grow: 1;
+ height: 100%;
+ width: 100%;
+`;
+
+const AutoResizeParent = styled('div')`
+ position: absolute;
+ inset: 0;
+
+ color: ${p => p.theme.headingColor};
+
+ container-type: size;
+ container-name: auto-resize-parent;
+
+ * {
+ line-height: 1;
+ text-align: left !important;
+ }
+`;
+
+const LoadingPlaceholder = styled('span')`
+ color: ${p => p.theme[DEEMPHASIS_COLOR_NAME]};
+ font-size: ${p => p.theme.fontSize.lg};
+`;
+
+DetailsWidgetVisualization.LoadingPlaceholder = function () {
+ return {LOADING_PLACEHOLDER};
+};
diff --git a/static/app/views/dashboards/widgets/detailsWidget/settings.ts b/static/app/views/dashboards/widgets/detailsWidget/settings.ts
new file mode 100644
index 00000000000000..266629685763b5
--- /dev/null
+++ b/static/app/views/dashboards/widgets/detailsWidget/settings.ts
@@ -0,0 +1,2 @@
+export const LOADING_PLACEHOLDER = '\u2014';
+export const DEEMPHASIS_COLOR_NAME = 'subText';
diff --git a/static/app/views/dashboards/widgets/detailsWidget/types.ts b/static/app/views/dashboards/widgets/detailsWidget/types.ts
new file mode 100644
index 00000000000000..14ab630389c8a3
--- /dev/null
+++ b/static/app/views/dashboards/widgets/detailsWidget/types.ts
@@ -0,0 +1,8 @@
+import type {SpanFields} from 'sentry/views/insights/types';
+
+export type DefaultDetailWidgetFields =
+ | SpanFields.SPAN_OP
+ | SpanFields.SPAN_GROUP
+ | SpanFields.SPAN_DESCRIPTION
+ | SpanFields.ID
+ | SpanFields.SPAN_CATEGORY;