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,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<typeof makeTab>[],
): DraftPageLayout => ({
id: 'test-layout',
name: 'Test Layout',
type: PageLayoutType.RECORD_PAGE,
objectMetadataId: null,
tabs,
});