From 3737e961ef34fc3b6b44c1f6636b146f59cabdcb Mon Sep 17 00:00:00 2001
From: Nikki Kapadia <72356613+nikkikapadia@users.noreply.github.com>
Date: Mon, 10 Feb 2025 14:09:07 -0500
Subject: [PATCH] feat(widget-builder): Make visualize fields draggable for
ordering (#84814)
One of the features we had left to implement was drag and drop ordering
of the visualize fields. On any non-bignumber widget will have the
draggable feature where there are 2 or more fields.
Here's what it looks like:
https://github.com/user-attachments/assets/7182b79d-65c5-4c39-912d-574413f7c8a1
Fair warning it looks like a lot of code changes but it's mostly
whitespace changes. I've created a general sortable component and a
"ghost" component to show when dragging.
---
.../common/sortableFieldWrapper.tsx | 71 ++
.../components/visualize/index.spec.tsx | 24 +
.../components/visualize/index.tsx | 1130 +++++++++--------
.../visualize/visualizeGhostField.tsx | 244 ++++
4 files changed, 954 insertions(+), 515 deletions(-)
create mode 100644 static/app/views/dashboards/widgetBuilder/components/common/sortableFieldWrapper.tsx
create mode 100644 static/app/views/dashboards/widgetBuilder/components/visualize/visualizeGhostField.tsx
diff --git a/static/app/views/dashboards/widgetBuilder/components/common/sortableFieldWrapper.tsx b/static/app/views/dashboards/widgetBuilder/components/common/sortableFieldWrapper.tsx
new file mode 100644
index 00000000000000..9e6c546adacc3f
--- /dev/null
+++ b/static/app/views/dashboards/widgetBuilder/components/common/sortableFieldWrapper.tsx
@@ -0,0 +1,71 @@
+import {useSortable} from '@dnd-kit/sortable';
+import {CSS} from '@dnd-kit/utilities';
+import {useTheme} from '@emotion/react';
+import styled from '@emotion/styled';
+
+import {Button} from 'sentry/components/button';
+import {IconGrabbable} from 'sentry/icons';
+import {t} from 'sentry/locale';
+import {space} from 'sentry/styles/space';
+
+function SortableVisualizeFieldWrapper({
+ dragId,
+ canDrag,
+ children,
+}: {
+ canDrag: boolean;
+ children: React.ReactNode;
+ dragId: string;
+}) {
+ const theme = useTheme();
+ const {listeners, setNodeRef, transform, transition, attributes, isDragging} =
+ useSortable({
+ id: dragId,
+ transition: null,
+ disabled: !canDrag,
+ });
+
+ let style = {
+ transform: CSS.Transform.toString(transform),
+ transition,
+ zIndex: 'auto',
+ display: 'flex',
+ gap: space(1),
+ width: '100%',
+ } as React.CSSProperties;
+
+ if (isDragging) {
+ style = {
+ ...style,
+ zIndex: 100,
+ height: theme.form.md.height,
+ border: `2px dashed ${theme.border}`,
+ borderRadius: theme.borderRadius,
+ };
+ }
+
+ return (
+
+ {canDrag && (
+ }
+ size="zero"
+ borderless
+ isDragging={isDragging}
+ />
+ )}
+ {children}
+
+ );
+}
+
+export default SortableVisualizeFieldWrapper;
+
+const DragAndReorderButton = styled(Button)<{isDragging: boolean}>`
+ height: ${p => p.theme.form.md.height}px;
+
+ ${p => p.isDragging && p.theme.visuallyHidden}
+`;
diff --git a/static/app/views/dashboards/widgetBuilder/components/visualize/index.spec.tsx b/static/app/views/dashboards/widgetBuilder/components/visualize/index.spec.tsx
index ee08dec340cb51..ce8288376e3e43 100644
--- a/static/app/views/dashboards/widgetBuilder/components/visualize/index.spec.tsx
+++ b/static/app/views/dashboards/widgetBuilder/components/visualize/index.spec.tsx
@@ -959,6 +959,30 @@ describe('Visualize', () => {
expect(removeButtons[1]).toBeDisabled();
});
+ it('shows draggable button when there is more than one field on non big number widgets', async () => {
+ render(
+
+
+ ,
+ {
+ organization,
+ router: RouterFixture({
+ location: LocationFixture({
+ query: {
+ dataset: WidgetType.TRANSACTIONS,
+ field: ['transaction.duration', 'transaction.id'],
+ displayType: DisplayType.TABLE,
+ },
+ }),
+ }),
+ }
+ );
+
+ expect(await screen.findAllByRole('button', {name: 'Drag to reorder'})).toHaveLength(
+ 2
+ );
+ });
+
describe('spans', () => {
beforeEach(() => {
jest.mocked(useSpanTags).mockImplementation((type?: 'string' | 'number') => {
diff --git a/static/app/views/dashboards/widgetBuilder/components/visualize/index.tsx b/static/app/views/dashboards/widgetBuilder/components/visualize/index.tsx
index 1355d20f529e1b..701938ddb5cfa1 100644
--- a/static/app/views/dashboards/widgetBuilder/components/visualize/index.tsx
+++ b/static/app/views/dashboards/widgetBuilder/components/visualize/index.tsx
@@ -1,4 +1,6 @@
import {Fragment, useMemo, useState} from 'react';
+import {closestCenter, DndContext, DragOverlay} from '@dnd-kit/core';
+import {arrayMove, SortableContext, verticalListSortingStrategy} from '@dnd-kit/sortable';
import type {Theme} from '@emotion/react';
import styled from '@emotion/styled';
import cloneDeep from 'lodash/cloneDeep';
@@ -40,7 +42,9 @@ import useTags from 'sentry/utils/useTags';
import {getDatasetConfig} from 'sentry/views/dashboards/datasetConfig/base';
import {DisplayType, WidgetType} from 'sentry/views/dashboards/types';
import {SectionHeader} from 'sentry/views/dashboards/widgetBuilder/components/common/sectionHeader';
+import SortableVisualizeFieldWrapper from 'sentry/views/dashboards/widgetBuilder/components/common/sortableFieldWrapper';
import {AggregateParameterField} from 'sentry/views/dashboards/widgetBuilder/components/visualize/aggregateParameterField';
+import VisualizeGhostField from 'sentry/views/dashboards/widgetBuilder/components/visualize/visualizeGhostField';
import {useWidgetBuilderContext} from 'sentry/views/dashboards/widgetBuilder/contexts/widgetBuilderContext';
import useDashboardWidgetSource from 'sentry/views/dashboards/widgetBuilder/hooks/useDashboardWidgetSource';
import useIsEditingWidget from 'sentry/views/dashboards/widgetBuilder/hooks/useIsEditingWidget';
@@ -199,6 +203,7 @@ interface VisualizeProps {
}
function Visualize({error, setError}: VisualizeProps) {
+ const [activeId, setActiveId] = useState(null);
const organization = useOrganization();
const api = useApi();
const {state, dispatch} = useWidgetBuilderContext();
@@ -313,6 +318,11 @@ function Visualize({error, setError}: VisualizeProps) {
(aggregateError: any) => aggregateError?.aggregates
)?.aggregates;
+ const canDrag =
+ fields?.length && fields.length > 1 && state.displayType !== DisplayType.BIG_NUMBER;
+
+ const draggableFieldIds = fields?.map((_field, index) => index.toString()) ?? [];
+
return (
-
- {fields?.map((field, index) => {
- const canDelete = canDeleteField(
- state.dataset ?? WidgetType.ERRORS,
- fields,
- field
- );
-
- const isOnlyFieldOrAggregate =
- fields.length === 2 &&
- field.kind !== FieldValueKind.EQUATION &&
- fields.filter(fieldItem => fieldItem.kind === FieldValueKind.EQUATION)
- .length > 0;
-
- // Depending on the dataset and the display type, we use different options for
- // displaying in the column select.
- // For charts, we show aggregate parameter options for the y-axis as primary options.
- // For tables, we show all string tags and fields as primary options, as well
- // as aggregates that don't take parameters.
- const columnFilterMethod = isChartWidget
- ? datasetConfig.filterYAxisAggregateParams?.(
- field,
- state.displayType ?? DisplayType.LINE
- )
- : field.kind === FieldValueKind.FUNCTION
- ? datasetConfig.filterAggregateParams
- : datasetConfig.filterTableOptions;
- const columnOptions = getColumnOptions(
- state.dataset ?? WidgetType.ERRORS,
- field,
- fieldOptions,
- // If no column filter method is provided, show all options
- columnFilterMethod ?? (() => true)
- );
-
- let aggregateOptions: Array<{
- label: string | React.ReactNode;
- trailingItems: React.ReactNode | null;
- value: string;
- textValue?: string;
- }> = aggregates.map(option => ({
- value: option.value.meta.name,
- label: option.value.meta.name,
- trailingItems: renderTag(option.value.kind, option.value.meta.name) ?? null,
- }));
- aggregateOptions =
- isChartWidget ||
- isBigNumberWidget ||
- (state.dataset === WidgetType.RELEASE && !canDelete)
- ? aggregateOptions
- : [NONE_AGGREGATE, ...aggregateOptions];
-
- let matchingAggregate: any;
- if (
- fields[index]!.kind === FieldValueKind.FUNCTION &&
- FieldValueKind.FUNCTION in fields[index]!
- ) {
- matchingAggregate = aggregates.find(
- option =>
- option.value.meta.name ===
- parseFunction(stringFields?.[index] ?? '')?.name
- );
+ {
+ setActiveId(active.id.toString());
+ }}
+ onDragEnd={({over, active}) => {
+ setActiveId(null);
+
+ if (over) {
+ const getIndex = draggableFieldIds.indexOf.bind(draggableFieldIds);
+ const activeIndex = getIndex(active.id);
+ const overIndex = getIndex(over.id);
+
+ if (activeIndex !== overIndex) {
+ dispatch({
+ type: updateAction,
+ payload: arrayMove(fields ?? [], activeIndex, overIndex),
+ });
+ }
}
-
- const parameterRefinements =
- matchingAggregate?.value.meta.parameters.length > 1
- ? matchingAggregate?.value.meta.parameters.slice(1)
- : [];
-
- // Apdex and User Misery are special cases where the column parameter is not applicable
- const isApdexOrUserMisery =
- matchingAggregate?.value.meta.name === 'apdex' ||
- matchingAggregate?.value.meta.name === 'user_misery';
-
- const hasColumnParameter =
- (fields[index]!.kind === FieldValueKind.FUNCTION &&
- !isApdexOrUserMisery &&
- matchingAggregate?.value.meta.parameters.length !== 0) ||
- fields[index]!.kind === FieldValueKind.FIELD;
-
- return (
-
- {fields.length > 1 && state.displayType === DisplayType.BIG_NUMBER && (
- setActiveId(null)}
+ >
+
+
+ {fields?.map((field, index) => {
+ const canDelete = canDeleteField(
+ state.dataset ?? WidgetType.ERRORS,
+ fields,
+ field
+ );
+
+ const isOnlyFieldOrAggregate =
+ fields.length === 2 &&
+ field.kind !== FieldValueKind.EQUATION &&
+ fields.filter(fieldItem => fieldItem.kind === FieldValueKind.EQUATION)
+ .length > 0;
+
+ // Depending on the dataset and the display type, we use different options for
+ // displaying in the column select.
+ // For charts, we show aggregate parameter options for the y-axis as primary options.
+ // For tables, we show all string tags and fields as primary options, as well
+ // as aggregates that don't take parameters.
+ const columnFilterMethod = isChartWidget
+ ? datasetConfig.filterYAxisAggregateParams?.(
+ field,
+ state.displayType ?? DisplayType.LINE
+ )
+ : field.kind === FieldValueKind.FUNCTION
+ ? datasetConfig.filterAggregateParams
+ : datasetConfig.filterTableOptions;
+ const columnOptions = getColumnOptions(
+ state.dataset ?? WidgetType.ERRORS,
+ field,
+ fieldOptions,
+ // If no column filter method is provided, show all options
+ columnFilterMethod ?? (() => true)
+ );
+
+ let aggregateOptions: Array<{
+ label: string | React.ReactNode;
+ trailingItems: React.ReactNode | null;
+ value: string;
+ textValue?: string;
+ }> = aggregates.map(option => ({
+ value: option.value.meta.name,
+ label: option.value.meta.name,
+ trailingItems:
+ renderTag(option.value.kind, option.value.meta.name) ?? null,
+ }));
+ aggregateOptions =
+ isChartWidget ||
+ isBigNumberWidget ||
+ (state.dataset === WidgetType.RELEASE && !canDelete)
+ ? aggregateOptions
+ : [NONE_AGGREGATE, ...aggregateOptions];
+
+ let matchingAggregate: any;
+ if (
+ fields[index]!.kind === FieldValueKind.FUNCTION &&
+ FieldValueKind.FUNCTION in fields[index]!
+ ) {
+ matchingAggregate = aggregates.find(
+ option =>
+ option.value.meta.name ===
+ parseFunction(stringFields?.[index] ?? '')?.name
+ );
+ }
+
+ const parameterRefinements =
+ matchingAggregate?.value.meta.parameters.length > 1
+ ? matchingAggregate?.value.meta.parameters.slice(1)
+ : [];
+
+ // Apdex and User Misery are special cases where the column parameter is not applicable
+ const isApdexOrUserMisery =
+ matchingAggregate?.value.meta.name === 'apdex' ||
+ matchingAggregate?.value.meta.name === 'user_misery';
+
+ const hasColumnParameter =
+ (fields[index]!.kind === FieldValueKind.FUNCTION &&
+ !isApdexOrUserMisery &&
+ matchingAggregate?.value.meta.parameters.length !== 0) ||
+ fields[index]!.kind === FieldValueKind.FIELD;
+
+ return (
+
- {
- dispatch({
- type: BuilderStateAction.SET_SELECTED_AGGREGATE,
- payload: index,
- });
- }}
- onClick={() => {
- setSelectedAggregateSet(true);
- trackAnalytics('dashboards_views.widget_builder.change', {
- builder_version: WidgetBuilderVersion.SLIDEOUT,
- field: 'visualize.selectAggregate',
- from: source,
- new_widget: !isEditing,
- value: '',
- widget_type: state.dataset ?? '',
- organization,
- });
- }}
- aria-label={'field' + index}
- />
-
- )}
-
- {field.kind === FieldValueKind.EQUATION ? (
- {
- dispatch({
- type: updateAction,
- payload: fields.map((_field, i) =>
- i === index ? {..._field, field: value} : _field
- ),
- });
- setError?.({...error, queries: []});
- trackAnalytics('dashboards_views.widget_builder.change', {
- builder_version: WidgetBuilderVersion.SLIDEOUT,
- field: 'visualize.updateEquation',
- from: source,
- new_widget: !isEditing,
- value: '',
- widget_type: state.dataset ?? '',
- organization,
- });
- }}
- options={fields}
- placeholder={t('Equation')}
- aria-label={t('Equation')}
- />
- ) : (
-
-
- {
- const isNone = aggregateSelection.value === NONE;
- const newFields = cloneDeep(fields);
- const currentField = newFields[index]!;
- const newAggregate = aggregates.find(
- option =>
- option.value.meta.name === aggregateSelection.value
- );
- // Update the current field's aggregate with the new aggregate
- if (!isNone) {
- if (currentField.kind === FieldValueKind.FUNCTION) {
- // Handle setting an aggregate from an aggregate
- currentField.function[0] =
- aggregateSelection.value as AggregationKeyWithAlias;
- if (
- newAggregate?.value.meta &&
- 'parameters' in newAggregate.value.meta
- ) {
- // There are aggregates that have no parameters, so wipe out the argument
- // if it's supposed to be empty
- if (newAggregate.value.meta.parameters.length === 0) {
- currentField.function[1] = '';
- } else {
- // Check if the column is a valid column for the new aggregate
- const newColumnOptions = getColumnOptions(
- state.dataset ?? WidgetType.ERRORS,
- currentField,
- fieldOptions,
- // If no column filter method is provided, show all options
- columnFilterMethod ?? (() => true)
- );
- const newAggregateIsApdexOrUserMisery =
- newAggregate?.value.meta.name === 'apdex' ||
- newAggregate?.value.meta.name === 'user_misery';
- const isValidColumn =
- !newAggregateIsApdexOrUserMisery &&
- Boolean(
- newColumnOptions.find(
- option =>
- option.value === currentField.function[1]
- )?.value
- );
- currentField.function[1] =
- (isValidColumn
- ? currentField.function[1]
- : newAggregate.value.meta.parameters[0]!
- .defaultValue) ?? '';
-
- // Set the remaining parameters for the new aggregate
- for (
- let i = 1; // The first parameter is the column selection
- i < newAggregate.value.meta.parameters.length;
- i++
- ) {
- // Increment by 1 to skip past the aggregate name
- currentField.function[i + 1] =
- newAggregate.value.meta.parameters[
- i
- ]!.defaultValue;
+ {activeId !== null && index === Number(activeId) ? null : (
+
+ {fields.length > 1 &&
+ state.displayType === DisplayType.BIG_NUMBER && (
+
+ {
+ dispatch({
+ type: BuilderStateAction.SET_SELECTED_AGGREGATE,
+ payload: index,
+ });
+ }}
+ onClick={() => {
+ setSelectedAggregateSet(true);
+ trackAnalytics(
+ 'dashboards_views.widget_builder.change',
+ {
+ builder_version: WidgetBuilderVersion.SLIDEOUT,
+ field: 'visualize.selectAggregate',
+ from: source,
+ new_widget: !isEditing,
+ value: '',
+ widget_type: state.dataset ?? '',
+ organization,
}
+ );
+ }}
+ aria-label={'field' + index}
+ />
+
+ )}
+
+ {field.kind === FieldValueKind.EQUATION ? (
+ {
+ dispatch({
+ type: updateAction,
+ payload: fields.map((_field, i) =>
+ i === index ? {..._field, field: value} : _field
+ ),
+ });
+ setError?.({...error, queries: []});
+ trackAnalytics('dashboards_views.widget_builder.change', {
+ builder_version: WidgetBuilderVersion.SLIDEOUT,
+ field: 'visualize.updateEquation',
+ from: source,
+ new_widget: !isEditing,
+ value: '',
+ widget_type: state.dataset ?? '',
+ organization,
+ });
+ }}
+ options={fields}
+ placeholder={t('Equation')}
+ aria-label={t('Equation')}
+ />
+ ) : (
+
+
+ 0 &&
- currentField.field) ||
- newAggregate?.value.meta?.parameters?.[0]
- ?.defaultValue) ??
- '',
- newAggregate?.value.meta?.parameters?.[1]
- ?.defaultValue ?? undefined,
- newAggregate?.value.meta?.parameters?.[2]
- ?.defaultValue ?? undefined,
- ];
- const newColumnOptions = getColumnOptions(
- state.dataset ?? WidgetType.ERRORS,
- {
- kind: FieldValueKind.FUNCTION,
- function: newFunction,
- },
- fieldOptions,
- // If no column filter method is provided, show all options
- columnFilterMethod ?? (() => true)
- );
- if (
- newAggregate?.value.meta &&
- 'parameters' in newAggregate.value.meta
- ) {
- newAggregate?.value.meta.parameters.forEach(
- (parameter, parameterIndex) => {
- const isValidParameter = validateParameter(
- newColumnOptions,
- parameter,
- newFunction[parameterIndex + 1]
+ position="bottom-start"
+ onChange={aggregateSelection => {
+ const isNone = aggregateSelection.value === NONE;
+ const newFields = cloneDeep(fields);
+ const currentField = newFields[index]!;
+ const newAggregate = aggregates.find(
+ option =>
+ option.value.meta.name ===
+ aggregateSelection.value
+ );
+ // Update the current field's aggregate with the new aggregate
+ if (!isNone) {
+ if (currentField.kind === FieldValueKind.FUNCTION) {
+ // Handle setting an aggregate from an aggregate
+ currentField.function[0] =
+ aggregateSelection.value as AggregationKeyWithAlias;
+ if (
+ newAggregate?.value.meta &&
+ 'parameters' in newAggregate.value.meta
+ ) {
+ // There are aggregates that have no parameters, so wipe out the argument
+ // if it's supposed to be empty
+ if (
+ newAggregate.value.meta.parameters.length ===
+ 0
+ ) {
+ currentField.function[1] = '';
+ } else {
+ // Check if the column is a valid column for the new aggregate
+ const newColumnOptions = getColumnOptions(
+ state.dataset ?? WidgetType.ERRORS,
+ currentField,
+ fieldOptions,
+ // If no column filter method is provided, show all options
+ columnFilterMethod ?? (() => true)
+ );
+ const newAggregateIsApdexOrUserMisery =
+ newAggregate?.value.meta.name === 'apdex' ||
+ newAggregate?.value.meta.name ===
+ 'user_misery';
+ const isValidColumn =
+ !newAggregateIsApdexOrUserMisery &&
+ Boolean(
+ newColumnOptions.find(
+ option =>
+ option.value ===
+ currentField.function[1]
+ )?.value
+ );
+ currentField.function[1] =
+ (isValidColumn
+ ? currentField.function[1]
+ : newAggregate.value.meta.parameters[0]!
+ .defaultValue) ?? '';
+
+ // Set the remaining parameters for the new aggregate
+ for (
+ let i = 1; // The first parameter is the column selection
+ i <
+ newAggregate.value.meta.parameters.length;
+ i++
+ ) {
+ // Increment by 1 to skip past the aggregate name
+ currentField.function[i + 1] =
+ newAggregate.value.meta.parameters[
+ i
+ ]!.defaultValue;
+ }
+ }
+
+ // Wipe out the remaining parameters that are unnecessary
+ // This is necessary for transitioning between aggregates that have
+ // more parameters to ones of fewer parameters
+ for (
+ let i =
+ newAggregate.value.meta.parameters.length;
+ i < MAX_FUNCTION_PARAMETERS;
+ i++
+ ) {
+ currentField.function[i + 1] = undefined;
+ }
+ }
+ } else {
+ if (
+ !newAggregate ||
+ !('parameters' in newAggregate.value.meta)
+ ) {
+ return;
+ }
+
+ // Handle setting an aggregate from a field
+ const newFunction: AggregateFunction = [
+ aggregateSelection.value as AggregationKeyWithAlias,
+ ((newAggregate?.value.meta?.parameters.length >
+ 0 &&
+ currentField.field) ||
+ newAggregate?.value.meta?.parameters?.[0]
+ ?.defaultValue) ??
+ '',
+ newAggregate?.value.meta?.parameters?.[1]
+ ?.defaultValue ?? undefined,
+ newAggregate?.value.meta?.parameters?.[2]
+ ?.defaultValue ?? undefined,
+ ];
+ const newColumnOptions = getColumnOptions(
+ state.dataset ?? WidgetType.ERRORS,
+ {
+ kind: FieldValueKind.FUNCTION,
+ function: newFunction,
+ },
+ fieldOptions,
+ // If no column filter method is provided, show all options
+ columnFilterMethod ?? (() => true)
+ );
+ if (
+ newAggregate?.value.meta &&
+ 'parameters' in newAggregate.value.meta
+ ) {
+ newAggregate?.value.meta.parameters.forEach(
+ (parameter, parameterIndex) => {
+ const isValidParameter = validateParameter(
+ newColumnOptions,
+ parameter,
+ newFunction[parameterIndex + 1]
+ );
+ // Increment by 1 to skip past the aggregate name
+ newFunction[parameterIndex + 1] =
+ (isValidParameter
+ ? newFunction[parameterIndex + 1]
+ : parameter.defaultValue) ?? '';
+ }
+ );
+ }
+ newFields[index] = {
+ kind: FieldValueKind.FUNCTION,
+ function: newFunction,
+ };
+ }
+ trackAnalytics(
+ 'dashboards_views.widget_builder.change',
+ {
+ builder_version: WidgetBuilderVersion.SLIDEOUT,
+ field: 'visualize.updateAggregate',
+ from: source,
+ new_widget: !isEditing,
+ value: 'aggregate',
+ widget_type: state.dataset ?? '',
+ organization,
+ }
+ );
+ } else {
+ // Handle selecting NONE so we can select just a field, e.g. for samples
+ // If NONE is selected, set the field to a field value
+
+ // When selecting NONE, the next possible columns may be different from the
+ // possible columns for the previous aggregate. Calculate the valid columns,
+ // see if the current field's function argument is in the valid columns, and if so,
+ // set the field to a field value. Otherwise, set the field to the first valid column.
+ const validColumnFields = Object.values(
+ datasetConfig.getTableFieldOptions?.(
+ organization,
+ tags,
+ customMeasurements,
+ api
+ ) ?? []
+ ).filter(
+ option =>
+ option.value.kind !== FieldValueKind.FUNCTION &&
+ (datasetConfig.filterTableOptions?.(option) ??
+ true)
+ );
+ const functionArgInValidColumnFields =
+ ('function' in currentField &&
+ validColumnFields.find(
+ option =>
+ option.value.meta.name ===
+ currentField.function[1]
+ )) ||
+ undefined;
+ const validColumn =
+ functionArgInValidColumnFields?.value.meta.name ??
+ validColumnFields?.[0]?.value.meta.name ??
+ '';
+ newFields[index] = {
+ kind: FieldValueKind.FIELD,
+ field: validColumn,
+ };
+
+ trackAnalytics(
+ 'dashboards_views.widget_builder.change',
+ {
+ builder_version: WidgetBuilderVersion.SLIDEOUT,
+ field: 'visualize.updateAggregate',
+ from: source,
+ new_widget: !isEditing,
+ value: 'column',
+ widget_type: state.dataset ?? '',
+ organization,
+ }
);
- // Increment by 1 to skip past the aggregate name
- newFunction[parameterIndex + 1] =
- (isValidParameter
- ? newFunction[parameterIndex + 1]
- : parameter.defaultValue) ?? '';
}
- );
- }
- newFields[index] = {
- kind: FieldValueKind.FUNCTION,
- function: newFunction,
- };
- }
- trackAnalytics('dashboards_views.widget_builder.change', {
- builder_version: WidgetBuilderVersion.SLIDEOUT,
- field: 'visualize.updateAggregate',
- from: source,
- new_widget: !isEditing,
- value: 'aggregate',
- widget_type: state.dataset ?? '',
- organization,
- });
- } else {
- // Handle selecting NONE so we can select just a field, e.g. for samples
- // If NONE is selected, set the field to a field value
-
- // When selecting NONE, the next possible columns may be different from the
- // possible columns for the previous aggregate. Calculate the valid columns,
- // see if the current field's function argument is in the valid columns, and if so,
- // set the field to a field value. Otherwise, set the field to the first valid column.
- const validColumnFields = Object.values(
- datasetConfig.getTableFieldOptions?.(
- organization,
- tags,
- customMeasurements,
- api
- ) ?? []
- ).filter(
- option =>
- option.value.kind !== FieldValueKind.FUNCTION &&
- (datasetConfig.filterTableOptions?.(option) ?? true)
- );
- const functionArgInValidColumnFields =
- ('function' in currentField &&
- validColumnFields.find(
- option =>
- option.value.meta.name === currentField.function[1]
- )) ||
- undefined;
- const validColumn =
- functionArgInValidColumnFields?.value.meta.name ??
- validColumnFields?.[0]?.value.meta.name ??
- '';
- newFields[index] = {
- kind: FieldValueKind.FIELD,
- field: validColumn,
- };
-
- trackAnalytics('dashboards_views.widget_builder.change', {
- builder_version: WidgetBuilderVersion.SLIDEOUT,
- field: 'visualize.updateAggregate',
- from: source,
- new_widget: !isEditing,
- value: 'column',
- widget_type: state.dataset ?? '',
- organization,
- });
- }
- dispatch({
- type: updateAction,
- payload: newFields,
- });
- setError?.({...error, queries: []});
- }}
- triggerProps={{
- 'aria-label': t('Aggregate Selection'),
- }}
- />
- {hasColumnParameter && (
- {
- const newFields = cloneDeep(fields);
- const currentField = newFields[index]!;
- // Update the current field's aggregate with the new aggregate
- if (currentField.kind === FieldValueKind.FUNCTION) {
- currentField.function[1] = newField.value as string;
- }
- if (currentField.kind === FieldValueKind.FIELD) {
- currentField.field = newField.value as string;
- }
- dispatch({
- type: updateAction,
- payload: newFields,
- });
- setError?.({...error, queries: []});
- trackAnalytics('dashboards_views.widget_builder.change', {
- builder_version: WidgetBuilderVersion.SLIDEOUT,
- field: 'visualize.updateColumn',
- from: source,
- new_widget: !isEditing,
- value:
- currentField.kind === FieldValueKind.FIELD
- ? 'column'
- : 'aggregate',
- widget_type: state.dataset ?? '',
- organization,
- });
- }}
- triggerProps={{
- 'aria-label': t('Column Selection'),
- }}
- />
- )}
-
- {field.kind === FieldValueKind.FUNCTION &&
- parameterRefinements.length > 0 && (
-
- {parameterRefinements.map(
- (parameter: any, parameterIndex: any) => {
- // The current value is displaced by 2 because the first two parameters
- // are the aggregate name and the column selection
- const currentValue =
- field.function[parameterIndex + 2] || '';
- const key = `${field.function.join('_')}-${parameterIndex}`;
- return (
+ dispatch({
+ type: updateAction,
+ payload: newFields,
+ });
+ setError?.({...error, queries: []});
+ }}
+ triggerProps={{
+ 'aria-label': t('Aggregate Selection'),
+ }}
+ />
+ {hasColumnParameter && (
+ {
+ const newFields = cloneDeep(fields);
+ const currentField = newFields[index]!;
+ // Update the current field's aggregate with the new aggregate
+ if (currentField.kind === FieldValueKind.FUNCTION) {
+ currentField.function[1] =
+ newField.value as string;
+ }
+ if (currentField.kind === FieldValueKind.FIELD) {
+ currentField.field = newField.value as string;
+ }
+ dispatch({
+ type: updateAction,
+ payload: newFields,
+ });
+ setError?.({...error, queries: []});
+ trackAnalytics(
+ 'dashboards_views.widget_builder.change',
+ {
+ builder_version: WidgetBuilderVersion.SLIDEOUT,
+ field: 'visualize.updateColumn',
+ from: source,
+ new_widget: !isEditing,
+ value:
+ currentField.kind === FieldValueKind.FIELD
+ ? 'column'
+ : 'aggregate',
+ widget_type: state.dataset ?? '',
+ organization,
+ }
+ );
+ }}
+ triggerProps={{
+ 'aria-label': t('Column Selection'),
+ }}
+ />
+ )}
+
+ {field.kind === FieldValueKind.FUNCTION &&
+ parameterRefinements.length > 0 && (
+
+ {parameterRefinements.map(
+ (parameter: any, parameterIndex: any) => {
+ // The current value is displaced by 2 because the first two parameters
+ // are the aggregate name and the column selection
+ const currentValue =
+ field.function[parameterIndex + 2] || '';
+ const key = `${field.function.join('_')}-${parameterIndex}`;
+ return (
+ {
+ const newFields = cloneDeep(fields);
+ if (
+ newFields[index]!.kind !==
+ FieldValueKind.FUNCTION
+ ) {
+ return;
+ }
+ newFields[index]!.function[
+ parameterIndex + 2
+ ] = value;
+ dispatch({
+ type: updateAction,
+ payload: newFields,
+ });
+ setError?.({...error, queries: []});
+ }}
+ />
+ );
+ }
+ )}
+
+ )}
+ {isApdexOrUserMisery &&
+ field.kind === FieldValueKind.FUNCTION && (
{
const newFields = cloneDeep(fields);
if (
@@ -744,8 +847,7 @@ function Visualize({error, setError}: VisualizeProps) {
) {
return;
}
- newFields[index]!.function[parameterIndex + 2] =
- value;
+ newFields[index]!.function[1] = value;
dispatch({
type: updateAction,
payload: newFields,
@@ -753,107 +855,102 @@ function Visualize({error, setError}: VisualizeProps) {
setError?.({...error, queries: []});
}}
/>
- );
- }
- )}
-
- )}
- {isApdexOrUserMisery && field.kind === FieldValueKind.FUNCTION && (
- {
- const newFields = cloneDeep(fields);
- if (newFields[index]!.kind !== FieldValueKind.FUNCTION) {
- return;
+ )}
+
+ )}
+
+
+ {!isChartWidget && !isBigNumberWidget && (
+ {
+ const newFields = cloneDeep(fields);
+ newFields[index]!.alias = e.target.value;
+ dispatch({
+ type: updateAction,
+ payload: newFields,
+ });
+ }}
+ onBlur={() => {
+ trackAnalytics('dashboards_views.widget_builder.change', {
+ builder_version: WidgetBuilderVersion.SLIDEOUT,
+ field: 'visualize.legendAlias',
+ from: source,
+ new_widget: !isEditing,
+ value: '',
+ widget_type: state.dataset ?? '',
+ organization,
+ });
+ }}
+ />
+ )}
+ }
+ size="zero"
+ disabled={
+ fields.length <= 1 || !canDelete || isOnlyFieldOrAggregate
}
- newFields[index]!.function[1] = value;
- dispatch({
- type: updateAction,
- payload: newFields,
- });
- setError?.({...error, queries: []});
- }}
- />
- )}
-
- )}
-
-
- {!isChartWidget && !isBigNumberWidget && (
- {
- const newFields = cloneDeep(fields);
- newFields[index]!.alias = e.target.value;
- dispatch({
- type: updateAction,
- payload: newFields,
- });
- }}
- onBlur={() => {
- trackAnalytics('dashboards_views.widget_builder.change', {
- builder_version: WidgetBuilderVersion.SLIDEOUT,
- field: 'visualize.legendAlias',
- from: source,
- new_widget: !isEditing,
- value: '',
- widget_type: state.dataset ?? '',
- organization,
- });
- }}
- />
- )}
- }
- size="zero"
- disabled={fields.length <= 1 || !canDelete || isOnlyFieldOrAggregate}
- onClick={() => {
- dispatch({
- type: updateAction,
- payload: fields?.filter((_field, i) => i !== index) ?? [],
- });
-
- if (
- state.displayType === DisplayType.BIG_NUMBER &&
- selectedAggregateSet
- ) {
- // Unset the selected aggregate if it's the last one
- // so the state will automatically choose the last aggregate
- // as new fields are added
- if (state.selectedAggregate === fields.length - 1) {
- dispatch({
- type: BuilderStateAction.SET_SELECTED_AGGREGATE,
- payload: undefined,
- });
- }
- }
-
- trackAnalytics('dashboards_views.widget_builder.change', {
- builder_version: WidgetBuilderVersion.SLIDEOUT,
- field:
- field.kind === FieldValueKind.EQUATION
- ? 'visualize.deleteEquation'
- : 'visualize.deleteField',
- from: source,
- new_widget: !isEditing,
- value: '',
- widget_type: state.dataset ?? '',
- organization,
- });
- }}
- aria-label={t('Remove field')}
- />
-
-
- );
- })}
-
+ onClick={() => {
+ dispatch({
+ type: updateAction,
+ payload: fields?.filter((_field, i) => i !== index) ?? [],
+ });
+
+ if (
+ state.displayType === DisplayType.BIG_NUMBER &&
+ selectedAggregateSet
+ ) {
+ // Unset the selected aggregate if it's the last one
+ // so the state will automatically choose the last aggregate
+ // as new fields are added
+ if (state.selectedAggregate === fields.length - 1) {
+ dispatch({
+ type: BuilderStateAction.SET_SELECTED_AGGREGATE,
+ payload: undefined,
+ });
+ }
+ }
+
+ trackAnalytics('dashboards_views.widget_builder.change', {
+ builder_version: WidgetBuilderVersion.SLIDEOUT,
+ field:
+ field.kind === FieldValueKind.EQUATION
+ ? 'visualize.deleteEquation'
+ : 'visualize.deleteField',
+ from: source,
+ new_widget: !isEditing,
+ value: '',
+ widget_type: state.dataset ?? '',
+ organization,
+ });
+ }}
+ aria-label={t('Remove field')}
+ />
+
+
+ )}
+
+ );
+ })}
+
+
+
+ {activeId && (
+
+ )}
+
+
@@ -945,7 +1042,7 @@ function renderTag(kind: FieldValueKind, label: string) {
return {text};
}
-const ColumnCompactSelect = styled(CompactSelect)`
+export const ColumnCompactSelect = styled(CompactSelect)`
flex: 1 1 auto;
min-width: 0;
@@ -954,7 +1051,9 @@ const ColumnCompactSelect = styled(CompactSelect)`
}
`;
-const AggregateCompactSelect = styled(CompactSelect)<{hasColumnParameter: boolean}>`
+export const AggregateCompactSelect = styled(CompactSelect)<{
+ hasColumnParameter: boolean;
+}>`
${p =>
p.hasColumnParameter
? `
@@ -974,9 +1073,9 @@ const AggregateCompactSelect = styled(CompactSelect)<{hasColumnParameter: boolea
}
`;
-const LegendAliasInput = styled(Input)``;
+export const LegendAliasInput = styled(Input)``;
-const ParameterRefinements = styled('div')`
+export const ParameterRefinements = styled('div')`
display: flex;
flex-direction: row;
gap: ${space(1)};
@@ -986,14 +1085,14 @@ const ParameterRefinements = styled('div')`
}
`;
-const FieldBar = styled('div')`
+export const FieldBar = styled('div')`
display: grid;
grid-template-columns: 1fr;
gap: ${space(1)};
flex: 3;
`;
-const PrimarySelectRow = styled('div')<{hasColumnParameter: boolean}>`
+export const PrimarySelectRow = styled('div')<{hasColumnParameter: boolean}>`
display: flex;
width: 100%;
flex: 3;
@@ -1013,15 +1112,16 @@ const PrimarySelectRow = styled('div')<{hasColumnParameter: boolean}>`
}
`;
-const FieldRow = styled('div')`
+export const FieldRow = styled('div')`
display: flex;
flex-direction: row;
gap: ${space(1)};
+ width: 100%;
`;
-const StyledDeleteButton = styled(Button)``;
+export const StyledDeleteButton = styled(Button)``;
-const FieldExtras = styled('div')<{isChartWidget: boolean}>`
+export const FieldExtras = styled('div')<{isChartWidget: boolean}>`
display: flex;
flex-direction: row;
gap: ${space(1)};
@@ -1043,7 +1143,7 @@ const Fields = styled('div')`
gap: ${space(1)};
`;
-const StyledArithmeticInput = styled(ArithmeticInput)`
+export const StyledArithmeticInput = styled(ArithmeticInput)`
width: 100%;
`;
diff --git a/static/app/views/dashboards/widgetBuilder/components/visualize/visualizeGhostField.tsx b/static/app/views/dashboards/widgetBuilder/components/visualize/visualizeGhostField.tsx
new file mode 100644
index 00000000000000..5df1b79667f90d
--- /dev/null
+++ b/static/app/views/dashboards/widgetBuilder/components/visualize/visualizeGhostField.tsx
@@ -0,0 +1,244 @@
+import {Fragment, useMemo} from 'react';
+import styled from '@emotion/styled';
+
+import {Button} from 'sentry/components/button';
+import {IconDelete, IconGrabbable} from 'sentry/icons';
+import {t} from 'sentry/locale';
+import {space} from 'sentry/styles/space';
+import type {SelectValue} from 'sentry/types/core';
+import {
+ generateFieldAsString,
+ parseFunction,
+ type QueryFieldValue,
+} from 'sentry/utils/discover/fields';
+import {AggregateParameterField} from 'sentry/views/dashboards/widgetBuilder/components/visualize/aggregateParameterField';
+import {
+ AggregateCompactSelect,
+ ColumnCompactSelect,
+ FieldBar,
+ FieldExtras,
+ FieldRow,
+ LegendAliasInput,
+ ParameterRefinements,
+ PrimarySelectRow,
+ StyledArithmeticInput,
+ StyledDeleteButton,
+} from 'sentry/views/dashboards/widgetBuilder/components/visualize/index';
+import {type FieldValue, FieldValueKind} from 'sentry/views/discover/table/types';
+
+type VisualizeGhostFieldProps = {
+ activeId: number;
+ aggregates: Array>;
+ fields: QueryFieldValue[];
+ isBigNumberWidget: boolean;
+ isChartWidget: boolean;
+ stringFields: string[];
+};
+
+function VisualizeGhostField({
+ isChartWidget,
+ isBigNumberWidget,
+ fields,
+ activeId,
+ aggregates,
+ stringFields,
+}: VisualizeGhostFieldProps) {
+ const draggingField = useMemo(() => {
+ return fields?.[Number(activeId)];
+ }, [activeId, fields]);
+
+ const draggableMatchingAggregate = useMemo(() => {
+ let matchingAggregate: any;
+ if (
+ draggingField!.kind === FieldValueKind.FUNCTION &&
+ FieldValueKind.FUNCTION in draggingField!
+ ) {
+ matchingAggregate = aggregates.find(
+ option =>
+ option.value.meta.name ===
+ parseFunction(stringFields?.[Number(activeId)] ?? '')?.name
+ );
+ }
+
+ return matchingAggregate;
+ }, [draggingField, aggregates, stringFields, activeId]);
+
+ const draggableHasColumnParameter = useMemo(() => {
+ const isApdexOrUserMisery =
+ draggableMatchingAggregate?.value.meta.name === 'apdex' ||
+ draggableMatchingAggregate?.value.meta.name === 'user_misery';
+
+ return (
+ (draggingField!.kind === FieldValueKind.FUNCTION &&
+ !isApdexOrUserMisery &&
+ draggableMatchingAggregate?.value.meta.parameters.length !== 0) ||
+ draggingField!.kind === FieldValueKind.FIELD
+ );
+ }, [draggableMatchingAggregate, draggingField]);
+
+ const draggableParameterRefinements = useMemo(() => {
+ return draggableMatchingAggregate?.value.meta.parameters.length > 1
+ ? draggableMatchingAggregate?.value.meta.parameters.slice(1)
+ : [];
+ }, [draggableMatchingAggregate]);
+
+ const isDraggableApdexOrUserMisery = useMemo(() => {
+ return (
+ draggableMatchingAggregate?.value.meta.name === 'apdex' ||
+ draggableMatchingAggregate?.value.meta.name === 'user_misery'
+ );
+ }, [draggableMatchingAggregate]);
+
+ return (
+
+
+ }
+ size="zero"
+ borderless
+ />
+
+ {draggingField?.kind === FieldValueKind.EQUATION ? (
+ {}}
+ />
+ ) : (
+
+
+ {}}
+ />
+ {draggableHasColumnParameter && (
+ {}}
+ />
+ )}
+
+ {draggingField?.kind === FieldValueKind.FUNCTION &&
+ draggableParameterRefinements.length > 0 && (
+
+ {draggableParameterRefinements.map(
+ (parameter: any, parameterIndex: number) => {
+ const currentValue =
+ draggingField?.function[parameterIndex + 2] || '';
+ const key = `${draggingField.function!.join('_')}-${parameterIndex}`;
+
+ return (
+ {}}
+ />
+ );
+ }
+ )}
+
+ )}
+ {isDraggableApdexOrUserMisery &&
+ draggingField?.kind === FieldValueKind.FUNCTION && (
+ {}}
+ />
+ )}
+
+ )}
+
+
+ {!isChartWidget && !isBigNumberWidget && (
+ {}}
+ />
+ )}
+ }
+ size="zero"
+ disabled
+ onClick={() => {}}
+ aria-label={t('Remove field')}
+ />
+
+
+
+ );
+}
+
+export default VisualizeGhostField;
+
+const Ghost = styled('div')`
+ position: absolute;
+ background: ${p => p.theme.background};
+ padding: ${space(0.5)};
+ border-radius: ${p => p.theme.borderRadius};
+ box-shadow: 0 0 15px rgba(0, 0, 0, 0.15);
+ opacity: 0.8;
+ cursor: grabbing;
+ padding-right: ${space(2)};
+ width: 100%;
+
+ button {
+ cursor: grabbing;
+ }
+
+ @media (min-width: ${p => p.theme.breakpoints.small}) {
+ width: 710px;
+ }
+`;
+
+const DragAndReorderButton = styled(Button)`
+ height: ${p => p.theme.form.md.height}px;
+`;