65d399c70d
## 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.
83 lines
2.4 KiB
TypeScript
83 lines
2.4 KiB
TypeScript
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;
|
|
}),
|
|
};
|
|
};
|