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:
@@ -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 (
|
||||
<PageLayoutVerticalListEditor
|
||||
widgets={activeTab.widgets}
|
||||
onReorder={reorderWidgets}
|
||||
isReorderEnabled={true}
|
||||
trailingElement={<RecordPageAddWidgetSection />}
|
||||
/>
|
||||
);
|
||||
|
||||
+4
-1
@@ -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,
|
||||
}}
|
||||
>
|
||||
<PageLayoutContent />
|
||||
<PageLayoutWidgetDndProvider>
|
||||
<PageLayoutContent />
|
||||
</PageLayoutWidgetDndProvider>
|
||||
</PageLayoutContentProvider>
|
||||
</>
|
||||
);
|
||||
|
||||
@@ -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 (
|
||||
<TabListComponentInstanceContext.Provider
|
||||
value={{ instanceId: componentInstanceId }}
|
||||
@@ -371,6 +386,7 @@ export const PageLayoutTabList = ({
|
||||
onChangeTab={onChangeTab}
|
||||
onSelectTab={handleSelectTab}
|
||||
canReorder={canReorderTabs}
|
||||
widgetDropTargetTabIds={widgetDropTargetTabIds}
|
||||
/>
|
||||
|
||||
{shouldRenderReorderableDropdown && (
|
||||
@@ -436,6 +452,7 @@ export const PageLayoutTabList = ({
|
||||
onChangeTab={onChangeTab}
|
||||
onSelectTab={handleSelectTab}
|
||||
canReorder={canReorderTabs}
|
||||
widgetDropTargetTabIds={widgetDropTargetTabIds}
|
||||
/>
|
||||
{shouldRenderStaticDropdown && (
|
||||
<StyledDropdownContainer>
|
||||
|
||||
+15
-1
@@ -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 = (
|
||||
<Draggable draggableId={tab.id} index={index} isDragDisabled={disabled}>
|
||||
{(draggableProvided, draggableSnapshot) => (
|
||||
<StyledTabContainer
|
||||
@@ -66,4 +70,14 @@ export const PageLayoutTabListReorderableTab = ({
|
||||
)}
|
||||
</Draggable>
|
||||
);
|
||||
|
||||
if (!isWidgetDropTarget) {
|
||||
return draggableTab;
|
||||
}
|
||||
|
||||
return (
|
||||
<PageLayoutTabWidgetDropTarget tabId={tab.id}>
|
||||
{draggableTab}
|
||||
</PageLayoutTabWidgetDropTarget>
|
||||
);
|
||||
};
|
||||
|
||||
+3
@@ -23,6 +23,7 @@ type PageLayoutTabListVisibleTabsProps = {
|
||||
onChangeTab?: (tabId: string) => void;
|
||||
onSelectTab: (tabId: string) => void;
|
||||
canReorder: boolean;
|
||||
widgetDropTargetTabIds: Set<string>;
|
||||
};
|
||||
|
||||
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)}
|
||||
/>
|
||||
))}
|
||||
|
||||
+53
-50
@@ -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 (
|
||||
<StyledContainer hasPinnedTab={isDefined(pinnedLeftTab)}>
|
||||
{isDefined(pinnedLeftTab) && (
|
||||
<PageLayoutLeftPanel pinnedLeftTabId={pinnedLeftTab.id} />
|
||||
)}
|
||||
|
||||
<StyledTabsAndDashboardContainer>
|
||||
<PageLayoutTabListEffect
|
||||
tabs={sortedActiveTabs}
|
||||
componentInstanceId={tabListInstanceId}
|
||||
defaultTabToFocusOnMobileAndSidePanelId={
|
||||
currentPageLayout.defaultTabToFocusOnMobileAndSidePanelId ??
|
||||
undefined
|
||||
}
|
||||
/>
|
||||
{(sortedActiveTabs.length > 1 || isPageLayoutInEditMode) && (
|
||||
<PageLayoutTabList
|
||||
className="page-layout-tab-list-print-hidden"
|
||||
tabs={sortedActiveTabs}
|
||||
behaveAsLinks={!isInSidePanel && !isPageLayoutInEditMode}
|
||||
isInSidePanel={isInSidePanel}
|
||||
componentInstanceId={tabListInstanceId}
|
||||
addTabStrategy={addTabStrategy}
|
||||
isReorderEnabled={canEnableTabEditing}
|
||||
onReorder={
|
||||
canEnableTabEditing
|
||||
? (result, provided) =>
|
||||
reorderRecordPageTabs(
|
||||
result,
|
||||
provided,
|
||||
isDefined(pinnedLeftTab),
|
||||
)
|
||||
: undefined
|
||||
}
|
||||
pageLayoutType={currentPageLayout.type}
|
||||
/>
|
||||
<PageLayoutWidgetDndProvider>
|
||||
<StyledContainer hasPinnedTab={isDefined(pinnedLeftTab)}>
|
||||
{isDefined(pinnedLeftTab) && (
|
||||
<PageLayoutLeftPanel pinnedLeftTabId={pinnedLeftTab.id} />
|
||||
)}
|
||||
|
||||
<StyledScrollWrapperContainer>
|
||||
<ScrollWrapper
|
||||
className="page-layout-scroll-wrapper"
|
||||
componentInstanceId={getScrollWrapperInstanceIdFromPageLayoutId(
|
||||
currentPageLayout.id,
|
||||
)}
|
||||
defaultEnableXScroll={false}
|
||||
>
|
||||
{isDefined(activeTabId) && activeTabExistsInCurrentPageLayout && (
|
||||
<PageLayoutMainContent tabId={activeTabId} />
|
||||
)}
|
||||
</ScrollWrapper>
|
||||
</StyledScrollWrapperContainer>
|
||||
</StyledTabsAndDashboardContainer>
|
||||
</StyledContainer>
|
||||
<StyledTabsAndDashboardContainer>
|
||||
<PageLayoutTabListEffect
|
||||
tabs={sortedActiveTabs}
|
||||
componentInstanceId={tabListInstanceId}
|
||||
defaultTabToFocusOnMobileAndSidePanelId={
|
||||
currentPageLayout.defaultTabToFocusOnMobileAndSidePanelId ??
|
||||
undefined
|
||||
}
|
||||
/>
|
||||
{(sortedActiveTabs.length > 1 || isPageLayoutInEditMode) && (
|
||||
<PageLayoutTabList
|
||||
className="page-layout-tab-list-print-hidden"
|
||||
tabs={sortedActiveTabs}
|
||||
behaveAsLinks={!isInSidePanel && !isPageLayoutInEditMode}
|
||||
isInSidePanel={isInSidePanel}
|
||||
componentInstanceId={tabListInstanceId}
|
||||
addTabStrategy={addTabStrategy}
|
||||
isReorderEnabled={canEnableTabEditing}
|
||||
onReorder={
|
||||
canEnableTabEditing
|
||||
? (result, provided) =>
|
||||
reorderRecordPageTabs(
|
||||
result,
|
||||
provided,
|
||||
isDefined(pinnedLeftTab),
|
||||
)
|
||||
: undefined
|
||||
}
|
||||
pageLayoutType={currentPageLayout.type}
|
||||
/>
|
||||
)}
|
||||
|
||||
<StyledScrollWrapperContainer>
|
||||
<ScrollWrapper
|
||||
className="page-layout-scroll-wrapper"
|
||||
componentInstanceId={getScrollWrapperInstanceIdFromPageLayoutId(
|
||||
currentPageLayout.id,
|
||||
)}
|
||||
defaultEnableXScroll={false}
|
||||
>
|
||||
{isDefined(activeTabId) && activeTabExistsInCurrentPageLayout && (
|
||||
<PageLayoutMainContent tabId={activeTabId} />
|
||||
)}
|
||||
</ScrollWrapper>
|
||||
</StyledScrollWrapperContainer>
|
||||
</StyledTabsAndDashboardContainer>
|
||||
</StyledContainer>
|
||||
</PageLayoutWidgetDndProvider>
|
||||
);
|
||||
};
|
||||
|
||||
+45
-71
@@ -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 (
|
||||
<DragDropContext
|
||||
onDragStart={(result) => {
|
||||
setPageLayoutDraggingWidgetId(result.draggableId);
|
||||
}}
|
||||
onDragEnd={(result) => {
|
||||
setPageLayoutDraggingWidgetId(null);
|
||||
onReorder(result);
|
||||
}}
|
||||
<StyledVerticalListContainer
|
||||
variant={variant}
|
||||
shouldUseWhiteBackground={!isInPinnedTab || isMobile}
|
||||
>
|
||||
<Droppable droppableId={droppableId}>
|
||||
{(provided) => (
|
||||
<StyledVerticalListContainer
|
||||
ref={provided.innerRef}
|
||||
variant={variant}
|
||||
shouldUseWhiteBackground={!isInPinnedTab || isMobile}
|
||||
// oxlint-disable-next-line react/jsx-props-no-spreading
|
||||
{...provided.droppableProps}
|
||||
>
|
||||
{widgets.map((widget, index) => (
|
||||
<Draggable
|
||||
key={widget.id}
|
||||
draggableId={widget.id}
|
||||
index={index}
|
||||
isDragDisabled={!isReorderEnabled}
|
||||
>
|
||||
{(provided, snapshot) => (
|
||||
<StyledDraggableWrapper
|
||||
ref={provided.innerRef}
|
||||
// oxlint-disable-next-line react/jsx-props-no-spreading
|
||||
{...getCssCompatibleDraggableProps(provided.draggableProps)}
|
||||
isDragging={snapshot.isDragging}
|
||||
>
|
||||
{/* oxlint-disable-next-line react/jsx-props-no-spreading */}
|
||||
<div {...provided.dragHandleProps}>
|
||||
<WidgetRenderer widget={widget} />
|
||||
</div>
|
||||
</StyledDraggableWrapper>
|
||||
)}
|
||||
</Draggable>
|
||||
))}
|
||||
{provided.placeholder}
|
||||
{trailingElement}
|
||||
</StyledVerticalListContainer>
|
||||
)}
|
||||
</Droppable>
|
||||
</DragDropContext>
|
||||
{widgets.map((widget, index) => (
|
||||
<PageLayoutWidgetSortableItem
|
||||
key={widget.id}
|
||||
widgetId={widget.id}
|
||||
tabId={tabId}
|
||||
index={index}
|
||||
>
|
||||
<WidgetRenderer widget={widget} />
|
||||
</PageLayoutWidgetSortableItem>
|
||||
))}
|
||||
<StyledEndDropZone ref={endDropRef}>
|
||||
{isEndDropTarget && <PageLayoutWidgetDropLine />}
|
||||
{trailingElement}
|
||||
</StyledEndDropZone>
|
||||
</StyledVerticalListContainer>
|
||||
);
|
||||
};
|
||||
|
||||
+42
@@ -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 (
|
||||
<StyledDropTarget ref={ref} isActive={isDropTarget}>
|
||||
{children}
|
||||
</StyledDropTarget>
|
||||
);
|
||||
};
|
||||
+30
@@ -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 (
|
||||
<DragDropProvider<PageLayoutWidgetDndData>
|
||||
sensors={DND_KIT_SENSORS}
|
||||
onDragStart={handlers.onDragStart}
|
||||
onDragEnd={handlers.onDragEnd}
|
||||
>
|
||||
{children}
|
||||
</DragDropProvider>
|
||||
);
|
||||
};
|
||||
+24
@@ -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 = () => (
|
||||
<StyledDropLineContainer>
|
||||
<StyledDropLine />
|
||||
</StyledDropLineContainer>
|
||||
);
|
||||
+64
@@ -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 (
|
||||
<StyledSortableRoot
|
||||
ref={ref}
|
||||
isDragging={isDragging}
|
||||
onDragStart={preventNativeDragStart}
|
||||
>
|
||||
{isDropTarget && <PageLayoutWidgetDropLine />}
|
||||
{children}
|
||||
</StyledSortableRoot>
|
||||
);
|
||||
};
|
||||
Reference in New Issue
Block a user