From 2f01330c10ea2dbeb682acd2027732451ff67a0a Mon Sep 17 00:00:00 2001
From: Nick Fitzpatrick <nick@metabase.com>
Date: Wed, 6 Mar 2024 15:07:18 -0400
Subject: [PATCH] 38092 metadata table columns dnd (#39637)

* Metadata Table columns use Sortable List

* cleanup

* e2e test adjustments
---
 .../admin/datamodel/editor.cy.spec.js         |  23 ++-
 .../MetadataTableColumn.styled.tsx            |   1 +
 .../MetadataTableColumnList.tsx               | 156 +++++++++---------
 .../metabase/components/Grabber/Grabber.jsx   |  10 +-
 .../core/components/Sortable/SortableList.tsx |  24 ++-
 .../ChartSettingOrderedItems.tsx              |   9 +-
 6 files changed, 127 insertions(+), 96 deletions(-)

diff --git a/e2e/test/scenarios/admin/datamodel/editor.cy.spec.js b/e2e/test/scenarios/admin/datamodel/editor.cy.spec.js
index d701be876ec..f0418cd3e49 100644
--- a/e2e/test/scenarios/admin/datamodel/editor.cy.spec.js
+++ b/e2e/test/scenarios/admin/datamodel/editor.cy.spec.js
@@ -659,10 +659,25 @@ const getFieldSection = fieldName => {
 };
 
 const moveField = (fieldIndex, deltaY) => {
-  cy.get(".Grabber").eq(fieldIndex).trigger("mousedown", 0, 0, { force: true });
-  cy.get("#ColumnsList")
-    .trigger("mousemove", 10, deltaY)
-    .trigger("mouseup", 10, deltaY);
+  cy.get(".Grabber")
+    .eq(fieldIndex)
+    .trigger("pointerdown", 0, 0, { force: true, button: 0, isPrimary: true })
+    .wait(200)
+    .trigger("pointermove", 5, 5, { force: true, button: 0, isPrimary: true })
+    .wait(200)
+    //cy.get("#ColumnsList")
+    .trigger("pointermove", 10, deltaY, {
+      force: true,
+      button: 0,
+      isPrimary: true,
+    })
+    .wait(200)
+    .trigger("pointerup", 10, deltaY, {
+      force: true,
+      button: 0,
+      isPrimary: true,
+    })
+    .wait(200);
 };
 
 const setTableOrder = order => {
diff --git a/frontend/src/metabase/admin/datamodel/metadata/components/MetadataTableColumn/MetadataTableColumn.styled.tsx b/frontend/src/metabase/admin/datamodel/metadata/components/MetadataTableColumn/MetadataTableColumn.styled.tsx
index 17efa813282..4b287452a80 100644
--- a/frontend/src/metabase/admin/datamodel/metadata/components/MetadataTableColumn/MetadataTableColumn.styled.tsx
+++ b/frontend/src/metabase/admin/datamodel/metadata/components/MetadataTableColumn/MetadataTableColumn.styled.tsx
@@ -15,6 +15,7 @@ export const ColumnContainer = styled.section`
   &:last-child {
     margin-bottom: 0;
   }
+  background: ${color("white")};
 `;
 
 export const ColumnInput = styled(InputBlurChange)`
diff --git a/frontend/src/metabase/admin/datamodel/metadata/components/MetadataTableColumnList/MetadataTableColumnList.tsx b/frontend/src/metabase/admin/datamodel/metadata/components/MetadataTableColumnList/MetadataTableColumnList.tsx
index 7df00689d7a..e0aabaf5513 100644
--- a/frontend/src/metabase/admin/datamodel/metadata/components/MetadataTableColumnList/MetadataTableColumnList.tsx
+++ b/frontend/src/metabase/admin/datamodel/metadata/components/MetadataTableColumnList/MetadataTableColumnList.tsx
@@ -1,5 +1,8 @@
+import type { UniqueIdentifier } from "@dnd-kit/core";
+import { useSensor, PointerSensor } from "@dnd-kit/core";
+import { useSortable } from "@dnd-kit/sortable";
+import { CSS } from "@dnd-kit/utilities";
 import cx from "classnames";
-import type { ReactNode } from "react";
 import { useCallback, useMemo } from "react";
 import { connect } from "react-redux";
 import { t } from "ttag";
@@ -7,12 +10,8 @@ import _ from "underscore";
 
 import Grabber from "metabase/components/Grabber";
 import TippyPopoverWithTrigger from "metabase/components/PopoverWithTrigger/TippyPopoverWithTrigger";
-import {
-  SortableContainer,
-  SortableElement,
-  SortableHandle,
-} from "metabase/components/sortable";
 import AccordionList from "metabase/core/components/AccordionList";
+import { SortableList } from "metabase/core/components/Sortable";
 import Tables from "metabase/entities/tables";
 import { Icon } from "metabase/ui";
 import type Field from "metabase-lib/metadata/Field";
@@ -45,11 +44,6 @@ interface DispatchProps {
   onUpdateFieldOrder: (table: Table, fieldOrder: FieldId[]) => void;
 }
 
-interface DragProps {
-  oldIndex: number;
-  newIndex: number;
-}
-
 type MetadataTableColumnListProps = OwnProps & DispatchProps;
 
 const mapDispatchToProps: DispatchProps = {
@@ -57,6 +51,8 @@ const mapDispatchToProps: DispatchProps = {
   onUpdateFieldOrder: Tables.actions.setFieldOrder,
 };
 
+const getId = (field: Field) => field.getId();
+
 const MetadataTableColumnList = ({
   table,
   idFields,
@@ -67,23 +63,31 @@ const MetadataTableColumnList = ({
   const { fields = [], visibility_type } = table;
   const isHidden = visibility_type != null;
 
+  const pointerSensor = useSensor(PointerSensor, {
+    activationConstraint: { distance: 0 },
+  });
+
   const sortedFields = useMemo(
     () => _.sortBy(fields, field => field.position),
     [fields],
   );
 
-  const handleSortStart = useCallback(() => {
-    document.body.classList.add("grabbing");
-  }, []);
-
   const handleSortEnd = useCallback(
-    ({ oldIndex, newIndex }: DragProps) => {
-      document.body.classList.remove("grabbing");
-
-      const fieldOrder = updateFieldOrder(sortedFields, oldIndex, newIndex);
+    ({ itemIds: fieldOrder }) => {
       onUpdateFieldOrder(table, fieldOrder);
     },
-    [table, sortedFields, onUpdateFieldOrder],
+    [table, onUpdateFieldOrder],
+  );
+
+  const renderItem = ({ item, id }: { item: Field; id: string | number }) => (
+    <SortableColumn
+      key={`sortable-${id}`}
+      id={id}
+      field={item}
+      idFields={idFields}
+      table={table}
+      selectedSchemaId={selectedSchemaId}
+    />
   );
 
   return (
@@ -108,41 +112,20 @@ const MetadataTableColumnList = ({
           </SortButtonContainer>
         </div>
       </div>
-      <SortableColumnList
-        helperClass="ColumnSortHelper"
-        useDragHandle={true}
-        onSortStart={handleSortStart}
-        onSortEnd={handleSortEnd}
-      >
-        {sortedFields.map((field, index) => (
-          <SortableColumn
-            key={field.getId()}
-            index={index}
-            field={field}
-            idFields={idFields}
-            selectedDatabaseId={table.db_id}
-            selectedSchemaId={selectedSchemaId}
-            selectedTableId={table.id}
-            dragHandle={<SortableColumnHandle />}
-          />
-        ))}
-      </SortableColumnList>
+      <div>
+        <SortableList
+          items={sortedFields}
+          renderItem={renderItem}
+          getId={getId}
+          onSortEnd={handleSortEnd}
+          sensors={[pointerSensor]}
+          useDragOverlay={false}
+        />
+      </div>
     </div>
   );
 };
 
-interface ColumnListProps {
-  children?: ReactNode;
-}
-
-const ColumnList = ({ children, ...props }: ColumnListProps) => {
-  return <div {...props}>{children}</div>;
-};
-
-const ColumnGrabber = () => {
-  return <Grabber style={{ width: 10 }} />;
-};
-
 interface TableFieldOrderOption {
   name: string;
   value: TableFieldOrder;
@@ -191,31 +174,56 @@ const TableFieldOrderDropdown = ({
   );
 };
 
-const SortableColumn = SortableElement(MetadataTableColumn);
-const SortableColumnList = SortableContainer(ColumnList);
-const SortableColumnHandle = SortableHandle(ColumnGrabber);
-
-const updateFieldOrder = (
-  fields: Field[],
-  oldIndex: number,
-  newIndex: number,
-) => {
-  const fieldOrder = new Array<FieldId>(fields.length);
-
-  fields.forEach((field, prevIndex) => {
-    const nextIndex =
-      newIndex <= prevIndex && prevIndex < oldIndex
-        ? prevIndex + 1 // shift down
-        : oldIndex < prevIndex && prevIndex <= newIndex
-        ? prevIndex - 1 // shift up
-        : prevIndex === oldIndex
-        ? newIndex // move dragged column to new location
-        : prevIndex; // otherwise, leave it where it is
-
-    fieldOrder[nextIndex] = Number(field.id);
+interface SortableColumnProps {
+  id: UniqueIdentifier;
+  field: Field;
+  idFields: Field[];
+  table: Table;
+  selectedSchemaId: SchemaId;
+}
+
+const SortableColumn = ({
+  id,
+  field,
+  table,
+  idFields,
+  selectedSchemaId,
+}: SortableColumnProps) => {
+  const {
+    attributes,
+    listeners,
+    setNodeRef,
+    transform,
+    transition,
+    isDragging,
+  } = useSortable({
+    id,
   });
 
-  return fieldOrder;
+  const dragHandle = (
+    <Grabber style={{ width: 10 }} {...attributes} {...listeners} />
+  );
+
+  return (
+    <div
+      ref={setNodeRef}
+      style={{
+        transform: CSS.Transform.toString(transform),
+        transition,
+        position: "relative",
+        zIndex: isDragging ? 100 : 1,
+      }}
+    >
+      <MetadataTableColumn
+        field={field}
+        idFields={idFields}
+        selectedDatabaseId={table.db_id}
+        selectedSchemaId={selectedSchemaId}
+        selectedTableId={table.id}
+        dragHandle={dragHandle}
+      />
+    </div>
+  );
 };
 
 // eslint-disable-next-line import/no-default-export -- deprecated usage
diff --git a/frontend/src/metabase/components/Grabber/Grabber.jsx b/frontend/src/metabase/components/Grabber/Grabber.jsx
index 81a77867fc8..7889d500b0c 100644
--- a/frontend/src/metabase/components/Grabber/Grabber.jsx
+++ b/frontend/src/metabase/components/Grabber/Grabber.jsx
@@ -1,6 +1,12 @@
 /* eslint-disable react/prop-types */
 import cx from "classnames";
 
-export default function Grabber({ className = "", style }) {
-  return <div className={cx("Grabber cursor-grab", className)} style={style} />;
+export default function Grabber({ className = "", style, ...props }) {
+  return (
+    <div
+      className={cx("Grabber cursor-grab", className)}
+      style={style}
+      {...props}
+    />
+  );
 }
diff --git a/frontend/src/metabase/core/components/Sortable/SortableList.tsx b/frontend/src/metabase/core/components/Sortable/SortableList.tsx
index c6dadf1391d..da63e07465f 100644
--- a/frontend/src/metabase/core/components/Sortable/SortableList.tsx
+++ b/frontend/src/metabase/core/components/Sortable/SortableList.tsx
@@ -15,6 +15,7 @@ type ItemId = number | string;
 export type DragEndEvent = {
   id: ItemId;
   newIndex: number;
+  itemIds: ItemId[];
 };
 
 interface RenderItemProps<T> {
@@ -30,6 +31,7 @@ interface useSortableListProps<T> {
   onSortEnd?: ({ id, newIndex }: DragEndEvent) => void;
   sensors?: SensorDescriptor<any>[];
   modifiers?: Modifier[];
+  useDragOverlay?: boolean;
 }
 
 export const SortableList = <T,>({
@@ -40,6 +42,7 @@ export const SortableList = <T,>({
   onSortEnd,
   sensors = [],
   modifiers = [],
+  useDragOverlay = true,
 }: useSortableListProps<T>) => {
   const [itemIds, setItemIds] = useState<ItemId[]>([]);
   const [indexedItems, setIndexedItems] = useState<Record<ItemId, T>>({});
@@ -90,6 +93,7 @@ export const SortableList = <T,>({
       onSortEnd({
         id: getId(activeItem),
         newIndex: itemIds.findIndex(id => id === getId(activeItem)),
+        itemIds,
       });
       setActiveItem(null);
     }
@@ -104,15 +108,17 @@ export const SortableList = <T,>({
       modifiers={modifiers}
     >
       <SortableContext items={itemIds}>{sortableElements}</SortableContext>
-      <DragOverlay>
-        {activeItem
-          ? renderItem({
-              item: activeItem,
-              id: getId(activeItem),
-              isDragOverlay: true,
-            })
-          : null}
-      </DragOverlay>
+      {useDragOverlay && (
+        <DragOverlay>
+          {activeItem
+            ? renderItem({
+                item: activeItem,
+                id: getId(activeItem),
+                isDragOverlay: true,
+              })
+            : null}
+        </DragOverlay>
+      )}
     </DndContext>
   );
 };
diff --git a/frontend/src/metabase/visualizations/components/settings/ChartSettingOrderedItems/ChartSettingOrderedItems.tsx b/frontend/src/metabase/visualizations/components/settings/ChartSettingOrderedItems/ChartSettingOrderedItems.tsx
index d12bada006b..4adb7351b7d 100644
--- a/frontend/src/metabase/visualizations/components/settings/ChartSettingOrderedItems/ChartSettingOrderedItems.tsx
+++ b/frontend/src/metabase/visualizations/components/settings/ChartSettingOrderedItems/ChartSettingOrderedItems.tsx
@@ -1,6 +1,7 @@
 import { useSensor, PointerSensor } from "@dnd-kit/core";
 import { useCallback } from "react";
 
+import type { DragEndEvent } from "metabase/core/components/Sortable";
 import { Sortable, SortableList } from "metabase/core/components/Sortable";
 import type { IconProps } from "metabase/ui";
 
@@ -23,13 +24,7 @@ interface SortableColumnFunctions<T> {
 }
 interface ChartSettingOrderedItemsProps<T extends SortableItem>
   extends SortableColumnFunctions<T> {
-  onSortEnd: ({
-    id,
-    newIndex,
-  }: {
-    id: number | string;
-    newIndex: number;
-  }) => void;
+  onSortEnd: ({ id, newIndex }: DragEndEvent) => void;
   items: T[];
   getId: (item: T) => string | number;
 }
-- 
GitLab