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:
+156
@@ -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);
|
||||
});
|
||||
});
|
||||
+108
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
}),
|
||||
};
|
||||
};
|
||||
@@ -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,
|
||||
),
|
||||
};
|
||||
};
|
||||
+14
@@ -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,
|
||||
},
|
||||
}));
|
||||
Reference in New Issue
Block a user