From 65d399c70d8701fecb6c87f81dc02c91a003aa11 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?F=C3=A9lix=20Malfait?= Date: Thu, 23 Jul 2026 15:06:20 +0200 Subject: [PATCH] feat(page-layout): drag widgets across tabs on record pages (#23023) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## What When editing a record page layout, you can now drag a widget out of one tab and into another. The most common case works: drag from the left column (the pinned first tab, in full mode) into the tab you're currently viewing. Ways to move a widget across tabs: - **Into the visible tab's content** — drop it into another vertical-list tab's list to place it at a precise index. A blue line shows exactly where it will land. - **Onto a tab button** — drop a widget onto another tab's button to move it to that tab; the button highlights while hovered. - **Into an empty tab / the end of a tab** — an end-of-list drop zone (wrapping the add-widget area) accepts the widget, so an empty tab is a valid drop target and widgets can be appended to the end of a populated one. Within-tab reordering keeps working as before, now with the same blue drop-line indicator. ## How The record-page widget list is migrated from `@hello-pangea/dnd` to `@dnd-kit/react` (already used elsewhere in the app, e.g. navigation-menu-item and record-board). A single `DragDropProvider` spans the left column, the tab bar, and the active tab content, which is what makes cross-list drag possible — Pangea scopes each list to its own context, so cross-tab drag wasn't expressible there. - Widgets are dnd-kit sortables grouped by `tabId`; dropping into a different group is a cross-tab move. - The drop line uses `useSortable().isDropTarget` on the targeted widget, plus an end-of-list droppable for append/empty-tab. - Each record-page tab button is a `useDroppable` target for widgets, opt-in per vertical-list tab so canvas/grid tabs keep their native placement. - The drag lifecycle lives in one router hook (`usePageLayoutWidgetDragAndDrop`) that routes to two pure, unit-tested draft utils (`moveWidgetWithinTabInDraft`, `moveWidgetToTabInDraft`). The side-panel "Move to tab" action shares the same `moveWidgetToTabInDraft` util, so drag and menu paths converge on one mutation. - Drop resolution reuses the shared module #23071 landed: `getDestinationIndex` compensates same-tab downward moves for the source-removal shift so the drop line and the landing slot agree, and the shared `preventNativeDragStart` guard stops links/images inside widget content from starting a native URL drag. ## Scope Deliberately staged to the record-page widget list. Not included (follow-ups): - Tab reordering and the field-config editors still use `@hello-pangea/dnd`; finishing the full page-layout removal of Pangea is separate. - Grid/dashboard cross-tab drag (the source there is `react-grid-layout`, which needs a cross-system bridge). - #23071 has merged and this branch sits on it; the remaining convergence is a follow-up: fold `PageLayoutWidgetSortableItem`/`PageLayoutWidgetDropLine` into the shared `DragDropItem*` cells, export a generic drag-event-type helper to delete the 7 copied `Parameters<...>` extractions, replace `useMovePageLayoutWidgetUp/Down` with `moveWidgetWithinTabInDraft`, and migrate the remaining page-layout test suites onto `pageLayoutDraftFixtures`. ## Testing - Unit tests for `moveWidgetToTabInDraft` and `moveWidgetWithinTabInDraft` (incl. the non-vertical destination guard); the three suites now share one fixture module (`page-layout/testing/pageLayoutDraftFixtures`). - The downward off-by-one is covered by the shared `getDestinationIndex` unit tests from #23071. - Full `page-layout` suite green (161 files / 1055 tests), plus typecheck, lint, and format. - The drag interaction itself (drop precision incl. downward same-tab drops, line/highlight, clone feedback) still needs a manual pass in the running app. --- .../components/PageLayoutContent.tsx | 5 - .../PageLayoutSingleTabRenderer.tsx | 5 +- .../components/PageLayoutTabList.tsx | 19 ++- .../PageLayoutTabListReorderableTab.tsx | 16 +- .../PageLayoutTabListVisibleTabs.tsx | 3 + .../components/PageLayoutTabsRenderer.tsx | 103 ++++++------ .../PageLayoutVerticalListEditor.tsx | 116 +++++-------- .../dnd/PageLayoutTabWidgetDropTarget.tsx | 42 +++++ .../dnd/PageLayoutWidgetDndProvider.tsx | 30 ++++ .../dnd/PageLayoutWidgetDropLine.tsx | 24 +++ .../dnd/PageLayoutWidgetSortableItem.tsx | 64 +++++++ .../__tests__/useMoveWidgetToTab.test.tsx | 61 +------ .../page-layout/hooks/useMoveWidgetToTab.ts | 84 +--------- .../hooks/usePageLayoutWidgetDragAndDrop.ts | 139 ++++++++++++++++ .../hooks/useReorderPageLayoutWidgets.ts | 60 ------- .../testing/pageLayoutDraftFixtures.ts | 59 +++++++ .../types/PageLayoutWidgetDndData.ts | 21 +++ .../__tests__/moveWidgetToTabInDraft.test.ts | 156 ++++++++++++++++++ .../moveWidgetWithinTabInDraft.test.ts | 108 ++++++++++++ .../utils/moveWidgetToTabInDraft.ts | 82 +++++++++ .../utils/moveWidgetWithinTabInDraft.ts | 44 +++++ .../reindexWidgetsToVerticalListPositions.ts | 14 ++ .../components/DragDropItemSortableCell.tsx | 9 +- .../utils/preventNativeDragStart.ts | 7 + 24 files changed, 939 insertions(+), 332 deletions(-) create mode 100644 packages/twenty-front/src/modules/page-layout/components/dnd/PageLayoutTabWidgetDropTarget.tsx create mode 100644 packages/twenty-front/src/modules/page-layout/components/dnd/PageLayoutWidgetDndProvider.tsx create mode 100644 packages/twenty-front/src/modules/page-layout/components/dnd/PageLayoutWidgetDropLine.tsx create mode 100644 packages/twenty-front/src/modules/page-layout/components/dnd/PageLayoutWidgetSortableItem.tsx create mode 100644 packages/twenty-front/src/modules/page-layout/hooks/usePageLayoutWidgetDragAndDrop.ts delete mode 100644 packages/twenty-front/src/modules/page-layout/hooks/useReorderPageLayoutWidgets.ts create mode 100644 packages/twenty-front/src/modules/page-layout/testing/pageLayoutDraftFixtures.ts create mode 100644 packages/twenty-front/src/modules/page-layout/types/PageLayoutWidgetDndData.ts create mode 100644 packages/twenty-front/src/modules/page-layout/utils/__tests__/moveWidgetToTabInDraft.test.ts create mode 100644 packages/twenty-front/src/modules/page-layout/utils/__tests__/moveWidgetWithinTabInDraft.test.ts create mode 100644 packages/twenty-front/src/modules/page-layout/utils/moveWidgetToTabInDraft.ts create mode 100644 packages/twenty-front/src/modules/page-layout/utils/moveWidgetWithinTabInDraft.ts create mode 100644 packages/twenty-front/src/modules/page-layout/utils/reindexWidgetsToVerticalListPositions.ts create mode 100644 packages/twenty-front/src/modules/ui/utilities/drag-and-drop/utils/preventNativeDragStart.ts diff --git a/packages/twenty-front/src/modules/page-layout/components/PageLayoutContent.tsx b/packages/twenty-front/src/modules/page-layout/components/PageLayoutContent.tsx index 41666a0f6c..2cb84e05d7 100644 --- a/packages/twenty-front/src/modules/page-layout/components/PageLayoutContent.tsx +++ b/packages/twenty-front/src/modules/page-layout/components/PageLayoutContent.tsx @@ -6,7 +6,6 @@ import { usePageLayoutContentContext } from '@/page-layout/contexts/PageLayoutCo import { useCurrentPageLayoutOrThrow } from '@/page-layout/hooks/useCurrentPageLayoutOrThrow'; import { useIsPageLayoutInEditMode } from '@/page-layout/hooks/useIsPageLayoutInEditMode'; import { usePageLayoutTabWithVisibleWidgetsOrThrow } from '@/page-layout/hooks/usePageLayoutTabWithVisibleWidgetsOrThrow'; -import { useReorderPageLayoutWidgets } from '@/page-layout/hooks/useReorderPageLayoutWidgets'; import { StandaloneWidgetPlaceholder } from '@/page-layout/widgets/components/StandaloneWidgetPlaceholder'; import { RecordPageAddWidgetSection } from '@/page-layout/widgets/components/RecordPageAddWidgetSection'; import { styled } from '@linaria/react'; @@ -25,8 +24,6 @@ export const PageLayoutContent = () => { const { tabId } = usePageLayoutContentContext(); - const { reorderWidgets } = useReorderPageLayoutWidgets(tabId); - const activeTab = usePageLayoutTabWithVisibleWidgetsOrThrow(tabId); const { layoutMode } = usePageLayoutContentContext(); @@ -60,8 +57,6 @@ export const PageLayoutContent = () => { return ( } /> ); diff --git a/packages/twenty-front/src/modules/page-layout/components/PageLayoutSingleTabRenderer.tsx b/packages/twenty-front/src/modules/page-layout/components/PageLayoutSingleTabRenderer.tsx index f472a147ea..a852b432b7 100644 --- a/packages/twenty-front/src/modules/page-layout/components/PageLayoutSingleTabRenderer.tsx +++ b/packages/twenty-front/src/modules/page-layout/components/PageLayoutSingleTabRenderer.tsx @@ -1,4 +1,5 @@ import { SummaryCard } from '@/object-record/record-show/components/SummaryCard'; +import { PageLayoutWidgetDndProvider } from '@/page-layout/components/dnd/PageLayoutWidgetDndProvider'; import { PageLayoutContent } from '@/page-layout/components/PageLayoutContent'; import { PageLayoutEditModeProvider } from '@/page-layout/components/PageLayoutEditModeProvider'; import { PageLayoutInitializationQueryEffect } from '@/page-layout/components/PageLayoutInitializationQueryEffect'; @@ -65,7 +66,9 @@ const PageLayoutSingleTabRendererInner = () => { layoutMode, }} > - + + + ); 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 b227261a34..61865bc7ef 100644 --- a/packages/twenty-front/src/modules/page-layout/components/PageLayoutTabList.tsx +++ b/packages/twenty-front/src/modules/page-layout/components/PageLayoutTabList.tsx @@ -49,7 +49,10 @@ import { useAtomComponentStateValue } from '@/ui/utilities/state/jotai/hooks/use import { SidePanelPages } from 'twenty-shared/types'; import { isDefined } from 'twenty-shared/utils'; import { themeCssVariables } from 'twenty-ui/theme-constants'; -import { type PageLayoutType } from '~/generated-metadata/graphql'; +import { + PageLayoutTabLayoutMode, + PageLayoutType, +} from '~/generated-metadata/graphql'; const StyledContainer = styled.div` box-sizing: border-box; @@ -320,6 +323,18 @@ export const PageLayoutTabList = ({ 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. + const widgetDropTargetTabIds = new Set( + pageLayoutType === PageLayoutType.RECORD_PAGE + ? tabs + .filter( + (tab) => tab.layoutMode === PageLayoutTabLayoutMode.VERTICAL_LIST, + ) + .map((tab) => tab.id) + : [], + ); + return ( {shouldRenderReorderableDropdown && ( @@ -436,6 +452,7 @@ export const PageLayoutTabList = ({ onChangeTab={onChangeTab} onSelectTab={handleSelectTab} canReorder={canReorderTabs} + widgetDropTargetTabIds={widgetDropTargetTabIds} /> {shouldRenderStaticDropdown && ( 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 095574c37e..3827f5d95a 100644 --- a/packages/twenty-front/src/modules/page-layout/components/PageLayoutTabListReorderableTab.tsx +++ b/packages/twenty-front/src/modules/page-layout/components/PageLayoutTabListReorderableTab.tsx @@ -1,5 +1,6 @@ import { Draggable } from '@hello-pangea/dnd'; +import { PageLayoutTabWidgetDropTarget } from '@/page-layout/components/dnd/PageLayoutTabWidgetDropTarget'; import { pageLayoutTabSettingsOpenTabIdComponentState } from '@/page-layout/states/pageLayoutTabSettingsOpenTabIdComponentState'; import { type SingleTabProps } from '@/ui/layout/tab-list/types/SingleTabProps'; import { useAtomComponentStateValue } from '@/ui/utilities/state/jotai/hooks/useAtomComponentStateValue'; @@ -12,6 +13,7 @@ type PageLayoutTabListReorderableTabProps = { index: number; isActive: boolean; disabled?: boolean; + isWidgetDropTarget?: boolean; onSelect: () => void; }; @@ -27,6 +29,7 @@ export const PageLayoutTabListReorderableTab = ({ index, isActive, disabled, + isWidgetDropTarget = false, onSelect, }: PageLayoutTabListReorderableTabProps) => { const pageLayoutTabSettingsOpenTabId = useAtomComponentStateValue( @@ -34,7 +37,8 @@ export const PageLayoutTabListReorderableTab = ({ ); const isSettingsOpenForThisTab = pageLayoutTabSettingsOpenTabId === tab.id; - return ( + + const draggableTab = ( {(draggableProvided, draggableSnapshot) => ( ); + + if (!isWidgetDropTarget) { + return draggableTab; + } + + return ( + + {draggableTab} + + ); }; 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 4d4184cd4f..96f0f2f157 100644 --- a/packages/twenty-front/src/modules/page-layout/components/PageLayoutTabListVisibleTabs.tsx +++ b/packages/twenty-front/src/modules/page-layout/components/PageLayoutTabListVisibleTabs.tsx @@ -23,6 +23,7 @@ type PageLayoutTabListVisibleTabsProps = { onChangeTab?: (tabId: string) => void; onSelectTab: (tabId: string) => void; canReorder: boolean; + widgetDropTargetTabIds: Set; }; const StyledTabContainer = styled.div` @@ -45,6 +46,7 @@ export const PageLayoutTabListVisibleTabs = ({ onChangeTab, onSelectTab, canReorder, + widgetDropTargetTabIds, }: PageLayoutTabListVisibleTabsProps) => { if (canReorder) { return ( @@ -80,6 +82,7 @@ export const PageLayoutTabListVisibleTabs = ({ index={index} isActive={tab.id === activeTabId} disabled={tab.disabled ?? loading} + isWidgetDropTarget={widgetDropTargetTabIds.has(tab.id)} onSelect={() => onSelectTab(tab.id)} /> ))} 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 3ff56e6cae..7c818f866a 100644 --- a/packages/twenty-front/src/modules/page-layout/components/PageLayoutTabsRenderer.tsx +++ b/packages/twenty-front/src/modules/page-layout/components/PageLayoutTabsRenderer.tsx @@ -1,6 +1,7 @@ import { metadataStoreState } from '@/metadata-store/states/metadataStoreState'; import { type FlatObjectMetadataItem } from '@/metadata-store/types/FlatObjectMetadataItem'; import { useObjectMetadataItems } from '@/object-metadata/hooks/useObjectMetadataItems'; +import { PageLayoutWidgetDndProvider } from '@/page-layout/components/dnd/PageLayoutWidgetDndProvider'; import { PageLayoutLeftPanel } from '@/page-layout/components/PageLayoutLeftPanel'; import { PageLayoutTabList } from '@/page-layout/components/PageLayoutTabList'; import { PageLayoutTabListEffect } from '@/page-layout/components/PageLayoutTabListEffect'; @@ -192,57 +193,59 @@ export const PageLayoutTabsRenderer = () => { ); return ( - - {isDefined(pinnedLeftTab) && ( - - )} - - - - {(sortedActiveTabs.length > 1 || isPageLayoutInEditMode) && ( - - reorderRecordPageTabs( - result, - provided, - isDefined(pinnedLeftTab), - ) - : undefined - } - pageLayoutType={currentPageLayout.type} - /> + + + {isDefined(pinnedLeftTab) && ( + )} - - - {isDefined(activeTabId) && activeTabExistsInCurrentPageLayout && ( - - )} - - - - + + + {(sortedActiveTabs.length > 1 || isPageLayoutInEditMode) && ( + + reorderRecordPageTabs( + result, + provided, + isDefined(pinnedLeftTab), + ) + : undefined + } + pageLayoutType={currentPageLayout.type} + /> + )} + + + + {isDefined(activeTabId) && activeTabExistsInCurrentPageLayout && ( + + )} + + + + + ); }; 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 8bc046271f..012b3c6e8c 100644 --- a/packages/twenty-front/src/modules/page-layout/components/PageLayoutVerticalListEditor.tsx +++ b/packages/twenty-front/src/modules/page-layout/components/PageLayoutVerticalListEditor.tsx @@ -1,24 +1,20 @@ +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 { pageLayoutDraggingWidgetIdComponentState } from '@/page-layout/states/pageLayoutDraggingWidgetIdComponentState'; +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 { WidgetRenderer } from '@/page-layout/widgets/components/WidgetRenderer'; import { useIsInPinnedTab } from '@/page-layout/widgets/hooks/useIsInPinnedTab'; import { useLayoutRenderingContext } from '@/ui/layout/contexts/LayoutRenderingContext'; -import { useSetAtomComponentState } from '@/ui/utilities/state/jotai/hooks/useSetAtomComponentState'; -import { - DragDropContext, - Draggable, - Droppable, - type DropResult, -} from '@hello-pangea/dnd'; import { styled } from '@linaria/react'; -import { type ReactNode, useId } from 'react'; +import { type ReactNode } from 'react'; import { themeCssVariables } from 'twenty-ui/theme-constants'; import { useIsMobile } from 'twenty-ui/utilities'; -import { getCssCompatibleDraggableProps } from '@/ui/layout/draggable-list/utils/getCssCompatibleDraggableProps'; - const StyledVerticalListContainer = styled.div<{ variant: PageLayoutVerticalListViewerVariant; shouldUseWhiteBackground: boolean; @@ -36,29 +32,27 @@ const StyledVerticalListContainer = styled.div<{ : themeCssVariables.spacing[2]}; `; -const StyledDraggableWrapper = styled.div<{ isDragging: boolean }>` - background: ${({ isDragging }) => - isDragging - ? themeCssVariables.background.transparent.light - : 'transparent'}; - border-radius: ${themeCssVariables.border.radius.sm}; - transition: background 0.1s ease; +// 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` + display: flex; + flex: 1; + flex-direction: column; + gap: ${themeCssVariables.spacing[4]}; + min-height: ${themeCssVariables.spacing[6]}; + position: relative; `; type PageLayoutVerticalListEditorProps = { widgets: PageLayoutWidget[]; - onReorder: (result: DropResult) => void; - isReorderEnabled?: boolean; trailingElement?: ReactNode; }; export const PageLayoutVerticalListEditor = ({ widgets, - onReorder, - isReorderEnabled = true, trailingElement, }: PageLayoutVerticalListEditorProps) => { - const droppableId = `page-layout-vertical-list-${useId()}`; + const { tabId } = usePageLayoutContentContext(); const { isInSidePanel } = useLayoutRenderingContext(); const isMobile = useIsMobile(); @@ -70,56 +64,36 @@ export const PageLayoutVerticalListEditor = ({ isInSidePanel, }); - const setPageLayoutDraggingWidgetId = useSetAtomComponentState( - pageLayoutDraggingWidgetIdComponentState, - ); + const endDropData: PageLayoutWidgetListDropData = { + type: 'widget-list', + tabId, + }; + + const { ref: endDropRef, isDropTarget: isEndDropTarget } = useDroppable({ + id: `page-layout-widget-list-${tabId}`, + collisionDetector: pointerIntersection, + data: endDropData, + }); return ( - { - setPageLayoutDraggingWidgetId(result.draggableId); - }} - onDragEnd={(result) => { - setPageLayoutDraggingWidgetId(null); - onReorder(result); - }} + - - {(provided) => ( - - {widgets.map((widget, index) => ( - - {(provided, snapshot) => ( - - {/* oxlint-disable-next-line react/jsx-props-no-spreading */} -
- -
-
- )} -
- ))} - {provided.placeholder} - {trailingElement} -
- )} -
-
+ {widgets.map((widget, index) => ( + + + + ))} + + {isEndDropTarget && } + {trailingElement} + + ); }; 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 new file mode 100644 index 0000000000..6e91c54c2e --- /dev/null +++ b/packages/twenty-front/src/modules/page-layout/components/dnd/PageLayoutTabWidgetDropTarget.tsx @@ -0,0 +1,42 @@ +import { pointerIntersection } from '@dnd-kit/collision'; +import { useDroppable } from '@dnd-kit/react'; +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'; + +const StyledDropTarget = styled.div<{ isActive: boolean }>` + border-radius: ${themeCssVariables.border.radius.sm}; + display: flex; + outline: ${({ isActive }) => + isActive ? `1px solid ${themeCssVariables.color.blue}` : 'none'}; + outline-offset: -1px; +`; + +type PageLayoutTabWidgetDropTargetProps = { + tabId: string; + children: ReactNode; +}; + +export const PageLayoutTabWidgetDropTarget = ({ + tabId, + children, +}: PageLayoutTabWidgetDropTargetProps) => { + const data: PageLayoutTabWidgetDropData = { + type: 'tab-widget-drop', + tabId, + }; + + const { ref, isDropTarget } = useDroppable({ + id: `page-layout-tab-widget-drop-${tabId}`, + collisionDetector: pointerIntersection, + data, + }); + + 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 new file mode 100644 index 0000000000..5b5d8ea603 --- /dev/null +++ b/packages/twenty-front/src/modules/page-layout/components/dnd/PageLayoutWidgetDndProvider.tsx @@ -0,0 +1,30 @@ +import { DragDropProvider } from '@dnd-kit/react'; +import { type ReactNode } from 'react'; + +import { usePageLayoutWidgetDragAndDrop } from '@/page-layout/hooks/usePageLayoutWidgetDragAndDrop'; +import { type PageLayoutWidgetDndData } from '@/page-layout/types/PageLayoutWidgetDndData'; +import { DND_KIT_SENSORS } from '@/ui/utilities/drag-and-drop/constants/DndKitSensors'; + +type PageLayoutWidgetDndProviderProps = { + children: ReactNode; +}; + +// Mounted in both view and edit mode so toggling edit mode does not remount the +// layout subtree (which would reset scroll position and widget-local state). +// Widget sortables and drop targets only render while editing, so the provider +// is inert in view mode. +export const PageLayoutWidgetDndProvider = ({ + children, +}: PageLayoutWidgetDndProviderProps) => { + const { handlers } = usePageLayoutWidgetDragAndDrop(); + + return ( + + sensors={DND_KIT_SENSORS} + onDragStart={handlers.onDragStart} + onDragEnd={handlers.onDragEnd} + > + {children} + + ); +}; 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 new file mode 100644 index 0000000000..0d8da68a5c --- /dev/null +++ b/packages/twenty-front/src/modules/page-layout/components/dnd/PageLayoutWidgetDropLine.tsx @@ -0,0 +1,24 @@ +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 new file mode 100644 index 0000000000..79fa45c06a --- /dev/null +++ b/packages/twenty-front/src/modules/page-layout/components/dnd/PageLayoutWidgetSortableItem.tsx @@ -0,0 +1,64 @@ +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/hooks/__tests__/useMoveWidgetToTab.test.tsx b/packages/twenty-front/src/modules/page-layout/hooks/__tests__/useMoveWidgetToTab.test.tsx index c6d9ccb861..82f2ed2a0c 100644 --- a/packages/twenty-front/src/modules/page-layout/hooks/__tests__/useMoveWidgetToTab.test.tsx +++ b/packages/twenty-front/src/modules/page-layout/hooks/__tests__/useMoveWidgetToTab.test.tsx @@ -1,69 +1,18 @@ import { useMoveWidgetToTab } from '@/page-layout/hooks/useMoveWidgetToTab'; 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('useMoveWidgetToTab', () => { const getWrapper = (store = createStore()) => diff --git a/packages/twenty-front/src/modules/page-layout/hooks/useMoveWidgetToTab.ts b/packages/twenty-front/src/modules/page-layout/hooks/useMoveWidgetToTab.ts index dcfe58c2b5..f002090cb0 100644 --- a/packages/twenty-front/src/modules/page-layout/hooks/useMoveWidgetToTab.ts +++ b/packages/twenty-front/src/modules/page-layout/hooks/useMoveWidgetToTab.ts @@ -1,13 +1,10 @@ import { PageLayoutComponentInstanceContext } from '@/page-layout/states/contexts/PageLayoutComponentInstanceContext'; import { pageLayoutDraftComponentState } from '@/page-layout/states/pageLayoutDraftComponentState'; -import { isVerticalListPosition } from '@/page-layout/utils/isVerticalListPosition'; -import { sortWidgetsByVerticalListPosition } from '@/page-layout/utils/sortWidgetsByVerticalListPosition'; +import { moveWidgetToTabInDraft } from '@/page-layout/utils/moveWidgetToTabInDraft'; 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 useMoveWidgetToTab = (pageLayoutIdFromProps?: string) => { const pageLayoutId = useAvailableComponentInstanceIdOrThrow( @@ -24,82 +21,9 @@ export const useMoveWidgetToTab = (pageLayoutIdFromProps?: string) => { const moveWidgetToTab = useCallback( (widgetId: string, destinationTabId: string) => { - store.set(pageLayoutDraftState, (prev) => { - const sourceTab = prev.tabs.find((candidateTab) => - candidateTab.widgets.some((widget) => widget.id === widgetId), - ); - - if (!sourceTab) { - return prev; - } - - if (sourceTab.id === destinationTabId) { - return prev; - } - - const destinationTab = prev.tabs.find( - (candidateTab) => candidateTab.id === destinationTabId, - ); - - if (!destinationTab) { - return prev; - } - - const widget = sourceTab.widgets.find( - (candidateWidget) => candidateWidget.id === widgetId, - ); - - if (!widget) { - return prev; - } - - const movedWidget = { - ...widget, - pageLayoutTabId: destinationTabId, - position: { - __typename: 'PageLayoutWidgetVerticalListPosition' as const, - layoutMode: PageLayoutTabLayoutMode.VERTICAL_LIST, - index: destinationTab.widgets.length, - }, - }; - - return { - ...prev, - tabs: prev.tabs.map((currentTab) => { - if (currentTab.id === sourceTab.id) { - const remainingWidgets = sortWidgetsByVerticalListPosition( - currentTab.widgets, - ) - .filter((tabWidget) => tabWidget.id !== widgetId) - .map((tabWidget, widgetIndex) => ({ - ...tabWidget, - position: - isDefined(tabWidget.position) && - isVerticalListPosition(tabWidget.position) - ? { - __typename: - 'PageLayoutWidgetVerticalListPosition' as const, - layoutMode: PageLayoutTabLayoutMode.VERTICAL_LIST, - index: widgetIndex, - } - : tabWidget.position, - })); - - return { - ...currentTab, - widgets: remainingWidgets, - }; - } - if (currentTab.id === destinationTabId) { - return { - ...currentTab, - widgets: [...currentTab.widgets, movedWidget], - }; - } - return currentTab; - }), - }; - }); + store.set(pageLayoutDraftState, (prev) => + moveWidgetToTabInDraft(prev, { widgetId, destinationTabId }), + ); }, [pageLayoutDraftState, store], ); diff --git a/packages/twenty-front/src/modules/page-layout/hooks/usePageLayoutWidgetDragAndDrop.ts b/packages/twenty-front/src/modules/page-layout/hooks/usePageLayoutWidgetDragAndDrop.ts new file mode 100644 index 0000000000..6dbd8ff5bd --- /dev/null +++ b/packages/twenty-front/src/modules/page-layout/hooks/usePageLayoutWidgetDragAndDrop.ts @@ -0,0 +1,139 @@ +import { type DragDropProvider } from '@dnd-kit/react'; +import { useStore } from 'jotai'; +import { type ComponentProps, useCallback } from 'react'; +import { isDefined } from 'twenty-shared/utils'; + +import { PageLayoutComponentInstanceContext } from '@/page-layout/states/contexts/PageLayoutComponentInstanceContext'; +import { pageLayoutDraftComponentState } from '@/page-layout/states/pageLayoutDraftComponentState'; +import { pageLayoutDraggingWidgetIdComponentState } from '@/page-layout/states/pageLayoutDraggingWidgetIdComponentState'; +import { type PageLayoutWidgetDndData } from '@/page-layout/types/PageLayoutWidgetDndData'; +import { moveWidgetToTabInDraft } from '@/page-layout/utils/moveWidgetToTabInDraft'; +import { moveWidgetWithinTabInDraft } from '@/page-layout/utils/moveWidgetWithinTabInDraft'; +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]; + +export const usePageLayoutWidgetDragAndDrop = ( + pageLayoutIdFromProps?: string, +) => { + const pageLayoutId = useAvailableComponentInstanceIdOrThrow( + PageLayoutComponentInstanceContext, + pageLayoutIdFromProps, + ); + + const pageLayoutDraftState = useAtomComponentStateCallbackState( + pageLayoutDraftComponentState, + pageLayoutId, + ); + + const store = useStore(); + + const setPageLayoutDraggingWidgetId = useSetAtomComponentState( + pageLayoutDraggingWidgetIdComponentState, + pageLayoutId, + ); + + const onDragStart = useCallback( + (event: DragStartEvent) => { + const sourceData = event.operation.source?.data as + | PageLayoutWidgetDndData + | undefined; + + if (sourceData?.type === 'widget') { + setPageLayoutDraggingWidgetId(sourceData.widgetId); + } + }, + [setPageLayoutDraggingWidgetId], + ); + + const onDragEnd = useCallback( + (event: DragEndEvent) => { + const sourceData = event.operation.source?.data as + | PageLayoutWidgetDndData + | undefined; + const targetData = event.operation.target?.data as + | PageLayoutWidgetDndData + | undefined; + + if ( + !event.canceled && + sourceData?.type === 'widget' && + isDefined(targetData) + ) { + const { widgetId, tabId: sourceTabId, index: sourceIndex } = sourceData; + + if (targetData.type === 'tab-widget-drop') { + const destinationTabId = targetData.tabId; + + store.set(pageLayoutDraftState, (prev) => + moveWidgetToTabInDraft(prev, { widgetId, destinationTabId }), + ); + } else if (targetData.type === 'widget-list') { + const destinationTabId = targetData.tabId; + + store.set(pageLayoutDraftState, (prev) => { + if (destinationTabId !== sourceTabId) { + return moveWidgetToTabInDraft(prev, { + widgetId, + destinationTabId, + }); + } + + const tab = prev.tabs.find( + (candidateTab) => candidateTab.id === sourceTabId, + ); + + if (!isDefined(tab)) { + return prev; + } + + return moveWidgetWithinTabInDraft(prev, { + tabId: sourceTabId, + fromIndex: sourceIndex, + toIndex: tab.widgets.length - 1, + }); + }); + } else if (targetData.type === 'widget') { + // The drop line renders above the hovered widget, so the drop targets + // the slot before it; getDestinationIndex compensates for the source + // removal shifting same-tab downward moves by one. + const destinationTabId = targetData.tabId; + const destinationIndex = getDestinationIndex({ + dropTargetIndex: targetData.index, + sourceIndex, + sourceDroppableId: sourceTabId, + destinationDroppableId: destinationTabId, + }); + + store.set(pageLayoutDraftState, (prev) => + destinationTabId === sourceTabId + ? moveWidgetWithinTabInDraft(prev, { + tabId: sourceTabId, + fromIndex: sourceIndex, + toIndex: destinationIndex, + }) + : moveWidgetToTabInDraft(prev, { + widgetId, + destinationTabId, + destinationIndex, + }), + ); + } + } + + setPageLayoutDraggingWidgetId(null); + }, + [store, pageLayoutDraftState, setPageLayoutDraggingWidgetId], + ); + + return { handlers: { onDragStart, onDragEnd } }; +}; diff --git a/packages/twenty-front/src/modules/page-layout/hooks/useReorderPageLayoutWidgets.ts b/packages/twenty-front/src/modules/page-layout/hooks/useReorderPageLayoutWidgets.ts deleted file mode 100644 index 7583bc731a..0000000000 --- a/packages/twenty-front/src/modules/page-layout/hooks/useReorderPageLayoutWidgets.ts +++ /dev/null @@ -1,60 +0,0 @@ -import { PageLayoutComponentInstanceContext } from '@/page-layout/states/contexts/PageLayoutComponentInstanceContext'; -import { pageLayoutDraftComponentState } from '@/page-layout/states/pageLayoutDraftComponentState'; -import { useAvailableComponentInstanceIdOrThrow } from '@/ui/utilities/state/component-state/hooks/useAvailableComponentInstanceIdOrThrow'; -import { useAtomComponentStateCallbackState } from '@/ui/utilities/state/jotai/hooks/useAtomComponentStateCallbackState'; -import { type DropResult } from '@hello-pangea/dnd'; -import { useStore } from 'jotai'; -import { useCallback } from 'react'; -import { isDefined } from 'twenty-shared/utils'; -import { PageLayoutTabLayoutMode } from '~/generated-metadata/graphql'; - -export const useReorderPageLayoutWidgets = ( - tabId: string, - pageLayoutIdFromProps?: string, -) => { - const pageLayoutId = useAvailableComponentInstanceIdOrThrow( - PageLayoutComponentInstanceContext, - pageLayoutIdFromProps, - ); - - const pageLayoutDraftState = useAtomComponentStateCallbackState( - pageLayoutDraftComponentState, - pageLayoutId, - ); - - const store = useStore(); - - const reorderWidgets = useCallback( - (result: DropResult) => { - if (!result.destination) return; - - store.set(pageLayoutDraftState, (prev) => { - const tab = prev.tabs.find((t) => t.id === tabId); - if (!isDefined(tab)) return prev; - - const newWidgets = Array.from(tab.widgets ?? []); - const [removed] = newWidgets.splice(result.source.index, 1); - newWidgets.splice(result.destination!.index, 0, removed); - - const reindexedWidgets = newWidgets.map((widget, index) => ({ - ...widget, - position: { - __typename: 'PageLayoutWidgetVerticalListPosition' as const, - layoutMode: PageLayoutTabLayoutMode.VERTICAL_LIST, - index, - }, - })); - - return { - ...prev, - tabs: prev.tabs.map((t) => - t.id === tabId ? { ...t, widgets: reindexedWidgets } : t, - ), - }; - }); - }, - [tabId, pageLayoutDraftState, store], - ); - - return { reorderWidgets }; -}; diff --git a/packages/twenty-front/src/modules/page-layout/testing/pageLayoutDraftFixtures.ts b/packages/twenty-front/src/modules/page-layout/testing/pageLayoutDraftFixtures.ts new file mode 100644 index 0000000000..e27436c738 --- /dev/null +++ b/packages/twenty-front/src/modules/page-layout/testing/pageLayoutDraftFixtures.ts @@ -0,0 +1,59 @@ +import { type DraftPageLayout } from '@/page-layout/types/DraftPageLayout'; +import { type PageLayoutWidget } from '@/page-layout/types/PageLayoutWidget'; +import { + PageLayoutTabLayoutMode, + PageLayoutType, + WidgetType, +} from '~/generated-metadata/graphql'; + +export const makeWidget = ( + id: string, + index: number, + tabId = '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; + +export const makeTab = ( + id: string, + widgets: PageLayoutWidget[], + position = 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, +}); + +export const makeDraft = ( + tabs: ReturnType[], +): DraftPageLayout => ({ + id: 'test-layout', + name: 'Test Layout', + type: PageLayoutType.RECORD_PAGE, + objectMetadataId: null, + tabs, +}); diff --git a/packages/twenty-front/src/modules/page-layout/types/PageLayoutWidgetDndData.ts b/packages/twenty-front/src/modules/page-layout/types/PageLayoutWidgetDndData.ts new file mode 100644 index 0000000000..32253a537b --- /dev/null +++ b/packages/twenty-front/src/modules/page-layout/types/PageLayoutWidgetDndData.ts @@ -0,0 +1,21 @@ +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; +}; + +export type PageLayoutWidgetDndData = + | PageLayoutWidgetDragData + | PageLayoutTabWidgetDropData + | PageLayoutWidgetListDropData; diff --git a/packages/twenty-front/src/modules/page-layout/utils/__tests__/moveWidgetToTabInDraft.test.ts b/packages/twenty-front/src/modules/page-layout/utils/__tests__/moveWidgetToTabInDraft.test.ts new file mode 100644 index 0000000000..e233be81c0 --- /dev/null +++ b/packages/twenty-front/src/modules/page-layout/utils/__tests__/moveWidgetToTabInDraft.test.ts @@ -0,0 +1,156 @@ +import { moveWidgetToTabInDraft } from '@/page-layout/utils/moveWidgetToTabInDraft'; +import { type DraftPageLayout } from '@/page-layout/types/DraftPageLayout'; +import { + makeDraft, + makeTab, + makeWidget, +} from '@/page-layout/testing/pageLayoutDraftFixtures'; +import { PageLayoutTabLayoutMode } from '~/generated-metadata/graphql'; + +const indicesOf = (draft: DraftPageLayout, tabIndex: number) => + draft.tabs[tabIndex].widgets.map((widget) => + widget.position && 'index' in widget.position ? widget.position.index : -1, + ); + +describe('moveWidgetToTabInDraft', () => { + it('appends the widget to the destination tab when no index is given', () => { + const draft = makeDraft([ + makeTab('tab-1', [makeWidget('widget-a', 0), makeWidget('widget-b', 1)]), + makeTab('tab-2', [makeWidget('widget-x', 0, 'tab-2')], 1), + ]); + + const result = moveWidgetToTabInDraft(draft, { + widgetId: 'widget-a', + destinationTabId: 'tab-2', + }); + + expect(result.tabs[0].widgets.map((w) => w.id)).toEqual(['widget-b']); + expect(result.tabs[1].widgets.map((w) => w.id)).toEqual([ + 'widget-x', + 'widget-a', + ]); + const moved = result.tabs[1].widgets.find((w) => w.id === 'widget-a'); + expect(moved?.position).toEqual(expect.objectContaining({ index: 1 })); + expect(moved?.pageLayoutTabId).toBe('tab-2'); + }); + + it('reindexes the remaining widgets in the source tab', () => { + const draft = makeDraft([ + makeTab('tab-1', [ + makeWidget('widget-a', 0), + makeWidget('widget-b', 1), + makeWidget('widget-c', 2), + ]), + makeTab('tab-2', [], 1), + ]); + + const result = moveWidgetToTabInDraft(draft, { + widgetId: 'widget-a', + destinationTabId: 'tab-2', + }); + + expect(indicesOf(result, 0)).toEqual([0, 1]); + }); + + it('inserts the widget at the given destination index and reindexes', () => { + const draft = makeDraft([ + makeTab('tab-1', [makeWidget('widget-a', 0)]), + makeTab( + 'tab-2', + [ + makeWidget('widget-x', 0, 'tab-2'), + makeWidget('widget-y', 1, 'tab-2'), + ], + 1, + ), + ]); + + const result = moveWidgetToTabInDraft(draft, { + widgetId: 'widget-a', + destinationTabId: 'tab-2', + destinationIndex: 1, + }); + + expect(result.tabs[1].widgets.map((w) => w.id)).toEqual([ + 'widget-x', + 'widget-a', + 'widget-y', + ]); + expect(indicesOf(result, 1)).toEqual([0, 1, 2]); + }); + + it('clamps an out-of-range destination index', () => { + const draft = makeDraft([ + makeTab('tab-1', [makeWidget('widget-a', 0)]), + makeTab('tab-2', [makeWidget('widget-x', 0, 'tab-2')], 1), + ]); + + const result = moveWidgetToTabInDraft(draft, { + widgetId: 'widget-a', + destinationTabId: 'tab-2', + destinationIndex: 99, + }); + + expect(result.tabs[1].widgets.map((w) => w.id)).toEqual([ + 'widget-x', + 'widget-a', + ]); + }); + + it('returns the draft unchanged for a same-tab move', () => { + const draft = makeDraft([ + makeTab('tab-1', [makeWidget('widget-a', 0), makeWidget('widget-b', 1)]), + ]); + + expect( + moveWidgetToTabInDraft(draft, { + widgetId: 'widget-a', + destinationTabId: 'tab-1', + }), + ).toBe(draft); + }); + + it('returns the draft unchanged when the widget is missing', () => { + const draft = makeDraft([ + makeTab('tab-1', [makeWidget('widget-a', 0)]), + makeTab('tab-2', [], 1), + ]); + + expect( + moveWidgetToTabInDraft(draft, { + widgetId: 'missing', + destinationTabId: 'tab-2', + }), + ).toBe(draft); + }); + + it('returns the draft unchanged when the destination tab is missing', () => { + const draft = makeDraft([makeTab('tab-1', [makeWidget('widget-a', 0)])]); + + expect( + moveWidgetToTabInDraft(draft, { + widgetId: 'widget-a', + destinationTabId: 'missing', + }), + ).toBe(draft); + }); + + it('returns the draft unchanged when the destination tab is not a vertical list', () => { + const draft = makeDraft([ + makeTab('tab-1', [makeWidget('widget-a', 0)]), + makeTab( + 'tab-2', + [makeWidget('widget-x', 0, 'tab-2')], + 1, + PageLayoutTabLayoutMode.CANVAS, + ), + ]); + + expect( + moveWidgetToTabInDraft(draft, { + widgetId: 'widget-a', + destinationTabId: 'tab-2', + }), + ).toBe(draft); + }); +}); diff --git a/packages/twenty-front/src/modules/page-layout/utils/__tests__/moveWidgetWithinTabInDraft.test.ts b/packages/twenty-front/src/modules/page-layout/utils/__tests__/moveWidgetWithinTabInDraft.test.ts new file mode 100644 index 0000000000..469d2c520d --- /dev/null +++ b/packages/twenty-front/src/modules/page-layout/utils/__tests__/moveWidgetWithinTabInDraft.test.ts @@ -0,0 +1,108 @@ +import { moveWidgetWithinTabInDraft } from '@/page-layout/utils/moveWidgetWithinTabInDraft'; +import { type DraftPageLayout } from '@/page-layout/types/DraftPageLayout'; +import { type PageLayoutWidget } from '@/page-layout/types/PageLayoutWidget'; +import { + makeDraft as makeDraftFromTabs, + makeTab, + makeWidget, +} from '@/page-layout/testing/pageLayoutDraftFixtures'; + +const makeDraft = (widgets: PageLayoutWidget[]): DraftPageLayout => + makeDraftFromTabs([makeTab('tab-1', widgets)]); + +const orderOf = (draft: DraftPageLayout) => + draft.tabs[0].widgets.map((widget) => widget.id); + +const indicesOf = (draft: DraftPageLayout) => + draft.tabs[0].widgets.map((widget) => + widget.position && 'index' in widget.position ? widget.position.index : -1, + ); + +describe('moveWidgetWithinTabInDraft', () => { + it('moves a widget down and reindexes', () => { + const draft = makeDraft([ + makeWidget('widget-a', 0), + makeWidget('widget-b', 1), + makeWidget('widget-c', 2), + ]); + + const result = moveWidgetWithinTabInDraft(draft, { + tabId: 'tab-1', + fromIndex: 0, + toIndex: 2, + }); + + expect(orderOf(result)).toEqual(['widget-b', 'widget-c', 'widget-a']); + expect(indicesOf(result)).toEqual([0, 1, 2]); + }); + + it('moves a widget up and reindexes', () => { + const draft = makeDraft([ + makeWidget('widget-a', 0), + makeWidget('widget-b', 1), + makeWidget('widget-c', 2), + ]); + + const result = moveWidgetWithinTabInDraft(draft, { + tabId: 'tab-1', + fromIndex: 2, + toIndex: 0, + }); + + expect(orderOf(result)).toEqual(['widget-c', 'widget-a', 'widget-b']); + expect(indicesOf(result)).toEqual([0, 1, 2]); + }); + + it('sorts by position before applying the move regardless of array order', () => { + const draft = makeDraft([ + makeWidget('widget-c', 2), + makeWidget('widget-a', 0), + makeWidget('widget-b', 1), + ]); + + const result = moveWidgetWithinTabInDraft(draft, { + tabId: 'tab-1', + fromIndex: 0, + toIndex: 1, + }); + + expect(orderOf(result)).toEqual(['widget-b', 'widget-a', 'widget-c']); + expect(indicesOf(result)).toEqual([0, 1, 2]); + }); + + it('returns the draft unchanged when indices are equal', () => { + const draft = makeDraft([makeWidget('widget-a', 0)]); + + expect( + moveWidgetWithinTabInDraft(draft, { + tabId: 'tab-1', + fromIndex: 0, + toIndex: 0, + }), + ).toBe(draft); + }); + + it('returns the draft unchanged when an index is out of range', () => { + const draft = makeDraft([makeWidget('widget-a', 0)]); + + expect( + moveWidgetWithinTabInDraft(draft, { + tabId: 'tab-1', + fromIndex: 0, + toIndex: 5, + }), + ).toBe(draft); + }); + + it('returns the draft unchanged when the tab is missing', () => { + const draft = makeDraft([makeWidget('widget-a', 0)]); + + expect( + moveWidgetWithinTabInDraft(draft, { + tabId: 'missing', + fromIndex: 0, + toIndex: 0, + }), + ).toBe(draft); + }); +}); diff --git a/packages/twenty-front/src/modules/page-layout/utils/moveWidgetToTabInDraft.ts b/packages/twenty-front/src/modules/page-layout/utils/moveWidgetToTabInDraft.ts new file mode 100644 index 0000000000..f9f03c5a75 --- /dev/null +++ b/packages/twenty-front/src/modules/page-layout/utils/moveWidgetToTabInDraft.ts @@ -0,0 +1,82 @@ +import { type DraftPageLayout } from '@/page-layout/types/DraftPageLayout'; +import { reindexWidgetsToVerticalListPositions } from '@/page-layout/utils/reindexWidgetsToVerticalListPositions'; +import { sortWidgetsByVerticalListPosition } from '@/page-layout/utils/sortWidgetsByVerticalListPosition'; +import { isDefined } from 'twenty-shared/utils'; +import { PageLayoutTabLayoutMode } from '~/generated-metadata/graphql'; + +type MoveWidgetToTabInDraftParams = { + widgetId: string; + destinationTabId: string; + destinationIndex?: number; +}; + +export const moveWidgetToTabInDraft = ( + draft: DraftPageLayout, + { + widgetId, + destinationTabId, + destinationIndex, + }: MoveWidgetToTabInDraftParams, +): 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); + + // Widgets carry vertical-list positions, so moving one into a canvas/grid tab + // would reindex that tab's widgets and clobber their native placement. + 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 remainingWidgets = reindexWidgetsToVerticalListPositions( + sortWidgetsByVerticalListPosition(sourceTab.widgets).filter( + (tabWidget) => tabWidget.id !== widgetId, + ), + ); + + const sortedDestinationWidgets = sortWidgetsByVerticalListPosition( + destinationTab.widgets, + ); + const insertIndex = isDefined(destinationIndex) + ? Math.max(0, Math.min(destinationIndex, sortedDestinationWidgets.length)) + : sortedDestinationWidgets.length; + + sortedDestinationWidgets.splice(insertIndex, 0, { + ...widget, + pageLayoutTabId: destinationTabId, + }); + + const destinationWidgets = reindexWidgetsToVerticalListPositions( + sortedDestinationWidgets, + ); + + return { + ...draft, + tabs: draft.tabs.map((tab) => { + if (tab.id === sourceTab.id) { + return { ...tab, widgets: remainingWidgets }; + } + if (tab.id === destinationTabId) { + return { ...tab, widgets: destinationWidgets }; + } + return tab; + }), + }; +}; diff --git a/packages/twenty-front/src/modules/page-layout/utils/moveWidgetWithinTabInDraft.ts b/packages/twenty-front/src/modules/page-layout/utils/moveWidgetWithinTabInDraft.ts new file mode 100644 index 0000000000..36bdd79537 --- /dev/null +++ b/packages/twenty-front/src/modules/page-layout/utils/moveWidgetWithinTabInDraft.ts @@ -0,0 +1,44 @@ +import { type DraftPageLayout } from '@/page-layout/types/DraftPageLayout'; +import { reindexWidgetsToVerticalListPositions } from '@/page-layout/utils/reindexWidgetsToVerticalListPositions'; +import { sortWidgetsByVerticalListPosition } from '@/page-layout/utils/sortWidgetsByVerticalListPosition'; +import { isDefined } from 'twenty-shared/utils'; +import { moveArrayItem } from '~/utils/array/moveArrayItem'; + +type MoveWidgetWithinTabInDraftParams = { + tabId: string; + fromIndex: number; + toIndex: number; +}; + +export const moveWidgetWithinTabInDraft = ( + draft: DraftPageLayout, + { tabId, fromIndex, toIndex }: MoveWidgetWithinTabInDraftParams, +): DraftPageLayout => { + const tab = draft.tabs.find((candidateTab) => candidateTab.id === tabId); + + if (!isDefined(tab)) { + return draft; + } + + const orderedWidgets = sortWidgetsByVerticalListPosition(tab.widgets); + const reorderedWidgets = moveArrayItem(orderedWidgets, { + fromIndex, + toIndex, + }); + + if (reorderedWidgets === orderedWidgets) { + return draft; + } + + const reindexedWidgets = + reindexWidgetsToVerticalListPositions(reorderedWidgets); + + return { + ...draft, + tabs: draft.tabs.map((candidateTab) => + candidateTab.id === tabId + ? { ...candidateTab, widgets: reindexedWidgets } + : candidateTab, + ), + }; +}; diff --git a/packages/twenty-front/src/modules/page-layout/utils/reindexWidgetsToVerticalListPositions.ts b/packages/twenty-front/src/modules/page-layout/utils/reindexWidgetsToVerticalListPositions.ts new file mode 100644 index 0000000000..3e961826a3 --- /dev/null +++ b/packages/twenty-front/src/modules/page-layout/utils/reindexWidgetsToVerticalListPositions.ts @@ -0,0 +1,14 @@ +import { type PageLayoutWidget } from '@/page-layout/types/PageLayoutWidget'; +import { PageLayoutTabLayoutMode } from '~/generated-metadata/graphql'; + +export const reindexWidgetsToVerticalListPositions = ( + widgets: PageLayoutWidget[], +): PageLayoutWidget[] => + widgets.map((widget, index) => ({ + ...widget, + position: { + __typename: 'PageLayoutWidgetVerticalListPosition' as const, + layoutMode: PageLayoutTabLayoutMode.VERTICAL_LIST, + index, + }, + })); 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 37e3e5dbbd..8a2ede4f0b 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 @@ -5,9 +5,10 @@ import { import { SortableKeyboardPlugin } from '@dnd-kit/dom/sortable'; import { useSortable } from '@dnd-kit/react/sortable'; import { styled } from '@linaria/react'; -import { type DragEvent, type ReactNode } from 'react'; +import { type ReactNode } from 'react'; 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; @@ -19,12 +20,6 @@ const SORTABLE_TRANSITION = { idle: true, }; -// Links and images inside sortable items are natively draggable, which lets -// the browser start a URL drag that cancels the dnd-kit pointer drag. -const preventNativeDragStart = (event: DragEvent) => { - event.preventDefault(); -}; - const StyledSortableRoot = styled.div<{ $fill?: boolean }>` display: ${({ $fill }) => ($fill ? 'flex' : 'block')}; flex-shrink: ${({ $fill }) => ($fill ? 0 : 'initial')}; diff --git a/packages/twenty-front/src/modules/ui/utilities/drag-and-drop/utils/preventNativeDragStart.ts b/packages/twenty-front/src/modules/ui/utilities/drag-and-drop/utils/preventNativeDragStart.ts new file mode 100644 index 0000000000..30eec39a58 --- /dev/null +++ b/packages/twenty-front/src/modules/ui/utilities/drag-and-drop/utils/preventNativeDragStart.ts @@ -0,0 +1,7 @@ +import { type DragEvent } from 'react'; + +// Links and images inside draggable items are natively draggable, which lets +// the browser start a URL drag that cancels the dnd-kit pointer drag. +export const preventNativeDragStart = (event: DragEvent) => { + event.preventDefault(); +};