From 3a8f086d15a142cb206a03719db808ed4b7376be Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?F=C3=A9lix=20Malfait?= Date: Fri, 24 Jul 2026 14:04:13 +0200 Subject: [PATCH] Converge drag and drop on shared dnd-kit primitives, remove @hello-pangea/dnd (#23211) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Follow-ups recorded in #23023, done in one pass. ## Shared primitives - Folded `PageLayoutWidgetSortableItem` and `PageLayoutWidgetDropLine` into the shared `DragDropItemSortableCell` / new `DragDropItemDropLine` (new `data`, `dropLine`, `highlightWhileDragging`, `hasTransition` props). - Added generic `DragDropProviderDragStartEvent` (and DragMove/DragOver/DragEnd/DropTarget) helpers and deleted the 7 copied `Parameters<...>` extractions across the dnd hooks. - Replaced the `useMovePageLayoutWidgetUp/Down` implementations (~140 lines) with `moveWidgetWithinTabInDraft`. - Migrated the remaining page-layout test suites onto `pageLayoutDraftFixtures`. ## Tab reordering off Pangea - Tabs are sortable cells on the same provider as widget drags, segregated by dnd type, so widget drops on tab buttons keep working while tabs reorder. - Reordering is ID based (`reorderTabInDraft`: insert before the hovered tab), which keeps the pinned first tab in place without index arithmetic. - Preserved overflow behaviors: the dropdown stays open while a tab drag is in flight, dropping a tab on the "+N More" button appends it and opens the dropdown, and both the visible strip and the overflow list have end drop zones. ## Fields configuration editors off Pangea - Group reorder, field reorder and cross-group field moves now run on the shared cells (same drop line and end-zone patterns). ## DraggableList off Pangea - `DraggableList` / `DraggableItem` keep their consumer-facing API — the ~9 consumers now type their handlers with a local `DraggableListDropResult` instead of pangea's `DropResult` — but run on the shared sortable cells; each list's uuid group doubles as its dnd type so nested lists stay isolated from page-level providers. - Items register their index in a list-scoped registry so the end drop zone can resolve the append index at drop time (with insert-before semantics an item could otherwise never reach the last position). - Deleted three dead files that only existed for pangea plumbing (the side panel navigation placeholder, `getCssCompatibleDraggableProps`, the orphaned `recordGroupPendingDragEndReorderState`). ## Record table row drag off Pangea - Rows register through `useSortable` directly on the row element — no wrapper div, so row CSS, sticky cells and virtualization stay untouched — with the grip cell wired as the drag handle via the shared sortable handle ref context. - Both table modes (virtualized flat list and record groups) share a `DragOverlay` clone that replaces pangea's virtual-mode `renderClone`, and end drop zones per record group (and after the virtualized list) allow dropping after the last row or into an empty group. - The drop handlers keep their pangea-shaped result object, retyped as a local `RecordDragDropResult`, so the position computation logic is untouched. ## Pangea removed `@hello-pangea/dnd` is gone from `package.json` and the lockfile, along with its orphaned transitive entries (`css-box-model`, `raf-schd`, `react-redux`, `redux`). Nothing in the repo imports it anymore. ## Dashboards: cross-tab widget drag for grids react-grid-layout drags never enter dnd-kit, so the bridge hit-tests the pointer against the tab buttons' `data-page-layout-tab-drop-target-id` rects during grid drags, highlights the hovered tab through state, and on drop moves the widget to the destination grid below its existing content (`moveWidgetToGridTabInDraft`, `buildTabWidgetLayouts`). The grid's own post-drag layout commit is suppressed once so it does not overwrite the cross-tab move. ## Fixes found while testing - With `feedback: 'clone'`, the drag source is its own initial drop target and its placeholder is a DOM clone taken at drag start, so the drop line rendered into the source got baked into the placeholder and stuck there for the whole drag. The line is now hidden on the source cell, leaving a single indicator at the actual target. - Reorderable tabs collapsed to text height and sat top-aligned next to "+ New Tab" because the sortable cell wrapper defaults to `display: block; height: auto`, breaking the tab height chain — the tab list now uses the cell's `fill` mode so tabs stretch to the strip height again. ## Testing Playwright against the dev app: - Record page: widget reorder up and down in the pinned column (single blue drop line at the target), drag to another tab via its tab button (highlight + move), drag back into content at a specific position, chained cross-tab moves, tab reorder with vertical drop line, new tab creation. - Overflow (narrow viewport): drop a tab on "+N More" (appends last, dropdown opens), reorder inside the dropdown (stays open), drag a tab from the dropdown back to the visible strip. - Dashboard: grid drag within a tab, cross-tab drag onto a tab button (hover highlight, widget lands below destination content, remaining widgets keep their positions), save and reload persistence in both directions. - Fields editor: field reorder, group reorder, field move across groups, plus the Move Up / Move Down widget actions. Since the pangea-removal commits: - Typecheck, oxlint and oxfmt green over the full front source; unit suites green including the migrated `useStartRecordDrag` test (jest needed a scoped transform exemption for `@preact/signals-core` once dnd-kit reached the side-panel suites). - Storybook visual regression unchanged across ~700 stories — expected, since the migrated surfaces render identical DOM at rest (drop lines and drag overlays only exist mid-drag). - The tab strip fix reverses the exact regression mechanism: the sortable cell wrapper defaulted to `display: block; height: auto`, collapsing the tab height chain next to the full-height "+ New Tab" button; `fill` restores the stretch. --- _Generated by [Claude Code](https://claude.ai/code/session_01XKRCzzu8oGyocXZtFp7VEG)_ ``<img alt="Review in cubic" src="https://www.cubic.dev/buttons/review-in-cubic-dark.svg">`` --- packages/twenty-front/jest.config.mjs | 4 +- packages/twenty-front/package.json | 1 - packages/twenty-front/setupTests.ts | 13 + .../SidePanelCommandMenuItemEditPage.tsx | 4 +- .../dnd/hooks/useNavigationMenuItemDndKit.ts | 24 +- .../NavigationMenuItemDndKitProvider.tsx | 2 + .../hooks/useObjectOptionsForBoard.ts | 6 +- .../hooks/useProcessOptionDropdownDragEnd.ts | 6 +- .../contexts/ObjectOptionsDropdownContext.ts | 6 +- .../dnd/hooks/useRecordBoardColumnDndKit.ts | 24 +- .../RecordBoardColumnDndKitProvider.tsx | 2 + .../hooks/useRecordBoardDndKit.ts | 24 +- .../providers/RecordBoardDndKitProvider.tsx | 2 + .../RecordCalendarMonthDragDropContext.tsx | 2 + .../hooks/useRecordCalendarMonthDndKit.ts | 24 +- .../RecordCalendarWeekDragDropContext.tsx | 28 +- .../__tests__/useStartRecordDrag.test.tsx | 35 +- .../useProcessTableWithGroupRecordDrop.ts | 4 +- .../useProcessTableWithoutGroupRecordDrop.ts | 5 +- .../record-drag/types/RecordDragDropResult.ts | 11 + .../RecordGroupsVisibilityDropdownSection.tsx | 12 +- .../useRecordGroupReorderConfirmationModal.ts | 14 +- .../recordGroupPendingDragEndReorderState.ts | 9 - .../RecordTableNoRecordGroupRows.tsx | 14 +- .../components/RecordTableRecordGroupRows.tsx | 22 +- .../perf/RecordTableCell.perf.stories.tsx | 1 - .../RecordTableNoRecordGroupDroppableId.ts | 2 + .../constants/RecordTableRowDndType.ts | 1 + .../record-table/constants/TableZIndex.ts | 1 + .../RecordTableRowDraggableContext.ts | 2 - .../RecordTableBodyDroppablePlaceholder.tsx | 7 - .../components/RecordTableBodyLoading.tsx | 1 - ...dyNoRecordGroupDragDropContextProvider.tsx | 96 +++++- .../RecordTableBodyNoRecordGroupDroppable.tsx | 47 --- ...BodyRecordGroupDragDropContextProvider.tsx | 94 +++++- .../RecordTableBodyRecordGroupDroppable.tsx | 39 --- .../RecordTableNoRecordGroupBody.tsx | 6 +- .../RecordTableRecordGroupsBody.tsx | 8 +- .../RecordTableBodyDroppableContext.ts | 13 - .../components/RecordTableCellDragAndDrop.tsx | 11 +- .../RecordTableCellFirstRowFirstColumn.tsx | 7 +- .../RecordTableCellStyleWrapper.tsx | 7 +- .../record-table-cell/hooks/__mocks__/cell.ts | 1 - .../dnd/hooks/useRecordTableHeaderDndKit.ts | 24 +- .../RecordTableHeaderDndKitProvider.tsx | 2 + .../components/RecordTableDraggableTr.tsx | 122 ++++--- .../components/RecordTableRowDiv.tsx | 2 + .../RecordTableRowDragOverlayContent.tsx} | 130 ++++---- .../components/RecordTableStaticTr.tsx | 1 - .../types/RecordTableRowDragData.ts | 6 + .../components/PageLayoutGridLayout.tsx | 19 +- .../components/PageLayoutTabList.tsx | 310 ++++++++---------- .../PageLayoutTabListDroppableMoreButton.tsx | 40 ++- ...youtTabListReorderableOverflowDropdown.tsx | 153 ++++----- .../PageLayoutTabListReorderableTab.tsx | 74 +++-- .../PageLayoutTabListVisibleTabs.tsx | 86 +++-- .../components/PageLayoutTabRenderClone.tsx | 101 ------ .../components/PageLayoutTabsRenderer.tsx | 15 - .../PageLayoutVerticalListEditor.tsx | 63 ++-- .../__stories__/PageLayoutTabList.stories.tsx | 153 +++------ .../dnd/PageLayoutTabWidgetDropTarget.tsx | 19 +- .../dnd/PageLayoutWidgetDndProvider.tsx | 2 + .../dnd/PageLayoutWidgetDropLine.tsx | 24 -- .../dnd/PageLayoutWidgetSortableItem.tsx | 64 ---- .../constants/PageLayoutTabDndType.ts | 1 + .../PageLayoutTabDropTargetDataAttribute.ts | 2 + .../PageLayoutTabListEndDropZoneWidth.ts | 3 + .../constants/PageLayoutWidgetDndType.ts | 1 + .../useCanMovePageLayoutWidgetDown.test.tsx | 63 +--- .../useCanMovePageLayoutWidgetUp.test.tsx | 63 +--- .../useInsertCreatedWidgetAtContext.test.tsx | 61 +--- .../useMovePageLayoutWidgetDown.test.tsx | 61 +--- .../useMovePageLayoutWidgetUp.test.tsx | 61 +--- .../hooks/useMovePageLayoutWidgetDown.ts | 60 +--- .../hooks/useMovePageLayoutWidgetUp.ts | 67 +--- .../hooks/usePageLayoutGridCrossTabDrop.ts | 183 +++++++++++ .../hooks/usePageLayoutWidgetDragAndDrop.ts | 40 ++- .../hooks/useReorderPageLayoutTabs.ts | 83 ----- .../hooks/useReorderRecordPageLayoutTabs.ts | 45 --- ...ayoutGridDragHoveredTabIdComponentState.ts | 11 + ...gnoreNextGridLayoutChangeComponentState.ts | 12 + ...istCurrentDragDroppableIdComponentState.ts | 10 - .../testing/pageLayoutDraftFixtures.ts | 3 + .../types/PageLayoutTabDragData.ts | 4 + .../types/PageLayoutTabListEndDropData.ts | 5 + .../types/PageLayoutTabMoreButtonDropData.ts | 3 + .../types/PageLayoutTabWidgetDropData.ts | 4 + .../types/PageLayoutWidgetDndData.ts | 27 +- .../types/PageLayoutWidgetDragData.ts | 6 + .../types/PageLayoutWidgetListDropData.ts | 4 + .../convertPageLayoutToTabLayouts.test.ts | 55 ++++ .../__tests__/isReactivatableTab.test.ts | 21 +- .../moveWidgetToGridTabInDraft.test.ts | 139 ++++++++ .../utils/__tests__/reorderTabInDraft.test.ts | 109 ++++++ .../sortWidgetsByVerticalListPosition.test.ts | 29 +- .../utils/buildTabWidgetLayouts.ts | 47 +++ .../utils/convertPageLayoutToTabLayouts.ts | 49 +-- .../utils/getWidgetGridPosition.ts | 11 + .../utils/moveWidgetToGridTabInDraft.ts | 94 ++++++ .../page-layout/utils/reorderTabInDraft.ts | 54 +++ .../components/FieldsConfigurationEditor.tsx | 204 +++++------- .../FieldsConfigurationGroupEditor.tsx | 168 +++++----- .../FieldsConfigurationUngroupedEditor.tsx | 155 ++++++--- .../FieldsConfigurationFieldDndType.ts | 1 + .../FieldsConfigurationGroupDndType.ts | 1 + .../FieldsConfigurationGroupsDroppableId.ts | 2 + ...useFieldsConfigurationEditorDragAndDrop.ts | 173 ++++++++++ .../types/FieldsConfigurationDndData.ts | 10 + .../types/FieldsConfigurationFieldDragData.ts | 5 + ...FieldsConfigurationFieldListEndDropData.ts | 5 + .../types/FieldsConfigurationGroupDragData.ts | 5 + ...FieldsConfigurationGroupListEndDropData.ts | 4 + .../SettingsDataModelFieldSelectForm.tsx | 5 +- ...nelAddToNavigationDraggablePlaceholder.tsx | 30 -- .../ChartManualSortSubMenuContent.tsx | 4 +- ...ableFieldsDropdownVisibleFieldsContent.tsx | 4 +- .../RecordTableSettingsFieldVisibility.tsx | 4 +- .../components/DraggableItem.tsx | 117 ++++--- .../components/DraggableList.tsx | 128 ++++++-- .../__stories__/DraggableItem.stories.tsx | 10 +- .../constants/DraggableListEndDropIndex.ts | 3 + .../contexts/DraggableListGroupContext.ts | 9 + .../types/DraggableListDropResult.ts | 5 + .../utils/getCssCompatibleDraggableProps.ts | 15 - .../components/DragDropItemDropLine.tsx | 46 +++ .../components/DragDropItemEndDropZone.tsx | 45 +++ .../components/DragDropItemSortableCell.tsx | 84 +++-- .../components/DragDropItemSortableHandle.tsx | 7 +- .../DndKitPluginsWithoutOptimistic.ts | 6 + ...dKitProviderPluginsWithoutDropAnimation.ts | 11 + .../types/DragDropProviderDragEndEvent.ts | 7 + .../types/DragDropProviderDragMoveEvent.ts | 7 + .../types/DragDropProviderDragOverEvent.ts | 7 + .../types/DragDropProviderDragStartEvent.ts | 7 + .../types/DragDropProviderDropTarget.ts | 6 + .../types/DragDropProviderProps.ts | 7 + .../utils/resolveDropFromPointerY.ts | 9 +- .../ViewFieldsVisibleDropdownSection.tsx | 14 +- .../components/ViewPickerListContent.tsx | 6 +- .../WorkflowEditActionFormBuilder.tsx | 9 +- yarn.lock | 63 +--- 141 files changed, 2648 insertions(+), 2303 deletions(-) create mode 100644 packages/twenty-front/src/modules/object-record/record-drag/types/RecordDragDropResult.ts delete mode 100644 packages/twenty-front/src/modules/object-record/record-group/states/recordGroupPendingDragEndReorderState.ts create mode 100644 packages/twenty-front/src/modules/object-record/record-table/constants/RecordTableNoRecordGroupDroppableId.ts create mode 100644 packages/twenty-front/src/modules/object-record/record-table/constants/RecordTableRowDndType.ts delete mode 100644 packages/twenty-front/src/modules/object-record/record-table/record-table-body/components/RecordTableBodyDroppablePlaceholder.tsx delete mode 100644 packages/twenty-front/src/modules/object-record/record-table/record-table-body/components/RecordTableBodyNoRecordGroupDroppable.tsx delete mode 100644 packages/twenty-front/src/modules/object-record/record-table/record-table-body/components/RecordTableBodyRecordGroupDroppable.tsx delete mode 100644 packages/twenty-front/src/modules/object-record/record-table/record-table-body/contexts/RecordTableBodyDroppableContext.ts rename packages/twenty-front/src/modules/object-record/record-table/{record-table-body/components/RecordTableBodyVirtualizedDraggableClone.tsx => record-table-row/components/RecordTableRowDragOverlayContent.tsx} (71%) create mode 100644 packages/twenty-front/src/modules/object-record/record-table/types/RecordTableRowDragData.ts delete mode 100644 packages/twenty-front/src/modules/page-layout/components/PageLayoutTabRenderClone.tsx delete mode 100644 packages/twenty-front/src/modules/page-layout/components/dnd/PageLayoutWidgetDropLine.tsx delete mode 100644 packages/twenty-front/src/modules/page-layout/components/dnd/PageLayoutWidgetSortableItem.tsx create mode 100644 packages/twenty-front/src/modules/page-layout/constants/PageLayoutTabDndType.ts create mode 100644 packages/twenty-front/src/modules/page-layout/constants/PageLayoutTabDropTargetDataAttribute.ts create mode 100644 packages/twenty-front/src/modules/page-layout/constants/PageLayoutTabListEndDropZoneWidth.ts create mode 100644 packages/twenty-front/src/modules/page-layout/constants/PageLayoutWidgetDndType.ts create mode 100644 packages/twenty-front/src/modules/page-layout/hooks/usePageLayoutGridCrossTabDrop.ts delete mode 100644 packages/twenty-front/src/modules/page-layout/hooks/useReorderPageLayoutTabs.ts delete mode 100644 packages/twenty-front/src/modules/page-layout/hooks/useReorderRecordPageLayoutTabs.ts create mode 100644 packages/twenty-front/src/modules/page-layout/states/pageLayoutGridDragHoveredTabIdComponentState.ts create mode 100644 packages/twenty-front/src/modules/page-layout/states/pageLayoutShouldIgnoreNextGridLayoutChangeComponentState.ts delete mode 100644 packages/twenty-front/src/modules/page-layout/states/pageLayoutTabListCurrentDragDroppableIdComponentState.ts create mode 100644 packages/twenty-front/src/modules/page-layout/types/PageLayoutTabDragData.ts create mode 100644 packages/twenty-front/src/modules/page-layout/types/PageLayoutTabListEndDropData.ts create mode 100644 packages/twenty-front/src/modules/page-layout/types/PageLayoutTabMoreButtonDropData.ts create mode 100644 packages/twenty-front/src/modules/page-layout/types/PageLayoutTabWidgetDropData.ts create mode 100644 packages/twenty-front/src/modules/page-layout/types/PageLayoutWidgetDragData.ts create mode 100644 packages/twenty-front/src/modules/page-layout/types/PageLayoutWidgetListDropData.ts create mode 100644 packages/twenty-front/src/modules/page-layout/utils/__tests__/moveWidgetToGridTabInDraft.test.ts create mode 100644 packages/twenty-front/src/modules/page-layout/utils/__tests__/reorderTabInDraft.test.ts create mode 100644 packages/twenty-front/src/modules/page-layout/utils/buildTabWidgetLayouts.ts create mode 100644 packages/twenty-front/src/modules/page-layout/utils/getWidgetGridPosition.ts create mode 100644 packages/twenty-front/src/modules/page-layout/utils/moveWidgetToGridTabInDraft.ts create mode 100644 packages/twenty-front/src/modules/page-layout/utils/reorderTabInDraft.ts create mode 100644 packages/twenty-front/src/modules/page-layout/widgets/fields/constants/FieldsConfigurationFieldDndType.ts create mode 100644 packages/twenty-front/src/modules/page-layout/widgets/fields/constants/FieldsConfigurationGroupDndType.ts create mode 100644 packages/twenty-front/src/modules/page-layout/widgets/fields/constants/FieldsConfigurationGroupsDroppableId.ts create mode 100644 packages/twenty-front/src/modules/page-layout/widgets/fields/hooks/useFieldsConfigurationEditorDragAndDrop.ts create mode 100644 packages/twenty-front/src/modules/page-layout/widgets/fields/types/FieldsConfigurationDndData.ts create mode 100644 packages/twenty-front/src/modules/page-layout/widgets/fields/types/FieldsConfigurationFieldDragData.ts create mode 100644 packages/twenty-front/src/modules/page-layout/widgets/fields/types/FieldsConfigurationFieldListEndDropData.ts create mode 100644 packages/twenty-front/src/modules/page-layout/widgets/fields/types/FieldsConfigurationGroupDragData.ts create mode 100644 packages/twenty-front/src/modules/page-layout/widgets/fields/types/FieldsConfigurationGroupListEndDropData.ts delete mode 100644 packages/twenty-front/src/modules/side-panel/components/SidePanelAddToNavigationDraggablePlaceholder.tsx create mode 100644 packages/twenty-front/src/modules/ui/layout/draggable-list/constants/DraggableListEndDropIndex.ts create mode 100644 packages/twenty-front/src/modules/ui/layout/draggable-list/contexts/DraggableListGroupContext.ts create mode 100644 packages/twenty-front/src/modules/ui/layout/draggable-list/types/DraggableListDropResult.ts delete mode 100644 packages/twenty-front/src/modules/ui/layout/draggable-list/utils/getCssCompatibleDraggableProps.ts create mode 100644 packages/twenty-front/src/modules/ui/utilities/drag-and-drop/components/DragDropItemDropLine.tsx create mode 100644 packages/twenty-front/src/modules/ui/utilities/drag-and-drop/components/DragDropItemEndDropZone.tsx create mode 100644 packages/twenty-front/src/modules/ui/utilities/drag-and-drop/constants/DndKitPluginsWithoutOptimistic.ts create mode 100644 packages/twenty-front/src/modules/ui/utilities/drag-and-drop/constants/DndKitProviderPluginsWithoutDropAnimation.ts create mode 100644 packages/twenty-front/src/modules/ui/utilities/drag-and-drop/types/DragDropProviderDragEndEvent.ts create mode 100644 packages/twenty-front/src/modules/ui/utilities/drag-and-drop/types/DragDropProviderDragMoveEvent.ts create mode 100644 packages/twenty-front/src/modules/ui/utilities/drag-and-drop/types/DragDropProviderDragOverEvent.ts create mode 100644 packages/twenty-front/src/modules/ui/utilities/drag-and-drop/types/DragDropProviderDragStartEvent.ts create mode 100644 packages/twenty-front/src/modules/ui/utilities/drag-and-drop/types/DragDropProviderDropTarget.ts create mode 100644 packages/twenty-front/src/modules/ui/utilities/drag-and-drop/types/DragDropProviderProps.ts diff --git a/packages/twenty-front/jest.config.mjs b/packages/twenty-front/jest.config.mjs index 6a09f82205..98bd2e6012 100644 --- a/packages/twenty-front/jest.config.mjs +++ b/packages/twenty-front/jest.config.mjs @@ -27,8 +27,8 @@ const jestConfig = { testEnvironmentOptions: {}, transformIgnorePatterns: [ - '/node_modules/(?!(twenty-ui|apollo-upload-client|extract-files|is-plain-obj)/.*)', - '../../node_modules/(?!(twenty-ui|apollo-upload-client|extract-files|is-plain-obj)/.*)', + '/node_modules/(?!(twenty-ui|apollo-upload-client|extract-files|is-plain-obj|@preact/signals-core)/.*)', + '../../node_modules/(?!(twenty-ui|apollo-upload-client|extract-files|is-plain-obj|@preact/signals-core)/.*)', '../../twenty-ui/', ], transform: { diff --git a/packages/twenty-front/package.json b/packages/twenty-front/package.json index 5923755afb..61af16bfbe 100644 --- a/packages/twenty-front/package.json +++ b/packages/twenty-front/package.json @@ -44,7 +44,6 @@ "@fontsource/inter": "^5.2.8", "@graphiql/plugin-explorer": "^5.1.2", "@graphiql/react": "^0.37.6", - "@hello-pangea/dnd": "^18.0.1", "@hookform/resolvers": "^5.2.2", "@linaria/core": "^7.0.0", "@linaria/react": "^7.0.1", diff --git a/packages/twenty-front/setupTests.ts b/packages/twenty-front/setupTests.ts index 360355548e..05d26d9225 100644 --- a/packages/twenty-front/setupTests.ts +++ b/packages/twenty-front/setupTests.ts @@ -38,6 +38,19 @@ if (typeof window !== 'undefined') { }); } +// jsdom does not implement ResizeObserver; @dnd-kit/dom expects it at import +// time. +class ResizeObserverMock { + observe() {} + unobserve() {} + disconnect() {} +} + +if (globalThis.ResizeObserver === undefined) { + globalThis.ResizeObserver = + ResizeObserverMock as unknown as typeof ResizeObserver; +} + // Add Jest matchers for toThrowError and other missing methods declare global { namespace jest { diff --git a/packages/twenty-front/src/modules/command-menu-item/edit/components/SidePanelCommandMenuItemEditPage.tsx b/packages/twenty-front/src/modules/command-menu-item/edit/components/SidePanelCommandMenuItemEditPage.tsx index 4924aea998..53afb3e500 100644 --- a/packages/twenty-front/src/modules/command-menu-item/edit/components/SidePanelCommandMenuItemEditPage.tsx +++ b/packages/twenty-front/src/modules/command-menu-item/edit/components/SidePanelCommandMenuItemEditPage.tsx @@ -14,7 +14,7 @@ import { DraggableItem } from '@/ui/layout/draggable-list/components/DraggableIt import { DraggableList } from '@/ui/layout/draggable-list/components/DraggableList'; import { SelectableListItem } from '@/ui/layout/selectable-list/components/SelectableListItem'; import { useAtomStateValue } from '@/ui/utilities/state/jotai/hooks/useAtomStateValue'; -import { type DropResult } from '@hello-pangea/dnd'; +import { type DraggableListDropResult } from '@/ui/layout/draggable-list/types/DraggableListDropResult'; import { styled } from '@linaria/react'; import { useLingui } from '@lingui/react/macro'; import { ContextStorePageType } from 'twenty-shared/types'; @@ -145,7 +145,7 @@ export const SidePanelCommandMenuItemEditPage = () => { /> ); - const handlePinnedDragEnd = (result: DropResult) => { + const handlePinnedDragEnd = (result: DraggableListDropResult) => { const { source, destination, draggableId } = result; if (!isDefined(destination)) { diff --git a/packages/twenty-front/src/modules/navigation-menu-item/display/dnd/hooks/useNavigationMenuItemDndKit.ts b/packages/twenty-front/src/modules/navigation-menu-item/display/dnd/hooks/useNavigationMenuItemDndKit.ts index 99c540cdd1..3cdb139236 100644 --- a/packages/twenty-front/src/modules/navigation-menu-item/display/dnd/hooks/useNavigationMenuItemDndKit.ts +++ b/packages/twenty-front/src/modules/navigation-menu-item/display/dnd/hooks/useNavigationMenuItemDndKit.ts @@ -1,7 +1,6 @@ -import { type DragDropProvider } from '@dnd-kit/react'; import { isSortable } from '@dnd-kit/react/sortable'; import { useStore } from 'jotai'; -import { type ComponentProps, useCallback, useState } from 'react'; +import { useCallback, useState } from 'react'; import { isDefined } from 'twenty-shared/utils'; import { ADD_TO_NAV_SOURCE_DROPPABLE_ID } from '@/navigation-menu-item/common/constants/AddToNavSourceDroppableId'; @@ -23,22 +22,13 @@ import { resolveDropTarget } from '@/navigation-menu-item/display/dnd/utils/navi import { useNavigationMenuItemsData } from '@/navigation-menu-item/display/hooks/useNavigationMenuItemsData'; import { useSortedNavigationMenuItems } from '@/navigation-menu-item/display/hooks/useSortedNavigationMenuItems'; import { useNavigationMenuItemsDraftState } from '@/navigation-menu-item/edit/hooks/useNavigationMenuItemsDraftState'; +import { type DragDropProviderDragEndEvent } from '@/ui/utilities/drag-and-drop/types/DragDropProviderDragEndEvent'; +import { type DragDropProviderDragOverEvent } from '@/ui/utilities/drag-and-drop/types/DragDropProviderDragOverEvent'; +import { type DragDropProviderDragStartEvent } from '@/ui/utilities/drag-and-drop/types/DragDropProviderDragStartEvent'; -type DragStartPayload = Parameters< - NonNullable< - ComponentProps>['onDragStart'] - > ->[0]; -type DragOverPayload = Parameters< - NonNullable< - ComponentProps>['onDragOver'] - > ->[0]; -type DragEndPayload = Parameters< - NonNullable< - ComponentProps>['onDragEnd'] - > ->[0]; +type DragStartPayload = DragDropProviderDragStartEvent; +type DragOverPayload = DragDropProviderDragOverEvent; +type DragEndPayload = DragDropProviderDragEndEvent; export type NavigationMenuItemDndKitContextValues = { dragSource: { sourceDroppableId: string | null }; diff --git a/packages/twenty-front/src/modules/navigation-menu-item/display/dnd/providers/NavigationMenuItemDndKitProvider.tsx b/packages/twenty-front/src/modules/navigation-menu-item/display/dnd/providers/NavigationMenuItemDndKitProvider.tsx index 72e60197b9..5dc864dbc7 100644 --- a/packages/twenty-front/src/modules/navigation-menu-item/display/dnd/providers/NavigationMenuItemDndKitProvider.tsx +++ b/packages/twenty-front/src/modules/navigation-menu-item/display/dnd/providers/NavigationMenuItemDndKitProvider.tsx @@ -7,6 +7,7 @@ import { NavigationDropTargetContext } from '@/navigation-menu-item/common/conte import { NavigationMenuItemDragContext } from '@/navigation-menu-item/common/contexts/NavigationMenuItemDragContext'; import type { DraggableData } from '@/navigation-menu-item/common/types/navigationMenuItemDndKitDraggableData'; import { useNavigationMenuItemDndKit } from '@/navigation-menu-item/display/dnd/hooks/useNavigationMenuItemDndKit'; +import { DND_KIT_PROVIDER_PLUGINS_WITHOUT_DROP_ANIMATION } from '@/ui/utilities/drag-and-drop/constants/DndKitProviderPluginsWithoutDropAnimation'; import { DND_KIT_SENSORS } from '@/ui/utilities/drag-and-drop/constants/DndKitSensors'; type NavigationMenuItemDndKitProviderProps = { @@ -26,6 +27,7 @@ export const NavigationMenuItemDndKitProvider = ({ sensors={DND_KIT_SENSORS} + plugins={DND_KIT_PROVIDER_PLUGINS_WITHOUT_DROP_ANIMATION} onDragStart={handlers.onDragStart} onDragOver={handlers.onDragOver} onDragEnd={handlers.onDragEnd} diff --git a/packages/twenty-front/src/modules/object-record/object-options-dropdown/hooks/useObjectOptionsForBoard.ts b/packages/twenty-front/src/modules/object-record/object-options-dropdown/hooks/useObjectOptionsForBoard.ts index 442f1fc2d6..55c78790fd 100644 --- a/packages/twenty-front/src/modules/object-record/object-options-dropdown/hooks/useObjectOptionsForBoard.ts +++ b/packages/twenty-front/src/modules/object-record/object-options-dropdown/hooks/useObjectOptionsForBoard.ts @@ -1,4 +1,4 @@ -import { type OnDragEndResponder } from '@hello-pangea/dnd'; +import { type DraggableListDropResult } from '@/ui/layout/draggable-list/types/DraggableListDropResult'; import { useCallback, useMemo } from 'react'; import { useColumnDefinitionsFromObjectMetadata } from '@/object-metadata/hooks/useColumnDefinitionsFromObjectMetadata'; @@ -92,8 +92,8 @@ export const useObjectOptionsForBoard = ({ [availableColumnDefinitions, recordIndexFieldDefinitionsByKey], ); - const handleReorderBoardFields: OnDragEndResponder = useCallback( - (result) => { + const handleReorderBoardFields = useCallback( + (result: DraggableListDropResult) => { if (!result.destination) { return; } diff --git a/packages/twenty-front/src/modules/object-record/object-options-dropdown/hooks/useProcessOptionDropdownDragEnd.ts b/packages/twenty-front/src/modules/object-record/object-options-dropdown/hooks/useProcessOptionDropdownDragEnd.ts index d6bfe17ac0..413f792690 100644 --- a/packages/twenty-front/src/modules/object-record/object-options-dropdown/hooks/useProcessOptionDropdownDragEnd.ts +++ b/packages/twenty-front/src/modules/object-record/object-options-dropdown/hooks/useProcessOptionDropdownDragEnd.ts @@ -1,4 +1,4 @@ -import { type OnDragEndResponder } from '@hello-pangea/dnd'; +import { type DraggableListDropResult } from '@/ui/layout/draggable-list/types/DraggableListDropResult'; import { useReorderVisibleRecordFields } from '@/object-record/record-field/hooks/useReorderVisibleRecordFields'; @@ -12,8 +12,8 @@ export const useProcessOptionDropdownDragEnd = (recordTableId: string) => { const { saveViewFields } = useSaveCurrentViewFields(); - const processOptionDropdownDragEnd: OnDragEndResponder = useCallback( - async (result) => { + const processOptionDropdownDragEnd = useCallback( + async (result: DraggableListDropResult) => { if ( !result.destination || result.destination.index === 1 || diff --git a/packages/twenty-front/src/modules/object-record/object-options-dropdown/states/contexts/ObjectOptionsDropdownContext.ts b/packages/twenty-front/src/modules/object-record/object-options-dropdown/states/contexts/ObjectOptionsDropdownContext.ts index 195efe7668..89d121a9e0 100644 --- a/packages/twenty-front/src/modules/object-record/object-options-dropdown/states/contexts/ObjectOptionsDropdownContext.ts +++ b/packages/twenty-front/src/modules/object-record/object-options-dropdown/states/contexts/ObjectOptionsDropdownContext.ts @@ -1,7 +1,7 @@ import { type EnrichedObjectMetadataItem } from '@/object-metadata/types/EnrichedObjectMetadataItem'; import { type ObjectOptionsContentId } from '@/object-record/object-options-dropdown/types/ObjectOptionsContentId'; import { type ViewType } from '@/views/types/ViewType'; -import { type OnDragEndResponder } from '@hello-pangea/dnd'; +import { type DraggableListDropResult } from '@/ui/layout/draggable-list/types/DraggableListDropResult'; import { createContext } from 'react'; export type ObjectOptionsDropdownContextValue = { @@ -12,7 +12,9 @@ export type ObjectOptionsDropdownContextValue = { onContentChange: (key: ObjectOptionsContentId) => void; resetContent: () => void; dropdownId: string; - handleRecordGroupOrderChangeWithModal?: OnDragEndResponder; + handleRecordGroupOrderChangeWithModal?: ( + result: DraggableListDropResult, + ) => void; }; export const ObjectOptionsDropdownContext = diff --git a/packages/twenty-front/src/modules/object-record/record-board/record-board-column/dnd/hooks/useRecordBoardColumnDndKit.ts b/packages/twenty-front/src/modules/object-record/record-board/record-board-column/dnd/hooks/useRecordBoardColumnDndKit.ts index 69080e749b..453ae06c4e 100644 --- a/packages/twenty-front/src/modules/object-record/record-board/record-board-column/dnd/hooks/useRecordBoardColumnDndKit.ts +++ b/packages/twenty-front/src/modules/object-record/record-board/record-board-column/dnd/hooks/useRecordBoardColumnDndKit.ts @@ -1,5 +1,4 @@ -import { type DragDropProvider } from '@dnd-kit/react'; -import { type ComponentProps, useState } from 'react'; +import { useState } from 'react'; import { isDefined } from 'twenty-shared/utils'; import { RECORD_GROUP_REORDER_CONFIRMATION_MODAL_ID } from '@/object-record/record-group/constants/RecordGroupReorderConfirmationModalId'; @@ -20,22 +19,13 @@ import { useAtomComponentFamilySelectorValue } from '@/ui/utilities/state/jotai/ import { useAtomComponentState } from '@/ui/utilities/state/jotai/hooks/useAtomComponentState'; import { useAtomComponentStateValue } from '@/ui/utilities/state/jotai/hooks/useAtomComponentStateValue'; import { ViewType } from '@/views/types/ViewType'; +import { type DragDropProviderDragEndEvent } from '@/ui/utilities/drag-and-drop/types/DragDropProviderDragEndEvent'; +import { type DragDropProviderDragMoveEvent } from '@/ui/utilities/drag-and-drop/types/DragDropProviderDragMoveEvent'; +import { type DragDropProviderDragStartEvent } from '@/ui/utilities/drag-and-drop/types/DragDropProviderDragStartEvent'; -type DragStartPayload = Parameters< - NonNullable< - ComponentProps>['onDragStart'] - > ->[0]; -type DragMovePayload = Parameters< - NonNullable< - ComponentProps>['onDragMove'] - > ->[0]; -type DragEndPayload = Parameters< - NonNullable< - ComponentProps>['onDragEnd'] - > ->[0]; +type DragStartPayload = DragDropProviderDragStartEvent; +type DragMovePayload = DragDropProviderDragMoveEvent; +type DragEndPayload = DragDropProviderDragEndEvent; type PendingReorder = { fromIndex: number; diff --git a/packages/twenty-front/src/modules/object-record/record-board/record-board-column/dnd/providers/RecordBoardColumnDndKitProvider.tsx b/packages/twenty-front/src/modules/object-record/record-board/record-board-column/dnd/providers/RecordBoardColumnDndKitProvider.tsx index 3fd9e9b144..2d6dd6c5a3 100644 --- a/packages/twenty-front/src/modules/object-record/record-board/record-board-column/dnd/providers/RecordBoardColumnDndKitProvider.tsx +++ b/packages/twenty-front/src/modules/object-record/record-board/record-board-column/dnd/providers/RecordBoardColumnDndKitProvider.tsx @@ -6,6 +6,7 @@ import { useRecordBoardColumnDndKit } from '@/object-record/record-board/record- import { useAtomComponentStateValue } from '@/ui/utilities/state/jotai/hooks/useAtomComponentStateValue'; import { RecordGroupReorderConfirmationModal } from '@/object-record/record-group/components/RecordGroupReorderConfirmationModal'; import { DragDropItemDndContext } from '@/ui/utilities/drag-and-drop/context/DragDropItemDndContext'; +import { DND_KIT_PROVIDER_PLUGINS_WITHOUT_DROP_ANIMATION } from '@/ui/utilities/drag-and-drop/constants/DndKitProviderPluginsWithoutDropAnimation'; import { DND_KIT_SENSORS } from '@/ui/utilities/drag-and-drop/constants/DndKitSensors'; import { type DragDropItemData } from '@/ui/utilities/drag-and-drop/types/DragDropItemData'; @@ -28,6 +29,7 @@ export const RecordBoardColumnDndKitProvider = ({ sensors={isRecordBoardViewSettingsReadOnly ? [] : DND_KIT_SENSORS} + plugins={DND_KIT_PROVIDER_PLUGINS_WITHOUT_DROP_ANIMATION} onDragStart={handlers.onDragStart} onDragMove={handlers.onDragMove} onDragEnd={handlers.onDragEnd} diff --git a/packages/twenty-front/src/modules/object-record/record-board/record-board-dnd/hooks/useRecordBoardDndKit.ts b/packages/twenty-front/src/modules/object-record/record-board/record-board-dnd/hooks/useRecordBoardDndKit.ts index 33a438a06a..9005cce55a 100644 --- a/packages/twenty-front/src/modules/object-record/record-board/record-board-dnd/hooks/useRecordBoardDndKit.ts +++ b/packages/twenty-front/src/modules/object-record/record-board/record-board-dnd/hooks/useRecordBoardDndKit.ts @@ -1,6 +1,5 @@ -import { type DragDropProvider } from '@dnd-kit/react'; import { useStore } from 'jotai'; -import { type ComponentProps, useContext, useState } from 'react'; +import { useContext, useState } from 'react'; import { isDefined } from 'twenty-shared/utils'; import { RecordBoardContext } from '@/object-record/record-board/contexts/RecordBoardContext'; @@ -21,22 +20,13 @@ import { currentRecordSortsComponentState } from '@/object-record/record-sort/st import { useModal } from '@/ui/layout/modal/hooks/useModal'; import { useAtomComponentSelectorCallbackState } from '@/ui/utilities/state/jotai/hooks/useAtomComponentSelectorCallbackState'; import { useAtomComponentStateCallbackState } from '@/ui/utilities/state/jotai/hooks/useAtomComponentStateCallbackState'; +import { type DragDropProviderDragEndEvent } from '@/ui/utilities/drag-and-drop/types/DragDropProviderDragEndEvent'; +import { type DragDropProviderDragMoveEvent } from '@/ui/utilities/drag-and-drop/types/DragDropProviderDragMoveEvent'; +import { type DragDropProviderDragStartEvent } from '@/ui/utilities/drag-and-drop/types/DragDropProviderDragStartEvent'; -type DragStartPayload = Parameters< - NonNullable< - ComponentProps>['onDragStart'] - > ->[0]; -type DragMovePayload = Parameters< - NonNullable< - ComponentProps>['onDragMove'] - > ->[0]; -type DragEndPayload = Parameters< - NonNullable< - ComponentProps>['onDragEnd'] - > ->[0]; +type DragStartPayload = DragDropProviderDragStartEvent; +type DragMovePayload = DragDropProviderDragMoveEvent; +type DragEndPayload = DragDropProviderDragEndEvent; export type RecordBoardDndKitContextValues = { activeDropTargetIndex: number | null; diff --git a/packages/twenty-front/src/modules/object-record/record-board/record-board-dnd/providers/RecordBoardDndKitProvider.tsx b/packages/twenty-front/src/modules/object-record/record-board/record-board-dnd/providers/RecordBoardDndKitProvider.tsx index 30463b6b34..a25decd8e6 100644 --- a/packages/twenty-front/src/modules/object-record/record-board/record-board-dnd/providers/RecordBoardDndKitProvider.tsx +++ b/packages/twenty-front/src/modules/object-record/record-board/record-board-dnd/providers/RecordBoardDndKitProvider.tsx @@ -3,6 +3,7 @@ import type { ReactNode } from 'react'; import { RecordBoardCardDragOverlayContent } from '@/object-record/record-board/record-board-card/components/RecordBoardCardDragOverlayContent'; import { useRecordBoardDndKit } from '@/object-record/record-board/record-board-dnd/hooks/useRecordBoardDndKit'; +import { DND_KIT_PROVIDER_PLUGINS_WITHOUT_DROP_ANIMATION } from '@/ui/utilities/drag-and-drop/constants/DndKitProviderPluginsWithoutDropAnimation'; import { DND_KIT_SENSORS } from '@/ui/utilities/drag-and-drop/constants/DndKitSensors'; import { DragDropItemDndContext } from '@/ui/utilities/drag-and-drop/context/DragDropItemDndContext'; import { type DragDropItemData } from '@/ui/utilities/drag-and-drop/types/DragDropItemData'; @@ -20,6 +21,7 @@ export const RecordBoardDndKitProvider = ({ sensors={DND_KIT_SENSORS} + plugins={DND_KIT_PROVIDER_PLUGINS_WITHOUT_DROP_ANIMATION} onDragStart={handlers.onDragStart} onDragMove={handlers.onDragMove} onDragEnd={handlers.onDragEnd} diff --git a/packages/twenty-front/src/modules/object-record/record-calendar/month/components/RecordCalendarMonthDragDropContext.tsx b/packages/twenty-front/src/modules/object-record/record-calendar/month/components/RecordCalendarMonthDragDropContext.tsx index 897e91d0a0..6ecb69ad15 100644 --- a/packages/twenty-front/src/modules/object-record/record-calendar/month/components/RecordCalendarMonthDragDropContext.tsx +++ b/packages/twenty-front/src/modules/object-record/record-calendar/month/components/RecordCalendarMonthDragDropContext.tsx @@ -2,6 +2,7 @@ import { DragDropProvider } from '@dnd-kit/react'; import type { ReactNode } from 'react'; import { useRecordCalendarMonthDndKit } from '@/object-record/record-calendar/month/hooks/useRecordCalendarMonthDndKit'; +import { DND_KIT_PROVIDER_PLUGINS_WITHOUT_DROP_ANIMATION } from '@/ui/utilities/drag-and-drop/constants/DndKitProviderPluginsWithoutDropAnimation'; import { DND_KIT_SENSORS } from '@/ui/utilities/drag-and-drop/constants/DndKitSensors'; import { DragDropItemDndContext } from '@/ui/utilities/drag-and-drop/context/DragDropItemDndContext'; import { type DragDropItemData } from '@/ui/utilities/drag-and-drop/types/DragDropItemData'; @@ -19,6 +20,7 @@ export const RecordCalendarMonthDragDropContext = ({ sensors={DND_KIT_SENSORS} + plugins={DND_KIT_PROVIDER_PLUGINS_WITHOUT_DROP_ANIMATION} onDragStart={handlers.onDragStart} onDragMove={handlers.onDragMove} onDragEnd={handlers.onDragEnd} diff --git a/packages/twenty-front/src/modules/object-record/record-calendar/month/hooks/useRecordCalendarMonthDndKit.ts b/packages/twenty-front/src/modules/object-record/record-calendar/month/hooks/useRecordCalendarMonthDndKit.ts index fdd461e380..649957b596 100644 --- a/packages/twenty-front/src/modules/object-record/record-calendar/month/hooks/useRecordCalendarMonthDndKit.ts +++ b/packages/twenty-front/src/modules/object-record/record-calendar/month/hooks/useRecordCalendarMonthDndKit.ts @@ -1,6 +1,5 @@ -import { type DragDropProvider } from '@dnd-kit/react'; import { useStore } from 'jotai'; -import { type ComponentProps, useState } from 'react'; +import { useState } from 'react'; import { Temporal } from 'temporal-polyfill'; import { isDefined } from 'twenty-shared/utils'; @@ -14,22 +13,13 @@ import { type DragDropItemData } from '@/ui/utilities/drag-and-drop/types/DragDr import { getDestinationIndex } from '@/ui/utilities/drag-and-drop/utils/getDestinationIndex'; import { resolveDropFromPointerY } from '@/ui/utilities/drag-and-drop/utils/resolveDropFromPointerY'; import { useAtomComponentFamilySelectorCallbackState } from '@/ui/utilities/state/jotai/hooks/useAtomComponentFamilySelectorCallbackState'; +import { type DragDropProviderDragEndEvent } from '@/ui/utilities/drag-and-drop/types/DragDropProviderDragEndEvent'; +import { type DragDropProviderDragMoveEvent } from '@/ui/utilities/drag-and-drop/types/DragDropProviderDragMoveEvent'; +import { type DragDropProviderDragStartEvent } from '@/ui/utilities/drag-and-drop/types/DragDropProviderDragStartEvent'; -type DragStartPayload = Parameters< - NonNullable< - ComponentProps>['onDragStart'] - > ->[0]; -type DragMovePayload = Parameters< - NonNullable< - ComponentProps>['onDragMove'] - > ->[0]; -type DragEndPayload = Parameters< - NonNullable< - ComponentProps>['onDragEnd'] - > ->[0]; +type DragStartPayload = DragDropProviderDragStartEvent; +type DragMovePayload = DragDropProviderDragMoveEvent; +type DragEndPayload = DragDropProviderDragEndEvent; export type RecordCalendarDndKitContextValues = { activeDropTargetIndex: number | null; diff --git a/packages/twenty-front/src/modules/object-record/record-calendar/week/components/RecordCalendarWeekDragDropContext.tsx b/packages/twenty-front/src/modules/object-record/record-calendar/week/components/RecordCalendarWeekDragDropContext.tsx index 44bd18c289..9c99a8bc3b 100644 --- a/packages/twenty-front/src/modules/object-record/record-calendar/week/components/RecordCalendarWeekDragDropContext.tsx +++ b/packages/twenty-front/src/modules/object-record/record-calendar/week/components/RecordCalendarWeekDragDropContext.tsx @@ -2,33 +2,20 @@ import { useProcessRecordCalendarWeekEventDrop } from '@/object-record/record-ca import { type RecordCalendarWeekDndData } from '@/object-record/record-calendar/week/types/RecordCalendarWeekDndData'; import { resolveRecordCalendarWeekEventDrop } from '@/object-record/record-calendar/week/utils/resolveRecordCalendarWeekEventDrop'; import { useSnackBar } from '@/ui/feedback/snack-bar-manager/hooks/useSnackBar'; +import { DND_KIT_PROVIDER_PLUGINS_WITHOUT_DROP_ANIMATION } from '@/ui/utilities/drag-and-drop/constants/DndKitProviderPluginsWithoutDropAnimation'; import { DND_KIT_SENSORS } from '@/ui/utilities/drag-and-drop/constants/DndKitSensors'; import { DragDropProvider } from '@dnd-kit/react'; import { t } from '@lingui/core/macro'; -import { - type ComponentProps, - type ReactNode, - type RefObject, - useState, -} from 'react'; +import { type ReactNode, type RefObject, useState } from 'react'; import type { Temporal } from 'temporal-polyfill'; import { isDefined } from 'twenty-shared/utils'; import { logError } from '~/utils/logError'; +import { type DragDropProviderDragEndEvent } from '@/ui/utilities/drag-and-drop/types/DragDropProviderDragEndEvent'; +import { type DragDropProviderDragStartEvent } from '@/ui/utilities/drag-and-drop/types/DragDropProviderDragStartEvent'; -type DragStartPayload = Parameters< - NonNullable< - ComponentProps< - typeof DragDropProvider - >['onDragStart'] - > ->[0]; -type DragEndPayload = Parameters< - NonNullable< - ComponentProps< - typeof DragDropProvider - >['onDragEnd'] - > ->[0]; +type DragStartPayload = + DragDropProviderDragStartEvent; +type DragEndPayload = DragDropProviderDragEndEvent; type RecordCalendarWeekDragDropContextProps = { children: ReactNode; @@ -106,6 +93,7 @@ export const RecordCalendarWeekDragDropContext = ({ return ( sensors={DND_KIT_SENSORS} + plugins={DND_KIT_PROVIDER_PLUGINS_WITHOUT_DROP_ANIMATION} onDragStart={handleDragStart} onDragEnd={handleDragEnd} > diff --git a/packages/twenty-front/src/modules/object-record/record-drag/hooks/__tests__/useStartRecordDrag.test.tsx b/packages/twenty-front/src/modules/object-record/record-drag/hooks/__tests__/useStartRecordDrag.test.tsx index a68a29deb5..7e5a29de15 100644 --- a/packages/twenty-front/src/modules/object-record/record-drag/hooks/__tests__/useStartRecordDrag.test.tsx +++ b/packages/twenty-front/src/modules/object-record/record-drag/hooks/__tests__/useStartRecordDrag.test.tsx @@ -1,4 +1,3 @@ -import { type DragStart } from '@hello-pangea/dnd'; import { renderHook } from '@testing-library/react'; import { act } from 'react'; @@ -10,16 +9,6 @@ import { primaryDraggedRecordIdComponentState } from '@/object-record/record-dra import { useAtomComponentStateValue } from '@/ui/utilities/state/jotai/hooks/useAtomComponentStateValue'; import { getJestMetadataAndApolloMocksWrapper } from '~/testing/jest/getJestMetadataAndApolloMocksWrapper'; -const createDragStart = (draggableId: string, index: number): DragStart => ({ - draggableId, - type: 'record', - source: { - droppableId: 'test-droppable', - index, - }, - mode: 'FLUID', -}); - describe('useStartRecordDrag', () => { const Wrapper = getJestMetadataAndApolloMocksWrapper({}); @@ -52,11 +41,11 @@ describe('useStartRecordDrag', () => { { wrapper: Wrapper }, ); - const dragStart = createDragStart('record-1', 0); + const draggedRecordId = 'record-1'; const selectedRecordIds = ['record-2', 'record-3']; act(() => { - result.current.startRecordDrag(dragStart.draggableId, selectedRecordIds); + result.current.startRecordDrag(draggedRecordId, selectedRecordIds); }); expect(result.current.isMultiDragActive).toBe(true); @@ -94,11 +83,11 @@ describe('useStartRecordDrag', () => { { wrapper: Wrapper }, ); - const dragStart = createDragStart('record-1', 0); + const draggedRecordId = 'record-1'; const selectedRecordIds = ['record-1']; act(() => { - result.current.startRecordDrag(dragStart.draggableId, selectedRecordIds); + result.current.startRecordDrag(draggedRecordId, selectedRecordIds); }); expect(result.current.isMultiDragActive).toBe(true); @@ -136,11 +125,11 @@ describe('useStartRecordDrag', () => { { wrapper: Wrapper }, ); - const dragStart = createDragStart('record-2', 1); + const draggedRecordId = 'record-2'; const selectedRecordIds = ['record-1', 'record-2', 'record-3']; act(() => { - result.current.startRecordDrag(dragStart.draggableId, selectedRecordIds); + result.current.startRecordDrag(draggedRecordId, selectedRecordIds); }); expect(result.current.isMultiDragActive).toBe(true); @@ -186,11 +175,11 @@ describe('useStartRecordDrag', () => { { wrapper: Wrapper }, ); - const dragStart = createDragStart('record-1', 0); + const draggedRecordId = 'record-1'; const selectedRecordIds: string[] = []; act(() => { - result.current.startRecordDrag(dragStart.draggableId, selectedRecordIds); + result.current.startRecordDrag(draggedRecordId, selectedRecordIds); }); expect(result.current.isMultiDragActive).toBe(true); @@ -228,11 +217,11 @@ describe('useStartRecordDrag', () => { { wrapper: Wrapper }, ); - const dragStart = createDragStart('record-1', 0); + const draggedRecordId = 'record-1'; const selectedRecordIds = ['record-2', 'record-3']; act(() => { - result.current.startRecordDrag(dragStart.draggableId, selectedRecordIds); + result.current.startRecordDrag(draggedRecordId, selectedRecordIds); }); expect(result.current.isMultiDragActive).toBe(true); @@ -270,11 +259,11 @@ describe('useStartRecordDrag', () => { { wrapper: Wrapper }, ); - const dragStart = createDragStart('record-2', 1); + const draggedRecordId = 'record-2'; const selectedRecordIds = ['record-1', 'record-2', 'record-3']; act(() => { - result.current.startRecordDrag(dragStart.draggableId, selectedRecordIds); + result.current.startRecordDrag(draggedRecordId, selectedRecordIds); }); expect(result.current.isMultiDragActive).toBe(true); diff --git a/packages/twenty-front/src/modules/object-record/record-drag/hooks/useProcessTableWithGroupRecordDrop.ts b/packages/twenty-front/src/modules/object-record/record-drag/hooks/useProcessTableWithGroupRecordDrop.ts index 01c86ca5a4..9e50eb65bf 100644 --- a/packages/twenty-front/src/modules/object-record/record-drag/hooks/useProcessTableWithGroupRecordDrop.ts +++ b/packages/twenty-front/src/modules/object-record/record-drag/hooks/useProcessTableWithGroupRecordDrop.ts @@ -1,10 +1,10 @@ -import { type DropResult } from '@hello-pangea/dnd'; import { useStore } from 'jotai'; import { useCallback } from 'react'; import { isDefined } from 'twenty-shared/utils'; import { useUpdateOneRecord } from '@/object-record/hooks/useUpdateOneRecord'; import { isDraggingRecordComponentState } from '@/object-record/record-drag/states/isDraggingRecordComponentState'; +import { type RecordDragDropResult } from '@/object-record/record-drag/types/RecordDragDropResult'; import { originalDragSelectionComponentState } from '@/object-record/record-drag/states/originalDragSelectionComponentState'; import { processGroupDrop } from '@/object-record/record-drag/utils/processGroupDrop'; import { recordGroupDefinitionFamilyState } from '@/object-record/record-group/states/recordGroupDefinitionFamilyState'; @@ -61,7 +61,7 @@ export const useProcessTableWithGroupRecordDrop = () => { ); const processTableWithGroupRecordDrop = useCallback( - (result: DropResult) => { + (result: RecordDragDropResult) => { if (!result.destination) return; const destinationRecordGroupId = result.destination.droppableId; diff --git a/packages/twenty-front/src/modules/object-record/record-drag/hooks/useProcessTableWithoutGroupRecordDrop.ts b/packages/twenty-front/src/modules/object-record/record-drag/hooks/useProcessTableWithoutGroupRecordDrop.ts index 9c3b9434fa..8cb0a4eaf3 100644 --- a/packages/twenty-front/src/modules/object-record/record-drag/hooks/useProcessTableWithoutGroupRecordDrop.ts +++ b/packages/twenty-front/src/modules/object-record/record-drag/hooks/useProcessTableWithoutGroupRecordDrop.ts @@ -1,8 +1,7 @@ -import { type DropResult } from '@hello-pangea/dnd'; - import { useUpdateOneRecord } from '@/object-record/hooks/useUpdateOneRecord'; import { useTriggerTableWithoutGroupDragAndDropOptimisticUpdate } from '@/object-record/record-drag/hooks/useTriggerTableWithoutGroupDragAndDropOptimisticUpdate'; +import { type RecordDragDropResult } from '@/object-record/record-drag/types/RecordDragDropResult'; import { originalDragSelectionComponentState } from '@/object-record/record-drag/states/originalDragSelectionComponentState'; import { getDragOperationType } from '@/object-record/record-drag/utils/getDragOperationType'; import { processMultiDrag } from '@/object-record/record-drag/utils/processMultiDrag'; @@ -54,7 +53,7 @@ export const useProcessTableWithoutGroupRecordDrop = () => { useTriggerTableWithoutGroupDragAndDropOptimisticUpdate(); const processTableWithoutGroupRecordDrop = useCallback( - async (tableRecordDropResult: DropResult) => { + async (tableRecordDropResult: RecordDragDropResult) => { if (!tableRecordDropResult.destination) return; if (currentRecordSorts.length > 0) { diff --git a/packages/twenty-front/src/modules/object-record/record-drag/types/RecordDragDropResult.ts b/packages/twenty-front/src/modules/object-record/record-drag/types/RecordDragDropResult.ts new file mode 100644 index 0000000000..c67b07f97f --- /dev/null +++ b/packages/twenty-front/src/modules/object-record/record-drag/types/RecordDragDropResult.ts @@ -0,0 +1,11 @@ +export type RecordDragDropResult = { + draggableId: string; + source: { + droppableId: string; + index: number; + }; + destination: { + droppableId: string; + index: number; + } | null; +}; diff --git a/packages/twenty-front/src/modules/object-record/record-group/components/RecordGroupsVisibilityDropdownSection.tsx b/packages/twenty-front/src/modules/object-record/record-group/components/RecordGroupsVisibilityDropdownSection.tsx index 3011f47f33..730b383101 100644 --- a/packages/twenty-front/src/modules/object-record/record-group/components/RecordGroupsVisibilityDropdownSection.tsx +++ b/packages/twenty-front/src/modules/object-record/record-group/components/RecordGroupsVisibilityDropdownSection.tsx @@ -1,8 +1,4 @@ -import { - type DropResult, - type OnDragEndResponder, - type ResponderProvided, -} from '@hello-pangea/dnd'; +import { type DraggableListDropResult } from '@/ui/layout/draggable-list/types/DraggableListDropResult'; import { useRef } from 'react'; import { RecordGroupMenuItemDraggable } from '@/object-record/record-group/components/RecordGroupMenuItemDraggable'; @@ -15,7 +11,7 @@ import { StyledDropdownMenuSubheader } from '@/ui/layout/dropdown/components/Sty type RecordGroupsVisibilityDropdownSectionProps = { recordGroupIds: string[]; isDraggable: boolean; - onDragEnd?: OnDragEndResponder; + onDragEnd?: (result: DraggableListDropResult) => void; onVisibilityChange: (recordGroup: RecordGroupDefinition) => void; title: string; showSubheader?: boolean; @@ -33,8 +29,8 @@ export const RecordGroupsVisibilityDropdownSection = ({ showDragGrip, isVisibleLimitReached = false, }: RecordGroupsVisibilityDropdownSectionProps) => { - const handleOnDrag = (result: DropResult, provided: ResponderProvided) => { - onDragEnd?.(result, provided); + const handleOnDrag = (result: DraggableListDropResult) => { + onDragEnd?.(result); }; const ref = useRef(null); diff --git a/packages/twenty-front/src/modules/object-record/record-group/hooks/useRecordGroupReorderConfirmationModal.ts b/packages/twenty-front/src/modules/object-record/record-group/hooks/useRecordGroupReorderConfirmationModal.ts index b5ee2eca70..59f59e6daa 100644 --- a/packages/twenty-front/src/modules/object-record/record-group/hooks/useRecordGroupReorderConfirmationModal.ts +++ b/packages/twenty-front/src/modules/object-record/record-group/hooks/useRecordGroupReorderConfirmationModal.ts @@ -10,7 +10,7 @@ import { useModal } from '@/ui/layout/modal/hooks/useModal'; import { useAtomComponentState } from '@/ui/utilities/state/jotai/hooks/useAtomComponentState'; import { useAtomComponentSelectorValue } from '@/ui/utilities/state/jotai/hooks/useAtomComponentSelectorValue'; import { type ViewType } from '@/views/types/ViewType'; -import { type OnDragEndResponder } from '@hello-pangea/dnd'; +import { type DraggableListDropResult } from '@/ui/layout/draggable-list/types/DraggableListDropResult'; import { useState } from 'react'; type UseRecordGroupReorderConfirmationModalParams = { @@ -30,14 +30,14 @@ export const useRecordGroupReorderConfirmationModal = ({ const { openModal } = useModal(); const [pendingDragEndHandlerParams, setPendingDragEndHandlerParams] = - useState | null>(null); + useState(null); const { reorderRecordGroups } = useReorderRecordGroups({ recordIndexId, viewType, }); - const handleDragEnd: OnDragEndResponder = (result) => { + const handleDragEnd = (result: DraggableListDropResult) => { if (!result.destination) { return; } @@ -57,14 +57,14 @@ export const useRecordGroupReorderConfirmationModal = ({ ); const { closeAnyOpenDropdown } = useCloseAnyOpenDropdown(); - const handleDragEndWithModal: OnDragEndResponder = (result, provided) => { + const handleDragEndWithModal = (result: DraggableListDropResult) => { if (!isDragableSortRecordGroup) { closeAnyOpenDropdown(); openModal(RECORD_GROUP_REORDER_CONFIRMATION_MODAL_ID); setActiveDropdownFocusIdAndMemorizePrevious(null); - setPendingDragEndHandlerParams([result, provided]); + setPendingDragEndHandlerParams(result); } else { - handleDragEnd(result, provided); + handleDragEnd(result); } }; @@ -75,7 +75,7 @@ export const useRecordGroupReorderConfirmationModal = ({ setRecordIndexRecordGroupSort(RecordGroupSort.Manual); setPendingDragEndHandlerParams(null); - handleDragEnd(...pendingDragEndHandlerParams); + handleDragEnd(pendingDragEndHandlerParams); goBackToPreviousDropdownFocusId(); }; diff --git a/packages/twenty-front/src/modules/object-record/record-group/states/recordGroupPendingDragEndReorderState.ts b/packages/twenty-front/src/modules/object-record/record-group/states/recordGroupPendingDragEndReorderState.ts deleted file mode 100644 index 10d3a4b13c..0000000000 --- a/packages/twenty-front/src/modules/object-record/record-group/states/recordGroupPendingDragEndReorderState.ts +++ /dev/null @@ -1,9 +0,0 @@ -import { type OnDragEndResponder } from '@hello-pangea/dnd'; - -import { createAtomState } from '@/ui/utilities/state/jotai/utils/createAtomState'; - -export const recordGroupPendingDragEndReorderState = - createAtomState | null>({ - key: 'recordGroupPendingDragEndReorderState', - defaultValue: null, - }); diff --git a/packages/twenty-front/src/modules/object-record/record-table/components/RecordTableNoRecordGroupRows.tsx b/packages/twenty-front/src/modules/object-record/record-table/components/RecordTableNoRecordGroupRows.tsx index af71daa192..95f3e9f59c 100644 --- a/packages/twenty-front/src/modules/object-record/record-table/components/RecordTableNoRecordGroupRows.tsx +++ b/packages/twenty-front/src/modules/object-record/record-table/components/RecordTableNoRecordGroupRows.tsx @@ -1,9 +1,12 @@ import { RecordTableNoRecordGroupAddNew } from '@/object-record/record-table/components/RecordTableNoRecordGroupAddNew'; +import { RECORD_TABLE_NO_RECORD_GROUP_DROPPABLE_ID } from '@/object-record/record-table/constants/RecordTableNoRecordGroupDroppableId'; +import { RECORD_TABLE_ROW_DND_TYPE } from '@/object-record/record-table/constants/RecordTableRowDndType'; import { RecordTableRowVirtualizedContainer } from '@/object-record/record-table/virtualization/components/RecordTableRowVirtualizedContainer'; import { RecordTableVirtualizedBodyPlaceholder } from '@/object-record/record-table/virtualization/components/RecordTableVirtualizedBodyPlaceholder'; import { RecordTableVirtualizedDebugHelper } from '@/object-record/record-table/virtualization/components/RecordTableVirtualizedDebugHelper'; import { NUMBER_OF_VIRTUALIZED_ROWS } from '@/object-record/record-table/virtualization/constants/NumberOfVirtualizedRows'; import { totalNumberOfRecordsToVirtualizeComponentState } from '@/object-record/record-table/virtualization/states/totalNumberOfRecordsToVirtualizeComponentState'; +import { DragDropItemEndDropZone } from '@/ui/utilities/drag-and-drop/components/DragDropItemEndDropZone'; import { useAtomComponentStateValue } from '@/ui/utilities/state/jotai/hooks/useAtomComponentStateValue'; import { styled } from '@linaria/react'; import { getContiguousIncrementalValues } from 'twenty-shared/utils'; @@ -38,7 +41,16 @@ export const RecordTableNoRecordGroupRows = () => { /> ); })} - + + + ); diff --git a/packages/twenty-front/src/modules/object-record/record-table/components/RecordTableRecordGroupRows.tsx b/packages/twenty-front/src/modules/object-record/record-table/components/RecordTableRecordGroupRows.tsx index 7d122c557d..49106982ce 100644 --- a/packages/twenty-front/src/modules/object-record/record-table/components/RecordTableRecordGroupRows.tsx +++ b/packages/twenty-front/src/modules/object-record/record-table/components/RecordTableRecordGroupRows.tsx @@ -2,17 +2,23 @@ import { useCurrentRecordGroupId } from '@/object-record/record-group/hooks/useC import { useShouldHideRecordGroup } from '@/object-record/record-group/hooks/useShouldHideRecordGroup'; import { recordIndexRecordIdsByGroupComponentFamilyState } from '@/object-record/record-index/states/recordIndexRecordIdsByGroupComponentFamilyState'; import { recordIndexAllRecordIdsComponentSelector } from '@/object-record/record-index/states/selectors/recordIndexAllRecordIdsComponentSelector'; -import { RecordTableBodyDroppablePlaceholder } from '@/object-record/record-table/record-table-body/components/RecordTableBodyDroppablePlaceholder'; +import { RECORD_TABLE_ROW_DND_TYPE } from '@/object-record/record-table/constants/RecordTableRowDndType'; import { RecordTableAggregateFooter } from '@/object-record/record-table/record-table-footer/components/RecordTableAggregateFooter'; import { RecordTableRow } from '@/object-record/record-table/record-table-row/components/RecordTableRow'; import { RecordTableRecordGroupSectionAddNew } from '@/object-record/record-table/record-table-section/components/RecordTableRecordGroupSectionAddNew'; import { RecordTableRecordGroupSectionLoadMore } from '@/object-record/record-table/record-table-section/components/RecordTableRecordGroupSectionLoadMore'; import { isRecordGroupTableSectionToggledComponentState } from '@/object-record/record-table/record-table-section/states/isRecordGroupTableSectionToggledComponentState'; +import { DragDropItemEndDropZone } from '@/ui/utilities/drag-and-drop/components/DragDropItemEndDropZone'; import { useAtomComponentFamilyStateValue } from '@/ui/utilities/state/jotai/hooks/useAtomComponentFamilyStateValue'; import { useAtomComponentSelectorValue } from '@/ui/utilities/state/jotai/hooks/useAtomComponentSelectorValue'; +import { styled } from '@linaria/react'; import { useMemo } from 'react'; import { isDefined } from 'twenty-shared/utils'; +const StyledRecordGroupEndDropZone = styled(DragDropItemEndDropZone)` + width: 100%; +`; + export const RecordTableRecordGroupRows = () => { const currentRecordGroupId = useCurrentRecordGroupId(); @@ -63,9 +69,17 @@ export const RecordTableRecordGroupRows = () => { /> ); })} - - - + + + + { - const { droppablePlaceholder } = useRecordTableBodyDroppableContextOrThrow(); - - return droppablePlaceholder; -}; diff --git a/packages/twenty-front/src/modules/object-record/record-table/record-table-body/components/RecordTableBodyLoading.tsx b/packages/twenty-front/src/modules/object-record/record-table/record-table-body/components/RecordTableBodyLoading.tsx index 0d46b1535a..5b8afd9f04 100644 --- a/packages/twenty-front/src/modules/object-record/record-table/record-table-body/components/RecordTableBodyLoading.tsx +++ b/packages/twenty-front/src/modules/object-record/record-table/record-table-body/components/RecordTableBodyLoading.tsx @@ -38,7 +38,6 @@ export const RecordTableBodyLoading = () => { > diff --git a/packages/twenty-front/src/modules/object-record/record-table/record-table-body/components/RecordTableBodyNoRecordGroupDragDropContextProvider.tsx b/packages/twenty-front/src/modules/object-record/record-table/record-table-body/components/RecordTableBodyNoRecordGroupDragDropContextProvider.tsx index b13a609344..959e639e62 100644 --- a/packages/twenty-front/src/modules/object-record/record-table/record-table-body/components/RecordTableBodyNoRecordGroupDragDropContextProvider.tsx +++ b/packages/twenty-front/src/modules/object-record/record-table/record-table-body/components/RecordTableBodyNoRecordGroupDragDropContextProvider.tsx @@ -1,19 +1,23 @@ -import { - DragDropContext, - type DragStart, - type DropResult, -} from '@hello-pangea/dnd'; -import { type ReactNode, useCallback } from 'react'; - -import { useRecordIndexContextOrThrow } from '@/object-record/record-index/contexts/RecordIndexContext'; -import { useRecordTableContextOrThrow } from '@/object-record/record-table/contexts/RecordTableContext'; -import { useAtomComponentSelectorCallbackState } from '@/ui/utilities/state/jotai/hooks/useAtomComponentSelectorCallbackState'; +import { DragDropProvider, DragOverlay } from '@dnd-kit/react'; import { useStore } from 'jotai'; +import { type ReactNode, useCallback } from 'react'; +import { isDefined } from 'twenty-shared/utils'; import { useEndRecordDrag } from '@/object-record/record-drag/hooks/useEndRecordDrag'; import { useProcessTableWithoutGroupRecordDrop } from '@/object-record/record-drag/hooks/useProcessTableWithoutGroupRecordDrop'; import { useStartRecordDrag } from '@/object-record/record-drag/hooks/useStartRecordDrag'; +import { useRecordIndexContextOrThrow } from '@/object-record/record-index/contexts/RecordIndexContext'; +import { useRecordTableContextOrThrow } from '@/object-record/record-table/contexts/RecordTableContext'; +import { RecordTableRowDragOverlayContent } from '@/object-record/record-table/record-table-row/components/RecordTableRowDragOverlayContent'; import { selectedRowIdsComponentSelector } from '@/object-record/record-table/states/selectors/selectedRowIdsComponentSelector'; +import { type RecordTableRowDragData } from '@/object-record/record-table/types/RecordTableRowDragData'; +import { DND_KIT_PROVIDER_PLUGINS_WITHOUT_DROP_ANIMATION } from '@/ui/utilities/drag-and-drop/constants/DndKitProviderPluginsWithoutDropAnimation'; +import { DND_KIT_SENSORS } from '@/ui/utilities/drag-and-drop/constants/DndKitSensors'; +import { type DragDropItemData } from '@/ui/utilities/drag-and-drop/types/DragDropItemData'; +import { type DragDropProviderDragEndEvent } from '@/ui/utilities/drag-and-drop/types/DragDropProviderDragEndEvent'; +import { type DragDropProviderDragStartEvent } from '@/ui/utilities/drag-and-drop/types/DragDropProviderDragStartEvent'; +import { getDestinationIndex } from '@/ui/utilities/drag-and-drop/utils/getDestinationIndex'; +import { useAtomComponentSelectorCallbackState } from '@/ui/utilities/state/jotai/hooks/useAtomComponentSelectorCallbackState'; export const RecordTableBodyNoRecordGroupDragDropContextProvider = ({ children, @@ -36,25 +40,83 @@ export const RecordTableBodyNoRecordGroupDragDropContextProvider = ({ useProcessTableWithoutGroupRecordDrop(); const handleDragStart = useCallback( - (start: DragStart) => { + (event: DragDropProviderDragStartEvent) => { + const source = event.operation.source; + const sourceData = source?.data as RecordTableRowDragData | undefined; + + if (!isDefined(source) || !isDefined(sourceData)) { + return; + } + const currentSelectedRecordIds = store.get(selectedRowIds) as string[]; - startRecordDrag(start.draggableId, currentSelectedRecordIds); + startRecordDrag(sourceData.recordId, currentSelectedRecordIds); }, [selectedRowIds, startRecordDrag, store], ); const handleDragEnd = useCallback( - (result: DropResult) => { - processTableWithoutGroupRecordDrop(result); - endRecordDrag(); + (event: DragDropProviderDragEndEvent) => { + const source = event.operation.source; + const sourceData = source?.data as RecordTableRowDragData | undefined; + const targetData = event.operation.target?.data as + | DragDropItemData + | undefined; + + if ( + event.canceled || + !isDefined(source) || + !isDefined(sourceData) || + !isDefined(targetData) + ) { + endRecordDrag(); + return; + } + + // Row targets and end drop zones mark the gap before them; convert that + // gap into the index the dragged row will occupy after the move. + const destinationIndex = getDestinationIndex({ + dropTargetIndex: targetData.index, + sourceIndex: sourceData.index, + sourceDroppableId: sourceData.droppableId, + destinationDroppableId: targetData.droppableId, + }); + + if (destinationIndex === sourceData.index) { + endRecordDrag(); + return; + } + + try { + processTableWithoutGroupRecordDrop({ + draggableId: sourceData.recordId, + source: { + droppableId: sourceData.droppableId, + index: sourceData.index, + }, + destination: { + droppableId: targetData.droppableId, + index: destinationIndex, + }, + }); + } finally { + endRecordDrag(); + } }, [endRecordDrag, processTableWithoutGroupRecordDrop], ); return ( - + + sensors={DND_KIT_SENSORS} + plugins={DND_KIT_PROVIDER_PLUGINS_WITHOUT_DROP_ANIMATION} + onDragStart={handleDragStart} + onDragEnd={handleDragEnd} + > {children} - + + {(source) => } + + ); }; diff --git a/packages/twenty-front/src/modules/object-record/record-table/record-table-body/components/RecordTableBodyNoRecordGroupDroppable.tsx b/packages/twenty-front/src/modules/object-record/record-table/record-table-body/components/RecordTableBodyNoRecordGroupDroppable.tsx deleted file mode 100644 index ebcc5c7f13..0000000000 --- a/packages/twenty-front/src/modules/object-record/record-table/record-table-body/components/RecordTableBodyNoRecordGroupDroppable.tsx +++ /dev/null @@ -1,47 +0,0 @@ -import { RecordTableBody } from '@/object-record/record-table/record-table-body/components/RecordTableBody'; -import { RecordTableBodyVirtualizedDraggableClone } from '@/object-record/record-table/record-table-body/components/RecordTableBodyVirtualizedDraggableClone'; -import { RecordTableBodyDroppableContextProvider } from '@/object-record/record-table/record-table-body/contexts/RecordTableBodyDroppableContext'; -import { Droppable } from '@hello-pangea/dnd'; -import { type ReactNode, useState } from 'react'; -import { v4 } from 'uuid'; - -type RecordTableBodyNoRecordGroupDroppableProps = { - children: ReactNode; - isDropDisabled?: boolean; -}; - -export const RecordTableBodyNoRecordGroupDroppable = ({ - children, - isDropDisabled, -}: RecordTableBodyNoRecordGroupDroppableProps) => { - const [v4Persistable] = useState(v4()); - - return ( - ( - - )} - > - {(provided) => ( - - - {children} - - - )} - - ); -}; diff --git a/packages/twenty-front/src/modules/object-record/record-table/record-table-body/components/RecordTableBodyRecordGroupDragDropContextProvider.tsx b/packages/twenty-front/src/modules/object-record/record-table/record-table-body/components/RecordTableBodyRecordGroupDragDropContextProvider.tsx index 729128a18d..1f7aa44582 100644 --- a/packages/twenty-front/src/modules/object-record/record-table/record-table-body/components/RecordTableBodyRecordGroupDragDropContextProvider.tsx +++ b/packages/twenty-front/src/modules/object-record/record-table/record-table-body/components/RecordTableBodyRecordGroupDragDropContextProvider.tsx @@ -1,17 +1,23 @@ +import { DragDropProvider, DragOverlay } from '@dnd-kit/react'; +import { useStore } from 'jotai'; +import { type ReactNode, useCallback } from 'react'; +import { isDefined } from 'twenty-shared/utils'; + import { useEndRecordDrag } from '@/object-record/record-drag/hooks/useEndRecordDrag'; import { useProcessTableWithGroupRecordDrop } from '@/object-record/record-drag/hooks/useProcessTableWithGroupRecordDrop'; import { useStartRecordDrag } from '@/object-record/record-drag/hooks/useStartRecordDrag'; import { useRecordIndexContextOrThrow } from '@/object-record/record-index/contexts/RecordIndexContext'; import { useRecordTableContextOrThrow } from '@/object-record/record-table/contexts/RecordTableContext'; +import { RecordTableRowDragOverlayContent } from '@/object-record/record-table/record-table-row/components/RecordTableRowDragOverlayContent'; import { selectedRowIdsComponentSelector } from '@/object-record/record-table/states/selectors/selectedRowIdsComponentSelector'; +import { type RecordTableRowDragData } from '@/object-record/record-table/types/RecordTableRowDragData'; +import { DND_KIT_PROVIDER_PLUGINS_WITHOUT_DROP_ANIMATION } from '@/ui/utilities/drag-and-drop/constants/DndKitProviderPluginsWithoutDropAnimation'; +import { DND_KIT_SENSORS } from '@/ui/utilities/drag-and-drop/constants/DndKitSensors'; +import { type DragDropItemData } from '@/ui/utilities/drag-and-drop/types/DragDropItemData'; +import { type DragDropProviderDragEndEvent } from '@/ui/utilities/drag-and-drop/types/DragDropProviderDragEndEvent'; +import { type DragDropProviderDragStartEvent } from '@/ui/utilities/drag-and-drop/types/DragDropProviderDragStartEvent'; +import { getDestinationIndex } from '@/ui/utilities/drag-and-drop/utils/getDestinationIndex'; import { useAtomComponentSelectorCallbackState } from '@/ui/utilities/state/jotai/hooks/useAtomComponentSelectorCallbackState'; -import { useStore } from 'jotai'; -import { - DragDropContext, - type DragStart, - type DropResult, -} from '@hello-pangea/dnd'; -import { type ReactNode, useCallback } from 'react'; export const RecordTableBodyRecordGroupDragDropContextProvider = ({ children, @@ -35,26 +41,86 @@ export const RecordTableBodyRecordGroupDragDropContextProvider = ({ useProcessTableWithGroupRecordDrop(); const handleDragStart = useCallback( - (start: DragStart) => { + (event: DragDropProviderDragStartEvent) => { + const source = event.operation.source; + const sourceData = source?.data as RecordTableRowDragData | undefined; + + if (!isDefined(source) || !isDefined(sourceData)) { + return; + } + const currentSelectedRecordIds = store.get(selectedRowIds) as string[]; - startRecordDrag(start.draggableId, currentSelectedRecordIds); + startRecordDrag(sourceData.recordId, currentSelectedRecordIds); }, [selectedRowIds, startRecordDrag, store], ); const handleDragEnd = useCallback( - (result: DropResult) => { - processTableWithGroupRecordDrop(result); + (event: DragDropProviderDragEndEvent) => { + const source = event.operation.source; + const sourceData = source?.data as RecordTableRowDragData | undefined; + const targetData = event.operation.target?.data as + | DragDropItemData + | undefined; - endRecordDrag(); + if ( + event.canceled || + !isDefined(source) || + !isDefined(sourceData) || + !isDefined(targetData) + ) { + endRecordDrag(); + return; + } + + // Row targets and end drop zones mark the gap before them; convert that + // gap into the index the dragged row will occupy after the move. + const destinationIndex = getDestinationIndex({ + dropTargetIndex: targetData.index, + sourceIndex: sourceData.index, + sourceDroppableId: sourceData.droppableId, + destinationDroppableId: targetData.droppableId, + }); + + const isSameRecordGroup = + sourceData.droppableId === targetData.droppableId; + + if (isSameRecordGroup && destinationIndex === sourceData.index) { + endRecordDrag(); + return; + } + + try { + processTableWithGroupRecordDrop({ + draggableId: sourceData.recordId, + source: { + droppableId: sourceData.droppableId, + index: sourceData.index, + }, + destination: { + droppableId: targetData.droppableId, + index: destinationIndex, + }, + }); + } finally { + endRecordDrag(); + } }, [endRecordDrag, processTableWithGroupRecordDrop], ); return ( - + + sensors={DND_KIT_SENSORS} + plugins={DND_KIT_PROVIDER_PLUGINS_WITHOUT_DROP_ANIMATION} + onDragStart={handleDragStart} + onDragEnd={handleDragEnd} + > {children} - + + {(source) => } + + ); }; diff --git a/packages/twenty-front/src/modules/object-record/record-table/record-table-body/components/RecordTableBodyRecordGroupDroppable.tsx b/packages/twenty-front/src/modules/object-record/record-table/record-table-body/components/RecordTableBodyRecordGroupDroppable.tsx deleted file mode 100644 index 0ba70f0705..0000000000 --- a/packages/twenty-front/src/modules/object-record/record-table/record-table-body/components/RecordTableBodyRecordGroupDroppable.tsx +++ /dev/null @@ -1,39 +0,0 @@ -import { RecordTableBody } from '@/object-record/record-table/record-table-body/components/RecordTableBody'; -import { RecordTableBodyDroppableContextProvider } from '@/object-record/record-table/record-table-body/contexts/RecordTableBodyDroppableContext'; -import { Droppable } from '@hello-pangea/dnd'; -import { type ReactNode } from 'react'; - -type RecordTableBodyRecordGroupDroppableProps = { - children: ReactNode; - recordGroupId: string; - isDropDisabled?: boolean; -}; - -export const RecordTableBodyRecordGroupDroppable = ({ - children, - recordGroupId, - isDropDisabled, -}: RecordTableBodyRecordGroupDroppableProps) => { - return ( - - {(provided) => ( - - - {children} - - - )} - - ); -}; diff --git a/packages/twenty-front/src/modules/object-record/record-table/record-table-body/components/RecordTableNoRecordGroupBody.tsx b/packages/twenty-front/src/modules/object-record/record-table/record-table-body/components/RecordTableNoRecordGroupBody.tsx index afae3713ce..870a573113 100644 --- a/packages/twenty-front/src/modules/object-record/record-table/record-table-body/components/RecordTableNoRecordGroupBody.tsx +++ b/packages/twenty-front/src/modules/object-record/record-table/record-table-body/components/RecordTableNoRecordGroupBody.tsx @@ -2,9 +2,9 @@ import { recordIndexHasRecordsComponentSelector } from '@/object-record/record-i import { RecordTableNoRecordGroupBodyContextProvider } from '@/object-record/record-table/components/RecordTableNoRecordGroupBodyContextProvider'; import { RecordTableNoRecordGroupRows } from '@/object-record/record-table/components/RecordTableNoRecordGroupRows'; +import { RecordTableBody } from '@/object-record/record-table/record-table-body/components/RecordTableBody'; import { RecordTableBodyLoading } from '@/object-record/record-table/record-table-body/components/RecordTableBodyLoading'; import { RecordTableBodyNoRecordGroupDragDropContextProvider } from '@/object-record/record-table/record-table-body/components/RecordTableBodyNoRecordGroupDragDropContextProvider'; -import { RecordTableBodyNoRecordGroupDroppable } from '@/object-record/record-table/record-table-body/components/RecordTableBodyNoRecordGroupDroppable'; import { RecordTableCellPortals } from '@/object-record/record-table/record-table-cell/components/RecordTableCellPortals'; import { RecordTableAggregateFooter } from '@/object-record/record-table/record-table-footer/components/RecordTableAggregateFooter'; import { isRecordTableInitialLoadingComponentState } from '@/object-record/record-table/states/isRecordTableInitialLoadingComponentState'; @@ -30,10 +30,10 @@ export const RecordTableNoRecordGroupBody = () => { return ( - + - + {!isRecordTableInitialLoading && recordTableHasRecords && ( )} diff --git a/packages/twenty-front/src/modules/object-record/record-table/record-table-body/components/RecordTableRecordGroupsBody.tsx b/packages/twenty-front/src/modules/object-record/record-table/record-table-body/components/RecordTableRecordGroupsBody.tsx index b584e4a526..4247ab3228 100644 --- a/packages/twenty-front/src/modules/object-record/record-table/record-table-body/components/RecordTableRecordGroupsBody.tsx +++ b/packages/twenty-front/src/modules/object-record/record-table/record-table-body/components/RecordTableRecordGroupsBody.tsx @@ -4,9 +4,9 @@ import { RecordIndexGroupAggregatesDataLoader } from '@/object-record/record-ind import { recordIndexAllRecordIdsComponentSelector } from '@/object-record/record-index/states/selectors/recordIndexAllRecordIdsComponentSelector'; import { RecordTableRecordGroupBodyContextProvider } from '@/object-record/record-table/components/RecordTableRecordGroupBodyContextProvider'; import { RecordTableRecordGroupRows } from '@/object-record/record-table/components/RecordTableRecordGroupRows'; +import { RecordTableBody } from '@/object-record/record-table/record-table-body/components/RecordTableBody'; import { RecordTableBodyLoading } from '@/object-record/record-table/record-table-body/components/RecordTableBodyLoading'; import { RecordTableBodyRecordGroupDragDropContextProvider } from '@/object-record/record-table/record-table-body/components/RecordTableBodyRecordGroupDragDropContextProvider'; -import { RecordTableBodyRecordGroupDroppable } from '@/object-record/record-table/record-table-body/components/RecordTableBodyRecordGroupDroppable'; import { RecordTableCellPortals } from '@/object-record/record-table/record-table-cell/components/RecordTableCellPortals'; import { RecordTableRecordGroupAddNewGroup } from '@/object-record/record-table/record-table-section/components/RecordTableRecordGroupAddNewGroup'; import { RecordTableRecordGroupSection } from '@/object-record/record-table/record-table-section/components/RecordTableRecordGroupSection'; @@ -43,13 +43,11 @@ export const RecordTableRecordGroupsBody = () => { recordGroupId={recordGroupId} > - + {index === 0 && } - + ))} diff --git a/packages/twenty-front/src/modules/object-record/record-table/record-table-body/contexts/RecordTableBodyDroppableContext.ts b/packages/twenty-front/src/modules/object-record/record-table/record-table-body/contexts/RecordTableBodyDroppableContext.ts deleted file mode 100644 index 06dd1be1c4..0000000000 --- a/packages/twenty-front/src/modules/object-record/record-table/record-table-body/contexts/RecordTableBodyDroppableContext.ts +++ /dev/null @@ -1,13 +0,0 @@ -import { type ReactNode } from 'react'; -import { createRequiredContext } from '~/utils/createRequiredContext'; - -export type RecordTableBodyDroppableContextValue = { - droppablePlaceholder: ReactNode; -}; - -export const [ - RecordTableBodyDroppableContextProvider, - useRecordTableBodyDroppableContextOrThrow, -] = createRequiredContext( - 'RecordTableBodyDroppableContext', -); diff --git a/packages/twenty-front/src/modules/object-record/record-table/record-table-cell/components/RecordTableCellDragAndDrop.tsx b/packages/twenty-front/src/modules/object-record/record-table/record-table-cell/components/RecordTableCellDragAndDrop.tsx index 4644b26f00..79c6ab9c0b 100644 --- a/packages/twenty-front/src/modules/object-record/record-table/record-table-cell/components/RecordTableCellDragAndDrop.tsx +++ b/packages/twenty-front/src/modules/object-record/record-table/record-table-cell/components/RecordTableCellDragAndDrop.tsx @@ -1,4 +1,5 @@ import { styled } from '@linaria/react'; +import { useContext } from 'react'; import { RECORD_TABLE_COLUMN_DRAG_AND_DROP_WIDTH_CLASS_NAME } from '@/object-record/record-table/constants/RecordTableColumnDragAndDropWidthClassName'; import { themeCssVariables } from 'twenty-ui/theme-constants'; @@ -6,6 +7,7 @@ import { RECORD_TABLE_ROW_HEIGHT } from '@/object-record/record-table/constants/ import { TABLE_Z_INDEX } from '@/object-record/record-table/constants/TableZIndex'; import { useRecordTableRowDraggableContextOrThrow } from '@/object-record/record-table/contexts/RecordTableRowDraggableContext'; import { RecordTableCellStyleWrapper } from '@/object-record/record-table/record-table-cell/components/RecordTableCellStyleWrapper'; +import { DragDropItemSortableHandleRefContext } from '@/ui/utilities/drag-and-drop/context/DragDropItemSortableHandleRefContext'; import { IconListViewGrip } from 'twenty-ui/input'; const StyledContainer = styled.div` @@ -30,19 +32,18 @@ const StyledIconWrapper = styled.div<{ isDragging: boolean }>` `; export const RecordTableCellDragAndDrop = () => { - const { dragHandleProps, isDragging } = - useRecordTableRowDraggableContextOrThrow(); + const { isDragging } = useRecordTableRowDraggableContextOrThrow(); + + const sortableHandleRef = useContext(DragDropItemSortableHandleRefContext); return ( - + diff --git a/packages/twenty-front/src/modules/object-record/record-table/record-table-cell/components/RecordTableCellFirstRowFirstColumn.tsx b/packages/twenty-front/src/modules/object-record/record-table/record-table-cell/components/RecordTableCellFirstRowFirstColumn.tsx index e70b8dcc45..6bb7ae50fd 100644 --- a/packages/twenty-front/src/modules/object-record/record-table/record-table-cell/components/RecordTableCellFirstRowFirstColumn.tsx +++ b/packages/twenty-front/src/modules/object-record/record-table/record-table-cell/components/RecordTableCellFirstRowFirstColumn.tsx @@ -1,6 +1,5 @@ import { TABLE_Z_INDEX } from '@/object-record/record-table/constants/TableZIndex'; import { getRecordTableColumnFieldWidthClassName } from '@/object-record/record-table/utils/getRecordTableColumnFieldWidthClassName'; -import { type DraggableProvidedDragHandleProps } from '@hello-pangea/dnd'; import { cx } from '@linaria/core'; import { styled } from '@linaria/react'; import { useContext, type ReactNode } from 'react'; @@ -39,15 +38,13 @@ export const RecordTableCellFirstRowFirstColumn = ({ isDragging, hasRightBorder = true, hasBottomBorder = true, - ...dragHandleProps }: { - className?: string; children?: ReactNode; isSelected?: boolean; isDragging?: boolean; hasRightBorder?: boolean; hasBottomBorder?: boolean; -} & (Partial | null)) => { +}) => { const { theme } = useContext(ThemeContext); const zIndex = TABLE_Z_INDEX.cell.sticky; @@ -69,8 +66,6 @@ export const RecordTableCellFirstRowFirstColumn = ({ hasRightBorder={hasRightBorder} hasBottomBorder={hasBottomBorder} zIndex={zIndex} - // oxlint-disable-next-line react/jsx-props-no-spreading - {...dragHandleProps} className={cx( 'table-cell-0-0', getRecordTableColumnFieldWidthClassName(0), diff --git a/packages/twenty-front/src/modules/object-record/record-table/record-table-cell/components/RecordTableCellStyleWrapper.tsx b/packages/twenty-front/src/modules/object-record/record-table/record-table-cell/components/RecordTableCellStyleWrapper.tsx index 6010329e53..ebb0993937 100644 --- a/packages/twenty-front/src/modules/object-record/record-table/record-table-cell/components/RecordTableCellStyleWrapper.tsx +++ b/packages/twenty-front/src/modules/object-record/record-table/record-table-cell/components/RecordTableCellStyleWrapper.tsx @@ -1,4 +1,3 @@ -import { type DraggableProvidedDragHandleProps } from '@hello-pangea/dnd'; import { cx } from '@linaria/core'; import { styled } from '@linaria/react'; import { type ReactNode, useContext } from 'react'; @@ -35,7 +34,7 @@ export const RecordTableCellStyleWrapper = ({ hasRightBorder = true, hasBottomBorder = true, widthClassName, - ...dragHandleProps + ...divProps }: { className?: string; children?: ReactNode; @@ -44,7 +43,7 @@ export const RecordTableCellStyleWrapper = ({ hasRightBorder?: boolean; hasBottomBorder?: boolean; widthClassName: string; -} & (Partial | null)) => { +} & React.ComponentProps<'div'>) => { const { theme } = useContext(ThemeContext); const tdBackgroundColor = isSelected @@ -64,7 +63,7 @@ export const RecordTableCellStyleWrapper = ({ hasRightBorder={hasRightBorder} hasBottomBorder={hasBottomBorder} // oxlint-disable-next-line react/jsx-props-no-spreading - {...dragHandleProps} + {...divProps} className={cx('table-cell', widthClassName)} > {children} diff --git a/packages/twenty-front/src/modules/object-record/record-table/record-table-cell/hooks/__mocks__/cell.ts b/packages/twenty-front/src/modules/object-record/record-table/record-table-cell/hooks/__mocks__/cell.ts index af8729f4cd..4bc7e39500 100644 --- a/packages/twenty-front/src/modules/object-record/record-table/record-table-cell/hooks/__mocks__/cell.ts +++ b/packages/twenty-front/src/modules/object-record/record-table/record-table-cell/hooks/__mocks__/cell.ts @@ -12,7 +12,6 @@ export const recordTableRowContextValue: RecordTableRowContextValue = { export const recordTableRowDraggableContextValue: RecordTableRowDraggableContextValue = { - dragHandleProps: {} as any, isDragging: false, }; diff --git a/packages/twenty-front/src/modules/object-record/record-table/record-table-header/dnd/hooks/useRecordTableHeaderDndKit.ts b/packages/twenty-front/src/modules/object-record/record-table/record-table-header/dnd/hooks/useRecordTableHeaderDndKit.ts index 021edcfce4..7e76f7a1a2 100644 --- a/packages/twenty-front/src/modules/object-record/record-table/record-table-header/dnd/hooks/useRecordTableHeaderDndKit.ts +++ b/packages/twenty-front/src/modules/object-record/record-table/record-table-header/dnd/hooks/useRecordTableHeaderDndKit.ts @@ -1,5 +1,4 @@ -import { type DragDropProvider } from '@dnd-kit/react'; -import { type ComponentProps, useState } from 'react'; +import { useState } from 'react'; import { filterOutByProperty, isDefined } from 'twenty-shared/utils'; import { useRecordIndexContextOrThrow } from '@/object-record/record-index/contexts/RecordIndexContext'; @@ -16,22 +15,13 @@ import { isRecordTableDragColumnHiddenComponentState } from '@/object-record/rec import { type DragDropItemData } from '@/ui/utilities/drag-and-drop/types/DragDropItemData'; import { resolveDragDropItemDrop } from '@/ui/utilities/drag-and-drop/utils/resolveDragDropItemDrop'; import { useAtomComponentStateValue } from '@/ui/utilities/state/jotai/hooks/useAtomComponentStateValue'; +import { type DragDropProviderDragEndEvent } from '@/ui/utilities/drag-and-drop/types/DragDropProviderDragEndEvent'; +import { type DragDropProviderDragMoveEvent } from '@/ui/utilities/drag-and-drop/types/DragDropProviderDragMoveEvent'; +import { type DragDropProviderDragStartEvent } from '@/ui/utilities/drag-and-drop/types/DragDropProviderDragStartEvent'; -type DragStartPayload = Parameters< - NonNullable< - ComponentProps>['onDragStart'] - > ->[0]; -type DragMovePayload = Parameters< - NonNullable< - ComponentProps>['onDragMove'] - > ->[0]; -type DragEndPayload = Parameters< - NonNullable< - ComponentProps>['onDragEnd'] - > ->[0]; +type DragStartPayload = DragDropProviderDragStartEvent; +type DragMovePayload = DragDropProviderDragMoveEvent; +type DragEndPayload = DragDropProviderDragEndEvent; export type RecordTableHeaderDndKitContextValues = { activeDropTargetIndex: number | null; diff --git a/packages/twenty-front/src/modules/object-record/record-table/record-table-header/dnd/providers/RecordTableHeaderDndKitProvider.tsx b/packages/twenty-front/src/modules/object-record/record-table/record-table-header/dnd/providers/RecordTableHeaderDndKitProvider.tsx index ab2f5e92ff..2bde9d8446 100644 --- a/packages/twenty-front/src/modules/object-record/record-table/record-table-header/dnd/providers/RecordTableHeaderDndKitProvider.tsx +++ b/packages/twenty-front/src/modules/object-record/record-table/record-table-header/dnd/providers/RecordTableHeaderDndKitProvider.tsx @@ -3,6 +3,7 @@ import type { ReactNode } from 'react'; import { useRecordTableHeaderDndKit } from '@/object-record/record-table/record-table-header/dnd/hooks/useRecordTableHeaderDndKit'; import { DragDropItemDndContext } from '@/ui/utilities/drag-and-drop/context/DragDropItemDndContext'; +import { DND_KIT_PROVIDER_PLUGINS_WITHOUT_DROP_ANIMATION } from '@/ui/utilities/drag-and-drop/constants/DndKitProviderPluginsWithoutDropAnimation'; import { DND_KIT_SENSORS } from '@/ui/utilities/drag-and-drop/constants/DndKitSensors'; import { type DragDropItemData } from '@/ui/utilities/drag-and-drop/types/DragDropItemData'; @@ -19,6 +20,7 @@ export const RecordTableHeaderDndKitProvider = ({ sensors={DND_KIT_SENSORS} + plugins={DND_KIT_PROVIDER_PLUGINS_WITHOUT_DROP_ANIMATION} onDragStart={handlers.onDragStart} onDragMove={handlers.onDragMove} onDragEnd={handlers.onDragEnd} diff --git a/packages/twenty-front/src/modules/object-record/record-table/record-table-row/components/RecordTableDraggableTr.tsx b/packages/twenty-front/src/modules/object-record/record-table/record-table-row/components/RecordTableDraggableTr.tsx index 5cb4fae6b9..df8d6ab035 100644 --- a/packages/twenty-front/src/modules/object-record/record-table/record-table-row/components/RecordTableDraggableTr.tsx +++ b/packages/twenty-front/src/modules/object-record/record-table/record-table-row/components/RecordTableDraggableTr.tsx @@ -1,11 +1,29 @@ -import { Draggable } from '@hello-pangea/dnd'; -import { type ReactNode, useContext } from 'react'; +import { useSortable } from '@dnd-kit/react/sortable'; +import { styled } from '@linaria/react'; +import { type ReactNode, useContext, useState } from 'react'; +import { isDefined } from 'twenty-shared/utils'; import { ThemeContext } from 'twenty-ui/theme-constants'; +import { v4 } from 'uuid'; +import { RecordGroupContext } from '@/object-record/record-group/states/context/RecordGroupContext'; +import { RECORD_TABLE_NO_RECORD_GROUP_DROPPABLE_ID } from '@/object-record/record-table/constants/RecordTableNoRecordGroupDroppableId'; +import { RECORD_TABLE_ROW_DND_TYPE } from '@/object-record/record-table/constants/RecordTableRowDndType'; +import { TABLE_Z_INDEX } from '@/object-record/record-table/constants/TableZIndex'; import { RecordTableRowDraggableContextProvider } from '@/object-record/record-table/contexts/RecordTableRowDraggableContext'; import { RecordTableRowMultiDragPreview } from '@/object-record/record-table/record-table-row/components/RecordTableRowMultiDragPreview'; import { RecordTableTr } from '@/object-record/record-table/record-table-row/components/RecordTableTr'; import { useIsTableRowSecondaryDragged } from '@/object-record/record-table/record-table-row/hooks/useIsRecordSecondaryDragged'; +import { type RecordTableRowDragData } from '@/object-record/record-table/types/RecordTableRowDragData'; +import { DragDropItemDropLine } from '@/ui/utilities/drag-and-drop/components/DragDropItemDropLine'; +import { DND_KIT_PLUGINS_WITHOUT_OPTIMISTIC } from '@/ui/utilities/drag-and-drop/constants/DndKitPluginsWithoutOptimistic'; +import { DragDropItemSortableHandleRefContext } from '@/ui/utilities/drag-and-drop/context/DragDropItemSortableHandleRefContext'; + +// The grip, checkbox and first field cells are sticky at +// TABLE_Z_INDEX.cell.sticky; without a higher z-index they would paint over +// the insertion line and truncate it to the scrollable columns. +const StyledRowDropLine = styled(DragDropItemDropLine)` + z-index: ${TABLE_Z_INDEX.rowDropLine}; +`; type RecordTableDraggableTrProps = { className?: string; @@ -30,49 +48,65 @@ export const RecordTableDraggableTr = ({ const { isSecondaryDragged } = useIsTableRowSecondaryDragged(recordId); + const { recordGroupId } = useContext(RecordGroupContext); + + const droppableId = isDefined(recordGroupId) + ? recordGroupId + : RECORD_TABLE_NO_RECORD_GROUP_DROPPABLE_ID; + + const rowDragData: RecordTableRowDragData = { + droppableId, + index: draggableIndex, + recordId, + focusIndex, + }; + + // The sortable id must never change in place: when the virtualization + // treadmill shifts recordIds across mounted rows after a reorder, dnd-kit + // re-registers each row under its new id and disposes the row that + // previously held it, leaving one row permanently undraggable. A stable + // per-instance id avoids the collision; recordId travels in the drag data. + const [sortableId] = useState(() => v4()); + + const { handleRef, ref, isDragging, isDragSource, isDropTarget } = + useSortable({ + id: sortableId, + index: draggableIndex, + group: droppableId, + type: RECORD_TABLE_ROW_DND_TYPE, + accept: RECORD_TABLE_ROW_DND_TYPE, + data: rowDragData, + disabled: isDragDisabled, + transition: null, + plugins: DND_KIT_PLUGINS_WITHOUT_OPTIMISTIC, + feedback: 'clone', + }); + return ( - - {(draggableProvided, draggableSnapshot) => ( - <> - - - {children} - - - - - )} - + + + {children} + + + + {isDropTarget && !isDragSource && } + ); }; diff --git a/packages/twenty-front/src/modules/object-record/record-table/record-table-row/components/RecordTableRowDiv.tsx b/packages/twenty-front/src/modules/object-record/record-table/record-table-row/components/RecordTableRowDiv.tsx index 83e17dd44e..909ccac0ed 100644 --- a/packages/twenty-front/src/modules/object-record/record-table/record-table-row/components/RecordTableRowDiv.tsx +++ b/packages/twenty-front/src/modules/object-record/record-table/record-table-row/components/RecordTableRowDiv.tsx @@ -10,6 +10,8 @@ const StyledTr = styled.div<{ display: flex; flex-direction: row; + position: relative; + &[data-focused='true'], &[data-active='true'] { div.table-cell, diff --git a/packages/twenty-front/src/modules/object-record/record-table/record-table-body/components/RecordTableBodyVirtualizedDraggableClone.tsx b/packages/twenty-front/src/modules/object-record/record-table/record-table-row/components/RecordTableRowDragOverlayContent.tsx similarity index 71% rename from packages/twenty-front/src/modules/object-record/record-table/record-table-body/components/RecordTableBodyVirtualizedDraggableClone.tsx rename to packages/twenty-front/src/modules/object-record/record-table/record-table-row/components/RecordTableRowDragOverlayContent.tsx index b43e62056e..f71d2f9517 100644 --- a/packages/twenty-front/src/modules/object-record/record-table/record-table-body/components/RecordTableBodyVirtualizedDraggableClone.tsx +++ b/packages/twenty-front/src/modules/object-record/record-table/record-table-row/components/RecordTableRowDragOverlayContent.tsx @@ -1,3 +1,9 @@ +import { type Draggable } from '@dnd-kit/dom'; +import { styled } from '@linaria/react'; +import { useMemo } from 'react'; +import { isDefined } from 'twenty-shared/utils'; +import { MOBILE_VIEWPORT, themeCssVariables } from 'twenty-ui/theme-constants'; + import { HorizontalScrollBoxShadowCSS } from '@/object-record/record-table/components/HorizontalScrollBoxShadowCSS'; import { getRecordTableColumnWidthInlineStyles } from '@/object-record/record-table/components/RecordTableStyleWrapper'; import { RECORD_TABLE_COLUMN_ADD_COLUMN_BUTTON_WIDTH } from '@/object-record/record-table/constants/RecordTableColumnAddColumnButtonWidth'; @@ -19,22 +25,11 @@ import { RecordTableCellDragAndDrop } from '@/object-record/record-table/record- import { RecordTableLastEmptyCell } from '@/object-record/record-table/record-table-cell/components/RecordTableLastEmptyCell'; import { RecordTablePlusButtonCellPlaceholder } from '@/object-record/record-table/record-table-cell/components/RecordTablePlusButtonCellPlaceholder'; import { RecordTableFieldsCells } from '@/object-record/record-table/record-table-row/components/RecordTableFieldsCells'; - -import { RecordTableRowMultiDragPreview } from '@/object-record/record-table/record-table-row/components/RecordTableRowMultiDragPreview'; +import { RecordTableRowMultiDragCounterChip } from '@/object-record/record-table/record-table-row/components/RecordTableRowMultiDragCounterChip'; import { RecordTableTr } from '@/object-record/record-table/record-table-row/components/RecordTableTr'; -import { useIsTableRowSecondaryDragged } from '@/object-record/record-table/record-table-row/hooks/useIsRecordSecondaryDragged'; +import { type RecordTableRowDragData } from '@/object-record/record-table/types/RecordTableRowDragData'; import { getRecordTableColumnFieldWidthClassName } from '@/object-record/record-table/utils/getRecordTableColumnFieldWidthClassName'; -import { recordIdByRealIndexComponentFamilySelector } from '@/object-record/record-table/virtualization/states/recordIdByRealIndexComponentFamilySelector'; -import { useAtomComponentFamilySelectorValue } from '@/ui/utilities/state/jotai/hooks/useAtomComponentFamilySelectorValue'; -import { - type DraggableProvided, - type DraggableRubric, - type DraggableStateSnapshot, -} from '@hello-pangea/dnd'; -import { styled } from '@linaria/react'; -import { useMemo } from 'react'; -import { isDefined } from 'twenty-shared/utils'; -import { MOBILE_VIEWPORT, themeCssVariables } from 'twenty-ui/theme-constants'; +import { useScrollWrapperHTMLElement } from '@/ui/utilities/scroll/hooks/useScrollWrapperHTMLElement'; const MAX_COLUMNS = 100; @@ -64,7 +59,11 @@ const cloneColumnFieldWidthRules = Array.from( }, ).join('\n'); -const StyledRowDraggableCloneCSSBridge = styled.div` +// The overlay renders in a portal outside the table, so the column width CSS +// variables and sticky cell rules of the table ancestors are redeclared here. +const StyledRowDragOverlayCSSBridge = styled.div` + position: relative; + div.table-cell.${RECORD_TABLE_COLUMN_DRAG_AND_DROP_WIDTH_CLASS_NAME} { left: 0px; position: sticky; @@ -123,27 +122,28 @@ const StyledRowDraggableCloneCSSBridge = styled.div` } `; -export const RecordTableBodyVirtualizedDraggableClone = ({ - draggableProvided, - draggableSnapshot, - rubric, +// The full-width row preview would overhang overlays such as the record side +// panel when the visible table is narrower than the row, so it is clipped to +// the scroll wrapper's width. The multi-drag counter chip renders outside +// this container because it pokes past the row's top-left corner. +const StyledRowClipContainer = styled.div` + overflow: hidden; +`; + +export const RecordTableRowDragOverlayContent = ({ + source, }: { - draggableProvided: DraggableProvided; - draggableSnapshot: DraggableStateSnapshot; - rubric: DraggableRubric; + source: Draggable | null; }) => { - const realIndex = rubric.source.index; - - const recordId = useAtomComponentFamilySelectorValue( - recordIdByRealIndexComponentFamilySelector, - realIndex, - ); - const { lastColumnWidth } = useRecordTableLastColumnWidthToFill(); - const { visibleRecordFields } = useRecordTableContextOrThrow(); + const { visibleRecordFields, recordTableId } = useRecordTableContextOrThrow(); - const { isSecondaryDragged } = useIsTableRowSecondaryDragged(recordId); + const { scrollWrapperHTMLElement } = useScrollWrapperHTMLElement( + `record-table-scroll-${recordTableId}`, + ); + + const visibleTableWidth = scrollWrapperHTMLElement?.clientWidth; const columnWidthStyles = useMemo(() => { const styles: Record = @@ -156,47 +156,45 @@ export const RecordTableBodyVirtualizedDraggableClone = ({ return styles; }, [visibleRecordFields, lastColumnWidth]); - if (!isDefined(recordId)) { + const sourceData = source?.data as RecordTableRowDragData | undefined; + + if (!isDefined(source) || !isDefined(sourceData)) { return null; } + const recordId = sourceData.recordId; + return ( - - {}} + + - {}} > - - - - - - - - - + + + + + + + + + + + ); }; diff --git a/packages/twenty-front/src/modules/object-record/record-table/record-table-row/components/RecordTableStaticTr.tsx b/packages/twenty-front/src/modules/object-record/record-table/record-table-row/components/RecordTableStaticTr.tsx index aa18de2922..a88beda003 100644 --- a/packages/twenty-front/src/modules/object-record/record-table/record-table-row/components/RecordTableStaticTr.tsx +++ b/packages/twenty-front/src/modules/object-record/record-table/record-table-row/components/RecordTableStaticTr.tsx @@ -25,7 +25,6 @@ export const RecordTableStaticTr = ({ {children} diff --git a/packages/twenty-front/src/modules/object-record/record-table/types/RecordTableRowDragData.ts b/packages/twenty-front/src/modules/object-record/record-table/types/RecordTableRowDragData.ts new file mode 100644 index 0000000000..8bd3468a68 --- /dev/null +++ b/packages/twenty-front/src/modules/object-record/record-table/types/RecordTableRowDragData.ts @@ -0,0 +1,6 @@ +import { type DragDropItemData } from '@/ui/utilities/drag-and-drop/types/DragDropItemData'; + +export type RecordTableRowDragData = DragDropItemData & { + recordId: string; + focusIndex: number; +}; diff --git a/packages/twenty-front/src/modules/page-layout/components/PageLayoutGridLayout.tsx b/packages/twenty-front/src/modules/page-layout/components/PageLayoutGridLayout.tsx index e4e967b016..55148a827c 100644 --- a/packages/twenty-front/src/modules/page-layout/components/PageLayoutGridLayout.tsx +++ b/packages/twenty-front/src/modules/page-layout/components/PageLayoutGridLayout.tsx @@ -12,6 +12,7 @@ import { PAGE_LAYOUT_GRID_ITEM_Z_INDEX } from '@/page-layout/constants/PageLayou import { PAGE_LAYOUT_GRID_MARGIN } from '@/page-layout/constants/PageLayoutGridMargin'; import { PAGE_LAYOUT_GRID_ROW_HEIGHT } from '@/page-layout/constants/PageLayoutGridRowHeight'; import { useIsPageLayoutInEditMode } from '@/page-layout/hooks/useIsPageLayoutInEditMode'; +import { usePageLayoutGridCrossTabDrop } from '@/page-layout/hooks/usePageLayoutGridCrossTabDrop'; import { usePageLayoutHandleLayoutChange } from '@/page-layout/hooks/usePageLayoutHandleLayoutChange'; import { usePageLayoutTabWithVisibleWidgetsOrThrow } from '@/page-layout/hooks/usePageLayoutTabWithVisibleWidgetsOrThrow'; import { PageLayoutComponentInstanceContext } from '@/page-layout/states/contexts/PageLayoutComponentInstanceContext'; @@ -155,6 +156,14 @@ export const PageLayoutGridLayout = ({ tabId }: PageLayoutGridLayoutProps) => { tabListInstanceId, }); + const { + handleGridDrag, + handleGridDragStop, + consumeShouldIgnoreNextGridLayoutChange, + } = usePageLayoutGridCrossTabDrop({ + tabId, + }); + const gridContainerRef = useRef(null); const isPageLayoutInEditMode = useIsPageLayoutInEditMode(); @@ -183,6 +192,10 @@ export const PageLayoutGridLayout = ({ tabId }: PageLayoutGridLayoutProps) => { currentLayout: Layout[], allLayouts: Layouts, ) => { + if (consumeShouldIgnoreNextGridLayoutChange()) { + return; + } + handleLayoutChange( currentLayout, filterPendingPlaceholderFromLayouts(allLayouts), @@ -247,7 +260,11 @@ export const PageLayoutGridLayout = ({ tabId }: PageLayoutGridLayoutProps) => { onDragStart={(_layout, _oldItem, newItem) => { setPageLayoutDraggingWidgetId(newItem.i); }} - onDragStop={() => { + onDrag={(_layout, _oldItem, _newItem, _placeholder, event) => { + handleGridDrag(event); + }} + onDragStop={(_layout, _oldItem, newItem, _placeholder, event) => { + handleGridDragStop(newItem.i, event); setPageLayoutDraggingWidgetId(null); }} onResizeStart={(_layout, _oldItem, newItem) => { diff --git a/packages/twenty-front/src/modules/page-layout/components/PageLayoutTabList.tsx b/packages/twenty-front/src/modules/page-layout/components/PageLayoutTabList.tsx index 61865bc7ef..b8420b6e55 100644 --- a/packages/twenty-front/src/modules/page-layout/components/PageLayoutTabList.tsx +++ b/packages/twenty-front/src/modules/page-layout/components/PageLayoutTabList.tsx @@ -1,11 +1,4 @@ -import { - DragDropContext, - type DropResult, - type OnDragEndResponder, - type OnDragStartResponder, - type OnDragUpdateResponder, - type ResponderProvided, -} from '@hello-pangea/dnd'; +import { useDragDropMonitor } from '@dnd-kit/react'; import { styled } from '@linaria/react'; import { useLingui } from '@lingui/react/macro'; import { useCallback, useMemo } from 'react'; @@ -29,15 +22,16 @@ import { useAtomComponentState } from '@/ui/utilities/state/jotai/hooks/useAtomC import { useSetAtomComponentState } from '@/ui/utilities/state/jotai/hooks/useSetAtomComponentState'; import { PAGE_LAYOUT_TAB_LIST_DROPPABLE_IDS } from '@/page-layout/components/PageLayoutTabListDroppableIds'; +import { PAGE_LAYOUT_TAB_LIST_END_DROP_ZONE_WIDTH } from '@/page-layout/constants/PageLayoutTabListEndDropZoneWidth'; import { PageLayoutTabListNewTabDropdownContent } from '@/page-layout/components/PageLayoutTabListNewTabDropdownContent'; import { PageLayoutTabListReorderableOverflowDropdown } from '@/page-layout/components/PageLayoutTabListReorderableOverflowDropdown'; import { PageLayoutTabListVisibleTabs } from '@/page-layout/components/PageLayoutTabListVisibleTabs'; import { useIsPageLayoutInEditMode } from '@/page-layout/hooks/useIsPageLayoutInEditMode'; import { PageLayoutComponentInstanceContext } from '@/page-layout/states/contexts/PageLayoutComponentInstanceContext'; -import { pageLayoutTabListCurrentDragDroppableIdComponentState } from '@/page-layout/states/pageLayoutTabListCurrentDragDroppableIdComponentState'; import { pageLayoutTabSettingsOpenTabIdComponentState } from '@/page-layout/states/pageLayoutTabSettingsOpenTabIdComponentState'; import { type PageLayoutAddTabStrategy } from '@/page-layout/types/PageLayoutAddTabStrategy'; import { type PageLayoutTab } from '@/page-layout/types/PageLayoutTab'; +import { type PageLayoutWidgetDndData } from '@/page-layout/types/PageLayoutWidgetDndData'; import { shouldEnableTabEditingFeatures } from '@/page-layout/utils/shouldEnableTabEditingFeatures'; import { useNavigatePageLayoutSidePanel } from '@/side-panel/pages/page-layout/hooks/useNavigatePageLayoutSidePanel'; import { Dropdown } from '@/ui/layout/dropdown/components/Dropdown'; @@ -90,7 +84,6 @@ type PageLayoutTabListProps = Omit & { tabs: PageLayoutTab[]; isReorderEnabled: boolean; addTabStrategy?: PageLayoutAddTabStrategy; - onReorder?: (result: DropResult, provided: ResponderProvided) => boolean; behaveAsLinks: boolean; pageLayoutType: PageLayoutType; }; @@ -105,7 +98,6 @@ export const PageLayoutTabList = ({ onChangeTab, addTabStrategy, isReorderEnabled, - onReorder, pageLayoutType, }: PageLayoutTabListProps) => { const { getIcon } = useIcons(); @@ -186,25 +178,43 @@ export const PageLayoutTabList = ({ closeDropdown(dropdownId); }, [closeDropdown, dropdownId]); - const setPageLayoutTabListCurrentDragDroppableId = useSetAtomComponentState( - pageLayoutTabListCurrentDragDroppableIdComponentState, - pageLayoutId, - ); + // The overflow dropdown must survive drops into itself: the dragging flag + // suppresses its close-on-click-outside while a tab drag is in flight, and a + // drop on the more button reopens it on the freshly appended tab. + useDragDropMonitor({ + onDragStart: (event) => { + const sourceData = event.operation.source?.data as + | PageLayoutWidgetDndData + | undefined; - const handleDragUpdate: OnDragUpdateResponder = (update) => { - setPageLayoutTabListCurrentDragDroppableId(update.destination?.droppableId); - }; + if (sourceData?.type !== 'tab') { + return; + } - const handleDragStart = useCallback(() => { - setIsPageLayoutTabDragging(true); - toggleClickOutside(false); - }, [setIsPageLayoutTabDragging, toggleClickOutside]); + setIsPageLayoutTabDragging(true); + toggleClickOutside(false); + }, + onDragEnd: (event) => { + const sourceData = event.operation.source?.data as + | PageLayoutWidgetDndData + | undefined; + + if (sourceData?.type !== 'tab') { + return; + } + + const target = event.operation.target; + const targetData = target?.data as PageLayoutWidgetDndData | undefined; + const targetDroppableId = ( + target?.data as { droppableId?: string } | undefined + )?.droppableId; - const handleDragEnd = useCallback( - (result, provided) => { const droppedInOverflow = - result.destination?.droppableId === - PAGE_LAYOUT_TAB_LIST_DROPPABLE_IDS.OVERFLOW_TABS; + !event.canceled && + (targetDroppableId === + PAGE_LAYOUT_TAB_LIST_DROPPABLE_IDS.OVERFLOW_TABS || + String(target?.id) === + `${PAGE_LAYOUT_TAB_LIST_DROPPABLE_IDS.OVERFLOW_TABS}-end`); if (!droppedInOverflow) { setIsPageLayoutTabDragging(false); @@ -212,26 +222,13 @@ export const PageLayoutTabList = ({ toggleClickOutside(true); - if (!onReorder) { - return; - } - - const shouldOpenDropdown = onReorder(result, provided); - - if (shouldOpenDropdown === true) { + if (!event.canceled && targetData?.type === 'tab-more-button') { openDropdown({ dropdownComponentInstanceIdFromProps: dropdownId, }); } }, - [ - onReorder, - setIsPageLayoutTabDragging, - toggleClickOutside, - openDropdown, - dropdownId, - ], - ); + }); const isPageLayoutInEditMode = useIsPageLayoutInEditMode(); const pageLayoutTabSettingsOpenTabId = useAtomComponentStateValue( @@ -257,6 +254,25 @@ export const PageLayoutTabList = ({ const isTabSettingsOpen = isDefined(pageLayoutTabSettingsOpenTabId); + // The reorderable strip appends an end drop zone the tab measurement does + // not know about; reserve its width so visible tabs never get clipped. + const handleContainerWidthChange = useCallback( + (dimensions: { width: number; height: number }) => { + onContainerWidthChange( + isReorderEnabled + ? { + ...dimensions, + width: Math.max( + dimensions.width - PAGE_LAYOUT_TAB_LIST_END_DROP_ZONE_WIDTH, + 0, + ), + } + : dimensions, + ); + }, + [onContainerWidthChange, isReorderEnabled], + ); + const handleSelectTab = useCallback( (tabId: string) => { const shouldOpenSettings = @@ -317,14 +333,15 @@ export const PageLayoutTabList = ({ return null; } - const canReorderTabs = isReorderEnabled && isDefined(onReorder); + const canReorderTabs = isReorderEnabled; const shouldRenderReorderableDropdown = hasHiddenTabs && canReorderTabs; const shouldRenderStaticDropdown = hasHiddenTabs && !canReorderTabs; - // Widgets can only be dropped onto record-page vertical-list tabs; other tab - // types keep their native (canvas/grid) widget placement. + // Record pages accept widget drops on vertical-list tabs (dnd-kit drags); + // dashboards accept them on grid tabs (react-grid-layout drags bridged by + // pointer hit-testing). const widgetDropTargetTabIds = new Set( pageLayoutType === PageLayoutType.RECORD_PAGE ? tabs @@ -332,7 +349,13 @@ export const PageLayoutTabList = ({ (tab) => tab.layoutMode === PageLayoutTabLayoutMode.VERTICAL_LIST, ) .map((tab) => tab.id) - : [], + : pageLayoutType === PageLayoutType.DASHBOARD + ? tabs + .filter( + (tab) => tab.layoutMode !== PageLayoutTabLayoutMode.VERTICAL_LIST, + ) + .map((tab) => tab.id) + : [], ); return ( @@ -369,142 +392,91 @@ export const PageLayoutTabList = ({ /> )} - - {isReorderEnabled && onReorder ? ( - - - + + + + {shouldRenderReorderableDropdown && ( + + + + )} - {shouldRenderReorderableDropdown && ( - - - - )} + {shouldRenderStaticDropdown && ( + + + + )} - {addTabStrategy?.mode === 'direct' && ( - + {addTabStrategy?.mode === 'direct' && ( + + addTabStrategy.onCreate()} + disableTestId + /> + + )} + {addTabStrategy?.mode === 'dropdown' && ( + + addTabStrategy.onCreate()} disableTestId /> - - )} - {addTabStrategy?.mode === 'dropdown' && ( - - - } - dropdownComponents={ - - } - dropdownPlacement="bottom-start" /> - - )} - - - ) : ( - - - {shouldRenderStaticDropdown && ( - - - - )} - {addTabStrategy?.mode === 'direct' && ( - - addTabStrategy.onCreate()} - disableTestId - /> - - )} - {addTabStrategy?.mode === 'dropdown' && ( - - - } - dropdownComponents={ - - } - dropdownPlacement="bottom-start" - /> - - )} - - )} + } + dropdownPlacement="bottom-start" + /> + + )} + ); diff --git a/packages/twenty-front/src/modules/page-layout/components/PageLayoutTabListDroppableMoreButton.tsx b/packages/twenty-front/src/modules/page-layout/components/PageLayoutTabListDroppableMoreButton.tsx index b700d4500f..13358dc7e0 100644 --- a/packages/twenty-front/src/modules/page-layout/components/PageLayoutTabListDroppableMoreButton.tsx +++ b/packages/twenty-front/src/modules/page-layout/components/PageLayoutTabListDroppableMoreButton.tsx @@ -1,7 +1,10 @@ +import { pointerIntersection } from '@dnd-kit/collision'; +import { useDroppable } from '@dnd-kit/react'; import { styled } from '@linaria/react'; -import { Droppable } from '@hello-pangea/dnd'; import { PAGE_LAYOUT_TAB_LIST_DROPPABLE_IDS } from '@/page-layout/components/PageLayoutTabListDroppableIds'; +import { PAGE_LAYOUT_TAB_DND_TYPE } from '@/page-layout/constants/PageLayoutTabDndType'; +import { type PageLayoutTabMoreButtonDropData } from '@/page-layout/types/PageLayoutTabMoreButtonDropData'; import { TabMoreButton } from '@/ui/layout/tab-list/components/TabMoreButton'; import { themeCssVariables } from 'twenty-ui/theme-constants'; @@ -24,22 +27,25 @@ export const PageLayoutTabListDroppableMoreButton = ({ hiddenTabsCount, isActiveTabHidden, }: PageLayoutTabListDroppableMoreButtonProps) => { + const moreButtonDropData: PageLayoutTabMoreButtonDropData = { + type: 'tab-more-button', + }; + + const { ref, isDropTarget } = useDroppable({ + id: PAGE_LAYOUT_TAB_LIST_DROPPABLE_IDS.MORE_BUTTON, + accept: PAGE_LAYOUT_TAB_DND_TYPE, + collisionDetector: pointerIntersection, + data: moreButtonDropData, + }); + return ( - - {(provided, snapshot) => ( -
- - - -
- )} -
+
+ + + +
); }; diff --git a/packages/twenty-front/src/modules/page-layout/components/PageLayoutTabListReorderableOverflowDropdown.tsx b/packages/twenty-front/src/modules/page-layout/components/PageLayoutTabListReorderableOverflowDropdown.tsx index 05ddbd3eb9..f1860162aa 100644 --- a/packages/twenty-front/src/modules/page-layout/components/PageLayoutTabListReorderableOverflowDropdown.tsx +++ b/packages/twenty-front/src/modules/page-layout/components/PageLayoutTabListReorderableOverflowDropdown.tsx @@ -1,20 +1,15 @@ -import { - Draggable, - type DraggableProvided, - type DraggableRubric, - type DraggableStateSnapshot, - Droppable, -} from '@hello-pangea/dnd'; import { styled } from '@linaria/react'; import { PAGE_LAYOUT_TAB_LIST_DROPPABLE_IDS } from '@/page-layout/components/PageLayoutTabListDroppableIds'; import { PageLayoutTabListDroppableMoreButton } from '@/page-layout/components/PageLayoutTabListDroppableMoreButton'; import { PageLayoutTabMenuItemSelectAvatar } from '@/page-layout/components/PageLayoutTabMenuItemSelectAvatar'; -import { PageLayoutTabRenderClone } from '@/page-layout/components/PageLayoutTabRenderClone'; +import { PAGE_LAYOUT_TAB_DND_TYPE } from '@/page-layout/constants/PageLayoutTabDndType'; import { useIsPageLayoutInEditMode } from '@/page-layout/hooks/useIsPageLayoutInEditMode'; import { PageLayoutComponentInstanceContext } from '@/page-layout/states/contexts/PageLayoutComponentInstanceContext'; import { isPageLayoutTabDraggingComponentState } from '@/page-layout/states/isPageLayoutTabDraggingComponentState'; import { pageLayoutTabSettingsOpenTabIdComponentState } from '@/page-layout/states/pageLayoutTabSettingsOpenTabIdComponentState'; +import { type PageLayoutTabDragData } from '@/page-layout/types/PageLayoutTabDragData'; +import { type PageLayoutTabListEndDropData } from '@/page-layout/types/PageLayoutTabListEndDropData'; import { shouldEnableTabEditingFeatures } from '@/page-layout/utils/shouldEnableTabEditingFeatures'; import { useNavigatePageLayoutSidePanel } from '@/side-panel/pages/page-layout/hooks/useNavigatePageLayoutSidePanel'; import { Dropdown } from '@/ui/layout/dropdown/components/Dropdown'; @@ -23,23 +18,37 @@ import { DropdownMenuItemsContainer } from '@/ui/layout/dropdown/components/Drop import { GenericDropdownContentWidth } from '@/ui/layout/dropdown/constants/GenericDropdownContentWidth'; import { TabListComponentInstanceContext } from '@/ui/layout/tab-list/states/contexts/TabListComponentInstanceContext'; import { type SingleTabProps } from '@/ui/layout/tab-list/types/SingleTabProps'; +import { DragDropItemEndDropZone } from '@/ui/utilities/drag-and-drop/components/DragDropItemEndDropZone'; +import { DragDropItemSortableCell } from '@/ui/utilities/drag-and-drop/components/DragDropItemSortableCell'; import { useAvailableComponentInstanceIdOrThrow } from '@/ui/utilities/state/component-state/hooks/useAvailableComponentInstanceIdOrThrow'; import { useAtomComponentStateValue } from '@/ui/utilities/state/jotai/hooks/useAtomComponentStateValue'; import { useSetAtomComponentState } from '@/ui/utilities/state/jotai/hooks/useSetAtomComponentState'; import { useContext } from 'react'; import { SidePanelPages } from 'twenty-shared/types'; -import { ThemeContext } from 'twenty-ui/theme-constants'; +import { themeCssVariables } from 'twenty-ui/theme-constants'; import { type PageLayoutType } from '~/generated-metadata/graphql'; -const StyledOverflowDropdownListDraggableWrapper = styled.div` +const StyledOverflowMenuItemWrapper = styled.div` cursor: grab; display: flex; + min-width: 100%; &:active { cursor: grabbing; } `; +// Kept tall enough that appending after the last overflow tab stays an easy +// target. +const StyledOverflowEndDropZone = styled(DragDropItemEndDropZone)` + min-height: ${themeCssVariables.spacing[4]}; +`; + +const OVERFLOW_END_DROP_DATA: PageLayoutTabListEndDropData = { + type: 'tab-list-end', + beforeTabId: null, +}; + type PageLayoutTabListReorderableOverflowDropdownProps = { dropdownId: string; hiddenTabs: SingleTabProps[]; @@ -65,7 +74,6 @@ export const PageLayoutTabListReorderableOverflowDropdown = ({ onClose, pageLayoutType, }: PageLayoutTabListReorderableOverflowDropdownProps) => { - const { theme } = useContext(ThemeContext); const context = useContext(TabListComponentInstanceContext); const instanceId = context?.instanceId; @@ -130,95 +138,46 @@ export const PageLayoutTabListReorderableOverflowDropdown = ({ } dropdownComponents={ - { - const overflowIndex = rubric.source.index - visibleTabCount; - const tab = hiddenTabs[overflowIndex]; + + {hiddenTabs.map((tab, index) => { + const disabled = tab.disabled ?? loading; + const tabDragData: PageLayoutTabDragData = { + type: 'tab', + tabId: tab.id, + }; return ( - - ); - }} - > - {(provided) => ( - -
- {hiddenTabs.map((tab, index) => { - const globalIndex = visibleTabCount + index; - const disabled = tab.disabled ?? loading; - - return ( - - {(draggableProvided, draggableSnapshot) => ( - -
- handleTabSelect(tab.id) - } - disabled={disabled} - showEditButton={shouldShowEditButton} - onEditClick={handleEditClick} - /> -
-
- )} -
- ); - })} -
{provided.placeholder}
-
-
- )} -
+ + handleTabSelect(tab.id)} + disabled={disabled} + showEditButton={shouldShowEditButton} + onEditClick={handleEditClick} + /> + + + ); + })} + +
} /> diff --git a/packages/twenty-front/src/modules/page-layout/components/PageLayoutTabListReorderableTab.tsx b/packages/twenty-front/src/modules/page-layout/components/PageLayoutTabListReorderableTab.tsx index 3827f5d95a..1bd7de6f7c 100644 --- a/packages/twenty-front/src/modules/page-layout/components/PageLayoutTabListReorderableTab.tsx +++ b/packages/twenty-front/src/modules/page-layout/components/PageLayoutTabListReorderableTab.tsx @@ -1,8 +1,9 @@ -import { Draggable } from '@hello-pangea/dnd'; - import { PageLayoutTabWidgetDropTarget } from '@/page-layout/components/dnd/PageLayoutTabWidgetDropTarget'; +import { PAGE_LAYOUT_TAB_DND_TYPE } from '@/page-layout/constants/PageLayoutTabDndType'; import { pageLayoutTabSettingsOpenTabIdComponentState } from '@/page-layout/states/pageLayoutTabSettingsOpenTabIdComponentState'; +import { type PageLayoutTabDragData } from '@/page-layout/types/PageLayoutTabDragData'; import { type SingleTabProps } from '@/ui/layout/tab-list/types/SingleTabProps'; +import { DragDropItemSortableCell } from '@/ui/utilities/drag-and-drop/components/DragDropItemSortableCell'; import { useAtomComponentStateValue } from '@/ui/utilities/state/jotai/hooks/useAtomComponentStateValue'; import { styled } from '@linaria/react'; import { StyledTabContainer, TabContent } from 'twenty-ui/input'; @@ -11,9 +12,11 @@ import { themeCssVariables } from 'twenty-ui/theme-constants'; type PageLayoutTabListReorderableTabProps = { tab: SingleTabProps; index: number; + group: string; isActive: boolean; disabled?: boolean; isWidgetDropTarget?: boolean; + dropLineOrientation?: 'horizontal' | 'vertical'; onSelect: () => void; }; @@ -27,9 +30,11 @@ const StyledTabContentWrapper = styled.div<{ isBeingEdited: boolean }>` export const PageLayoutTabListReorderableTab = ({ tab, index, + group, isActive, disabled, isWidgetDropTarget = false, + dropLineOrientation = 'vertical', onSelect, }: PageLayoutTabListReorderableTabProps) => { const pageLayoutTabSettingsOpenTabId = useAtomComponentStateValue( @@ -38,37 +43,42 @@ export const PageLayoutTabListReorderableTab = ({ const isSettingsOpenForThisTab = pageLayoutTabSettingsOpenTabId === tab.id; + const tabDragData: PageLayoutTabDragData = { + type: 'tab', + tabId: tab.id, + }; + const draggableTab = ( - - {(draggableProvided, draggableSnapshot) => ( - - - - - - )} - + + + + + + + ); if (!isWidgetDropTarget) { diff --git a/packages/twenty-front/src/modules/page-layout/components/PageLayoutTabListVisibleTabs.tsx b/packages/twenty-front/src/modules/page-layout/components/PageLayoutTabListVisibleTabs.tsx index 96f0f2f157..8d3f02f820 100644 --- a/packages/twenty-front/src/modules/page-layout/components/PageLayoutTabListVisibleTabs.tsx +++ b/packages/twenty-front/src/modules/page-layout/components/PageLayoutTabListVisibleTabs.tsx @@ -1,10 +1,4 @@ import { styled } from '@linaria/react'; -import { - type DraggableProvided, - type DraggableRubric, - type DraggableStateSnapshot, - Droppable, -} from '@hello-pangea/dnd'; import { TabButton } from 'twenty-ui/input'; import { TAB_LIST_GAP } from '@/ui/layout/tab-list/constants/TabListGap'; @@ -12,7 +6,10 @@ import { type SingleTabProps } from '@/ui/layout/tab-list/types/SingleTabProps'; import { PAGE_LAYOUT_TAB_LIST_DROPPABLE_IDS } from '@/page-layout/components/PageLayoutTabListDroppableIds'; import { PageLayoutTabListReorderableTab } from '@/page-layout/components/PageLayoutTabListReorderableTab'; -import { PageLayoutTabRenderClone } from '@/page-layout/components/PageLayoutTabRenderClone'; +import { PAGE_LAYOUT_TAB_DND_TYPE } from '@/page-layout/constants/PageLayoutTabDndType'; +import { PAGE_LAYOUT_TAB_LIST_END_DROP_ZONE_WIDTH } from '@/page-layout/constants/PageLayoutTabListEndDropZoneWidth'; +import { type PageLayoutTabListEndDropData } from '@/page-layout/types/PageLayoutTabListEndDropData'; +import { DragDropItemEndDropZone } from '@/ui/utilities/drag-and-drop/components/DragDropItemEndDropZone'; type PageLayoutTabListVisibleTabsProps = { visibleTabs: SingleTabProps[]; @@ -24,6 +21,7 @@ type PageLayoutTabListVisibleTabsProps = { onSelectTab: (tabId: string) => void; canReorder: boolean; widgetDropTargetTabIds: Set; + firstHiddenTabId: string | null; }; const StyledTabContainer = styled.div` @@ -37,6 +35,14 @@ const StyledTabContainer = styled.div` } `; +// Catches drops after the last visible tab; inserting before the first hidden +// tab keeps the dropped tab visible instead of sending it to the overflow. +// Its width is reserved by PageLayoutTabList's container measurement. +const StyledEndDropZone = styled(DragDropItemEndDropZone)` + align-self: stretch; + flex: 0 0 ${PAGE_LAYOUT_TAB_LIST_END_DROP_ZONE_WIDTH}px; +`; + export const PageLayoutTabListVisibleTabs = ({ visibleTabs, visibleTabCount, @@ -47,49 +53,35 @@ export const PageLayoutTabListVisibleTabs = ({ onSelectTab, canReorder, widgetDropTargetTabIds, + firstHiddenTabId, }: PageLayoutTabListVisibleTabsProps) => { if (canReorder) { - return ( - { - const tab = visibleTabs[rubric.source.index]; + const endDropData: PageLayoutTabListEndDropData = { + type: 'tab-list-end', + beforeTabId: firstHiddenTabId, + }; - return ( - - ); - }} - > - {(provided) => ( - - {visibleTabs.slice(0, visibleTabCount).map((tab, index) => ( - onSelectTab(tab.id)} - /> - ))} - {provided.placeholder} - - )} - + return ( + + {visibleTabs.slice(0, visibleTabCount).map((tab, index) => ( + onSelectTab(tab.id)} + /> + ))} + + ); } diff --git a/packages/twenty-front/src/modules/page-layout/components/PageLayoutTabRenderClone.tsx b/packages/twenty-front/src/modules/page-layout/components/PageLayoutTabRenderClone.tsx deleted file mode 100644 index 59e8080b28..0000000000 --- a/packages/twenty-front/src/modules/page-layout/components/PageLayoutTabRenderClone.tsx +++ /dev/null @@ -1,101 +0,0 @@ -import { isDefined } from 'twenty-shared/utils'; -import { PAGE_LAYOUT_TAB_LIST_DROPPABLE_IDS } from '@/page-layout/components/PageLayoutTabListDroppableIds'; -import { pageLayoutTabListCurrentDragDroppableIdComponentState } from '@/page-layout/states/pageLayoutTabListCurrentDragDroppableIdComponentState'; -import { GenericDropdownContentWidth } from '@/ui/layout/dropdown/constants/GenericDropdownContentWidth'; -import { TabAvatar } from '@/ui/layout/tab-list/components/TabAvatar'; -import { type SingleTabProps } from '@/ui/layout/tab-list/types/SingleTabProps'; -import { useAtomComponentStateValue } from '@/ui/utilities/state/jotai/hooks/useAtomComponentStateValue'; -import { type DraggableProvided } from '@hello-pangea/dnd'; -import { styled } from '@linaria/react'; -import { useContext } from 'react'; -import { StyledTabContainer, TabContent } from 'twenty-ui/input'; -import { MenuItemSelectAvatar } from 'twenty-ui/navigation'; -import { ThemeContext } from 'twenty-ui/theme-constants'; -const StyledDraggableWrapper = styled.div` - cursor: grab; - display: flex; - - &:active { - cursor: grabbing; - } -`; - -export const PageLayoutTabRenderClone = ({ - tab, - provided, - activeTabId, -}: { - tab: SingleTabProps; - provided: DraggableProvided; - activeTabId: string | null; -}) => { - const { theme } = useContext(ThemeContext); - const pageLayoutTabListCurrentDragDroppableId = useAtomComponentStateValue( - pageLayoutTabListCurrentDragDroppableIdComponentState, - ); - - const isHoveringTabList = - pageLayoutTabListCurrentDragDroppableId !== - PAGE_LAYOUT_TAB_LIST_DROPPABLE_IDS.OVERFLOW_TABS; - - if (!isDefined(tab)) return null; - - if (isHoveringTabList) { - return ( - - - - - - ); - } else { - return ( - -
- } - selected={tab.id === activeTabId} - onClick={undefined} - disabled - /> -
-
- ); - } -}; diff --git a/packages/twenty-front/src/modules/page-layout/components/PageLayoutTabsRenderer.tsx b/packages/twenty-front/src/modules/page-layout/components/PageLayoutTabsRenderer.tsx index 7c818f866a..62fc78bba0 100644 --- a/packages/twenty-front/src/modules/page-layout/components/PageLayoutTabsRenderer.tsx +++ b/packages/twenty-front/src/modules/page-layout/components/PageLayoutTabsRenderer.tsx @@ -11,7 +11,6 @@ import { WIDGET_TYPE_TO_RELATION_FIELD_NAME } from '@/page-layout/constants/Widg import { useCurrentPageLayoutOrThrow } from '@/page-layout/hooks/useCurrentPageLayoutOrThrow'; import { useIsPageLayoutInEditMode } from '@/page-layout/hooks/useIsPageLayoutInEditMode'; import { usePageLayoutAddTabStrategy } from '@/page-layout/hooks/usePageLayoutAddTabStrategy'; -import { useReorderRecordPageLayoutTabs } from '@/page-layout/hooks/useReorderRecordPageLayoutTabs'; import { PageLayoutMainContent } from '@/page-layout/PageLayoutMainContent'; import { getScrollWrapperInstanceIdFromPageLayoutId } from '@/page-layout/utils/getScrollWrapperInstanceIdFromPageLayoutId'; import { getTabListInstanceIdFromPageLayoutAndRecord } from '@/page-layout/utils/getTabListInstanceIdFromPageLayoutAndRecord'; @@ -95,10 +94,6 @@ export const PageLayoutTabsRenderer = () => { tabListInstanceId, }); - const { reorderRecordPageTabs } = useReorderRecordPageLayoutTabs( - currentPageLayout.id, - ); - const { objectMetadataItems } = useObjectMetadataItems(); const inactiveRelationFieldNames = useMemo(() => { @@ -217,16 +212,6 @@ export const PageLayoutTabsRenderer = () => { componentInstanceId={tabListInstanceId} addTabStrategy={addTabStrategy} isReorderEnabled={canEnableTabEditing} - onReorder={ - canEnableTabEditing - ? (result, provided) => - reorderRecordPageTabs( - result, - provided, - isDefined(pinnedLeftTab), - ) - : undefined - } pageLayoutType={currentPageLayout.type} /> )} diff --git a/packages/twenty-front/src/modules/page-layout/components/PageLayoutVerticalListEditor.tsx b/packages/twenty-front/src/modules/page-layout/components/PageLayoutVerticalListEditor.tsx index 012b3c6e8c..f1e0c8471c 100644 --- a/packages/twenty-front/src/modules/page-layout/components/PageLayoutVerticalListEditor.tsx +++ b/packages/twenty-front/src/modules/page-layout/components/PageLayoutVerticalListEditor.tsx @@ -1,15 +1,15 @@ -import { pointerIntersection } from '@dnd-kit/collision'; -import { useDroppable } from '@dnd-kit/react'; -import { PageLayoutWidgetDropLine } from '@/page-layout/components/dnd/PageLayoutWidgetDropLine'; -import { PageLayoutWidgetSortableItem } from '@/page-layout/components/dnd/PageLayoutWidgetSortableItem'; import { getPageLayoutVerticalListViewerVariant } from '@/page-layout/components/utils/getPageLayoutVerticalListViewerVariant'; import { usePageLayoutContentContext } from '@/page-layout/contexts/PageLayoutContentContext'; import { type PageLayoutVerticalListViewerVariant } from '@/page-layout/types/PageLayoutVerticalListViewerVariant'; import { type PageLayoutWidget } from '@/page-layout/types/PageLayoutWidget'; -import { type PageLayoutWidgetListDropData } from '@/page-layout/types/PageLayoutWidgetDndData'; +import { type PageLayoutWidgetDragData } from '@/page-layout/types/PageLayoutWidgetDragData'; +import { type PageLayoutWidgetListDropData } from '@/page-layout/types/PageLayoutWidgetListDropData'; +import { PAGE_LAYOUT_WIDGET_DND_TYPE } from '@/page-layout/constants/PageLayoutWidgetDndType'; import { WidgetRenderer } from '@/page-layout/widgets/components/WidgetRenderer'; import { useIsInPinnedTab } from '@/page-layout/widgets/hooks/useIsInPinnedTab'; import { useLayoutRenderingContext } from '@/ui/layout/contexts/LayoutRenderingContext'; +import { DragDropItemEndDropZone } from '@/ui/utilities/drag-and-drop/components/DragDropItemEndDropZone'; +import { DragDropItemSortableCell } from '@/ui/utilities/drag-and-drop/components/DragDropItemSortableCell'; import { styled } from '@linaria/react'; import { type ReactNode } from 'react'; import { themeCssVariables } from 'twenty-ui/theme-constants'; @@ -32,15 +32,12 @@ const StyledVerticalListContainer = styled.div<{ : themeCssVariables.spacing[2]}; `; -// Catches drops below the last widget (append) and drops into an empty tab, -// where there is no sortable item to target. -const StyledEndDropZone = styled.div` +const StyledEndDropZone = styled(DragDropItemEndDropZone)` display: flex; flex: 1; flex-direction: column; gap: ${themeCssVariables.spacing[4]}; min-height: ${themeCssVariables.spacing[6]}; - position: relative; `; type PageLayoutVerticalListEditorProps = { @@ -69,29 +66,41 @@ export const PageLayoutVerticalListEditor = ({ tabId, }; - const { ref: endDropRef, isDropTarget: isEndDropTarget } = useDroppable({ - id: `page-layout-widget-list-${tabId}`, - collisionDetector: pointerIntersection, - data: endDropData, - }); - return ( - {widgets.map((widget, index) => ( - - - - ))} - - {isEndDropTarget && } + {widgets.map((widget, index) => { + const widgetDragData: PageLayoutWidgetDragData = { + type: 'widget', + widgetId: widget.id, + tabId, + index, + }; + + return ( + + + + ); + })} + {trailingElement} diff --git a/packages/twenty-front/src/modules/page-layout/components/__stories__/PageLayoutTabList.stories.tsx b/packages/twenty-front/src/modules/page-layout/components/__stories__/PageLayoutTabList.stories.tsx index 245bf2d65f..988221c214 100644 --- a/packages/twenty-front/src/modules/page-layout/components/__stories__/PageLayoutTabList.stories.tsx +++ b/packages/twenty-front/src/modules/page-layout/components/__stories__/PageLayoutTabList.stories.tsx @@ -1,16 +1,16 @@ -import { type DropResult, type ResponderProvided } from '@hello-pangea/dnd'; import { styled } from '@linaria/react'; import type { Meta, StoryObj } from '@storybook/react-vite'; -import { useMemo, useState } from 'react'; +import { useEffect, useMemo } from 'react'; import { ComponentWithRouterDecorator } from 'twenty-ui/testing'; import { PageLayoutTabList } from '@/page-layout/components/PageLayoutTabList'; -import { PAGE_LAYOUT_TAB_LIST_DROPPABLE_IDS } from '@/page-layout/components/PageLayoutTabListDroppableIds'; import { PageLayoutTabListEffect } from '@/page-layout/components/PageLayoutTabListEffect'; +import { PageLayoutWidgetDndProvider } from '@/page-layout/components/dnd/PageLayoutWidgetDndProvider'; import { PageLayoutEditModeProviderContext } from '@/page-layout/contexts/PageLayoutEditModeContext'; import { PageLayoutComponentInstanceContext } from '@/page-layout/states/contexts/PageLayoutComponentInstanceContext'; +import { pageLayoutDraftComponentState } from '@/page-layout/states/pageLayoutDraftComponentState'; import { type PageLayoutTab } from '@/page-layout/types/PageLayoutTab'; -import { calculateNewPosition } from '@/ui/layout/draggable-list/utils/calculateNewPosition'; +import { useAtomComponentState } from '@/ui/utilities/state/jotai/hooks/useAtomComponentState'; import { themeCssVariables } from 'twenty-ui/theme-constants'; import { PageLayoutType } from '~/generated-metadata/graphql'; @@ -68,94 +68,46 @@ const PageLayoutTabListPlayground = ({ }: { isReorderEnabled: boolean; }) => { - const [tabs, setTabs] = useState(createInitialTabs()); - const [nextIndex, setNextIndex] = useState(tabs.length); + // Tab drops are routed into the page-layout draft by the dnd provider, so + // the story renders from that draft to stay interactive. + const [pageLayoutDraft, setPageLayoutDraft] = useAtomComponentState( + pageLayoutDraftComponentState, + ); + + useEffect(() => { + setPageLayoutDraft((prev) => + prev.tabs.length > 0 ? prev : { ...prev, tabs: createInitialTabs() }, + ); + }, [setPageLayoutDraft]); const sortedTabs = useMemo(() => { - return [...tabs].sort((a, b) => a.position - b.position); - }, [tabs]); + return [...pageLayoutDraft.tabs].sort((a, b) => a.position - b.position); + }, [pageLayoutDraft.tabs]); const handleAddTab = () => { - setTabs((prev) => [ - ...prev, - { - __typename: 'PageLayoutTab', - isActive: true, - applicationId: '', - id: `new-tab-${nextIndex}`, - title: `New Tab ${nextIndex}`, - position: nextIndex, - pageLayoutId: 'test-layout', - widgets: [], - createdAt: new Date().toISOString(), - updatedAt: new Date().toISOString(), - deletedAt: null, - }, - ]); - setNextIndex((value) => value + 1); - }; + setPageLayoutDraft((prev) => { + const nextIndex = prev.tabs.length; - const handleReorder = ( - result: DropResult, - _provided: ResponderProvided, - ): boolean => { - const { destination, source, draggableId } = result; - - if (!destination) { - return false; - } - - const isDroppedOnMoreButton = - destination.droppableId === - PAGE_LAYOUT_TAB_LIST_DROPPABLE_IDS.MORE_BUTTON; - - if (isDroppedOnMoreButton) { - setTabs((prev) => { - const maxPosition = Math.max(...prev.map((tab) => tab.position), 0); - return prev.map((tab) => - tab.id === draggableId ? { ...tab, position: maxPosition + 1 } : tab, - ); - }); - return true; - } - - setTabs((prev) => { - const sorted = [...prev].sort((a, b) => a.position - b.position); - - if ( - destination.droppableId === source.droppableId && - destination.index === source.index - ) { - return prev; - } - - const draggedTab = sorted.find((tab) => tab.id === draggableId); - if (!draggedTab) { - return prev; - } - - const withoutDragged = sorted.filter((tab) => tab.id !== draggableId); - - const movingBetweenDroppables = - destination.droppableId !== source.droppableId; - - const destinationIndexAdjusted = - movingBetweenDroppables && destination.index > source.index - ? destination.index - 1 - : destination.index; - - const newPosition = calculateNewPosition({ - destinationIndex: destinationIndexAdjusted, - sourceIndex: source.index, - items: withoutDragged, - }); - - return prev.map((tab) => - tab.id === draggableId ? { ...tab, position: newPosition } : tab, - ); + return { + ...prev, + tabs: [ + ...prev.tabs, + { + __typename: 'PageLayoutTab', + isActive: true, + applicationId: '', + id: `new-tab-${nextIndex}`, + title: `New Tab ${nextIndex}`, + position: nextIndex, + pageLayoutId: 'test-layout', + widgets: [], + createdAt: new Date().toISOString(), + updatedAt: new Date().toISOString(), + deletedAt: null, + }, + ], + }; }); - - return false; }; return ( @@ -165,20 +117,21 @@ const PageLayoutTabListPlayground = ({ componentInstanceId="page-layout-tab-list-story" /> - + + +
); }; diff --git a/packages/twenty-front/src/modules/page-layout/components/dnd/PageLayoutTabWidgetDropTarget.tsx b/packages/twenty-front/src/modules/page-layout/components/dnd/PageLayoutTabWidgetDropTarget.tsx index 6e91c54c2e..f2538e40b3 100644 --- a/packages/twenty-front/src/modules/page-layout/components/dnd/PageLayoutTabWidgetDropTarget.tsx +++ b/packages/twenty-front/src/modules/page-layout/components/dnd/PageLayoutTabWidgetDropTarget.tsx @@ -4,7 +4,11 @@ import { styled } from '@linaria/react'; import { type ReactNode } from 'react'; import { themeCssVariables } from 'twenty-ui/theme-constants'; -import { type PageLayoutTabWidgetDropData } from '@/page-layout/types/PageLayoutWidgetDndData'; +import { PAGE_LAYOUT_TAB_DROP_TARGET_DATA_ATTRIBUTE } from '@/page-layout/constants/PageLayoutTabDropTargetDataAttribute'; +import { PAGE_LAYOUT_WIDGET_DND_TYPE } from '@/page-layout/constants/PageLayoutWidgetDndType'; +import { pageLayoutGridDragHoveredTabIdComponentState } from '@/page-layout/states/pageLayoutGridDragHoveredTabIdComponentState'; +import { type PageLayoutTabWidgetDropData } from '@/page-layout/types/PageLayoutTabWidgetDropData'; +import { useAtomComponentStateValue } from '@/ui/utilities/state/jotai/hooks/useAtomComponentStateValue'; const StyledDropTarget = styled.div<{ isActive: boolean }>` border-radius: ${themeCssVariables.border.radius.sm}; @@ -30,12 +34,23 @@ export const PageLayoutTabWidgetDropTarget = ({ const { ref, isDropTarget } = useDroppable({ id: `page-layout-tab-widget-drop-${tabId}`, + accept: PAGE_LAYOUT_WIDGET_DND_TYPE, collisionDetector: pointerIntersection, data, }); + // Grid drags come from react-grid-layout, outside dnd-kit; their hover + // highlight is driven by pointer hit-testing instead of isDropTarget. + const pageLayoutGridDragHoveredTabId = useAtomComponentStateValue( + pageLayoutGridDragHoveredTabIdComponentState, + ); + return ( - + {children} ); diff --git a/packages/twenty-front/src/modules/page-layout/components/dnd/PageLayoutWidgetDndProvider.tsx b/packages/twenty-front/src/modules/page-layout/components/dnd/PageLayoutWidgetDndProvider.tsx index 5b5d8ea603..bcc146a7c9 100644 --- a/packages/twenty-front/src/modules/page-layout/components/dnd/PageLayoutWidgetDndProvider.tsx +++ b/packages/twenty-front/src/modules/page-layout/components/dnd/PageLayoutWidgetDndProvider.tsx @@ -3,6 +3,7 @@ import { type ReactNode } from 'react'; import { usePageLayoutWidgetDragAndDrop } from '@/page-layout/hooks/usePageLayoutWidgetDragAndDrop'; import { type PageLayoutWidgetDndData } from '@/page-layout/types/PageLayoutWidgetDndData'; +import { DND_KIT_PROVIDER_PLUGINS_WITHOUT_DROP_ANIMATION } from '@/ui/utilities/drag-and-drop/constants/DndKitProviderPluginsWithoutDropAnimation'; import { DND_KIT_SENSORS } from '@/ui/utilities/drag-and-drop/constants/DndKitSensors'; type PageLayoutWidgetDndProviderProps = { @@ -21,6 +22,7 @@ export const PageLayoutWidgetDndProvider = ({ return ( sensors={DND_KIT_SENSORS} + plugins={DND_KIT_PROVIDER_PLUGINS_WITHOUT_DROP_ANIMATION} onDragStart={handlers.onDragStart} onDragEnd={handlers.onDragEnd} > diff --git a/packages/twenty-front/src/modules/page-layout/components/dnd/PageLayoutWidgetDropLine.tsx b/packages/twenty-front/src/modules/page-layout/components/dnd/PageLayoutWidgetDropLine.tsx deleted file mode 100644 index 0d8da68a5c..0000000000 --- a/packages/twenty-front/src/modules/page-layout/components/dnd/PageLayoutWidgetDropLine.tsx +++ /dev/null @@ -1,24 +0,0 @@ -import { styled } from '@linaria/react'; -import { themeCssVariables } from 'twenty-ui/theme-constants'; - -// Absolutely positioned in the gap above its (position: relative) parent so -// activating the drop target does not reflow the list. -const StyledDropLineContainer = styled.div` - left: 0; - position: absolute; - right: 0; - top: calc(-1 * ${themeCssVariables.spacing[2]}); -`; - -const StyledDropLine = styled.div` - background-color: ${themeCssVariables.color.blue}; - border-radius: ${themeCssVariables.border.radius.sm}; - height: 2px; - width: 100%; -`; - -export const PageLayoutWidgetDropLine = () => ( - - - -); diff --git a/packages/twenty-front/src/modules/page-layout/components/dnd/PageLayoutWidgetSortableItem.tsx b/packages/twenty-front/src/modules/page-layout/components/dnd/PageLayoutWidgetSortableItem.tsx deleted file mode 100644 index 79fa45c06a..0000000000 --- a/packages/twenty-front/src/modules/page-layout/components/dnd/PageLayoutWidgetSortableItem.tsx +++ /dev/null @@ -1,64 +0,0 @@ -import { SortableKeyboardPlugin } from '@dnd-kit/dom/sortable'; -import { useSortable } from '@dnd-kit/react/sortable'; -import { styled } from '@linaria/react'; -import { type ReactNode } from 'react'; -import { themeCssVariables } from 'twenty-ui/theme-constants'; - -import { PageLayoutWidgetDropLine } from '@/page-layout/components/dnd/PageLayoutWidgetDropLine'; -import { type PageLayoutWidgetDragData } from '@/page-layout/types/PageLayoutWidgetDndData'; -import { preventNativeDragStart } from '@/ui/utilities/drag-and-drop/utils/preventNativeDragStart'; - -const PLUGINS_WITHOUT_OPTIMISTIC = [SortableKeyboardPlugin]; - -const StyledSortableRoot = styled.div<{ isDragging: boolean }>` - background: ${({ isDragging }) => - isDragging - ? themeCssVariables.background.transparent.light - : 'transparent'}; - border-radius: ${themeCssVariables.border.radius.sm}; - min-height: 0; - position: relative; - transition: background 0.1s ease; -`; - -type PageLayoutWidgetSortableItemProps = { - widgetId: string; - tabId: string; - index: number; - children: ReactNode; -}; - -export const PageLayoutWidgetSortableItem = ({ - widgetId, - tabId, - index, - children, -}: PageLayoutWidgetSortableItemProps) => { - const data: PageLayoutWidgetDragData = { - type: 'widget', - widgetId, - tabId, - index, - }; - - const { ref, isDragging, isDropTarget } = useSortable({ - id: widgetId, - index, - group: tabId, - data, - transition: null, - plugins: PLUGINS_WITHOUT_OPTIMISTIC, - feedback: 'clone', - }); - - return ( - - {isDropTarget && } - {children} - - ); -}; diff --git a/packages/twenty-front/src/modules/page-layout/constants/PageLayoutTabDndType.ts b/packages/twenty-front/src/modules/page-layout/constants/PageLayoutTabDndType.ts new file mode 100644 index 0000000000..9f074ea18d --- /dev/null +++ b/packages/twenty-front/src/modules/page-layout/constants/PageLayoutTabDndType.ts @@ -0,0 +1 @@ +export const PAGE_LAYOUT_TAB_DND_TYPE = 'page-layout-tab'; diff --git a/packages/twenty-front/src/modules/page-layout/constants/PageLayoutTabDropTargetDataAttribute.ts b/packages/twenty-front/src/modules/page-layout/constants/PageLayoutTabDropTargetDataAttribute.ts new file mode 100644 index 0000000000..26c97437cc --- /dev/null +++ b/packages/twenty-front/src/modules/page-layout/constants/PageLayoutTabDropTargetDataAttribute.ts @@ -0,0 +1,2 @@ +export const PAGE_LAYOUT_TAB_DROP_TARGET_DATA_ATTRIBUTE = + 'data-page-layout-tab-drop-target-id'; diff --git a/packages/twenty-front/src/modules/page-layout/constants/PageLayoutTabListEndDropZoneWidth.ts b/packages/twenty-front/src/modules/page-layout/constants/PageLayoutTabListEndDropZoneWidth.ts new file mode 100644 index 0000000000..1261fdcf52 --- /dev/null +++ b/packages/twenty-front/src/modules/page-layout/constants/PageLayoutTabListEndDropZoneWidth.ts @@ -0,0 +1,3 @@ +import { TAB_LIST_GAP } from '@/ui/layout/tab-list/constants/TabListGap'; + +export const PAGE_LAYOUT_TAB_LIST_END_DROP_ZONE_WIDTH = TAB_LIST_GAP * 2; diff --git a/packages/twenty-front/src/modules/page-layout/constants/PageLayoutWidgetDndType.ts b/packages/twenty-front/src/modules/page-layout/constants/PageLayoutWidgetDndType.ts new file mode 100644 index 0000000000..a95ec5ab35 --- /dev/null +++ b/packages/twenty-front/src/modules/page-layout/constants/PageLayoutWidgetDndType.ts @@ -0,0 +1 @@ +export const PAGE_LAYOUT_WIDGET_DND_TYPE = 'page-layout-widget'; diff --git a/packages/twenty-front/src/modules/page-layout/hooks/__tests__/useCanMovePageLayoutWidgetDown.test.tsx b/packages/twenty-front/src/modules/page-layout/hooks/__tests__/useCanMovePageLayoutWidgetDown.test.tsx index 540c6f758a..59e410ebc4 100644 --- a/packages/twenty-front/src/modules/page-layout/hooks/__tests__/useCanMovePageLayoutWidgetDown.test.tsx +++ b/packages/twenty-front/src/modules/page-layout/hooks/__tests__/useCanMovePageLayoutWidgetDown.test.tsx @@ -1,70 +1,19 @@ import { useCanMovePageLayoutWidgetDown } from '@/page-layout/hooks/useCanMovePageLayoutWidgetDown'; import { pageLayoutDraftComponentState } from '@/page-layout/states/pageLayoutDraftComponentState'; -import { type DraftPageLayout } from '@/page-layout/types/DraftPageLayout'; -import { type PageLayoutWidget } from '@/page-layout/types/PageLayoutWidget'; +import { + makeDraft, + makeTab, + makeWidget, +} from '@/page-layout/testing/pageLayoutDraftFixtures'; import { renderHook } from '@testing-library/react'; import { createStore } from 'jotai'; import { type ReactNode } from 'react'; -import { - PageLayoutTabLayoutMode, - PageLayoutType, - WidgetType, -} from '~/generated-metadata/graphql'; +import { PageLayoutTabLayoutMode } from '~/generated-metadata/graphql'; import { PAGE_LAYOUT_TEST_INSTANCE_ID, PageLayoutTestWrapper, } from './PageLayoutTestWrapper'; -const makeWidget = ( - id: string, - index: number, - tabId: string = 'tab-1', -): PageLayoutWidget => - ({ - id, - pageLayoutTabId: tabId, - title: id, - isActive: true, - type: WidgetType.FIELDS, - gridPosition: { column: 0, columnSpan: 1, row: 0, rowSpan: 1 }, - configuration: { __typename: 'FieldsConfiguration' as const }, - position: { - __typename: 'PageLayoutWidgetVerticalListPosition' as const, - layoutMode: PageLayoutTabLayoutMode.VERTICAL_LIST, - index, - }, - createdAt: new Date().toISOString(), - updatedAt: new Date().toISOString(), - deletedAt: null, - }) as unknown as PageLayoutWidget; - -const makeTab = ( - id: string, - widgets: PageLayoutWidget[], - position: number = 0, - layoutMode: PageLayoutTabLayoutMode = PageLayoutTabLayoutMode.VERTICAL_LIST, -) => ({ - id, - applicationId: '', - title: id, - isActive: true, - position, - layoutMode, - pageLayoutId: '', - widgets, - createdAt: new Date().toISOString(), - updatedAt: new Date().toISOString(), - deletedAt: null, -}); - -const makeDraft = (tabs: ReturnType[]): DraftPageLayout => ({ - id: 'test-layout', - name: 'Test Layout', - type: PageLayoutType.RECORD_PAGE, - objectMetadataId: null, - tabs, -}); - describe('useCanMovePageLayoutWidgetDown', () => { const getWrapper = (store = createStore()) => diff --git a/packages/twenty-front/src/modules/page-layout/hooks/__tests__/useCanMovePageLayoutWidgetUp.test.tsx b/packages/twenty-front/src/modules/page-layout/hooks/__tests__/useCanMovePageLayoutWidgetUp.test.tsx index 6874e43338..a69b2f9bb2 100644 --- a/packages/twenty-front/src/modules/page-layout/hooks/__tests__/useCanMovePageLayoutWidgetUp.test.tsx +++ b/packages/twenty-front/src/modules/page-layout/hooks/__tests__/useCanMovePageLayoutWidgetUp.test.tsx @@ -1,70 +1,19 @@ import { useCanMovePageLayoutWidgetUp } from '@/page-layout/hooks/useCanMovePageLayoutWidgetUp'; import { pageLayoutDraftComponentState } from '@/page-layout/states/pageLayoutDraftComponentState'; -import { type DraftPageLayout } from '@/page-layout/types/DraftPageLayout'; -import { type PageLayoutWidget } from '@/page-layout/types/PageLayoutWidget'; +import { + makeDraft, + makeTab, + makeWidget, +} from '@/page-layout/testing/pageLayoutDraftFixtures'; import { renderHook } from '@testing-library/react'; import { createStore } from 'jotai'; import { type ReactNode } from 'react'; -import { - PageLayoutTabLayoutMode, - PageLayoutType, - WidgetType, -} from '~/generated-metadata/graphql'; +import { PageLayoutTabLayoutMode } from '~/generated-metadata/graphql'; import { PAGE_LAYOUT_TEST_INSTANCE_ID, PageLayoutTestWrapper, } from './PageLayoutTestWrapper'; -const makeWidget = ( - id: string, - index: number, - tabId: string = 'tab-1', -): PageLayoutWidget => - ({ - id, - pageLayoutTabId: tabId, - title: id, - isActive: true, - type: WidgetType.FIELDS, - gridPosition: { column: 0, columnSpan: 1, row: 0, rowSpan: 1 }, - configuration: { __typename: 'FieldsConfiguration' as const }, - position: { - __typename: 'PageLayoutWidgetVerticalListPosition' as const, - layoutMode: PageLayoutTabLayoutMode.VERTICAL_LIST, - index, - }, - createdAt: new Date().toISOString(), - updatedAt: new Date().toISOString(), - deletedAt: null, - }) as unknown as PageLayoutWidget; - -const makeTab = ( - id: string, - widgets: PageLayoutWidget[], - position: number = 0, - layoutMode: PageLayoutTabLayoutMode = PageLayoutTabLayoutMode.VERTICAL_LIST, -) => ({ - id, - applicationId: '', - title: id, - isActive: true, - position, - layoutMode, - pageLayoutId: '', - widgets, - createdAt: new Date().toISOString(), - updatedAt: new Date().toISOString(), - deletedAt: null, -}); - -const makeDraft = (tabs: ReturnType[]): DraftPageLayout => ({ - id: 'test-layout', - name: 'Test Layout', - type: PageLayoutType.RECORD_PAGE, - objectMetadataId: null, - tabs, -}); - describe('useCanMovePageLayoutWidgetUp', () => { const getWrapper = (store = createStore()) => diff --git a/packages/twenty-front/src/modules/page-layout/hooks/__tests__/useInsertCreatedWidgetAtContext.test.tsx b/packages/twenty-front/src/modules/page-layout/hooks/__tests__/useInsertCreatedWidgetAtContext.test.tsx index 5dfc18a1a8..e148685456 100644 --- a/packages/twenty-front/src/modules/page-layout/hooks/__tests__/useInsertCreatedWidgetAtContext.test.tsx +++ b/packages/twenty-front/src/modules/page-layout/hooks/__tests__/useInsertCreatedWidgetAtContext.test.tsx @@ -1,69 +1,20 @@ import { useInsertCreatedWidgetAtContext } from '@/page-layout/hooks/useInsertCreatedWidgetAtContext'; import { pageLayoutDraftComponentState } from '@/page-layout/states/pageLayoutDraftComponentState'; import { widgetInsertionContextComponentState } from '@/page-layout/states/widgetInsertionContextComponentState'; -import { type DraftPageLayout } from '@/page-layout/types/DraftPageLayout'; -import { type PageLayoutWidget } from '@/page-layout/types/PageLayoutWidget'; +import { + makeDraft, + makeTab, + makeWidget, +} from '@/page-layout/testing/pageLayoutDraftFixtures'; import { act, renderHook } from '@testing-library/react'; import { createStore } from 'jotai'; import { type ReactNode } from 'react'; -import { - PageLayoutTabLayoutMode, - PageLayoutType, - WidgetType, -} from '~/generated-metadata/graphql'; +import { PageLayoutTabLayoutMode } from '~/generated-metadata/graphql'; import { PAGE_LAYOUT_TEST_INSTANCE_ID, PageLayoutTestWrapper, } from './PageLayoutTestWrapper'; -const makeWidget = ( - id: string, - index: number, - tabId: string = 'tab-1', -): PageLayoutWidget => - ({ - id, - pageLayoutTabId: tabId, - title: id, - isActive: true, - type: WidgetType.FIELDS, - gridPosition: { column: 0, columnSpan: 1, row: 0, rowSpan: 1 }, - configuration: { __typename: 'FieldsConfiguration' as const }, - position: { - __typename: 'PageLayoutWidgetVerticalListPosition' as const, - layoutMode: PageLayoutTabLayoutMode.VERTICAL_LIST, - index, - }, - createdAt: new Date().toISOString(), - updatedAt: new Date().toISOString(), - deletedAt: null, - }) as unknown as PageLayoutWidget; - -const makeTab = ( - id: string, - widgets: PageLayoutWidget[], - position: number = 0, -) => ({ - id, - applicationId: '', - title: id, - isActive: true, - position, - pageLayoutId: '', - widgets, - createdAt: new Date().toISOString(), - updatedAt: new Date().toISOString(), - deletedAt: null, -}); - -const makeDraft = (tabs: ReturnType[]): DraftPageLayout => ({ - id: 'test-layout', - name: 'Test Layout', - type: PageLayoutType.RECORD_PAGE, - objectMetadataId: null, - tabs, -}); - describe('useInsertCreatedWidgetAtContext', () => { const getWrapper = (store = createStore()) => diff --git a/packages/twenty-front/src/modules/page-layout/hooks/__tests__/useMovePageLayoutWidgetDown.test.tsx b/packages/twenty-front/src/modules/page-layout/hooks/__tests__/useMovePageLayoutWidgetDown.test.tsx index 6bc302e522..94f79092ae 100644 --- a/packages/twenty-front/src/modules/page-layout/hooks/__tests__/useMovePageLayoutWidgetDown.test.tsx +++ b/packages/twenty-front/src/modules/page-layout/hooks/__tests__/useMovePageLayoutWidgetDown.test.tsx @@ -1,69 +1,18 @@ import { useMovePageLayoutWidgetDown } from '@/page-layout/hooks/useMovePageLayoutWidgetDown'; import { pageLayoutDraftComponentState } from '@/page-layout/states/pageLayoutDraftComponentState'; -import { type DraftPageLayout } from '@/page-layout/types/DraftPageLayout'; -import { type PageLayoutWidget } from '@/page-layout/types/PageLayoutWidget'; +import { + makeDraft, + makeTab, + makeWidget, +} from '@/page-layout/testing/pageLayoutDraftFixtures'; import { act, renderHook } from '@testing-library/react'; import { createStore } from 'jotai'; import { type ReactNode } from 'react'; -import { - PageLayoutTabLayoutMode, - PageLayoutType, - WidgetType, -} from '~/generated-metadata/graphql'; import { PAGE_LAYOUT_TEST_INSTANCE_ID, PageLayoutTestWrapper, } from './PageLayoutTestWrapper'; -const makeWidget = ( - id: string, - index: number, - tabId: string = 'tab-1', -): PageLayoutWidget => - ({ - id, - pageLayoutTabId: tabId, - title: id, - isActive: true, - type: WidgetType.FIELDS, - gridPosition: { column: 0, columnSpan: 1, row: 0, rowSpan: 1 }, - configuration: { __typename: 'FieldsConfiguration' as const }, - position: { - __typename: 'PageLayoutWidgetVerticalListPosition' as const, - layoutMode: PageLayoutTabLayoutMode.VERTICAL_LIST, - index, - }, - createdAt: new Date().toISOString(), - updatedAt: new Date().toISOString(), - deletedAt: null, - }) as unknown as PageLayoutWidget; - -const makeTab = ( - id: string, - widgets: PageLayoutWidget[], - position: number = 0, -) => ({ - id, - applicationId: '', - title: id, - isActive: true, - position, - layoutMode: PageLayoutTabLayoutMode.VERTICAL_LIST, - pageLayoutId: '', - widgets, - createdAt: new Date().toISOString(), - updatedAt: new Date().toISOString(), - deletedAt: null, -}); - -const makeDraft = (tabs: ReturnType[]): DraftPageLayout => ({ - id: 'test-layout', - name: 'Test Layout', - type: PageLayoutType.RECORD_PAGE, - objectMetadataId: null, - tabs, -}); - describe('useMovePageLayoutWidgetDown', () => { const getWrapper = (store = createStore()) => diff --git a/packages/twenty-front/src/modules/page-layout/hooks/__tests__/useMovePageLayoutWidgetUp.test.tsx b/packages/twenty-front/src/modules/page-layout/hooks/__tests__/useMovePageLayoutWidgetUp.test.tsx index 1a04cef1f2..be36267519 100644 --- a/packages/twenty-front/src/modules/page-layout/hooks/__tests__/useMovePageLayoutWidgetUp.test.tsx +++ b/packages/twenty-front/src/modules/page-layout/hooks/__tests__/useMovePageLayoutWidgetUp.test.tsx @@ -1,69 +1,18 @@ import { useMovePageLayoutWidgetUp } from '@/page-layout/hooks/useMovePageLayoutWidgetUp'; import { pageLayoutDraftComponentState } from '@/page-layout/states/pageLayoutDraftComponentState'; -import { type DraftPageLayout } from '@/page-layout/types/DraftPageLayout'; -import { type PageLayoutWidget } from '@/page-layout/types/PageLayoutWidget'; +import { + makeDraft, + makeTab, + makeWidget, +} from '@/page-layout/testing/pageLayoutDraftFixtures'; import { act, renderHook } from '@testing-library/react'; import { createStore } from 'jotai'; import { type ReactNode } from 'react'; -import { - PageLayoutTabLayoutMode, - PageLayoutType, - WidgetType, -} from '~/generated-metadata/graphql'; import { PAGE_LAYOUT_TEST_INSTANCE_ID, PageLayoutTestWrapper, } from './PageLayoutTestWrapper'; -const makeWidget = ( - id: string, - index: number, - tabId: string = 'tab-1', -): PageLayoutWidget => - ({ - id, - pageLayoutTabId: tabId, - title: id, - isActive: true, - type: WidgetType.FIELDS, - gridPosition: { column: 0, columnSpan: 1, row: 0, rowSpan: 1 }, - configuration: { __typename: 'FieldsConfiguration' as const }, - position: { - __typename: 'PageLayoutWidgetVerticalListPosition' as const, - layoutMode: PageLayoutTabLayoutMode.VERTICAL_LIST, - index, - }, - createdAt: new Date().toISOString(), - updatedAt: new Date().toISOString(), - deletedAt: null, - }) as unknown as PageLayoutWidget; - -const makeTab = ( - id: string, - widgets: PageLayoutWidget[], - position: number = 0, -) => ({ - id, - applicationId: '', - title: id, - isActive: true, - position, - layoutMode: PageLayoutTabLayoutMode.VERTICAL_LIST, - pageLayoutId: '', - widgets, - createdAt: new Date().toISOString(), - updatedAt: new Date().toISOString(), - deletedAt: null, -}); - -const makeDraft = (tabs: ReturnType[]): DraftPageLayout => ({ - id: 'test-layout', - name: 'Test Layout', - type: PageLayoutType.RECORD_PAGE, - objectMetadataId: null, - tabs, -}); - describe('useMovePageLayoutWidgetUp', () => { const getWrapper = (store = createStore()) => diff --git a/packages/twenty-front/src/modules/page-layout/hooks/useMovePageLayoutWidgetDown.ts b/packages/twenty-front/src/modules/page-layout/hooks/useMovePageLayoutWidgetDown.ts index 2eb1c2faf4..64065f1b1b 100644 --- a/packages/twenty-front/src/modules/page-layout/hooks/useMovePageLayoutWidgetDown.ts +++ b/packages/twenty-front/src/modules/page-layout/hooks/useMovePageLayoutWidgetDown.ts @@ -1,13 +1,11 @@ import { PageLayoutComponentInstanceContext } from '@/page-layout/states/contexts/PageLayoutComponentInstanceContext'; import { pageLayoutDraftComponentState } from '@/page-layout/states/pageLayoutDraftComponentState'; -import { isVerticalListPosition } from '@/page-layout/utils/isVerticalListPosition'; +import { moveWidgetWithinTabInDraft } from '@/page-layout/utils/moveWidgetWithinTabInDraft'; import { sortWidgetsByVerticalListPosition } from '@/page-layout/utils/sortWidgetsByVerticalListPosition'; import { useAvailableComponentInstanceIdOrThrow } from '@/ui/utilities/state/component-state/hooks/useAvailableComponentInstanceIdOrThrow'; import { useAtomComponentStateCallbackState } from '@/ui/utilities/state/jotai/hooks/useAtomComponentStateCallbackState'; import { useStore } from 'jotai'; import { useCallback } from 'react'; -import { isDefined } from 'twenty-shared/utils'; -import { PageLayoutTabLayoutMode } from '~/generated-metadata/graphql'; export const useMovePageLayoutWidgetDown = (pageLayoutIdFromProps?: string) => { const pageLayoutId = useAvailableComponentInstanceIdOrThrow( @@ -34,7 +32,6 @@ export const useMovePageLayoutWidgetDown = (pageLayoutIdFromProps?: string) => { } const sortedWidgets = sortWidgetsByVerticalListPosition(tab.widgets); - const currentIndex = sortedWidgets.findIndex( (widget) => widget.id === widgetId, ); @@ -43,56 +40,11 @@ export const useMovePageLayoutWidgetDown = (pageLayoutIdFromProps?: string) => { return prev; } - const currentWidget = sortedWidgets[currentIndex]; - const neighborWidget = sortedWidgets[currentIndex + 1]; - - const currentPositionIndex = - isDefined(currentWidget.position) && - isVerticalListPosition(currentWidget.position) - ? currentWidget.position.index - : currentIndex; - const neighborPositionIndex = - isDefined(neighborWidget.position) && - isVerticalListPosition(neighborWidget.position) - ? neighborWidget.position.index - : currentIndex + 1; - - return { - ...prev, - tabs: prev.tabs.map((currentTab) => { - if (currentTab.id !== tab.id) { - return currentTab; - } - return { - ...currentTab, - widgets: currentTab.widgets.map((widget) => { - if (widget.id === currentWidget.id) { - return { - ...widget, - position: { - __typename: - 'PageLayoutWidgetVerticalListPosition' as const, - layoutMode: PageLayoutTabLayoutMode.VERTICAL_LIST, - index: neighborPositionIndex, - }, - }; - } - if (widget.id === neighborWidget.id) { - return { - ...widget, - position: { - __typename: - 'PageLayoutWidgetVerticalListPosition' as const, - layoutMode: PageLayoutTabLayoutMode.VERTICAL_LIST, - index: currentPositionIndex, - }, - }; - } - return widget; - }), - }; - }), - }; + return moveWidgetWithinTabInDraft(prev, { + tabId: tab.id, + fromIndex: currentIndex, + toIndex: currentIndex + 1, + }); }); }, [pageLayoutDraftState, store], diff --git a/packages/twenty-front/src/modules/page-layout/hooks/useMovePageLayoutWidgetUp.ts b/packages/twenty-front/src/modules/page-layout/hooks/useMovePageLayoutWidgetUp.ts index bce10b9c41..5d524b854f 100644 --- a/packages/twenty-front/src/modules/page-layout/hooks/useMovePageLayoutWidgetUp.ts +++ b/packages/twenty-front/src/modules/page-layout/hooks/useMovePageLayoutWidgetUp.ts @@ -1,13 +1,11 @@ import { PageLayoutComponentInstanceContext } from '@/page-layout/states/contexts/PageLayoutComponentInstanceContext'; import { pageLayoutDraftComponentState } from '@/page-layout/states/pageLayoutDraftComponentState'; -import { isVerticalListPosition } from '@/page-layout/utils/isVerticalListPosition'; +import { moveWidgetWithinTabInDraft } from '@/page-layout/utils/moveWidgetWithinTabInDraft'; import { sortWidgetsByVerticalListPosition } from '@/page-layout/utils/sortWidgetsByVerticalListPosition'; import { useAvailableComponentInstanceIdOrThrow } from '@/ui/utilities/state/component-state/hooks/useAvailableComponentInstanceIdOrThrow'; import { useAtomComponentStateCallbackState } from '@/ui/utilities/state/jotai/hooks/useAtomComponentStateCallbackState'; import { useStore } from 'jotai'; import { useCallback } from 'react'; -import { isDefined } from 'twenty-shared/utils'; -import { PageLayoutTabLayoutMode } from '~/generated-metadata/graphql'; export const useMovePageLayoutWidgetUp = (pageLayoutIdFromProps?: string) => { const pageLayoutId = useAvailableComponentInstanceIdOrThrow( @@ -33,66 +31,19 @@ export const useMovePageLayoutWidgetUp = (pageLayoutIdFromProps?: string) => { return prev; } - const sortedWidgets = sortWidgetsByVerticalListPosition(tab.widgets); - - const currentIndex = sortedWidgets.findIndex( - (widget) => widget.id === widgetId, - ); + const currentIndex = sortWidgetsByVerticalListPosition( + tab.widgets, + ).findIndex((widget) => widget.id === widgetId); if (currentIndex <= 0) { return prev; } - const currentWidget = sortedWidgets[currentIndex]; - const neighborWidget = sortedWidgets[currentIndex - 1]; - - const currentPositionIndex = - isDefined(currentWidget.position) && - isVerticalListPosition(currentWidget.position) - ? currentWidget.position.index - : currentIndex; - const neighborPositionIndex = - isDefined(neighborWidget.position) && - isVerticalListPosition(neighborWidget.position) - ? neighborWidget.position.index - : currentIndex - 1; - - return { - ...prev, - tabs: prev.tabs.map((currentTab) => { - if (currentTab.id !== tab.id) { - return currentTab; - } - return { - ...currentTab, - widgets: currentTab.widgets.map((widget) => { - if (widget.id === currentWidget.id) { - return { - ...widget, - position: { - __typename: - 'PageLayoutWidgetVerticalListPosition' as const, - layoutMode: PageLayoutTabLayoutMode.VERTICAL_LIST, - index: neighborPositionIndex, - }, - }; - } - if (widget.id === neighborWidget.id) { - return { - ...widget, - position: { - __typename: - 'PageLayoutWidgetVerticalListPosition' as const, - layoutMode: PageLayoutTabLayoutMode.VERTICAL_LIST, - index: currentPositionIndex, - }, - }; - } - return widget; - }), - }; - }), - }; + return moveWidgetWithinTabInDraft(prev, { + tabId: tab.id, + fromIndex: currentIndex, + toIndex: currentIndex - 1, + }); }); }, [pageLayoutDraftState, store], diff --git a/packages/twenty-front/src/modules/page-layout/hooks/usePageLayoutGridCrossTabDrop.ts b/packages/twenty-front/src/modules/page-layout/hooks/usePageLayoutGridCrossTabDrop.ts new file mode 100644 index 0000000000..0777f795d4 --- /dev/null +++ b/packages/twenty-front/src/modules/page-layout/hooks/usePageLayoutGridCrossTabDrop.ts @@ -0,0 +1,183 @@ +import { useStore } from 'jotai'; +import { useCallback } from 'react'; +import { isDefined } from 'twenty-shared/utils'; +import { PageLayoutTabLayoutMode } from '~/generated-metadata/graphql'; + +import { PAGE_LAYOUT_TAB_DROP_TARGET_DATA_ATTRIBUTE } from '@/page-layout/constants/PageLayoutTabDropTargetDataAttribute'; +import { PageLayoutComponentInstanceContext } from '@/page-layout/states/contexts/PageLayoutComponentInstanceContext'; +import { pageLayoutCurrentLayoutsComponentState } from '@/page-layout/states/pageLayoutCurrentLayoutsComponentState'; +import { pageLayoutDraftComponentState } from '@/page-layout/states/pageLayoutDraftComponentState'; +import { pageLayoutGridDragHoveredTabIdComponentState } from '@/page-layout/states/pageLayoutGridDragHoveredTabIdComponentState'; +import { pageLayoutShouldIgnoreNextGridLayoutChangeComponentState } from '@/page-layout/states/pageLayoutShouldIgnoreNextGridLayoutChangeComponentState'; +import { buildTabWidgetLayouts } from '@/page-layout/utils/buildTabWidgetLayouts'; +import { moveWidgetToGridTabInDraft } from '@/page-layout/utils/moveWidgetToGridTabInDraft'; +import { useAvailableComponentInstanceIdOrThrow } from '@/ui/utilities/state/component-state/hooks/useAvailableComponentInstanceIdOrThrow'; +import { useAtomComponentStateCallbackState } from '@/ui/utilities/state/jotai/hooks/useAtomComponentStateCallbackState'; + +const findTabDropTargetIdAtPoint = ( + clientX: number, + clientY: number, +): string | null => { + const dropTargetElements = document.querySelectorAll( + `[${PAGE_LAYOUT_TAB_DROP_TARGET_DATA_ATTRIBUTE}]`, + ); + + for (const dropTargetElement of dropTargetElements) { + const rect = dropTargetElement.getBoundingClientRect(); + + if ( + clientX >= rect.left && + clientX <= rect.right && + clientY >= rect.top && + clientY <= rect.bottom + ) { + return dropTargetElement.getAttribute( + PAGE_LAYOUT_TAB_DROP_TARGET_DATA_ATTRIBUTE, + ); + } + } + + return null; +}; + +// Bridges react-grid-layout drags to the tab strip: grid drags never enter +// dnd-kit, so hovering and dropping on tab buttons is resolved by hit-testing +// the pointer against the tab drop targets. +export const usePageLayoutGridCrossTabDrop = ({ tabId }: { tabId: string }) => { + const pageLayoutId = useAvailableComponentInstanceIdOrThrow( + PageLayoutComponentInstanceContext, + ); + + const store = useStore(); + + const pageLayoutDraftState = useAtomComponentStateCallbackState( + pageLayoutDraftComponentState, + pageLayoutId, + ); + + const pageLayoutCurrentLayoutsState = useAtomComponentStateCallbackState( + pageLayoutCurrentLayoutsComponentState, + pageLayoutId, + ); + + const gridDragHoveredTabIdState = useAtomComponentStateCallbackState( + pageLayoutGridDragHoveredTabIdComponentState, + pageLayoutId, + ); + + const shouldIgnoreNextGridLayoutChangeState = + useAtomComponentStateCallbackState( + pageLayoutShouldIgnoreNextGridLayoutChangeComponentState, + pageLayoutId, + ); + + const findGridDropDestinationTabId = useCallback( + (clientX: number, clientY: number): string | null => { + const hoveredTabId = findTabDropTargetIdAtPoint(clientX, clientY); + + if (!isDefined(hoveredTabId) || hoveredTabId === tabId) { + return null; + } + + const draft = store.get(pageLayoutDraftState); + const destinationTab = draft.tabs.find((tab) => tab.id === hoveredTabId); + + if ( + !isDefined(destinationTab) || + destinationTab.layoutMode === PageLayoutTabLayoutMode.VERTICAL_LIST + ) { + return null; + } + + return hoveredTabId; + }, + [store, pageLayoutDraftState, tabId], + ); + + const handleGridDrag = useCallback( + (event: MouseEvent) => { + const destinationTabId = findGridDropDestinationTabId( + event.clientX, + event.clientY, + ); + + if (store.get(gridDragHoveredTabIdState) !== destinationTabId) { + store.set(gridDragHoveredTabIdState, destinationTabId); + } + }, + [store, gridDragHoveredTabIdState, findGridDropDestinationTabId], + ); + + const handleGridDragStop = useCallback( + (widgetId: string, event: MouseEvent): boolean => { + store.set(gridDragHoveredTabIdState, null); + + const destinationTabId = findGridDropDestinationTabId( + event.clientX, + event.clientY, + ); + + if (!isDefined(destinationTabId)) { + return false; + } + + const previousDraft = store.get(pageLayoutDraftState); + const updatedDraft = moveWidgetToGridTabInDraft(previousDraft, { + widgetId, + destinationTabId, + }); + + if (updatedDraft === previousDraft) { + return false; + } + + store.set(pageLayoutDraftState, updatedDraft); + + const sourceTab = updatedDraft.tabs.find((tab) => tab.id === tabId); + const destinationTab = updatedDraft.tabs.find( + (tab) => tab.id === destinationTabId, + ); + + store.set(pageLayoutCurrentLayoutsState, (previousLayouts) => ({ + ...previousLayouts, + ...(isDefined(sourceTab) + ? { [tabId]: buildTabWidgetLayouts(sourceTab.widgets) } + : {}), + ...(isDefined(destinationTab) + ? { + [destinationTabId]: buildTabWidgetLayouts(destinationTab.widgets), + } + : {}), + })); + + store.set(shouldIgnoreNextGridLayoutChangeState, true); + + return true; + }, + [ + store, + gridDragHoveredTabIdState, + findGridDropDestinationTabId, + pageLayoutDraftState, + pageLayoutCurrentLayoutsState, + shouldIgnoreNextGridLayoutChangeState, + tabId, + ], + ); + + const consumeShouldIgnoreNextGridLayoutChange = useCallback((): boolean => { + const shouldIgnore = store.get(shouldIgnoreNextGridLayoutChangeState); + + if (shouldIgnore) { + store.set(shouldIgnoreNextGridLayoutChangeState, false); + } + + return shouldIgnore; + }, [store, shouldIgnoreNextGridLayoutChangeState]); + + return { + handleGridDrag, + handleGridDragStop, + consumeShouldIgnoreNextGridLayoutChange, + }; +}; diff --git a/packages/twenty-front/src/modules/page-layout/hooks/usePageLayoutWidgetDragAndDrop.ts b/packages/twenty-front/src/modules/page-layout/hooks/usePageLayoutWidgetDragAndDrop.ts index 6dbd8ff5bd..b6eda53f2d 100644 --- a/packages/twenty-front/src/modules/page-layout/hooks/usePageLayoutWidgetDragAndDrop.ts +++ b/packages/twenty-front/src/modules/page-layout/hooks/usePageLayoutWidgetDragAndDrop.ts @@ -1,6 +1,5 @@ -import { type DragDropProvider } from '@dnd-kit/react'; import { useStore } from 'jotai'; -import { type ComponentProps, useCallback } from 'react'; +import { useCallback } from 'react'; import { isDefined } from 'twenty-shared/utils'; import { PageLayoutComponentInstanceContext } from '@/page-layout/states/contexts/PageLayoutComponentInstanceContext'; @@ -9,18 +8,16 @@ import { pageLayoutDraggingWidgetIdComponentState } from '@/page-layout/states/p import { type PageLayoutWidgetDndData } from '@/page-layout/types/PageLayoutWidgetDndData'; import { moveWidgetToTabInDraft } from '@/page-layout/utils/moveWidgetToTabInDraft'; import { moveWidgetWithinTabInDraft } from '@/page-layout/utils/moveWidgetWithinTabInDraft'; +import { reorderTabInDraft } from '@/page-layout/utils/reorderTabInDraft'; +import { type DragDropProviderDragEndEvent } from '@/ui/utilities/drag-and-drop/types/DragDropProviderDragEndEvent'; +import { type DragDropProviderDragStartEvent } from '@/ui/utilities/drag-and-drop/types/DragDropProviderDragStartEvent'; import { getDestinationIndex } from '@/ui/utilities/drag-and-drop/utils/getDestinationIndex'; import { useAvailableComponentInstanceIdOrThrow } from '@/ui/utilities/state/component-state/hooks/useAvailableComponentInstanceIdOrThrow'; import { useAtomComponentStateCallbackState } from '@/ui/utilities/state/jotai/hooks/useAtomComponentStateCallbackState'; import { useSetAtomComponentState } from '@/ui/utilities/state/jotai/hooks/useSetAtomComponentState'; -type Provider = typeof DragDropProvider; -type DragStartEvent = Parameters< - NonNullable['onDragStart']> ->[0]; -type DragEndEvent = Parameters< - NonNullable['onDragEnd']> ->[0]; +type DragStartEvent = DragDropProviderDragStartEvent; +type DragEndEvent = DragDropProviderDragEndEvent; export const usePageLayoutWidgetDragAndDrop = ( pageLayoutIdFromProps?: string, @@ -130,6 +127,31 @@ export const usePageLayoutWidgetDragAndDrop = ( } } + if ( + !event.canceled && + sourceData?.type === 'tab' && + isDefined(targetData) + ) { + const draggedTabId = sourceData.tabId; + + // The drop line renders before the hovered tab, so tab targets insert + // the dragged tab before them; end zones and the more button append. + const beforeTabId = + targetData.type === 'tab' + ? targetData.tabId + : targetData.type === 'tab-list-end' + ? targetData.beforeTabId + : targetData.type === 'tab-more-button' + ? null + : undefined; + + if (beforeTabId !== undefined) { + store.set(pageLayoutDraftState, (prev) => + reorderTabInDraft(prev, { tabId: draggedTabId, beforeTabId }), + ); + } + } + setPageLayoutDraggingWidgetId(null); }, [store, pageLayoutDraftState, setPageLayoutDraggingWidgetId], diff --git a/packages/twenty-front/src/modules/page-layout/hooks/useReorderPageLayoutTabs.ts b/packages/twenty-front/src/modules/page-layout/hooks/useReorderPageLayoutTabs.ts deleted file mode 100644 index efd7de109b..0000000000 --- a/packages/twenty-front/src/modules/page-layout/hooks/useReorderPageLayoutTabs.ts +++ /dev/null @@ -1,83 +0,0 @@ -import { PAGE_LAYOUT_TAB_LIST_DROPPABLE_IDS } from '@/page-layout/components/PageLayoutTabListDroppableIds'; -import { useCurrentPageLayout } from '@/page-layout/hooks/useCurrentPageLayout'; -import { usePageLayoutDraftState } from '@/page-layout/hooks/usePageLayoutDraftState'; -import { PageLayoutComponentInstanceContext } from '@/page-layout/states/contexts/PageLayoutComponentInstanceContext'; -import { sortTabsByPosition } from '@/page-layout/utils/sortTabsByPosition'; -import { useAvailableComponentInstanceIdOrThrow } from '@/ui/utilities/state/component-state/hooks/useAvailableComponentInstanceIdOrThrow'; -import { type DropResult } from '@hello-pangea/dnd'; -import { useCallback } from 'react'; -import { isDefined } from 'twenty-shared/utils'; - -export const useReorderPageLayoutTabs = (pageLayoutIdFromProps?: string) => { - const pageLayoutId = useAvailableComponentInstanceIdOrThrow( - PageLayoutComponentInstanceContext, - pageLayoutIdFromProps, - ); - - const { currentPageLayout } = useCurrentPageLayout(); - const { setPageLayoutDraft } = usePageLayoutDraftState(pageLayoutId); - - const reorderTabs = useCallback( - (result: DropResult): boolean => { - const { source, destination, draggableId } = result; - - if (!isDefined(destination) || !isDefined(currentPageLayout)) { - return false; - } - - if ( - source.droppableId === destination.droppableId && - source.index === destination.index - ) { - return false; - } - - const sortedTabs = sortTabsByPosition( - currentPageLayout.tabs.filter((tab) => tab.isActive), - ); - - const draggedTab = sortedTabs.find((tab) => tab.id === draggableId); - if (!isDefined(draggedTab)) { - return false; - } - - const isDropOnMoreButton = - destination.droppableId === - PAGE_LAYOUT_TAB_LIST_DROPPABLE_IDS.MORE_BUTTON; - - const orderedIds = sortedTabs - .map((tab) => tab.id) - .filter((id) => id !== draggableId); - - const movingBetweenDroppables = - source.droppableId !== destination.droppableId; - - const insertIndex = isDropOnMoreButton - ? orderedIds.length - : movingBetweenDroppables && destination.index > source.index - ? destination.index - 1 - : destination.index; - - orderedIds.splice(insertIndex, 0, draggableId); - - const newPositionById = new Map( - orderedIds.map((id, index) => [id, index]), - ); - - setPageLayoutDraft((prev) => ({ - ...prev, - tabs: prev.tabs.map((tab) => { - const newPosition = newPositionById.get(tab.id); - return isDefined(newPosition) - ? { ...tab, position: newPosition } - : tab; - }), - })); - - return isDropOnMoreButton; - }, - [currentPageLayout, setPageLayoutDraft], - ); - - return { reorderTabs }; -}; diff --git a/packages/twenty-front/src/modules/page-layout/hooks/useReorderRecordPageLayoutTabs.ts b/packages/twenty-front/src/modules/page-layout/hooks/useReorderRecordPageLayoutTabs.ts deleted file mode 100644 index fd3baed44e..0000000000 --- a/packages/twenty-front/src/modules/page-layout/hooks/useReorderRecordPageLayoutTabs.ts +++ /dev/null @@ -1,45 +0,0 @@ -import { useReorderPageLayoutTabs } from '@/page-layout/hooks/useReorderPageLayoutTabs'; -import { type DropResult, type ResponderProvided } from '@hello-pangea/dnd'; -import { useCallback } from 'react'; -import { isDefined } from 'twenty-shared/utils'; - -export const useReorderRecordPageLayoutTabs = ( - pageLayoutIdFromProps?: string, -) => { - const { reorderTabs } = useReorderPageLayoutTabs(pageLayoutIdFromProps); - - const reorderRecordPageTabs = useCallback( - ( - result: DropResult, - provided: ResponderProvided, - hasPinnedTab: boolean, - ): boolean => { - if (!hasPinnedTab) { - return reorderTabs(result); - } - - const { source, destination } = result; - - if (!isDefined(destination)) { - return reorderTabs(result); - } - - const adjustedResult: DropResult = { - ...result, - source: { - ...source, - index: source.index + 1, - }, - destination: { - ...destination, - index: destination.index + 1, - }, - }; - - return reorderTabs(adjustedResult); - }, - [reorderTabs], - ); - - return { reorderRecordPageTabs }; -}; diff --git a/packages/twenty-front/src/modules/page-layout/states/pageLayoutGridDragHoveredTabIdComponentState.ts b/packages/twenty-front/src/modules/page-layout/states/pageLayoutGridDragHoveredTabIdComponentState.ts new file mode 100644 index 0000000000..3a647309d8 --- /dev/null +++ b/packages/twenty-front/src/modules/page-layout/states/pageLayoutGridDragHoveredTabIdComponentState.ts @@ -0,0 +1,11 @@ +import { PageLayoutComponentInstanceContext } from '@/page-layout/states/contexts/PageLayoutComponentInstanceContext'; +import { createAtomComponentState } from '@/ui/utilities/state/jotai/utils/createAtomComponentState'; + +// Tab hovered by a react-grid-layout widget drag; grid drags never enter +// dnd-kit, so the tab highlight is driven through this state instead. +export const pageLayoutGridDragHoveredTabIdComponentState = + createAtomComponentState({ + key: 'pageLayoutGridDragHoveredTabIdComponentState', + defaultValue: null, + componentInstanceContext: PageLayoutComponentInstanceContext, + }); diff --git a/packages/twenty-front/src/modules/page-layout/states/pageLayoutShouldIgnoreNextGridLayoutChangeComponentState.ts b/packages/twenty-front/src/modules/page-layout/states/pageLayoutShouldIgnoreNextGridLayoutChangeComponentState.ts new file mode 100644 index 0000000000..7aa21f2d3e --- /dev/null +++ b/packages/twenty-front/src/modules/page-layout/states/pageLayoutShouldIgnoreNextGridLayoutChangeComponentState.ts @@ -0,0 +1,12 @@ +import { PageLayoutComponentInstanceContext } from '@/page-layout/states/contexts/PageLayoutComponentInstanceContext'; +import { createAtomComponentState } from '@/ui/utilities/state/jotai/utils/createAtomComponentState'; + +// A grid drop routed to another tab rebuilds both tabs' layouts itself; the +// grid's own post-drag layout commit must be skipped once so it does not +// overwrite the cross-tab move. +export const pageLayoutShouldIgnoreNextGridLayoutChangeComponentState = + createAtomComponentState({ + key: 'pageLayoutShouldIgnoreNextGridLayoutChangeComponentState', + defaultValue: false, + componentInstanceContext: PageLayoutComponentInstanceContext, + }); diff --git a/packages/twenty-front/src/modules/page-layout/states/pageLayoutTabListCurrentDragDroppableIdComponentState.ts b/packages/twenty-front/src/modules/page-layout/states/pageLayoutTabListCurrentDragDroppableIdComponentState.ts deleted file mode 100644 index cbbcbbaab4..0000000000 --- a/packages/twenty-front/src/modules/page-layout/states/pageLayoutTabListCurrentDragDroppableIdComponentState.ts +++ /dev/null @@ -1,10 +0,0 @@ -import { createAtomComponentState } from '@/ui/utilities/state/jotai/utils/createAtomComponentState'; - -import { PageLayoutComponentInstanceContext } from './contexts/PageLayoutComponentInstanceContext'; - -export const pageLayoutTabListCurrentDragDroppableIdComponentState = - createAtomComponentState({ - key: 'pageLayoutTabListCurrentDragDroppableIdComponentState', - defaultValue: undefined, - componentInstanceContext: PageLayoutComponentInstanceContext, - }); diff --git a/packages/twenty-front/src/modules/page-layout/testing/pageLayoutDraftFixtures.ts b/packages/twenty-front/src/modules/page-layout/testing/pageLayoutDraftFixtures.ts index e27436c738..17e5c12610 100644 --- a/packages/twenty-front/src/modules/page-layout/testing/pageLayoutDraftFixtures.ts +++ b/packages/twenty-front/src/modules/page-layout/testing/pageLayoutDraftFixtures.ts @@ -1,4 +1,5 @@ import { type DraftPageLayout } from '@/page-layout/types/DraftPageLayout'; +import { type PageLayoutTab } from '@/page-layout/types/PageLayoutTab'; import { type PageLayoutWidget } from '@/page-layout/types/PageLayoutWidget'; import { PageLayoutTabLayoutMode, @@ -34,6 +35,7 @@ export const makeTab = ( widgets: PageLayoutWidget[], position = 0, layoutMode: PageLayoutTabLayoutMode = PageLayoutTabLayoutMode.VERTICAL_LIST, + overrides?: Partial, ) => ({ id, applicationId: '', @@ -46,6 +48,7 @@ export const makeTab = ( createdAt: new Date().toISOString(), updatedAt: new Date().toISOString(), deletedAt: null, + ...overrides, }); export const makeDraft = ( diff --git a/packages/twenty-front/src/modules/page-layout/types/PageLayoutTabDragData.ts b/packages/twenty-front/src/modules/page-layout/types/PageLayoutTabDragData.ts new file mode 100644 index 0000000000..a4f6579915 --- /dev/null +++ b/packages/twenty-front/src/modules/page-layout/types/PageLayoutTabDragData.ts @@ -0,0 +1,4 @@ +export type PageLayoutTabDragData = { + type: 'tab'; + tabId: string; +}; diff --git a/packages/twenty-front/src/modules/page-layout/types/PageLayoutTabListEndDropData.ts b/packages/twenty-front/src/modules/page-layout/types/PageLayoutTabListEndDropData.ts new file mode 100644 index 0000000000..ec069af1e8 --- /dev/null +++ b/packages/twenty-front/src/modules/page-layout/types/PageLayoutTabListEndDropData.ts @@ -0,0 +1,5 @@ +// beforeTabId null means append after the last tab. +export type PageLayoutTabListEndDropData = { + type: 'tab-list-end'; + beforeTabId: string | null; +}; diff --git a/packages/twenty-front/src/modules/page-layout/types/PageLayoutTabMoreButtonDropData.ts b/packages/twenty-front/src/modules/page-layout/types/PageLayoutTabMoreButtonDropData.ts new file mode 100644 index 0000000000..77f2eb2ee1 --- /dev/null +++ b/packages/twenty-front/src/modules/page-layout/types/PageLayoutTabMoreButtonDropData.ts @@ -0,0 +1,3 @@ +export type PageLayoutTabMoreButtonDropData = { + type: 'tab-more-button'; +}; diff --git a/packages/twenty-front/src/modules/page-layout/types/PageLayoutTabWidgetDropData.ts b/packages/twenty-front/src/modules/page-layout/types/PageLayoutTabWidgetDropData.ts new file mode 100644 index 0000000000..633674e72b --- /dev/null +++ b/packages/twenty-front/src/modules/page-layout/types/PageLayoutTabWidgetDropData.ts @@ -0,0 +1,4 @@ +export type PageLayoutTabWidgetDropData = { + type: 'tab-widget-drop'; + tabId: string; +}; diff --git a/packages/twenty-front/src/modules/page-layout/types/PageLayoutWidgetDndData.ts b/packages/twenty-front/src/modules/page-layout/types/PageLayoutWidgetDndData.ts index 32253a537b..61640bcba9 100644 --- a/packages/twenty-front/src/modules/page-layout/types/PageLayoutWidgetDndData.ts +++ b/packages/twenty-front/src/modules/page-layout/types/PageLayoutWidgetDndData.ts @@ -1,21 +1,14 @@ -export type PageLayoutWidgetDragData = { - type: 'widget'; - widgetId: string; - tabId: string; - index: number; -}; - -export type PageLayoutTabWidgetDropData = { - type: 'tab-widget-drop'; - tabId: string; -}; - -export type PageLayoutWidgetListDropData = { - type: 'widget-list'; - tabId: string; -}; +import { type PageLayoutTabDragData } from '@/page-layout/types/PageLayoutTabDragData'; +import { type PageLayoutTabListEndDropData } from '@/page-layout/types/PageLayoutTabListEndDropData'; +import { type PageLayoutTabMoreButtonDropData } from '@/page-layout/types/PageLayoutTabMoreButtonDropData'; +import { type PageLayoutTabWidgetDropData } from '@/page-layout/types/PageLayoutTabWidgetDropData'; +import { type PageLayoutWidgetDragData } from '@/page-layout/types/PageLayoutWidgetDragData'; +import { type PageLayoutWidgetListDropData } from '@/page-layout/types/PageLayoutWidgetListDropData'; export type PageLayoutWidgetDndData = | PageLayoutWidgetDragData | PageLayoutTabWidgetDropData - | PageLayoutWidgetListDropData; + | PageLayoutWidgetListDropData + | PageLayoutTabDragData + | PageLayoutTabListEndDropData + | PageLayoutTabMoreButtonDropData; diff --git a/packages/twenty-front/src/modules/page-layout/types/PageLayoutWidgetDragData.ts b/packages/twenty-front/src/modules/page-layout/types/PageLayoutWidgetDragData.ts new file mode 100644 index 0000000000..fa9c30030d --- /dev/null +++ b/packages/twenty-front/src/modules/page-layout/types/PageLayoutWidgetDragData.ts @@ -0,0 +1,6 @@ +export type PageLayoutWidgetDragData = { + type: 'widget'; + widgetId: string; + tabId: string; + index: number; +}; diff --git a/packages/twenty-front/src/modules/page-layout/types/PageLayoutWidgetListDropData.ts b/packages/twenty-front/src/modules/page-layout/types/PageLayoutWidgetListDropData.ts new file mode 100644 index 0000000000..8086921715 --- /dev/null +++ b/packages/twenty-front/src/modules/page-layout/types/PageLayoutWidgetListDropData.ts @@ -0,0 +1,4 @@ +export type PageLayoutWidgetListDropData = { + type: 'widget-list'; + tabId: string; +}; diff --git a/packages/twenty-front/src/modules/page-layout/utils/__tests__/convertPageLayoutToTabLayouts.test.ts b/packages/twenty-front/src/modules/page-layout/utils/__tests__/convertPageLayoutToTabLayouts.test.ts index 029c249372..bcfe9b86cf 100644 --- a/packages/twenty-front/src/modules/page-layout/utils/__tests__/convertPageLayoutToTabLayouts.test.ts +++ b/packages/twenty-front/src/modules/page-layout/utils/__tests__/convertPageLayoutToTabLayouts.test.ts @@ -212,4 +212,59 @@ describe('convertPageLayoutToTabLayouts', () => { minH: richTextMinSize.h, }); }); + + it('should use the widget-type minimum size for iframe widgets', () => { + const pageLayout: PageLayout = { + id: 'page-layout-1', + name: 'Page Layout 1', + type: PageLayoutType.DASHBOARD, + objectMetadataId: null, + universalIdentifier: '20202020-0000-0000-0000-000000000001', + tabs: [ + { + id: 'tab-1', + applicationId: '', + isActive: true, + title: 'Tab 1', + position: 0, + pageLayoutId: 'page-layout-1', + widgets: [ + { + __typename: 'PageLayoutWidget', + id: 'iframe-widget', + applicationId: '', + isActive: true, + pageLayoutTabId: 'tab-1', + title: 'Iframe', + type: WidgetType.IFRAME, + configuration: { + configurationType: WidgetConfigurationType.IFRAME, + url: 'https://example.com', + }, + gridPosition: { row: 0, column: 0, rowSpan: 6, columnSpan: 6 }, + objectMetadataId: null, + createdAt: '2025-01-01T00:00:00.000Z', + updatedAt: '2025-01-01T00:00:00.000Z', + deletedAt: null, + }, + ], + createdAt: '2025-01-01T00:00:00.000Z', + updatedAt: '2025-01-01T00:00:00.000Z', + deletedAt: null, + }, + ], + createdAt: '2025-01-01T00:00:00.000Z', + updatedAt: '2025-01-01T00:00:00.000Z', + deletedAt: null, + }; + + const result = convertPageLayoutToTabLayouts(pageLayout); + const iframeMinSize = WIDGET_SIZES[WidgetType.IFRAME]!.minimum; + + expect(result['tab-1'].desktop[0]).toMatchObject({ + i: 'iframe-widget', + minW: iframeMinSize.w, + minH: iframeMinSize.h, + }); + }); }); diff --git a/packages/twenty-front/src/modules/page-layout/utils/__tests__/isReactivatableTab.test.ts b/packages/twenty-front/src/modules/page-layout/utils/__tests__/isReactivatableTab.test.ts index 3e339a772f..3c39d290e4 100644 --- a/packages/twenty-front/src/modules/page-layout/utils/__tests__/isReactivatableTab.test.ts +++ b/packages/twenty-front/src/modules/page-layout/utils/__tests__/isReactivatableTab.test.ts @@ -1,30 +1,15 @@ -import { type PageLayoutTab } from '@/page-layout/types/PageLayoutTab'; +import { makeTab } from '@/page-layout/testing/pageLayoutDraftFixtures'; import { isReactivatableTab } from '@/page-layout/utils/isReactivatableTab'; -const makeTab = (overrides: Partial = {}): PageLayoutTab => - ({ - id: 'tab-1', - applicationId: 'app-1', - title: 'Tab', - isActive: true, - position: 0, - pageLayoutId: '', - widgets: [], - createdAt: new Date().toISOString(), - updatedAt: new Date().toISOString(), - deletedAt: null, - ...overrides, - }) as unknown as PageLayoutTab; - describe('isReactivatableTab', () => { it('should return true when tab is inactive', () => { - const tab = makeTab({ isActive: false }); + const tab = makeTab('tab-1', [], 0, undefined, { isActive: false }); expect(isReactivatableTab(tab)).toBe(true); }); it('should return false when tab is active', () => { - const tab = makeTab({ isActive: true }); + const tab = makeTab('tab-1', [], 0, undefined, { isActive: true }); expect(isReactivatableTab(tab)).toBe(false); }); diff --git a/packages/twenty-front/src/modules/page-layout/utils/__tests__/moveWidgetToGridTabInDraft.test.ts b/packages/twenty-front/src/modules/page-layout/utils/__tests__/moveWidgetToGridTabInDraft.test.ts new file mode 100644 index 0000000000..056285c3c9 --- /dev/null +++ b/packages/twenty-front/src/modules/page-layout/utils/__tests__/moveWidgetToGridTabInDraft.test.ts @@ -0,0 +1,139 @@ +import { + makeDraft, + makeTab, + makeWidget, +} from '@/page-layout/testing/pageLayoutDraftFixtures'; +import { type PageLayoutWidget } from '@/page-layout/types/PageLayoutWidget'; +import { moveWidgetToGridTabInDraft } from '@/page-layout/utils/moveWidgetToGridTabInDraft'; +import { PageLayoutTabLayoutMode } from '~/generated-metadata/graphql'; + +const makeGridWidget = ( + id: string, + tabId: string, + gridPosition: { + row: number; + column: number; + rowSpan: number; + columnSpan: number; + }, +): PageLayoutWidget => ({ + ...makeWidget(id, 0, tabId), + gridPosition, + position: { + __typename: 'PageLayoutWidgetGridPosition' as const, + layoutMode: PageLayoutTabLayoutMode.GRID, + ...gridPosition, + }, +}); + +const makeGridTab = (id: string, widgets: PageLayoutWidget[], position = 0) => + makeTab(id, widgets, position, PageLayoutTabLayoutMode.GRID); + +describe('moveWidgetToGridTabInDraft', () => { + it('moves the widget below the lowest widget of the destination grid', () => { + const draft = makeDraft([ + makeGridTab('tab-1', [ + makeGridWidget('widget-a', 'tab-1', { + row: 0, + column: 0, + rowSpan: 4, + columnSpan: 6, + }), + ]), + makeGridTab( + 'tab-2', + [ + makeGridWidget('widget-b', 'tab-2', { + row: 2, + column: 3, + rowSpan: 5, + columnSpan: 4, + }), + ], + 1, + ), + ]); + + const result = moveWidgetToGridTabInDraft(draft, { + widgetId: 'widget-a', + destinationTabId: 'tab-2', + }); + + expect(result.tabs[0].widgets).toHaveLength(0); + expect(result.tabs[1].widgets.map((widget) => widget.id)).toEqual([ + 'widget-b', + 'widget-a', + ]); + + const movedWidget = result.tabs[1].widgets.find( + (widget) => widget.id === 'widget-a', + ); + expect(movedWidget?.pageLayoutTabId).toBe('tab-2'); + expect(movedWidget?.gridPosition).toEqual({ + row: 7, + column: 0, + rowSpan: 4, + columnSpan: 6, + }); + }); + + it('places the widget at the top of an empty destination grid', () => { + const draft = makeDraft([ + makeGridTab('tab-1', [ + makeGridWidget('widget-a', 'tab-1', { + row: 3, + column: 2, + rowSpan: 2, + columnSpan: 2, + }), + ]), + makeGridTab('tab-2', [], 1), + ]); + + const result = moveWidgetToGridTabInDraft(draft, { + widgetId: 'widget-a', + destinationTabId: 'tab-2', + }); + + const movedWidget = result.tabs[1].widgets[0]; + expect(movedWidget?.gridPosition).toEqual({ + row: 0, + column: 0, + rowSpan: 2, + columnSpan: 2, + }); + }); + + it('rejects vertical-list destinations and no-op moves', () => { + const draft = makeDraft([ + makeGridTab('tab-1', [ + makeGridWidget('widget-a', 'tab-1', { + row: 0, + column: 0, + rowSpan: 2, + columnSpan: 2, + }), + ]), + makeTab('tab-vertical', [], 1), + ]); + + expect( + moveWidgetToGridTabInDraft(draft, { + widgetId: 'widget-a', + destinationTabId: 'tab-vertical', + }), + ).toBe(draft); + expect( + moveWidgetToGridTabInDraft(draft, { + widgetId: 'widget-a', + destinationTabId: 'tab-1', + }), + ).toBe(draft); + expect( + moveWidgetToGridTabInDraft(draft, { + widgetId: 'missing', + destinationTabId: 'tab-1', + }), + ).toBe(draft); + }); +}); diff --git a/packages/twenty-front/src/modules/page-layout/utils/__tests__/reorderTabInDraft.test.ts b/packages/twenty-front/src/modules/page-layout/utils/__tests__/reorderTabInDraft.test.ts new file mode 100644 index 0000000000..0de2eb2c05 --- /dev/null +++ b/packages/twenty-front/src/modules/page-layout/utils/__tests__/reorderTabInDraft.test.ts @@ -0,0 +1,109 @@ +import { + makeDraft, + makeTab, +} from '@/page-layout/testing/pageLayoutDraftFixtures'; +import { type DraftPageLayout } from '@/page-layout/types/DraftPageLayout'; +import { reorderTabInDraft } from '@/page-layout/utils/reorderTabInDraft'; + +const orderOf = (draft: DraftPageLayout) => + [...draft.tabs] + .sort((tabA, tabB) => tabA.position - tabB.position) + .map((tab) => tab.id); + +describe('reorderTabInDraft', () => { + it('moves a tab before another tab', () => { + const draft = makeDraft([ + makeTab('tab-a', [], 0), + makeTab('tab-b', [], 1), + makeTab('tab-c', [], 2), + ]); + + const result = reorderTabInDraft(draft, { + tabId: 'tab-c', + beforeTabId: 'tab-a', + }); + + expect(orderOf(result)).toEqual(['tab-c', 'tab-a', 'tab-b']); + }); + + it('moves a tab forward before a later tab, compensating for its removal', () => { + const draft = makeDraft([ + makeTab('tab-a', [], 0), + makeTab('tab-b', [], 1), + makeTab('tab-c', [], 2), + ]); + + const result = reorderTabInDraft(draft, { + tabId: 'tab-a', + beforeTabId: 'tab-c', + }); + + expect(orderOf(result)).toEqual(['tab-b', 'tab-a', 'tab-c']); + }); + + it('appends the tab at the end when beforeTabId is null', () => { + const draft = makeDraft([ + makeTab('tab-a', [], 0), + makeTab('tab-b', [], 1), + makeTab('tab-c', [], 2), + ]); + + const result = reorderTabInDraft(draft, { + tabId: 'tab-a', + beforeTabId: null, + }); + + expect(orderOf(result)).toEqual(['tab-b', 'tab-c', 'tab-a']); + }); + + it('keeps tabs that are not rendered in the tab list in place', () => { + const draft = makeDraft([ + makeTab('pinned-tab', [], 0), + makeTab('tab-a', [], 1), + makeTab('tab-b', [], 2), + ]); + + const result = reorderTabInDraft(draft, { + tabId: 'tab-b', + beforeTabId: 'tab-a', + }); + + expect(orderOf(result)).toEqual(['pinned-tab', 'tab-b', 'tab-a']); + }); + + it('ignores inactive tabs when computing positions', () => { + const draft = makeDraft([ + makeTab('tab-a', [], 0), + makeTab('tab-inactive', [], 1, undefined, { isActive: false }), + makeTab('tab-b', [], 2), + ]); + + const result = reorderTabInDraft(draft, { + tabId: 'tab-b', + beforeTabId: 'tab-a', + }); + + expect(result.tabs.find((tab) => tab.id === 'tab-inactive')?.position).toBe( + 1, + ); + expect(result.tabs.find((tab) => tab.id === 'tab-b')?.position).toBe(0); + expect(result.tabs.find((tab) => tab.id === 'tab-a')?.position).toBe(1); + }); + + it('returns the draft unchanged when the move is a no-op', () => { + const draft = makeDraft([makeTab('tab-a', [], 0), makeTab('tab-b', [], 1)]); + + expect( + reorderTabInDraft(draft, { tabId: 'tab-a', beforeTabId: 'tab-b' }), + ).toBe(draft); + expect( + reorderTabInDraft(draft, { tabId: 'tab-a', beforeTabId: 'tab-a' }), + ).toBe(draft); + expect( + reorderTabInDraft(draft, { tabId: 'missing', beforeTabId: null }), + ).toBe(draft); + expect( + reorderTabInDraft(draft, { tabId: 'tab-a', beforeTabId: 'missing' }), + ).toBe(draft); + }); +}); diff --git a/packages/twenty-front/src/modules/page-layout/utils/__tests__/sortWidgetsByVerticalListPosition.test.ts b/packages/twenty-front/src/modules/page-layout/utils/__tests__/sortWidgetsByVerticalListPosition.test.ts index cec7559113..84afc83605 100644 --- a/packages/twenty-front/src/modules/page-layout/utils/__tests__/sortWidgetsByVerticalListPosition.test.ts +++ b/packages/twenty-front/src/modules/page-layout/utils/__tests__/sortWidgetsByVerticalListPosition.test.ts @@ -1,33 +1,6 @@ +import { makeWidget } from '@/page-layout/testing/pageLayoutDraftFixtures'; import { type PageLayoutWidget } from '@/page-layout/types/PageLayoutWidget'; import { sortWidgetsByVerticalListPosition } from '@/page-layout/utils/sortWidgetsByVerticalListPosition'; -import { - PageLayoutTabLayoutMode, - WidgetConfigurationType, - WidgetType, -} from '~/generated-metadata/graphql'; - -const makeWidget = (id: string, index: number): PageLayoutWidget => - ({ - __typename: 'PageLayoutWidget', - id, - pageLayoutTabId: 'tab-1', - title: id, - type: WidgetType.FIELDS, - gridPosition: { row: 0, column: 0, rowSpan: 1, columnSpan: 1 }, - configuration: { - __typename: 'FieldsConfiguration', - configurationType: WidgetConfigurationType.FIELDS, - }, - position: { - __typename: 'PageLayoutWidgetVerticalListPosition', - layoutMode: PageLayoutTabLayoutMode.VERTICAL_LIST, - index, - }, - objectMetadataId: null, - createdAt: '2024-01-01T00:00:00Z', - updatedAt: '2024-01-01T00:00:00Z', - deletedAt: null, - }) as unknown as PageLayoutWidget; describe('sortWidgetsByVerticalListPosition', () => { it('should sort widgets by index ascending', () => { diff --git a/packages/twenty-front/src/modules/page-layout/utils/buildTabWidgetLayouts.ts b/packages/twenty-front/src/modules/page-layout/utils/buildTabWidgetLayouts.ts new file mode 100644 index 0000000000..7d0cabfaa4 --- /dev/null +++ b/packages/twenty-front/src/modules/page-layout/utils/buildTabWidgetLayouts.ts @@ -0,0 +1,47 @@ +import { type Layouts } from 'react-grid-layout'; +import { DEFAULT_WIDGET_SIZE } from 'twenty-shared/constants'; +import { isDefined } from 'twenty-shared/utils'; + +import { WIDGET_SIZES } from '@/page-layout/constants/WidgetSizes'; +import { type PageLayoutWidget } from '@/page-layout/types/PageLayoutWidget'; +import { getWidgetGridPosition } from '@/page-layout/utils/getWidgetGridPosition'; +import { getWidgetSize } from '@/page-layout/utils/getWidgetSize'; + +const getWidgetMinimumSize = (widget: PageLayoutWidget) => { + const typeMinimum = WIDGET_SIZES[widget.type]?.minimum; + + if (isDefined(typeMinimum)) { + return typeMinimum; + } + + if ( + isDefined(widget.configuration) && + widget.configuration.__typename !== 'FieldsConfiguration' + ) { + return getWidgetSize(widget.configuration.configurationType, 'minimum'); + } + + return DEFAULT_WIDGET_SIZE.minimum; +}; + +export const buildTabWidgetLayouts = (widgets: PageLayoutWidget[]): Layouts => { + const layouts = widgets.map((widget) => { + const minimumSize = getWidgetMinimumSize(widget); + const gridPos = getWidgetGridPosition(widget); + + return { + i: widget.id, + x: gridPos?.column ?? 0, + y: gridPos?.row ?? 0, + w: gridPos?.columnSpan ?? DEFAULT_WIDGET_SIZE.default.w, + h: gridPos?.rowSpan ?? DEFAULT_WIDGET_SIZE.default.h, + minW: minimumSize.w, + minH: minimumSize.h, + }; + }); + + return { + desktop: layouts, + mobile: layouts.map((layout) => ({ ...layout, w: 1, x: 0 })), + }; +}; diff --git a/packages/twenty-front/src/modules/page-layout/utils/convertPageLayoutToTabLayouts.ts b/packages/twenty-front/src/modules/page-layout/utils/convertPageLayoutToTabLayouts.ts index abd95484b0..51e117681e 100644 --- a/packages/twenty-front/src/modules/page-layout/utils/convertPageLayoutToTabLayouts.ts +++ b/packages/twenty-front/src/modules/page-layout/utils/convertPageLayoutToTabLayouts.ts @@ -1,8 +1,6 @@ -import { DEFAULT_WIDGET_SIZE } from 'twenty-shared/constants'; import { type PageLayout } from '@/page-layout/types/PageLayout'; import { type TabLayouts } from '@/page-layout/types/TabLayouts'; -import { getWidgetSize } from '@/page-layout/utils/getWidgetSize'; -import { isDefined } from 'twenty-shared/utils'; +import { buildTabWidgetLayouts } from '@/page-layout/utils/buildTabWidgetLayouts'; export const convertPageLayoutToTabLayouts = ( pageLayout: PageLayout, @@ -14,50 +12,7 @@ export const convertPageLayoutToTabLayouts = ( const tabLayouts: TabLayouts = {}; pageLayout.tabs.forEach((tab) => { - const layouts = tab.widgets.map((widget) => { - let minW = DEFAULT_WIDGET_SIZE.minimum.w; - let minH = DEFAULT_WIDGET_SIZE.minimum.h; - - if (isDefined(widget.configuration)) { - if (widget.configuration.__typename === 'FieldsConfiguration') { - minW = DEFAULT_WIDGET_SIZE.minimum.w; - minH = DEFAULT_WIDGET_SIZE.minimum.h; - } else { - const minimumSize = getWidgetSize( - widget.configuration.configurationType, - 'minimum', - ); - minW = minimumSize.w; - minH = minimumSize.h; - } - } - - const gridPos = - isDefined(widget.position) && - widget.position.__typename === 'PageLayoutWidgetGridPosition' - ? { - row: widget.position.row, - column: widget.position.column, - rowSpan: widget.position.rowSpan, - columnSpan: widget.position.columnSpan, - } - : widget.gridPosition; - - return { - i: widget.id, - x: gridPos?.column ?? 0, - y: gridPos?.row ?? 0, - w: gridPos?.columnSpan ?? DEFAULT_WIDGET_SIZE.default.w, - h: gridPos?.rowSpan ?? DEFAULT_WIDGET_SIZE.default.h, - minW, - minH, - }; - }); - - tabLayouts[tab.id] = { - desktop: layouts, - mobile: layouts.map((layout) => ({ ...layout, w: 1, x: 0 })), - }; + tabLayouts[tab.id] = buildTabWidgetLayouts(tab.widgets); }); return tabLayouts; diff --git a/packages/twenty-front/src/modules/page-layout/utils/getWidgetGridPosition.ts b/packages/twenty-front/src/modules/page-layout/utils/getWidgetGridPosition.ts new file mode 100644 index 0000000000..198d17233f --- /dev/null +++ b/packages/twenty-front/src/modules/page-layout/utils/getWidgetGridPosition.ts @@ -0,0 +1,11 @@ +import { isDefined } from 'twenty-shared/utils'; + +import { type PageLayoutWidget } from '@/page-layout/types/PageLayoutWidget'; + +// Widgets carry their grid position either as the GraphQL union or as the +// plain gridPosition field depending on where they came from; the union wins. +export const getWidgetGridPosition = (widget: PageLayoutWidget) => + isDefined(widget.position) && + widget.position.__typename === 'PageLayoutWidgetGridPosition' + ? widget.position + : widget.gridPosition; diff --git a/packages/twenty-front/src/modules/page-layout/utils/moveWidgetToGridTabInDraft.ts b/packages/twenty-front/src/modules/page-layout/utils/moveWidgetToGridTabInDraft.ts new file mode 100644 index 0000000000..5d470d9f7a --- /dev/null +++ b/packages/twenty-front/src/modules/page-layout/utils/moveWidgetToGridTabInDraft.ts @@ -0,0 +1,94 @@ +import { isDefined } from 'twenty-shared/utils'; +import { PageLayoutTabLayoutMode } from '~/generated-metadata/graphql'; + +import { type DraftPageLayout } from '@/page-layout/types/DraftPageLayout'; +import { type PageLayoutWidget } from '@/page-layout/types/PageLayoutWidget'; +import { getWidgetGridPosition } from '@/page-layout/utils/getWidgetGridPosition'; + +type MoveWidgetToGridTabInDraftParams = { + widgetId: string; + destinationTabId: string; +}; + +// Moves a widget into a grid tab, placing it full-left below the lowest +// existing widget so it never overlaps the destination layout. +export const moveWidgetToGridTabInDraft = ( + draft: DraftPageLayout, + { widgetId, destinationTabId }: MoveWidgetToGridTabInDraftParams, +): DraftPageLayout => { + const sourceTab = draft.tabs.find((tab) => + tab.widgets.some((widget) => widget.id === widgetId), + ); + + if (!isDefined(sourceTab) || sourceTab.id === destinationTabId) { + return draft; + } + + const destinationTab = draft.tabs.find((tab) => tab.id === destinationTabId); + + if ( + !isDefined(destinationTab) || + destinationTab.layoutMode === PageLayoutTabLayoutMode.VERTICAL_LIST + ) { + return draft; + } + + const widget = sourceTab.widgets.find( + (candidateWidget) => candidateWidget.id === widgetId, + ); + + if (!isDefined(widget)) { + return draft; + } + + const destinationBottomRow = destinationTab.widgets.reduce( + (bottomRow, destinationWidget) => { + const gridPosition = getWidgetGridPosition(destinationWidget); + + return Math.max( + bottomRow, + (gridPosition?.row ?? 0) + (gridPosition?.rowSpan ?? 0), + ); + }, + 0, + ); + + const widgetGridPosition = getWidgetGridPosition(widget); + const rowSpan = widgetGridPosition?.rowSpan ?? 2; + const columnSpan = widgetGridPosition?.columnSpan ?? 2; + + const movedWidget: PageLayoutWidget = { + ...widget, + pageLayoutTabId: destinationTabId, + gridPosition: { + row: destinationBottomRow, + column: 0, + rowSpan, + columnSpan, + }, + position: { + __typename: 'PageLayoutWidgetGridPosition' as const, + layoutMode: PageLayoutTabLayoutMode.GRID, + row: destinationBottomRow, + column: 0, + rowSpan, + columnSpan, + }, + }; + + return { + ...draft, + tabs: draft.tabs.map((tab) => { + if (tab.id === sourceTab.id) { + return { + ...tab, + widgets: tab.widgets.filter((tabWidget) => tabWidget.id !== widgetId), + }; + } + if (tab.id === destinationTabId) { + return { ...tab, widgets: [...tab.widgets, movedWidget] }; + } + return tab; + }), + }; +}; diff --git a/packages/twenty-front/src/modules/page-layout/utils/reorderTabInDraft.ts b/packages/twenty-front/src/modules/page-layout/utils/reorderTabInDraft.ts new file mode 100644 index 0000000000..7c87e5d657 --- /dev/null +++ b/packages/twenty-front/src/modules/page-layout/utils/reorderTabInDraft.ts @@ -0,0 +1,54 @@ +import { type DraftPageLayout } from '@/page-layout/types/DraftPageLayout'; +import { sortTabsByPosition } from '@/page-layout/utils/sortTabsByPosition'; +import { isDefined } from 'twenty-shared/utils'; + +type ReorderTabInDraftParams = { + tabId: string; + beforeTabId: string | null; +}; + +// Repositions a tab relative to another one; beforeTabId null appends it after +// the last active tab. Operating on ids instead of list indices keeps tabs not +// rendered in the tab list (the pinned first tab) in place without index +// arithmetic. +export const reorderTabInDraft = ( + draft: DraftPageLayout, + { tabId, beforeTabId }: ReorderTabInDraftParams, +): DraftPageLayout => { + const orderedIds = sortTabsByPosition( + draft.tabs.filter((tab) => tab.isActive), + ).map((tab) => tab.id); + + if (!orderedIds.includes(tabId)) { + return draft; + } + + const reorderedIds = orderedIds.filter( + (candidateTabId) => candidateTabId !== tabId, + ); + + const insertIndex = isDefined(beforeTabId) + ? reorderedIds.indexOf(beforeTabId) + : reorderedIds.length; + + if (insertIndex < 0) { + return draft; + } + + reorderedIds.splice(insertIndex, 0, tabId); + + if (reorderedIds.every((id, index) => id === orderedIds[index])) { + return draft; + } + + const newPositionById = new Map(reorderedIds.map((id, index) => [id, index])); + + return { + ...draft, + tabs: draft.tabs.map((tab) => { + const newPosition = newPositionById.get(tab.id); + + return isDefined(newPosition) ? { ...tab, position: newPosition } : tab; + }), + }; +}; diff --git a/packages/twenty-front/src/modules/page-layout/widgets/fields/components/FieldsConfigurationEditor.tsx b/packages/twenty-front/src/modules/page-layout/widgets/fields/components/FieldsConfigurationEditor.tsx index c70e073d66..0fea7d5ef0 100644 --- a/packages/twenty-front/src/modules/page-layout/widgets/fields/components/FieldsConfigurationEditor.tsx +++ b/packages/twenty-front/src/modules/page-layout/widgets/fields/components/FieldsConfigurationEditor.tsx @@ -1,9 +1,4 @@ -import { - DragDropContext, - Draggable, - Droppable, - type DropResult, -} from '@hello-pangea/dnd'; +import { DragDropProvider } from '@dnd-kit/react'; import { styled } from '@linaria/react'; import { useContextStoreObjectMetadataItemOrThrow } from '@/context-store/hooks/useContextStoreObjectMetadataItemOrThrow'; @@ -11,17 +6,25 @@ import { fieldsWidgetGroupsDraftComponentState } from '@/page-layout/states/fiel import { fieldsWidgetUngroupedFieldsDraftComponentState } from '@/page-layout/states/fieldsWidgetUngroupedFieldsDraftComponentState'; import { FieldsConfigurationGroupEditor } from '@/page-layout/widgets/fields/components/FieldsConfigurationGroupEditor'; import { FieldsConfigurationUngroupedEditor } from '@/page-layout/widgets/fields/components/FieldsConfigurationUngroupedEditor'; +import { FIELDS_CONFIGURATION_GROUP_DND_TYPE } from '@/page-layout/widgets/fields/constants/FieldsConfigurationGroupDndType'; +import { FIELDS_CONFIGURATION_GROUPS_DROPPABLE_ID } from '@/page-layout/widgets/fields/constants/FieldsConfigurationGroupsDroppableId'; import { useCreateFieldsWidgetEditorGroup } from '@/page-layout/widgets/fields/hooks/useCreateFieldsWidgetEditorGroup'; import { useDeleteFieldsWidgetEditorGroup } from '@/page-layout/widgets/fields/hooks/useDeleteFieldsWidgetEditorGroup'; +import { useFieldsConfigurationEditorDragAndDrop } from '@/page-layout/widgets/fields/hooks/useFieldsConfigurationEditorDragAndDrop'; import { useFieldsWidgetEditorMode } from '@/page-layout/widgets/fields/hooks/useFieldsWidgetEditorMode'; -import { useMoveFieldInDraft } from '@/page-layout/widgets/fields/hooks/useMoveFieldInDraft'; import { useMoveUngroupedFieldInDraft } from '@/page-layout/widgets/fields/hooks/useMoveUngroupedFieldInDraft'; -import { useReorderFieldsWidgetEditorGroups } from '@/page-layout/widgets/fields/hooks/useReorderFieldsWidgetEditorGroups'; import { useToggleFieldVisibilityInDraft } from '@/page-layout/widgets/fields/hooks/useToggleFieldVisibilityInDraft'; import { useToggleUngroupedFieldVisibilityInDraft } from '@/page-layout/widgets/fields/hooks/useToggleUngroupedFieldVisibilityInDraft'; import { useUpdateFieldsWidgetEditorGroup } from '@/page-layout/widgets/fields/hooks/useUpdateFieldsWidgetEditorGroup'; +import { type FieldsConfigurationDndData } from '@/page-layout/widgets/fields/types/FieldsConfigurationDndData'; +import { type FieldsConfigurationGroupDragData } from '@/page-layout/widgets/fields/types/FieldsConfigurationGroupDragData'; +import { type FieldsConfigurationGroupListEndDropData } from '@/page-layout/widgets/fields/types/FieldsConfigurationGroupListEndDropData'; import { getFieldsConfigurationGroupRenameDropdownId } from '@/page-layout/widgets/fields/utils/getFieldsConfigurationGroupRenameDropdownId'; import { useOpenDropdown } from '@/ui/layout/dropdown/hooks/useOpenDropdown'; +import { DragDropItemEndDropZone } from '@/ui/utilities/drag-and-drop/components/DragDropItemEndDropZone'; +import { DragDropItemSortableCell } from '@/ui/utilities/drag-and-drop/components/DragDropItemSortableCell'; +import { DND_KIT_PROVIDER_PLUGINS_WITHOUT_DROP_ANIMATION } from '@/ui/utilities/drag-and-drop/constants/DndKitProviderPluginsWithoutDropAnimation'; +import { DND_KIT_SENSORS } from '@/ui/utilities/drag-and-drop/constants/DndKitSensors'; import { useAtomComponentStateValue } from '@/ui/utilities/state/jotai/hooks/useAtomComponentStateValue'; import { useLingui } from '@lingui/react/macro'; import { useState } from 'react'; @@ -38,6 +41,10 @@ const StyledAddGroupButtonContainer = styled.div` border: 1px solid transparent; `; +const GROUPS_END_DROP_DATA: FieldsConfigurationGroupListEndDropData = { + type: 'group-list-end', +}; + type FieldsConfigurationEditorProps = { pageLayoutId: string; widgetId: string; @@ -74,15 +81,12 @@ export const FieldsConfigurationEditor = ({ widgetId, }); - const { reorderGroups } = useReorderFieldsWidgetEditorGroups({ - pageLayoutId, - widgetId, - }); - - const { moveField } = useMoveFieldInDraft({ - pageLayoutId, - widgetId, - }); + const { draggingGroupId, handlers } = useFieldsConfigurationEditorDragAndDrop( + { + pageLayoutId, + widgetId, + }, + ); const { toggleFieldVisibility } = useToggleFieldVisibilityInDraft({ pageLayoutId, @@ -132,64 +136,6 @@ export const FieldsConfigurationEditor = ({ deleteGroup(groupId); }; - const handleDragEnd = (result: DropResult) => { - const { source, destination, type } = result; - - if (!destination) { - return; - } - - if ( - source.droppableId === destination.droppableId && - source.index === destination.index - ) { - return; - } - - if (type === 'GROUP') { - handleGroupReorder(source.index, destination.index); - } else if (type === 'FIELD') { - handleFieldMove( - source.droppableId, - destination.droppableId, - source.index, - destination.index, - ); - } - }; - - const handleGroupReorder = ( - sourceIndex: number, - destinationIndex: number, - ) => { - const sortedGroups = [...draftGroups].sort( - (a, b) => a.position - b.position, - ); - - const reorderedGroupIds = sortedGroups.map((g) => g.id); - const [movedGroupId] = reorderedGroupIds.splice(sourceIndex, 1); - reorderedGroupIds.splice(destinationIndex, 0, movedGroupId); - - reorderGroups(reorderedGroupIds); - }; - - const handleFieldMove = ( - sourceGroupId: string, - destinationGroupId: string, - sourceIndex: number, - destinationIndex: number, - ) => { - const cleanSourceGroupId = sourceGroupId.replace('group-', ''); - const cleanDestinationGroupId = destinationGroupId.replace('group-', ''); - - moveField( - cleanSourceGroupId, - cleanDestinationGroupId, - sourceIndex, - destinationIndex, - ); - }; - const handleAddGroup = ({ afterGroupId }: { afterGroupId?: string }) => { const newGroupName = t`New Group`; const newGroupId = createGroup({ name: newGroupName, afterGroupId }); @@ -219,56 +165,66 @@ export const FieldsConfigurationEditor = ({ } return ( - - - {(provided) => ( - - {sortedGroups.map((group, index) => ( - - {(draggableProvided, snapshot) => ( - - handleAddGroup({ afterGroupId: group.id }) - } - onToggleFieldVisibility={(fieldMetadataId) => - toggleFieldVisibility(group.id, fieldMetadataId) - } - onRenameGroup={handleRenameGroup} - onDeleteGroup={handleDeleteGroup} - renamingGroupValue={renamingGroupValue} - onRenamingGroupValueChange={setRenamingGroupValue} - onStartRename={handleStartRename} - /> - )} - - ))} - {provided.placeholder} + + sensors={DND_KIT_SENSORS} + plugins={DND_KIT_PROVIDER_PLUGINS_WITHOUT_DROP_ANIMATION} + onDragStart={handlers.onDragStart} + onDragEnd={handlers.onDragEnd} + > + + {sortedGroups.map((group, index) => { + const groupDragData: FieldsConfigurationGroupDragData = { + type: 'group', + groupId: group.id, + index, + }; - - handleAddGroup({})} - withIconContainer - withIconContainerBackground={false} + return ( + + handleAddGroup({ afterGroupId: group.id })} + onToggleFieldVisibility={(fieldMetadataId) => + toggleFieldVisibility(group.id, fieldMetadataId) + } + onRenameGroup={handleRenameGroup} + onDeleteGroup={handleDeleteGroup} + renamingGroupValue={renamingGroupValue} + onRenamingGroupValueChange={setRenamingGroupValue} + onStartRename={handleStartRename} /> - - - )} - - + + ); + })} + + + + handleAddGroup({})} + withIconContainer + withIconContainerBackground={false} + /> + + + + ); }; diff --git a/packages/twenty-front/src/modules/page-layout/widgets/fields/components/FieldsConfigurationGroupEditor.tsx b/packages/twenty-front/src/modules/page-layout/widgets/fields/components/FieldsConfigurationGroupEditor.tsx index 5348453d92..b6613fa4cc 100644 --- a/packages/twenty-front/src/modules/page-layout/widgets/fields/components/FieldsConfigurationGroupEditor.tsx +++ b/packages/twenty-front/src/modules/page-layout/widgets/fields/components/FieldsConfigurationGroupEditor.tsx @@ -1,14 +1,13 @@ -import { Droppable, type DraggableProvided } from '@hello-pangea/dnd'; import { styled } from '@linaria/react'; import { useLingui } from '@lingui/react/macro'; -import { DraggableItem } from '@/ui/layout/draggable-list/components/DraggableItem'; -import { getCssCompatibleDraggableProps } from '@/ui/layout/draggable-list/utils/getCssCompatibleDraggableProps'; - import { type EnrichedObjectMetadataItem } from '@/object-metadata/types/EnrichedObjectMetadataItem'; import { FieldsConfigurationFieldEditor } from '@/page-layout/widgets/fields/components/FieldsConfigurationFieldEditor'; import { FieldsConfigurationGroupDropdown } from '@/page-layout/widgets/fields/components/FieldsConfigurationGroupDropdown'; import { FieldsConfigurationGroupRenameInput } from '@/page-layout/widgets/fields/components/FieldsConfigurationGroupRenameInput'; +import { FIELDS_CONFIGURATION_FIELD_DND_TYPE } from '@/page-layout/widgets/fields/constants/FieldsConfigurationFieldDndType'; +import { type FieldsConfigurationFieldDragData } from '@/page-layout/widgets/fields/types/FieldsConfigurationFieldDragData'; +import { type FieldsConfigurationFieldListEndDropData } from '@/page-layout/widgets/fields/types/FieldsConfigurationFieldListEndDropData'; import { type FieldsWidgetGroup } from '@/page-layout/widgets/fields/types/FieldsWidgetGroup'; import { getFieldsConfigurationGroupRenameDropdownId } from '@/page-layout/widgets/fields/utils/getFieldsConfigurationGroupRenameDropdownId'; import { Dropdown } from '@/ui/layout/dropdown/components/Dropdown'; @@ -16,6 +15,9 @@ import { DropdownContent } from '@/ui/layout/dropdown/components/DropdownContent import { GenericDropdownContentWidth } from '@/ui/layout/dropdown/constants/GenericDropdownContentWidth'; import { useCloseDropdown } from '@/ui/layout/dropdown/hooks/useCloseDropdown'; import { useOpenDropdown } from '@/ui/layout/dropdown/hooks/useOpenDropdown'; +import { DragDropItemEndDropZone } from '@/ui/utilities/drag-and-drop/components/DragDropItemEndDropZone'; +import { DragDropItemSortableCell } from '@/ui/utilities/drag-and-drop/components/DragDropItemSortableCell'; +import { DragDropItemSortableHandle } from '@/ui/utilities/drag-and-drop/components/DragDropItemSortableHandle'; import { FieldsConfigurationGroupDraggableHeader } from '@/page-layout/widgets/fields/components/FieldsConfigurationGroupDraggableHeader'; import { themeCssVariables } from 'twenty-ui/theme-constants'; @@ -25,7 +27,7 @@ const StyledFieldsDroppable = styled.div` flex-direction: column; `; -const StyledEmptyGroupDropZone = styled.div` +const StyledEmptyGroupDropZone = styled(DragDropItemEndDropZone)` align-items: center; border: 1px dashed ${themeCssVariables.border.color.medium}; border-radius: ${themeCssVariables.border.radius.sm}; @@ -37,6 +39,12 @@ const StyledEmptyGroupDropZone = styled.div` min-height: ${themeCssVariables.spacing[10]}; `; +// Kept tall enough that appending after the group's last field stays an easy +// target. +const StyledFieldsEndDropZone = styled(DragDropItemEndDropZone)` + min-height: ${themeCssVariables.spacing[4]}; +`; + const StyledGroupContainer = styled.div<{ isDragging: boolean }>` background: ${({ isDragging }) => isDragging ? themeCssVariables.background.primary : 'transparent'}; @@ -71,9 +79,7 @@ const StyledDropdownContainer = styled.div` type FieldsConfigurationGroupEditorProps = { group: FieldsWidgetGroup; - index: number; objectMetadataItem: EnrichedObjectMetadataItem; - draggableProvided: DraggableProvided; isDragging: boolean; onAddGroup?: () => void; onToggleFieldVisibility: (fieldMetadataId: string) => void; @@ -86,7 +92,6 @@ type FieldsConfigurationGroupEditorProps = { export const FieldsConfigurationGroupEditor = ({ group, - draggableProvided, isDragging, onAddGroup, onToggleFieldVisibility, @@ -127,45 +132,48 @@ export const FieldsConfigurationGroupEditor = ({ onRenameGroup({ groupId, newName }); }; + const fieldsEndDropData: FieldsConfigurationFieldListEndDropData = { + type: 'field-list-end', + groupId: group.id, + }; + const sortedFields = [...group.fields].sort( (a, b) => a.position - b.position, ); return ( - - {/* oxlint-disable-next-line react/jsx-props-no-spreading */} - - - - - } - disableClickForClickableComponent - dropdownPlacement="bottom-start" - dropdownOffset={{ x: 32 }} - onClose={handleCancelRename} - dropdownComponents={ - - - handleRenameGroup({ groupId: group.id, newName }) - } - onCancel={handleCancelRename} - /> - - } - /> + + + + + + + } + disableClickForClickableComponent + dropdownPlacement="bottom-start" + dropdownOffset={{ x: 32 }} + onClose={handleCancelRename} + dropdownComponents={ + + + handleRenameGroup({ groupId: group.id, newName }) + } + onCancel={handleCancelRename} + /> + + } + /> + - - {(droppableProvided) => ( - + {sortedFields.length === 0 ? ( + - {sortedFields.length === 0 && ( - - {t`Drop fields here`} - - )} + {t`Drop fields here`} + + ) : ( + <> {sortedFields.map((field, fieldIndex) => { + const fieldDragData: FieldsConfigurationFieldDragData = { + type: 'field', + groupId: group.id, + index: fieldIndex, + }; + return ( - { - onToggleFieldVisibility(field.fieldMetadataItem.id); - }} - /> - } - /> + group={group.id} + data={fieldDragData} + type={FIELDS_CONFIGURATION_FIELD_DND_TYPE} + accept={FIELDS_CONFIGURATION_FIELD_DND_TYPE} + hasTransition={false} + highlightWhileDragging + dropLine="horizontal" + > + { + onToggleFieldVisibility(field.fieldMetadataItem.id); + }} + /> + ); })} - {droppableProvided.placeholder} - + + )} - + ); }; diff --git a/packages/twenty-front/src/modules/page-layout/widgets/fields/components/FieldsConfigurationUngroupedEditor.tsx b/packages/twenty-front/src/modules/page-layout/widgets/fields/components/FieldsConfigurationUngroupedEditor.tsx index 6f1bc4299f..d4b73b5253 100644 --- a/packages/twenty-front/src/modules/page-layout/widgets/fields/components/FieldsConfigurationUngroupedEditor.tsx +++ b/packages/twenty-front/src/modules/page-layout/widgets/fields/components/FieldsConfigurationUngroupedEditor.tsx @@ -1,20 +1,36 @@ -import { DragDropContext, Droppable, type DropResult } from '@hello-pangea/dnd'; - -import { DraggableItem } from '@/ui/layout/draggable-list/components/DraggableItem'; +import { DragDropProvider } from '@dnd-kit/react'; +import { isDefined } from 'twenty-shared/utils'; import { FieldsConfigurationFieldEditor } from '@/page-layout/widgets/fields/components/FieldsConfigurationFieldEditor'; +import { FIELDS_CONFIGURATION_FIELD_DND_TYPE } from '@/page-layout/widgets/fields/constants/FieldsConfigurationFieldDndType'; +import { type FieldsConfigurationDndData } from '@/page-layout/widgets/fields/types/FieldsConfigurationDndData'; +import { type FieldsConfigurationFieldDragData } from '@/page-layout/widgets/fields/types/FieldsConfigurationFieldDragData'; +import { type FieldsConfigurationFieldListEndDropData } from '@/page-layout/widgets/fields/types/FieldsConfigurationFieldListEndDropData'; import { type FieldsWidgetGroupField } from '@/page-layout/widgets/fields/types/FieldsWidgetGroup'; +import { DragDropItemEndDropZone } from '@/ui/utilities/drag-and-drop/components/DragDropItemEndDropZone'; +import { DragDropItemSortableCell } from '@/ui/utilities/drag-and-drop/components/DragDropItemSortableCell'; +import { DND_KIT_PROVIDER_PLUGINS_WITHOUT_DROP_ANIMATION } from '@/ui/utilities/drag-and-drop/constants/DndKitProviderPluginsWithoutDropAnimation'; +import { DND_KIT_SENSORS } from '@/ui/utilities/drag-and-drop/constants/DndKitSensors'; +import { type DragDropProviderDragEndEvent } from '@/ui/utilities/drag-and-drop/types/DragDropProviderDragEndEvent'; +import { getDestinationIndex } from '@/ui/utilities/drag-and-drop/utils/getDestinationIndex'; import { styled } from '@linaria/react'; import { useLingui } from '@lingui/react/macro'; import { IconNewSection } from 'twenty-ui/icon'; import { MenuItem } from 'twenty-ui/navigation'; +const UNGROUPED_FIELDS_DROPPABLE_ID = 'ungrouped-fields'; + const StyledFieldsDroppable = styled.div` display: flex; flex-direction: column; width: 100%; `; +const UNGROUPED_END_DROP_DATA: FieldsConfigurationFieldListEndDropData = { + type: 'field-list-end', + groupId: UNGROUPED_FIELDS_DROPPABLE_ID, +}; + type FieldsConfigurationUngroupedEditorProps = { ungroupedFields: FieldsWidgetGroupField[]; onMoveField: (sourceIndex: number, destinationIndex: number) => void; @@ -34,62 +50,103 @@ export const FieldsConfigurationUngroupedEditor = ({ (a, b) => a.position - b.position, ); - const handleDragEnd = (result: DropResult) => { - const { source, destination } = result; + const handleDragEnd = ( + event: DragDropProviderDragEndEvent, + ) => { + const sourceData = event.operation.source?.data as + | FieldsConfigurationDndData + | undefined; + const targetData = event.operation.target?.data as + | FieldsConfigurationDndData + | undefined; - if (!destination) { + if (event.canceled || sourceData?.type !== 'field') { return; } - if (source.index === destination.index) { + // The drop line renders before the hovered field, so field targets insert + // the dragged field before them; the end drop zone appends it. + const dropTargetIndex = + targetData?.type === 'field' + ? targetData.index + : targetData?.type === 'field-list-end' + ? sortedFields.length + : null; + + if (!isDefined(dropTargetIndex)) { return; } - onMoveField(source.index, destination.index); + const destinationIndex = getDestinationIndex({ + dropTargetIndex, + sourceIndex: sourceData.index, + sourceDroppableId: UNGROUPED_FIELDS_DROPPABLE_ID, + destinationDroppableId: UNGROUPED_FIELDS_DROPPABLE_ID, + }); + + if (destinationIndex === sourceData.index) { + return; + } + + onMoveField(sourceData.index, destinationIndex); }; return ( - - - {(provided) => ( - - {sortedFields.map((field, fieldIndex) => ( - { - onToggleFieldVisibility(field.fieldMetadataItem.id); - }} - /> - } - /> - ))} - {provided.placeholder} + + sensors={DND_KIT_SENSORS} + plugins={DND_KIT_PROVIDER_PLUGINS_WITHOUT_DROP_ANIMATION} + onDragEnd={handleDragEnd} + > + + {sortedFields.map((field, fieldIndex) => { + const fieldDragData: FieldsConfigurationFieldDragData = { + type: 'field', + groupId: UNGROUPED_FIELDS_DROPPABLE_ID, + index: fieldIndex, + }; - - - )} - - + return ( + + { + onToggleFieldVisibility(field.fieldMetadataItem.id); + }} + /> + + ); + })} + + + + + + ); }; diff --git a/packages/twenty-front/src/modules/page-layout/widgets/fields/constants/FieldsConfigurationFieldDndType.ts b/packages/twenty-front/src/modules/page-layout/widgets/fields/constants/FieldsConfigurationFieldDndType.ts new file mode 100644 index 0000000000..67ce8ab6b1 --- /dev/null +++ b/packages/twenty-front/src/modules/page-layout/widgets/fields/constants/FieldsConfigurationFieldDndType.ts @@ -0,0 +1 @@ +export const FIELDS_CONFIGURATION_FIELD_DND_TYPE = 'fields-config-field'; diff --git a/packages/twenty-front/src/modules/page-layout/widgets/fields/constants/FieldsConfigurationGroupDndType.ts b/packages/twenty-front/src/modules/page-layout/widgets/fields/constants/FieldsConfigurationGroupDndType.ts new file mode 100644 index 0000000000..bdeb6c6f4b --- /dev/null +++ b/packages/twenty-front/src/modules/page-layout/widgets/fields/constants/FieldsConfigurationGroupDndType.ts @@ -0,0 +1 @@ +export const FIELDS_CONFIGURATION_GROUP_DND_TYPE = 'fields-config-group'; diff --git a/packages/twenty-front/src/modules/page-layout/widgets/fields/constants/FieldsConfigurationGroupsDroppableId.ts b/packages/twenty-front/src/modules/page-layout/widgets/fields/constants/FieldsConfigurationGroupsDroppableId.ts new file mode 100644 index 0000000000..ab494ccb1d --- /dev/null +++ b/packages/twenty-front/src/modules/page-layout/widgets/fields/constants/FieldsConfigurationGroupsDroppableId.ts @@ -0,0 +1,2 @@ +export const FIELDS_CONFIGURATION_GROUPS_DROPPABLE_ID = + 'fields-configuration-groups'; diff --git a/packages/twenty-front/src/modules/page-layout/widgets/fields/hooks/useFieldsConfigurationEditorDragAndDrop.ts b/packages/twenty-front/src/modules/page-layout/widgets/fields/hooks/useFieldsConfigurationEditorDragAndDrop.ts new file mode 100644 index 0000000000..7df29753e3 --- /dev/null +++ b/packages/twenty-front/src/modules/page-layout/widgets/fields/hooks/useFieldsConfigurationEditorDragAndDrop.ts @@ -0,0 +1,173 @@ +import { useState } from 'react'; +import { isDefined } from 'twenty-shared/utils'; + +import { fieldsWidgetGroupsDraftComponentState } from '@/page-layout/states/fieldsWidgetGroupsDraftComponentState'; +import { FIELDS_CONFIGURATION_GROUPS_DROPPABLE_ID } from '@/page-layout/widgets/fields/constants/FieldsConfigurationGroupsDroppableId'; +import { useMoveFieldInDraft } from '@/page-layout/widgets/fields/hooks/useMoveFieldInDraft'; +import { useReorderFieldsWidgetEditorGroups } from '@/page-layout/widgets/fields/hooks/useReorderFieldsWidgetEditorGroups'; +import { type FieldsConfigurationDndData } from '@/page-layout/widgets/fields/types/FieldsConfigurationDndData'; +import { type FieldsConfigurationFieldDragData } from '@/page-layout/widgets/fields/types/FieldsConfigurationFieldDragData'; +import { type FieldsConfigurationGroupDragData } from '@/page-layout/widgets/fields/types/FieldsConfigurationGroupDragData'; +import { type DragDropProviderDragEndEvent } from '@/ui/utilities/drag-and-drop/types/DragDropProviderDragEndEvent'; +import { type DragDropProviderDragStartEvent } from '@/ui/utilities/drag-and-drop/types/DragDropProviderDragStartEvent'; +import { getDestinationIndex } from '@/ui/utilities/drag-and-drop/utils/getDestinationIndex'; +import { useAtomComponentStateValue } from '@/ui/utilities/state/jotai/hooks/useAtomComponentStateValue'; + +type DragStartEvent = + DragDropProviderDragStartEvent; +type DragEndEvent = DragDropProviderDragEndEvent; + +type UseFieldsConfigurationEditorDragAndDropParams = { + pageLayoutId: string; + widgetId: string; +}; + +export const useFieldsConfigurationEditorDragAndDrop = ({ + pageLayoutId, + widgetId, +}: UseFieldsConfigurationEditorDragAndDropParams) => { + const fieldsWidgetGroupsDraft = useAtomComponentStateValue( + fieldsWidgetGroupsDraftComponentState, + pageLayoutId, + ); + + const draftGroups = fieldsWidgetGroupsDraft[widgetId] ?? []; + + const { reorderGroups } = useReorderFieldsWidgetEditorGroups({ + pageLayoutId, + widgetId, + }); + + const { moveField } = useMoveFieldInDraft({ + pageLayoutId, + widgetId, + }); + + const [draggingGroupId, setDraggingGroupId] = useState(null); + + const handleGroupDrop = ({ + sourceData, + targetData, + }: { + sourceData: FieldsConfigurationGroupDragData; + targetData: FieldsConfigurationDndData; + }) => { + const sortedGroups = [...draftGroups].sort( + (a, b) => a.position - b.position, + ); + + // The drop line renders before the hovered group, so group targets insert + // the dragged group before them; the end drop zone appends it. + const dropTargetIndex = + targetData.type === 'group' + ? targetData.index + : targetData.type === 'group-list-end' + ? sortedGroups.length + : null; + + if (!isDefined(dropTargetIndex)) { + return; + } + + const destinationIndex = getDestinationIndex({ + dropTargetIndex, + sourceIndex: sourceData.index, + sourceDroppableId: FIELDS_CONFIGURATION_GROUPS_DROPPABLE_ID, + destinationDroppableId: FIELDS_CONFIGURATION_GROUPS_DROPPABLE_ID, + }); + + if (destinationIndex === sourceData.index) { + return; + } + + const reorderedGroupIds = sortedGroups.map((group) => group.id); + const [movedGroupId] = reorderedGroupIds.splice(sourceData.index, 1); + reorderedGroupIds.splice(destinationIndex, 0, movedGroupId); + + reorderGroups(reorderedGroupIds); + }; + + const handleFieldDrop = ({ + sourceData, + targetData, + }: { + sourceData: FieldsConfigurationFieldDragData; + targetData: FieldsConfigurationDndData; + }) => { + if (targetData.type !== 'field' && targetData.type !== 'field-list-end') { + return; + } + + const destinationGroup = draftGroups.find( + (group) => group.id === targetData.groupId, + ); + + if (!isDefined(destinationGroup)) { + return; + } + + // The drop line renders before the hovered field, so field targets insert + // the dragged field before them; the end drop zone appends it to the group. + const dropTargetIndex = + targetData.type === 'field' + ? targetData.index + : destinationGroup.fields.length; + + const destinationIndex = getDestinationIndex({ + dropTargetIndex, + sourceIndex: sourceData.index, + sourceDroppableId: sourceData.groupId, + destinationDroppableId: targetData.groupId, + }); + + if ( + targetData.groupId === sourceData.groupId && + destinationIndex === sourceData.index + ) { + return; + } + + moveField( + sourceData.groupId, + targetData.groupId, + sourceData.index, + destinationIndex, + ); + }; + + const onDragStart = (event: DragStartEvent) => { + const sourceData = event.operation.source?.data as + | FieldsConfigurationDndData + | undefined; + + if (sourceData?.type === 'group') { + setDraggingGroupId(sourceData.groupId); + } + }; + + const onDragEnd = (event: DragEndEvent) => { + setDraggingGroupId(null); + + const sourceData = event.operation.source?.data as + | FieldsConfigurationDndData + | undefined; + const targetData = event.operation.target?.data as + | FieldsConfigurationDndData + | undefined; + + if (event.canceled || !isDefined(sourceData) || !isDefined(targetData)) { + return; + } + + if (sourceData.type === 'group') { + handleGroupDrop({ sourceData, targetData }); + } else if (sourceData.type === 'field') { + handleFieldDrop({ sourceData, targetData }); + } + }; + + return { + draggingGroupId, + handlers: { onDragStart, onDragEnd }, + }; +}; diff --git a/packages/twenty-front/src/modules/page-layout/widgets/fields/types/FieldsConfigurationDndData.ts b/packages/twenty-front/src/modules/page-layout/widgets/fields/types/FieldsConfigurationDndData.ts new file mode 100644 index 0000000000..430d769004 --- /dev/null +++ b/packages/twenty-front/src/modules/page-layout/widgets/fields/types/FieldsConfigurationDndData.ts @@ -0,0 +1,10 @@ +import { type FieldsConfigurationFieldDragData } from '@/page-layout/widgets/fields/types/FieldsConfigurationFieldDragData'; +import { type FieldsConfigurationFieldListEndDropData } from '@/page-layout/widgets/fields/types/FieldsConfigurationFieldListEndDropData'; +import { type FieldsConfigurationGroupDragData } from '@/page-layout/widgets/fields/types/FieldsConfigurationGroupDragData'; +import { type FieldsConfigurationGroupListEndDropData } from '@/page-layout/widgets/fields/types/FieldsConfigurationGroupListEndDropData'; + +export type FieldsConfigurationDndData = + | FieldsConfigurationGroupDragData + | FieldsConfigurationGroupListEndDropData + | FieldsConfigurationFieldDragData + | FieldsConfigurationFieldListEndDropData; diff --git a/packages/twenty-front/src/modules/page-layout/widgets/fields/types/FieldsConfigurationFieldDragData.ts b/packages/twenty-front/src/modules/page-layout/widgets/fields/types/FieldsConfigurationFieldDragData.ts new file mode 100644 index 0000000000..79786326b5 --- /dev/null +++ b/packages/twenty-front/src/modules/page-layout/widgets/fields/types/FieldsConfigurationFieldDragData.ts @@ -0,0 +1,5 @@ +export type FieldsConfigurationFieldDragData = { + type: 'field'; + groupId: string; + index: number; +}; diff --git a/packages/twenty-front/src/modules/page-layout/widgets/fields/types/FieldsConfigurationFieldListEndDropData.ts b/packages/twenty-front/src/modules/page-layout/widgets/fields/types/FieldsConfigurationFieldListEndDropData.ts new file mode 100644 index 0000000000..f6cfd007bc --- /dev/null +++ b/packages/twenty-front/src/modules/page-layout/widgets/fields/types/FieldsConfigurationFieldListEndDropData.ts @@ -0,0 +1,5 @@ +// Catches drops below the last field of a list and drops into an empty group. +export type FieldsConfigurationFieldListEndDropData = { + type: 'field-list-end'; + groupId: string; +}; diff --git a/packages/twenty-front/src/modules/page-layout/widgets/fields/types/FieldsConfigurationGroupDragData.ts b/packages/twenty-front/src/modules/page-layout/widgets/fields/types/FieldsConfigurationGroupDragData.ts new file mode 100644 index 0000000000..3a8ad18613 --- /dev/null +++ b/packages/twenty-front/src/modules/page-layout/widgets/fields/types/FieldsConfigurationGroupDragData.ts @@ -0,0 +1,5 @@ +export type FieldsConfigurationGroupDragData = { + type: 'group'; + groupId: string; + index: number; +}; diff --git a/packages/twenty-front/src/modules/page-layout/widgets/fields/types/FieldsConfigurationGroupListEndDropData.ts b/packages/twenty-front/src/modules/page-layout/widgets/fields/types/FieldsConfigurationGroupListEndDropData.ts new file mode 100644 index 0000000000..1dd5974380 --- /dev/null +++ b/packages/twenty-front/src/modules/page-layout/widgets/fields/types/FieldsConfigurationGroupListEndDropData.ts @@ -0,0 +1,4 @@ +// Catches drops below the last group to append the dragged group at the end. +export type FieldsConfigurationGroupListEndDropData = { + type: 'group-list-end'; +}; diff --git a/packages/twenty-front/src/modules/settings/data-model/fields/forms/select/components/SettingsDataModelFieldSelectForm.tsx b/packages/twenty-front/src/modules/settings/data-model/fields/forms/select/components/SettingsDataModelFieldSelectForm.tsx index 556238fb18..78a947497a 100644 --- a/packages/twenty-front/src/modules/settings/data-model/fields/forms/select/components/SettingsDataModelFieldSelectForm.tsx +++ b/packages/twenty-front/src/modules/settings/data-model/fields/forms/select/components/SettingsDataModelFieldSelectForm.tsx @@ -1,5 +1,5 @@ import { styled } from '@linaria/react'; -import { type DropResult } from '@hello-pangea/dnd'; +import { type DraggableListDropResult } from '@/ui/layout/draggable-list/types/DraggableListDropResult'; import { Controller, useFormContext } from 'react-hook-form'; import { z } from 'zod'; @@ -210,7 +210,7 @@ export const SettingsDataModelFieldSelectForm = ({ const handleDragEnd = ( values: FieldMetadataItemOption[], - result: DropResult, + result: DraggableListDropResult, onChange: (options: FieldMetadataItemOption[]) => void, ) => { if (!result.destination) return; @@ -440,7 +440,6 @@ export const SettingsDataModelFieldSelectForm = ({ <> {options.map((option, index) => ( ( - - {(provided) => ( -
- {children} -
- )} -
-); diff --git a/packages/twenty-front/src/modules/side-panel/pages/page-layout/components/dropdown-content/ChartManualSortSubMenuContent.tsx b/packages/twenty-front/src/modules/side-panel/pages/page-layout/components/dropdown-content/ChartManualSortSubMenuContent.tsx index fb8d9b3568..dd1f87067d 100644 --- a/packages/twenty-front/src/modules/side-panel/pages/page-layout/components/dropdown-content/ChartManualSortSubMenuContent.tsx +++ b/packages/twenty-front/src/modules/side-panel/pages/page-layout/components/dropdown-content/ChartManualSortSubMenuContent.tsx @@ -1,4 +1,4 @@ -import { type DropResult } from '@hello-pangea/dnd'; +import { type DraggableListDropResult } from '@/ui/layout/draggable-list/types/DraggableListDropResult'; import { usePageLayoutIdFromContextStore } from '@/side-panel/pages/page-layout/hooks/usePageLayoutIdFromContextStore'; import { useUpdateCurrentWidgetConfig } from '@/side-panel/pages/page-layout/hooks/useUpdateCurrentWidgetConfig'; @@ -51,7 +51,7 @@ export const ChartManualSortSubMenuContent = ({ currentManualSortOrder, ); - const handleDragEnd = (result: DropResult) => { + const handleDragEnd = (result: DraggableListDropResult) => { if (!isDefined(result.destination)) { return; } diff --git a/packages/twenty-front/src/modules/side-panel/pages/page-layout/components/record-table-settings/RecordTableFieldsDropdownVisibleFieldsContent.tsx b/packages/twenty-front/src/modules/side-panel/pages/page-layout/components/record-table-settings/RecordTableFieldsDropdownVisibleFieldsContent.tsx index 075608ad01..32c2d8a07e 100644 --- a/packages/twenty-front/src/modules/side-panel/pages/page-layout/components/record-table-settings/RecordTableFieldsDropdownVisibleFieldsContent.tsx +++ b/packages/twenty-front/src/modules/side-panel/pages/page-layout/components/record-table-settings/RecordTableFieldsDropdownVisibleFieldsContent.tsx @@ -10,7 +10,7 @@ import { DropdownContent } from '@/ui/layout/dropdown/components/DropdownContent import { DropdownMenuItemsContainer } from '@/ui/layout/dropdown/components/DropdownMenuItemsContainer'; import { DropdownMenuSeparator } from '@/ui/layout/dropdown/components/DropdownMenuSeparator'; import { useAtomComponentSelectorValue } from '@/ui/utilities/state/jotai/hooks/useAtomComponentSelectorValue'; -import { type DropResult } from '@hello-pangea/dnd'; +import { type DraggableListDropResult } from '@/ui/layout/draggable-list/types/DraggableListDropResult'; import { t } from '@lingui/core/macro'; import { isDefined } from 'twenty-shared/utils'; import { IconEyeOff, useIcons } from 'twenty-ui/icon'; @@ -67,7 +67,7 @@ export const RecordTableFieldsDropdownVisibleFieldsContent = ({ ) .toSorted(sortByProperty('position')); - const handleDragEnd = (result: DropResult) => { + const handleDragEnd = (result: DraggableListDropResult) => { if ( !result.destination || result.destination.index === 1 || diff --git a/packages/twenty-front/src/modules/side-panel/pages/page-layout/components/record-table-settings/RecordTableSettingsFieldVisibility.tsx b/packages/twenty-front/src/modules/side-panel/pages/page-layout/components/record-table-settings/RecordTableSettingsFieldVisibility.tsx index 313a156868..5786e1a909 100644 --- a/packages/twenty-front/src/modules/side-panel/pages/page-layout/components/record-table-settings/RecordTableSettingsFieldVisibility.tsx +++ b/packages/twenty-front/src/modules/side-panel/pages/page-layout/components/record-table-settings/RecordTableSettingsFieldVisibility.tsx @@ -3,7 +3,7 @@ import { useReorderRecordTableWidgetFields } from '@/page-layout/widgets/record- import { useToggleRecordTableWidgetFieldVisibility } from '@/page-layout/widgets/record-table/hooks/useToggleRecordTableWidgetFieldVisibility'; import { DraggableItem } from '@/ui/layout/draggable-list/components/DraggableItem'; import { DraggableList } from '@/ui/layout/draggable-list/components/DraggableList'; -import { type DropResult } from '@hello-pangea/dnd'; +import { type DraggableListDropResult } from '@/ui/layout/draggable-list/types/DraggableListDropResult'; import { styled } from '@linaria/react'; import { useMemo } from 'react'; import { IconEye, IconEyeOff, useIcons } from 'twenty-ui/icon'; @@ -65,7 +65,7 @@ export const RecordTableSettingsFieldVisibility = ({ [recordTableWidgetViewFieldItems], ); - const handleDragEnd = (result: DropResult) => { + const handleDragEnd = (result: DraggableListDropResult) => { const { source, destination } = result; if (!destination) { diff --git a/packages/twenty-front/src/modules/ui/layout/draggable-list/components/DraggableItem.tsx b/packages/twenty-front/src/modules/ui/layout/draggable-list/components/DraggableItem.tsx index 2aa3293da9..a46ed4644d 100644 --- a/packages/twenty-front/src/modules/ui/layout/draggable-list/components/DraggableItem.tsx +++ b/packages/twenty-front/src/modules/ui/layout/draggable-list/components/DraggableItem.tsx @@ -1,81 +1,80 @@ -import { Draggable } from '@hello-pangea/dnd'; +import { useDragDropMonitor } from '@dnd-kit/react'; import { isFunction } from '@sniptt/guards'; -import { type JSX, useContext } from 'react'; +import { type JSX, useContext, useEffect, useState } from 'react'; import { isDefined } from 'twenty-shared/utils'; -import { ThemeContext } from 'twenty-ui/theme-constants'; + +import { DraggableListGroupContext } from '@/ui/layout/draggable-list/contexts/DraggableListGroupContext'; +import { DragDropItemSortableCell } from '@/ui/utilities/drag-and-drop/components/DragDropItemSortableCell'; type DraggableItemProps = { draggableId: string; isDragDisabled?: boolean; - disableInteractiveElementBlocking?: boolean; index: number; itemComponent: | JSX.Element | ((props: { isDragging: boolean }) => JSX.Element); - isInsideScrollableContainer?: boolean; - draggableComponentStyles?: React.CSSProperties; disableDraggingBackground?: boolean; - containerOffsetY?: number; }; export const DraggableItem = ({ draggableId, isDragDisabled = false, - disableInteractiveElementBlocking = false, index, itemComponent, - isInsideScrollableContainer, - draggableComponentStyles, - disableDraggingBackground, - containerOffsetY, + disableDraggingBackground = false, }: DraggableItemProps) => { - const { theme } = useContext(ThemeContext); - return ( - - {(draggableProvided, draggableSnapshot) => { - const draggableStyle = draggableProvided.draggableProps.style; - const isDragging = draggableSnapshot.isDragging; + const draggableListGroupContext = useContext(DraggableListGroupContext); - return ( -
- {isFunction(itemComponent) - ? itemComponent({ - isDragging, - }) - : itemComponent} -
- ); - }} -
+ const [isDragging, setIsDragging] = useState(false); + + useDragDropMonitor({ + onDragStart: (event) => { + if (String(event.operation.source?.id) === draggableId) { + setIsDragging(true); + } + }, + onDragEnd: () => { + setIsDragging(false); + }, + }); + + // The list's end drop zone resolves its drop index from this registry, + // since only the rendered items know how many of them there are. + useEffect(() => { + if (!isDefined(draggableListGroupContext)) { + return; + } + + const itemIndexByDraggableId = + draggableListGroupContext.itemIndexByDraggableId; + + itemIndexByDraggableId.set(draggableId, index); + + return () => { + itemIndexByDraggableId.delete(draggableId); + }; + }, [draggableListGroupContext, draggableId, index]); + + if (!isDefined(draggableListGroupContext)) { + throw new Error('DraggableItem must be rendered inside a DraggableList'); + } + + const { group } = draggableListGroupContext; + + return ( + + {isFunction(itemComponent) + ? itemComponent({ isDragging }) + : itemComponent} + ); }; diff --git a/packages/twenty-front/src/modules/ui/layout/draggable-list/components/DraggableList.tsx b/packages/twenty-front/src/modules/ui/layout/draggable-list/components/DraggableList.tsx index 68a324b0bf..2a3d7cbd74 100644 --- a/packages/twenty-front/src/modules/ui/layout/draggable-list/components/DraggableList.tsx +++ b/packages/twenty-front/src/modules/ui/layout/draggable-list/components/DraggableList.tsx @@ -1,45 +1,119 @@ +import { DragDropProvider } from '@dnd-kit/react'; import { styled } from '@linaria/react'; -import { - DragDropContext, - Droppable, - type OnDragEndResponder, - type OnDragStartResponder, -} from '@hello-pangea/dnd'; -import { useState } from 'react'; +import { useMemo, useState } from 'react'; +import { isDefined } from 'twenty-shared/utils'; +import { themeCssVariables } from 'twenty-ui/theme-constants'; import { v4 } from 'uuid'; + +import { DRAGGABLE_LIST_END_DROP_INDEX } from '@/ui/layout/draggable-list/constants/DraggableListEndDropIndex'; +import { DraggableListGroupContext } from '@/ui/layout/draggable-list/contexts/DraggableListGroupContext'; +import { type DraggableListDropResult } from '@/ui/layout/draggable-list/types/DraggableListDropResult'; +import { DragDropItemEndDropZone } from '@/ui/utilities/drag-and-drop/components/DragDropItemEndDropZone'; +import { DND_KIT_PROVIDER_PLUGINS_WITHOUT_DROP_ANIMATION } from '@/ui/utilities/drag-and-drop/constants/DndKitProviderPluginsWithoutDropAnimation'; +import { DND_KIT_SENSORS } from '@/ui/utilities/drag-and-drop/constants/DndKitSensors'; +import { type DragDropProviderDragEndEvent } from '@/ui/utilities/drag-and-drop/types/DragDropProviderDragEndEvent'; +import { getDestinationIndex } from '@/ui/utilities/drag-and-drop/utils/getDestinationIndex'; + +type DraggableListItemDndData = { + droppableId: string; + index: number; +}; + type DraggableListProps = { draggableItems: React.ReactNode; - onDragEnd: OnDragEndResponder; - onDragStart?: OnDragStartResponder; + onDragEnd: (result: DraggableListDropResult) => void; }; const StyledDragDropItemsWrapper = styled.div` width: 100%; `; +// Catches drops after the last item; the negative margin cancels its +// footprint so the list keeps its height. +const StyledEndDropZone = styled(DragDropItemEndDropZone)` + height: ${themeCssVariables.spacing[2]}; + margin-bottom: calc(-1 * ${themeCssVariables.spacing[2]}); +`; + export const DraggableList = ({ draggableItems, onDragEnd, - onDragStart, }: DraggableListProps) => { - const [v4Persistable] = useState(v4()); + // The group id doubles as the items' dnd type, so drags from this list can't + // land on outer providers' targets (or the other way around). + const [group] = useState(() => v4()); + + // A mutable registry rather than render state: items write their index on + // mount so the end drop zone can resolve the append index at drop time. + const [itemIndexByDraggableId] = useState(() => new Map()); + + const groupContextValue = useMemo( + () => ({ group, itemIndexByDraggableId }), + [group, itemIndexByDraggableId], + ); + + const handleDragEnd = ( + event: DragDropProviderDragEndEvent, + ) => { + const source = event.operation.source; + const sourceData = source?.data as DraggableListItemDndData | undefined; + const targetData = event.operation.target?.data as + | DraggableListItemDndData + | undefined; + + if ( + event.canceled || + !isDefined(source) || + sourceData?.droppableId !== group || + targetData?.droppableId !== group + ) { + return; + } + + const dropTargetIndex = + targetData.index === DRAGGABLE_LIST_END_DROP_INDEX + ? itemIndexByDraggableId.size + : targetData.index; + + // The drop line renders before the hovered item, so a drop inserts the + // dragged item before it; convert that gap index into the final index. + const destinationIndex = getDestinationIndex({ + dropTargetIndex, + sourceIndex: sourceData.index, + sourceDroppableId: sourceData.droppableId, + destinationDroppableId: targetData.droppableId, + }); + + if (destinationIndex === sourceData.index) { + return; + } + + onDragEnd({ + draggableId: String(source.id), + source: { index: sourceData.index }, + destination: { index: destinationIndex }, + }); + }; return ( - - - - {(provided) => ( -
- {draggableItems} - {provided.placeholder} -
- )} -
-
-
+ + sensors={DND_KIT_SENSORS} + plugins={DND_KIT_PROVIDER_PLUGINS_WITHOUT_DROP_ANIMATION} + onDragEnd={handleDragEnd} + > + + + {draggableItems} + + + + ); }; diff --git a/packages/twenty-front/src/modules/ui/layout/draggable-list/components/__stories__/DraggableItem.stories.tsx b/packages/twenty-front/src/modules/ui/layout/draggable-list/components/__stories__/DraggableItem.stories.tsx index f99efef78d..d2ca720fd2 100644 --- a/packages/twenty-front/src/modules/ui/layout/draggable-list/components/__stories__/DraggableItem.stories.tsx +++ b/packages/twenty-front/src/modules/ui/layout/draggable-list/components/__stories__/DraggableItem.stories.tsx @@ -1,8 +1,8 @@ -import { DragDropContext, Droppable } from '@hello-pangea/dnd'; import { type Meta, type StoryObj } from '@storybook/react-vite'; import { fn } from 'storybook/test'; import { DraggableItem } from '@/ui/layout/draggable-list/components/DraggableItem'; +import { DraggableList } from '@/ui/layout/draggable-list/components/DraggableList'; import { IconBell } from 'twenty-ui/icon'; import { MenuItemDraggable } from 'twenty-ui/navigation'; import { ComponentDecorator } from 'twenty-ui/testing'; @@ -11,13 +11,7 @@ const meta: Meta = { title: 'UI/Layout/DraggableList/DraggableItem', component: DraggableItem, decorators: [ - (Story) => ( - - - {(_provided) => } - - - ), + (Story) => } />, ComponentDecorator, ], parameters: { diff --git a/packages/twenty-front/src/modules/ui/layout/draggable-list/constants/DraggableListEndDropIndex.ts b/packages/twenty-front/src/modules/ui/layout/draggable-list/constants/DraggableListEndDropIndex.ts new file mode 100644 index 0000000000..b169553a98 --- /dev/null +++ b/packages/twenty-front/src/modules/ui/layout/draggable-list/constants/DraggableListEndDropIndex.ts @@ -0,0 +1,3 @@ +// Sentinel index carried by the list's end drop zone; resolved to the item +// count at drop time since the zone cannot know how many items are rendered. +export const DRAGGABLE_LIST_END_DROP_INDEX = -1; diff --git a/packages/twenty-front/src/modules/ui/layout/draggable-list/contexts/DraggableListGroupContext.ts b/packages/twenty-front/src/modules/ui/layout/draggable-list/contexts/DraggableListGroupContext.ts new file mode 100644 index 0000000000..680a806669 --- /dev/null +++ b/packages/twenty-front/src/modules/ui/layout/draggable-list/contexts/DraggableListGroupContext.ts @@ -0,0 +1,9 @@ +import { createContext } from 'react'; + +export type DraggableListGroupContextValue = { + group: string; + itemIndexByDraggableId: Map; +}; + +export const DraggableListGroupContext = + createContext(null); diff --git a/packages/twenty-front/src/modules/ui/layout/draggable-list/types/DraggableListDropResult.ts b/packages/twenty-front/src/modules/ui/layout/draggable-list/types/DraggableListDropResult.ts new file mode 100644 index 0000000000..a40be56a4d --- /dev/null +++ b/packages/twenty-front/src/modules/ui/layout/draggable-list/types/DraggableListDropResult.ts @@ -0,0 +1,5 @@ +export type DraggableListDropResult = { + draggableId: string; + source: { index: number }; + destination: { index: number } | null; +}; diff --git a/packages/twenty-front/src/modules/ui/layout/draggable-list/utils/getCssCompatibleDraggableProps.ts b/packages/twenty-front/src/modules/ui/layout/draggable-list/utils/getCssCompatibleDraggableProps.ts deleted file mode 100644 index 8e4220c2d4..0000000000 --- a/packages/twenty-front/src/modules/ui/layout/draggable-list/utils/getCssCompatibleDraggableProps.ts +++ /dev/null @@ -1,15 +0,0 @@ -import { type DraggableProvidedDraggableProps } from '@hello-pangea/dnd'; -import { type CSSProperties } from 'react'; - -type CssCompatibleDraggableProps = Omit< - DraggableProvidedDraggableProps, - 'style' -> & { style?: CSSProperties }; - -// @hello-pangea/dnd types draggableProps.style as DraggingStyle | NotDraggingStyle — -// closed interfaces that do not satisfy the `--radix-${string}` index signature -// @radix-ui/react-popper augments onto React.CSSProperties. Widen the style so the -// props can be spread onto a styled element without a per-call-site cast. -export const getCssCompatibleDraggableProps = ( - draggableProps: DraggableProvidedDraggableProps, -): CssCompatibleDraggableProps => draggableProps as CssCompatibleDraggableProps; diff --git a/packages/twenty-front/src/modules/ui/utilities/drag-and-drop/components/DragDropItemDropLine.tsx b/packages/twenty-front/src/modules/ui/utilities/drag-and-drop/components/DragDropItemDropLine.tsx new file mode 100644 index 0000000000..b5ab2030b1 --- /dev/null +++ b/packages/twenty-front/src/modules/ui/utilities/drag-and-drop/components/DragDropItemDropLine.tsx @@ -0,0 +1,46 @@ +import { styled } from '@linaria/react'; +import { themeCssVariables } from 'twenty-ui/theme-constants'; + +type DragDropItemDropLineOrientation = 'horizontal' | 'vertical'; + +// Absolutely positioned over the leading edge of its (position: relative) +// parent so activating the drop target does not reflow the list. Horizontal +// straddles the item's top boundary (vertical lists); vertical draws on its +// left (horizontal lists). +const StyledDropLineContainer = styled.div<{ + $orientation: DragDropItemDropLineOrientation; +}>` + bottom: ${({ $orientation }) => ($orientation === 'vertical' ? '0' : 'auto')}; + left: ${({ $orientation }) => + $orientation === 'vertical' + ? `calc(-1 * ${themeCssVariables.spacing[1]})` + : '0'}; + position: absolute; + right: ${({ $orientation }) => ($orientation === 'vertical' ? 'auto' : '0')}; + top: ${({ $orientation }) => ($orientation === 'vertical' ? '0' : '-1px')}; +`; + +const StyledDropLine = styled.div<{ + $orientation: DragDropItemDropLineOrientation; +}>` + background-color: ${themeCssVariables.color.blue}; + border-radius: ${themeCssVariables.border.radius.sm}; + height: ${({ $orientation }) => + $orientation === 'vertical' ? '100%' : '2px'}; + width: ${({ $orientation }) => + $orientation === 'vertical' ? '2px' : '100%'}; +`; + +type DragDropItemDropLineProps = { + orientation?: DragDropItemDropLineOrientation; + className?: string; +}; + +export const DragDropItemDropLine = ({ + orientation = 'horizontal', + className, +}: DragDropItemDropLineProps) => ( + + + +); diff --git a/packages/twenty-front/src/modules/ui/utilities/drag-and-drop/components/DragDropItemEndDropZone.tsx b/packages/twenty-front/src/modules/ui/utilities/drag-and-drop/components/DragDropItemEndDropZone.tsx new file mode 100644 index 0000000000..b9a05e064d --- /dev/null +++ b/packages/twenty-front/src/modules/ui/utilities/drag-and-drop/components/DragDropItemEndDropZone.tsx @@ -0,0 +1,45 @@ +import { pointerIntersection } from '@dnd-kit/collision'; +import { useDroppable } from '@dnd-kit/react'; +import { styled } from '@linaria/react'; +import { type ReactNode } from 'react'; + +import { DragDropItemDropLine } from '@/ui/utilities/drag-and-drop/components/DragDropItemDropLine'; + +const StyledEndDropZone = styled.div` + position: relative; +`; + +type DragDropItemEndDropZoneProps = { + id: string; + accept: string; + data: Record; + dropLine?: 'horizontal' | 'vertical'; + className?: string; + children?: ReactNode; +}; + +// Catches drops after the last sortable item of a list, or into an empty +// list, where there is no item cell to target. Style with styled(...) to give +// the zone its layout footprint. +export const DragDropItemEndDropZone = ({ + id, + accept, + data, + dropLine = 'horizontal', + className, + children, +}: DragDropItemEndDropZoneProps) => { + const { ref, isDropTarget } = useDroppable({ + id, + accept, + collisionDetector: pointerIntersection, + data, + }); + + return ( + + {isDropTarget && } + {children} + + ); +}; diff --git a/packages/twenty-front/src/modules/ui/utilities/drag-and-drop/components/DragDropItemSortableCell.tsx b/packages/twenty-front/src/modules/ui/utilities/drag-and-drop/components/DragDropItemSortableCell.tsx index 8a2ede4f0b..a6e98c767f 100644 --- a/packages/twenty-front/src/modules/ui/utilities/drag-and-drop/components/DragDropItemSortableCell.tsx +++ b/packages/twenty-front/src/modules/ui/utilities/drag-and-drop/components/DragDropItemSortableCell.tsx @@ -2,25 +2,36 @@ import { RestrictToHorizontalAxis, RestrictToVerticalAxis, } from '@dnd-kit/abstract/modifiers'; -import { SortableKeyboardPlugin } from '@dnd-kit/dom/sortable'; import { useSortable } from '@dnd-kit/react/sortable'; import { styled } from '@linaria/react'; import { type ReactNode } from 'react'; +import { themeCssVariables } from 'twenty-ui/theme-constants'; +import { DragDropItemDropLine } from '@/ui/utilities/drag-and-drop/components/DragDropItemDropLine'; +import { DND_KIT_PLUGINS_WITHOUT_OPTIMISTIC } from '@/ui/utilities/drag-and-drop/constants/DndKitPluginsWithoutOptimistic'; import { DragDropItemSortableHandleRefContext } from '@/ui/utilities/drag-and-drop/context/DragDropItemSortableHandleRefContext'; import { preventNativeDragStart } from '@/ui/utilities/drag-and-drop/utils/preventNativeDragStart'; const SORTABLE_COLLISION_PRIORITY = 3; -const PLUGINS_WITHOUT_OPTIMISTIC = [SortableKeyboardPlugin]; - const SORTABLE_TRANSITION = { duration: 180, easing: 'cubic-bezier(0.2, 0, 0, 1)', idle: true, }; -const StyledSortableRoot = styled.div<{ $fill?: boolean }>` +const StyledSortableRoot = styled.div<{ + $disabled?: boolean; + $fill?: boolean; + $isDraggingHighlighted?: boolean; +}>` + background: ${({ $isDraggingHighlighted }) => + $isDraggingHighlighted + ? themeCssVariables.background.transparent.light + : 'transparent'}; + border-radius: ${({ $isDraggingHighlighted }) => + $isDraggingHighlighted ? themeCssVariables.border.radius.sm : '0'}; + cursor: ${({ $disabled }) => ($disabled ? 'inherit' : 'grab')}; display: ${({ $fill }) => ($fill ? 'flex' : 'block')}; flex-shrink: ${({ $fill }) => ($fill ? 0 : 'initial')}; height: ${({ $fill }) => ($fill ? '100%' : 'auto')}; @@ -28,60 +39,87 @@ const StyledSortableRoot = styled.div<{ $fill?: boolean }>` min-width: ${({ $fill }) => ($fill ? '0' : 'auto')}; outline: none; position: relative; + transition: background 0.1s ease; will-change: transform; + + /* When the cell delegates dragging to an explicit handle, only the handle + is grabbable, so the rest of the cell keeps its ambient cursor. */ + &:has([data-dnd-sortable-handle]) { + cursor: inherit; + } `; type DragDropItemSortableCellProps = { accept?: string; children: ReactNode; + data?: Record; disabled?: boolean; fill?: boolean; group: string; + hasTransition?: boolean; + highlightWhileDragging?: boolean; id: string; index: number; restrictMovementTo?: 'x' | 'y' | 'none'; + dropLine?: 'horizontal' | 'vertical' | 'none'; type?: string; }; export const DragDropItemSortableCell = ({ accept, children, + data, disabled = false, fill = false, group, + hasTransition = true, + highlightWhileDragging = false, id, index, restrictMovementTo = 'none', + dropLine = 'none', type, }: DragDropItemSortableCellProps) => { - const { handleRef, ref } = useSortable({ - id, - index, - group, - type, - accept, - collisionPriority: SORTABLE_COLLISION_PRIORITY, - data: { - droppableId: group, + const { handleRef, ref, isDragging, isDragSource, isDropTarget } = + useSortable({ + id, index, - }, - disabled, - transition: SORTABLE_TRANSITION, - plugins: PLUGINS_WITHOUT_OPTIMISTIC, - modifiers: [ - ...(restrictMovementTo === 'x' ? [RestrictToHorizontalAxis] : []), - ...(restrictMovementTo === 'y' ? [RestrictToVerticalAxis] : []), - ], - feedback: 'clone', - }); + group, + type, + accept, + collisionPriority: SORTABLE_COLLISION_PRIORITY, + // Sortable metadata stays authoritative over consumer data so drag + // handlers always resolve the cell's real group and position. + data: { + ...data, + droppableId: group, + index, + }, + disabled, + transition: hasTransition ? SORTABLE_TRANSITION : null, + plugins: DND_KIT_PLUGINS_WITHOUT_OPTIMISTIC, + modifiers: [ + ...(restrictMovementTo === 'x' ? [RestrictToHorizontalAxis] : []), + ...(restrictMovementTo === 'y' ? [RestrictToVerticalAxis] : []), + ], + feedback: 'clone', + }); + + // The drag source is its own initial drop target; rendering the line on it + // would bake a stale copy into the placeholder clone taken at drag start. + const shouldShowDropLine = + dropLine !== 'none' && isDropTarget && !isDragSource; return ( + {shouldShowDropLine && } {children} diff --git a/packages/twenty-front/src/modules/ui/utilities/drag-and-drop/components/DragDropItemSortableHandle.tsx b/packages/twenty-front/src/modules/ui/utilities/drag-and-drop/components/DragDropItemSortableHandle.tsx index b300077716..0a364ad228 100644 --- a/packages/twenty-front/src/modules/ui/utilities/drag-and-drop/components/DragDropItemSortableHandle.tsx +++ b/packages/twenty-front/src/modules/ui/utilities/drag-and-drop/components/DragDropItemSortableHandle.tsx @@ -5,6 +5,7 @@ import { themeCssVariables } from 'twenty-ui/theme-constants'; import { DragDropItemSortableHandleRefContext } from '@/ui/utilities/drag-and-drop/context/DragDropItemSortableHandleRefContext'; const StyledSortableHandle = styled.div<{ $fill?: boolean }>` + cursor: grab; display: ${({ $fill }) => ($fill ? 'flex' : 'block')}; height: 100%; min-width: 0; @@ -28,7 +29,11 @@ export const DragDropItemSortableHandle = ({ const sortableHandleRef = useContext(DragDropItemSortableHandleRefContext); return ( - + {children} ); diff --git a/packages/twenty-front/src/modules/ui/utilities/drag-and-drop/constants/DndKitPluginsWithoutOptimistic.ts b/packages/twenty-front/src/modules/ui/utilities/drag-and-drop/constants/DndKitPluginsWithoutOptimistic.ts new file mode 100644 index 0000000000..a299d028ae --- /dev/null +++ b/packages/twenty-front/src/modules/ui/utilities/drag-and-drop/constants/DndKitPluginsWithoutOptimistic.ts @@ -0,0 +1,6 @@ +import { SortableKeyboardPlugin } from '@dnd-kit/dom/sortable'; + +// Deliberately excludes dnd-kit's optimistic sorting plugin: application state +// stays the single source of truth for item order, so lists only reorder once +// a drop is committed. +export const DND_KIT_PLUGINS_WITHOUT_OPTIMISTIC = [SortableKeyboardPlugin]; diff --git a/packages/twenty-front/src/modules/ui/utilities/drag-and-drop/constants/DndKitProviderPluginsWithoutDropAnimation.ts b/packages/twenty-front/src/modules/ui/utilities/drag-and-drop/constants/DndKitProviderPluginsWithoutDropAnimation.ts new file mode 100644 index 0000000000..61723defc2 --- /dev/null +++ b/packages/twenty-front/src/modules/ui/utilities/drag-and-drop/constants/DndKitProviderPluginsWithoutDropAnimation.ts @@ -0,0 +1,11 @@ +import { defaultPreset, Feedback } from '@dnd-kit/dom'; + +// Twenty applies reorders to application state the moment a drop commits, so +// dnd-kit's default drop animation, which flies the dragged element back to +// its pre-drag position, reads as the drop being reverted. The animating +// element is also position: fixed with an infinite z-index, so it paints over +// overlays such as the record side panel while it flies back. +export const DND_KIT_PROVIDER_PLUGINS_WITHOUT_DROP_ANIMATION = + defaultPreset.plugins.map((plugin) => + plugin === Feedback ? Feedback.configure({ dropAnimation: null }) : plugin, + ); diff --git a/packages/twenty-front/src/modules/ui/utilities/drag-and-drop/types/DragDropProviderDragEndEvent.ts b/packages/twenty-front/src/modules/ui/utilities/drag-and-drop/types/DragDropProviderDragEndEvent.ts new file mode 100644 index 0000000000..aed810e2b6 --- /dev/null +++ b/packages/twenty-front/src/modules/ui/utilities/drag-and-drop/types/DragDropProviderDragEndEvent.ts @@ -0,0 +1,7 @@ +import { type Data } from '@dnd-kit/abstract'; + +import { type DragDropProviderProps } from '@/ui/utilities/drag-and-drop/types/DragDropProviderProps'; + +export type DragDropProviderDragEndEvent = Parameters< + NonNullable['onDragEnd']> +>[0]; diff --git a/packages/twenty-front/src/modules/ui/utilities/drag-and-drop/types/DragDropProviderDragMoveEvent.ts b/packages/twenty-front/src/modules/ui/utilities/drag-and-drop/types/DragDropProviderDragMoveEvent.ts new file mode 100644 index 0000000000..9609083d2d --- /dev/null +++ b/packages/twenty-front/src/modules/ui/utilities/drag-and-drop/types/DragDropProviderDragMoveEvent.ts @@ -0,0 +1,7 @@ +import { type Data } from '@dnd-kit/abstract'; + +import { type DragDropProviderProps } from '@/ui/utilities/drag-and-drop/types/DragDropProviderProps'; + +export type DragDropProviderDragMoveEvent = Parameters< + NonNullable['onDragMove']> +>[0]; diff --git a/packages/twenty-front/src/modules/ui/utilities/drag-and-drop/types/DragDropProviderDragOverEvent.ts b/packages/twenty-front/src/modules/ui/utilities/drag-and-drop/types/DragDropProviderDragOverEvent.ts new file mode 100644 index 0000000000..1d4d4ba332 --- /dev/null +++ b/packages/twenty-front/src/modules/ui/utilities/drag-and-drop/types/DragDropProviderDragOverEvent.ts @@ -0,0 +1,7 @@ +import { type Data } from '@dnd-kit/abstract'; + +import { type DragDropProviderProps } from '@/ui/utilities/drag-and-drop/types/DragDropProviderProps'; + +export type DragDropProviderDragOverEvent = Parameters< + NonNullable['onDragOver']> +>[0]; diff --git a/packages/twenty-front/src/modules/ui/utilities/drag-and-drop/types/DragDropProviderDragStartEvent.ts b/packages/twenty-front/src/modules/ui/utilities/drag-and-drop/types/DragDropProviderDragStartEvent.ts new file mode 100644 index 0000000000..4e969f90e3 --- /dev/null +++ b/packages/twenty-front/src/modules/ui/utilities/drag-and-drop/types/DragDropProviderDragStartEvent.ts @@ -0,0 +1,7 @@ +import { type Data } from '@dnd-kit/abstract'; + +import { type DragDropProviderProps } from '@/ui/utilities/drag-and-drop/types/DragDropProviderProps'; + +export type DragDropProviderDragStartEvent = Parameters< + NonNullable['onDragStart']> +>[0]; diff --git a/packages/twenty-front/src/modules/ui/utilities/drag-and-drop/types/DragDropProviderDropTarget.ts b/packages/twenty-front/src/modules/ui/utilities/drag-and-drop/types/DragDropProviderDropTarget.ts new file mode 100644 index 0000000000..a031809a4f --- /dev/null +++ b/packages/twenty-front/src/modules/ui/utilities/drag-and-drop/types/DragDropProviderDropTarget.ts @@ -0,0 +1,6 @@ +import { type Data } from '@dnd-kit/abstract'; + +import { type DragDropProviderDragEndEvent } from '@/ui/utilities/drag-and-drop/types/DragDropProviderDragEndEvent'; + +export type DragDropProviderDropTarget = + DragDropProviderDragEndEvent['operation']['target']; diff --git a/packages/twenty-front/src/modules/ui/utilities/drag-and-drop/types/DragDropProviderProps.ts b/packages/twenty-front/src/modules/ui/utilities/drag-and-drop/types/DragDropProviderProps.ts new file mode 100644 index 0000000000..9ebe51d26e --- /dev/null +++ b/packages/twenty-front/src/modules/ui/utilities/drag-and-drop/types/DragDropProviderProps.ts @@ -0,0 +1,7 @@ +import { type Data } from '@dnd-kit/abstract'; +import { type DragDropProvider } from '@dnd-kit/react'; +import { type ComponentProps } from 'react'; + +export type DragDropProviderProps = ComponentProps< + typeof DragDropProvider +>; diff --git a/packages/twenty-front/src/modules/ui/utilities/drag-and-drop/utils/resolveDropFromPointerY.ts b/packages/twenty-front/src/modules/ui/utilities/drag-and-drop/utils/resolveDropFromPointerY.ts index 62f8abf883..cc7c16238d 100644 --- a/packages/twenty-front/src/modules/ui/utilities/drag-and-drop/utils/resolveDropFromPointerY.ts +++ b/packages/twenty-front/src/modules/ui/utilities/drag-and-drop/utils/resolveDropFromPointerY.ts @@ -1,16 +1,11 @@ -import { type DragDropProvider } from '@dnd-kit/react'; import { isSortable } from '@dnd-kit/react/sortable'; -import { type ComponentProps } from 'react'; import { isDefined } from 'twenty-shared/utils'; import { type DragDropItemData } from '@/ui/utilities/drag-and-drop/types/DragDropItemData'; +import { type DragDropProviderDropTarget } from '@/ui/utilities/drag-and-drop/types/DragDropProviderDropTarget'; import { resolveDropTarget } from '@/ui/utilities/drag-and-drop/utils/resolveDropTarget'; -type DropTarget = Parameters< - NonNullable< - ComponentProps>['onDragEnd'] - > ->[0]['operation']['target']; +type DropTarget = DragDropProviderDropTarget; export type ResolvedDrop = { droppableId: string; diff --git a/packages/twenty-front/src/modules/views/components/ViewFieldsVisibleDropdownSection.tsx b/packages/twenty-front/src/modules/views/components/ViewFieldsVisibleDropdownSection.tsx index 793b5d63b2..c2cd6bbd31 100644 --- a/packages/twenty-front/src/modules/views/components/ViewFieldsVisibleDropdownSection.tsx +++ b/packages/twenty-front/src/modules/views/components/ViewFieldsVisibleDropdownSection.tsx @@ -1,4 +1,4 @@ -import { type DropResult, type ResponderProvided } from '@hello-pangea/dnd'; +import { type DraggableListDropResult } from '@/ui/layout/draggable-list/types/DraggableListDropResult'; import { useGetFieldMetadataItemByIdOrThrow } from '@/object-metadata/hooks/useGetFieldMetadataItemById'; import { getLabelIdentifierFieldMetadataItem } from '@/object-metadata/utils/getLabelIdentifierFieldMetadataItem'; @@ -10,9 +10,7 @@ import { visibleRecordFieldsComponentSelector } from '@/object-record/record-fie import { DraggableItem } from '@/ui/layout/draggable-list/components/DraggableItem'; import { DraggableList } from '@/ui/layout/draggable-list/components/DraggableList'; import { DropdownMenuItemsContainer } from '@/ui/layout/dropdown/components/DropdownMenuItemsContainer'; -import { dropdownYPositionComponentState } from '@/ui/layout/dropdown/states/internal/dropdownYPositionComponentState'; import { useAtomComponentSelectorValue } from '@/ui/utilities/state/jotai/hooks/useAtomComponentSelectorValue'; -import { useAtomComponentStateValue } from '@/ui/utilities/state/jotai/hooks/useAtomComponentStateValue'; import { ViewType } from '@/views/types/ViewType'; import { useContext } from 'react'; import { isDefined } from 'twenty-shared/utils'; @@ -51,8 +49,8 @@ export const ViewFieldsVisibleDropdownSection = () => { ? handleBoardFieldVisibilityChange : changeRecordFieldVisibility; - const handleDragEnd = (result: DropResult, provided: ResponderProvided) => { - handleReorderFields(result, provided); + const handleDragEnd = (result: DraggableListDropResult) => { + handleReorderFields(result); }; const { getIcon } = useIcons(); @@ -78,10 +76,6 @@ export const ViewFieldsVisibleDropdownSection = () => { ) .toSorted(sortByProperty('position')); - const dropdownYPosition = useAtomComponentStateValue( - dropdownYPositionComponentState, - ); - return ( <> @@ -113,8 +107,6 @@ export const ViewFieldsVisibleDropdownSection = () => { key={recordField.fieldMetadataItemId} draggableId={recordField.fieldMetadataItemId} index={fieldIndex + 1} - isInsideScrollableContainer - containerOffsetY={dropdownYPosition} itemComponent={ { }; const handleWorkspaceDragEnd = useCallback( - async (result: DropResult) => { + async (result: DraggableListDropResult) => { if (!result.destination) return; const viewsReordered = moveArrayItem(workspaceViews, { @@ -112,7 +112,7 @@ export const ViewPickerListContent = () => { ); const handleUnlistedDragEnd = useCallback( - async (result: DropResult) => { + async (result: DraggableListDropResult) => { if (!result.destination) return; const viewsReordered = moveArrayItem(unlistedViews, { diff --git a/packages/twenty-front/src/modules/workflow/workflow-steps/workflow-actions/form-action/components/WorkflowEditActionFormBuilder.tsx b/packages/twenty-front/src/modules/workflow/workflow-steps/workflow-actions/form-action/components/WorkflowEditActionFormBuilder.tsx index 1178a2758a..11d68846fe 100644 --- a/packages/twenty-front/src/modules/workflow/workflow-steps/workflow-actions/form-action/components/WorkflowEditActionFormBuilder.tsx +++ b/packages/twenty-front/src/modules/workflow/workflow-steps/workflow-actions/form-action/components/WorkflowEditActionFormBuilder.tsx @@ -5,6 +5,7 @@ import { FormFieldPlaceholder } from '@/object-record/record-field/ui/form-types import { InputLabel } from '@/ui/input/components/InputLabel'; import { DraggableItem } from '@/ui/layout/draggable-list/components/DraggableItem'; import { DraggableList } from '@/ui/layout/draggable-list/components/DraggableList'; +import { type DraggableListDropResult } from '@/ui/layout/draggable-list/types/DraggableListDropResult'; import { type WorkflowFormAction, type WorkflowTriggerType, @@ -14,7 +15,6 @@ import { WorkflowStepFooter } from '@/workflow/workflow-steps/components/Workflo import { WorkflowEditActionFormFieldSettings } from '@/workflow/workflow-steps/workflow-actions/form-action/components/WorkflowEditActionFormFieldSettings'; import { type WorkflowFormActionField } from '@/workflow/workflow-steps/workflow-actions/form-action/types/WorkflowFormActionField'; import { getDefaultFormFieldSettings } from '@/workflow/workflow-steps/workflow-actions/form-action/utils/getDefaultFormFieldSettings'; -import { type OnDragEndResponder } from '@hello-pangea/dnd'; import { styled } from '@linaria/react'; import { useLingui } from '@lingui/react/macro'; import { isNonEmptyString } from '@sniptt/guards'; @@ -57,6 +57,7 @@ const StyledFormFieldContainer = styled.div` 'grip input delete' '. settings .'; grid-template-columns: 24px 1fr 24px; + margin-bottom: ${themeCssVariables.spacing[4]}; position: relative; `; @@ -191,7 +192,7 @@ export const WorkflowEditActionFormBuilder = ({ saveAction(updatedFormData); }; - const handleDragEnd: OnDragEndResponder = ({ source, destination }) => { + const handleDragEnd = ({ source, destination }: DraggableListDropResult) => { if (actionOptions.readonly === true) { return; } @@ -279,11 +280,7 @@ export const WorkflowEditActionFormBuilder = ({ draggableId={field.id} index={index} isDragDisabled={actionOptions.readonly} - isInsideScrollableContainer disableDraggingBackground - draggableComponentStyles={{ - marginBottom: themeCssVariables.spacing[4], - }} itemComponent={({ isDragging }) => { const showButtons = !actionOptions.readonly && diff --git a/yarn.lock b/yarn.lock index 7ab3d871b6..82088c1174 100644 --- a/yarn.lock +++ b/yarn.lock @@ -4456,7 +4456,7 @@ __metadata: languageName: node linkType: hard -"@babel/runtime@npm:^7.26.7, @babel/runtime@npm:^7.27.6, @babel/runtime@npm:^7.29.2": +"@babel/runtime@npm:^7.27.6, @babel/runtime@npm:^7.29.2": version: 7.29.7 resolution: "@babel/runtime@npm:7.29.7" checksum: 10c0/ca11572f7146b21e0bde6a9ed4bb6a89eafbee5f0944c7eb54d0d8a2dac962c33638a1d611e14faa71dfbb92b4b5f9236232208568a6b7d5c6f3f39ddb91771e @@ -7996,22 +7996,6 @@ __metadata: languageName: node linkType: hard -"@hello-pangea/dnd@npm:^18.0.1": - version: 18.0.1 - resolution: "@hello-pangea/dnd@npm:18.0.1" - dependencies: - "@babel/runtime": "npm:^7.26.7" - css-box-model: "npm:^1.2.1" - raf-schd: "npm:^4.0.3" - react-redux: "npm:^9.2.0" - redux: "npm:^5.0.1" - peerDependencies: - react: ^18.0.0 || ^19.0.0 - react-dom: ^18.0.0 || ^19.0.0 - checksum: 10c0/30c47ac8048f85e5c6d39c0b5a492cf2cc9e5f532cee12c5ecc77688596c8846670be142bd716212db789f161cd769601a5da135fa99ac65824fbb6a07d4d137 - languageName: node - linkType: hard - "@hookform/resolvers@npm:^5.2.2": version: 5.2.2 resolution: "@hookform/resolvers@npm:5.2.2" @@ -29682,15 +29666,6 @@ __metadata: languageName: node linkType: hard -"css-box-model@npm:^1.2.1": - version: 1.2.1 - resolution: "css-box-model@npm:1.2.1" - dependencies: - tiny-invariant: "npm:^1.0.6" - checksum: 10c0/611e56d76b16e4e21956ed9fa53f1936fbbfaccd378659587e9c929f342037fc6c062f8af9447226e11fe7c95e31e6c007a37e592f9bff4c2d40e6915553104a - languageName: node - linkType: hard - "css-color-keywords@npm:^1.0.0": version: 1.0.0 resolution: "css-color-keywords@npm:1.0.0" @@ -46711,13 +46686,6 @@ __metadata: languageName: node linkType: hard -"raf-schd@npm:^4.0.3": - version: 4.0.3 - resolution: "raf-schd@npm:4.0.3" - checksum: 10c0/ecabf0957c05fad059779bddcd992f1a9d3a35dfea439a6f0935c382fcf4f7f7fa60489e467b4c2db357a3665167d2a379782586b59712bb36c766e02824709b - languageName: node - linkType: hard - "ramda@npm:^0.27.1": version: 0.27.2 resolution: "ramda@npm:0.27.2" @@ -47220,25 +47188,6 @@ __metadata: languageName: node linkType: hard -"react-redux@npm:^9.2.0": - version: 9.3.0 - resolution: "react-redux@npm:9.3.0" - dependencies: - "@types/use-sync-external-store": "npm:^0.0.6" - use-sync-external-store: "npm:^1.4.0" - peerDependencies: - "@types/react": ^18.2.25 || ^19 - react: ^18.0 || ^19 - redux: ^5.0.0 - peerDependenciesMeta: - "@types/react": - optional: true - redux: - optional: true - checksum: 10c0/b9f4efcfbfbc90cac9d1709ab3affb1e18a9dc9bd3cceda43bd2e1d9d2394ee0c29df36ec2202d52b566db774888b594c8c5aa86b64f27ef34fca607c687c9e3 - languageName: node - linkType: hard - "react-remove-scroll-bar@npm:^2.3.7": version: 2.3.8 resolution: "react-remove-scroll-bar@npm:2.3.8" @@ -47699,13 +47648,6 @@ __metadata: languageName: node linkType: hard -"redux@npm:^5.0.1": - version: 5.0.1 - resolution: "redux@npm:5.0.1" - checksum: 10c0/b10c28357194f38e7d53b760ed5e64faa317cc63de1fb95bc5d9e127fab956392344368c357b8e7a9bedb0c35b111e7efa522210cfdc3b3c75e5074718e9069c - languageName: node - linkType: hard - "reflect-metadata@npm:0.2.2, reflect-metadata@npm:^0.2.2": version: 0.2.2 resolution: "reflect-metadata@npm:0.2.2" @@ -52045,7 +51987,7 @@ __metadata: languageName: node linkType: hard -"tiny-invariant@npm:^1.0.0, tiny-invariant@npm:^1.0.6, tiny-invariant@npm:^1.3.3": +"tiny-invariant@npm:^1.0.0, tiny-invariant@npm:^1.3.3": version: 1.3.3 resolution: "tiny-invariant@npm:1.3.3" checksum: 10c0/65af4a07324b591a059b35269cd696aba21bef2107f29b9f5894d83cc143159a204b299553435b03874ebb5b94d019afa8b8eff241c8a4cfee95872c2e1c1c4a @@ -52872,7 +52814,6 @@ __metadata: "@graphql-codegen/typed-document-node": "npm:^6.1.8" "@graphql-codegen/typescript": "npm:^5.0.10" "@graphql-codegen/typescript-operations": "npm:^5.1.0" - "@hello-pangea/dnd": "npm:^18.0.1" "@hookform/resolvers": "npm:^5.2.2" "@linaria/core": "npm:^7.0.0" "@linaria/react": "npm:^7.0.1"