refactor(twenty-front): unify Favorites and Workspace navigation menu item code (#18697)

## Summary

- Consolidate duplicated Favorites and Workspace navigation menu item
frontend code into a single unified codebase within
`navigation-menu-item/`
- Move all DnD-related code from `navigation/` module into
`navigation-menu-item/display/dnd/`, renaming `workspaceDndKit*` files
to `navigationMenuItemDndKit*`
- Unify duplicated components (DnD providers, DnD hooks, folder
components, orphan items, section shell) using a `NavigationSections`
enum to parameterize section-specific behavior
- Rename residual workspace-prefixed symbols
(`WorkspaceDndKitSortableItem`, `WorkspaceDndKitDroppableSlot`,
`useWorkspaceSectionItems`, etc.) to `navigationMenuItem`-prefixed
equivalents
- Clean `navigation/` module to only contain app-level concerns (drawer
layout, settings, routing)

### Key changes

| Before (duplicated) | After (unified) |
|---|---|
| `FavoritesDndKitProvider` + `WorkspaceDndKitProvider` |
`NavigationMenuItemDndKitProvider` with `section` prop |
| `useFavoritesDndKit` + `useWorkspaceDndKit` |
`useNavigationMenuItemDndKit(section)` |
| `FavoritesFolderItem` + `WorkspaceNavigationMenuItemsFolder` |
`NavigationMenuItemFolder` with `section` prop |
| `FavoritesOrphanItems` | `NavigationMenuItemOrphanItems` with
`section` prop |
| Separate section shells | Shared `NavigationMenuItemSection` with thin
wrappers |
| `WorkspaceDndKitSortableItem` | `NavigationMenuItemSortableItem` |
| `WorkspaceDndKitDroppableSlot` | `NavigationMenuItemDroppableSlot` |
| `useWorkspaceSectionItems` | `useNavigationMenuItemSectionItems` |
| `useWorkspaceFolderOpenState` | `useNavigationMenuItemFolderOpenState`
|

Net result: **~1650 lines deleted** across 51 files.

## Test plan

- [x] `npx nx typecheck twenty-front` passes
- [x] `npx nx lint twenty-front` passes (0 errors)
- [x] `npx nx test twenty-front` passes (763 suites, 4467 tests)
- [ ] Smoke test: Favorites section renders, DnD reorder works, folder
create/rename/delete works
- [ ] Smoke test: Workspace section renders, edit mode works, DnD
reorder works
- [ ] Smoke test: Add-to-navigation from side panel works for both
sections

Made with [Cursor](https://cursor.com)
This commit is contained in:
Charles Bochet
2026-03-17 19:01:09 +01:00
committed by GitHub
parent 5a51764b8e
commit 9f7c29bce8
77 changed files with 1995 additions and 2255 deletions
@@ -1,5 +1,10 @@
export enum NavigationMenuItemDroppableIds {
ORPHAN_NAVIGATION_MENU_ITEMS = 'orphan-navigation-menu-items',
FAVORITE_DROPPABLE_PREFIX = 'favorite-',
FAVORITE_ORPHAN_NAVIGATION_MENU_ITEMS = 'favorite-orphan-navigation-menu-items',
FAVORITE_FOLDER_PREFIX = 'favorite-folder-',
FAVORITE_FOLDER_HEADER_PREFIX = 'favorite-folder-header-',
WORKSPACE_DROPPABLE_PREFIX = 'workspace-',
WORKSPACE_ORPHAN_NAVIGATION_MENU_ITEMS = 'workspace-orphan-navigation-menu-items',
WORKSPACE_FOLDER_PREFIX = 'workspace-folder-',
WORKSPACE_FOLDER_HEADER_PREFIX = 'workspace-folder-header-',
@@ -0,0 +1,28 @@
import { NavigationMenuItemDroppableIds } from '@/navigation-menu-item/common/constants/NavigationMenuItemDroppableIds';
import type { NavigationMenuItemSection } from '@/navigation-menu-item/common/types/NavigationMenuItemSection';
type SectionDroppableConfig = {
orphanDroppableId: string;
folderHeaderPrefix: string;
folderPrefix: string;
};
export const NAVIGATION_MENU_ITEM_SECTION_DROPPABLE_CONFIG: Record<
NavigationMenuItemSection,
SectionDroppableConfig
> = {
favorite: {
orphanDroppableId:
NavigationMenuItemDroppableIds.FAVORITE_ORPHAN_NAVIGATION_MENU_ITEMS,
folderHeaderPrefix:
NavigationMenuItemDroppableIds.FAVORITE_FOLDER_HEADER_PREFIX,
folderPrefix: NavigationMenuItemDroppableIds.FAVORITE_FOLDER_PREFIX,
},
workspace: {
orphanDroppableId:
NavigationMenuItemDroppableIds.WORKSPACE_ORPHAN_NAVIGATION_MENU_ITEMS,
folderHeaderPrefix:
NavigationMenuItemDroppableIds.WORKSPACE_FOLDER_HEADER_PREFIX,
folderPrefix: NavigationMenuItemDroppableIds.WORKSPACE_FOLDER_PREFIX,
},
};
@@ -10,6 +10,7 @@ export const useUpdateNavigationMenuItem = () => {
UpdateNavigationMenuItemDocument,
{
refetchQueries: ['FindManyNavigationMenuItems'],
awaitRefetchQueries: false,
},
);
@@ -0,0 +1 @@
export type NavigationMenuItemSection = 'workspace' | 'favorite';
@@ -1,4 +1,4 @@
import type { DropDestination } from '@/navigation/types/workspaceDndKitDropDestination';
import type { DropDestination } from '@/navigation-menu-item/common/types/navigationMenuItemDndKitDropDestination';
export type SortableTargetDestination = {
destination: DropDestination;
@@ -1,37 +1,109 @@
import { getDropTargetIdFromDestination } from '@/navigation-menu-item/common/utils/getDropTargetIdFromDestination';
describe('getDropTargetIdFromDestination', () => {
it('should return null when destination is null or droppableId is not workspace', () => {
expect(getDropTargetIdFromDestination(null)).toBe(null);
expect(
getDropTargetIdFromDestination({
droppableId: 'favorites-orphan',
index: 0,
}),
).toBe(null);
describe('workspace section', () => {
it('should return null when destination is null or droppableId is not workspace', () => {
expect(
getDropTargetIdFromDestination({
navigationMenuItemSection: 'workspace',
destination: null,
}),
).toBe(null);
expect(
getDropTargetIdFromDestination({
navigationMenuItemSection: 'workspace',
destination: { droppableId: 'favorite-orphan', index: 0 },
}),
).toBe(null);
});
it('should return workspace-orphan-index for workspace orphan droppable', () => {
expect(
getDropTargetIdFromDestination({
navigationMenuItemSection: 'workspace',
destination: {
droppableId: 'workspace-orphan-navigation-menu-items',
index: 2,
},
}),
).toBe('workspace-orphan-2');
});
it('should return workspace-folderId-index for workspace folder droppable', () => {
expect(
getDropTargetIdFromDestination({
navigationMenuItemSection: 'workspace',
destination: {
droppableId: 'workspace-folder-folder-123',
index: 1,
},
}),
).toBe('workspace-folder-123-1');
});
it('should return workspace-folderId-index for workspace folder header droppable', () => {
expect(
getDropTargetIdFromDestination({
navigationMenuItemSection: 'workspace',
destination: {
droppableId: 'workspace-folder-header-folder-456',
index: 0,
},
}),
).toBe('workspace-folder-456-0');
});
});
it('should return workspace-orphan-index for workspace orphan droppable', () => {
const result = getDropTargetIdFromDestination({
droppableId: 'workspace-orphan-navigation-menu-items',
index: 2,
describe('favorite section', () => {
it('should return null when destination is null or droppableId is not favorite', () => {
expect(
getDropTargetIdFromDestination({
navigationMenuItemSection: 'favorite',
destination: null,
}),
).toBe(null);
expect(
getDropTargetIdFromDestination({
navigationMenuItemSection: 'favorite',
destination: { droppableId: 'workspace-orphan', index: 0 },
}),
).toBe(null);
});
expect(result).toBe('workspace-orphan-2');
});
it('should return workspace-folderId-index for workspace folder droppable', () => {
const result = getDropTargetIdFromDestination({
droppableId: 'workspace-folder-folder-123',
index: 1,
it('should return favorite-orphan-index for favorite orphan droppable', () => {
expect(
getDropTargetIdFromDestination({
navigationMenuItemSection: 'favorite',
destination: {
droppableId: 'favorite-orphan-navigation-menu-items',
index: 3,
},
}),
).toBe('favorite-orphan-3');
});
expect(result).toBe('workspace-folder-123-1');
});
it('should return workspace-folderId-index for workspace folder header droppable', () => {
const result = getDropTargetIdFromDestination({
droppableId: 'workspace-folder-header-folder-456',
index: 0,
it('should return favorite-folderId-index for favorite folder droppable', () => {
expect(
getDropTargetIdFromDestination({
navigationMenuItemSection: 'favorite',
destination: {
droppableId: 'favorite-folder-abc-123',
index: 1,
},
}),
).toBe('favorite-abc-123-1');
});
it('should return favorite-folderId-index for favorite folder header droppable', () => {
expect(
getDropTargetIdFromDestination({
navigationMenuItemSection: 'favorite',
destination: {
droppableId: 'favorite-folder-header-def-456',
index: 0,
},
}),
).toBe('favorite-def-456-0');
});
expect(result).toBe('workspace-folder-456-0');
});
});
@@ -0,0 +1,23 @@
import { isDefined } from 'twenty-shared/utils';
import { NavigationMenuItemDroppableIds } from '@/navigation-menu-item/common/constants/NavigationMenuItemDroppableIds';
import type { NavigationMenuItemSection } from '@/navigation-menu-item/common/types/NavigationMenuItemSection';
export const canNavigationMenuItemBeDroppedIn = ({
navigationMenuItemSection,
droppableId,
}: {
navigationMenuItemSection: NavigationMenuItemSection;
droppableId: string | null | undefined;
}): boolean => {
if (!isDefined(droppableId)) {
return false;
}
const prefix =
navigationMenuItemSection === 'workspace'
? NavigationMenuItemDroppableIds.WORKSPACE_DROPPABLE_PREFIX
: NavigationMenuItemDroppableIds.FAVORITE_DROPPABLE_PREFIX;
return droppableId.startsWith(prefix);
};
@@ -0,0 +1,24 @@
import { NAVIGATION_MENU_ITEM_SECTION_DROPPABLE_CONFIG } from '@/navigation-menu-item/common/constants/NavigationMenuItemSectionDroppableConfig';
import type { NavigationMenuItemSection } from '@/navigation-menu-item/common/types/NavigationMenuItemSection';
export const extractFolderIdFromDroppableId = (
droppableId: string,
navigationMenuItemSection: NavigationMenuItemSection,
): string | null => {
const config =
NAVIGATION_MENU_ITEM_SECTION_DROPPABLE_CONFIG[navigationMenuItemSection];
if (droppableId === config.orphanDroppableId) {
return null;
}
if (droppableId.startsWith(config.folderHeaderPrefix)) {
return droppableId.slice(config.folderHeaderPrefix.length) || null;
}
if (droppableId.startsWith(config.folderPrefix)) {
return droppableId.slice(config.folderPrefix.length) || null;
}
return null;
};
@@ -1,15 +1,42 @@
import { NavigationSections } from '@/navigation-menu-item/common/constants/NavigationSections.constants';
import { isWorkspaceDroppableId } from '@/navigation-menu-item/common/utils/isWorkspaceDroppableId';
import { validateAndExtractWorkspaceFolderId } from '@/navigation-menu-item/common/utils/validateAndExtractWorkspaceFolderId';
import { NAVIGATION_MENU_ITEM_SECTION_DROPPABLE_CONFIG } from '@/navigation-menu-item/common/constants/NavigationMenuItemSectionDroppableConfig';
import type { NavigationMenuItemSection } from '@/navigation-menu-item/common/types/NavigationMenuItemSection';
import { canNavigationMenuItemBeDroppedIn } from '@/navigation-menu-item/common/utils/canNavigationMenuItemBeDroppedIn';
import type { DropResult } from '@hello-pangea/dnd';
export const getDropTargetIdFromDestination = (
destination: DropResult['destination'],
): string | null => {
if (!destination || !isWorkspaceDroppableId(destination.droppableId)) {
export const getDropTargetIdFromDestination = ({
navigationMenuItemSection,
destination,
}: {
navigationMenuItemSection: NavigationMenuItemSection;
destination: DropResult['destination'];
}): string | null => {
if (
!destination ||
!canNavigationMenuItemBeDroppedIn({
navigationMenuItemSection,
droppableId: destination.droppableId,
})
) {
return null;
}
const folderId = validateAndExtractWorkspaceFolderId(destination.droppableId);
const folderSegment = folderId ?? 'orphan';
return `${NavigationSections.WORKSPACE}-${folderSegment}-${destination.index}`;
const config =
NAVIGATION_MENU_ITEM_SECTION_DROPPABLE_CONFIG[navigationMenuItemSection];
const { droppableId, index } = destination;
if (droppableId === config.orphanDroppableId) {
return `${navigationMenuItemSection}-orphan-${index}`;
}
if (droppableId.startsWith(config.folderHeaderPrefix)) {
const folderId = droppableId.slice(config.folderHeaderPrefix.length);
return `${navigationMenuItemSection}-${folderId}-${index}`;
}
if (droppableId.startsWith(config.folderPrefix)) {
const folderId = droppableId.slice(config.folderPrefix.length);
return `${navigationMenuItemSection}-${folderId}-${index}`;
}
return null;
};
@@ -1,22 +0,0 @@
import { NavigationMenuItemDroppableIds } from '@/navigation-menu-item/common/constants/NavigationMenuItemDroppableIds';
import { NavigationSections } from '@/navigation-menu-item/common/constants/NavigationSections.constants';
import type { DropResult } from '@hello-pangea/dnd';
const FOLDER_PREFIX = 'folder-';
export const getFavoritesDropTargetIdFromDestination = (
destination: DropResult['destination'],
): string | null => {
if (!destination) return null;
const { droppableId, index } = destination;
if (
droppableId === NavigationMenuItemDroppableIds.ORPHAN_NAVIGATION_MENU_ITEMS
) {
return `${NavigationSections.FAVORITES}-orphan-${index}`;
}
if (droppableId.startsWith(FOLDER_PREFIX)) {
const folderId = droppableId.slice(FOLDER_PREFIX.length);
return `${NavigationSections.FAVORITES}-${folderId}-${index}`;
}
return null;
};
@@ -1,21 +0,0 @@
import { isDefined } from 'twenty-shared/utils';
import { NavigationMenuItemDroppableIds } from '@/navigation-menu-item/common/constants/NavigationMenuItemDroppableIds';
export const isWorkspaceDroppableId = (
droppableId: string | null | undefined,
): boolean => {
if (!isDefined(droppableId)) {
return false;
}
return (
droppableId ===
NavigationMenuItemDroppableIds.WORKSPACE_ORPHAN_NAVIGATION_MENU_ITEMS ||
droppableId.startsWith(
NavigationMenuItemDroppableIds.WORKSPACE_FOLDER_PREFIX,
) ||
droppableId.startsWith(
NavigationMenuItemDroppableIds.WORKSPACE_FOLDER_HEADER_PREFIX,
)
);
};
@@ -1,17 +1,19 @@
import { NavigationMenuItemType } from 'twenty-shared/types';
import { NavigationMenuItemFolderDisplay } from '@/navigation-menu-item/display/folder/components/NavigationMenuItemFolderDisplay';
import { NavigationMenuItemFolder } from '@/navigation-menu-item/display/folder/components/NavigationMenuItemFolder';
import { NavigationMenuItemLinkDisplay } from '@/navigation-menu-item/display/link/components/NavigationMenuItemLinkDisplay';
import { NavigationMenuItemObjectDisplay } from '@/navigation-menu-item/display/object/components/NavigationMenuItemObjectDisplay';
import type { WorkspaceSectionItemContentProps } from '@/navigation-menu-item/display/sections/types/WorkspaceSectionItemContentProps';
import type { NavigationMenuItemSectionContentProps } from '@/navigation-menu-item/display/sections/types/NavigationMenuItemSectionContentProps';
type NavigationMenuItemDisplayProps = WorkspaceSectionItemContentProps;
type NavigationMenuItemDisplayProps = NavigationMenuItemSectionContentProps;
export const NavigationMenuItemDisplay = ({
item,
isEditInPlace,
editModeProps,
isDragging,
folderChildrenById,
folderCount,
rightOptions,
selectedNavigationMenuItemId,
onNavigationMenuItemClick,
onActiveObjectMetadataItemClick,
@@ -20,8 +22,9 @@ export const NavigationMenuItemDisplay = ({
switch (item.type) {
case NavigationMenuItemType.FOLDER:
return (
<NavigationMenuItemFolderDisplay
<NavigationMenuItemFolder
item={item}
isEditInPlace={isEditInPlace}
editModeProps={editModeProps}
isDragging={isDragging}
folderChildrenById={folderChildrenById}
@@ -35,10 +38,12 @@ export const NavigationMenuItemDisplay = ({
return (
<NavigationMenuItemLinkDisplay
item={item}
isEditInPlace={isEditInPlace}
editModeProps={editModeProps}
isDragging={isDragging}
folderChildrenById={folderChildrenById}
folderCount={folderCount}
rightOptions={rightOptions}
selectedNavigationMenuItemId={selectedNavigationMenuItemId}
onNavigationMenuItemClick={onNavigationMenuItemClick}
onActiveObjectMetadataItemClick={onActiveObjectMetadataItemClick}
@@ -49,10 +54,12 @@ export const NavigationMenuItemDisplay = ({
return (
<NavigationMenuItemObjectDisplay
item={item}
isEditInPlace={isEditInPlace}
editModeProps={editModeProps}
isDragging={isDragging}
folderChildrenById={folderChildrenById}
folderCount={folderCount}
rightOptions={rightOptions}
selectedNavigationMenuItemId={selectedNavigationMenuItemId}
onNavigationMenuItemClick={onNavigationMenuItemClick}
onActiveObjectMetadataItemClick={onActiveObjectMetadataItemClick}
@@ -4,7 +4,7 @@ import { type ReactNode } from 'react';
import { themeCssVariables } from 'twenty-ui/theme-constants';
import { getDndKitDropTargetId } from '@/navigation-menu-item/common/utils/getDndKitDropTargetId';
import type { DroppableData } from '@/navigation/types/workspaceDndKitDroppableData';
import type { DroppableData } from '@/navigation-menu-item/common/types/navigationMenuItemDndKitDroppableData';
const StyledSlotWrapper = styled.div<{ $empty: boolean }>`
min-height: 0;
@@ -16,7 +16,7 @@ const SLOT_COLLISION_PRIORITY = 1;
export const FOLDER_HEADER_SLOT_COLLISION_PRIORITY = 2;
type WorkspaceDndKitDroppableSlotProps = {
type NavigationMenuItemDroppableSlotProps = {
droppableId: string;
index: number;
children?: ReactNode;
@@ -24,13 +24,13 @@ type WorkspaceDndKitDroppableSlotProps = {
collisionPriority?: number;
};
export const WorkspaceDndKitDroppableSlot = ({
export const NavigationMenuItemDroppableSlot = ({
droppableId,
index,
children,
disabled = false,
collisionPriority = SLOT_COLLISION_PRIORITY,
}: WorkspaceDndKitDroppableSlotProps) => {
}: NavigationMenuItemDroppableSlotProps) => {
const id = getDndKitDropTargetId(droppableId, index);
const data: DroppableData = { droppableId, index };
const { ref } = useDroppable({
@@ -14,7 +14,7 @@ const StyledSortableRoot = styled.div`
position: relative;
`;
type WorkspaceDndKitSortableItemProps = {
type NavigationMenuItemSortableItemProps = {
children: ReactNode;
disabled?: boolean;
group: string;
@@ -22,13 +22,13 @@ type WorkspaceDndKitSortableItemProps = {
index: number;
};
export const WorkspaceDndKitSortableItem = ({
export const NavigationMenuItemSortableItem = ({
id,
index,
group,
disabled = false,
children,
}: WorkspaceDndKitSortableItemProps) => {
}: NavigationMenuItemSortableItemProps) => {
const { handleRef, ref, targetRef } = useSortable({
id,
index,
@@ -19,7 +19,7 @@ import { navigationMenuItemsDraftState } from '@/navigation-menu-item/common/sta
import { openNavigationMenuItemFolderIdsState } from '@/navigation-menu-item/common/states/openNavigationMenuItemFolderIdsState';
import { getObjectMetadataIdsInDraft } from '@/navigation-menu-item/common/utils/getObjectMetadataIdsInDraft';
import { getStandardObjectIconColor } from '@/navigation-menu-item/common/utils/getStandardObjectIconColor';
import { isWorkspaceDroppableId } from '@/navigation-menu-item/common/utils/isWorkspaceDroppableId';
import { canNavigationMenuItemBeDroppedIn } from '@/navigation-menu-item/common/utils/canNavigationMenuItemBeDroppedIn';
import { validateAndExtractWorkspaceFolderId } from '@/navigation-menu-item/common/utils/validateAndExtractWorkspaceFolderId';
import { useObjectMetadataItems } from '@/object-metadata/hooks/useObjectMetadataItems';
import { useAtomStateValue } from '@/ui/utilities/state/jotai/hooks/useAtomStateValue';
@@ -54,7 +54,10 @@ export const useHandleAddToNavigationDrop = () => {
if (
source.droppableId !== ADD_TO_NAV_SOURCE_DROPPABLE_ID ||
!destination ||
!isWorkspaceDroppableId(destination.droppableId)
!canNavigationMenuItemBeDroppedIn({
navigationMenuItemSection: 'workspace',
droppableId: destination.droppableId,
})
) {
return;
}
@@ -1,14 +1,13 @@
import { type OnDragEndResponder } from '@hello-pangea/dnd';
import { NavigationMenuItemDroppableIds } from '@/navigation-menu-item/common/constants/NavigationMenuItemDroppableIds';
import { isWorkspaceDroppableId } from '@/navigation-menu-item/common/utils/isWorkspaceDroppableId';
import { canNavigationMenuItemBeDroppedIn } from '@/navigation-menu-item/common/utils/canNavigationMenuItemBeDroppedIn';
import { useSortedNavigationMenuItems } from '@/navigation-menu-item/display/hooks/useSortedNavigationMenuItems';
import { useUpdateNavigationMenuItem } from '@/navigation-menu-item/common/hooks/useUpdateNavigationMenuItem';
import { openNavigationMenuItemFolderIdsState } from '@/navigation-menu-item/common/states/openNavigationMenuItemFolderIdsState';
import { useSetAtomState } from '@/ui/utilities/state/jotai/hooks/useSetAtomState';
import { calculateNewPosition } from '@/ui/layout/draggable-list/utils/calculateNewPosition';
import { FOLDER_DROPPABLE_IDS } from '@/ui/layout/draggable-list/utils/folderDroppableIds';
import { validateAndExtractFolderId } from '@/ui/layout/draggable-list/utils/validateAndExtractFolderId';
import { extractFolderIdFromDroppableId } from '@/navigation-menu-item/common/utils/extractFolderIdFromDroppableId';
import { useNavigationMenuItemsData } from '@/navigation-menu-item/display/hooks/useNavigationMenuItemsData';
@@ -49,7 +48,12 @@ export const useHandleNavigationMenuItemDragAndDrop = () => {
return;
}
if (isWorkspaceDroppableId(destination.droppableId)) {
if (
canNavigationMenuItemBeDroppedIn({
navigationMenuItemSection: 'workspace',
droppableId: destination.droppableId,
})
) {
return;
}
@@ -60,20 +64,18 @@ export const useHandleNavigationMenuItemDragAndDrop = () => {
return;
}
const destinationFolderId = validateAndExtractFolderId({
droppableId: destination.droppableId,
orphanDroppableId:
NavigationMenuItemDroppableIds.ORPHAN_NAVIGATION_MENU_ITEMS,
});
const sourceFolderId = validateAndExtractFolderId({
droppableId: source.droppableId,
orphanDroppableId:
NavigationMenuItemDroppableIds.ORPHAN_NAVIGATION_MENU_ITEMS,
});
const destinationFolderId = extractFolderIdFromDroppableId(
destination.droppableId,
'favorite',
);
const sourceFolderId = extractFolderIdFromDroppableId(
source.droppableId,
'favorite',
);
if (
destination.droppableId.startsWith(
FOLDER_DROPPABLE_IDS.FOLDER_HEADER_PREFIX,
NavigationMenuItemDroppableIds.FAVORITE_FOLDER_HEADER_PREFIX,
)
) {
if (destinationFolderId === null)
@@ -1,6 +1,6 @@
import { ADD_TO_NAV_SOURCE_DROPPABLE_ID } from '@/navigation-menu-item/common/constants/AddToNavSourceDroppableId';
import { NavigationDragSourceContext } from '@/navigation-menu-item/common/contexts/NavigationDragSourceContext';
import { isWorkspaceDroppableId } from '@/navigation-menu-item/common/utils/isWorkspaceDroppableId';
import { canNavigationMenuItemBeDroppedIn } from '@/navigation-menu-item/common/utils/canNavigationMenuItemBeDroppedIn';
import { useContext } from 'react';
import { isDefined } from 'twenty-shared/utils';
@@ -12,5 +12,9 @@ export const useIsDropDisabledForSection = (isWorkspaceSection: boolean) => {
if (sourceDroppableId === ADD_TO_NAV_SOURCE_DROPPABLE_ID) {
return !isWorkspaceSection;
}
return isWorkspaceDroppableId(sourceDroppableId) !== isWorkspaceSection;
const sourceIsWorkspace = canNavigationMenuItemBeDroppedIn({
navigationMenuItemSection: 'workspace',
droppableId: sourceDroppableId,
});
return sourceIsWorkspace !== isWorkspaceSection;
};
@@ -0,0 +1,426 @@
import { type DragDropProvider } from '@dnd-kit/react';
import { isSortable } from '@dnd-kit/react/sortable';
import type { ResponderProvided } from '@hello-pangea/dnd';
import { useStore } from 'jotai';
import { type ComponentProps, useCallback, useState } from 'react';
import { isDefined } from 'twenty-shared/utils';
import { ADD_TO_NAV_SOURCE_DROPPABLE_ID } from '@/navigation-menu-item/common/constants/AddToNavSourceDroppableId';
import { NavigationSections } from '@/navigation-menu-item/common/constants/NavigationSections.constants';
import { addToNavPayloadRegistryState } from '@/navigation-menu-item/common/states/addToNavPayloadRegistryState';
import type { DraggableData } from '@/navigation-menu-item/common/types/navigationMenuItemDndKitDraggableData';
import type { DropDestination } from '@/navigation-menu-item/common/types/navigationMenuItemDndKitDropDestination';
import type { NavigationMenuItemSection } from '@/navigation-menu-item/common/types/NavigationMenuItemSection';
import { canNavigationMenuItemBeDroppedIn } from '@/navigation-menu-item/common/utils/canNavigationMenuItemBeDroppedIn';
import { extractFolderIdFromDroppableId } from '@/navigation-menu-item/common/utils/extractFolderIdFromDroppableId';
import { getDndKitDropTargetId } from '@/navigation-menu-item/common/utils/getDndKitDropTargetId';
import { isNavigationMenuItemFolder } from '@/navigation-menu-item/common/utils/isNavigationMenuItemFolder';
import { DROP_RESULT_OPTIONS } from '@/navigation-menu-item/display/dnd/constants/navigationMenuItemDndKitDropResultOptions';
import { useHandleAddToNavigationDrop } from '@/navigation-menu-item/display/dnd/hooks/useHandleAddToNavigationDrop';
import { useHandleNavigationMenuItemDragAndDrop } from '@/navigation-menu-item/display/dnd/hooks/useHandleNavigationMenuItemDragAndDrop';
import { useHandleWorkspaceNavigationMenuItemDragAndDrop } from '@/navigation-menu-item/display/dnd/hooks/useHandleWorkspaceNavigationMenuItemDragAndDrop';
import { resolveDropTarget } from '@/navigation-menu-item/display/dnd/utils/navigationMenuItemDndKitResolveDropTarget';
import { toDropResult } from '@/navigation-menu-item/display/dnd/utils/navigationMenuItemDndKitToDropResult';
import { useNavigationMenuItemsData } from '@/navigation-menu-item/display/hooks/useNavigationMenuItemsData';
import { useSortedNavigationMenuItems } from '@/navigation-menu-item/display/hooks/useSortedNavigationMenuItems';
import { useNavigationMenuItemsDraftState } from '@/navigation-menu-item/edit/hooks/useNavigationMenuItemsDraftState';
import { NAVIGATION_MENU_ITEM_SECTION_DROPPABLE_CONFIG } from '@/navigation-menu-item/common/constants/NavigationMenuItemSectionDroppableConfig';
import type { SortableTargetDestination } from '@/navigation-menu-item/common/types/navigationMenuItemDndKitSortableTargetDestination';
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 NavigationMenuItemDndKitContextValues = {
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 useNavigationMenuItemDndKit = (
section: NavigationSections,
): {
contextValues: NavigationMenuItemDndKitContextValues;
handlers: {
onDragStart: (event: DragStartPayload) => void;
onDragOver: (event: DragOverPayload) => void;
onDragEnd: (event: DragEndPayload) => void;
};
} => {
const sectionType: NavigationMenuItemSection =
section === NavigationSections.FAVORITES ? 'favorite' : 'workspace';
const isWorkspaceSection = sectionType === 'workspace';
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 { navigationMenuItems } = useNavigationMenuItemsData();
const { navigationMenuItemsSorted } = useSortedNavigationMenuItems();
const { workspaceNavigationMenuItems } = useNavigationMenuItemsDraftState();
const { handleAddToNavigationDrop } = useHandleAddToNavigationDrop();
const { handleNavigationMenuItemDragAndDrop } =
useHandleNavigationMenuItemDragAndDrop();
const { handleWorkspaceNavigationMenuItemDragAndDrop } =
useHandleWorkspaceNavigationMenuItemDragAndDrop();
const items = isWorkspaceSection
? workspaceNavigationMenuItems
: navigationMenuItems;
const orphanItems = isWorkspaceSection
? workspaceNavigationMenuItems
: navigationMenuItemsSorted;
const orphanItemCount = orphanItems.filter(
(item: { folderId?: string | null }) => !isDefined(item.folderId),
).length;
const { orphanDroppableId: defaultOrphanDroppableId } =
NAVIGATION_MENU_ITEM_SECTION_DROPPABLE_CONFIG[sectionType];
const getNavItemById = useCallback(
(id: string | undefined) =>
id ? items.find((item) => item.id === id) : undefined,
[items],
);
const getAddToNavPayload = useCallback(
(sourceId: unknown) =>
store.get(addToNavPayloadRegistryState.atom).get(String(sourceId)) ??
null,
[store],
);
const isSourceFolderDrag = useCallback(
(source: { id?: unknown; data?: unknown } | null): boolean => {
const sourceItem = getNavItemById(
source?.id != null ? String(source.id) : undefined,
);
if (isDefined(sourceItem) && isNavigationMenuItemFolder(sourceItem)) {
return true;
}
const payload = getAddToNavPayload(source?.id);
return payload?.type === 'FOLDER';
},
[getNavItemById, getAddToNavPayload],
);
const computeForbiddenTargetId = useCallback(
(
source: { id?: unknown; data?: unknown } | null,
resolved: SortableTargetDestination,
isAddToNavDrag: boolean,
): string | null => {
const sourceIsFolder = isAddToNavDrag
? getAddToNavPayload(source?.id)?.type === 'FOLDER'
: isSourceFolderDrag(source);
if (!sourceIsFolder) {
return null;
}
if (resolved.isTargetFolder) {
return resolved.effectiveDropTargetId;
}
const destFolderId = extractFolderIdFromDroppableId(
resolved.destination.droppableId,
sectionType,
);
if (isDefined(destFolderId)) {
return resolved.dropTargetId;
}
return null;
},
[sectionType, getAddToNavPayload, isSourceFolderDrag],
);
const applyWorkspaceReorderIfAllowed = useCallback(
(
id: string,
source: DropDestination,
destination: DropDestination,
insertBeforeItemId?: string | null,
) => {
const draggedItem = getNavItemById(id);
const destFolderId = extractFolderIdFromDroppableId(
destination.droppableId,
'workspace',
);
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,
...(insertBeforeItemId != null && { insertBeforeItemId }),
},
provided,
);
},
[getNavItemById, handleWorkspaceNavigationMenuItemDragAndDrop],
);
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: defaultOrphanDroppableId,
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, sectionType);
// Branch 1: sortable-to-sortable
if (
resolved !== null &&
source !== null &&
target !== null &&
isSortable(source) &&
isSortable(target)
) {
setActiveDropTargetId(resolved.effectiveDropTargetId);
setForbiddenDropTargetId(
isAddToNavDrag
? computeForbiddenTargetId(source, resolved, true)
: computeForbiddenTargetId(source, resolved, false),
);
return;
}
// Branch 2: sortable-to-droppable-slot
if (resolved !== null && sourceIsSortable) {
setActiveDropTargetId(resolved.effectiveDropTargetId);
setAddToNavigationFallbackDestination(resolved.destination);
setForbiddenDropTargetId(
computeForbiddenTargetId(source, resolved, isAddToNavDrag),
);
return;
}
if (!isAddToNavDrag) {
return;
}
// Branch 3: add-to-nav drag
if (resolved !== null) {
setAddToNavigationFallbackDestination(resolved.destination);
setActiveDropTargetId(resolved.effectiveDropTargetId);
setForbiddenDropTargetId(
computeForbiddenTargetId(source, resolved, true),
);
return;
}
const fallback = addToNavigationFallbackDestination;
setActiveDropTargetId(
fallback
? getDndKitDropTargetId(fallback.droppableId, fallback.index)
: null,
);
setForbiddenDropTargetId(null);
},
[
sourceDroppableId,
addToNavigationFallbackDestination,
getNavItemById,
sectionType,
computeForbiddenTargetId,
],
);
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 resolved = resolveDropTarget(target, getNavItemById, sectionType);
// Workspace fast path: sortable-to-sortable within workspace
if (
isWorkspaceSection &&
sourceIsSortable &&
targetIsSortable &&
isDefined(source) &&
isDefined(target) &&
resolved !== null
) {
const sourceDraggable = 'initialGroup' in source ? source : null;
const initialGroup = String(sourceDraggable?.initialGroup ?? '');
const initialIndex = sourceDraggable?.initialIndex ?? 0;
const destGroup = String(target.group ?? '');
const bothWorkspace =
canNavigationMenuItemBeDroppedIn({
navigationMenuItemSection: 'workspace',
droppableId: initialGroup,
}) &&
canNavigationMenuItemBeDroppedIn({
navigationMenuItemSection: 'workspace',
droppableId: destGroup,
});
if (bothWorkspace) {
const insertBeforeItemId = resolved.isTargetFolder
? null
: String(target?.id ?? '');
applyWorkspaceReorderIfAllowed(
draggableId,
{ droppableId: initialGroup, index: initialIndex },
resolved.destination,
insertBeforeItemId || undefined,
);
return;
}
}
let destination: DropDestination | null = resolved?.destination ?? null;
if (
destination == null &&
isDefined(fallback) &&
canNavigationMenuItemBeDroppedIn({
navigationMenuItemSection: sectionType,
droppableId: 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 (isWorkspaceSection) {
if (
isDefined(sourceId) &&
canNavigationMenuItemBeDroppedIn({
navigationMenuItemSection: 'workspace',
droppableId: sourceId,
}) &&
isDefined(destination) &&
canNavigationMenuItemBeDroppedIn({
navigationMenuItemSection: 'workspace',
droppableId: destination.droppableId,
})
) {
applyWorkspaceReorderIfAllowed(
draggableId,
{
droppableId: data?.sourceDroppableId ?? '',
index: data?.sourceIndex ?? 0,
},
destination,
);
}
return;
}
handleNavigationMenuItemDragAndDrop(dropResult, provided);
};
const contextValues: NavigationMenuItemDndKitContextValues = {
dragSource: { sourceDroppableId },
drag: { isDragging },
dropTarget: {
activeDropTargetId,
setActiveDropTargetId,
forbiddenDropTargetId,
setForbiddenDropTargetId,
addToNavigationFallbackDestination,
},
};
return {
contextValues,
handlers: {
onDragStart: handleDragStart,
onDragOver: handleDragOver,
onDragEnd: handleDragEnd,
},
};
};
@@ -0,0 +1,18 @@
let preloadScheduled = false;
const preload = () => {
void import(
'@/navigation-menu-item/display/dnd/providers/NavigationMenuItemDndKitProvider'
);
void import(
'@/navigation-menu-item/display/sections/workspace/components/WorkspaceSectionListDndKit'
);
};
export const preloadNavigationMenuItemDndKit = (): void => {
if (preloadScheduled) {
return;
}
preloadScheduled = true;
preload();
};
@@ -6,14 +6,14 @@ import {
} from '@dnd-kit/react';
import type { ReactNode } from 'react';
import type { NavigationSections } from '@/navigation-menu-item/common/constants/NavigationSections.constants';
import { NavigationDragSourceContext } from '@/navigation-menu-item/common/contexts/NavigationDragSourceContext';
import { NavigationDropTargetContext } from '@/navigation-menu-item/common/contexts/NavigationDropTargetContext';
import { NavigationMenuItemDragContext } from '@/navigation-menu-item/common/contexts/NavigationMenuItemDragContext';
import type { DraggableData } from '@/navigation/types/workspaceDndKitDraggableData';
import type { DraggableData } from '@/navigation-menu-item/common/types/navigationMenuItemDndKitDraggableData';
import { useNavigationMenuItemDndKit } from '@/navigation-menu-item/display/dnd/hooks/useNavigationMenuItemDndKit';
import { useFavoritesDndKit } from '@/navigation/hooks/useFavoritesDndKit';
const FAVORITES_DND_SENSORS = [
const NAVIGATION_MENU_ITEM_DND_SENSORS = [
PointerSensor.configure({
activationConstraints: [
new PointerActivationConstraints.Distance({ value: 8 }),
@@ -22,21 +22,23 @@ const FAVORITES_DND_SENSORS = [
KeyboardSensor,
];
type FavoritesDndKitProviderProps = {
type NavigationMenuItemDndKitProviderProps = {
section: NavigationSections;
children: ReactNode;
};
export const FavoritesDndKitProvider = ({
export const NavigationMenuItemDndKitProvider = ({
section,
children,
}: FavoritesDndKitProviderProps) => {
const { contextValues, handlers } = useFavoritesDndKit();
}: NavigationMenuItemDndKitProviderProps) => {
const { contextValues, handlers } = useNavigationMenuItemDndKit(section);
return (
<NavigationDragSourceContext.Provider value={contextValues.dragSource}>
<NavigationMenuItemDragContext.Provider value={contextValues.drag}>
<NavigationDropTargetContext.Provider value={contextValues.dropTarget}>
<DragDropProvider<DraggableData>
sensors={FAVORITES_DND_SENSORS}
sensors={NAVIGATION_MENU_ITEM_DND_SENSORS}
onDragStart={handlers.onDragStart}
onDragOver={handlers.onDragOver}
onDragEnd={handlers.onDragEnd}
@@ -0,0 +1,43 @@
import { lazy, Suspense, useState, type ReactNode } from 'react';
import { NavigationSections } from '@/navigation-menu-item/common/constants/NavigationSections.constants';
import { PageDragDropProviderMountEffect } from '@/navigation-menu-item/display/dnd/providers/PageDragDropProviderMountEffect';
const LazyNavigationMenuItemDndKitProvider = lazy(() =>
import(
'@/navigation-menu-item/display/dnd/providers/NavigationMenuItemDndKitProvider'
).then((m) => ({
default: m.NavigationMenuItemDndKitProvider,
})),
);
type PageDragDropProviderProps = {
children: ReactNode;
};
export const PageDragDropProvider = ({
children,
}: PageDragDropProviderProps) => {
const [hasProviderMounted, setHasProviderMounted] = useState(false);
if (!hasProviderMounted) {
return (
<>
<PageDragDropProviderMountEffect
onEnterEditMode={() => setHasProviderMounted(true)}
/>
{children}
</>
);
}
return (
<Suspense fallback={<>{children}</>}>
<LazyNavigationMenuItemDndKitProvider
section={NavigationSections.WORKSPACE}
>
{children}
</LazyNavigationMenuItemDndKitProvider>
</Suspense>
);
};
@@ -1,13 +1,13 @@
import { isDefined } from 'twenty-shared/utils';
import type { NavigationMenuItem } from '~/generated-metadata/graphql';
import { NavigationMenuItemDroppableIds } from '@/navigation-menu-item/common/constants/NavigationMenuItemDroppableIds';
import { NAVIGATION_MENU_ITEM_SECTION_DROPPABLE_CONFIG } from '@/navigation-menu-item/common/constants/NavigationMenuItemSectionDroppableConfig';
import type { NavigationMenuItemSection } from '@/navigation-menu-item/common/types/NavigationMenuItemSection';
import type { DropDestination } from '@/navigation-menu-item/common/types/navigationMenuItemDndKitDropDestination';
import type { SortableTargetDestination } from '@/navigation-menu-item/common/types/navigationMenuItemDndKitSortableTargetDestination';
import { getDndKitDropTargetId } from '@/navigation-menu-item/common/utils/getDndKitDropTargetId';
import { isNavigationMenuItemFolder } from '@/navigation-menu-item/common/utils/isNavigationMenuItemFolder';
import type { DropDestination } from '@/navigation/types/workspaceDndKitDropDestination';
import type { SortableTargetDestination } from '@/navigation/types/workspaceDndKitSortableTargetDestination';
type GetNavItemById = (
id: string | undefined,
) => NavigationMenuItem | undefined;
@@ -15,6 +15,7 @@ type GetNavItemById = (
export const getDestinationFromSortableTarget = (
target: { id: unknown; group?: unknown; index?: unknown },
getNavItemById: GetNavItemById,
navigationMenuItemSection: NavigationMenuItemSection,
): SortableTargetDestination | null => {
const group = target.group;
const rawIndex = target.index;
@@ -32,15 +33,16 @@ export const getDestinationFromSortableTarget = (
const isTargetFolder =
isDefined(targetItem) && isNavigationMenuItemFolder(targetItem);
const dropTargetId = getDndKitDropTargetId(destDroppableId, index);
const { folderHeaderPrefix } =
NAVIGATION_MENU_ITEM_SECTION_DROPPABLE_CONFIG[navigationMenuItemSection];
const effectiveDropTargetId = isTargetFolder
? getDndKitDropTargetId(
`${NavigationMenuItemDroppableIds.WORKSPACE_FOLDER_HEADER_PREFIX}${target.id}`,
0,
)
? getDndKitDropTargetId(`${folderHeaderPrefix}${target.id}`, 0)
: dropTargetId;
const destination: DropDestination = {
droppableId: isTargetFolder
? `${NavigationMenuItemDroppableIds.WORKSPACE_FOLDER_HEADER_PREFIX}${target.id}`
? `${folderHeaderPrefix}${target.id}`
: destDroppableId,
index: isTargetFolder ? 0 : index,
};
@@ -1,11 +1,11 @@
import { isDefined } from 'twenty-shared/utils';
import type { NavigationMenuItem } from '~/generated-metadata/graphql';
import { isWorkspaceDroppableId } from '@/navigation-menu-item/common/utils/isWorkspaceDroppableId';
import type { DroppableData } from '@/navigation/types/workspaceDndKitDroppableData';
import type { SortableTargetDestination } from '@/navigation/types/workspaceDndKitSortableTargetDestination';
import { getDestinationFromSortableTarget } from '@/navigation/utils/workspaceDndKitGetDestinationFromSortableTarget';
import type { NavigationMenuItemSection } from '@/navigation-menu-item/common/types/NavigationMenuItemSection';
import type { DroppableData } from '@/navigation-menu-item/common/types/navigationMenuItemDndKitDroppableData';
import type { SortableTargetDestination } from '@/navigation-menu-item/common/types/navigationMenuItemDndKitSortableTargetDestination';
import { canNavigationMenuItemBeDroppedIn } from '@/navigation-menu-item/common/utils/canNavigationMenuItemBeDroppedIn';
import { getDestinationFromSortableTarget } from '@/navigation-menu-item/display/dnd/utils/navigationMenuItemDndKitGetDestinationFromSortableTarget';
type GetNavItemById = (
id: string | undefined,
@@ -25,6 +25,7 @@ export const resolveDropTarget = (
data?: unknown;
} | null,
getNavItemById: GetNavItemById,
navigationMenuItemSection: NavigationMenuItemSection,
): SortableTargetDestination | null => {
if (target === null || target === undefined) {
return null;
@@ -33,11 +34,17 @@ export const resolveDropTarget = (
return getDestinationFromSortableTarget(
{ id: target.id, group: target.group, index: target.index },
getNavItemById,
navigationMenuItemSection,
);
}
if (isDroppableData(target.data)) {
const { droppableId, index } = target.data;
if (isWorkspaceDroppableId(droppableId)) {
if (
canNavigationMenuItemBeDroppedIn({
navigationMenuItemSection,
droppableId,
})
) {
return {
destination: { droppableId, index },
effectiveDropTargetId: String(target.id),
@@ -1,5 +1,5 @@
import type { DropDestination } from '@/navigation/types/workspaceDndKitDropDestination';
import type { DraggableData } from '@/navigation/types/workspaceDndKitDraggableData';
import type { DropDestination } from '@/navigation-menu-item/common/types/navigationMenuItemDndKitDropDestination';
import type { DraggableData } from '@/navigation-menu-item/common/types/navigationMenuItemDndKitDraggableData';
export const toDropResult = (
draggableId: string,
@@ -0,0 +1,169 @@
import { Suspense, lazy } from 'react';
import { isDefined } from 'twenty-shared/utils';
import { IconChevronDown, IconChevronRight, useIcons } from 'twenty-ui/display';
import { themeCssVariables } from 'twenty-ui/theme-constants';
import { useIsMobile } from 'twenty-ui/utilities';
import { type NavigationMenuItem } from '~/generated-metadata/graphql';
import { FOLDER_ICON_DEFAULT } from '@/navigation-menu-item/common/constants/FolderIconDefault';
import { DEFAULT_NAVIGATION_MENU_ITEM_COLOR_FOLDER } from '@/navigation-menu-item/common/constants/NavigationMenuItemDefaultColorFolder';
import { NavigationMenuItemFolderLayout } from '@/navigation-menu-item/display/folder/components/NavigationMenuItemFolderLayout';
import { NavigationMenuItemFolderSubItem } from '@/navigation-menu-item/display/folder/components/NavigationMenuItemFolderSubItem';
import { useNavigationMenuItemFolderOpenState } from '@/navigation-menu-item/display/folder/hooks/useNavigationMenuItemFolderOpenState';
import type { NavigationMenuItemSectionContentProps } from '@/navigation-menu-item/display/sections/types/NavigationMenuItemSectionContentProps';
import { NavigationDrawerItem } from '@/ui/navigation/navigation-drawer/components/NavigationDrawerItem';
const LazyNavigationMenuItemFolderDnd = lazy(() =>
import(
'@/navigation-menu-item/display/folder/components/NavigationMenuItemFolderDnd'
).then((module) => ({ default: module.NavigationMenuItemFolderDnd })),
);
type NavigationMenuItemFolderProps = Pick<
NavigationMenuItemSectionContentProps,
| 'item'
| 'isEditInPlace'
| 'editModeProps'
| 'isDragging'
| 'folderChildrenById'
| 'folderCount'
| 'selectedNavigationMenuItemId'
| 'onNavigationMenuItemClick'
| 'readOnly'
>;
export const NavigationMenuItemFolder = ({
item,
isEditInPlace = false,
editModeProps,
isDragging,
folderChildrenById,
folderCount,
selectedNavigationMenuItemId,
onNavigationMenuItemClick,
readOnly = false,
}: NavigationMenuItemFolderProps) => {
const folderId = item.id;
const folderName = item.name ?? 'Folder';
const folderIconKey = item.icon;
const folderColor = 'color' in item ? (item.color as string | null) : null;
const navigationMenuItems = folderChildrenById.get(folderId) ?? [];
const isGroup = folderCount > 1;
if (readOnly) {
return (
<NavigationMenuItemFolderReadOnlyContent
folderId={folderId}
folderName={folderName}
folderIconKey={folderIconKey}
folderColor={folderColor}
navigationMenuItems={navigationMenuItems}
isGroup={isGroup}
/>
);
}
return (
<Suspense
fallback={
<NavigationMenuItemFolderReadOnlyContent
folderId={folderId}
folderName={folderName}
folderIconKey={folderIconKey}
folderColor={folderColor}
navigationMenuItems={navigationMenuItems}
isGroup={isGroup}
/>
}
>
<LazyNavigationMenuItemFolderDnd
folderId={folderId}
folderName={folderName}
folderIconKey={folderIconKey}
folderColor={folderColor}
navigationMenuItems={navigationMenuItems}
isGroup={isGroup}
isEditInPlace={isEditInPlace}
editModeProps={editModeProps}
isDragging={isDragging}
selectedNavigationMenuItemId={selectedNavigationMenuItemId}
onNavigationMenuItemClick={onNavigationMenuItemClick}
/>
</Suspense>
);
};
type NavigationMenuItemFolderReadOnlyContentProps = {
folderId: string;
folderName: string;
folderIconKey?: string | null;
folderColor?: string | null;
navigationMenuItems: NavigationMenuItem[];
isGroup: boolean;
};
const NavigationMenuItemFolderReadOnlyContent = ({
folderId,
folderName,
folderIconKey,
folderColor,
navigationMenuItems,
isGroup,
}: NavigationMenuItemFolderReadOnlyContentProps) => {
const { getIcon } = useIcons();
const isMobile = useIsMobile();
const FolderIcon = getIcon(folderIconKey ?? FOLDER_ICON_DEFAULT);
const { isOpen, handleToggle, selectedNavigationMenuItemIndex } =
useNavigationMenuItemFolderOpenState({ folderId, navigationMenuItems });
return (
<NavigationMenuItemFolderLayout
header={
<NavigationDrawerItem
label={folderName}
Icon={FolderIcon}
iconColor={
isDefined(folderColor)
? folderColor
: DEFAULT_NAVIGATION_MENU_ITEM_COLOR_FOLDER
}
active={!isOpen && selectedNavigationMenuItemIndex >= 0}
onClick={handleToggle}
className="navigation-drawer-item"
triggerEvent="CLICK"
preventCollapseOnMobile={isMobile}
alwaysShowRightOptions
rightOptions={
isOpen ? (
<IconChevronDown
size={themeCssVariables.icon.size.sm}
stroke={themeCssVariables.icon.stroke.sm}
color={themeCssVariables.font.color.tertiary}
/>
) : (
<IconChevronRight
size={themeCssVariables.icon.size.sm}
stroke={themeCssVariables.icon.stroke.sm}
color={themeCssVariables.font.color.tertiary}
/>
)
}
/>
}
isOpen={isOpen}
isGroup={isGroup}
>
{navigationMenuItems.map((navigationMenuItem, index) => (
<NavigationMenuItemFolderSubItem
key={navigationMenuItem.id}
navigationMenuItem={navigationMenuItem}
index={index}
arrayLength={navigationMenuItems.length}
selectedNavigationMenuItemIndex={selectedNavigationMenuItemIndex}
isDragging={false}
/>
))}
</NavigationMenuItemFolderLayout>
);
};
@@ -2,9 +2,10 @@ import { IconHeartOff } from 'twenty-ui/display';
import { LightIconButton } from 'twenty-ui/input';
import { type NavigationMenuItem } from '~/generated-metadata/graphql';
import { NavigationMenuItemDroppableIds } from '@/navigation-menu-item/common/constants/NavigationMenuItemDroppableIds';
import { NavigationMenuItemBackButton } from '@/navigation-menu-item/edit/components/NavigationMenuItemBackButton';
import { NavigationMenuItemIcon } from '@/navigation-menu-item/display/components/NavigationMenuItemIcon';
import { WorkspaceDndKitSortableItem } from '@/navigation-menu-item/display/dnd/components/WorkspaceDndKitSortableItem';
import { NavigationMenuItemSortableItem } from '@/navigation-menu-item/display/dnd/components/NavigationMenuItemSortableItem';
import { useDeleteNavigationMenuItem } from '@/navigation-menu-item/common/hooks/useDeleteNavigationMenuItem';
import { getNavigationMenuItemComputedLink } from '@/navigation-menu-item/display/utils/getNavigationMenuItemComputedLink';
import { getNavigationMenuItemLabel } from '@/navigation-menu-item/display/utils/getNavigationMenuItemLabel';
@@ -30,7 +31,7 @@ export const NavigationMenuItemFolderContent = ({
const views = useAtomStateValue(viewsSelector);
const { deleteNavigationMenuItem } = useDeleteNavigationMenuItem();
const folderDroppableId = `folder-${folderId}`;
const folderDroppableId = `${NavigationMenuItemDroppableIds.FAVORITE_FOLDER_PREFIX}${folderId}`;
return (
<>
@@ -53,7 +54,7 @@ export const NavigationMenuItemFolderContent = ({
);
return (
<WorkspaceDndKitSortableItem
<NavigationMenuItemSortableItem
key={navigationMenuItem.id}
id={navigationMenuItem.id}
index={index}
@@ -83,7 +84,7 @@ export const NavigationMenuItemFolderContent = ({
triggerEvent="CLICK"
to={computedLink}
/>
</WorkspaceDndKitSortableItem>
</NavigationMenuItemSortableItem>
);
})}
</>
@@ -1,71 +0,0 @@
import { lazy, Suspense } from 'react';
import { NavigationMenuItemFolderReadOnly } from '@/navigation-menu-item/display/folder/components/NavigationMenuItemFolderReadOnly';
import type { WorkspaceSectionItemContentProps } from '@/navigation-menu-item/display/sections/types/WorkspaceSectionItemContentProps';
const LazyWorkspaceNavigationMenuItemsFolder = lazy(() =>
import(
'@/navigation-menu-item/display/folder/components/WorkspaceNavigationMenuItemsFolder'
).then((m) => ({ default: m.WorkspaceNavigationMenuItemsFolder })),
);
type NavigationMenuItemFolderDisplayProps = WorkspaceSectionItemContentProps;
export const NavigationMenuItemFolderDisplay = ({
item,
editModeProps,
isDragging,
folderChildrenById,
folderCount,
selectedNavigationMenuItemId,
onNavigationMenuItemClick,
readOnly = false,
}: NavigationMenuItemFolderDisplayProps) => {
const folderId = item.id;
const folderName = item.name ?? 'Folder';
const folderIconKey = item.icon;
const folderColor = 'color' in item ? item.color : undefined;
const navigationMenuItems = folderChildrenById.get(item.id) ?? [];
const isGroup = folderCount > 1;
if (readOnly) {
return (
<NavigationMenuItemFolderReadOnly
folderId={folderId}
folderName={folderName}
folderIconKey={folderIconKey}
folderColor={folderColor}
navigationMenuItems={navigationMenuItems}
isGroup={isGroup}
/>
);
}
return (
<Suspense
fallback={
<NavigationMenuItemFolderReadOnly
folderId={folderId}
folderName={folderName}
folderIconKey={folderIconKey}
folderColor={folderColor}
navigationMenuItems={navigationMenuItems}
isGroup={isGroup}
/>
}
>
<LazyWorkspaceNavigationMenuItemsFolder
folderId={folderId}
folderName={folderName}
folderIconKey={folderIconKey}
folderColor={folderColor}
navigationMenuItems={navigationMenuItems}
isGroup={isGroup}
isSelectedInEditMode={editModeProps.isSelectedInEditMode}
onEditModeClick={editModeProps.onEditModeClick}
onNavigationMenuItemClick={onNavigationMenuItemClick}
selectedNavigationMenuItemId={selectedNavigationMenuItemId}
isDragging={isDragging}
/>
</Suspense>
);
};
@@ -0,0 +1,404 @@
import { styled } from '@linaria/react';
import { useLingui } from '@lingui/react/macro';
import React, { Fragment, useCallback, useContext } from 'react';
import { createPortal } from 'react-dom';
import { SidePanelPages } from 'twenty-shared/types';
import { isDefined } from 'twenty-shared/utils';
import {
IconChevronDown,
IconChevronRight,
IconFolder,
IconHeartOff,
IconPlus,
useIcons,
} from 'twenty-ui/display';
import { LightIconButton } from 'twenty-ui/input';
import { ThemeContext, themeCssVariables } from 'twenty-ui/theme-constants';
import { useIsMobile } from 'twenty-ui/utilities';
import { type NavigationMenuItem } from '~/generated-metadata/graphql';
import { FOLDER_ICON_DEFAULT } from '@/navigation-menu-item/common/constants/FolderIconDefault';
import { DEFAULT_NAVIGATION_MENU_ITEM_COLOR_FOLDER } from '@/navigation-menu-item/common/constants/NavigationMenuItemDefaultColorFolder';
import { NAVIGATION_MENU_ITEM_SECTION_DROPPABLE_CONFIG } from '@/navigation-menu-item/common/constants/NavigationMenuItemSectionDroppableConfig';
import { NavigationSections } from '@/navigation-menu-item/common/constants/NavigationSections.constants';
import { NavigationDropTargetContext } from '@/navigation-menu-item/common/contexts/NavigationDropTargetContext';
import { NavigationMenuItemDragContext } from '@/navigation-menu-item/common/contexts/NavigationMenuItemDragContext';
import { SortableDropTargetRefContext } from '@/navigation-menu-item/common/contexts/SortableDropTargetRefContext';
import { useDeleteNavigationMenuItem } from '@/navigation-menu-item/common/hooks/useDeleteNavigationMenuItem';
import { addMenuItemInsertionContextState } from '@/navigation-menu-item/common/states/addMenuItemInsertionContextState';
import { isLayoutCustomizationModeEnabledState } from '@/layout-customization/states/isLayoutCustomizationModeEnabledState';
import type { NavigationMenuItemSection } from '@/navigation-menu-item/common/types/NavigationMenuItemSection';
import { getDndKitDropTargetId } from '@/navigation-menu-item/common/utils/getDndKitDropTargetId';
import { NavigationItemDropTarget } from '@/navigation-menu-item/display/dnd/components/NavigationItemDropTarget';
import {
FOLDER_HEADER_SLOT_COLLISION_PRIORITY,
NavigationMenuItemDroppableSlot,
} from '@/navigation-menu-item/display/dnd/components/NavigationMenuItemDroppableSlot';
import { NavigationMenuItemSortableItem } from '@/navigation-menu-item/display/dnd/components/NavigationMenuItemSortableItem';
import { useIsDropDisabledForSection } from '@/navigation-menu-item/display/dnd/hooks/useIsDropDisabledForSection';
import { NavigationMenuItemFolderLayout } from '@/navigation-menu-item/display/folder/components/NavigationMenuItemFolderLayout';
import { NavigationMenuItemFolderNavigationDrawerItemDropdown } from '@/navigation-menu-item/display/folder/components/NavigationMenuItemFolderNavigationDrawerItemDropdown';
import { NavigationMenuItemFolderSubItem } from '@/navigation-menu-item/display/folder/components/NavigationMenuItemFolderSubItem';
import { useNavigationMenuItemFolderOpenState } from '@/navigation-menu-item/display/folder/hooks/useNavigationMenuItemFolderOpenState';
import type { NavigationMenuItemClickParams } from '@/navigation-menu-item/display/hooks/useNavigationMenuItemSectionItems';
import { useOpenAddItemToFolderPage } from '@/navigation-menu-item/edit/hooks/useOpenAddItemToFolderPage';
import { useFavoritesFolderEdit } from '@/navigation-menu-item/edit/folder/hooks/useFavoritesFolderEdit';
import type { EditModeProps } from '@/object-metadata/components/EditModeProps';
import { sidePanelPageState } from '@/side-panel/states/sidePanelPageState';
import { ConfirmationModal } from '@/ui/layout/modal/components/ConfirmationModal';
import { NavigationDrawerInput } from '@/ui/navigation/navigation-drawer/components/NavigationDrawerInput';
import { NavigationDrawerItem } from '@/ui/navigation/navigation-drawer/components/NavigationDrawerItem';
import { NavigationDrawerSubItem } from '@/ui/navigation/navigation-drawer/components/NavigationDrawerSubItem';
import { getNavigationSubItemLeftAdornment } from '@/ui/navigation/navigation-drawer/utils/getNavigationSubItemLeftAdornment';
import { useAtomStateValue } from '@/ui/utilities/state/jotai/hooks/useAtomStateValue';
const StyledFolderContainer = styled.div<{
$isSelectedInEditMode: boolean;
}>`
border: ${({ $isSelectedInEditMode }) =>
$isSelectedInEditMode
? `1px solid ${themeCssVariables.color.blue}`
: '1px solid transparent'};
border-radius: ${themeCssVariables.border.radius.sm};
transition: background-color 150ms ease-in-out;
&[data-drag-over-header='true'] {
background-color: ${themeCssVariables.background.transparent.blue};
}
&[data-forbidden-drop-target='true'] {
background-color: ${themeCssVariables.background.transparent.danger};
}
`;
const StyledFolderDroppableContent = styled.div`
display: flex;
flex-direction: column;
`;
type NavigationMenuItemFolderDndProps = {
folderId: string;
folderName: string;
folderIconKey?: string | null;
folderColor?: string | null;
navigationMenuItems: NavigationMenuItem[];
isGroup: boolean;
isEditInPlace: boolean;
editModeProps?: EditModeProps;
isDragging: boolean;
selectedNavigationMenuItemId?: string | null;
onNavigationMenuItemClick?: (params: NavigationMenuItemClickParams) => void;
};
export const NavigationMenuItemFolderDnd = ({
folderId,
folderName,
folderIconKey,
folderColor,
navigationMenuItems,
isGroup,
isEditInPlace,
editModeProps,
isDragging: isDraggingProp,
selectedNavigationMenuItemId,
onNavigationMenuItemClick,
}: NavigationMenuItemFolderDndProps) => {
const { t } = useLingui();
const { theme } = useContext(ThemeContext);
const { getIcon } = useIcons();
const isMobile = useIsMobile();
const section: NavigationMenuItemSection = isEditInPlace
? 'favorite'
: 'workspace';
const isWorkspace = !isEditInPlace;
const sectionId = isEditInPlace
? NavigationSections.FAVORITES
: NavigationSections.WORKSPACE;
const { isOpen, handleToggle, selectedNavigationMenuItemIndex } =
useNavigationMenuItemFolderOpenState({ folderId, navigationMenuItems });
const { isDragging: isContextDragging } = useContext(
NavigationMenuItemDragContext,
);
const isDragging = isDraggingProp || isContextDragging;
const setSortableDropTargetRef = useContext(SortableDropTargetRefContext);
const dropDisabled = useIsDropDisabledForSection(isWorkspace);
const { activeDropTargetId, forbiddenDropTargetId } = useContext(
NavigationDropTargetContext,
);
const { deleteNavigationMenuItem } = useDeleteNavigationMenuItem();
const favoritesEdit = useFavoritesFolderEdit({
folderId,
folderName,
navigationMenuItems,
});
const isLayoutCustomizationModeEnabled = useAtomStateValue(
isLayoutCustomizationModeEnabledState,
);
const { openAddItemToFolderPage } = useOpenAddItemToFolderPage();
const sidePanelPage = useAtomStateValue(sidePanelPageState);
const addMenuItemInsertionContext = useAtomStateValue(
addMenuItemInsertionContextState,
);
const config = NAVIGATION_MENU_ITEM_SECTION_DROPPABLE_CONFIG[section];
const folderHeaderDroppableId = `${config.folderHeaderPrefix}${folderId}`;
const folderContentDroppableId = `${config.folderPrefix}${folderId}`;
const folderHeaderSlotId = getDndKitDropTargetId(folderHeaderDroppableId, 0);
const isForbiddenDropTarget =
isDefined(forbiddenDropTargetId) &&
(forbiddenDropTargetId.startsWith(`${folderContentDroppableId}::`) ||
forbiddenDropTargetId.startsWith(`${folderHeaderDroppableId}::`));
const isDragOverFolderHeader =
!isForbiddenDropTarget && activeDropTargetId === folderHeaderSlotId;
const FolderIcon = getIcon(folderIconKey ?? FOLDER_ICON_DEFAULT);
const iconColor = isDefined(folderColor)
? folderColor
: DEFAULT_NAVIGATION_MENU_ITEM_COLOR_FOLDER;
const isSelectedInEditMode = editModeProps?.isSelectedInEditMode ?? false;
const shouldUseEditModeClick =
isWorkspace &&
isLayoutCustomizationModeEnabled &&
isDefined(editModeProps?.onEditModeClick);
const handleHeaderClick = shouldUseEditModeClick
? (event?: React.MouseEvent) => {
event?.stopPropagation();
if (isSelectedInEditMode) {
handleToggle();
} else {
editModeProps?.onEditModeClick?.();
}
}
: handleToggle;
const headerRightOptions = isEditInPlace ? (
<NavigationMenuItemFolderNavigationDrawerItemDropdown
folderId={folderId}
onRename={() => favoritesEdit.setIsRenaming(true)}
onDelete={favoritesEdit.handleFolderDelete}
closeDropdown={favoritesEdit.closeDropdown}
/>
) : (
<div
onClick={(event) => {
event.stopPropagation();
handleToggle();
}}
>
{isOpen ? (
<IconChevronDown
size={theme.icon.size.sm}
stroke={theme.icon.stroke.sm}
color={theme.font.color.tertiary}
/>
) : (
<IconChevronRight
size={theme.icon.size.sm}
stroke={theme.icon.stroke.sm}
color={theme.font.color.tertiary}
/>
)}
</div>
);
const headerOverride =
isEditInPlace && favoritesEdit.isRenaming ? (
<NavigationDrawerInput
Icon={IconFolder}
value={favoritesEdit.folderNameValue}
onChange={favoritesEdit.setFolderNameValue}
onSubmit={favoritesEdit.handleSubmitRename}
onCancel={favoritesEdit.handleCancelRename}
onClickOutside={favoritesEdit.handleClickOutsideRename}
/>
) : undefined;
const header = headerOverride ?? (
<NavigationDrawerItem
label={folderName}
Icon={FolderIcon}
iconColor={iconColor}
active={
(!isOpen && selectedNavigationMenuItemIndex >= 0) ||
(isWorkspace && isSelectedInEditMode && !isOpen)
}
onClick={handleHeaderClick}
rightOptions={headerRightOptions}
className="navigation-drawer-item"
isRightOptionsDropdownOpen={
isEditInPlace ? favoritesEdit.isDropdownOpen : undefined
}
triggerEvent="CLICK"
preventCollapseOnMobile={isMobile}
isDragging={isDragging}
alwaysShowRightOptions
/>
);
const wrappedHeader = (
<div ref={setSortableDropTargetRef ?? undefined}>
<NavigationMenuItemDroppableSlot
droppableId={folderHeaderDroppableId}
index={0}
disabled={dropDisabled}
collisionPriority={FOLDER_HEADER_SLOT_COLLISION_PRIORITY}
>
{header}
</NavigationMenuItemDroppableSlot>
</div>
);
const isCompact = isWorkspace
? isLayoutCustomizationModeEnabled || navigationMenuItems.length === 0
: true;
const folderContentLength =
isWorkspace && isLayoutCustomizationModeEnabled
? navigationMenuItems.length + 1
: navigationMenuItems.length;
const handleAddMenuItemToFolder = useCallback(() => {
openAddItemToFolderPage({
targetFolderId: folderId,
targetIndex: navigationMenuItems.length,
resetNavigationStack: true,
});
}, [folderId, navigationMenuItems.length, openAddItemToFolderPage]);
const deleteModal =
isEditInPlace && favoritesEdit.isModalOpened
? createPortal(
<ConfirmationModal
modalInstanceId={favoritesEdit.modalId}
title={
favoritesEdit.navigationMenuItemCount > 1
? t`Remove ${favoritesEdit.navigationMenuItemCount} navigation menu items?`
: t`Remove ${favoritesEdit.navigationMenuItemCount} navigation menu item?`
}
subtitle={
favoritesEdit.navigationMenuItemCount > 1
? t`This action will delete this folder and all ${favoritesEdit.navigationMenuItemCount} navigation menu items inside. Do you want to continue?`
: t`This action will delete this folder and the navigation menu item inside. Do you want to continue?`
}
onConfirmClick={favoritesEdit.handleConfirmDelete}
confirmButtonText={t`Delete Folder`}
/>,
document.body,
)
: null;
return (
<>
<StyledFolderContainer
$isSelectedInEditMode={isWorkspace && isSelectedInEditMode}
data-drag-over-header={isDragOverFolderHeader ? 'true' : undefined}
data-forbidden-drop-target={isForbiddenDropTarget ? 'true' : undefined}
>
<NavigationMenuItemFolderLayout
header={wrappedHeader}
isOpen={isOpen}
isGroup={isGroup}
>
<StyledFolderDroppableContent>
{navigationMenuItems.map((navigationMenuItem, index) => (
<Fragment key={navigationMenuItem.id}>
<NavigationItemDropTarget
folderId={folderId}
index={index}
sectionId={sectionId}
compact={isCompact}
dropTargetIdOverride={getDndKitDropTargetId(
folderContentDroppableId,
index,
)}
/>
<NavigationMenuItemSortableItem
id={navigationMenuItem.id}
index={index}
group={folderContentDroppableId}
disabled={
isWorkspace
? !isLayoutCustomizationModeEnabled || dropDisabled
: dropDisabled
}
>
<NavigationMenuItemFolderSubItem
navigationMenuItem={navigationMenuItem}
index={index}
arrayLength={folderContentLength}
selectedNavigationMenuItemIndex={
selectedNavigationMenuItemIndex
}
isDragging={isDragging}
rightOptions={
isEditInPlace ? (
<LightIconButton
Icon={IconHeartOff}
onClick={(event) => {
event.stopPropagation();
deleteNavigationMenuItem(navigationMenuItem.id);
}}
accent="tertiary"
/>
) : undefined
}
onNavigationMenuItemClick={
isWorkspace ? onNavigationMenuItemClick : undefined
}
selectedNavigationMenuItemId={
isWorkspace ? selectedNavigationMenuItemId : undefined
}
/>
</NavigationMenuItemSortableItem>
</Fragment>
))}
<NavigationMenuItemDroppableSlot
droppableId={folderContentDroppableId}
index={navigationMenuItems.length}
disabled={dropDisabled}
>
<NavigationItemDropTarget
folderId={folderId}
index={navigationMenuItems.length}
sectionId={sectionId}
compact={!(isWorkspace && isLayoutCustomizationModeEnabled)}
dropTargetIdOverride={getDndKitDropTargetId(
folderContentDroppableId,
navigationMenuItems.length,
)}
/>
{isWorkspace && isLayoutCustomizationModeEnabled && (
<NavigationDrawerSubItem
label={t`Add menu item`}
Icon={IconPlus}
onClick={handleAddMenuItemToFolder}
triggerEvent="CLICK"
variant="tertiary"
isSelectedInEditMode={
sidePanelPage === SidePanelPages.NavigationMenuAddItem &&
addMenuItemInsertionContext?.targetFolderId === folderId
}
subItemState={getNavigationSubItemLeftAdornment({
index: navigationMenuItems.length,
arrayLength: folderContentLength,
selectedIndex: selectedNavigationMenuItemIndex,
})}
/>
)}
</NavigationMenuItemDroppableSlot>
</StyledFolderDroppableContent>
</NavigationMenuItemFolderLayout>
</StyledFolderContainer>
{deleteModal}
</>
);
};
@@ -0,0 +1,44 @@
import { styled } from '@linaria/react';
import { useState, type ReactNode } from 'react';
import { AnimatedExpandableContainer } from 'twenty-ui/layout';
import { NavigationDrawerItemsCollapsableContainer } from '@/ui/navigation/navigation-drawer/components/NavigationDrawerItemsCollapsableContainer';
const StyledFolderExpandableWrapper = styled.div`
& > div {
overflow: visible !important;
}
`;
type NavigationMenuItemFolderLayoutProps = {
header: ReactNode;
isOpen: boolean;
isGroup: boolean;
children: ReactNode;
};
export const NavigationMenuItemFolderLayout = ({
header,
isOpen,
isGroup,
children,
}: NavigationMenuItemFolderLayoutProps) => {
const [skipInitialExpandAnimation] = useState(() => isOpen);
return (
<NavigationDrawerItemsCollapsableContainer isGroup={isGroup}>
{header}
<StyledFolderExpandableWrapper>
<AnimatedExpandableContainer
isExpanded={isOpen}
dimension="height"
mode="fit-content"
containAnimation
initial={!skipInitialExpandAnimation}
>
{children}
</AnimatedExpandableContainer>
</StyledFolderExpandableWrapper>
</NavigationDrawerItemsCollapsableContainer>
);
};
@@ -1,120 +0,0 @@
import { styled } from '@linaria/react';
import { IconChevronDown, IconChevronRight, useIcons } from 'twenty-ui/display';
import { AnimatedExpandableContainer } from 'twenty-ui/layout';
import { themeCssVariables } from 'twenty-ui/theme-constants';
import { useIsMobile } from 'twenty-ui/utilities';
import { useState } from 'react';
import { isDefined } from 'twenty-shared/utils';
import { type NavigationMenuItem } from '~/generated-metadata/graphql';
import { WorkspaceNavigationMenuItemFolderSubItem } from '@/navigation-menu-item/display/folder/components/WorkspaceNavigationMenuItemFolderSubItem';
import { FOLDER_ICON_DEFAULT } from '@/navigation-menu-item/common/constants/FolderIconDefault';
import { DEFAULT_NAVIGATION_MENU_ITEM_COLOR_FOLDER } from '@/navigation-menu-item/common/constants/NavigationMenuItemDefaultColorFolder';
import { useWorkspaceFolderOpenState } from '@/navigation-menu-item/display/folder/hooks/useWorkspaceFolderOpenState';
import { NavigationDrawerItem } from '@/ui/navigation/navigation-drawer/components/NavigationDrawerItem';
import { NavigationDrawerItemsCollapsableContainer } from '@/ui/navigation/navigation-drawer/components/NavigationDrawerItemsCollapsableContainer';
const StyledFolderContainer = styled.div`
border: 1px solid transparent;
border-radius: ${themeCssVariables.border.radius.sm};
`;
const StyledFolderContent = styled.div`
display: flex;
flex-direction: column;
`;
const StyledFolderExpandableWrapper = styled.div`
& > div {
overflow: visible !important;
}
`;
type NavigationMenuItemFolderReadOnlyProps = {
folderId: string;
folderName: string;
folderIconKey?: string | null;
folderColor?: string | null;
navigationMenuItems: NavigationMenuItem[];
isGroup: boolean;
};
export const NavigationMenuItemFolderReadOnly = ({
folderId,
folderName,
folderIconKey,
folderColor,
navigationMenuItems,
isGroup,
}: NavigationMenuItemFolderReadOnlyProps) => {
const { getIcon } = useIcons();
const FolderIcon = getIcon(folderIconKey ?? FOLDER_ICON_DEFAULT);
const isMobile = useIsMobile();
const { isOpen, handleToggle, selectedNavigationMenuItemIndex } =
useWorkspaceFolderOpenState({ folderId, navigationMenuItems });
const [skipInitialExpandAnimation] = useState(() => isOpen);
return (
<StyledFolderContainer>
<NavigationDrawerItemsCollapsableContainer isGroup={isGroup}>
<NavigationDrawerItem
label={folderName}
Icon={FolderIcon}
iconColor={
isDefined(folderColor)
? folderColor
: DEFAULT_NAVIGATION_MENU_ITEM_COLOR_FOLDER
}
active={!isOpen && selectedNavigationMenuItemIndex >= 0}
onClick={handleToggle}
className="navigation-drawer-item"
triggerEvent="CLICK"
preventCollapseOnMobile={isMobile}
alwaysShowRightOptions
rightOptions={
isOpen ? (
<IconChevronDown
size={themeCssVariables.icon.size.sm}
stroke={themeCssVariables.icon.stroke.sm}
color={themeCssVariables.font.color.tertiary}
/>
) : (
<IconChevronRight
size={themeCssVariables.icon.size.sm}
stroke={themeCssVariables.icon.stroke.sm}
color={themeCssVariables.font.color.tertiary}
/>
)
}
/>
<StyledFolderExpandableWrapper>
<AnimatedExpandableContainer
isExpanded={isOpen}
dimension="height"
mode="fit-content"
containAnimation
initial={!skipInitialExpandAnimation}
>
<StyledFolderContent>
{navigationMenuItems.map((navigationMenuItem, index) => (
<WorkspaceNavigationMenuItemFolderSubItem
key={navigationMenuItem.id}
navigationMenuItem={navigationMenuItem}
index={index}
arrayLength={navigationMenuItems.length}
selectedNavigationMenuItemIndex={
selectedNavigationMenuItemIndex
}
onNavigationMenuItemClick={undefined}
selectedNavigationMenuItemId={null}
isContextDragging={false}
/>
))}
</StyledFolderContent>
</AnimatedExpandableContainer>
</StyledFolderExpandableWrapper>
</NavigationDrawerItemsCollapsableContainer>
</StyledFolderContainer>
);
};
@@ -1,15 +1,16 @@
import { type ReactNode } from 'react';
import { NavigationMenuItemType } from 'twenty-shared/types';
import { isDefined } from 'twenty-shared/utils';
import { type NavigationMenuItem } from '~/generated-metadata/graphql';
import { isLayoutCustomizationModeEnabledState } from '@/layout-customization/states/isLayoutCustomizationModeEnabledState';
import { getEffectiveNavigationMenuItemColor } from '@/navigation-menu-item/common/utils/getEffectiveNavigationMenuItemColor';
import { NavigationMenuItemIcon } from '@/navigation-menu-item/display/components/NavigationMenuItemIcon';
import { type NavigationMenuItemClickParams } from '@/navigation-menu-item/display/hooks/useWorkspaceSectionItems';
import { getNavigationMenuItemComputedLink } from '@/navigation-menu-item/display/utils/getNavigationMenuItemComputedLink';
import { getNavigationMenuItemLabel } from '@/navigation-menu-item/display/utils/getNavigationMenuItemLabel';
import type { ObjectMetadataItem } from '@/object-metadata/types/ObjectMetadataItem';
import { getObjectMetadataForNavigationMenuItem } from '@/navigation-menu-item/display/object/utils/getObjectMetadataForNavigationMenuItem';
import { getNavigationMenuItemObjectNameSingular } from '@/navigation-menu-item/display/object/utils/getNavigationMenuItemObjectNameSingular';
import { getObjectNavigationMenuItemSecondaryLabel } from '@/navigation-menu-item/display/object/utils/getObjectNavigationMenuItemSecondaryLabel';
import { getObjectMetadataForNavigationMenuItem } from '@/navigation-menu-item/display/object/utils/getObjectMetadataForNavigationMenuItem';
import { getNavigationMenuItemComputedLink } from '@/navigation-menu-item/display/utils/getNavigationMenuItemComputedLink';
import { getNavigationMenuItemLabel } from '@/navigation-menu-item/display/utils/getNavigationMenuItemLabel';
import { objectMetadataItemsSelector } from '@/object-metadata/states/objectMetadataItemsSelector';
import { NavigationDrawerSubItem } from '@/ui/navigation/navigation-drawer/components/NavigationDrawerSubItem';
import { getNavigationSubItemLeftAdornment } from '@/ui/navigation/navigation-drawer/utils/getNavigationSubItemLeftAdornment';
@@ -17,57 +18,35 @@ import { useAtomStateValue } from '@/ui/utilities/state/jotai/hooks/useAtomState
import { viewsSelector } from '@/views/states/selectors/viewsSelector';
import { ViewKey } from '@/views/types/ViewKey';
type WorkspaceNavigationMenuItemFolderSubItemProps = {
type NavigationMenuItemFolderSubItemProps = {
navigationMenuItem: NavigationMenuItem;
index: number;
arrayLength: number;
selectedNavigationMenuItemIndex: number;
onNavigationMenuItemClick?: (params: NavigationMenuItemClickParams) => void;
selectedNavigationMenuItemId: string | null;
isContextDragging: boolean;
isDragging: boolean;
rightOptions?: ReactNode;
onClick?: () => void;
onNavigationMenuItemClick?: (params: {
item: NavigationMenuItem;
objectMetadataItem?: ObjectMetadataItem;
}) => void;
selectedNavigationMenuItemId?: string | null;
};
export const WorkspaceNavigationMenuItemFolderSubItem = ({
export const NavigationMenuItemFolderSubItem = ({
navigationMenuItem,
index,
arrayLength,
selectedNavigationMenuItemIndex,
isDragging,
rightOptions,
onClick,
onNavigationMenuItemClick,
selectedNavigationMenuItemId,
isContextDragging,
}: WorkspaceNavigationMenuItemFolderSubItemProps) => {
const isLayoutCustomizationModeEnabled = useAtomStateValue(
isLayoutCustomizationModeEnabledState,
);
}: NavigationMenuItemFolderSubItemProps) => {
const objectMetadataItems = useAtomStateValue(objectMetadataItemsSelector);
const views = useAtomStateValue(viewsSelector);
const objectMetadataItem =
navigationMenuItem.type === NavigationMenuItemType.OBJECT ||
navigationMenuItem.type === NavigationMenuItemType.VIEW ||
navigationMenuItem.type === NavigationMenuItemType.RECORD
? getObjectMetadataForNavigationMenuItem(
navigationMenuItem,
objectMetadataItems,
views,
)
: null;
const isEditableInEditMode =
isLayoutCustomizationModeEnabled &&
isDefined(onNavigationMenuItemClick) &&
(navigationMenuItem.type === NavigationMenuItemType.LINK ||
isDefined(objectMetadataItem));
const handleEditModeClick =
isEditableInEditMode && isDefined(onNavigationMenuItemClick)
? () =>
onNavigationMenuItemClick({
item: navigationMenuItem,
objectMetadataItem: objectMetadataItem ?? undefined,
})
: undefined;
const label = getNavigationMenuItemLabel(
navigationMenuItem,
objectMetadataItems,
@@ -85,10 +64,36 @@ export const WorkspaceNavigationMenuItemFolderSubItem = ({
);
const view = isDefined(navigationMenuItem.viewId)
? views.find((view) => view.id === navigationMenuItem.viewId)
? views.find((viewItem) => viewItem.id === navigationMenuItem.viewId)
: undefined;
const isIndexView = view?.key === ViewKey.INDEX;
const objectMetadataItem =
navigationMenuItem.type === NavigationMenuItemType.OBJECT ||
navigationMenuItem.type === NavigationMenuItemType.VIEW ||
navigationMenuItem.type === NavigationMenuItemType.RECORD
? getObjectMetadataForNavigationMenuItem(
navigationMenuItem,
objectMetadataItems,
views,
)
: null;
const isEditable =
isDefined(onNavigationMenuItemClick) &&
(navigationMenuItem.type === NavigationMenuItemType.LINK ||
isDefined(objectMetadataItem));
const handleClick =
onClick ??
(isEditable
? () =>
onNavigationMenuItemClick({
item: navigationMenuItem,
objectMetadataItem: objectMetadataItem ?? undefined,
})
: undefined);
return (
<NavigationDrawerSubItem
secondaryLabel={
@@ -103,8 +108,9 @@ export const WorkspaceNavigationMenuItemFolderSubItem = ({
Icon={() => (
<NavigationMenuItemIcon navigationMenuItem={navigationMenuItem} />
)}
to={isContextDragging || handleEditModeClick ? undefined : computedLink}
onClick={handleEditModeClick}
iconColor={getEffectiveNavigationMenuItemColor(navigationMenuItem)}
to={isDragging || handleClick ? undefined : computedLink}
onClick={handleClick}
active={index === selectedNavigationMenuItemIndex}
isSelectedInEditMode={
selectedNavigationMenuItemId === navigationMenuItem.id
@@ -114,7 +120,8 @@ export const WorkspaceNavigationMenuItemFolderSubItem = ({
arrayLength,
selectedIndex: selectedNavigationMenuItemIndex,
})}
isDragging={isContextDragging}
rightOptions={rightOptions}
isDragging={isDragging}
triggerEvent="CLICK"
/>
);
@@ -1,81 +0,0 @@
import { useState } from 'react';
import { IconFolder } from 'twenty-ui/display';
import { FavoritesFolderItem } from '@/navigation-menu-item/display/sections/favorites/components/FavoritesFolderItem';
import { useCreateNavigationMenuItemFolder } from '@/navigation-menu-item/display/folder/hooks/useCreateNavigationMenuItemFolder';
import { useNavigationMenuItemsByFolder } from '@/navigation-menu-item/display/folder/hooks/useNavigationMenuItemsByFolder';
import { isNavigationMenuItemFolderCreatingState } from '@/navigation-menu-item/common/states/isNavigationMenuItemFolderCreatingState';
import { useAtomState } from '@/ui/utilities/state/jotai/hooks/useAtomState';
import { NavigationDrawerAnimatedCollapseWrapper } from '@/ui/navigation/navigation-drawer/components/NavigationDrawerAnimatedCollapseWrapper';
import { NavigationDrawerInput } from '@/ui/navigation/navigation-drawer/components/NavigationDrawerInput';
export const NavigationMenuItemFolders = () => {
const [newFolderName, setNewFolderName] = useState('');
const { userNavigationMenuItemsByFolder } = useNavigationMenuItemsByFolder();
const { createNewNavigationMenuItemFolder } =
useCreateNavigationMenuItemFolder();
const [
isNavigationMenuItemFolderCreating,
setIsNavigationMenuItemFolderCreating,
] = useAtomState(isNavigationMenuItemFolderCreatingState);
const handleNavigationMenuItemFolderNameChange = (value: string) => {
setNewFolderName(value);
};
const handleSubmitNavigationMenuItemFolderCreation = async (
value: string,
) => {
if (value === '') return;
setIsNavigationMenuItemFolderCreating(false);
setNewFolderName('');
await createNewNavigationMenuItemFolder(value);
return true;
};
const handleClickOutside = async (
_event: MouseEvent | TouchEvent,
value: string,
) => {
if (!value) {
setIsNavigationMenuItemFolderCreating(false);
return;
}
setIsNavigationMenuItemFolderCreating(false);
setNewFolderName('');
await createNewNavigationMenuItemFolder(value);
};
const handleCancelNavigationMenuItemFolderCreation = () => {
setNewFolderName('');
setIsNavigationMenuItemFolderCreating(false);
};
return (
<>
{isNavigationMenuItemFolderCreating && (
<NavigationDrawerAnimatedCollapseWrapper>
<NavigationDrawerInput
Icon={IconFolder}
value={newFolderName}
onChange={handleNavigationMenuItemFolderNameChange}
onSubmit={handleSubmitNavigationMenuItemFolderCreation}
onCancel={handleCancelNavigationMenuItemFolderCreation}
onClickOutside={handleClickOutside}
/>
</NavigationDrawerAnimatedCollapseWrapper>
)}
{userNavigationMenuItemsByFolder.map((folder) => (
<FavoritesFolderItem
key={folder.id}
folder={folder}
isGroup={userNavigationMenuItemsByFolder.length > 1}
/>
))}
</>
);
};
@@ -1,309 +0,0 @@
import { styled } from '@linaria/react';
import { useLingui } from '@lingui/react/macro';
import React, { useContext, useState } from 'react';
import { SidePanelPages } from 'twenty-shared/types';
import { isDefined } from 'twenty-shared/utils';
import {
IconChevronDown,
IconChevronRight,
IconPlus,
useIcons,
} from 'twenty-ui/display';
import { ThemeContext, themeCssVariables } from 'twenty-ui/theme-constants';
import { useIsDropDisabledForSection } from '@/navigation-menu-item/display/dnd/hooks/useIsDropDisabledForSection';
import { useOpenAddItemToFolderPage } from '@/navigation-menu-item/edit/hooks/useOpenAddItemToFolderPage';
import { useWorkspaceFolderOpenState } from '@/navigation-menu-item/display/folder/hooks/useWorkspaceFolderOpenState';
import { addMenuItemInsertionContextState } from '@/navigation-menu-item/common/states/addMenuItemInsertionContextState';
import { sidePanelPageState } from '@/side-panel/states/sidePanelPageState';
import { AnimatedExpandableContainer } from 'twenty-ui/layout';
import { useIsMobile } from 'twenty-ui/utilities';
import { NavigationItemDropTarget } from '@/navigation-menu-item/display/dnd/components/NavigationItemDropTarget';
import {
FOLDER_HEADER_SLOT_COLLISION_PRIORITY,
WorkspaceDndKitDroppableSlot,
} from '@/navigation-menu-item/display/dnd/components/WorkspaceDndKitDroppableSlot';
import { WorkspaceDndKitSortableItem } from '@/navigation-menu-item/display/dnd/components/WorkspaceDndKitSortableItem';
import { WorkspaceNavigationMenuItemFolderSubItem } from '@/navigation-menu-item/display/folder/components/WorkspaceNavigationMenuItemFolderSubItem';
import { FOLDER_ICON_DEFAULT } from '@/navigation-menu-item/common/constants/FolderIconDefault';
import { DEFAULT_NAVIGATION_MENU_ITEM_COLOR_FOLDER } from '@/navigation-menu-item/common/constants/NavigationMenuItemDefaultColorFolder';
import { NavigationMenuItemDroppableIds } from '@/navigation-menu-item/common/constants/NavigationMenuItemDroppableIds';
import { NavigationSections } from '@/navigation-menu-item/common/constants/NavigationSections.constants';
import { NavigationDropTargetContext } from '@/navigation-menu-item/common/contexts/NavigationDropTargetContext';
import { NavigationMenuItemDragContext } from '@/navigation-menu-item/common/contexts/NavigationMenuItemDragContext';
import { SortableDropTargetRefContext } from '@/navigation-menu-item/common/contexts/SortableDropTargetRefContext';
import { type NavigationMenuItemClickParams } from '@/navigation-menu-item/display/hooks/useWorkspaceSectionItems';
import { isLayoutCustomizationModeEnabledState } from '@/layout-customization/states/isLayoutCustomizationModeEnabledState';
import { getDndKitDropTargetId } from '@/navigation-menu-item/common/utils/getDndKitDropTargetId';
import { NavigationDrawerItem } from '@/ui/navigation/navigation-drawer/components/NavigationDrawerItem';
import { type NavigationMenuItem } from '~/generated-metadata/graphql';
import { NavigationDrawerItemsCollapsableContainer } from '@/ui/navigation/navigation-drawer/components/NavigationDrawerItemsCollapsableContainer';
import { NavigationDrawerSubItem } from '@/ui/navigation/navigation-drawer/components/NavigationDrawerSubItem';
import { getNavigationSubItemLeftAdornment } from '@/ui/navigation/navigation-drawer/utils/getNavigationSubItemLeftAdornment';
import { useAtomStateValue } from '@/ui/utilities/state/jotai/hooks/useAtomStateValue';
const StyledFolderContainer = styled.div<{ $isSelectedInEditMode: boolean }>`
border: ${({ $isSelectedInEditMode }) =>
$isSelectedInEditMode
? `1px solid ${themeCssVariables.color.blue}`
: '1px solid transparent'};
border-radius: ${themeCssVariables.border.radius.sm};
transition: background-color 150ms ease-in-out;
&[data-drag-over-header='true'] {
background-color: ${themeCssVariables.background.transparent.blue};
}
&[data-forbidden-drop-target='true'] {
background-color: ${themeCssVariables.background.transparent.danger};
}
`;
const StyledFolderDroppableContent = styled.div`
display: flex;
flex-direction: column;
`;
const StyledFolderExpandableWrapper = styled.div`
& > div {
overflow: visible !important;
}
`;
type WorkspaceNavigationMenuItemsFolderProps = {
folderId: string;
folderName: string;
folderIconKey?: string | null;
folderColor?: string | null;
navigationMenuItems: NavigationMenuItem[];
isGroup: boolean;
isSelectedInEditMode?: boolean;
onEditModeClick?: () => void;
onNavigationMenuItemClick?: (params: NavigationMenuItemClickParams) => void;
selectedNavigationMenuItemId?: string | null;
isDragging?: boolean;
};
export const WorkspaceNavigationMenuItemsFolder = ({
folderId,
folderName,
folderIconKey,
folderColor,
navigationMenuItems,
isGroup,
isSelectedInEditMode = false,
onEditModeClick,
onNavigationMenuItemClick,
selectedNavigationMenuItemId = null,
isDragging = false,
}: WorkspaceNavigationMenuItemsFolderProps) => {
const isLayoutCustomizationModeEnabled = useAtomStateValue(
isLayoutCustomizationModeEnabledState,
);
const { theme } = useContext(ThemeContext);
const { getIcon } = useIcons();
const FolderIcon = getIcon(folderIconKey ?? FOLDER_ICON_DEFAULT);
const isMobile = useIsMobile();
const { t } = useLingui();
const { isOpen, handleToggle, selectedNavigationMenuItemIndex } =
useWorkspaceFolderOpenState({ folderId, navigationMenuItems });
const { openAddItemToFolderPage } = useOpenAddItemToFolderPage();
const sidePanelPage = useAtomStateValue(sidePanelPageState);
const addMenuItemInsertionContext = useAtomStateValue(
addMenuItemInsertionContextState,
);
const folderContentLengthForTree = isLayoutCustomizationModeEnabled
? navigationMenuItems.length + 1
: navigationMenuItems.length;
const handleAddMenuItemToFolder = () => {
openAddItemToFolderPage({
targetFolderId: folderId,
targetIndex: navigationMenuItems.length,
resetNavigationStack: true,
});
};
const shouldUseEditModeClick =
isLayoutCustomizationModeEnabled && isDefined(onEditModeClick);
const handleClick = shouldUseEditModeClick
? (e?: React.MouseEvent) => {
e?.stopPropagation();
if (isSelectedInEditMode) {
handleToggle();
} else {
onEditModeClick?.();
}
}
: handleToggle;
const [skipInitialExpandAnimation] = useState(() => isOpen);
const { isDragging: isContextDragging } = useContext(
NavigationMenuItemDragContext,
);
const setSortableDropTargetRef = useContext(SortableDropTargetRefContext);
const folderContentDropDisabled = useIsDropDisabledForSection(true);
const { activeDropTargetId, forbiddenDropTargetId } = useContext(
NavigationDropTargetContext,
);
const folderHeaderDroppableId = `${NavigationMenuItemDroppableIds.WORKSPACE_FOLDER_HEADER_PREFIX}${folderId}`;
const folderContentDroppableId = `${NavigationMenuItemDroppableIds.WORKSPACE_FOLDER_PREFIX}${folderId}`;
const folderHeaderSlotId = getDndKitDropTargetId(folderHeaderDroppableId, 0);
const isForbiddenDropTarget =
isDefined(forbiddenDropTargetId) &&
(forbiddenDropTargetId.startsWith(`${folderContentDroppableId}::`) ||
forbiddenDropTargetId.startsWith(`${folderHeaderDroppableId}::`));
const isDragOverFolderHeader =
!isForbiddenDropTarget && activeDropTargetId === folderHeaderSlotId;
const isCompact =
isLayoutCustomizationModeEnabled || navigationMenuItems.length === 0;
const headerItem = (
<NavigationDrawerItem
label={folderName}
Icon={FolderIcon}
iconColor={
isDefined(folderColor)
? folderColor
: DEFAULT_NAVIGATION_MENU_ITEM_COLOR_FOLDER
}
active={!isOpen && selectedNavigationMenuItemIndex >= 0}
onClick={handleClick}
className="navigation-drawer-item"
triggerEvent="CLICK"
preventCollapseOnMobile={isMobile}
isDragging={isDragging}
alwaysShowRightOptions
rightOptions={
<div
onClick={(e) => {
e.stopPropagation();
handleToggle();
}}
>
{isOpen ? (
<IconChevronDown
size={theme.icon.size.sm}
stroke={theme.icon.stroke.sm}
color={theme.font.color.tertiary}
/>
) : (
<IconChevronRight
size={theme.icon.size.sm}
stroke={theme.icon.stroke.sm}
color={theme.font.color.tertiary}
/>
)}
</div>
}
/>
);
return (
<StyledFolderContainer
$isSelectedInEditMode={isSelectedInEditMode}
data-drag-over-header={isDragOverFolderHeader ? 'true' : undefined}
data-forbidden-drop-target={isForbiddenDropTarget ? 'true' : undefined}
>
<NavigationDrawerItemsCollapsableContainer isGroup={isGroup}>
<div ref={setSortableDropTargetRef ?? undefined}>
<WorkspaceDndKitDroppableSlot
droppableId={folderHeaderDroppableId}
index={0}
disabled={folderContentDropDisabled}
collisionPriority={FOLDER_HEADER_SLOT_COLLISION_PRIORITY}
>
{headerItem}
</WorkspaceDndKitDroppableSlot>
</div>
<StyledFolderExpandableWrapper>
<AnimatedExpandableContainer
isExpanded={isOpen}
dimension="height"
mode="fit-content"
containAnimation
initial={!skipInitialExpandAnimation}
>
<StyledFolderDroppableContent>
{navigationMenuItems.map((navigationMenuItem, index) => (
<React.Fragment key={navigationMenuItem.id}>
<NavigationItemDropTarget
folderId={folderId}
index={index}
sectionId={NavigationSections.WORKSPACE}
compact={isCompact}
dropTargetIdOverride={getDndKitDropTargetId(
folderContentDroppableId,
index,
)}
/>
<WorkspaceDndKitSortableItem
id={navigationMenuItem.id}
index={index}
group={folderContentDroppableId}
disabled={
!isLayoutCustomizationModeEnabled ||
folderContentDropDisabled
}
>
<WorkspaceNavigationMenuItemFolderSubItem
navigationMenuItem={navigationMenuItem}
index={index}
arrayLength={folderContentLengthForTree}
selectedNavigationMenuItemIndex={
selectedNavigationMenuItemIndex
}
onNavigationMenuItemClick={onNavigationMenuItemClick}
selectedNavigationMenuItemId={
selectedNavigationMenuItemId ?? null
}
isContextDragging={isContextDragging}
/>
</WorkspaceDndKitSortableItem>
</React.Fragment>
))}
<WorkspaceDndKitDroppableSlot
droppableId={folderContentDroppableId}
index={navigationMenuItems.length}
disabled={folderContentDropDisabled}
>
<NavigationItemDropTarget
folderId={folderId}
index={navigationMenuItems.length}
sectionId={NavigationSections.WORKSPACE}
compact
dropTargetIdOverride={getDndKitDropTargetId(
folderContentDroppableId,
navigationMenuItems.length,
)}
/>
{isLayoutCustomizationModeEnabled && (
<NavigationDrawerSubItem
label={t`Add menu item`}
Icon={IconPlus}
onClick={handleAddMenuItemToFolder}
triggerEvent="CLICK"
variant="tertiary"
isSelectedInEditMode={
sidePanelPage === SidePanelPages.NavigationMenuAddItem &&
addMenuItemInsertionContext?.targetFolderId === folderId
}
subItemState={getNavigationSubItemLeftAdornment({
index: navigationMenuItems.length,
arrayLength: folderContentLengthForTree,
selectedIndex: selectedNavigationMenuItemIndex,
})}
/>
)}
</WorkspaceDndKitDroppableSlot>
</StyledFolderDroppableContent>
</AnimatedExpandableContainer>
</StyledFolderExpandableWrapper>
</NavigationDrawerItemsCollapsableContainer>
</StyledFolderContainer>
);
};
@@ -15,15 +15,15 @@ import { useAtomStateValue } from '@/ui/utilities/state/jotai/hooks/useAtomState
import { useSetAtomState } from '@/ui/utilities/state/jotai/hooks/useSetAtomState';
import { viewsSelector } from '@/views/states/selectors/viewsSelector';
type UseWorkspaceFolderOpenStateParams = {
type UseNavigationMenuItemFolderOpenStateParams = {
folderId: string;
navigationMenuItems: NavigationMenuItem[];
};
export const useWorkspaceFolderOpenState = ({
export const useNavigationMenuItemFolderOpenState = ({
folderId,
navigationMenuItems,
}: UseWorkspaceFolderOpenStateParams) => {
}: UseNavigationMenuItemFolderOpenStateParams) => {
const location = useLocation();
const navigate = useNavigate();
const currentPath = location.pathname;
@@ -21,7 +21,7 @@ export type NavigationMenuItemClickParams = {
objectMetadataItem?: ObjectMetadataItem | null;
};
export const useWorkspaceSectionItems = (): NavigationMenuItem[] => {
export const useNavigationMenuItemSectionItems = (): NavigationMenuItem[] => {
const { workspaceNavigationMenuItems } = useNavigationMenuItemsData();
const { workspaceNavigationMenuItemsSorted } = useSortedNavigationMenuItems();
const { workspaceNavigationMenuItemsByFolder } =
@@ -1,19 +1,21 @@
import { IconArrowUpRight } from 'twenty-ui/display';
import { themeCssVariables } from 'twenty-ui/theme-constants';
import { isLayoutCustomizationModeEnabledState } from '@/layout-customization/states/isLayoutCustomizationModeEnabledState';
import { NavigationMenuItemIcon } from '@/navigation-menu-item/display/components/NavigationMenuItemIcon';
import { getLinkNavigationMenuItemComputedLink } from '@/navigation-menu-item/display/link/utils/getLinkNavigationMenuItemComputedLink';
import { getLinkNavigationMenuItemLabel } from '@/navigation-menu-item/display/link/utils/getLinkNavigationMenuItemLabel';
import type { WorkspaceSectionItemContentProps } from '@/navigation-menu-item/display/sections/types/WorkspaceSectionItemContentProps';
import type { NavigationMenuItemSectionContentProps } from '@/navigation-menu-item/display/sections/types/NavigationMenuItemSectionContentProps';
import { NavigationDrawerItem } from '@/ui/navigation/navigation-drawer/components/NavigationDrawerItem';
import { useAtomStateValue } from '@/ui/utilities/state/jotai/hooks/useAtomStateValue';
import { IconArrowUpRight } from 'twenty-ui/display';
import { themeCssVariables } from 'twenty-ui/theme-constants';
type NavigationMenuItemLinkDisplayProps = WorkspaceSectionItemContentProps;
type NavigationMenuItemLinkDisplayProps = NavigationMenuItemSectionContentProps;
export const NavigationMenuItemLinkDisplay = ({
item,
editModeProps,
isDragging,
rightOptions,
}: NavigationMenuItemLinkDisplayProps) => {
const isLayoutCustomizationModeEnabled = useAtomStateValue(
isLayoutCustomizationModeEnabledState,
@@ -22,6 +24,14 @@ export const NavigationMenuItemLinkDisplay = ({
const label = getLinkNavigationMenuItemLabel(item);
const computedLink = getLinkNavigationMenuItemComputedLink(item);
const defaultRightOptions = !isLayoutCustomizationModeEnabled && (
<IconArrowUpRight
size={themeCssVariables.icon.size.sm}
stroke={themeCssVariables.icon.stroke.md}
color={themeCssVariables.font.color.light}
/>
);
return (
<NavigationDrawerItem
label={label}
@@ -32,23 +42,15 @@ export const NavigationMenuItemLinkDisplay = ({
}
onClick={
isLayoutCustomizationModeEnabled
? editModeProps.onEditModeClick
? editModeProps?.onEditModeClick
: undefined
}
Icon={() => <NavigationMenuItemIcon navigationMenuItem={item} />}
active={false}
isSelectedInEditMode={editModeProps.isSelectedInEditMode}
isSelectedInEditMode={editModeProps?.isSelectedInEditMode}
isDragging={isDragging}
triggerEvent="CLICK"
rightOptions={
!isLayoutCustomizationModeEnabled && (
<IconArrowUpRight
size={themeCssVariables.icon.size.sm}
stroke={themeCssVariables.icon.stroke.md}
color={themeCssVariables.font.color.light}
/>
)
}
rightOptions={rightOptions ?? defaultRightOptions}
/>
);
};
@@ -1,3 +1,5 @@
import type { ReactNode } from 'react';
import { isLayoutCustomizationModeEnabledState } from '@/layout-customization/states/isLayoutCustomizationModeEnabledState';
import { ObjectIconWithViewOverlay } from '@/navigation-menu-item/display/view/components/ObjectIconWithViewOverlay';
import { useObjectNavItemColor } from '@/navigation-menu-item/common/hooks/useObjectNavItemColor';
@@ -28,6 +30,7 @@ export type NavigationDrawerItemForObjectMetadataItemProps = {
onEditModeClick?: () => void;
onActiveItemClickWhenNotInEditMode?: () => void;
isDragging?: boolean;
rightOptions?: ReactNode;
};
export const NavigationDrawerItemForObjectMetadataItem = ({
@@ -37,6 +40,7 @@ export const NavigationDrawerItemForObjectMetadataItem = ({
onEditModeClick,
onActiveItemClickWhenNotInEditMode: _onActiveItemClickWhenNotInEditMode,
isDragging = false,
rightOptions,
}: NavigationDrawerItemForObjectMetadataItemProps) => {
const isLayoutCustomizationModeEnabled = useAtomStateValue(
isLayoutCustomizationModeEnabledState,
@@ -171,6 +175,7 @@ export const NavigationDrawerItemForObjectMetadataItem = ({
isSelectedInEditMode={isSelectedInEditMode}
isDragging={isDragging}
triggerEvent={isLayoutCustomizationModeEnabled ? 'CLICK' : undefined}
rightOptions={rightOptions}
/>
);
};
@@ -1,16 +1,18 @@
import { getObjectMetadataForNavigationMenuItem } from '@/navigation-menu-item/display/object/utils/getObjectMetadataForNavigationMenuItem';
import { NavigationDrawerItemForObjectMetadataItem } from '@/navigation-menu-item/display/object/components/NavigationDrawerItemForObjectMetadataItem';
import type { WorkspaceSectionItemContentProps } from '@/navigation-menu-item/display/sections/types/WorkspaceSectionItemContentProps';
import type { NavigationMenuItemSectionContentProps } from '@/navigation-menu-item/display/sections/types/NavigationMenuItemSectionContentProps';
import { objectMetadataItemsSelector } from '@/object-metadata/states/objectMetadataItemsSelector';
import { useAtomStateValue } from '@/ui/utilities/state/jotai/hooks/useAtomStateValue';
import { viewsSelector } from '@/views/states/selectors/viewsSelector';
type NavigationMenuItemObjectDisplayProps = WorkspaceSectionItemContentProps;
type NavigationMenuItemObjectDisplayProps =
NavigationMenuItemSectionContentProps;
export const NavigationMenuItemObjectDisplay = ({
item,
editModeProps,
isDragging,
rightOptions,
onActiveObjectMetadataItemClick,
}: NavigationMenuItemObjectDisplayProps) => {
const objectMetadataItems = useAtomStateValue(objectMetadataItemsSelector);
@@ -27,9 +29,10 @@ export const NavigationMenuItemObjectDisplay = ({
<NavigationDrawerItemForObjectMetadataItem
objectMetadataItem={objectMetadataItem}
navigationMenuItem={item}
isSelectedInEditMode={editModeProps.isSelectedInEditMode}
onEditModeClick={editModeProps.onEditModeClick}
isSelectedInEditMode={editModeProps?.isSelectedInEditMode}
onEditModeClick={editModeProps?.onEditModeClick}
isDragging={isDragging}
rightOptions={rightOptions}
onActiveItemClickWhenNotInEditMode={
onActiveObjectMetadataItemClick
? () => onActiveObjectMetadataItemClick(objectMetadataItem, item.id)
@@ -1,30 +1,31 @@
import type { ReactNode } from 'react';
import { NavigationItemDropTarget } from '@/navigation-menu-item/display/dnd/components/NavigationItemDropTarget';
import { NavigationSections } from '@/navigation-menu-item/common/constants/NavigationSections.constants';
import { NavigationMenuItemDroppableIds } from '@/navigation-menu-item/common/constants/NavigationMenuItemDroppableIds';
import { NavigationSections } from '@/navigation-menu-item/common/constants/NavigationSections.constants';
import { getDndKitDropTargetId } from '@/navigation-menu-item/common/utils/getDndKitDropTargetId';
import { NavigationItemDropTarget } from '@/navigation-menu-item/display/dnd/components/NavigationItemDropTarget';
type WorkspaceOrphanDropTargetProps = {
type NavigationMenuItemOrphanDropTargetProps = {
index: number;
compact?: boolean;
children?: ReactNode;
sectionId?: NavigationSections;
droppableId?: string;
};
export const WorkspaceOrphanDropTarget = ({
export const NavigationMenuItemOrphanDropTarget = ({
index,
compact = false,
children,
}: WorkspaceOrphanDropTargetProps) => (
sectionId = NavigationSections.WORKSPACE,
droppableId = NavigationMenuItemDroppableIds.WORKSPACE_ORPHAN_NAVIGATION_MENU_ITEMS,
}: NavigationMenuItemOrphanDropTargetProps) => (
<NavigationItemDropTarget
folderId={null}
index={index}
sectionId={NavigationSections.WORKSPACE}
sectionId={sectionId}
compact={compact}
dropTargetIdOverride={getDndKitDropTargetId(
NavigationMenuItemDroppableIds.WORKSPACE_ORPHAN_NAVIGATION_MENU_ITEMS,
index,
)}
dropTargetIdOverride={getDndKitDropTargetId(droppableId, index)}
>
{children}
</NavigationItemDropTarget>
@@ -0,0 +1,55 @@
import type { ReactNode } from 'react';
import { AnimatedExpandableContainer } from 'twenty-ui/layout';
import { NavigationDrawerAnimatedCollapseWrapper } from '@/ui/navigation/navigation-drawer/components/NavigationDrawerAnimatedCollapseWrapper';
import { NavigationDrawerSection } from '@/ui/navigation/navigation-drawer/components/NavigationDrawerSection';
import { NavigationDrawerSectionTitle } from '@/ui/navigation/navigation-drawer/components/NavigationDrawerSectionTitle';
type NavigationMenuItemSectionProps = {
title: string;
isOpen: boolean;
onToggle: () => void;
rightIcon?: ReactNode;
alwaysShowRightIcon?: boolean;
forceExpanded?: boolean;
children: ReactNode;
contentWrapper?: (children: ReactNode) => ReactNode;
};
export const NavigationMenuItemSection = ({
title,
isOpen,
onToggle,
rightIcon,
alwaysShowRightIcon,
forceExpanded = false,
children,
contentWrapper,
}: NavigationMenuItemSectionProps) => {
const content = (
<AnimatedExpandableContainer
isExpanded={isOpen || forceExpanded}
dimension="height"
mode="fit-content"
containAnimation
initial={false}
>
{children}
</AnimatedExpandableContainer>
);
return (
<NavigationDrawerSection>
<NavigationDrawerAnimatedCollapseWrapper>
<NavigationDrawerSectionTitle
label={title}
onClick={onToggle}
rightIcon={rightIcon}
alwaysShowRightIcon={alwaysShowRightIcon}
isOpen={isOpen}
/>
</NavigationDrawerAnimatedCollapseWrapper>
{contentWrapper ? contentWrapper(content) : content}
</NavigationDrawerSection>
);
};
@@ -1,372 +0,0 @@
import { useLingui } from '@lingui/react/macro';
import { useContext, useState } from 'react';
import { type NavigationMenuItem } from '~/generated-metadata/graphql';
import { NavigationSections } from '@/navigation-menu-item/common/constants/NavigationSections.constants';
import { createPortal } from 'react-dom';
import { useLocation, useNavigate } from 'react-router-dom';
import { NavigationMenuItemType } from 'twenty-shared/types';
import { isDefined } from 'twenty-shared/utils';
import { IconFolder, IconFolderOpen, IconHeartOff } from 'twenty-ui/display';
import { LightIconButton } from 'twenty-ui/input';
import { AnimatedExpandableContainer } from 'twenty-ui/layout';
import { useIsMobile } from 'twenty-ui/utilities';
import { NavigationItemDropTarget } from '@/navigation-menu-item/display/dnd/components/NavigationItemDropTarget';
import { WorkspaceDndKitSortableItem } from '@/navigation-menu-item/display/dnd/components/WorkspaceDndKitSortableItem';
import { NavigationMenuItemFolderNavigationDrawerItemDropdown } from '@/navigation-menu-item/display/folder/components/NavigationMenuItemFolderNavigationDrawerItemDropdown';
import { NavigationMenuItemIcon } from '@/navigation-menu-item/display/components/NavigationMenuItemIcon';
import { DEFAULT_NAVIGATION_MENU_ITEM_COLOR_FOLDER } from '@/navigation-menu-item/common/constants/NavigationMenuItemDefaultColorFolder';
import { NAVIGATION_MENU_ITEM_FOLDER_DELETE_MODAL_ID } from '@/navigation-menu-item/common/constants/NavigationMenuItemFolderDeleteModalId';
import { NavigationMenuItemDragContext } from '@/navigation-menu-item/common/contexts/NavigationMenuItemDragContext';
import { useDeleteNavigationMenuItem } from '@/navigation-menu-item/common/hooks/useDeleteNavigationMenuItem';
import { useDeleteNavigationMenuItemFolder } from '@/navigation-menu-item/edit/folder/hooks/useDeleteNavigationMenuItemFolder';
import { useRenameNavigationMenuItemFolder } from '@/navigation-menu-item/edit/folder/hooks/useRenameNavigationMenuItemFolder';
import { openNavigationMenuItemFolderIdsState } from '@/navigation-menu-item/common/states/openNavigationMenuItemFolderIdsState';
import { getEffectiveNavigationMenuItemColor } from '@/navigation-menu-item/common/utils/getEffectiveNavigationMenuItemColor';
import { getNavigationMenuItemComputedLink } from '@/navigation-menu-item/display/utils/getNavigationMenuItemComputedLink';
import { getNavigationMenuItemLabel } from '@/navigation-menu-item/display/utils/getNavigationMenuItemLabel';
import { getNavigationMenuItemObjectNameSingular } from '@/navigation-menu-item/display/object/utils/getNavigationMenuItemObjectNameSingular';
import { getObjectNavigationMenuItemSecondaryLabel } from '@/navigation-menu-item/display/object/utils/getObjectNavigationMenuItemSecondaryLabel';
import { isLocationMatchingNavigationMenuItem } from '@/navigation-menu-item/common/utils/isLocationMatchingNavigationMenuItem';
import { objectMetadataItemsSelector } from '@/object-metadata/states/objectMetadataItemsSelector';
import { useCloseDropdown } from '@/ui/layout/dropdown/hooks/useCloseDropdown';
import { isDropdownOpenComponentState } from '@/ui/layout/dropdown/states/isDropdownOpenComponentState';
import { ConfirmationModal } from '@/ui/layout/modal/components/ConfirmationModal';
import { useModal } from '@/ui/layout/modal/hooks/useModal';
import { isModalOpenedComponentState } from '@/ui/layout/modal/states/isModalOpenedComponentState';
import { NavigationDrawerInput } from '@/ui/navigation/navigation-drawer/components/NavigationDrawerInput';
import { NavigationDrawerItem } from '@/ui/navigation/navigation-drawer/components/NavigationDrawerItem';
import { NavigationDrawerItemsCollapsableContainer } from '@/ui/navigation/navigation-drawer/components/NavigationDrawerItemsCollapsableContainer';
import { NavigationDrawerSubItem } from '@/ui/navigation/navigation-drawer/components/NavigationDrawerSubItem';
import { currentNavigationMenuItemFolderIdState } from '@/navigation-menu-item/common/states/currentNavigationMenuItemFolderIdState';
import { getNavigationSubItemLeftAdornment } from '@/ui/navigation/navigation-drawer/utils/getNavigationSubItemLeftAdornment';
import { useAtomComponentStateValue } from '@/ui/utilities/state/jotai/hooks/useAtomComponentStateValue';
import { useAtomState } from '@/ui/utilities/state/jotai/hooks/useAtomState';
import { useAtomStateValue } from '@/ui/utilities/state/jotai/hooks/useAtomStateValue';
import { useSetAtomState } from '@/ui/utilities/state/jotai/hooks/useSetAtomState';
import { viewsSelector } from '@/views/states/selectors/viewsSelector';
import { ViewKey } from '@/views/types/ViewKey';
import { isNonEmptyString } from '@sniptt/guards';
type FavoritesFolderItemProps = {
folder: {
id: string;
folderName: string;
navigationMenuItems: NavigationMenuItem[];
};
isGroup: boolean;
isWorkspaceFolder?: boolean;
};
export const FavoritesFolderItem = ({
folder,
isGroup,
isWorkspaceFolder = false,
}: FavoritesFolderItemProps) => {
const { t } = useLingui();
const objectMetadataItems = useAtomStateValue(objectMetadataItemsSelector);
const views = useAtomStateValue(viewsSelector);
const location = useLocation();
const navigate = useNavigate();
const currentPath = location.pathname;
const currentViewPath = location.pathname + location.search;
const { isDragging } = useContext(NavigationMenuItemDragContext);
const [
isNavigationMenuItemFolderRenaming,
setIsNavigationMenuItemFolderRenaming,
] = useState(false);
const [navigationMenuItemFolderName, setNavigationMenuItemFolderName] =
useState(folder.folderName);
const { openModal } = useModal();
const isMobile = useIsMobile();
const [openNavigationMenuItemFolderIds, setOpenNavigationMenuItemFolderIds] =
useAtomState(openNavigationMenuItemFolderIdsState);
const setCurrentNavigationMenuItemFolderId = useSetAtomState(
currentNavigationMenuItemFolderIdState,
);
const isOpen = openNavigationMenuItemFolderIds.includes(folder.id);
const handleToggle = () => {
if (isMobile) {
setCurrentNavigationMenuItemFolderId((prev) =>
prev === folder.id ? null : folder.id,
);
} else {
setOpenNavigationMenuItemFolderIds((currentOpenFolders) => {
if (isOpen) {
return currentOpenFolders.filter((id) => id !== folder.id);
} else {
return [...currentOpenFolders, folder.id];
}
});
}
if (!isOpen) {
const firstNonLinkItem = folder.navigationMenuItems.find((item) => {
if (item.type === NavigationMenuItemType.LINK) {
return false;
}
const computedLink = getNavigationMenuItemComputedLink(
item,
objectMetadataItems,
views,
);
return isNonEmptyString(computedLink);
});
if (isDefined(firstNonLinkItem)) {
const link = getNavigationMenuItemComputedLink(
firstNonLinkItem,
objectMetadataItems,
views,
);
if (isNonEmptyString(link)) {
navigate(link);
}
}
}
};
const { renameNavigationMenuItemFolder } =
useRenameNavigationMenuItemFolder();
const { deleteNavigationMenuItemFolder } =
useDeleteNavigationMenuItemFolder();
const dropdownId = `navigation-menu-item-folder-edit-${folder.id}`;
const isDropdownOpen = useAtomComponentStateValue(
isDropdownOpenComponentState,
dropdownId,
);
const { closeDropdown } = useCloseDropdown();
const selectedNavigationMenuItemIndex = folder.navigationMenuItems.findIndex(
(item) => {
const computedLink = getNavigationMenuItemComputedLink(
item,
objectMetadataItems,
views,
);
return isLocationMatchingNavigationMenuItem(
currentPath,
currentViewPath,
item.type,
computedLink,
);
},
);
const { deleteNavigationMenuItem } = useDeleteNavigationMenuItem();
const navigationMenuItemFolderContentLength =
folder.navigationMenuItems.length;
const handleSubmitRename = async (value: string) => {
if (value === '') return;
await renameNavigationMenuItemFolder(folder.id, value);
setIsNavigationMenuItemFolderRenaming(false);
return true;
};
const handleCancelRename = () => {
setNavigationMenuItemFolderName(folder.folderName);
setIsNavigationMenuItemFolderRenaming(false);
};
const handleClickOutside = async (
_event: MouseEvent | TouchEvent,
value: string,
) => {
if (!value) {
setIsNavigationMenuItemFolderRenaming(false);
return;
}
await renameNavigationMenuItemFolder(folder.id, value);
setIsNavigationMenuItemFolderRenaming(false);
};
const modalId = `${NAVIGATION_MENU_ITEM_FOLDER_DELETE_MODAL_ID}-${folder.id}`;
const handleNavigationMenuItemFolderDelete = async () => {
if (folder.navigationMenuItems.length > 0) {
openModal(modalId);
closeDropdown(dropdownId);
} else {
await deleteNavigationMenuItemFolder(folder.id);
closeDropdown(dropdownId);
}
};
const handleConfirmDelete = async () => {
await deleteNavigationMenuItemFolder(folder.id);
};
const rightOptions = isWorkspaceFolder ? undefined : (
<NavigationMenuItemFolderNavigationDrawerItemDropdown
folderId={folder.id}
onRename={() => setIsNavigationMenuItemFolderRenaming(true)}
onDelete={handleNavigationMenuItemFolderDelete}
closeDropdown={() => {
closeDropdown(dropdownId);
}}
/>
);
const isModalOpened = useAtomComponentStateValue(
isModalOpenedComponentState,
modalId,
);
const navigationMenuItemCount = folder.navigationMenuItems.length;
const folderDroppableId = `folder-${folder.id}`;
return (
<>
<NavigationDrawerItemsCollapsableContainer
key={folder.id}
isGroup={isGroup}
>
{isNavigationMenuItemFolderRenaming ? (
<NavigationDrawerInput
Icon={IconFolder}
value={navigationMenuItemFolderName}
onChange={setNavigationMenuItemFolderName}
onSubmit={handleSubmitRename}
onCancel={handleCancelRename}
onClickOutside={handleClickOutside}
/>
) : (
<NavigationItemDropTarget
folderId={folder.id}
index={0}
sectionId={
isWorkspaceFolder
? NavigationSections.WORKSPACE
: NavigationSections.FAVORITES
}
>
<NavigationDrawerItem
label={folder.folderName}
Icon={isOpen ? IconFolderOpen : IconFolder}
iconColor={DEFAULT_NAVIGATION_MENU_ITEM_COLOR_FOLDER}
onClick={handleToggle}
rightOptions={rightOptions}
className="navigation-drawer-item"
isRightOptionsDropdownOpen={isDropdownOpen}
triggerEvent="CLICK"
preventCollapseOnMobile={isMobile}
/>
</NavigationItemDropTarget>
)}
<AnimatedExpandableContainer
isExpanded={isOpen}
dimension="height"
mode="fit-content"
containAnimation
>
{folder.navigationMenuItems.map((navigationMenuItem, index) => {
const label = getNavigationMenuItemLabel(
navigationMenuItem,
objectMetadataItems,
views,
);
const computedLink = getNavigationMenuItemComputedLink(
navigationMenuItem,
objectMetadataItems,
views,
);
const objectNameSingular = getNavigationMenuItemObjectNameSingular(
navigationMenuItem,
objectMetadataItems,
views,
);
const view = isDefined(navigationMenuItem.viewId)
? views.find((view) => view.id === navigationMenuItem.viewId)
: undefined;
const isIndexView = view?.key === ViewKey.INDEX;
return (
<WorkspaceDndKitSortableItem
key={navigationMenuItem.id}
id={navigationMenuItem.id}
index={index}
group={folderDroppableId}
>
<NavigationDrawerSubItem
secondaryLabel={
isIndexView
? undefined
: getObjectNavigationMenuItemSecondaryLabel({
objectMetadataItems,
navigationMenuItemObjectNameSingular:
objectNameSingular ?? '',
})
}
label={label}
Icon={() => (
<NavigationMenuItemIcon
navigationMenuItem={navigationMenuItem}
/>
)}
to={isDragging ? undefined : computedLink}
active={index === selectedNavigationMenuItemIndex}
subItemState={getNavigationSubItemLeftAdornment({
index,
arrayLength: navigationMenuItemFolderContentLength,
selectedIndex: selectedNavigationMenuItemIndex,
})}
rightOptions={
isWorkspaceFolder ? undefined : (
<LightIconButton
Icon={IconHeartOff}
onClick={(e) => {
e.stopPropagation();
deleteNavigationMenuItem(navigationMenuItem.id);
}}
accent="tertiary"
/>
)
}
isDragging={isDragging}
triggerEvent="CLICK"
iconColor={getEffectiveNavigationMenuItemColor(
navigationMenuItem,
)}
/>
</WorkspaceDndKitSortableItem>
);
})}
</AnimatedExpandableContainer>
</NavigationDrawerItemsCollapsableContainer>
{isModalOpened &&
createPortal(
<ConfirmationModal
modalInstanceId={modalId}
title={
folder.navigationMenuItems.length > 1
? t`Remove ${navigationMenuItemCount} navigation menu items?`
: t`Remove ${navigationMenuItemCount} navigation menu item?`
}
subtitle={
folder.navigationMenuItems.length > 1
? t`This action will delete this folder and all ${navigationMenuItemCount} navigation menu items inside. Do you want to continue?`
: t`This action will delete this folder and the navigation menu item inside. Do you want to continue?`
}
onConfirmClick={handleConfirmDelete}
confirmButtonText={t`Delete Folder`}
/>,
document.body,
)}
</>
);
};
@@ -1,116 +0,0 @@
import { styled } from '@linaria/react';
import { useContext } from 'react';
import { useLocation } from 'react-router-dom';
import { IconHeartOff } from 'twenty-ui/display';
import { LightIconButton } from 'twenty-ui/input';
import { themeCssVariables } from 'twenty-ui/theme-constants';
import { WorkspaceDndKitSortableItem } from '@/navigation-menu-item/display/dnd/components/WorkspaceDndKitSortableItem';
import { NavigationMenuItemIcon } from '@/navigation-menu-item/display/components/NavigationMenuItemIcon';
import { NavigationMenuItemDroppableIds } from '@/navigation-menu-item/common/constants/NavigationMenuItemDroppableIds';
import { NavigationMenuItemDragContext } from '@/navigation-menu-item/common/contexts/NavigationMenuItemDragContext';
import { useDeleteNavigationMenuItem } from '@/navigation-menu-item/common/hooks/useDeleteNavigationMenuItem';
import { useSortedNavigationMenuItems } from '@/navigation-menu-item/display/hooks/useSortedNavigationMenuItems';
import { getEffectiveNavigationMenuItemColor } from '@/navigation-menu-item/common/utils/getEffectiveNavigationMenuItemColor';
import { getNavigationMenuItemComputedLink } from '@/navigation-menu-item/display/utils/getNavigationMenuItemComputedLink';
import { getNavigationMenuItemLabel } from '@/navigation-menu-item/display/utils/getNavigationMenuItemLabel';
import { getNavigationMenuItemObjectNameSingular } from '@/navigation-menu-item/display/object/utils/getNavigationMenuItemObjectNameSingular';
import { getObjectNavigationMenuItemSecondaryLabel } from '@/navigation-menu-item/display/object/utils/getObjectNavigationMenuItemSecondaryLabel';
import { isLocationMatchingNavigationMenuItem } from '@/navigation-menu-item/common/utils/isLocationMatchingNavigationMenuItem';
import { objectMetadataItemsSelector } from '@/object-metadata/states/objectMetadataItemsSelector';
import { useAtomStateValue } from '@/ui/utilities/state/jotai/hooks/useAtomStateValue';
import { viewsSelector } from '@/views/states/selectors/viewsSelector';
import { NavigationDrawerItem } from '@/ui/navigation/navigation-drawer/components/NavigationDrawerItem';
const StyledEmptyContainer = styled.div`
width: 100%;
`;
const StyledOrphanNavigationMenuItemsContainer = styled.div`
margin-bottom: ${themeCssVariables.betweenSiblingsGap};
`;
export const FavoritesOrphanItems = () => {
const objectMetadataItems = useAtomStateValue(objectMetadataItemsSelector);
const views = useAtomStateValue(viewsSelector);
const { navigationMenuItemsSorted } = useSortedNavigationMenuItems();
const { deleteNavigationMenuItem } = useDeleteNavigationMenuItem();
const currentPath = useLocation().pathname;
const currentViewPath = useLocation().pathname + useLocation().search;
const { isDragging } = useContext(NavigationMenuItemDragContext);
const orphanNavigationMenuItems = navigationMenuItemsSorted.filter(
(item) => !item.folderId,
);
return orphanNavigationMenuItems.length > 0 ? (
<>
{orphanNavigationMenuItems.map((navigationMenuItem, index) => {
const label = getNavigationMenuItemLabel(
navigationMenuItem,
objectMetadataItems,
views,
);
const computedLink = getNavigationMenuItemComputedLink(
navigationMenuItem,
objectMetadataItems,
views,
);
const objectNameSingular = getNavigationMenuItemObjectNameSingular(
navigationMenuItem,
objectMetadataItems,
views,
);
return (
<WorkspaceDndKitSortableItem
key={navigationMenuItem.id}
id={navigationMenuItem.id}
index={index}
group={NavigationMenuItemDroppableIds.ORPHAN_NAVIGATION_MENU_ITEMS}
>
<StyledOrphanNavigationMenuItemsContainer>
<NavigationDrawerItem
secondaryLabel={getObjectNavigationMenuItemSecondaryLabel({
objectMetadataItems,
navigationMenuItemObjectNameSingular:
objectNameSingular ?? '',
})}
label={label}
Icon={() => (
<NavigationMenuItemIcon
navigationMenuItem={navigationMenuItem}
/>
)}
iconColor={getEffectiveNavigationMenuItemColor(
navigationMenuItem,
)}
active={isLocationMatchingNavigationMenuItem(
currentPath,
currentViewPath,
navigationMenuItem.type,
computedLink,
)}
to={isDragging ? undefined : computedLink}
rightOptions={
<LightIconButton
Icon={IconHeartOff}
onClick={(e) => {
e.stopPropagation();
deleteNavigationMenuItem(navigationMenuItem.id);
}}
accent="tertiary"
/>
}
isDragging={isDragging}
triggerEvent="CLICK"
/>
</StyledOrphanNavigationMenuItemsContainer>
</WorkspaceDndKitSortableItem>
);
})}
</>
) : (
<StyledEmptyContainer style={{ height: isDragging ? '24px' : '1px' }} />
);
};
@@ -1,23 +1,63 @@
import { styled } from '@linaria/react';
import { useLingui } from '@lingui/react/macro';
import { IconFolderPlus } from 'twenty-ui/display';
import { useCallback, useContext, useMemo, useState } from 'react';
import { IconFolder, IconFolderPlus, IconHeartOff } from 'twenty-ui/display';
import { LightIconButton } from 'twenty-ui/input';
import { AnimatedExpandableContainer } from 'twenty-ui/layout';
import { FavoritesOrphanItems } from '@/navigation-menu-item/display/sections/favorites/components/FavoritesOrphanItems';
import { NavigationMenuItemFolders } from '@/navigation-menu-item/display/folder/components/NavigationMenuItemFolders';
import { themeCssVariables } from 'twenty-ui/theme-constants';
import { type NavigationMenuItem } from '~/generated-metadata/graphql';
import { NavigationMenuItemDroppableIds } from '@/navigation-menu-item/common/constants/NavigationMenuItemDroppableIds';
import { NavigationSections } from '@/navigation-menu-item/common/constants/NavigationSections.constants';
import { NavigationMenuItemDragContext } from '@/navigation-menu-item/common/contexts/NavigationMenuItemDragContext';
import { useDeleteNavigationMenuItem } from '@/navigation-menu-item/common/hooks/useDeleteNavigationMenuItem';
import { isNavigationMenuItemFolderCreatingState } from '@/navigation-menu-item/common/states/isNavigationMenuItemFolderCreatingState';
import { isNavigationMenuItemFolder } from '@/navigation-menu-item/common/utils/isNavigationMenuItemFolder';
import { NavigationMenuItemDisplay } from '@/navigation-menu-item/display/components/NavigationMenuItemDisplay';
import { NavigationMenuItemDroppableSlot } from '@/navigation-menu-item/display/dnd/components/NavigationMenuItemDroppableSlot';
import { NavigationMenuItemSortableItem } from '@/navigation-menu-item/display/dnd/components/NavigationMenuItemSortableItem';
import { useIsDropDisabledForSection } from '@/navigation-menu-item/display/dnd/hooks/useIsDropDisabledForSection';
import { useCreateNavigationMenuItemFolder } from '@/navigation-menu-item/display/folder/hooks/useCreateNavigationMenuItemFolder';
import { useNavigationMenuItemsByFolder } from '@/navigation-menu-item/display/folder/hooks/useNavigationMenuItemsByFolder';
import { useSortedNavigationMenuItems } from '@/navigation-menu-item/display/hooks/useSortedNavigationMenuItems';
import { isNavigationMenuItemFolderCreatingState } from '@/navigation-menu-item/common/states/isNavigationMenuItemFolderCreatingState';
import { useAtomState } from '@/ui/utilities/state/jotai/hooks/useAtomState';
import { NavigationMenuItemOrphanDropTarget } from '@/navigation-menu-item/display/sections/components/NavigationMenuItemOrphanDropTarget';
import { NavigationMenuItemSection } from '@/navigation-menu-item/display/sections/components/NavigationMenuItemSection';
import { NavigationDrawerAnimatedCollapseWrapper } from '@/ui/navigation/navigation-drawer/components/NavigationDrawerAnimatedCollapseWrapper';
import { NavigationDrawerSection } from '@/ui/navigation/navigation-drawer/components/NavigationDrawerSection';
import { NavigationDrawerSectionTitle } from '@/ui/navigation/navigation-drawer/components/NavigationDrawerSectionTitle';
import { NavigationDrawerInput } from '@/ui/navigation/navigation-drawer/components/NavigationDrawerInput';
import { useNavigationSection } from '@/ui/navigation/navigation-drawer/hooks/useNavigationSection';
import { isNavigationSectionOpenFamilyState } from '@/ui/navigation/navigation-drawer/states/isNavigationSectionOpenFamilyState';
import { useAtomFamilyStateValue } from '@/ui/utilities/state/jotai/hooks/useAtomFamilyStateValue';
import { useAtomState } from '@/ui/utilities/state/jotai/hooks/useAtomState';
const StyledList = styled.div`
display: flex;
flex-direction: column;
gap: ${themeCssVariables.betweenSiblingsGap};
padding-top: ${themeCssVariables.betweenSiblingsGap};
`;
const StyledListItemRow = styled.div`
display: flex;
flex-direction: column;
gap: 0;
`;
const StyledEmptyContainer = styled.div`
width: 100%;
`;
const ORPHAN_DROPPABLE_ID =
NavigationMenuItemDroppableIds.FAVORITE_ORPHAN_NAVIGATION_MENU_ITEMS;
export const FavoritesSection = () => {
const { navigationMenuItemsSorted } = useSortedNavigationMenuItems();
const { userNavigationMenuItemsByFolder } = useNavigationMenuItemsByFolder();
const { deleteNavigationMenuItem } = useDeleteNavigationMenuItem();
const { isDragging } = useContext(NavigationMenuItemDragContext);
const favoritesDropDisabled = useIsDropDisabledForSection(false);
const [newFolderName, setNewFolderName] = useState('');
const { createNewNavigationMenuItemFolder } =
useCreateNavigationMenuItemFolder();
const [
isNavigationMenuItemFolderCreating,
@@ -33,13 +73,71 @@ export const FavoritesSection = () => {
'Favorites',
);
const topLevelItems = useMemo(
() => navigationMenuItemsSorted.filter((item) => !item.folderId),
[navigationMenuItemsSorted],
);
const folderChildrenById = useMemo(() => {
const map = new Map<string, NavigationMenuItem[]>();
for (const folder of userNavigationMenuItemsByFolder) {
map.set(folder.id, folder.navigationMenuItems);
}
return map;
}, [userNavigationMenuItemsByFolder]);
const folderCount = useMemo(
() => topLevelItems.filter(isNavigationMenuItemFolder).length,
[topLevelItems],
);
const toggleNewFolder = () => {
openNavigationSection();
setIsNavigationMenuItemFolderCreating((current) => !current);
};
const handleSubmitFolderCreation = async (value: string) => {
if (value === '') return;
setIsNavigationMenuItemFolderCreating(false);
setNewFolderName('');
await createNewNavigationMenuItemFolder(value);
return true;
};
const handleClickOutside = async (
_event: MouseEvent | TouchEvent,
value: string,
) => {
if (!value) {
setIsNavigationMenuItemFolderCreating(false);
return;
}
setIsNavigationMenuItemFolderCreating(false);
setNewFolderName('');
await createNewNavigationMenuItemFolder(value);
};
const handleCancelFolderCreation = () => {
setNewFolderName('');
setIsNavigationMenuItemFolderCreating(false);
};
const makeRightOptions = useCallback(
(item: NavigationMenuItem) => (
<LightIconButton
Icon={IconHeartOff}
onClick={(event) => {
event.stopPropagation();
deleteNavigationMenuItem(item.id);
}}
accent="tertiary"
/>
),
[deleteNavigationMenuItem],
);
if (
navigationMenuItemsSorted.length === 0 &&
topLevelItems.length === 0 &&
!isNavigationMenuItemFolderCreating &&
userNavigationMenuItemsByFolder.length === 0
) {
@@ -47,31 +145,92 @@ export const FavoritesSection = () => {
}
return (
<NavigationDrawerSection>
<NavigationDrawerAnimatedCollapseWrapper>
<NavigationDrawerSectionTitle
label={t`Favorites`}
onClick={toggleNavigationSection}
rightIcon={
<LightIconButton
Icon={IconFolderPlus}
onClick={toggleNewFolder}
accent="tertiary"
/>
}
isOpen={isNavigationSectionOpen}
<NavigationMenuItemSection
title={t`Favorites`}
isOpen={isNavigationSectionOpen}
onToggle={toggleNavigationSection}
rightIcon={
<LightIconButton
Icon={IconFolderPlus}
onClick={toggleNewFolder}
accent="tertiary"
/>
</NavigationDrawerAnimatedCollapseWrapper>
<AnimatedExpandableContainer
isExpanded={isNavigationSectionOpen}
dimension="height"
mode="fit-content"
containAnimation
initial={false}
>
<NavigationMenuItemFolders />
<FavoritesOrphanItems />
</AnimatedExpandableContainer>
</NavigationDrawerSection>
}
>
{isNavigationMenuItemFolderCreating && (
<NavigationDrawerAnimatedCollapseWrapper>
<NavigationDrawerInput
Icon={IconFolder}
value={newFolderName}
onChange={setNewFolderName}
onSubmit={handleSubmitFolderCreation}
onCancel={handleCancelFolderCreation}
onClickOutside={handleClickOutside}
/>
</NavigationDrawerAnimatedCollapseWrapper>
)}
{topLevelItems.length > 0 ? (
<StyledList>
{topLevelItems.map((item, index) => (
<StyledListItemRow key={item.id}>
{index === 0 ? (
<NavigationMenuItemDroppableSlot
droppableId={ORPHAN_DROPPABLE_ID}
index={0}
disabled={favoritesDropDisabled}
>
<NavigationMenuItemOrphanDropTarget
index={0}
compact
sectionId={NavigationSections.FAVORITES}
droppableId={ORPHAN_DROPPABLE_ID}
/>
</NavigationMenuItemDroppableSlot>
) : (
<NavigationMenuItemOrphanDropTarget
index={index}
compact
sectionId={NavigationSections.FAVORITES}
droppableId={ORPHAN_DROPPABLE_ID}
/>
)}
<NavigationMenuItemSortableItem
id={item.id}
index={index}
group={ORPHAN_DROPPABLE_ID}
disabled={favoritesDropDisabled}
>
<NavigationMenuItemDisplay
item={item}
isEditInPlace={isNavigationMenuItemFolder(item)}
isDragging={isDragging}
folderChildrenById={folderChildrenById}
folderCount={folderCount}
rightOptions={
isNavigationMenuItemFolder(item)
? undefined
: makeRightOptions(item)
}
/>
</NavigationMenuItemSortableItem>
</StyledListItemRow>
))}
<NavigationMenuItemDroppableSlot
droppableId={ORPHAN_DROPPABLE_ID}
index={topLevelItems.length}
disabled={favoritesDropDisabled}
>
<NavigationMenuItemOrphanDropTarget
index={topLevelItems.length}
compact
sectionId={NavigationSections.FAVORITES}
droppableId={ORPHAN_DROPPABLE_ID}
/>
</NavigationMenuItemDroppableSlot>
</StyledList>
) : (
<StyledEmptyContainer style={{ height: isDragging ? '24px' : '1px' }} />
)}
</NavigationMenuItemSection>
);
};
@@ -1,6 +1,7 @@
import { isLayoutCustomizationModeEnabledState } from '@/layout-customization/states/isLayoutCustomizationModeEnabledState';
import { NavigationSections } from '@/navigation-menu-item/common/constants/NavigationSections.constants';
import { NavigationMenuItemDndKitProvider } from '@/navigation-menu-item/display/dnd/providers/NavigationMenuItemDndKitProvider';
import { FavoritesSection } from '@/navigation-menu-item/display/sections/favorites/components/FavoritesSection';
import { FavoritesDndKitProvider } from '@/navigation/components/FavoritesDndKitProvider';
import { useAtomStateValue } from '@/ui/utilities/state/jotai/hooks/useAtomStateValue';
import { AnimatedEaseInOut } from 'twenty-ui/utilities';
@@ -11,9 +12,9 @@ export const FavoritesSectionDispatcher = () => {
return (
<AnimatedEaseInOut isOpen={!isLayoutCustomizationModeEnabled} initial>
<FavoritesDndKitProvider>
<NavigationMenuItemDndKitProvider section={NavigationSections.FAVORITES}>
<FavoritesSection />
</FavoritesDndKitProvider>
</NavigationMenuItemDndKitProvider>
</AnimatedEaseInOut>
);
};
@@ -1,16 +1,20 @@
import type { NavigationMenuItemClickParams } from '@/navigation-menu-item/display/hooks/useWorkspaceSectionItems';
import type { ReactNode } from 'react';
import type { NavigationMenuItemClickParams } from '@/navigation-menu-item/display/hooks/useNavigationMenuItemSectionItems';
import type { ObjectMetadataItem } from '@/object-metadata/types/ObjectMetadataItem';
import type { NavigationMenuItem } from '~/generated-metadata/graphql';
import type { EditModeProps } from '@/object-metadata/components/EditModeProps';
export type WorkspaceSectionItemContentProps = {
export type NavigationMenuItemSectionContentProps = {
item: NavigationMenuItem;
editModeProps: EditModeProps;
isEditInPlace?: boolean;
editModeProps?: EditModeProps;
isDragging: boolean;
folderChildrenById: Map<string, NavigationMenuItem[]>;
folderCount: number;
selectedNavigationMenuItemId: string | null;
rightOptions?: ReactNode;
selectedNavigationMenuItemId?: string | null;
onNavigationMenuItemClick?: (params: NavigationMenuItemClickParams) => void;
onActiveObjectMetadataItemClick?: (
objectMetadataItem: ObjectMetadataItem,
@@ -1,10 +1,10 @@
import type { NavigationMenuItemClickParams } from '@/navigation-menu-item/display/hooks/useWorkspaceSectionItems';
import type { NavigationMenuItemClickParams } from '@/navigation-menu-item/display/hooks/useNavigationMenuItemSectionItems';
import type { ObjectMetadataItem } from '@/object-metadata/types/ObjectMetadataItem';
import type { NavigationMenuItem } from '~/generated-metadata/graphql';
import type { EditModeProps } from '@/object-metadata/components/EditModeProps';
export type WorkspaceSectionListDndKitProps = {
export type NavigationMenuItemSectionListDndKitProps = {
filteredItems: NavigationMenuItem[];
getEditModeProps: (item: NavigationMenuItem) => EditModeProps;
folderChildrenById: Map<string, NavigationMenuItem[]>;
@@ -1,5 +0,0 @@
import { WorkspaceNavigationMenuItems } from '@/navigation-menu-item/display/sections/workspace/components/WorkspaceNavigationMenuItems';
export const WorkspaceNavigationMenuItemsDispatcher = () => {
return <WorkspaceNavigationMenuItems />;
};
@@ -20,14 +20,14 @@ import { useOpenNavigationMenuItemInSidePanel } from '@/navigation-menu-item/edi
import { useSortedNavigationMenuItems } from '@/navigation-menu-item/display/hooks/useSortedNavigationMenuItems';
import {
type NavigationMenuItemClickParams,
useWorkspaceSectionItems,
} from '@/navigation-menu-item/display/hooks/useWorkspaceSectionItems';
useNavigationMenuItemSectionItems,
} from '@/navigation-menu-item/display/hooks/useNavigationMenuItemSectionItems';
import { openNavigationMenuItemFolderIdsState } from '@/navigation-menu-item/common/states/openNavigationMenuItemFolderIdsState';
import { selectedNavigationMenuItemInEditModeState } from '@/navigation-menu-item/common/states/selectedNavigationMenuItemInEditModeState';
import { getNavigationMenuItemComputedLink } from '@/navigation-menu-item/display/utils/getNavigationMenuItemComputedLink';
import { getNavigationMenuItemLabel } from '@/navigation-menu-item/display/utils/getNavigationMenuItemLabel';
import { preloadWorkspaceDndKit } from '@/navigation/preloadWorkspaceDndKit';
import { NavigationDrawerSectionForWorkspaceItems } from '@/navigation-menu-item/display/sections/components/NavigationDrawerSectionForWorkspaceItems';
import { preloadNavigationMenuItemDndKit } from '@/navigation-menu-item/display/dnd/preloadNavigationMenuItemDndKit';
import { WorkspaceSectionContainer } from '@/navigation-menu-item/display/sections/workspace/components/WorkspaceSectionContainer';
import { objectMetadataItemsSelector } from '@/object-metadata/states/objectMetadataItemsSelector';
import { type ObjectMetadataItem } from '@/object-metadata/types/ObjectMetadataItem';
import { useNavigateSidePanel } from '@/side-panel/hooks/useNavigateSidePanel';
@@ -42,8 +42,8 @@ const StyledRightIconsContainer = styled.div`
gap: ${themeCssVariables.spacing[1]};
`;
export const WorkspaceNavigationMenuItems = () => {
const items = useWorkspaceSectionItems();
export const WorkspaceSection = () => {
const items = useNavigationMenuItemSectionItems();
const { workspaceNavigationMenuItemsSorted } = useSortedNavigationMenuItems();
const objectMetadataItems = useAtomStateValue(objectMetadataItemsSelector);
const views = useAtomStateValue(viewsSelector);
@@ -173,7 +173,7 @@ export const WorkspaceNavigationMenuItems = () => {
};
return (
<NavigationDrawerSectionForWorkspaceItems
<WorkspaceSectionContainer
sectionTitle={t`Workspace`}
items={items}
rightIcon={
@@ -186,7 +186,7 @@ export const WorkspaceNavigationMenuItems = () => {
onClick={handleAddMenuItem}
/>
) : (
<div onMouseEnter={preloadWorkspaceDndKit}>
<div onMouseEnter={preloadNavigationMenuItemDndKit}>
<LightIconButton
Icon={IconTool}
accent="tertiary"
@@ -1,33 +1,30 @@
import { NavigationDropTargetContext } from '@/navigation-menu-item/common/contexts/NavigationDropTargetContext';
import { styled } from '@linaria/react';
import React, { lazy, Suspense, useContext } from 'react';
import { NavigationMenuItemType } from 'twenty-shared/types';
import { isDefined } from 'twenty-shared/utils';
import { AnimatedExpandableContainer } from 'twenty-ui/layout';
import { themeCssVariables } from 'twenty-ui/theme-constants';
import { type NavigationMenuItem } from '~/generated-metadata/graphql';
import { isLayoutCustomizationModeEnabledState } from '@/layout-customization/states/isLayoutCustomizationModeEnabledState';
import { NavigationMenuItemDroppableIds } from '@/navigation-menu-item/common/constants/NavigationMenuItemDroppableIds';
import { type NavigationMenuItemClickParams } from '@/navigation-menu-item/display/hooks/useWorkspaceSectionItems';
import { NavigationDropTargetContext } from '@/navigation-menu-item/common/contexts/NavigationDropTargetContext';
import { type NavigationMenuItemClickParams } from '@/navigation-menu-item/display/hooks/useNavigationMenuItemSectionItems';
import { getObjectMetadataForNavigationMenuItem } from '@/navigation-menu-item/display/object/utils/getObjectMetadataForNavigationMenuItem';
import { WorkspaceSectionListReadOnly } from '@/navigation-menu-item/display/sections/workspace/components/WorkspaceSectionListReadOnly';
import { NavigationMenuItemSection } from '@/navigation-menu-item/display/sections/components/NavigationMenuItemSection';
import type { EditModeProps } from '@/object-metadata/components/EditModeProps';
import { NavigationDrawerSectionForWorkspaceItemsListReadOnly } from '@/navigation-menu-item/display/sections/components/NavigationDrawerSectionForWorkspaceItemsListReadOnly';
import { WorkspaceSectionListEditModeFallback } from '@/object-metadata/components/WorkspaceSectionListEditModeFallback';
import { objectMetadataItemsSelector } from '@/object-metadata/states/objectMetadataItemsSelector';
import { type ObjectMetadataItem } from '@/object-metadata/types/ObjectMetadataItem';
import { getObjectPermissionsForObject } from '@/object-metadata/utils/getObjectPermissionsForObject';
import { useObjectPermissions } from '@/object-record/hooks/useObjectPermissions';
import { NavigationDrawerAnimatedCollapseWrapper } from '@/ui/navigation/navigation-drawer/components/NavigationDrawerAnimatedCollapseWrapper';
import { NavigationDrawerSection } from '@/ui/navigation/navigation-drawer/components/NavigationDrawerSection';
import { NavigationDrawerSectionTitle } from '@/ui/navigation/navigation-drawer/components/NavigationDrawerSectionTitle';
import { useNavigationSection } from '@/ui/navigation/navigation-drawer/hooks/useNavigationSection';
import { useAtomStateValue } from '@/ui/utilities/state/jotai/hooks/useAtomStateValue';
import { viewsSelector } from '@/views/states/selectors/viewsSelector';
import { styled } from '@linaria/react';
import { themeCssVariables } from 'twenty-ui/theme-constants';
const LazyWorkspaceSectionListDndKit = lazy(() =>
import(
'@/navigation-menu-item/display/sections/components/NavigationDrawerSectionForWorkspaceItemsListDndKit'
'@/navigation-menu-item/display/sections/workspace/components/WorkspaceSectionListDndKit'
).then((m) => ({ default: m.WorkspaceSectionListDndKit })),
);
@@ -35,7 +32,7 @@ const StyledWorkspaceSectionContentGapOffset = styled.div`
margin-top: calc(-1 * ${themeCssVariables.betweenSiblingsGap});
`;
type NavigationDrawerSectionForWorkspaceItemsProps = {
type WorkspaceSectionContainerProps = {
sectionTitle: string;
items: NavigationMenuItem[];
rightIcon?: React.ReactNode;
@@ -47,14 +44,14 @@ type NavigationDrawerSectionForWorkspaceItemsProps = {
) => void;
};
export const NavigationDrawerSectionForWorkspaceItems = ({
export const WorkspaceSectionContainer = ({
sectionTitle,
items,
rightIcon,
selectedNavigationMenuItemId = null,
onNavigationMenuItemClick,
onActiveObjectMetadataItemClick,
}: NavigationDrawerSectionForWorkspaceItemsProps) => {
}: WorkspaceSectionContainerProps) => {
const isLayoutCustomizationModeEnabled = useAtomStateValue(
isLayoutCustomizationModeEnabledState,
);
@@ -145,58 +142,45 @@ export const NavigationDrawerSectionForWorkspaceItems = ({
}
return (
<NavigationDrawerSection>
<NavigationDrawerAnimatedCollapseWrapper>
<NavigationDrawerSectionTitle
label={sectionTitle}
onClick={() => toggleNavigationSection()}
rightIcon={rightIcon}
alwaysShowRightIcon={isLayoutCustomizationModeEnabled}
isOpen={isNavigationSectionOpen}
/>
</NavigationDrawerAnimatedCollapseWrapper>
<StyledWorkspaceSectionContentGapOffset>
<AnimatedExpandableContainer
isExpanded={
isNavigationSectionOpen || isAddToNavigationDropTargetVisible
}
dimension="height"
mode="fit-content"
containAnimation
initial={false}
>
{isLayoutCustomizationModeEnabled ? (
<Suspense
fallback={
<WorkspaceSectionListEditModeFallback
filteredItems={filteredItems}
folderChildrenById={folderChildrenById}
onActiveObjectMetadataItemClick={
onActiveObjectMetadataItemClick
}
/>
}
>
<LazyWorkspaceSectionListDndKit
filteredItems={filteredItems}
getEditModeProps={getEditModeProps}
folderChildrenById={folderChildrenById}
selectedNavigationMenuItemId={selectedNavigationMenuItemId}
onNavigationMenuItemClick={onNavigationMenuItemClick}
onActiveObjectMetadataItemClick={
onActiveObjectMetadataItemClick
}
/>
</Suspense>
) : (
<NavigationDrawerSectionForWorkspaceItemsListReadOnly
<NavigationMenuItemSection
title={sectionTitle}
isOpen={isNavigationSectionOpen}
onToggle={() => toggleNavigationSection()}
rightIcon={rightIcon}
alwaysShowRightIcon={isLayoutCustomizationModeEnabled}
forceExpanded={isAddToNavigationDropTargetVisible}
contentWrapper={(children) => (
<StyledWorkspaceSectionContentGapOffset>
{children}
</StyledWorkspaceSectionContentGapOffset>
)}
>
{isLayoutCustomizationModeEnabled ? (
<Suspense
fallback={
<WorkspaceSectionListEditModeFallback
filteredItems={filteredItems}
folderChildrenById={folderChildrenById}
onActiveObjectMetadataItemClick={onActiveObjectMetadataItemClick}
/>
)}
</AnimatedExpandableContainer>
</StyledWorkspaceSectionContentGapOffset>
</NavigationDrawerSection>
}
>
<LazyWorkspaceSectionListDndKit
filteredItems={filteredItems}
getEditModeProps={getEditModeProps}
folderChildrenById={folderChildrenById}
selectedNavigationMenuItemId={selectedNavigationMenuItemId}
onNavigationMenuItemClick={onNavigationMenuItemClick}
onActiveObjectMetadataItemClick={onActiveObjectMetadataItemClick}
/>
</Suspense>
) : (
<WorkspaceSectionListReadOnly
filteredItems={filteredItems}
folderChildrenById={folderChildrenById}
onActiveObjectMetadataItemClick={onActiveObjectMetadataItemClick}
/>
)}
</NavigationMenuItemSection>
);
};
@@ -0,0 +1,5 @@
import { WorkspaceSection } from '@/navigation-menu-item/display/sections/workspace/components/WorkspaceSection';
export const WorkspaceSectionDispatcher = () => {
return <WorkspaceSection />;
};
@@ -1,6 +1,6 @@
import { NavigationMenuItemDroppableSlot } from '@/navigation-menu-item/display/dnd/components/NavigationMenuItemDroppableSlot';
import { NavigationMenuItemSortableItem } from '@/navigation-menu-item/display/dnd/components/NavigationMenuItemSortableItem';
import { isLayoutCustomizationModeEnabledState } from '@/layout-customization/states/isLayoutCustomizationModeEnabledState';
import { WorkspaceDndKitDroppableSlot } from '@/navigation-menu-item/display/dnd/components/WorkspaceDndKitDroppableSlot';
import { WorkspaceDndKitSortableItem } from '@/navigation-menu-item/display/dnd/components/WorkspaceDndKitSortableItem';
import { NavigationMenuItemDroppableIds } from '@/navigation-menu-item/common/constants/NavigationMenuItemDroppableIds';
import { NavigationMenuItemType } from 'twenty-shared/types';
import { NavigationDropTargetContext } from '@/navigation-menu-item/common/contexts/NavigationDropTargetContext';
@@ -12,9 +12,11 @@ import { themeCssVariables } from 'twenty-ui/theme-constants';
import { NavigationMenuItemDragContext } from '@/navigation-menu-item/common/contexts/NavigationMenuItemDragContext';
import { NavigationMenuItemDisplay } from '@/navigation-menu-item/display/components/NavigationMenuItemDisplay';
import { WorkspaceOrphanDropTarget } from '@/navigation-menu-item/display/sections/components/WorkspaceOrphanDropTarget';
import { NavigationMenuItemOrphanDropTarget } from '@/navigation-menu-item/display/sections/components/NavigationMenuItemOrphanDropTarget';
import { WorkspaceSectionAddMenuItemButton } from '@/navigation-menu-item/edit/components/WorkspaceSectionAddMenuItemButton';
import type { WorkspaceSectionListDndKitProps } from '@/navigation-menu-item/display/sections/types/WorkspaceSectionListDndKitProps';
import type { NavigationMenuItemSectionListDndKitProps } from '@/navigation-menu-item/display/sections/types/NavigationMenuItemSectionListDndKitProps';
type WorkspaceSectionListDndKitProps = NavigationMenuItemSectionListDndKitProps;
const StyledList = styled.div`
display: flex;
@@ -53,8 +55,8 @@ export const WorkspaceSectionListDndKit = ({
<StyledList>
{filteredItems.map((item, index) => (
<StyledListItemRow key={item.id}>
<WorkspaceOrphanDropTarget index={index} compact />
<WorkspaceDndKitSortableItem
<NavigationMenuItemOrphanDropTarget index={index} compact />
<NavigationMenuItemSortableItem
id={item.id}
index={index}
group={
@@ -74,38 +76,38 @@ export const WorkspaceSectionListDndKit = ({
onNavigationMenuItemClick={onNavigationMenuItemClick}
onActiveObjectMetadataItemClick={onActiveObjectMetadataItemClick}
/>
</WorkspaceDndKitSortableItem>
</NavigationMenuItemSortableItem>
</StyledListItemRow>
))}
<WorkspaceDndKitDroppableSlot
<NavigationMenuItemDroppableSlot
droppableId={
NavigationMenuItemDroppableIds.WORKSPACE_ORPHAN_NAVIGATION_MENU_ITEMS
}
index={filteredItems.length}
disabled={workspaceDropDisabled}
>
<WorkspaceOrphanDropTarget
<NavigationMenuItemOrphanDropTarget
index={filteredItems.length}
compact={!isAddMenuItemButtonVisible}
>
{isAddMenuItemButtonVisible && <WorkspaceSectionAddMenuItemButton />}
</WorkspaceOrphanDropTarget>
</WorkspaceDndKitDroppableSlot>
</NavigationMenuItemOrphanDropTarget>
</NavigationMenuItemDroppableSlot>
{addToNavigationFallbackDestination?.droppableId ===
NavigationMenuItemDroppableIds.WORKSPACE_ORPHAN_NAVIGATION_MENU_ITEMS &&
addToNavigationFallbackDestination.index > filteredItems.length && (
<WorkspaceDndKitDroppableSlot
<NavigationMenuItemDroppableSlot
droppableId={
NavigationMenuItemDroppableIds.WORKSPACE_ORPHAN_NAVIGATION_MENU_ITEMS
}
index={addToNavigationFallbackDestination.index}
disabled={workspaceDropDisabled}
>
<WorkspaceOrphanDropTarget
<NavigationMenuItemOrphanDropTarget
index={addToNavigationFallbackDestination.index}
compact
/>
</WorkspaceDndKitDroppableSlot>
</NavigationMenuItemDroppableSlot>
)}
</StyledList>
);
@@ -3,7 +3,7 @@ import { themeCssVariables } from 'twenty-ui/theme-constants';
import type { NavigationMenuItem } from '~/generated-metadata/graphql';
import { NavigationMenuItemType } from 'twenty-shared/types';
import type { WorkspaceSectionListDndKitProps } from '@/navigation-menu-item/display/sections/types/WorkspaceSectionListDndKitProps';
import type { NavigationMenuItemSectionListDndKitProps } from '@/navigation-menu-item/display/sections/types/NavigationMenuItemSectionListDndKitProps';
import { NavigationMenuItemDisplay } from '@/navigation-menu-item/display/components/NavigationMenuItemDisplay';
const StyledList = styled.div`
@@ -13,8 +13,8 @@ const StyledList = styled.div`
padding-top: ${themeCssVariables.betweenSiblingsGap};
`;
type NavigationDrawerSectionForWorkspaceItemsListReadOnlyProps = Pick<
WorkspaceSectionListDndKitProps,
type WorkspaceSectionListReadOnlyProps = Pick<
NavigationMenuItemSectionListDndKitProps,
'filteredItems' | 'folderChildrenById' | 'onActiveObjectMetadataItemClick'
>;
@@ -23,11 +23,11 @@ const READ_ONLY_EDIT_MODE_PROPS = {
onEditModeClick: undefined,
} as const;
export const NavigationDrawerSectionForWorkspaceItemsListReadOnly = ({
export const WorkspaceSectionListReadOnly = ({
filteredItems,
folderChildrenById,
onActiveObjectMetadataItemClick,
}: NavigationDrawerSectionForWorkspaceItemsListReadOnlyProps) => {
}: WorkspaceSectionListReadOnlyProps) => {
const folderCount = filteredItems.filter(
(item) => item.type === NavigationMenuItemType.FOLDER,
).length;
@@ -0,0 +1,101 @@
import { useState } from 'react';
import { type NavigationMenuItem } from '~/generated-metadata/graphql';
import { NAVIGATION_MENU_ITEM_FOLDER_DELETE_MODAL_ID } from '@/navigation-menu-item/common/constants/NavigationMenuItemFolderDeleteModalId';
import { useDeleteNavigationMenuItemFolder } from '@/navigation-menu-item/edit/folder/hooks/useDeleteNavigationMenuItemFolder';
import { useRenameNavigationMenuItemFolder } from '@/navigation-menu-item/edit/folder/hooks/useRenameNavigationMenuItemFolder';
import { useCloseDropdown } from '@/ui/layout/dropdown/hooks/useCloseDropdown';
import { isDropdownOpenComponentState } from '@/ui/layout/dropdown/states/isDropdownOpenComponentState';
import { useModal } from '@/ui/layout/modal/hooks/useModal';
import { isModalOpenedComponentState } from '@/ui/layout/modal/states/isModalOpenedComponentState';
import { useAtomComponentStateValue } from '@/ui/utilities/state/jotai/hooks/useAtomComponentStateValue';
type UseFavoritesFolderEditParams = {
folderId: string;
folderName: string;
navigationMenuItems: NavigationMenuItem[];
};
export const useFavoritesFolderEdit = ({
folderId,
folderName: initialFolderName,
navigationMenuItems,
}: UseFavoritesFolderEditParams) => {
const [isRenaming, setIsRenaming] = useState(false);
const [folderNameValue, setFolderNameValue] = useState(initialFolderName);
const { renameNavigationMenuItemFolder } =
useRenameNavigationMenuItemFolder();
const { deleteNavigationMenuItemFolder } =
useDeleteNavigationMenuItemFolder();
const { openModal } = useModal();
const dropdownId = `navigation-menu-item-folder-edit-${folderId}`;
const isDropdownOpen = useAtomComponentStateValue(
isDropdownOpenComponentState,
dropdownId,
);
const { closeDropdown } = useCloseDropdown();
const modalId = `${NAVIGATION_MENU_ITEM_FOLDER_DELETE_MODAL_ID}-${folderId}`;
const isModalOpened = useAtomComponentStateValue(
isModalOpenedComponentState,
modalId,
);
const handleSubmitRename = async (value: string) => {
if (value === '') return;
await renameNavigationMenuItemFolder(folderId, value);
setIsRenaming(false);
return true;
};
const handleCancelRename = () => {
setFolderNameValue(initialFolderName);
setIsRenaming(false);
};
const handleClickOutsideRename = async (
_event: MouseEvent | TouchEvent,
value: string,
) => {
if (!value) {
setIsRenaming(false);
return;
}
await renameNavigationMenuItemFolder(folderId, value);
setIsRenaming(false);
};
const handleFolderDelete = async () => {
if (navigationMenuItems.length > 0) {
openModal(modalId);
closeDropdown(dropdownId);
} else {
await deleteNavigationMenuItemFolder(folderId);
closeDropdown(dropdownId);
}
};
const handleConfirmDelete = async () => {
await deleteNavigationMenuItemFolder(folderId);
};
return {
isRenaming,
setIsRenaming,
folderNameValue,
setFolderNameValue,
handleSubmitRename,
handleCancelRename,
handleClickOutsideRename,
handleFolderDelete,
handleConfirmDelete,
isDropdownOpen,
dropdownId,
closeDropdown: () => closeDropdown(dropdownId),
modalId,
isModalOpened,
navigationMenuItemCount: navigationMenuItems.length,
};
};
@@ -5,13 +5,13 @@ import { getPositionBetween } from '@/navigation-menu-item/common/utils/getPosit
import { isNavigationMenuItemFolder } from '@/navigation-menu-item/common/utils/isNavigationMenuItemFolder';
import { useSetAtomState } from '@/ui/utilities/state/jotai/hooks/useSetAtomState';
import { useWorkspaceSectionItems } from '@/navigation-menu-item/display/hooks/useWorkspaceSectionItems';
import { useNavigationMenuItemSectionItems } from '@/navigation-menu-item/display/hooks/useNavigationMenuItemSectionItems';
export const useNavigationMenuItemMoveRemove = () => {
const setNavigationMenuItemsDraft = useSetAtomState(
navigationMenuItemsDraftState,
);
const items = useWorkspaceSectionItems();
const items = useNavigationMenuItemSectionItems();
const visibleItemIds = new Set(items.map((item) => item.id));
const moveUp = (navigationMenuItemId: string) => {
@@ -1,4 +1,4 @@
import { useWorkspaceSectionItems } from '@/navigation-menu-item/display/hooks/useWorkspaceSectionItems';
import { useNavigationMenuItemSectionItems } from '@/navigation-menu-item/display/hooks/useNavigationMenuItemSectionItems';
import { selectedNavigationMenuItemInEditModeState } from '@/navigation-menu-item/common/states/selectedNavigationMenuItemInEditModeState';
import { useAtomStateValue } from '@/ui/utilities/state/jotai/hooks/useAtomStateValue';
@@ -6,7 +6,7 @@ export const useSelectedNavigationMenuItemEditItem = () => {
const selectedNavigationMenuItemInEditMode = useAtomStateValue(
selectedNavigationMenuItemInEditModeState,
);
const items = useWorkspaceSectionItems();
const items = useNavigationMenuItemSectionItems();
const selectedItem = selectedNavigationMenuItemInEditMode
? items.find((item) => item.id === selectedNavigationMenuItemInEditMode)
@@ -7,7 +7,7 @@ import { useNavigateSidePanel } from '@/side-panel/hooks/useNavigateSidePanel';
import { type OrganizeActionsProps } from '@/navigation-menu-item/edit/side-panel/components/SidePanelEditOrganizeActions';
import { useNavigationMenuItemMoveRemove } from '@/navigation-menu-item/edit/hooks/useNavigationMenuItemMoveRemove';
import { useNavigationMenuItemsDraftState } from '@/navigation-menu-item/edit/hooks/useNavigationMenuItemsDraftState';
import { useWorkspaceSectionItems } from '@/navigation-menu-item/display/hooks/useWorkspaceSectionItems';
import { useNavigationMenuItemSectionItems } from '@/navigation-menu-item/display/hooks/useNavigationMenuItemSectionItems';
import { addMenuItemInsertionContextState } from '@/navigation-menu-item/common/states/addMenuItemInsertionContextState';
import { selectedNavigationMenuItemInEditModeState } from '@/navigation-menu-item/common/states/selectedNavigationMenuItemInEditModeState';
import { type AddMenuItemInsertionContext } from '@/navigation-menu-item/common/types/AddMenuItemInsertionContext';
@@ -60,7 +60,7 @@ export const useNavigationMenuItemEditOrganizeActions =
addMenuItemInsertionContextState,
);
const { workspaceNavigationMenuItems } = useNavigationMenuItemsDraftState();
const items = useWorkspaceSectionItems();
const items = useNavigationMenuItemSectionItems();
const { moveUp, moveDown, remove } = useNavigationMenuItemMoveRemove();
const selectedItem = selectedNavigationMenuItemInEditMode
@@ -16,11 +16,11 @@ const FavoritesSectionDispatcher = lazy(() =>
})),
);
const WorkspaceNavigationMenuItemsDispatcher = lazy(() =>
const WorkspaceSectionDispatcher = lazy(() =>
import(
'@/navigation-menu-item/display/sections/workspace/components/WorkspaceNavigationMenuItemsDispatcher'
'@/navigation-menu-item/display/sections/workspace/components/WorkspaceSectionDispatcher'
).then((module) => ({
default: module.WorkspaceNavigationMenuItemsDispatcher,
default: module.WorkspaceSectionDispatcher,
})),
);
@@ -36,7 +36,7 @@ export const MainNavigationDrawerScrollableItems = () => {
<NavigationDrawerOpenedSection />
<Suspense fallback={<NavigationDrawerWorkspaceSectionSkeletonLoader />}>
<FavoritesSectionDispatcher />
<WorkspaceNavigationMenuItemsDispatcher />
<WorkspaceSectionDispatcher />
</Suspense>
<RemoteNavigationDrawerSection />
<NavigationDrawerOtherSection />
@@ -1,36 +0,0 @@
import { lazy, Suspense, useState, type ReactNode } from 'react';
import { PageDragDropProviderMountEffect } from '@/navigation/components/PageDragDropProviderMountEffect';
const LazyWorkspaceDndKitProvider = lazy(() =>
import('@/navigation/components/WorkspaceDndKitProvider').then((m) => ({
default: m.WorkspaceDndKitProvider,
})),
);
type PageDragDropProviderProps = {
children: ReactNode;
};
export const PageDragDropProvider = ({
children,
}: PageDragDropProviderProps) => {
const [hasProviderMounted, setHasProviderMounted] = useState(false);
if (!hasProviderMounted) {
return (
<>
<PageDragDropProviderMountEffect
onEnterEditMode={() => setHasProviderMounted(true)}
/>
{children}
</>
);
}
return (
<Suspense fallback={<>{children}</>}>
<LazyWorkspaceDndKitProvider>{children}</LazyWorkspaceDndKitProvider>
</Suspense>
);
};
@@ -1,50 +0,0 @@
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/common/contexts/NavigationDragSourceContext';
import { NavigationDropTargetContext } from '@/navigation-menu-item/common/contexts/NavigationDropTargetContext';
import { NavigationMenuItemDragContext } from '@/navigation-menu-item/common/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>
);
};
@@ -1,267 +0,0 @@
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 { isDefined } from 'twenty-shared/utils';
import { type NavigationMenuItem } from '~/generated-metadata/graphql';
import { ADD_TO_NAV_SOURCE_DROPPABLE_ID } from '@/navigation-menu-item/common/constants/AddToNavSourceDroppableId';
import { NavigationMenuItemDroppableIds } from '@/navigation-menu-item/common/constants/NavigationMenuItemDroppableIds';
import { useHandleAddToNavigationDrop } from '@/navigation-menu-item/display/dnd/hooks/useHandleAddToNavigationDrop';
import { useHandleNavigationMenuItemDragAndDrop } from '@/navigation-menu-item/display/dnd/hooks/useHandleNavigationMenuItemDragAndDrop';
import { getDndKitDropTargetId } from '@/navigation-menu-item/common/utils/getDndKitDropTargetId';
import { isNavigationMenuItemFolder } from '@/navigation-menu-item/common/utils/isNavigationMenuItemFolder';
import { useNavigationMenuItemsData } from '@/navigation-menu-item/display/hooks/useNavigationMenuItemsData';
import { useSortedNavigationMenuItems } from '@/navigation-menu-item/display/hooks/useSortedNavigationMenuItems';
import { DROP_RESULT_OPTIONS } from '@/navigation/constants/workspaceDndKitDropResultOptions';
import type { DraggableData } from '@/navigation/types/workspaceDndKitDraggableData';
import type { DropDestination } from '@/navigation/types/workspaceDndKitDropDestination';
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 FavoritesDndKitContextValues = {
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;
};
};
const resolveFavoritesDropTarget = (
target: {
id?: unknown;
group?: unknown;
index?: unknown;
data?: unknown;
} | null,
getNavItemById: (id: string | undefined) => NavigationMenuItem | undefined,
): {
destination: DropDestination;
effectiveDropTargetId: string;
isTargetFolder: boolean;
} | null => {
if (target === null || target === undefined) {
return null;
}
if (isDefined(target.group) && isDefined(target.index)) {
const group = String(target.group);
const index = Number(target.index);
if (!Number.isInteger(index) || index < 0) {
return null;
}
const targetItem = getNavItemById(
target.id != null ? String(target.id) : undefined,
);
const isTargetFolder =
isDefined(targetItem) && isNavigationMenuItemFolder(targetItem);
const destination: DropDestination = isTargetFolder
? { droppableId: `folder-header-${target.id}`, index: 0 }
: { droppableId: group, index };
const effectiveDropTargetId = isTargetFolder
? getDndKitDropTargetId(`folder-header-${target.id}`, 0)
: getDndKitDropTargetId(group, index);
return { destination, effectiveDropTargetId, isTargetFolder };
}
return null;
};
export const useFavoritesDndKit = (): {
contextValues: FavoritesDndKitContextValues;
handlers: {
onDragStart: (event: DragStartPayload) => void;
onDragOver: (event: DragOverPayload) => void;
onDragEnd: (event: DragEndPayload) => void;
};
} => {
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 { navigationMenuItems } = useNavigationMenuItemsData();
const { navigationMenuItemsSorted } = useSortedNavigationMenuItems();
const { handleAddToNavigationDrop } = useHandleAddToNavigationDrop();
const { handleNavigationMenuItemDragAndDrop } =
useHandleNavigationMenuItemDragAndDrop();
const orphanItemCount = navigationMenuItemsSorted.filter(
(item: { folderId?: string | null }) => !isDefined(item.folderId),
).length;
const getNavItemById = useCallback(
(id: string | undefined) =>
id ? navigationMenuItems.find((item) => item.id === id) : undefined,
[navigationMenuItems],
);
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.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 resolved = resolveFavoritesDropTarget(target, getNavItemById);
if (
resolved !== null &&
source !== null &&
target !== null &&
isSortable(source) &&
isSortable(target)
) {
setActiveDropTargetId(resolved.effectiveDropTargetId);
setForbiddenDropTargetId(null);
return;
}
if (resolved !== null && source !== null && isSortable(source)) {
setActiveDropTargetId(resolved.effectiveDropTargetId);
setAddToNavigationFallbackDestination(resolved.destination);
setForbiddenDropTargetId(null);
return;
}
if (!isAddToNavDrag) {
return;
}
if (resolved !== null) {
setAddToNavigationFallbackDestination(resolved.destination);
setActiveDropTargetId(resolved.effectiveDropTargetId);
setForbiddenDropTargetId(null);
return;
}
const fallback = addToNavigationFallbackDestination;
setActiveDropTargetId(
fallback
? getDndKitDropTargetId(fallback.droppableId, fallback.index)
: null,
);
setForbiddenDropTargetId(null);
},
[
sourceDroppableId,
addToNavigationFallbackDestination,
getNavItemById,
setActiveDropTargetId,
setForbiddenDropTargetId,
setAddToNavigationFallbackDestination,
],
);
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 resolved = resolveFavoritesDropTarget(target, getNavItemById);
let destination: DropDestination | null = resolved?.destination ?? null;
if (destination == null && isDefined(fallback)) {
destination = fallback;
}
const result = toDropResult(draggableId, data, destination);
const dropResult = { ...result, ...DROP_RESULT_OPTIONS };
const provided: ResponderProvided = { announce: () => {} };
if (sourceId === ADD_TO_NAV_SOURCE_DROPPABLE_ID) {
handleAddToNavigationDrop(dropResult, provided);
return;
}
handleNavigationMenuItemDragAndDrop(dropResult, provided);
};
const contextValues: FavoritesDndKitContextValues = {
dragSource: { sourceDroppableId },
drag: { isDragging },
dropTarget: {
activeDropTargetId,
setActiveDropTargetId,
forbiddenDropTargetId,
setForbiddenDropTargetId,
addToNavigationFallbackDestination,
},
};
return {
contextValues,
handlers: {
onDragStart: handleDragStart,
onDragOver: handleDragOver,
onDragEnd: handleDragEnd,
},
};
};
@@ -1,360 +0,0 @@
import { type DragDropProvider } from '@dnd-kit/react';
import { isSortable } from '@dnd-kit/react/sortable';
import type { ResponderProvided } from '@hello-pangea/dnd';
import { useStore } from 'jotai';
import { type ComponentProps, useCallback, useState } from 'react';
import { isDefined } from 'twenty-shared/utils';
import { ADD_TO_NAV_SOURCE_DROPPABLE_ID } from '@/navigation-menu-item/common/constants/AddToNavSourceDroppableId';
import { NavigationMenuItemDroppableIds } from '@/navigation-menu-item/common/constants/NavigationMenuItemDroppableIds';
import { useHandleAddToNavigationDrop } from '@/navigation-menu-item/display/dnd/hooks/useHandleAddToNavigationDrop';
import { useHandleWorkspaceNavigationMenuItemDragAndDrop } from '@/navigation-menu-item/display/dnd/hooks/useHandleWorkspaceNavigationMenuItemDragAndDrop';
import { useNavigationMenuItemsDraftState } from '@/navigation-menu-item/edit/hooks/useNavigationMenuItemsDraftState';
import { addToNavPayloadRegistryState } from '@/navigation-menu-item/common/states/addToNavPayloadRegistryState';
import { getDndKitDropTargetId } from '@/navigation-menu-item/common/utils/getDndKitDropTargetId';
import { isNavigationMenuItemFolder } from '@/navigation-menu-item/common/utils/isNavigationMenuItemFolder';
import { isWorkspaceDroppableId } from '@/navigation-menu-item/common/utils/isWorkspaceDroppableId';
import { validateAndExtractWorkspaceFolderId } from '@/navigation-menu-item/common/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,
insertBeforeItemId?: string | null,
) => {
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,
...(insertBeforeItemId != null && { insertBeforeItemId }),
},
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) {
const insertBeforeItemId = resolved.isTargetFolder
? null
: String(target?.id ?? '');
applyWorkspaceReorderIfAllowed(
draggableId,
{ droppableId: initialGroupStr, index: initialIndex },
resolved.destination,
insertBeforeItemId || undefined,
);
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,
},
};
};
@@ -1,16 +0,0 @@
let preloadScheduled = false;
const preload = () => {
void import('@/navigation/components/WorkspaceDndKitProvider');
void import(
'@/navigation-menu-item/display/sections/components/NavigationDrawerSectionForWorkspaceItemsListDndKit'
);
};
export const preloadWorkspaceDndKit = (): void => {
if (preloadScheduled) {
return;
}
preloadScheduled = true;
preload();
};
@@ -1,13 +0,0 @@
import type { NavigationMenuItem } from '~/generated-metadata/graphql';
import { isDefined } from 'twenty-shared/utils';
import { isNavigationMenuItemFolder } from '@/navigation-menu-item/common/utils/isNavigationMenuItemFolder';
type AddToNavPayload = { type?: string } | null;
export const isFolderDrag = (
payload: AddToNavPayload,
sourceItem: NavigationMenuItem | undefined,
): boolean =>
payload?.type === 'FOLDER' ||
(isDefined(sourceItem) && isNavigationMenuItemFolder(sourceItem));
@@ -1,8 +1,8 @@
import { styled } from '@linaria/react';
import { NavigationDrawerSectionForWorkspaceItemsListReadOnly } from '@/navigation-menu-item/display/sections/components/NavigationDrawerSectionForWorkspaceItemsListReadOnly';
import { WorkspaceSectionListReadOnly } from '@/navigation-menu-item/display/sections/workspace/components/WorkspaceSectionListReadOnly';
import { WorkspaceSectionAddMenuItemButton } from '@/navigation-menu-item/edit/components/WorkspaceSectionAddMenuItemButton';
import type { WorkspaceSectionListDndKitProps } from '@/navigation-menu-item/display/sections/types/WorkspaceSectionListDndKitProps';
import type { NavigationMenuItemSectionListDndKitProps } from '@/navigation-menu-item/display/sections/types/NavigationMenuItemSectionListDndKitProps';
import { themeCssVariables } from 'twenty-ui/theme-constants';
const StyledFallback = styled.div`
@@ -12,7 +12,7 @@ const StyledFallback = styled.div`
`;
type WorkspaceSectionListEditModeFallbackProps = Pick<
WorkspaceSectionListDndKitProps,
NavigationMenuItemSectionListDndKitProps,
'filteredItems' | 'folderChildrenById' | 'onActiveObjectMetadataItemClick'
>;
@@ -22,7 +22,7 @@ export const WorkspaceSectionListEditModeFallback = ({
onActiveObjectMetadataItemClick,
}: WorkspaceSectionListEditModeFallbackProps) => (
<StyledFallback>
<NavigationDrawerSectionForWorkspaceItemsListReadOnly
<WorkspaceSectionListReadOnly
filteredItems={filteredItems}
folderChildrenById={folderChildrenById}
onActiveObjectMetadataItemClick={onActiveObjectMetadataItemClick}
@@ -9,7 +9,7 @@ import { NavigationMenuItemStyleIcon } from '@/navigation-menu-item/display/comp
import { FOLDER_ICON_DEFAULT } from '@/navigation-menu-item/common/constants/FolderIconDefault';
import { NavigationMenuItemType } from 'twenty-shared/types';
import { useUpdateFolderInDraft } from '@/navigation-menu-item/edit/folder/hooks/useUpdateFolderInDraft';
import { useWorkspaceSectionItems } from '@/navigation-menu-item/display/hooks/useWorkspaceSectionItems';
import { useNavigationMenuItemSectionItems } from '@/navigation-menu-item/display/hooks/useNavigationMenuItemSectionItems';
import { selectedNavigationMenuItemInEditModeState } from '@/navigation-menu-item/common/states/selectedNavigationMenuItemInEditModeState';
import { IconPicker } from '@/ui/input/components/IconPicker';
import { TitleInput } from '@/ui/input/components/TitleInput';
@@ -32,7 +32,7 @@ export const SidePanelFolderInfo = () => {
const selectedNavigationMenuItemInEditMode = useAtomStateValue(
selectedNavigationMenuItemInEditModeState,
);
const items = useWorkspaceSectionItems();
const items = useNavigationMenuItemSectionItems();
const { updateFolderInDraft } = useUpdateFolderInDraft();
const defaultLabel = t`New folder`;
@@ -4,7 +4,7 @@ import { IconLink, IconWorld } from 'twenty-ui/display';
import { LinkIconWithLinkOverlay } from '@/navigation-menu-item/display/link/components/LinkIconWithLinkOverlay';
import { NavigationMenuItemType } from 'twenty-shared/types';
import { useUpdateLinkInDraft } from '@/navigation-menu-item/edit/link/hooks/useUpdateLinkInDraft';
import { useWorkspaceSectionItems } from '@/navigation-menu-item/display/hooks/useWorkspaceSectionItems';
import { useNavigationMenuItemSectionItems } from '@/navigation-menu-item/display/hooks/useNavigationMenuItemSectionItems';
import { selectedNavigationMenuItemInEditModeState } from '@/navigation-menu-item/common/states/selectedNavigationMenuItemInEditModeState';
import { SidePanelPageInfoLayout } from '@/side-panel/components/SidePanelPageInfoLayout';
import { sidePanelPageInfoState } from '@/side-panel/states/sidePanelPageInfoState';
@@ -24,7 +24,7 @@ export const SidePanelLinkInfo = () => {
const selectedNavigationMenuItemInEditMode = useAtomStateValue(
selectedNavigationMenuItemInEditModeState,
);
const items = useWorkspaceSectionItems();
const items = useNavigationMenuItemSectionItems();
const { updateLinkInDraft } = useUpdateLinkInDraft();
const defaultLabel = t`Link label`;
@@ -6,7 +6,7 @@ import {
} from 'twenty-ui/display';
import { NavigationMenuItemType, SidePanelPages } from 'twenty-shared/types';
import { useWorkspaceSectionItems } from '@/navigation-menu-item/display/hooks/useWorkspaceSectionItems';
import { useNavigationMenuItemSectionItems } from '@/navigation-menu-item/display/hooks/useNavigationMenuItemSectionItems';
import { selectedNavigationMenuItemInEditModeState } from '@/navigation-menu-item/common/states/selectedNavigationMenuItemInEditModeState';
import { SidePanelAskAIInfo } from '@/side-panel/components/SidePanelAskAIInfo';
import { SidePanelFolderInfo } from '@/side-panel/components/SidePanelFolderInfo';
@@ -37,7 +37,7 @@ export const SidePanelPageInfo = ({ pageChip }: SidePanelPageInfoProps) => {
const selectedNavigationMenuItemInEditMode = useAtomStateValue(
selectedNavigationMenuItemInEditModeState,
);
const items = useWorkspaceSectionItems();
const items = useNavigationMenuItemSectionItems();
if (!isDefined(pageChip)) {
return null;
@@ -66,11 +66,10 @@ describe('calculateNewPosition', () => {
items,
});
expect(result).toBe(Math.round(30 + (20 - 30) / 2));
expect(result).toBe(25);
});
it('should round fractional midpoints', () => {
it('should return fractional midpoints for adjacent positions', () => {
const items = createItems([1, 2, 5]);
const result = calculateNewPosition({
@@ -79,8 +78,7 @@ describe('calculateNewPosition', () => {
items,
});
expect(result).toBe(Math.round(5 + (2 - 5) / 2));
expect(result).toBe(4);
expect(result).toBe(3.5);
});
});
@@ -94,11 +92,10 @@ describe('calculateNewPosition', () => {
items,
});
expect(result).toBe(Math.round(20 - (20 - 10) / 2));
expect(result).toBe(15);
});
it('should round fractional midpoints', () => {
it('should return fractional midpoints for adjacent positions', () => {
const items = createItems([1, 4, 10]);
const result = calculateNewPosition({
@@ -107,8 +104,21 @@ describe('calculateNewPosition', () => {
items,
});
expect(result).toBe(Math.round(4 - (4 - 1) / 2));
expect(result).toBe(3);
expect(result).toBe(2.5);
});
it('should produce unique position for sequential integers', () => {
const items = createItems([1, 2]);
const result = calculateNewPosition({
destinationIndex: 1,
sourceIndex: 2,
items,
});
expect(result).toBe(1.5);
expect(result).not.toBe(items[0].position);
expect(result).not.toBe(items[1].position);
});
it('should handle destinationIndex equal to sourceIndex', () => {
@@ -18,18 +18,17 @@ export const calculateNewPosition = ({
}
if (destinationIndex > sourceIndex) {
return Math.round(
return (
items[destinationIndex].position +
(items[destinationIndex - 1].position -
items[destinationIndex].position) /
2,
(items[destinationIndex - 1].position -
items[destinationIndex].position) /
2
);
}
return Math.round(
return (
items[destinationIndex].position -
(items[destinationIndex].position -
items[destinationIndex - 1].position) /
2,
(items[destinationIndex].position - items[destinationIndex - 1].position) /
2
);
};
@@ -1,4 +0,0 @@
export const FOLDER_DROPPABLE_IDS = {
FOLDER_PREFIX: 'folder-',
FOLDER_HEADER_PREFIX: 'folder-header-',
} as const;
@@ -1,48 +0,0 @@
import { CustomError } from 'twenty-shared/utils';
import { FOLDER_DROPPABLE_IDS } from './folderDroppableIds';
type ValidateAndExtractFolderIdParams = {
droppableId: string;
// TODO: Remove orphanDroppableId prop when deleting all favorites code
orphanDroppableId: string;
};
export const validateAndExtractFolderId = ({
droppableId,
orphanDroppableId,
}: ValidateAndExtractFolderIdParams): string | null => {
if (droppableId === orphanDroppableId) {
return null;
}
if (droppableId.startsWith(FOLDER_DROPPABLE_IDS.FOLDER_HEADER_PREFIX)) {
const folderId = droppableId.replace(
FOLDER_DROPPABLE_IDS.FOLDER_HEADER_PREFIX,
'',
);
if (!folderId)
throw new CustomError(
`Invalid folder header ID: ${droppableId}`,
'INVALID_FOLDER_HEADER_ID',
);
return folderId;
}
if (droppableId.startsWith(FOLDER_DROPPABLE_IDS.FOLDER_PREFIX)) {
const folderId = droppableId.replace(
FOLDER_DROPPABLE_IDS.FOLDER_PREFIX,
'',
);
if (!folderId)
throw new CustomError(
`Invalid folder ID: ${droppableId}`,
'INVALID_FOLDER_ID',
);
return folderId;
}
throw new CustomError(
`Invalid droppable ID format: ${droppableId}`,
'INVALID_DROPPABLE_ID_FORMAT',
);
};
@@ -8,7 +8,7 @@ import { KeyboardShortcutMenu } from '@/keyboard-shortcut-menu/components/Keyboa
import { LayoutCustomizationBar } from '@/layout-customization/components/LayoutCustomizationBar';
import { AppNavigationDrawer } from '@/navigation/components/AppNavigationDrawer';
import { MobileNavigationBar } from '@/navigation/components/MobileNavigationBar';
import { PageDragDropProvider } from '@/navigation/components/PageDragDropProvider';
import { PageDragDropProvider } from '@/navigation-menu-item/display/dnd/providers/PageDragDropProvider';
import { useIsSettingsPage } from '@/navigation/hooks/useIsSettingsPage';
import { OBJECT_SETTINGS_WIDTH } from '@/settings/data-model/constants/ObjectSettings';
import { SignInAppNavigationDrawerMock } from '@/sign-in-background-mock/components/SignInAppNavigationDrawerMock';