Navbar drag drop using dnd kit (#18288)

This commit is contained in:
Abdul Rahman
2026-03-09 01:55:50 +05:30
committed by GitHub
parent 66d93c4d28
commit 5b28e59ca7
119 changed files with 3563 additions and 1297 deletions
@@ -0,0 +1,177 @@
import {
DragDropContext,
type DragStart,
type DropResult,
type OnDragUpdateResponder,
type ResponderProvided,
} from '@hello-pangea/dnd';
import { useState, type ReactNode } from 'react';
import { FavoritesDragContext } from '@/favorites/contexts/FavoritesDragContext';
import { useHandleFavoriteDragAndDrop } from '@/favorites/hooks/useHandleFavoriteDragAndDrop';
import { ADD_TO_NAV_SOURCE_DROPPABLE_ID } from '@/navigation-menu-item/constants/AddToNavSourceDroppableId';
import { NavigationMenuItemDroppableIds } from '@/navigation-menu-item/constants/NavigationMenuItemDroppableIds';
import { NavigationDragSourceContext } from '@/navigation-menu-item/contexts/NavigationDragSourceContext';
import { NavigationDropTargetContext } from '@/navigation-menu-item/contexts/NavigationDropTargetContext';
import { NavigationMenuItemDragContext } from '@/navigation-menu-item/contexts/NavigationMenuItemDragContext';
import { useHandleAddToNavigationDrop } from '@/navigation-menu-item/hooks/useHandleAddToNavigationDrop';
import { useHandleNavigationMenuItemDragAndDrop } from '@/navigation-menu-item/hooks/useHandleNavigationMenuItemDragAndDrop';
import { useNavigationMenuItemsDraftState } from '@/navigation-menu-item/hooks/useNavigationMenuItemsDraftState';
import { addToNavPayloadRegistryState } from '@/navigation-menu-item/states/addToNavPayloadRegistryState';
import { getDropTargetIdFromDestination } from '@/navigation-menu-item/utils/getDropTargetIdFromDestination';
import { getFavoritesDropTargetIdFromDestination } from '@/navigation-menu-item/utils/getFavoritesDropTargetIdFromDestination';
import { isWorkspaceDroppableId } from '@/navigation-menu-item/utils/isWorkspaceDroppableId';
import { validateAndExtractWorkspaceFolderId } from '@/navigation-menu-item/utils/validateAndExtractWorkspaceFolderId';
import { useStore } from 'jotai';
import { isDefined } from 'twenty-shared/utils';
type FavoritesDragDropProviderContentProps = {
children: ReactNode;
};
export const FavoritesDragDropProviderContent = ({
children,
}: FavoritesDragDropProviderContentProps) => {
const [isDragging, setIsDragging] = useState(false);
const [sourceDroppableId, setSourceDroppableId] = useState<string | null>(
null,
);
const [activeDropTargetId, setActiveDropTargetId] = useState<string | null>(
null,
);
const [forbiddenDropTargetId, setForbiddenDropTargetId] = useState<
string | null
>(null);
const [
addToNavigationFallbackDestination,
setAddToNavigationFallbackDestination,
] = useState<{ droppableId: string; index: number } | null>(null);
const store = useStore();
const { workspaceNavigationMenuItems } = useNavigationMenuItemsDraftState();
const { handleAddToNavigationDrop } = useHandleAddToNavigationDrop();
const { handleFavoriteDragAndDrop } = useHandleFavoriteDragAndDrop();
const { handleNavigationMenuItemDragAndDrop } =
useHandleNavigationMenuItemDragAndDrop();
const isFavoritesDroppableId = (droppableId: string) =>
droppableId ===
NavigationMenuItemDroppableIds.ORPHAN_NAVIGATION_MENU_ITEMS ||
droppableId.startsWith('folder-');
const orphanItemCount = workspaceNavigationMenuItems.filter(
(item: { folderId?: string | null }) => !isDefined(item.folderId),
).length;
const handleDragStart = (dragStart: DragStart) => {
setIsDragging(true);
setSourceDroppableId(dragStart.source.droppableId);
if (dragStart.source.droppableId === ADD_TO_NAV_SOURCE_DROPPABLE_ID) {
const defaultDestination = {
droppableId:
NavigationMenuItemDroppableIds.WORKSPACE_ORPHAN_NAVIGATION_MENU_ITEMS,
index: orphanItemCount,
};
setAddToNavigationFallbackDestination(defaultDestination);
setActiveDropTargetId(getDropTargetIdFromDestination(defaultDestination));
}
};
const handleDragUpdate = (update: Parameters<OnDragUpdateResponder>[0]) => {
const { source, destination } = update;
if (source.droppableId === ADD_TO_NAV_SOURCE_DROPPABLE_ID) {
if (
destination !== null &&
isWorkspaceDroppableId(destination.droppableId)
) {
setAddToNavigationFallbackDestination(destination);
const dropTargetId = getDropTargetIdFromDestination(destination);
setActiveDropTargetId(dropTargetId);
const payload =
store
.get(addToNavPayloadRegistryState.atom)
.get(update.draggableId) ?? null;
const folderId = validateAndExtractWorkspaceFolderId(
destination.droppableId,
);
const isFolderOverFolder =
payload?.type === 'folder' && folderId !== null;
setForbiddenDropTargetId(isFolderOverFolder ? dropTargetId : null);
} else {
setForbiddenDropTargetId(null);
const fallback = addToNavigationFallbackDestination;
setActiveDropTargetId(
fallback ? getDropTargetIdFromDestination(fallback) : null,
);
}
return;
}
if (isFavoritesDroppableId(source.droppableId)) {
if (isDefined(destination)) {
const dropTargetId =
getFavoritesDropTargetIdFromDestination(destination);
setActiveDropTargetId(dropTargetId);
} else {
setActiveDropTargetId(null);
}
}
};
const handleDragEnd = (result: DropResult, provided: ResponderProvided) => {
const isAddToNavigationSource =
result.source.droppableId === ADD_TO_NAV_SOURCE_DROPPABLE_ID;
const effectiveResult: DropResult =
isAddToNavigationSource &&
!result.destination &&
addToNavigationFallbackDestination
? { ...result, destination: addToNavigationFallbackDestination }
: result;
setIsDragging(false);
setSourceDroppableId(null);
setActiveDropTargetId(null);
setForbiddenDropTargetId(null);
setAddToNavigationFallbackDestination(null);
if (isAddToNavigationSource) {
handleAddToNavigationDrop(effectiveResult, provided);
return;
}
if (isFavoritesDroppableId(result.source.droppableId)) {
handleNavigationMenuItemDragAndDrop(result, provided);
return;
}
handleFavoriteDragAndDrop(result, provided);
};
return (
<NavigationDragSourceContext.Provider value={{ sourceDroppableId }}>
<NavigationMenuItemDragContext.Provider value={{ isDragging }}>
<FavoritesDragContext.Provider value={{ isDragging }}>
<NavigationDropTargetContext.Provider
value={{
activeDropTargetId,
setActiveDropTargetId,
forbiddenDropTargetId,
setForbiddenDropTargetId,
addToNavigationFallbackDestination,
}}
>
<DragDropContext
onDragStart={handleDragStart}
onDragUpdate={handleDragUpdate}
onDragEnd={handleDragEnd}
>
{children}
</DragDropContext>
</NavigationDropTargetContext.Provider>
</FavoritesDragContext.Provider>
</NavigationMenuItemDragContext.Provider>
</NavigationDragSourceContext.Provider>
);
};
@@ -1,9 +1,11 @@
import { NavigationDrawerOpenedSection } from '@/object-metadata/components/NavigationDrawerOpenedSection';
import { NavigationDrawerWorkspaceSectionSkeletonLoader } from '@/object-metadata/components/NavigationDrawerWorkspaceSectionSkeletonLoader';
import { RemoteNavigationDrawerSection } from '@/object-metadata/components/RemoteNavigationDrawerSection';
import { NavigationDrawerOtherSection } from '@/navigation/components/NavigationDrawerOtherSection';
import { styled } from '@linaria/react';
import { lazy, Suspense } from 'react';
import { NavigationDrawerOtherSection } from '@/navigation/components/NavigationDrawerOtherSection';
import { themeCssVariables } from 'twenty-ui/theme-constants';
const CurrentWorkspaceMemberNavigationMenuItemFoldersDispatcher = lazy(() =>
@@ -32,10 +34,8 @@ export const MainNavigationDrawerScrollableItems = () => {
return (
<StyledScrollableItemsContainer>
<NavigationDrawerOpenedSection />
<Suspense fallback={null}>
<Suspense fallback={<NavigationDrawerWorkspaceSectionSkeletonLoader />}>
<CurrentWorkspaceMemberNavigationMenuItemFoldersDispatcher />
</Suspense>
<Suspense fallback={null}>
<WorkspaceNavigationMenuItemsDispatcher />
</Suspense>
<RemoteNavigationDrawerSection />
@@ -3,6 +3,7 @@ import { useLocation, useNavigate } from 'react-router-dom';
import { SettingsPath } from 'twenty-shared/types';
import { getSettingsPath } from 'twenty-shared/utils';
import { IconHelpCircle, IconSettings } from 'twenty-ui/display';
import { AnimatedExpandableContainer } from 'twenty-ui/layout';
import { currentWorkspaceMemberState } from '@/auth/states/currentWorkspaceMemberState';
import { getDocumentationUrl } from '@/support/utils/getDocumentationUrl';
@@ -54,24 +55,29 @@ export const NavigationDrawerOtherSection = () => {
<NavigationDrawerSectionTitle
label={t`Other`}
onClick={toggleNavigationSection}
isOpen={isNavigationSectionOpen}
/>
</NavigationDrawerAnimatedCollapseWrapper>
{isNavigationSectionOpen && (
<>
<NavigationDrawerItem
label={t`Settings`}
Icon={IconSettings}
onClick={handleSettingsClick}
/>
<NavigationDrawerItem
label={t`Documentation`}
to={getDocumentationUrl({
locale: currentWorkspaceMember?.locale,
})}
Icon={IconHelpCircle}
/>
</>
)}
<AnimatedExpandableContainer
isExpanded={isNavigationSectionOpen}
dimension="height"
mode="fit-content"
containAnimation
initial={false}
>
<NavigationDrawerItem
label={t`Settings`}
Icon={IconSettings}
onClick={handleSettingsClick}
/>
<NavigationDrawerItem
label={t`Documentation`}
to={getDocumentationUrl({
locale: currentWorkspaceMember?.locale,
})}
Icon={IconHelpCircle}
/>
</AnimatedExpandableContainer>
</NavigationDrawerSection>
);
};
@@ -1,31 +1,12 @@
import {
DragDropContext,
type DragStart,
type DropResult,
type OnDragUpdateResponder,
type ResponderProvided,
} from '@hello-pangea/dnd';
import { type ReactNode, useCallback, useState } from 'react';
import { FeatureFlagKey } from '~/generated-metadata/graphql';
import { lazy, Suspense, useState, type ReactNode } from 'react';
import { FavoritesDragContext } from '@/favorites/contexts/FavoritesDragContext';
import { useHandleFavoriteDragAndDrop } from '@/favorites/hooks/useHandleFavoriteDragAndDrop';
import { ADD_TO_NAV_SOURCE_DROPPABLE_ID } from '@/navigation-menu-item/constants/AddToNavSourceDroppableId';
import { NavigationMenuItemDroppableIds } from '@/navigation-menu-item/constants/NavigationMenuItemDroppableIds';
import { NavigationDragSourceContext } from '@/navigation-menu-item/contexts/NavigationDragSourceContext';
import { NavigationDropTargetContext } from '@/navigation-menu-item/contexts/NavigationDropTargetContext';
import { NavigationMenuItemDragContext } from '@/navigation-menu-item/contexts/NavigationMenuItemDragContext';
import { useHandleAddToNavigationDrop } from '@/navigation-menu-item/hooks/useHandleAddToNavigationDrop';
import { useHandleNavigationMenuItemDragAndDrop } from '@/navigation-menu-item/hooks/useHandleNavigationMenuItemDragAndDrop';
import { useHandleWorkspaceNavigationMenuItemDragAndDrop } from '@/navigation-menu-item/hooks/useHandleWorkspaceNavigationMenuItemDragAndDrop';
import { useNavigationMenuItemsDraftState } from '@/navigation-menu-item/hooks/useNavigationMenuItemsDraftState';
import { addToNavPayloadRegistryState } from '@/navigation-menu-item/states/addToNavPayloadRegistryState';
import { getDropTargetIdFromDestination } from '@/navigation-menu-item/utils/getDropTargetIdFromDestination';
import { isWorkspaceDroppableId } from '@/navigation-menu-item/utils/isWorkspaceDroppableId';
import { validateAndExtractWorkspaceFolderId } from '@/navigation-menu-item/utils/validateAndExtractWorkspaceFolderId';
import { useStore } from 'jotai';
import { useIsFeatureEnabled } from '@/workspace/hooks/useIsFeatureEnabled';
import { isDefined } from 'twenty-shared/utils';
import { PageDragDropProviderMountEffect } from '@/navigation/components/PageDragDropProviderMountEffect';
const LazyWorkspaceDndKitProvider = lazy(() =>
import('@/navigation/components/WorkspaceDndKitProvider').then((m) => ({
default: m.WorkspaceDndKitProvider,
})),
);
type PageDragDropProviderProps = {
children: ReactNode;
@@ -34,144 +15,22 @@ type PageDragDropProviderProps = {
export const PageDragDropProvider = ({
children,
}: PageDragDropProviderProps) => {
const isNavigationMenuItemEditingEnabled = useIsFeatureEnabled(
FeatureFlagKey.IS_NAVIGATION_MENU_ITEM_EDITING_ENABLED,
);
const [isDragging, setIsDragging] = useState(false);
const [sourceDroppableId, setSourceDroppableId] = useState<string | null>(
null,
);
const [activeDropTargetId, setActiveDropTargetId] = useState<string | null>(
null,
);
const [forbiddenDropTargetId, setForbiddenDropTargetId] = useState<
string | null
>(null);
const [
addToNavigationFallbackDestination,
setAddToNavigationFallbackDestination,
] = useState<{ droppableId: string; index: number } | null>(null);
const [hasProviderMounted, setHasProviderMounted] = useState(false);
const store = useStore();
const { workspaceNavigationMenuItems } = useNavigationMenuItemsDraftState();
const { handleAddToNavigationDrop } = useHandleAddToNavigationDrop();
const { handleNavigationMenuItemDragAndDrop } =
useHandleNavigationMenuItemDragAndDrop();
const { handleWorkspaceNavigationMenuItemDragAndDrop } =
useHandleWorkspaceNavigationMenuItemDragAndDrop();
const { handleFavoriteDragAndDrop } = useHandleFavoriteDragAndDrop();
const orphanItemCount = workspaceNavigationMenuItems.filter(
(item) => !isDefined(item.folderId),
).length;
const handleDragStart = (dragStart: DragStart) => {
setIsDragging(true);
setSourceDroppableId(dragStart.source.droppableId);
if (dragStart.source.droppableId === ADD_TO_NAV_SOURCE_DROPPABLE_ID) {
const defaultDestination = {
droppableId:
NavigationMenuItemDroppableIds.WORKSPACE_ORPHAN_NAVIGATION_MENU_ITEMS,
index: orphanItemCount,
};
setAddToNavigationFallbackDestination(defaultDestination);
setActiveDropTargetId(getDropTargetIdFromDestination(defaultDestination));
}
};
const handleDragUpdate = useCallback(
((update: Parameters<OnDragUpdateResponder>[0]) => {
const { source, destination } = update;
if (source.droppableId !== ADD_TO_NAV_SOURCE_DROPPABLE_ID) {
return;
}
if (
destination !== null &&
isWorkspaceDroppableId(destination.droppableId)
) {
setAddToNavigationFallbackDestination(destination);
const dropTargetId = getDropTargetIdFromDestination(destination);
setActiveDropTargetId(dropTargetId);
const payload =
store
.get(addToNavPayloadRegistryState.atom)
.get(update.draggableId) ?? null;
const folderId = validateAndExtractWorkspaceFolderId(
destination.droppableId,
);
const isFolderOverFolder =
payload?.type === 'folder' && folderId !== null;
setForbiddenDropTargetId(isFolderOverFolder ? dropTargetId : null);
} else {
setForbiddenDropTargetId(null);
const fallback = addToNavigationFallbackDestination;
setActiveDropTargetId(
fallback ? getDropTargetIdFromDestination(fallback) : null,
);
}
}) as OnDragUpdateResponder,
[addToNavigationFallbackDestination, store],
);
const handleDragEnd = (result: DropResult, provided: ResponderProvided) => {
const isAddToNavigationSource =
result.source.droppableId === ADD_TO_NAV_SOURCE_DROPPABLE_ID;
const effectiveResult: DropResult =
isAddToNavigationSource &&
!result.destination &&
addToNavigationFallbackDestination
? { ...result, destination: addToNavigationFallbackDestination }
: result;
setIsDragging(false);
setSourceDroppableId(null);
setActiveDropTargetId(null);
setForbiddenDropTargetId(null);
setAddToNavigationFallbackDestination(null);
if (isAddToNavigationSource) {
handleAddToNavigationDrop(effectiveResult, provided);
return;
}
if (isNavigationMenuItemEditingEnabled) {
const isWorkspaceDrop =
isWorkspaceDroppableId(result.source?.droppableId) &&
isWorkspaceDroppableId(result.destination?.droppableId);
if (isWorkspaceDrop) {
handleWorkspaceNavigationMenuItemDragAndDrop(result, provided);
} else {
handleNavigationMenuItemDragAndDrop(result, provided);
}
} else {
handleFavoriteDragAndDrop(result, provided);
}
};
if (!hasProviderMounted) {
return (
<>
<PageDragDropProviderMountEffect
onEnterEditMode={() => setHasProviderMounted(true)}
/>
{children}
</>
);
}
return (
<NavigationDragSourceContext.Provider value={{ sourceDroppableId }}>
<NavigationMenuItemDragContext.Provider value={{ isDragging }}>
<FavoritesDragContext.Provider value={{ isDragging }}>
<NavigationDropTargetContext.Provider
value={{
activeDropTargetId,
setActiveDropTargetId,
forbiddenDropTargetId,
setForbiddenDropTargetId,
addToNavigationFallbackDestination,
}}
>
<DragDropContext
onDragStart={handleDragStart}
onDragUpdate={handleDragUpdate}
onDragEnd={handleDragEnd}
>
{children}
</DragDropContext>
</NavigationDropTargetContext.Provider>
</FavoritesDragContext.Provider>
</NavigationMenuItemDragContext.Provider>
</NavigationDragSourceContext.Provider>
<Suspense fallback={<>{children}</>}>
<LazyWorkspaceDndKitProvider>{children}</LazyWorkspaceDndKitProvider>
</Suspense>
);
};
@@ -0,0 +1,24 @@
import { useEffect } from 'react';
import { isNavigationMenuInEditModeState } from '@/navigation-menu-item/states/isNavigationMenuInEditModeState';
import { useAtomStateValue } from '@/ui/utilities/state/jotai/hooks/useAtomStateValue';
type PageDragDropProviderMountEffectProps = {
onEnterEditMode: () => void;
};
export const PageDragDropProviderMountEffect = ({
onEnterEditMode,
}: PageDragDropProviderMountEffectProps) => {
const isNavigationMenuInEditMode = useAtomStateValue(
isNavigationMenuInEditModeState,
);
useEffect(() => {
if (isNavigationMenuInEditMode) {
onEnterEditMode();
}
}, [isNavigationMenuInEditMode, onEnterEditMode]);
return null;
};
@@ -0,0 +1,50 @@
import { PointerActivationConstraints } from '@dnd-kit/dom';
import {
DragDropProvider,
KeyboardSensor,
PointerSensor,
} from '@dnd-kit/react';
import type { ReactNode } from 'react';
import { NavigationDragSourceContext } from '@/navigation-menu-item/contexts/NavigationDragSourceContext';
import { NavigationDropTargetContext } from '@/navigation-menu-item/contexts/NavigationDropTargetContext';
import { NavigationMenuItemDragContext } from '@/navigation-menu-item/contexts/NavigationMenuItemDragContext';
import type { DraggableData } from '@/navigation/types/workspaceDndKitDraggableData';
import { useWorkspaceDndKit } from '@/navigation/hooks/useWorkspaceDndKit';
const WORKSPACE_DND_SENSORS = [
PointerSensor.configure({
activationConstraints: [
new PointerActivationConstraints.Distance({ value: 8 }),
],
}),
KeyboardSensor,
];
type WorkspaceDndKitProviderProps = {
children: ReactNode;
};
export const WorkspaceDndKitProvider = ({
children,
}: WorkspaceDndKitProviderProps) => {
const { contextValues, handlers } = useWorkspaceDndKit();
return (
<NavigationDragSourceContext.Provider value={contextValues.dragSource}>
<NavigationMenuItemDragContext.Provider value={contextValues.drag}>
<NavigationDropTargetContext.Provider value={contextValues.dropTarget}>
<DragDropProvider<DraggableData>
sensors={WORKSPACE_DND_SENSORS}
onDragStart={handlers.onDragStart}
onDragOver={handlers.onDragOver}
onDragEnd={handlers.onDragEnd}
>
{children}
</DragDropProvider>
</NavigationDropTargetContext.Provider>
</NavigationMenuItemDragContext.Provider>
</NavigationDragSourceContext.Provider>
);
};
@@ -0,0 +1,6 @@
export const DROP_RESULT_OPTIONS = {
reason: 'DROP' as const,
combine: null,
mode: 'FLUID' as const,
type: 'DEFAULT' as const,
};
@@ -0,0 +1,351 @@
import { type DragDropProvider } from '@dnd-kit/react';
import { isSortable } from '@dnd-kit/react/sortable';
import type { ResponderProvided } from '@hello-pangea/dnd';
import { type ComponentProps, useCallback, useState } from 'react';
import { useStore } from 'jotai';
import { isDefined } from 'twenty-shared/utils';
import { ADD_TO_NAV_SOURCE_DROPPABLE_ID } from '@/navigation-menu-item/constants/AddToNavSourceDroppableId';
import { NavigationMenuItemDroppableIds } from '@/navigation-menu-item/constants/NavigationMenuItemDroppableIds';
import { useHandleAddToNavigationDrop } from '@/navigation-menu-item/hooks/useHandleAddToNavigationDrop';
import { useHandleWorkspaceNavigationMenuItemDragAndDrop } from '@/navigation-menu-item/hooks/useHandleWorkspaceNavigationMenuItemDragAndDrop';
import { useNavigationMenuItemsDraftState } from '@/navigation-menu-item/hooks/useNavigationMenuItemsDraftState';
import { addToNavPayloadRegistryState } from '@/navigation-menu-item/states/addToNavPayloadRegistryState';
import { getDndKitDropTargetId } from '@/navigation-menu-item/utils/getDndKitDropTargetId';
import { isNavigationMenuItemFolder } from '@/navigation-menu-item/utils/isNavigationMenuItemFolder';
import { isWorkspaceDroppableId } from '@/navigation-menu-item/utils/isWorkspaceDroppableId';
import { validateAndExtractWorkspaceFolderId } from '@/navigation-menu-item/utils/validateAndExtractWorkspaceFolderId';
import { DROP_RESULT_OPTIONS } from '@/navigation/constants/workspaceDndKitDropResultOptions';
import type { DraggableData } from '@/navigation/types/workspaceDndKitDraggableData';
import type { DropDestination } from '@/navigation/types/workspaceDndKitDropDestination';
import { isFolderDrag } from '@/navigation/utils/workspaceDndKitIsFolderDrag';
import { resolveDropTarget } from '@/navigation/utils/workspaceDndKitResolveDropTarget';
import { toDropResult } from '@/navigation/utils/workspaceDndKitToDropResult';
type DragStartPayload = Parameters<
NonNullable<
ComponentProps<typeof DragDropProvider<DraggableData>>['onDragStart']
>
>[0];
type DragOverPayload = Parameters<
NonNullable<
ComponentProps<typeof DragDropProvider<DraggableData>>['onDragOver']
>
>[0];
type DragEndPayload = Parameters<
NonNullable<
ComponentProps<typeof DragDropProvider<DraggableData>>['onDragEnd']
>
>[0];
export type WorkspaceDndKitContextValues = {
dragSource: { sourceDroppableId: string | null };
drag: { isDragging: boolean };
dropTarget: {
activeDropTargetId: string | null;
setActiveDropTargetId: (id: string | null) => void;
forbiddenDropTargetId: string | null;
setForbiddenDropTargetId: (id: string | null) => void;
addToNavigationFallbackDestination: DropDestination | null;
};
};
export const useWorkspaceDndKit = (): {
contextValues: WorkspaceDndKitContextValues;
handlers: {
onDragStart: (event: DragStartPayload) => void;
onDragOver: (event: DragOverPayload) => void;
onDragEnd: (event: DragEndPayload) => void;
};
} => {
const store = useStore();
const [isDragging, setIsDragging] = useState(false);
const [sourceDroppableId, setSourceDroppableId] = useState<string | null>(
null,
);
const [activeDropTargetId, setActiveDropTargetId] = useState<string | null>(
null,
);
const [forbiddenDropTargetId, setForbiddenDropTargetId] = useState<
string | null
>(null);
const [
addToNavigationFallbackDestination,
setAddToNavigationFallbackDestination,
] = useState<DropDestination | null>(null);
const { workspaceNavigationMenuItems } = useNavigationMenuItemsDraftState();
const { handleAddToNavigationDrop } = useHandleAddToNavigationDrop();
const { handleWorkspaceNavigationMenuItemDragAndDrop } =
useHandleWorkspaceNavigationMenuItemDragAndDrop();
const orphanItemCount = workspaceNavigationMenuItems.filter(
(item: { folderId?: string | null }) => !isDefined(item.folderId),
).length;
const getNavItemById = useCallback(
(id: string | undefined) =>
id
? workspaceNavigationMenuItems.find((item) => item.id === id)
: undefined,
[workspaceNavigationMenuItems],
);
const applyWorkspaceReorderIfAllowed = (
id: string,
source: DropDestination,
destination: DropDestination,
) => {
const draggedItem = getNavItemById(id);
const destFolderId = validateAndExtractWorkspaceFolderId(
destination.droppableId,
);
if (
isDefined(destFolderId) &&
isDefined(draggedItem) &&
isNavigationMenuItemFolder(draggedItem)
) {
return;
}
const result = toDropResult(
id,
{
sourceDroppableId: source.droppableId,
sourceIndex: source.index,
},
destination,
);
const provided: ResponderProvided = { announce: () => {} };
handleWorkspaceNavigationMenuItemDragAndDrop(
{ ...result, ...DROP_RESULT_OPTIONS },
provided,
);
};
const handleDragStart = (event: DragStartPayload) => {
const { operation } = event;
setIsDragging(true);
const source = operation.source;
const sourceId = source?.data?.sourceDroppableId ?? null;
setSourceDroppableId(sourceId);
if (sourceId === ADD_TO_NAV_SOURCE_DROPPABLE_ID) {
const defaultDestination: DropDestination = {
droppableId:
NavigationMenuItemDroppableIds.WORKSPACE_ORPHAN_NAVIGATION_MENU_ITEMS,
index: orphanItemCount,
};
setAddToNavigationFallbackDestination(defaultDestination);
setActiveDropTargetId(
getDndKitDropTargetId(
defaultDestination.droppableId,
defaultDestination.index,
),
);
}
};
const handleDragOver = useCallback(
(event: DragOverPayload) => {
const { operation } = event;
const source = operation.source;
const target = operation.target;
const isAddToNavDrag =
sourceDroppableId === ADD_TO_NAV_SOURCE_DROPPABLE_ID;
const sourceIsSortable = source !== null && isSortable(source);
const resolved = resolveDropTarget(target, getNavItemById);
const getPayload = () =>
store.get(addToNavPayloadRegistryState.atom).get(String(source?.id)) ??
null;
const getSourceItem = () =>
getNavItemById(source?.id != null ? String(source.id) : undefined);
if (
resolved !== null &&
source !== null &&
target !== null &&
isSortable(source) &&
isSortable(target)
) {
setActiveDropTargetId(resolved.effectiveDropTargetId);
if (isAddToNavDrag) {
setForbiddenDropTargetId(null);
} else {
const destFolderId =
'group' in target
? validateAndExtractWorkspaceFolderId(String(target.group))
: validateAndExtractWorkspaceFolderId(
resolved.destination.droppableId,
);
const folderDrag = isFolderDrag(getPayload(), getSourceItem());
const isFolderOverFolder = resolved.isTargetFolder && folderDrag;
const isFolderOverFolderInList =
!resolved.isTargetFolder && isDefined(destFolderId) && folderDrag;
setForbiddenDropTargetId(
isFolderOverFolder
? resolved.effectiveDropTargetId
: isFolderOverFolderInList
? resolved.dropTargetId
: null,
);
}
return;
}
if (resolved !== null && sourceIsSortable) {
setActiveDropTargetId(resolved.effectiveDropTargetId);
setAddToNavigationFallbackDestination(resolved.destination);
if (!isAddToNavDrag) {
const destFolderId = validateAndExtractWorkspaceFolderId(
resolved.destination.droppableId,
);
const folderDrag = isFolderDrag(null, getSourceItem());
setForbiddenDropTargetId(
isDefined(destFolderId) && folderDrag
? resolved.effectiveDropTargetId
: null,
);
} else {
setForbiddenDropTargetId(null);
}
return;
}
if (!isAddToNavDrag) {
return;
}
if (resolved !== null) {
setAddToNavigationFallbackDestination(resolved.destination);
setActiveDropTargetId(resolved.effectiveDropTargetId);
const folderId = validateAndExtractWorkspaceFolderId(
resolved.destination.droppableId,
);
const folderDrag =
getPayload()?.type === 'folder' && isDefined(folderId);
setForbiddenDropTargetId(
folderDrag ? resolved.effectiveDropTargetId : null,
);
return;
}
const fallback = addToNavigationFallbackDestination;
setActiveDropTargetId(
fallback
? getDndKitDropTargetId(fallback.droppableId, fallback.index)
: null,
);
setForbiddenDropTargetId(null);
},
[
sourceDroppableId,
addToNavigationFallbackDestination,
getNavItemById,
setActiveDropTargetId,
setForbiddenDropTargetId,
setAddToNavigationFallbackDestination,
store,
],
);
const handleDragEnd = (event: DragEndPayload) => {
const { operation } = event;
const source = operation.source;
const target = operation.target;
const draggableId = String(source?.id);
const data = source?.data;
const sourceId = data?.sourceDroppableId ?? null;
const fallback = addToNavigationFallbackDestination;
setIsDragging(false);
setSourceDroppableId(null);
setActiveDropTargetId(null);
setForbiddenDropTargetId(null);
setAddToNavigationFallbackDestination(null);
const sourceIsSortable = source !== null && isSortable(source);
const targetIsSortable = target !== null && isSortable(target);
const sortableToSortable =
sourceIsSortable &&
targetIsSortable &&
isDefined(source) &&
isDefined(target);
const resolved = resolveDropTarget(target, getNavItemById);
if (sortableToSortable && resolved !== null) {
const sourceDraggable = 'initialGroup' in source ? source : null;
const initialGroup = sourceDraggable?.initialGroup ?? '';
const initialIndex = sourceDraggable?.initialIndex ?? 0;
const initialGroupStr = String(initialGroup);
const destGroup = String(target.group ?? '');
const bothWorkspace =
isWorkspaceDroppableId(initialGroupStr) &&
isWorkspaceDroppableId(destGroup);
if (bothWorkspace) {
applyWorkspaceReorderIfAllowed(
draggableId,
{ droppableId: initialGroupStr, index: initialIndex },
resolved.destination,
);
return;
}
}
let destination: DropDestination | null = resolved?.destination ?? null;
if (
destination == null &&
isDefined(fallback) &&
isWorkspaceDroppableId(fallback.droppableId)
) {
destination = fallback;
}
const result = toDropResult(draggableId, data, destination);
const provided: ResponderProvided = { announce: () => {} };
const dropResult = { ...result, ...DROP_RESULT_OPTIONS };
if (sourceId === ADD_TO_NAV_SOURCE_DROPPABLE_ID) {
handleAddToNavigationDrop(dropResult, provided);
return;
}
if (
isDefined(sourceId) &&
isWorkspaceDroppableId(sourceId) &&
isDefined(destination) &&
isWorkspaceDroppableId(destination.droppableId)
) {
applyWorkspaceReorderIfAllowed(
draggableId,
{
droppableId: data?.sourceDroppableId ?? '',
index: data?.sourceIndex ?? 0,
},
destination,
);
}
};
const contextValues: WorkspaceDndKitContextValues = {
dragSource: { sourceDroppableId },
drag: { isDragging },
dropTarget: {
activeDropTargetId,
setActiveDropTargetId,
forbiddenDropTargetId,
setForbiddenDropTargetId,
addToNavigationFallbackDestination,
},
};
return {
contextValues,
handlers: {
onDragStart: handleDragStart,
onDragOver: handleDragOver,
onDragEnd: handleDragEnd,
},
};
};
@@ -0,0 +1,16 @@
let preloadScheduled = false;
const preload = () => {
void import('@/navigation/components/WorkspaceDndKitProvider');
void import(
'@/object-metadata/components/NavigationDrawerSectionForWorkspaceItemsListDndKit'
);
};
export const preloadWorkspaceDndKit = (): void => {
if (preloadScheduled) {
return;
}
preloadScheduled = true;
preload();
};
@@ -0,0 +1,4 @@
export type DraggableData = {
sourceDroppableId?: string;
sourceIndex?: number;
};
@@ -0,0 +1 @@
export type DropDestination = { droppableId: string; index: number };
@@ -0,0 +1,4 @@
export type DroppableData = {
droppableId: string;
index: number;
};
@@ -0,0 +1,8 @@
import type { DropDestination } from '@/navigation/types/workspaceDndKitDropDestination';
export type SortableTargetDestination = {
destination: DropDestination;
effectiveDropTargetId: string;
isTargetFolder: boolean;
dropTargetId: string;
};
@@ -0,0 +1,53 @@
import { isDefined } from 'twenty-shared/utils';
import type { NavigationMenuItem } from '~/generated-metadata/graphql';
import { NavigationMenuItemDroppableIds } from '@/navigation-menu-item/constants/NavigationMenuItemDroppableIds';
import { getDndKitDropTargetId } from '@/navigation-menu-item/utils/getDndKitDropTargetId';
import { isNavigationMenuItemFolder } from '@/navigation-menu-item/utils/isNavigationMenuItemFolder';
import type { DropDestination } from '@/navigation/types/workspaceDndKitDropDestination';
import type { SortableTargetDestination } from '@/navigation/types/workspaceDndKitSortableTargetDestination';
type GetNavItemById = (
id: string | undefined,
) => NavigationMenuItem | undefined;
export const getDestinationFromSortableTarget = (
target: { id: unknown; group?: unknown; index?: unknown },
getNavItemById: GetNavItemById,
): SortableTargetDestination | null => {
const group = target.group;
const rawIndex = target.index;
if (!isDefined(group) || !isDefined(rawIndex)) {
return null;
}
const index = Number(rawIndex);
if (!Number.isInteger(index) || index < 0) {
return null;
}
const destDroppableId = String(group);
const targetItem = getNavItemById(
target.id != null ? String(target.id) : undefined,
);
const isTargetFolder =
isDefined(targetItem) && isNavigationMenuItemFolder(targetItem);
const dropTargetId = getDndKitDropTargetId(destDroppableId, index);
const effectiveDropTargetId = isTargetFolder
? getDndKitDropTargetId(
`${NavigationMenuItemDroppableIds.WORKSPACE_FOLDER_HEADER_PREFIX}${target.id}`,
0,
)
: dropTargetId;
const destination: DropDestination = {
droppableId: isTargetFolder
? `${NavigationMenuItemDroppableIds.WORKSPACE_FOLDER_HEADER_PREFIX}${target.id}`
: destDroppableId,
index: isTargetFolder ? 0 : index,
};
return {
destination,
effectiveDropTargetId,
isTargetFolder,
dropTargetId,
};
};
@@ -0,0 +1,13 @@
import type { NavigationMenuItem } from '~/generated-metadata/graphql';
import { isDefined } from 'twenty-shared/utils';
import { isNavigationMenuItemFolder } from '@/navigation-menu-item/utils/isNavigationMenuItemFolder';
type AddToNavPayload = { type?: string } | null;
export const isFolderDrag = (
payload: AddToNavPayload,
sourceItem: NavigationMenuItem | undefined,
): boolean =>
payload?.type === 'folder' ||
(isDefined(sourceItem) && isNavigationMenuItemFolder(sourceItem));
@@ -0,0 +1,50 @@
import { isDefined } from 'twenty-shared/utils';
import type { NavigationMenuItem } from '~/generated-metadata/graphql';
import { isWorkspaceDroppableId } from '@/navigation-menu-item/utils/isWorkspaceDroppableId';
import type { DroppableData } from '@/navigation/types/workspaceDndKitDroppableData';
import type { SortableTargetDestination } from '@/navigation/types/workspaceDndKitSortableTargetDestination';
import { getDestinationFromSortableTarget } from '@/navigation/utils/workspaceDndKitGetDestinationFromSortableTarget';
type GetNavItemById = (
id: string | undefined,
) => NavigationMenuItem | undefined;
const isDroppableData = (data: unknown): data is DroppableData =>
typeof data === 'object' &&
data !== null &&
typeof (data as DroppableData).droppableId === 'string' &&
typeof (data as DroppableData).index === 'number';
export const resolveDropTarget = (
target: {
id?: unknown;
group?: unknown;
index?: unknown;
data?: unknown;
} | null,
getNavItemById: GetNavItemById,
): SortableTargetDestination | null => {
if (target === null || target === undefined) {
return null;
}
if (isDefined(target.group) && isDefined(target.index)) {
return getDestinationFromSortableTarget(
{ id: target.id, group: target.group, index: target.index },
getNavItemById,
);
}
if (isDroppableData(target.data)) {
const { droppableId, index } = target.data;
if (isWorkspaceDroppableId(droppableId)) {
return {
destination: { droppableId, index },
effectiveDropTargetId: String(target.id),
isTargetFolder: false,
dropTargetId: String(target.id),
};
}
}
return null;
};
@@ -0,0 +1,20 @@
import type { DropDestination } from '@/navigation/types/workspaceDndKitDropDestination';
import type { DraggableData } from '@/navigation/types/workspaceDndKitDraggableData';
export const toDropResult = (
draggableId: string,
data: DraggableData | undefined,
destination: DropDestination | null,
): {
source: DropDestination;
destination: DropDestination | null;
draggableId: string;
} => {
const sourceDroppableId = data?.sourceDroppableId ?? '';
const sourceIndex = data?.sourceIndex ?? 0;
return {
source: { droppableId: sourceDroppableId, index: sourceIndex },
destination,
draggableId,
};
};