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