Browse Source

ref(new-widget-builder-experience): Add drag-and-drop to the groupByField - (#33719)

Priscila Oliveira 2 years ago
parent
commit
bdf4792143

+ 136 - 65
static/app/views/dashboardsV2/widgetBuilder/buildSteps/groupByStep/groupBySelector.tsx

@@ -1,30 +1,33 @@
-import {Fragment} from 'react';
+import React, {Fragment, useMemo, useState} from 'react';
+import {closestCenter, DndContext, DragOverlay} from '@dnd-kit/core';
+import {arrayMove, SortableContext, verticalListSortingStrategy} from '@dnd-kit/sortable';
 import styled from '@emotion/styled';
 
 import Button from 'sentry/components/button';
 import Field from 'sentry/components/forms/field';
-import {IconAdd, IconDelete} from 'sentry/icons';
+import {IconAdd} from 'sentry/icons';
 import {t} from 'sentry/locale';
 import space from 'sentry/styles/space';
 import {defined} from 'sentry/utils';
-import {QueryFieldValue} from 'sentry/utils/discover/fields';
-import {FieldValueOption, QueryField} from 'sentry/views/eventsV2/table/queryField';
+import {generateFieldAsString, QueryFieldValue} from 'sentry/utils/discover/fields';
 import {FieldValueKind} from 'sentry/views/eventsV2/table/types';
 import {generateFieldOptions} from 'sentry/views/eventsV2/utils';
 
+import {QueryField} from './queryField';
+import {SortableQueryField} from './sortableQueryField';
+
 const GROUP_BY_LIMIT = 20;
 const EMPTY_FIELD: QueryFieldValue = {kind: FieldValueKind.FIELD, field: ''};
 
+type FieldOptions = ReturnType<typeof generateFieldOptions>;
 interface Props {
-  fieldOptions: ReturnType<typeof generateFieldOptions>;
+  fieldOptions: FieldOptions;
   onChange: (fields: QueryFieldValue[]) => void;
   columns?: QueryFieldValue[];
 }
 
 export function GroupBySelector({fieldOptions, columns = [], onChange}: Props) {
-  function filterPrimaryOptions(option: FieldValueOption) {
-    return option.value.kind !== FieldValueKind.FUNCTION;
-  }
+  const [activeId, setActiveId] = useState<string | null>(null);
 
   function handleAdd() {
     const newColumns =
@@ -50,61 +53,117 @@ export function GroupBySelector({fieldOptions, columns = [], onChange}: Props) {
     onChange(newColumns);
   }
 
-  if (columns.length === 0) {
-    return (
-      <Fragment>
-        <StyledField inline={false} flexibleControlStateSize stacked>
-          <QueryFieldWrapper>
-            <QueryField
-              placeholder={t('Select group')}
-              fieldValue={EMPTY_FIELD}
-              fieldOptions={fieldOptions}
-              onChange={value => handleSelect(value, 0)}
-              filterPrimaryOptions={filterPrimaryOptions}
-            />
-          </QueryFieldWrapper>
-        </StyledField>
-
-        <AddGroupButton size="small" icon={<IconAdd isCircled />} onClick={handleAdd}>
-          {t('Add Group')}
-        </AddGroupButton>
-      </Fragment>
-    );
-  }
-
   const hasOnlySingleColumnWithValue =
     columns.length === 1 &&
     columns[0].kind === FieldValueKind.FIELD &&
     columns[0]?.field !== '';
 
-  const canDelete = columns.length > 1 || hasOnlySingleColumnWithValue;
+  const canDrag = columns.length > 1;
+  const canDelete = canDrag || hasOnlySingleColumnWithValue;
+  const columnFieldsAsString = columns.map(generateFieldAsString);
+
+  const {filteredFieldOptions, columnsAsFieldOptions} = useMemo(() => {
+    return Object.keys(fieldOptions).reduce(
+      (acc, key) => {
+        const value = fieldOptions[key];
+        const optionInColumnsIndex = columnFieldsAsString.findIndex(
+          column => column === value.value.meta.name
+        );
+        if (optionInColumnsIndex === -1) {
+          acc.filteredFieldOptions[key] = value;
+          return acc;
+        }
+        acc.columnsAsFieldOptions[optionInColumnsIndex] = {[key]: value};
+        return acc;
+      },
+      {
+        filteredFieldOptions: {},
+        columnsAsFieldOptions: [],
+      } as {
+        columnsAsFieldOptions: FieldOptions[];
+        filteredFieldOptions: FieldOptions;
+      }
+    );
+  }, [fieldOptions, columns]);
+
+  const items = useMemo(() => {
+    return columns.reduce((acc, _column, index) => {
+      acc.push(String(index));
+      return acc;
+    }, [] as string[]);
+  }, [columns]);
 
   return (
     <Fragment>
       <StyledField inline={false} flexibleControlStateSize stacked>
-        {columns.map((column, index) => (
-          <QueryFieldWrapper key={`groupby-${index}`}>
-            <QueryField
-              placeholder={t('Select group')}
-              fieldValue={column}
-              fieldOptions={fieldOptions}
-              onChange={value => handleSelect(value, index)}
-              filterPrimaryOptions={filterPrimaryOptions}
-            />
-            {canDelete && (
-              <Button
-                size="zero"
-                borderless
-                onClick={() => handleRemove(index)}
-                icon={<IconDelete />}
-                title={t('Remove group')}
-                aria-label={t('Remove group')}
-              />
-            )}
-          </QueryFieldWrapper>
-        ))}
+        {columns.length === 0 ? (
+          <QueryField
+            value={EMPTY_FIELD}
+            fieldOptions={filteredFieldOptions}
+            onChange={value => handleSelect(value, 0)}
+            canDelete={canDelete}
+          />
+        ) : (
+          <DndContext
+            collisionDetection={closestCenter}
+            onDragStart={({active}) => {
+              setActiveId(active.id);
+            }}
+            onDragEnd={({over, active}) => {
+              setActiveId(null);
+
+              if (over) {
+                const getIndex = items.indexOf.bind(items);
+                const activeIndex = getIndex(active.id);
+                const overIndex = getIndex(over.id);
+
+                if (activeIndex !== overIndex) {
+                  onChange(arrayMove(columns, activeIndex, overIndex));
+                }
+              }
+            }}
+            onDragCancel={() => {
+              setActiveId(null);
+            }}
+          >
+            <SortableContext items={items} strategy={verticalListSortingStrategy}>
+              <SortableQueryFields>
+                {columns.map((column, index) => (
+                  <SortableQueryField
+                    key={items[index]}
+                    dragId={items[index]}
+                    value={column}
+                    fieldOptions={{
+                      ...filteredFieldOptions,
+                      ...columnsAsFieldOptions[index],
+                    }}
+                    onChange={value => handleSelect(value, index)}
+                    onDelete={() => handleRemove(index)}
+                    canDrag={canDrag}
+                    canDelete={canDelete}
+                  />
+                ))}
+              </SortableQueryFields>
+            </SortableContext>
+            <DragOverlay dropAnimation={null}>
+              {activeId ? (
+                <Ghost>
+                  <QueryField
+                    value={columns[Number(activeId)]}
+                    fieldOptions={{
+                      ...filteredFieldOptions,
+                      ...columnsAsFieldOptions[Number(activeId)],
+                    }}
+                    onChange={value => handleSelect(value, Number(activeId))}
+                    canDrag={canDrag}
+                    canDelete={canDelete}
+                  />
+                </Ghost>
+              ) : null}
+            </DragOverlay>
+          </DndContext>
+        )}
       </StyledField>
-
       {columns.length < GROUP_BY_LIMIT && (
         <AddGroupButton size="small" icon={<IconAdd isCircled />} onClick={handleAdd}>
           {t('Add Group')}
@@ -118,20 +177,32 @@ const StyledField = styled(Field)`
   padding-bottom: ${space(1)};
 `;
 
-const QueryFieldWrapper = styled('div')`
-  display: flex;
-  align-items: center;
-  justify-content: space-between;
+const AddGroupButton = styled(Button)`
+  width: min-content;
+`;
 
-  :not(:last-child) {
-    margin-bottom: ${space(1)};
-  }
+const SortableQueryFields = styled('div')`
+  display: grid;
+  grid-auto-flow: row;
+  grid-gap: ${space(1)};
+`;
 
-  > * + * {
-    margin-left: ${space(1)};
+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;
   }
-`;
 
-const AddGroupButton = styled(Button)`
-  width: min-content;
+  @media (min-width: ${p => p.theme.breakpoints[0]}) {
+    width: 710px;
+  }
 `;

+ 27 - 3
static/app/views/dashboardsV2/widgetBuilder/buildSteps/groupByStep/index.tsx

@@ -2,12 +2,28 @@ import {t} from 'sentry/locale';
 import {Organization, TagCollection} from 'sentry/types';
 import {QueryFieldValue} from 'sentry/utils/discover/fields';
 import Measurements from 'sentry/utils/measurements/measurements';
+import {FieldValueKind} from 'sentry/views/eventsV2/table/types';
 
+import {SESSIONS_TAGS} from '../../releaseWidget/fields';
 import {DataSet, getAmendedFieldOptions} from '../../utils';
 import {BuildStep} from '../buildStep';
 
 import {GroupBySelector} from './groupBySelector';
-import {ReleaseGroupBySelector} from './releaseGroupBySelector';
+
+// Tags are sorted alphabetically to make it easier to find the correct option
+// and are converted to fieldOptions format
+const releaseFieldOptions = Object.values(SESSIONS_TAGS)
+  .sort((a, b) => a.localeCompare(b))
+  .reduce((acc, tagKey) => {
+    acc[`tag:${tagKey}`] = {
+      label: tagKey,
+      value: {
+        kind: FieldValueKind.TAG,
+        meta: {name: tagKey, dataType: 'string'},
+      },
+    };
+    return acc;
+  }, {});
 
 interface Props {
   columns: QueryFieldValue[];
@@ -30,13 +46,21 @@ export function GroupByStep({
       description={t('This is how you can group your data result by field or tag.')}
     >
       {dataSet === DataSet.RELEASE ? (
-        <ReleaseGroupBySelector columns={columns} onChange={onGroupByChange} />
+        <GroupBySelector
+          columns={columns}
+          fieldOptions={releaseFieldOptions}
+          onChange={onGroupByChange}
+        />
       ) : (
         <Measurements>
           {({measurements}) => (
             <GroupBySelector
               columns={columns}
-              fieldOptions={getAmendedFieldOptions({measurements, tags, organization})}
+              fieldOptions={getAmendedFieldOptions({
+                measurements,
+                tags,
+                organization,
+              })}
               onChange={onGroupByChange}
             />
           )}

+ 90 - 0
static/app/views/dashboardsV2/widgetBuilder/buildSteps/groupByStep/queryField.tsx

@@ -0,0 +1,90 @@
+import * as React from 'react';
+import {DraggableSyntheticListeners, UseDraggableArguments} from '@dnd-kit/core';
+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 {QueryFieldValue} from 'sentry/utils/discover/fields';
+import {QueryField as TableQueryField} from 'sentry/views/eventsV2/table/queryField';
+import {FieldValueKind} from 'sentry/views/eventsV2/table/types';
+
+export interface QueryFieldProps {
+  fieldOptions: React.ComponentProps<typeof TableQueryField>['fieldOptions'];
+  onChange: (newValue: QueryFieldValue) => void;
+  value: QueryFieldValue;
+  attributes?: UseDraggableArguments['attributes'];
+  canDelete?: boolean;
+  canDrag?: boolean;
+  forwardRef?: React.Ref<HTMLDivElement>;
+  isDragging?: boolean;
+  listeners?: DraggableSyntheticListeners;
+  onDelete?: () => void;
+  style?: React.CSSProperties;
+}
+
+export function QueryField({
+  onDelete,
+  onChange,
+  fieldOptions,
+  value,
+  forwardRef,
+  listeners,
+  attributes,
+  canDelete,
+  canDrag,
+  style,
+  isDragging,
+}: QueryFieldProps) {
+  return (
+    <QueryFieldWrapper ref={forwardRef} style={style}>
+      {isDragging ? null : (
+        <React.Fragment>
+          {canDrag && (
+            <DragAndReorderButton
+              {...listeners}
+              {...attributes}
+              aria-label={t('Drag to reorder')}
+              icon={<IconGrabbable size="xs" />}
+              size="zero"
+              borderless
+            />
+          )}
+          <TableQueryField
+            placeholder={t('Select group')}
+            fieldValue={value}
+            fieldOptions={fieldOptions}
+            onChange={onChange}
+            filterPrimaryOptions={option => option.value.kind !== FieldValueKind.FUNCTION}
+          />
+          {canDelete && (
+            <Button
+              size="zero"
+              borderless
+              onClick={onDelete}
+              icon={<IconDelete />}
+              title={t('Remove group')}
+              aria-label={t('Remove group')}
+            />
+          )}
+        </React.Fragment>
+      )}
+    </QueryFieldWrapper>
+  );
+}
+
+const DragAndReorderButton = styled(Button)`
+  height: 40px;
+`;
+
+const QueryFieldWrapper = styled('div')`
+  display: flex;
+  align-items: center;
+  justify-content: space-between;
+  width: 100%;
+
+  > * + * {
+    margin-left: ${space(1)};
+  }
+`;

+ 0 - 32
static/app/views/dashboardsV2/widgetBuilder/buildSteps/groupByStep/releaseGroupBySelector.tsx

@@ -1,32 +0,0 @@
-import {QueryFieldValue} from 'sentry/utils/discover/fields';
-import {FieldValueKind} from 'sentry/views/eventsV2/table/types';
-
-import {SESSIONS_TAGS} from '../../releaseWidget/fields';
-
-import {GroupBySelector} from './groupBySelector';
-
-interface Props {
-  columns: QueryFieldValue[];
-  onChange: (newFields: QueryFieldValue[]) => void;
-}
-
-export function ReleaseGroupBySelector({columns, onChange}: Props) {
-  // Tags are sorted alphabetically to make it easier to find the correct option
-  // and are converted to fieldOptions format
-  const fieldOptions = Object.values(SESSIONS_TAGS)
-    .sort((a, b) => a.localeCompare(b))
-    .reduce((acc, tagKey) => {
-      acc[`tag:${tagKey}`] = {
-        label: tagKey,
-        value: {
-          kind: FieldValueKind.TAG,
-          meta: {name: tagKey, dataType: 'string'},
-        },
-      };
-      return acc;
-    }, {});
-
-  return (
-    <GroupBySelector columns={columns} fieldOptions={fieldOptions} onChange={onChange} />
-  );
-}

+ 45 - 0
static/app/views/dashboardsV2/widgetBuilder/buildSteps/groupByStep/sortableQueryField.tsx

@@ -0,0 +1,45 @@
+import {useSortable} from '@dnd-kit/sortable';
+import {CSS} from '@dnd-kit/utilities';
+import {useTheme} from '@emotion/react';
+
+import {QueryField, QueryFieldProps} from './queryField';
+
+interface SortableItemProps extends QueryFieldProps {
+  dragId: string;
+}
+
+export function SortableQueryField({dragId, ...props}: SortableItemProps) {
+  const theme = useTheme();
+  const {listeners, setNodeRef, transform, transition, attributes, isDragging} =
+    useSortable({
+      id: dragId,
+      transition: null,
+    });
+
+  let style = {
+    transform: CSS.Transform.toString(transform),
+    transition,
+    zIndex: 'auto',
+  } as React.CSSProperties;
+
+  if (isDragging) {
+    style = {
+      ...style,
+      zIndex: 100,
+      height: '40px',
+      border: `2px dashed ${theme.border}`,
+      borderRadius: theme.borderRadius,
+    };
+  }
+
+  return (
+    <QueryField
+      forwardRef={setNodeRef}
+      listeners={listeners}
+      attributes={attributes}
+      isDragging={isDragging}
+      style={style}
+      {...props}
+    />
+  );
+}

+ 25 - 0
tests/js/spec/views/dashboardsV2/widgetBuilder/widgetBuilder.spec.tsx

@@ -2211,6 +2211,31 @@ describe('WidgetBuilder', function () {
         expect(screen.queryByLabelText('Remove group')).not.toBeInTheDocument()
       );
     });
+
+    it("display 'remove' and 'drag to reorder' buttons", async function () {
+      renderTestComponent({
+        query: {displayType: 'line'},
+        orgFeatures: [...defaultOrgFeatures, 'new-widget-builder-experience-design'],
+      });
+
+      await screen.findByText('Select group');
+
+      expect(screen.queryByLabelText('Remove group')).not.toBeInTheDocument();
+
+      await selectEvent.select(screen.getByText('Select group'), 'project');
+
+      expect(screen.getByLabelText('Remove group')).toBeInTheDocument();
+      expect(screen.queryByLabelText('Drag to reorder')).not.toBeInTheDocument();
+
+      userEvent.click(screen.getByText('Add Group'));
+
+      expect(screen.getAllByLabelText('Remove group')).toHaveLength(2);
+      expect(screen.getAllByLabelText('Drag to reorder')).toHaveLength(2);
+    });
+
+    it.todo(
+      'Since simulate drag and drop with RTL is not recommended because of browser layout, remember to create acceptance test for this'
+    );
   });
 
   describe('limit field', function () {