feat(page-layout): drag widgets across tabs on record pages (#23023)

## 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.
This commit is contained in:
Félix Malfait
2026-07-23 15:06:20 +02:00
committed by GitHub
parent 96a2456367
commit 65d399c70d
24 changed files with 939 additions and 332 deletions
@@ -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;
}),
};
};