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();
+};