Navbar drag drop using dnd kit (#18288)

This commit is contained in:
Abdul Rahman 2026-03-09 01:55:50 +05:30 committed by GitHub
parent 66d93c4d28
commit 5b28e59ca7
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
119 changed files with 3563 additions and 1297 deletions

View file

@ -61,8 +61,8 @@ const jestConfig = {
extensionsToTreatAsEsm: ['.ts', '.tsx'],
coverageThreshold: {
global: {
statements: 49.5,
lines: 48,
statements: 49.3,
lines: 47.9,
functions: 39.5,
},
},

View file

@ -38,6 +38,7 @@
"@calcom/embed-react": "^1.5.3",
"@cyntler/react-doc-viewer": "^1.17.0",
"@dagrejs/dagre": "^1.1.8",
"@dnd-kit/react": "^0.3.2",
"@floating-ui/react": "^0.24.3",
"@graphiql/plugin-explorer": "^1.0.2",
"@graphiql/react": "^0.23.0",

View file

@ -29,19 +29,19 @@ import { SingleRecordActionKeys } from '@/action-menu/actions/record-actions/sin
import { type ActionConfig } from '@/action-menu/actions/types/ActionConfig';
import { ActionScope } from '@/action-menu/actions/types/ActionScope';
import { ActionType } from '@/action-menu/actions/types/ActionType';
import {
ActionViewType,
CoreObjectNameSingular,
AppPath,
SettingsPath,
} from 'twenty-shared/types';
import { CoreObjectNamePlural } from '@/object-metadata/types/CoreObjectNamePlural';
import { msg } from '@lingui/core/macro';
import { isNonEmptyString } from '@sniptt/guards';
import {
BACKEND_BATCH_REQUEST_MAX_COUNT,
MUTATION_MAX_MERGE_RECORDS,
} from 'twenty-shared/constants';
import { msg } from '@lingui/core/macro';
import { isNonEmptyString } from '@sniptt/guards';
import {
ActionViewType,
AppPath,
CoreObjectNameSingular,
SettingsPath,
} from 'twenty-shared/types';
import {
IconArrowMerge,
IconBuildingSkyscraper,
@ -72,8 +72,8 @@ import {
import { isDefined } from 'twenty-shared/utils';
import {
PermissionFlagType,
FeatureFlagKey,
PermissionFlagType,
} from '~/generated-metadata/graphql';
export const DEFAULT_RECORD_ACTIONS_CONFIG: Record<

View file

@ -0,0 +1,16 @@
import { Action } from '@/action-menu/actions/components/Action';
import { isNavigationMenuInEditModeState } from '@/navigation-menu-item/states/isNavigationMenuInEditModeState';
import { useSetAtomState } from '@/ui/utilities/state/jotai/hooks/useSetAtomState';
export const EditNavigationSidebarNoSelectionRecordAction = () => {
const setIsNavigationMenuInEditMode = useSetAtomState(
isNavigationMenuInEditModeState,
);
return (
<Action
onClick={() => setIsNavigationMenuInEditMode(true)}
closeSidePanelOnCommandMenuListActionExecution
/>
);
};

View file

@ -1,11 +1,18 @@
import { ActionOpenSidePanelPage } from '@/action-menu/actions/components/ActionOpenSidePanelPage';
import { EditNavigationSidebarNoSelectionRecordAction } from '@/action-menu/actions/record-actions/no-selection/components/EditNavigationSidebarNoSelectionRecordAction';
import { RecordAgnosticActionsKeys } from '@/action-menu/actions/record-agnostic-actions/types/RecordAgnosticActionsKeys';
import { type ActionConfig } from '@/action-menu/actions/types/ActionConfig';
import { ActionScope } from '@/action-menu/actions/types/ActionScope';
import { ActionType } from '@/action-menu/actions/types/ActionType';
import { ActionViewType, SidePanelPages } from 'twenty-shared/types';
import { msg } from '@lingui/core/macro';
import { IconHistory, IconSearch, IconSparkles } from 'twenty-ui/display';
import { ActionViewType, SidePanelPages } from 'twenty-shared/types';
import {
IconHistory,
IconLayout,
IconSearch,
IconSparkles,
} from 'twenty-ui/display';
import { FeatureFlagKey } from '~/generated-metadata/graphql';
export const RECORD_AGNOSTIC_ACTIONS_CONFIG: Record<string, ActionConfig> = {
[RecordAgnosticActionsKeys.SEARCH_RECORDS]: {
@ -88,4 +95,20 @@ export const RECORD_AGNOSTIC_ACTIONS_CONFIG: Record<string, ActionConfig> = {
),
shouldBeRegistered: () => true,
},
[RecordAgnosticActionsKeys.EDIT_NAVIGATION_SIDEBAR]: {
type: ActionType.Navigation,
scope: ActionScope.Global,
key: RecordAgnosticActionsKeys.EDIT_NAVIGATION_SIDEBAR,
label: msg`Edit navigation sidebar`,
shortLabel: msg`Edit sidebar`,
position: 4,
Icon: IconLayout,
isPinned: false,
availableOn: [ActionViewType.GLOBAL],
shouldBeRegistered: ({ isFeatureFlagEnabled }) =>
isFeatureFlagEnabled(
FeatureFlagKey.IS_NAVIGATION_MENU_ITEM_EDITING_ENABLED,
),
component: <EditNavigationSidebarNoSelectionRecordAction />,
},
};

View file

@ -14,6 +14,10 @@ export const useRecordAgnosticActions = () => {
RECORD_AGNOSTIC_ACTIONS_CONFIG[
RecordAgnosticActionsKeys.SEARCH_RECORDS_FALLBACK
],
[RecordAgnosticActionsKeys.EDIT_NAVIGATION_SIDEBAR]:
RECORD_AGNOSTIC_ACTIONS_CONFIG[
RecordAgnosticActionsKeys.EDIT_NAVIGATION_SIDEBAR
],
};
if (isAiEnabled) {

View file

@ -3,4 +3,5 @@ export enum RecordAgnosticActionsKeys {
SEARCH_RECORDS_FALLBACK = 'search-records-fallback',
ASK_AI = 'ask-ai',
VIEW_PREVIOUS_AI_CHATS = 'view-previous-ai-chats',
EDIT_NAVIGATION_SIDEBAR = 'edit-navigation-sidebar',
}

View file

@ -11,19 +11,19 @@ import { useFavorites } from '@/favorites/hooks/useFavorites';
import { usePrefetchedNavigationMenuItemsData } from '@/navigation-menu-item/hooks/usePrefetchedNavigationMenuItemsData';
import { CoreObjectNameSingular } from 'twenty-shared/types';
import { type ObjectMetadataItem } from '@/object-metadata/types/ObjectMetadataItem';
import { useRecordIndexIdFromCurrentContextStore } from '@/object-record/record-index/hooks/useRecordIndexIdFromCurrentContextStore';
import { useObjectPermissionsForObject } from '@/object-record/hooks/useObjectPermissionsForObject';
import { hasAnySoftDeleteFilterOnViewComponentSelector } from '@/object-record/record-filter/states/hasAnySoftDeleteFilterOnView';
import { useRecordIndexIdFromCurrentContextStore } from '@/object-record/record-index/hooks/useRecordIndexIdFromCurrentContextStore';
import { recordStoreFamilyState } from '@/object-record/record-store/states/recordStoreFamilyState';
import { useAtomComponentSelectorValue } from '@/ui/utilities/state/jotai/hooks/useAtomComponentSelectorValue';
import { useAtomComponentStateValue } from '@/ui/utilities/state/jotai/hooks/useAtomComponentStateValue';
import { useAtomFamilyStateValue } from '@/ui/utilities/state/jotai/hooks/useAtomFamilyStateValue';
import { useAtomStateValue } from '@/ui/utilities/state/jotai/hooks/useAtomStateValue';
import { useIsFeatureEnabled } from '@/workspace/hooks/useIsFeatureEnabled';
import { useStore } from 'jotai';
import { useCallback, useContext, useMemo } from 'react';
import { isDefined } from 'twenty-shared/utils';
import { FeatureFlagKey } from '~/generated-metadata/graphql';
import { useStore } from 'jotai';
export const useShouldActionBeRegisteredParams = ({
objectMetadataItem,

View file

@ -0,0 +1,27 @@
import { useDroppable } from '@dnd-kit/react';
import { type ReactNode } from 'react';
import { ADD_TO_NAV_SOURCE_DROPPABLE_ID } from '@/navigation-menu-item/constants/AddToNavSourceDroppableId';
import type { AddToNavDroppableProvided } from '@/command-menu/components/CommandMenuAddToNavDroppableTypes';
type CommandMenuAddToNavDroppableDndKitProps = {
children: (provided: AddToNavDroppableProvided) => ReactNode;
isDropDisabled: boolean;
};
export const CommandMenuAddToNavDroppableDndKit = ({
children,
isDropDisabled,
}: CommandMenuAddToNavDroppableDndKitProps) => {
const { ref } = useDroppable({
id: ADD_TO_NAV_SOURCE_DROPPABLE_ID,
disabled: isDropDisabled,
});
const provided: AddToNavDroppableProvided = {
innerRef: ref,
droppableProps: { 'data-dnd-group': ADD_TO_NAV_SOURCE_DROPPABLE_ID },
placeholder: null,
};
return <>{children(provided)}</>;
};

View file

@ -0,0 +1,7 @@
import type { ReactNode } from 'react';
export type AddToNavDroppableProvided = {
innerRef: (element: HTMLElement | null) => void;
droppableProps: object;
placeholder: ReactNode;
};

View file

@ -0,0 +1,27 @@
import { useDraggable } from '@dnd-kit/react';
import { type ReactNode } from 'react';
import { ADD_TO_NAV_SOURCE_DROPPABLE_ID } from '@/navigation-menu-item/constants/AddToNavSourceDroppableId';
type CommandMenuItemWithAddToNavigationDragDndKitProps = {
id: string;
dragIndex: number;
menuItemContent: ReactNode;
};
export const CommandMenuItemWithAddToNavigationDragDndKit = ({
id,
dragIndex,
menuItemContent,
}: CommandMenuItemWithAddToNavigationDragDndKitProps) => {
const { ref } = useDraggable({
id,
data: {
sourceDroppableId: ADD_TO_NAV_SOURCE_DROPPABLE_ID,
sourceIndex: dragIndex,
},
disabled: false,
feedback: 'clone',
});
return <div ref={ref}>{menuItemContent}</div>;
};

View file

@ -7,12 +7,10 @@ import { useDeleteFavorite } from '@/favorites/hooks/useDeleteFavorite';
import { useDeleteFavoriteFolder } from '@/favorites/hooks/useDeleteFavoriteFolder';
import { useRenameFavoriteFolder } from '@/favorites/hooks/useRenameFavoriteFolder';
import { openFavoriteFolderIdsState } from '@/favorites/states/openFavoriteFolderIdsState';
import { useAtomState } from '@/ui/utilities/state/jotai/hooks/useAtomState';
import { getFavoriteSecondaryLabel } from '@/favorites/utils/getFavoriteSecondaryLabel';
import { isLocationMatchingFavorite } from '@/favorites/utils/isLocationMatchingFavorite';
import { type ProcessedFavorite } from '@/favorites/utils/sortFavorites';
import { objectMetadataItemsState } from '@/object-metadata/states/objectMetadataItemsState';
import { useAtomStateValue } from '@/ui/utilities/state/jotai/hooks/useAtomStateValue';
import { DraggableItem } from '@/ui/layout/draggable-list/components/DraggableItem';
import { useCloseDropdown } from '@/ui/layout/dropdown/hooks/useCloseDropdown';
import { isDropdownOpenComponentState } from '@/ui/layout/dropdown/states/isDropdownOpenComponentState';
@ -24,9 +22,11 @@ import { NavigationDrawerItem } from '@/ui/navigation/navigation-drawer/componen
import { NavigationDrawerItemsCollapsableContainer } from '@/ui/navigation/navigation-drawer/components/NavigationDrawerItemsCollapsableContainer';
import { NavigationDrawerSubItem } from '@/ui/navigation/navigation-drawer/components/NavigationDrawerSubItem';
import { currentFavoriteFolderIdState } from '@/ui/navigation/navigation-drawer/states/currentFavoriteFolderIdState';
import { useSetAtomState } from '@/ui/utilities/state/jotai/hooks/useSetAtomState';
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 { Droppable } from '@hello-pangea/dnd';
import { useLingui } from '@lingui/react/macro';
import { useContext, useState } from 'react';
@ -239,7 +239,10 @@ export const CurrentWorkspaceMemberFavorites = ({
rightOptions={
<LightIconButton
Icon={IconHeartOff}
onClick={() => deleteFavorite(favorite.id)}
onClick={(e) => {
e.stopPropagation();
deleteFavorite(favorite.id);
}}
accent="tertiary"
/>
}

View file

@ -1,11 +1,11 @@
import { currentWorkspaceMemberState } from '@/auth/states/currentWorkspaceMemberState';
import { CurrentWorkspaceMemberOrphanFavorites } from '@/favorites/components/CurrentWorkspaceMemberOrphanFavorites';
import { FavoritesDragProvider } from '@/favorites/components/FavoritesDragProvider';
import { FavoriteFolders } from '@/favorites/components/FavoritesFolders';
import { FavoritesSkeletonLoader } from '@/favorites/components/FavoritesSkeletonLoader';
import { useFavorites } from '@/favorites/hooks/useFavorites';
import { useFavoritesByFolder } from '@/favorites/hooks/useFavoritesByFolder';
import { isFavoriteFolderCreatingState } from '@/favorites/states/isFavoriteFolderCreatingState';
import { useAtomState } from '@/ui/utilities/state/jotai/hooks/useAtomState';
import { useIsPrefetchLoading } from '@/prefetch/hooks/useIsPrefetchLoading';
import { NavigationDrawerAnimatedCollapseWrapper } from '@/ui/navigation/navigation-drawer/components/NavigationDrawerAnimatedCollapseWrapper';
import { NavigationDrawerSection } from '@/ui/navigation/navigation-drawer/components/NavigationDrawerSection';
@ -13,11 +13,13 @@ import { NavigationDrawerSectionTitle } from '@/ui/navigation/navigation-drawer/
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';
import { useAtomStateValue } from '@/ui/utilities/state/jotai/hooks/useAtomStateValue';
import { useLingui } from '@lingui/react/macro';
import { isDefined } from 'twenty-shared/utils';
import { IconFolderPlus } from 'twenty-ui/display';
import { LightIconButton } from 'twenty-ui/input';
import { AnimatedExpandableContainer } from 'twenty-ui/layout';
export const CurrentWorkspaceMemberFavoritesFolders = () => {
const currentWorkspaceMember = useAtomStateValue(currentWorkspaceMemberState);
@ -69,14 +71,21 @@ export const CurrentWorkspaceMemberFavoritesFolders = () => {
accent="tertiary"
/>
}
isOpen={isNavigationSectionOpen}
/>
</NavigationDrawerAnimatedCollapseWrapper>
{isNavigationSectionOpen && (
<>
<FavoriteFolders isNavigationSectionOpen={isNavigationSectionOpen} />
<AnimatedExpandableContainer
isExpanded={isNavigationSectionOpen}
dimension="height"
mode="fit-content"
containAnimation
initial={false}
>
<FavoritesDragProvider>
<FavoriteFolders />
<CurrentWorkspaceMemberOrphanFavorites />
</>
)}
</FavoritesDragProvider>
</AnimatedExpandableContainer>
</NavigationDrawerSection>
);
};

View file

@ -63,7 +63,10 @@ export const CurrentWorkspaceMemberOrphanFavorites = () => {
rightOptions={
<LightIconButton
Icon={IconHeartOff}
onClick={() => deleteFavorite(favorite.id)}
onClick={(e) => {
e.stopPropagation();
deleteFavorite(favorite.id);
}}
accent="tertiary"
/>
}

View file

@ -54,7 +54,10 @@ export const FavoritesFolderContent = ({
rightOptions={
<LightIconButton
Icon={IconHeartOff}
onClick={() => deleteFavorite(favorite.id)}
onClick={(e) => {
e.stopPropagation();
deleteFavorite(favorite.id);
}}
accent="tertiary"
/>
}

View file

@ -2,19 +2,13 @@ import { CurrentWorkspaceMemberFavorites } from '@/favorites/components/CurrentW
import { useCreateFavoriteFolder } from '@/favorites/hooks/useCreateFavoriteFolder';
import { useFavoritesByFolder } from '@/favorites/hooks/useFavoritesByFolder';
import { isFavoriteFolderCreatingState } from '@/favorites/states/isFavoriteFolderCreatingState';
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';
import { useAtomState } from '@/ui/utilities/state/jotai/hooks/useAtomState';
import { useState } from 'react';
import { IconFolder } from 'twenty-ui/display';
type FavoriteFoldersProps = {
isNavigationSectionOpen: boolean;
};
export const FavoriteFolders = ({
isNavigationSectionOpen,
}: FavoriteFoldersProps) => {
export const FavoriteFolders = () => {
const [newFolderName, setNewFolderName] = useState('');
const { favoritesByFolder } = useFavoritesByFolder();
@ -56,10 +50,6 @@ export const FavoriteFolders = ({
setIsFavoriteFolderCreating(false);
};
if (!isNavigationSectionOpen) {
return null;
}
return (
<>
{isFavoriteFolderCreating && (

View file

@ -1,12 +1,12 @@
import { FAVORITE_DROPPABLE_IDS } from '@/favorites/constants/FavoriteDroppableIds';
import { useSortedFavorites } from '@/favorites/hooks/useSortedFavorites';
import { openFavoriteFolderIdsState } from '@/favorites/states/openFavoriteFolderIdsState';
import { useSetAtomState } from '@/ui/utilities/state/jotai/hooks/useSetAtomState';
import { CoreObjectNameSingular } from 'twenty-shared/types';
import { useUpdateOneRecord } from '@/object-record/hooks/useUpdateOneRecord';
import { calculateNewPosition } from '@/ui/layout/draggable-list/utils/calculateNewPosition';
import { validateAndExtractFolderId } from '@/ui/layout/draggable-list/utils/validateAndExtractFolderId';
import { useSetAtomState } from '@/ui/utilities/state/jotai/hooks/useSetAtomState';
import { type OnDragEndResponder } from '@hello-pangea/dnd';
import { CoreObjectNameSingular } from 'twenty-shared/types';
import { usePrefetchedFavoritesData } from './usePrefetchedFavoritesData';
export const useHandleFavoriteDragAndDrop = () => {

View file

@ -1,6 +1,6 @@
import { styled } from '@linaria/react';
import { isNonEmptyString } from '@sniptt/guards';
import { type ReactNode, useContext } from 'react';
import { useContext, type ReactNode } from 'react';
import { isDefined } from 'twenty-shared/utils';
import { IconGripVertical, type IconComponent } from 'twenty-ui/display';
import { ThemeContext, themeCssVariables } from 'twenty-ui/theme-constants';
@ -11,19 +11,26 @@ import { DEFAULT_NAVIGATION_MENU_ITEM_COLOR_LINK } from '@/navigation-menu-item/
import { NavigationMenuItemType } from '@/navigation-menu-item/constants/NavigationMenuItemType';
import type { AddToNavigationDragPayload } from '@/navigation-menu-item/types/add-to-navigation-drag-payload';
const StyledIconSlot = styled.div<{ $hasFixedSize: boolean }>`
const StyledIconSlot = styled.div<{
$hasFixedSize: boolean;
$disabled?: boolean;
$disableDrag?: boolean;
}>`
align-items: center;
cursor: grab;
cursor: ${({ $disabled, $disableDrag }) =>
$disabled || $disableDrag ? 'default' : 'grab'};
display: flex;
flex-shrink: 0;
height: ${({ $hasFixedSize }) =>
$hasFixedSize ? themeCssVariables.spacing[4] : 'auto'};
justify-content: center;
opacity: ${({ $disabled }) => ($disabled ? 0.5 : 1)};
width: ${({ $hasFixedSize }) =>
$hasFixedSize ? themeCssVariables.spacing[4] : 'auto'};
&:active {
cursor: grabbing;
cursor: ${({ $disabled, $disableDrag }) =>
$disabled || $disableDrag ? 'default' : 'grabbing'};
}
`;
@ -63,6 +70,8 @@ type AddToNavigationDragHandleProps = {
customIconContent?: ReactNode;
payload: AddToNavigationDragPayload;
isHovered: boolean;
disabled?: boolean;
disableDrag?: boolean;
};
export const AddToNavigationDragHandle = ({
@ -70,6 +79,8 @@ export const AddToNavigationDragHandle = ({
customIconContent,
payload,
isHovered,
disabled = false,
disableDrag = false,
}: AddToNavigationDragHandleProps) => {
const { theme } = useContext(ThemeContext);
const effectiveColor =
@ -89,6 +100,8 @@ export const AddToNavigationDragHandle = ({
return (
<StyledIconSlot
$hasFixedSize={hasBackgroundColor || showCustomContentWithoutWrapper}
$disabled={disabled}
$disableDrag={disableDrag}
>
{isHovered ? (
<IconGripVertical

View file

@ -3,6 +3,7 @@ import { useLingui } from '@lingui/react/macro';
import { isDefined } from 'twenty-shared/utils';
import { IconFolderPlus } from 'twenty-ui/display';
import { LightIconButton } from 'twenty-ui/input';
import { AnimatedExpandableContainer } from 'twenty-ui/layout';
import { CurrentWorkspaceMemberOrphanNavigationMenuItems } from '@/navigation-menu-item/components/CurrentWorkspaceMemberOrphanNavigationMenuItems';
import { NavigationMenuItemFolders } from '@/navigation-menu-item/components/NavigationMenuItemFolders';
import { NavigationMenuItemSkeletonLoader } from '@/navigation-menu-item/components/NavigationMenuItemSkeletonLoader';
@ -70,16 +71,19 @@ export const CurrentWorkspaceMemberNavigationMenuItemFolders = () => {
accent="tertiary"
/>
}
isOpen={isNavigationSectionOpen}
/>
</NavigationDrawerAnimatedCollapseWrapper>
{isNavigationSectionOpen && (
<>
<NavigationMenuItemFolders
isNavigationSectionOpen={isNavigationSectionOpen}
/>
<CurrentWorkspaceMemberOrphanNavigationMenuItems />
</>
)}
<AnimatedExpandableContainer
isExpanded={isNavigationSectionOpen}
dimension="height"
mode="fit-content"
containAnimation
initial={false}
>
<NavigationMenuItemFolders />
<CurrentWorkspaceMemberOrphanNavigationMenuItems />
</AnimatedExpandableContainer>
</NavigationDrawerSection>
);
};

View file

@ -2,16 +2,31 @@ import { FeatureFlagKey } from '~/generated-metadata/graphql';
import { CurrentWorkspaceMemberFavoritesFolders } from '@/favorites/components/CurrentWorkspaceMemberFavoritesFolders';
import { CurrentWorkspaceMemberNavigationMenuItemFolders } from '@/navigation-menu-item/components/CurrentWorkspaceMemberNavigationMenuItemFolders';
import { isNavigationMenuInEditModeState } from '@/navigation-menu-item/states/isNavigationMenuInEditModeState';
import { FavoritesDragDropProviderContent } from '@/navigation/components/FavoritesDragDropProviderContent';
import { useAtomStateValue } from '@/ui/utilities/state/jotai/hooks/useAtomStateValue';
import { useIsFeatureEnabled } from '@/workspace/hooks/useIsFeatureEnabled';
import { AnimatedEaseInOut } from 'twenty-ui/utilities';
export const CurrentWorkspaceMemberNavigationMenuItemFoldersDispatcher = () => {
const isNavigationMenuItemEditingEnabled = useIsFeatureEnabled(
FeatureFlagKey.IS_NAVIGATION_MENU_ITEM_EDITING_ENABLED,
);
const isNavigationMenuInEditMode = useAtomStateValue(
isNavigationMenuInEditModeState,
);
if (isNavigationMenuItemEditingEnabled) {
return <CurrentWorkspaceMemberNavigationMenuItemFolders />;
}
return <CurrentWorkspaceMemberFavoritesFolders />;
return (
<AnimatedEaseInOut isOpen={!isNavigationMenuInEditMode} initial>
{isNavigationMenuItemEditingEnabled ? (
<FavoritesDragDropProviderContent>
<CurrentWorkspaceMemberNavigationMenuItemFolders />
</FavoritesDragDropProviderContent>
) : (
<FavoritesDragDropProviderContent>
<CurrentWorkspaceMemberFavoritesFolders />
</FavoritesDragDropProviderContent>
)}
</AnimatedEaseInOut>
);
};

View file

@ -291,9 +291,10 @@ export const CurrentWorkspaceMemberNavigationMenuItems = ({
isWorkspaceFolder ? undefined : (
<LightIconButton
Icon={IconHeartOff}
onClick={() =>
deleteNavigationMenuItem(navigationMenuItem.id)
}
onClick={(e) => {
e.stopPropagation();
deleteNavigationMenuItem(navigationMenuItem.id);
}}
accent="tertiary"
/>
)

View file

@ -85,9 +85,10 @@ export const CurrentWorkspaceMemberOrphanNavigationMenuItems = () => {
rightOptions={
<LightIconButton
Icon={IconHeartOff}
onClick={() =>
deleteNavigationMenuItem(navigationMenuItem.id)
}
onClick={(e) => {
e.stopPropagation();
deleteNavigationMenuItem(navigationMenuItem.id);
}}
accent="tertiary"
/>
}

View file

@ -0,0 +1,122 @@
import { styled } from '@linaria/react';
import { useState } from 'react';
import { isDefined } from 'twenty-shared/utils';
import type { IconComponent } from 'twenty-ui/display';
import { themeCssVariables } from 'twenty-ui/theme-constants';
import { DEFAULT_NAVIGATION_MENU_ITEM_COLOR_LINK } from '@/navigation-menu-item/constants/NavigationMenuItemDefaultColorLink';
import { getLinkFaviconUrl } from '@/navigation-menu-item/utils/getLinkFaviconUrl';
import { getNavigationMenuItemIconStyleFromColor } from '@/navigation-menu-item/utils/getNavigationMenuItemIconStyleFromColor';
const failedFaviconUrls = new Set<string>();
const StyledCompositeContainer = styled.div`
align-items: center;
border-radius: 4px;
box-sizing: border-box;
display: flex;
flex-shrink: 0;
height: 16px;
justify-content: center;
position: relative;
width: 16px;
`;
const StyledMainIconWrapper = styled.div<{
$backgroundColor: string;
$borderColor?: string;
$noBackgroundOrBorder?: boolean;
}>`
align-items: center;
background-color: ${({ $backgroundColor, $noBackgroundOrBorder }) =>
$noBackgroundOrBorder ? 'transparent' : $backgroundColor};
border: ${({ $borderColor, $noBackgroundOrBorder }) =>
$noBackgroundOrBorder || !$borderColor
? 'none'
: `1px solid ${$borderColor}`};
border-radius: 4px;
box-sizing: border-box;
display: flex;
inset: 0;
justify-content: center;
overflow: hidden;
position: absolute;
`;
const StyledFaviconImage = styled.img`
height: 100%;
object-fit: contain;
width: 100%;
`;
const StyledLinkOverlay = styled.div<{ $backgroundColor: string }>`
align-items: center;
background-color: ${({ $backgroundColor }) => $backgroundColor};
border-radius: ${themeCssVariables.border.radius.xs};
bottom: -5px;
display: flex;
height: 14px;
justify-content: center;
position: absolute;
right: -6px;
width: 14px;
`;
export type LinkIconWithLinkOverlayProps = {
link: string | null | undefined;
LinkIcon: IconComponent;
DefaultIcon: IconComponent;
color?: string | null;
};
export const LinkIconWithLinkOverlay = ({
link,
LinkIcon,
DefaultIcon,
color: navItemColor,
}: LinkIconWithLinkOverlayProps) => {
const [localFailedLink, setLocalFailedLink] = useState<string | null>(null);
const faviconUrl = getLinkFaviconUrl(link);
const linkKey = link ?? '';
const isKnownFailed = failedFaviconUrls.has(linkKey);
const showFavicon =
isDefined(faviconUrl) && !isKnownFailed && localFailedLink !== linkKey;
const linkStyle = getNavigationMenuItemIconStyleFromColor(
navItemColor ?? DEFAULT_NAVIGATION_MENU_ITEM_COLOR_LINK,
);
return (
<StyledCompositeContainer>
<StyledMainIconWrapper
$backgroundColor={linkStyle.backgroundColor}
$borderColor={linkStyle.borderColor}
$noBackgroundOrBorder={showFavicon}
>
{showFavicon ? (
<StyledFaviconImage
src={faviconUrl}
alt=""
onError={() => {
if (isDefined(link)) failedFaviconUrls.add(link);
setLocalFailedLink(linkKey);
}}
/>
) : (
<DefaultIcon
size="14px"
stroke={themeCssVariables.icon.stroke.md}
color={linkStyle.iconColor}
/>
)}
</StyledMainIconWrapper>
<StyledLinkOverlay $backgroundColor={themeCssVariables.grayScale.gray4}>
<LinkIcon
size="14px"
stroke={themeCssVariables.icon.stroke.md}
color={themeCssVariables.grayScale.gray10}
/>
</StyledLinkOverlay>
</StyledCompositeContainer>
);
};

View file

@ -5,19 +5,13 @@ import { themeCssVariables } from 'twenty-ui/theme-constants';
import { type NavigationSections } from '@/navigation-menu-item/constants/NavigationSections.constants';
import { NavigationDropTargetContext } from '@/navigation-menu-item/contexts/NavigationDropTargetContext';
const StyledDropTarget = styled.div<{
$isDragOver: boolean;
$isDropForbidden: boolean;
$compact?: boolean;
}>`
const StyledDropTarget = styled.div<{ $compact?: boolean }>`
min-height: ${({ $compact }) =>
$compact ? 0 : themeCssVariables.spacing[2]};
position: relative;
transition: all 150ms ease-in-out;
${({ $isDragOver }) =>
$isDragOver
? `
&[data-drag-over='true'] {
background-color: ${themeCssVariables.background.transparent.blue};
&::before {
@ -28,29 +22,14 @@ const StyledDropTarget = styled.div<{
width: 100%;
height: 2px;
background-color: ${themeCssVariables.color.blue};
border-radius: ${themeCssVariables.border.radius.sm} ${themeCssVariables.border.radius.sm} 0 0;
border-radius: ${themeCssVariables.border.radius.sm}
${themeCssVariables.border.radius.sm} 0 0;
}
`
: ''}
}
${({ $isDropForbidden }) =>
$isDropForbidden
? `
background-color: ${themeCssVariables.background.transparent.danger};
&[data-drop-forbidden='true'] {
cursor: not-allowed;
&::after {
content: '';
position: absolute;
bottom: 0;
left: 0;
width: 100%;
height: 2px;
background-color: ${themeCssVariables.color.red};
border-radius: ${themeCssVariables.border.radius.sm} ${themeCssVariables.border.radius.sm} 0 0;
}
`
: ''}
}
`;
type NavigationItemDropTargetProps = {
@ -59,6 +38,7 @@ type NavigationItemDropTargetProps = {
sectionId: NavigationSections;
children?: ReactNode;
compact?: boolean;
dropTargetIdOverride?: string;
};
export const NavigationItemDropTarget = ({
@ -67,19 +47,21 @@ export const NavigationItemDropTarget = ({
sectionId,
children,
compact = false,
dropTargetIdOverride,
}: NavigationItemDropTargetProps) => {
const { activeDropTargetId, forbiddenDropTargetId } = useContext(
NavigationDropTargetContext,
);
const dropTargetId = `${sectionId}-${folderId ?? 'orphan'}-${index}`;
const dropTargetId =
dropTargetIdOverride ?? `${sectionId}-${folderId ?? 'orphan'}-${index}`;
const isDragOver = activeDropTargetId === dropTargetId;
const isDropForbidden = forbiddenDropTargetId === dropTargetId;
return (
<StyledDropTarget
$isDragOver={isDragOver}
$isDropForbidden={isDropForbidden}
$compact={compact}
data-drag-over={isDragOver && !isDropForbidden ? 'true' : undefined}
data-drop-forbidden={isDropForbidden ? 'true' : undefined}
>
{children}
</StyledDropTarget>

View file

@ -1,17 +1,18 @@
import { useSidePanelMenu } from '@/side-panel/hooks/useSidePanelMenu';
import { sidePanelPageState } from '@/side-panel/states/sidePanelPageState';
import { useNavigationMenuItemsDraftState } from '@/navigation-menu-item/hooks/useNavigationMenuItemsDraftState';
import { useSaveNavigationMenuItemsDraft } from '@/navigation-menu-item/hooks/useSaveNavigationMenuItemsDraft';
import { isNavigationMenuInEditModeState } from '@/navigation-menu-item/states/isNavigationMenuInEditModeState';
import { navigationMenuItemsDraftState } from '@/navigation-menu-item/states/navigationMenuItemsDraftState';
import { selectedNavigationMenuItemInEditModeState } from '@/navigation-menu-item/states/selectedNavigationMenuItemInEditModeState';
import { SaveAndCancelButtons } from '@/settings/components/SaveAndCancelButtons/SaveAndCancelButtons';
import { useSidePanelMenu } from '@/side-panel/hooks/useSidePanelMenu';
import { sidePanelPageState } from '@/side-panel/states/sidePanelPageState';
import { useSnackBar } from '@/ui/feedback/snack-bar-manager/hooks/useSnackBar';
import { useAtomStateValue } from '@/ui/utilities/state/jotai/hooks/useAtomStateValue';
import { useSetAtomState } from '@/ui/utilities/state/jotai/hooks/useSetAtomState';
import { useIsFeatureEnabled } from '@/workspace/hooks/useIsFeatureEnabled';
import { styled } from '@linaria/react';
import { useLingui } from '@lingui/react/macro';
import { AnimatePresence, motion } from 'framer-motion';
import { useContext, useState } from 'react';
import { SidePanelPages } from 'twenty-shared/types';
import { IconCheck, useIcons } from 'twenty-ui/display';
@ -96,24 +97,34 @@ export const NavigationMenuEditModeBar = () => {
const IconPaint = getIcon('IconPaint');
if (!showNavigationMenuEditModeBar) {
return null;
}
return (
<StyledContainer>
<StyledTitle>
<IconPaint size={theme.icon.size.md} />
{t`Layout customization`}
</StyledTitle>
<SaveAndCancelButtons
onSave={handleSave}
onCancel={cancelEditMode}
isSaveDisabled={!isDirty || isSaving}
isLoading={isSaving}
inverted
saveIcon={IconCheck}
/>
</StyledContainer>
<AnimatePresence>
{showNavigationMenuEditModeBar && (
<motion.div
initial={{ height: 0, opacity: 0 }}
animate={{ height: 'auto', opacity: 1 }}
exit={{ height: 0, opacity: 0 }}
transition={{
duration: theme.animation.duration.normal,
ease: 'easeInOut',
}}
>
<StyledContainer>
<StyledTitle>
<IconPaint size={theme.icon.size.md} />
{t`Layout customization`}
</StyledTitle>
<SaveAndCancelButtons
onSave={handleSave}
onCancel={cancelEditMode}
isSaveDisabled={!isDirty || isSaving}
isLoading={isSaving}
inverted
saveIcon={IconCheck}
/>
</StyledContainer>
</motion.div>
)}
</AnimatePresence>
);
};

View file

@ -1,5 +1,5 @@
import { styled } from '@linaria/react';
import { Droppable } from '@hello-pangea/dnd';
import { styled } from '@linaria/react';
import { themeCssVariables } from 'twenty-ui/theme-constants';
import { useIsDropDisabledForSection } from '@/navigation-menu-item/hooks/useIsDropDisabledForSection';
@ -12,38 +12,26 @@ type NavigationMenuItemDroppableProps = {
isWorkspaceSection?: boolean;
};
const StyledDroppableWrapper = styled.div<{
isDraggingOver: boolean;
isDragIndicatorVisible: boolean;
showDropLine: boolean;
}>`
const StyledDroppableWrapper = styled.div`
position: relative;
transition: all 150ms ease-in-out;
width: 100%;
${({ isDraggingOver, isDragIndicatorVisible, showDropLine }) =>
isDraggingOver && isDragIndicatorVisible
? `
background-color: ${themeCssVariables.background.transparent.blue};
&[data-dragging-over='true'] {
background-color: ${themeCssVariables.background.transparent.blue};
}
${
showDropLine
? `
&::before {
content: '';
position: absolute;
bottom: 0;
left: 0;
width: 100%;
height: 2px;
background-color: ${themeCssVariables.color.blue};
border-radius: ${themeCssVariables.border.radius.sm} ${themeCssVariables.border.radius.sm} 0 0;
}
`
: ''
}
`
: ''}
&[data-dragging-over='true'][data-show-drop-line='true']::before {
background-color: ${themeCssVariables.color.blue};
border-radius: ${themeCssVariables.border.radius.sm}
${themeCssVariables.border.radius.sm} 0 0;
bottom: 0;
content: '';
height: 2px;
left: 0;
position: absolute;
width: 100%;
}
`;
export const NavigationMenuItemDroppable = ({
@ -59,9 +47,12 @@ export const NavigationMenuItemDroppable = ({
<Droppable droppableId={droppableId} isDropDisabled={isDropDisabled}>
{(provided, snapshot) => (
<StyledDroppableWrapper
isDraggingOver={snapshot.isDraggingOver}
isDragIndicatorVisible={isDragIndicatorVisible}
showDropLine={showDropLine}
data-dragging-over={
snapshot.isDraggingOver && isDragIndicatorVisible
? 'true'
: undefined
}
data-show-drop-line={showDropLine ? 'true' : undefined}
>
<div
ref={provided.innerRef}

View file

@ -58,9 +58,10 @@ export const NavigationMenuItemFolderContent = ({
rightOptions={
<LightIconButton
Icon={IconHeartOff}
onClick={() =>
deleteNavigationMenuItem(navigationMenuItem.id)
}
onClick={(e) => {
e.stopPropagation();
deleteNavigationMenuItem(navigationMenuItem.id);
}}
accent="tertiary"
/>
}

View file

@ -9,13 +9,7 @@ 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';
type NavigationMenuItemFoldersProps = {
isNavigationSectionOpen: boolean;
};
export const NavigationMenuItemFolders = ({
isNavigationSectionOpen,
}: NavigationMenuItemFoldersProps) => {
export const NavigationMenuItemFolders = () => {
const [newFolderName, setNewFolderName] = useState('');
const { userNavigationMenuItemsByFolder } = useNavigationMenuItemsByFolder();
@ -61,10 +55,6 @@ export const NavigationMenuItemFolders = ({
setIsNavigationMenuItemFolderCreating(false);
};
if (!isNavigationSectionOpen) {
return null;
}
return (
<>
{isNavigationMenuItemFolderCreating && (

View file

@ -1,9 +1,10 @@
import { isNonEmptyString } from '@sniptt/guards';
import { isDefined } from 'twenty-shared/utils';
import { Avatar, useIcons } from 'twenty-ui/display';
import { Avatar, IconLink, IconWorld, useIcons } from 'twenty-ui/display';
import { themeCssVariables } from 'twenty-ui/theme-constants';
import { FeatureFlagKey } from '~/generated-metadata/graphql';
import { LinkIconWithLinkOverlay } from '@/navigation-menu-item/components/LinkIconWithLinkOverlay';
import { StyledNavigationMenuItemIconContainer } from '@/navigation-menu-item/components/NavigationMenuItemIconContainer';
import { ObjectIconWithViewOverlay } from '@/navigation-menu-item/components/ObjectIconWithViewOverlay';
import { NavigationMenuItemType } from '@/navigation-menu-item/constants/NavigationMenuItemType';
@ -63,6 +64,17 @@ export const NavigationMenuItemIcon = ({
);
}
if (navigationMenuItem.itemType === NavigationMenuItemType.LINK) {
return (
<LinkIconWithLinkOverlay
link={navigationMenuItem.link}
LinkIcon={IconLink}
DefaultIcon={IconWorld}
color={getEffectiveNavigationMenuItemColor(navigationMenuItem)}
/>
);
}
const iconToUse =
StandardIcon ??
(navigationMenuItem.Icon ? getIcon(navigationMenuItem.Icon) : undefined);

View file

@ -1,6 +1,6 @@
import { styled } from '@linaria/react';
import type { IconComponent } from 'twenty-ui/display';
import { useContext } from 'react';
import type { IconComponent } from 'twenty-ui/display';
import { ThemeContext, themeCssVariables } from 'twenty-ui/theme-constants';
import { getNavigationMenuItemIconStyleFromColor } from '@/navigation-menu-item/utils/getNavigationMenuItemIconStyleFromColor';
@ -37,13 +37,13 @@ const StyledViewOverlay = styled.div<{ $backgroundColor: string }>`
align-items: center;
background-color: ${({ $backgroundColor }) => $backgroundColor};
border-radius: 4px;
bottom: -7px;
bottom: -5px;
display: flex;
height: 14px;
justify-content: center;
position: absolute;
right: -7px;
width: 14px;
right: -6px;
width: 12px;
`;
export type ObjectIconWithViewOverlayProps = {
@ -74,8 +74,8 @@ export const ObjectIconWithViewOverlay = ({
</StyledObjectIconWrapper>
<StyledViewOverlay $backgroundColor={themeCssVariables.grayScale.gray4}>
<ViewIcon
size="10px"
stroke={theme.icon.stroke.lg}
size="12px"
stroke={theme.icon.stroke.md}
color={themeCssVariables.grayScale.gray10}
/>
</StyledViewOverlay>

View file

@ -0,0 +1,51 @@
import { useDroppable } from '@dnd-kit/react';
import { styled } from '@linaria/react';
import { type ReactNode } from 'react';
import { themeCssVariables } from 'twenty-ui/theme-constants';
import { getDndKitDropTargetId } from '@/navigation-menu-item/utils/getDndKitDropTargetId';
import type { DroppableData } from '@/navigation/types/workspaceDndKitDroppableData';
const StyledSlotWrapper = styled.div<{ $empty: boolean }>`
min-height: 0;
${({ $empty }) =>
$empty ? `min-height: ${themeCssVariables.spacing[2]};` : ''}
`;
const SLOT_COLLISION_PRIORITY = 1;
export const FOLDER_HEADER_SLOT_COLLISION_PRIORITY = 2;
type WorkspaceDndKitDroppableSlotProps = {
droppableId: string;
index: number;
children?: ReactNode;
disabled?: boolean;
collisionPriority?: number;
};
export const WorkspaceDndKitDroppableSlot = ({
droppableId,
index,
children,
disabled = false,
collisionPriority = SLOT_COLLISION_PRIORITY,
}: WorkspaceDndKitDroppableSlotProps) => {
const id = getDndKitDropTargetId(droppableId, index);
const data: DroppableData = { droppableId, index };
const { ref } = useDroppable({
id,
disabled,
collisionPriority,
data,
});
const isEmpty =
children == null || (Array.isArray(children) && children.length === 0);
return (
<StyledSlotWrapper ref={ref} $empty={isEmpty}>
{children}
</StyledSlotWrapper>
);
};

View file

@ -0,0 +1,59 @@
import { SortableKeyboardPlugin } from '@dnd-kit/dom/sortable';
import { useSortable } from '@dnd-kit/react/sortable';
import { styled } from '@linaria/react';
import { type ReactNode } from 'react';
import { SortableDropTargetRefContext } from '@/navigation-menu-item/contexts/SortableDropTargetRefContext';
const SORTABLE_COLLISION_PRIORITY = 3;
const PLUGINS_WITHOUT_OPTIMISTIC = [SortableKeyboardPlugin];
const StyledSortableRoot = styled.div`
min-height: 0;
position: relative;
`;
type WorkspaceDndKitSortableItemProps = {
children: ReactNode;
disabled?: boolean;
group: string;
id: string;
index: number;
};
export const WorkspaceDndKitSortableItem = ({
id,
index,
group,
disabled = false,
children,
}: WorkspaceDndKitSortableItemProps) => {
const { handleRef, ref, targetRef } = useSortable({
id,
index,
group,
collisionPriority: SORTABLE_COLLISION_PRIORITY,
data: {
sourceDroppableId: group,
sourceIndex: index,
},
disabled,
transition: null,
plugins: PLUGINS_WITHOUT_OPTIMISTIC,
feedback: 'clone',
});
return (
<SortableDropTargetRefContext.Provider value={targetRef}>
<StyledSortableRoot
ref={(el) => {
ref(el);
handleRef?.(el);
}}
>
{children}
</StyledSortableRoot>
</SortableDropTargetRefContext.Provider>
);
};

View file

@ -0,0 +1,101 @@
import { isDefined } from 'twenty-shared/utils';
import { NavigationMenuItemIcon } from '@/navigation-menu-item/components/NavigationMenuItemIcon';
import { NavigationMenuItemType } from '@/navigation-menu-item/constants/NavigationMenuItemType';
import { type NavigationMenuItemClickParams } from '@/navigation-menu-item/hooks/useWorkspaceSectionItems';
import { isNavigationMenuInEditModeState } from '@/navigation-menu-item/states/isNavigationMenuInEditModeState';
import { getNavigationMenuItemSecondaryLabel } from '@/navigation-menu-item/utils/getNavigationMenuItemSecondaryLabel';
import { getObjectMetadataForNavigationMenuItem } from '@/navigation-menu-item/utils/getObjectMetadataForNavigationMenuItem';
import { type ProcessedNavigationMenuItem } from '@/navigation-menu-item/utils/sortNavigationMenuItems';
import { objectMetadataItemsState } from '@/object-metadata/states/objectMetadataItemsState';
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';
import { coreViewsState } from '@/views/states/coreViewState';
import { ViewKey } from '@/views/types/ViewKey';
import { convertCoreViewToView } from '@/views/utils/convertCoreViewToView';
type WorkspaceNavigationMenuItemFolderSubItemProps = {
navigationMenuItem: ProcessedNavigationMenuItem;
index: number;
arrayLength: number;
selectedNavigationMenuItemIndex: number;
onNavigationMenuItemClick?: (params: NavigationMenuItemClickParams) => void;
selectedNavigationMenuItemId: string | null;
isContextDragging: boolean;
};
export const WorkspaceNavigationMenuItemFolderSubItem = ({
navigationMenuItem,
index,
arrayLength,
selectedNavigationMenuItemIndex,
onNavigationMenuItemClick,
selectedNavigationMenuItemId,
isContextDragging,
}: WorkspaceNavigationMenuItemFolderSubItemProps) => {
const isNavigationMenuInEditMode = useAtomStateValue(
isNavigationMenuInEditModeState,
);
const objectMetadataItems = useAtomStateValue(objectMetadataItemsState);
const coreViews = useAtomStateValue(coreViewsState);
const views = coreViews.map(convertCoreViewToView);
const objectMetadataItem =
navigationMenuItem.itemType === NavigationMenuItemType.VIEW ||
navigationMenuItem.itemType === NavigationMenuItemType.RECORD
? getObjectMetadataForNavigationMenuItem(
navigationMenuItem,
objectMetadataItems,
views,
)
: null;
const isEditableInEditMode =
isNavigationMenuInEditMode &&
isDefined(onNavigationMenuItemClick) &&
(navigationMenuItem.itemType === NavigationMenuItemType.LINK ||
isDefined(objectMetadataItem));
const handleEditModeClick =
isEditableInEditMode && isDefined(onNavigationMenuItemClick)
? () =>
onNavigationMenuItemClick({
item: navigationMenuItem,
objectMetadataItem: objectMetadataItem ?? undefined,
})
: undefined;
return (
<NavigationDrawerSubItem
secondaryLabel={
navigationMenuItem.viewKey === ViewKey.Index
? undefined
: getNavigationMenuItemSecondaryLabel({
objectMetadataItems,
navigationMenuItemObjectNameSingular:
navigationMenuItem.objectNameSingular,
})
}
label={navigationMenuItem.labelIdentifier}
Icon={() => (
<NavigationMenuItemIcon navigationMenuItem={navigationMenuItem} />
)}
to={
isContextDragging || handleEditModeClick
? undefined
: navigationMenuItem.link
}
onClick={handleEditModeClick}
active={index === selectedNavigationMenuItemIndex}
isSelectedInEditMode={
selectedNavigationMenuItemId === navigationMenuItem.id
}
subItemState={getNavigationSubItemLeftAdornment({
index,
arrayLength,
selectedIndex: selectedNavigationMenuItemIndex,
})}
isDragging={isContextDragging}
triggerEvent="CLICK"
/>
);
};

View file

@ -24,6 +24,7 @@ import { navigationMenuItemsDraftState } from '@/navigation-menu-item/states/nav
import { openNavigationMenuItemFolderIdsState } from '@/navigation-menu-item/states/openNavigationMenuItemFolderIdsState';
import { selectedNavigationMenuItemInEditModeState } from '@/navigation-menu-item/states/selectedNavigationMenuItemInEditModeState';
import { filterWorkspaceNavigationMenuItems } from '@/navigation-menu-item/utils/filterWorkspaceNavigationMenuItems';
import { preloadWorkspaceDndKit } from '@/navigation/preloadWorkspaceDndKit';
import { NavigationDrawerSectionForObjectMetadataItemsSkeletonLoader } from '@/object-metadata/components/NavigationDrawerSectionForObjectMetadataItemsSkeletonLoader';
import { NavigationDrawerSectionForWorkspaceItems } from '@/object-metadata/components/NavigationDrawerSectionForWorkspaceItems';
import { type ObjectMetadataItem } from '@/object-metadata/types/ObjectMetadataItem';
@ -137,9 +138,6 @@ export const WorkspaceNavigationMenuItems = () => {
});
};
const isEditMode =
isNavigationMenuItemEditingEnabled && isNavigationMenuInEditMode;
if (loading) {
return <NavigationDrawerSectionForObjectMetadataItemsSkeletonLoader />;
}
@ -151,7 +149,7 @@ export const WorkspaceNavigationMenuItems = () => {
rightIcon={
isNavigationMenuItemEditingEnabled ? (
<StyledRightIconsContainer>
{isEditMode ? (
{isNavigationMenuInEditMode ? (
<LightIconButton
Icon={IconPlus}
accent="tertiary"
@ -159,25 +157,21 @@ export const WorkspaceNavigationMenuItems = () => {
onClick={handleAddMenuItem}
/>
) : (
<LightIconButton
Icon={IconTool}
accent="tertiary"
size="small"
onClick={handleEditClick}
/>
<div onMouseEnter={preloadWorkspaceDndKit}>
<LightIconButton
Icon={IconTool}
accent="tertiary"
size="small"
onClick={handleEditClick}
/>
</div>
)}
</StyledRightIconsContainer>
) : undefined
}
onAddMenuItem={
isNavigationMenuItemEditingEnabled && isEditMode
? handleAddMenuItem
: undefined
}
isEditMode={isEditMode}
selectedNavigationMenuItemId={selectedNavigationMenuItemInEditMode}
onNavigationMenuItemClick={
isEditMode ? handleNavigationMenuItemClick : undefined
isNavigationMenuInEditMode ? handleNavigationMenuItemClick : undefined
}
onActiveObjectMetadataItemClick={
isNavigationMenuItemEditingEnabled

View file

@ -1,9 +1,7 @@
import { styled } from '@linaria/react';
import { Droppable } from '@hello-pangea/dnd';
import { useLingui } from '@lingui/react/macro';
import { isNonEmptyString } from '@sniptt/guards';
import { useContext } from 'react';
import { useLocation, useNavigate } from 'react-router-dom';
import React, { useContext, useState } from 'react';
import { SidePanelPages } from 'twenty-shared/types';
import { isDefined } from 'twenty-shared/utils';
import {
IconChevronDown,
@ -15,36 +13,35 @@ import { ThemeContext, themeCssVariables } from 'twenty-ui/theme-constants';
import { useIsDropDisabledForSection } from '@/navigation-menu-item/hooks/useIsDropDisabledForSection';
import { useOpenAddItemToFolderPage } from '@/navigation-menu-item/hooks/useOpenAddItemToFolderPage';
import { useWorkspaceFolderOpenState } from '@/navigation-menu-item/hooks/useWorkspaceFolderOpenState';
import { addMenuItemInsertionContextState } from '@/navigation-menu-item/states/addMenuItemInsertionContextState';
import { sidePanelPageState } from '@/side-panel/states/sidePanelPageState';
import { AnimatedExpandableContainer } from 'twenty-ui/layout';
import { useIsMobile } from 'twenty-ui/utilities';
import { NavigationMenuItemDroppable } from '@/navigation-menu-item/components/NavigationMenuItemDroppable';
import { NavigationMenuItemIcon } from '@/navigation-menu-item/components/NavigationMenuItemIcon';
import { WorkspaceNavigationMenuItemFolderDragClone } from '@/navigation-menu-item/components/WorkspaceNavigationMenuItemFolderDragClone';
import { NavigationItemDropTarget } from '@/navigation-menu-item/components/NavigationItemDropTarget';
import {
FOLDER_HEADER_SLOT_COLLISION_PRIORITY,
WorkspaceDndKitDroppableSlot,
} from '@/navigation-menu-item/components/WorkspaceDndKitDroppableSlot';
import { WorkspaceDndKitSortableItem } from '@/navigation-menu-item/components/WorkspaceDndKitSortableItem';
import { WorkspaceNavigationMenuItemFolderSubItem } from '@/navigation-menu-item/components/WorkspaceNavigationMenuItemFolderSubItem';
import { FOLDER_ICON_DEFAULT } from '@/navigation-menu-item/constants/FolderIconDefault';
import { DEFAULT_NAVIGATION_MENU_ITEM_COLOR_FOLDER } from '@/navigation-menu-item/constants/NavigationMenuItemDefaultColorFolder';
import { NavigationMenuItemDroppableIds } from '@/navigation-menu-item/constants/NavigationMenuItemDroppableIds';
import { NavigationMenuItemType } from '@/navigation-menu-item/constants/NavigationMenuItemType';
import { NavigationSections } from '@/navigation-menu-item/constants/NavigationSections.constants';
import { NavigationDropTargetContext } from '@/navigation-menu-item/contexts/NavigationDropTargetContext';
import { NavigationMenuItemDragContext } from '@/navigation-menu-item/contexts/NavigationMenuItemDragContext';
import { SortableDropTargetRefContext } from '@/navigation-menu-item/contexts/SortableDropTargetRefContext';
import { type NavigationMenuItemClickParams } from '@/navigation-menu-item/hooks/useWorkspaceSectionItems';
import { openNavigationMenuItemFolderIdsState } from '@/navigation-menu-item/states/openNavigationMenuItemFolderIdsState';
import { getNavigationMenuItemSecondaryLabel } from '@/navigation-menu-item/utils/getNavigationMenuItemSecondaryLabel';
import { getObjectMetadataForNavigationMenuItem } from '@/navigation-menu-item/utils/getObjectMetadataForNavigationMenuItem';
import { isLocationMatchingNavigationMenuItem } from '@/navigation-menu-item/utils/isLocationMatchingNavigationMenuItem';
import { isNavigationMenuInEditModeState } from '@/navigation-menu-item/states/isNavigationMenuInEditModeState';
import { getDndKitDropTargetId } from '@/navigation-menu-item/utils/getDndKitDropTargetId';
import { type ProcessedNavigationMenuItem } from '@/navigation-menu-item/utils/sortNavigationMenuItems';
import { objectMetadataItemsState } from '@/object-metadata/states/objectMetadataItemsState';
import { DraggableItem } from '@/ui/layout/draggable-list/components/DraggableItem';
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 '@/ui/navigation/navigation-drawer/states/currentNavigationMenuItemFolderIdState';
import { getNavigationSubItemLeftAdornment } from '@/ui/navigation/navigation-drawer/utils/getNavigationSubItemLeftAdornment';
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 { coreViewsState } from '@/views/states/coreViewState';
import { ViewKey } from '@/views/types/ViewKey';
import { convertCoreViewToView } from '@/views/utils/convertCoreViewToView';
const StyledFolderContainer = styled.div<{ $isSelectedInEditMode: boolean }>`
border: ${({ $isSelectedInEditMode }) =>
@ -52,15 +49,20 @@ const StyledFolderContainer = styled.div<{ $isSelectedInEditMode: boolean }>`
? `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<{
$compact: boolean;
}>`
const StyledFolderDroppableContent = styled.div`
display: flex;
flex-direction: column;
padding-bottom: ${({ $compact }) =>
$compact ? 0 : themeCssVariables.spacing[2]};
`;
const StyledFolderExpandableWrapper = styled.div`
@ -76,7 +78,6 @@ type WorkspaceNavigationMenuItemsFolderProps = {
folderColor?: string | null;
navigationMenuItems: ProcessedNavigationMenuItem[];
isGroup: boolean;
isEditMode?: boolean;
isSelectedInEditMode?: boolean;
onEditModeClick?: () => void;
onNavigationMenuItemClick?: (params: NavigationMenuItemClickParams) => void;
@ -91,40 +92,31 @@ export const WorkspaceNavigationMenuItemsFolder = ({
folderColor,
navigationMenuItems,
isGroup,
isEditMode = false,
isSelectedInEditMode = false,
onEditModeClick,
onNavigationMenuItemClick,
selectedNavigationMenuItemId = null,
isDragging = false,
}: WorkspaceNavigationMenuItemsFolderProps) => {
const isNavigationMenuInEditMode = useAtomStateValue(
isNavigationMenuInEditModeState,
);
const { theme } = useContext(ThemeContext);
const { getIcon } = useIcons();
const FolderIcon = getIcon(folderIconKey ?? FOLDER_ICON_DEFAULT);
const objectMetadataItems = useAtomStateValue(objectMetadataItemsState);
const coreViews = useAtomStateValue(coreViewsState);
const views = coreViews.map(convertCoreViewToView);
const location = useLocation();
const navigate = useNavigate();
const currentPath = location.pathname;
const currentViewPath = location.pathname + location.search;
const isMobile = useIsMobile();
const { t } = useLingui();
const [openNavigationMenuItemFolderIds, setOpenNavigationMenuItemFolderIds] =
useAtomState(openNavigationMenuItemFolderIdsState);
const setCurrentNavigationMenuItemFolderId = useSetAtomState(
currentNavigationMenuItemFolderIdState,
);
const { isOpen, handleToggle, selectedNavigationMenuItemIndex } =
useWorkspaceFolderOpenState({ folderId, navigationMenuItems });
const { openAddItemToFolderPage } = useOpenAddItemToFolderPage();
const sidePanelPage = useAtomStateValue(sidePanelPageState);
const addMenuItemInsertionContext = useAtomStateValue(
addMenuItemInsertionContextState,
);
const isOpen = openNavigationMenuItemFolderIds.includes(folderId);
const folderContentLengthForTree =
isEditMode && isSelectedInEditMode
? navigationMenuItems.length + 1
: navigationMenuItems.length;
const folderContentLengthForTree = isNavigationMenuInEditMode
? navigationMenuItems.length + 1
: navigationMenuItems.length;
const handleAddMenuItemToFolder = () => {
openAddItemToFolderPage({
@ -134,208 +126,180 @@ export const WorkspaceNavigationMenuItemsFolder = ({
});
};
const handleToggle = () => {
if (isMobile) {
setCurrentNavigationMenuItemFolderId((prev) =>
prev === folderId ? null : folderId,
);
} else {
setOpenNavigationMenuItemFolderIds((current) =>
isOpen
? current.filter((id) => id !== folderId)
: [...current, folderId],
);
}
if (!isOpen) {
const firstNonLinkItem = navigationMenuItems.find(
(item) =>
item.itemType !== NavigationMenuItemType.LINK &&
isNonEmptyString(item.link),
);
if (isDefined(firstNonLinkItem?.link)) {
navigate(firstNonLinkItem.link);
const shouldUseEditModeClick =
isNavigationMenuInEditMode && isDefined(onEditModeClick);
const handleClick = shouldUseEditModeClick
? (e?: React.MouseEvent) => {
e?.stopPropagation();
if (isSelectedInEditMode) {
handleToggle();
} else {
onEditModeClick?.();
}
}
}
};
: handleToggle;
const shouldUseEditModeClick = isEditMode && isDefined(onEditModeClick);
const handleClick = shouldUseEditModeClick ? onEditModeClick : handleToggle;
const [skipInitialExpandAnimation] = useState(() => isOpen);
const selectedNavigationMenuItemIndex = navigationMenuItems.findIndex(
(item) =>
isLocationMatchingNavigationMenuItem(currentPath, currentViewPath, item),
);
const navigationMenuItemFolderContentLength = navigationMenuItems.length;
const { isDragging: isContextDragging } = useContext(
NavigationMenuItemDragContext,
);
const setSortableDropTargetRef = useContext(SortableDropTargetRefContext);
const folderContentDropDisabled = useIsDropDisabledForSection(true);
return (
<StyledFolderContainer $isSelectedInEditMode={isSelectedInEditMode}>
<NavigationDrawerItemsCollapsableContainer isGroup={isGroup}>
<NavigationMenuItemDroppable
droppableId={`${NavigationMenuItemDroppableIds.WORKSPACE_FOLDER_HEADER_PREFIX}${folderId}`}
isWorkspaceSection={true}
>
<NavigationDrawerItem
label={folderName}
Icon={FolderIcon}
iconColor={
isDefined(folderColor)
? folderColor
: DEFAULT_NAVIGATION_MENU_ITEM_COLOR_FOLDER
}
onClick={handleClick}
className="navigation-drawer-item"
triggerEvent="CLICK"
preventCollapseOnMobile={isMobile}
isDragging={isDragging}
rightOptions={
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}
/>
)
}
/>
</NavigationMenuItemDroppable>
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 =
isNavigationMenuInEditMode || 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}
>
<Droppable
droppableId={`${NavigationMenuItemDroppableIds.WORKSPACE_FOLDER_PREFIX}${folderId}`}
isDropDisabled={folderContentDropDisabled}
ignoreContainerClipping
renderClone={(provided, snapshot, rubric) => (
<WorkspaceNavigationMenuItemFolderDragClone
draggableProvided={provided}
draggableSnapshot={snapshot}
rubric={rubric}
navigationMenuItems={navigationMenuItems}
navigationMenuItemFolderContentLength={
navigationMenuItemFolderContentLength
}
selectedNavigationMenuItemIndex={
selectedNavigationMenuItemIndex
}
<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={
!isNavigationMenuInEditMode || 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,
)}
/>
)}
getContainerForClone={() => document.body}
>
{(provided) => (
<StyledFolderDroppableContent
ref={provided.innerRef}
$compact={isEditMode || navigationMenuItems.length === 0}
// oxlint-disable-next-line react/jsx-props-no-spreading
{...provided.droppableProps}
>
{navigationMenuItems.map((navigationMenuItem, index) => {
const objectMetadataItem =
navigationMenuItem.itemType ===
NavigationMenuItemType.VIEW ||
navigationMenuItem.itemType ===
NavigationMenuItemType.RECORD
? getObjectMetadataForNavigationMenuItem(
navigationMenuItem,
objectMetadataItems,
views,
)
: null;
const handleEditModeClick =
isEditMode &&
isDefined(onNavigationMenuItemClick) &&
(navigationMenuItem.itemType ===
NavigationMenuItemType.LINK ||
isDefined(objectMetadataItem))
? () =>
onNavigationMenuItemClick({
item: navigationMenuItem,
objectMetadataItem:
objectMetadataItem ?? undefined,
})
: undefined;
return (
<DraggableItem
key={navigationMenuItem.id}
draggableId={navigationMenuItem.id}
index={index}
isInsideScrollableContainer
isDragDisabled={!isEditMode}
disableInteractiveElementBlocking={isEditMode}
itemComponent={
<NavigationDrawerSubItem
secondaryLabel={
navigationMenuItem.viewKey === ViewKey.Index
? undefined
: getNavigationMenuItemSecondaryLabel({
objectMetadataItems,
navigationMenuItemObjectNameSingular:
navigationMenuItem.objectNameSingular,
})
}
label={navigationMenuItem.labelIdentifier}
Icon={() => (
<NavigationMenuItemIcon
navigationMenuItem={navigationMenuItem}
/>
)}
to={
isContextDragging || handleEditModeClick
? undefined
: navigationMenuItem.link
}
onClick={handleEditModeClick}
active={index === selectedNavigationMenuItemIndex}
isSelectedInEditMode={
selectedNavigationMenuItemId ===
navigationMenuItem.id
}
subItemState={getNavigationSubItemLeftAdornment({
index,
arrayLength: folderContentLengthForTree,
selectedIndex: selectedNavigationMenuItemIndex,
})}
isDragging={isContextDragging}
triggerEvent="CLICK"
/>
}
/>
);
})}
{provided.placeholder}
</StyledFolderDroppableContent>
)}
</Droppable>
{isEditMode && isSelectedInEditMode && (
<NavigationDrawerSubItem
label={t`Add menu item`}
Icon={IconPlus}
onClick={handleAddMenuItemToFolder}
triggerEvent="CLICK"
subItemState={getNavigationSubItemLeftAdornment({
index: navigationMenuItems.length,
arrayLength: folderContentLengthForTree,
selectedIndex: selectedNavigationMenuItemIndex,
})}
/>
)}
{isNavigationMenuInEditMode && (
<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>

View file

@ -0,0 +1 @@
export const DND_KIT_DROP_TARGET_ID_SEPARATOR = '::';

View file

@ -0,0 +1,5 @@
import { createContext } from 'react';
export const SortableDropTargetRefContext = createContext<
((element: Element | null) => void) | null
>(null);

View file

@ -17,7 +17,6 @@ import { addToNavPayloadRegistryState } from '@/navigation-menu-item/states/addT
import { isNavigationMenuInEditModeState } from '@/navigation-menu-item/states/isNavigationMenuInEditModeState';
import { navigationMenuItemsDraftState } from '@/navigation-menu-item/states/navigationMenuItemsDraftState';
import { openNavigationMenuItemFolderIdsState } from '@/navigation-menu-item/states/openNavigationMenuItemFolderIdsState';
import { selectedNavigationMenuItemInEditModeState } from '@/navigation-menu-item/states/selectedNavigationMenuItemInEditModeState';
import { getObjectMetadataIdsInDraft } from '@/navigation-menu-item/utils/getObjectMetadataIdsInDraft';
import { getStandardObjectIconColor } from '@/navigation-menu-item/utils/getStandardObjectIconColor';
import { isWorkspaceDroppableId } from '@/navigation-menu-item/utils/isWorkspaceDroppableId';
@ -45,9 +44,6 @@ export const useHandleAddToNavigationDrop = () => {
const { objectMetadataItems } = useObjectMetadataItems();
const coreViews = useAtomStateValue(coreViewsState);
const { getIcon } = useIcons();
const setSelectedNavigationMenuItemInEditMode = useSetAtomState(
selectedNavigationMenuItemInEditModeState,
);
const setIsNavigationMenuInEditMode = useSetAtomState(
isNavigationMenuInEditModeState,
);
@ -92,11 +88,13 @@ export const useHandleAddToNavigationDrop = () => {
const openEditForNewNavItem = (
newItemId: string,
options: Parameters<typeof openNavigationMenuItemInSidePanel>[0],
options: Omit<
Parameters<typeof openNavigationMenuItemInSidePanel>[0],
'itemId'
>,
) => {
setIsNavigationMenuInEditMode(true);
setSelectedNavigationMenuItemInEditMode(newItemId);
openNavigationMenuItemInSidePanel(options);
openNavigationMenuItemInSidePanel({ ...options, itemId: newItemId });
};
switch (payload.type) {
@ -223,7 +221,6 @@ export const useHandleAddToNavigationDrop = () => {
openNavigationMenuItemInSidePanel,
setOpenNavigationMenuItemFolderIds,
setIsNavigationMenuInEditMode,
setSelectedNavigationMenuItemInEditMode,
workspaceNavigationMenuItems,
store,
],

View file

@ -5,12 +5,14 @@ import { NavigationMenuItemDroppableIds } from '@/navigation-menu-item/constants
import { isNavigationMenuInEditModeState } from '@/navigation-menu-item/states/isNavigationMenuInEditModeState';
import { navigationMenuItemsDraftState } from '@/navigation-menu-item/states/navigationMenuItemsDraftState';
import { openNavigationMenuItemFolderIdsState } from '@/navigation-menu-item/states/openNavigationMenuItemFolderIdsState';
import { useAtomStateValue } from '@/ui/utilities/state/jotai/hooks/useAtomStateValue';
import { useSetAtomState } from '@/ui/utilities/state/jotai/hooks/useSetAtomState';
import { getPositionBetween } from '@/navigation-menu-item/utils/getPositionBetween';
import { isNavigationMenuItemFolder } from '@/navigation-menu-item/utils/isNavigationMenuItemFolder';
import {
matchesWorkspaceFolderId,
validateAndExtractWorkspaceFolderId,
} from '@/navigation-menu-item/utils/validateAndExtractWorkspaceFolderId';
import { useAtomStateValue } from '@/ui/utilities/state/jotai/hooks/useAtomStateValue';
import { useSetAtomState } from '@/ui/utilities/state/jotai/hooks/useSetAtomState';
import { isDefined } from 'twenty-shared/utils';
import { usePrefetchedNavigationMenuItemsData } from './usePrefetchedNavigationMenuItemsData';
@ -83,6 +85,13 @@ export const useHandleWorkspaceNavigationMenuItemDragAndDrop = () => {
const destinationFolderId = validateAndExtractWorkspaceFolderId(
destination.droppableId,
);
if (
isNavigationMenuItemFolder(draggedItem) &&
isDefined(destinationFolderId)
) {
return;
}
const sourceFolderId = validateAndExtractWorkspaceFolderId(
source.droppableId,
);
@ -108,69 +117,41 @@ export const useHandleWorkspaceNavigationMenuItemDragAndDrop = () => {
}
const isSameList = sourceFolderId === destinationFolderId;
let reorderedDestinationList: NavigationMenuItem[];
if (isSameList) {
const listWithoutDragged = sourceList.filter(
(item) => item.id !== draggableId,
);
reorderedDestinationList = [
...listWithoutDragged.slice(0, destination.index),
draggedItem,
...listWithoutDragged.slice(destination.index),
];
} else {
const destinationListWithInsertedItem = [
...destinationList.slice(0, destination.index),
{ ...draggedItem, folderId: destinationFolderId },
...destinationList.slice(destination.index),
];
reorderedDestinationList = destinationListWithInsertedItem;
}
const destinationWithNormalizedPositions = reorderedDestinationList.map(
(item, index) => ({
...item,
position: index,
folderId: isSameList ? item.folderId : destinationFolderId,
}),
);
const positionUpdates = new Map<
string,
{ position: number; folderId: string | null }
>();
destinationWithNormalizedPositions.forEach((item) => {
positionUpdates.set(item.id, {
position: item.position,
folderId: item.folderId ?? null,
});
});
if (!isSameList) {
const sourceListWithoutDragged = sourceList.filter(
(item) => item.id !== draggableId,
const prevItem = listWithoutDragged[destination.index - 1];
const nextItem = listWithoutDragged[destination.index];
const newPosition = getPositionBetween(
prevItem?.position,
nextItem?.position,
);
sourceListWithoutDragged.forEach((item, index) => {
positionUpdates.set(item.id, {
position: index,
folderId: sourceFolderId,
});
});
const updatedDraft = navigationMenuItemsDraft.map(
(item): NavigationMenuItem =>
item.id === draggableId ? { ...item, position: newPosition } : item,
);
setNavigationMenuItemsDraft(updatedDraft);
return;
}
const prevItem = destinationList[destination.index - 1];
const nextItem = destinationList[destination.index];
const newPosition = getPositionBetween(
prevItem?.position,
nextItem?.position,
);
const updatedDraft = navigationMenuItemsDraft.map(
(item): NavigationMenuItem => {
const update = positionUpdates.get(item.id);
if (!update) return item;
if (item.id !== draggableId) return item;
return {
...item,
position: update.position,
folderId: update.folderId,
position: newPosition,
folderId: destinationFolderId,
};
},
);
setNavigationMenuItemsDraft(updatedDraft);
};

View file

@ -1,66 +1,78 @@
import { isDefined } from 'twenty-shared/utils';
import type { NavigationMenuItem } from '~/generated-metadata/graphql';
import { navigationMenuItemsDraftState } from '@/navigation-menu-item/states/navigationMenuItemsDraftState';
import { useSetAtomState } from '@/ui/utilities/state/jotai/hooks/useSetAtomState';
import { getPositionBetween } from '@/navigation-menu-item/utils/getPositionBetween';
import { isNavigationMenuItemFolder } from '@/navigation-menu-item/utils/isNavigationMenuItemFolder';
import { useSetAtomState } from '@/ui/utilities/state/jotai/hooks/useSetAtomState';
const swapPositionsInDraft = (
draft: NavigationMenuItem[],
itemA: NavigationMenuItem,
itemB: NavigationMenuItem,
): NavigationMenuItem[] =>
draft.map((item) => {
if (item.id === itemA.id) {
return { ...item, position: itemB.position };
}
if (item.id === itemB.id) {
return { ...item, position: itemA.position };
}
return item;
});
import { useWorkspaceSectionItems } from './useWorkspaceSectionItems';
export const useNavigationMenuItemMoveRemove = () => {
const setNavigationMenuItemsDraft = useSetAtomState(
navigationMenuItemsDraftState,
);
const items = useWorkspaceSectionItems();
const visibleItemIds = new Set(items.map((item) => item.id));
const moveUp = (navigationMenuItemId: string) => {
setNavigationMenuItemsDraft((draft) => {
if (!draft) return draft;
if (!draft) {
return draft;
}
const currentItem = draft.find(
(item) => item.id === navigationMenuItemId,
);
if (!currentItem) return draft;
if (!currentItem) {
return draft;
}
const folderId = currentItem.folderId ?? null;
const siblings = draft
.filter((item) => (item.folderId ?? null) === folderId)
.filter(
(item) =>
(item.folderId ?? null) === folderId && visibleItemIds.has(item.id),
)
.sort((a, b) => a.position - b.position);
const currentIndex = siblings.findIndex(
(item) => item.id === navigationMenuItemId,
);
if (currentIndex <= 0) return draft;
if (currentIndex <= 0) {
return draft;
}
const itemAbove = siblings[currentIndex - 1];
return swapPositionsInDraft(draft, currentItem, itemAbove);
const prev = siblings[currentIndex - 1];
const prevPrev = siblings[currentIndex - 2];
const newPosition = getPositionBetween(prevPrev?.position, prev.position);
return draft.map((item) =>
item.id === navigationMenuItemId
? { ...item, position: newPosition }
: item,
);
});
};
const moveDown = (navigationMenuItemId: string) => {
setNavigationMenuItemsDraft((draft) => {
if (!draft) return draft;
if (!draft) {
return draft;
}
const currentItem = draft.find(
(item) => item.id === navigationMenuItemId,
);
if (!currentItem) return draft;
if (!currentItem) {
return draft;
}
const folderId = currentItem.folderId ?? null;
const siblings = draft
.filter((item) => (item.folderId ?? null) === folderId)
.filter(
(item) =>
(item.folderId ?? null) === folderId && visibleItemIds.has(item.id),
)
.sort((a, b) => a.position - b.position);
const currentIndex = siblings.findIndex(
@ -70,19 +82,30 @@ export const useNavigationMenuItemMoveRemove = () => {
return draft;
}
const itemBelow = siblings[currentIndex + 1];
return swapPositionsInDraft(draft, currentItem, itemBelow);
const next = siblings[currentIndex + 1];
const nextNext = siblings[currentIndex + 2];
const newPosition = getPositionBetween(next.position, nextNext?.position);
return draft.map((item) =>
item.id === navigationMenuItemId
? { ...item, position: newPosition }
: item,
);
});
};
const remove = (navigationMenuItemId: string) => {
setNavigationMenuItemsDraft((draft) => {
if (!draft) return draft;
if (!draft) {
return draft;
}
const itemToRemove = draft.find(
(item) => item.id === navigationMenuItemId,
);
if (!itemToRemove) return draft;
if (!itemToRemove) {
return draft;
}
const isFolder = isNavigationMenuItemFolder(itemToRemove);
@ -103,10 +126,14 @@ export const useNavigationMenuItemMoveRemove = () => {
targetFolderId: string | null,
) => {
setNavigationMenuItemsDraft((draft) => {
if (!draft) return draft;
if (!draft) {
return draft;
}
const itemToMove = draft.find((item) => item.id === navigationMenuItemId);
if (!itemToMove) return draft;
if (!itemToMove) {
return draft;
}
const isFolder = isNavigationMenuItemFolder(itemToMove);
if (isFolder && targetFolderId === navigationMenuItemId) {

View file

@ -1,5 +1,6 @@
import { useNavigateSidePanel } from '@/side-panel/hooks/useNavigateSidePanel';
import { addMenuItemInsertionContextState } from '@/navigation-menu-item/states/addMenuItemInsertionContextState';
import { selectedNavigationMenuItemInEditModeState } from '@/navigation-menu-item/states/selectedNavigationMenuItemInEditModeState';
import { useNavigateSidePanel } from '@/side-panel/hooks/useNavigateSidePanel';
import { useSetAtomState } from '@/ui/utilities/state/jotai/hooks/useSetAtomState';
import { useLingui } from '@lingui/react/macro';
import { SidePanelPages } from 'twenty-shared/types';
@ -17,12 +18,16 @@ export const useOpenAddItemToFolderPage = () => {
const setAddMenuItemInsertionContext = useSetAtomState(
addMenuItemInsertionContextState,
);
const setSelectedNavigationMenuItemInEditMode = useSetAtomState(
selectedNavigationMenuItemInEditModeState,
);
const openAddItemToFolderPage = ({
targetFolderId,
targetIndex,
resetNavigationStack = true,
}: OpenAddItemToFolderPageParams) => {
setSelectedNavigationMenuItemInEditMode(null);
setAddMenuItemInsertionContext({
targetFolderId,
targetIndex,

View file

@ -1,19 +1,30 @@
import { selectedNavigationMenuItemInEditModeState } from '@/navigation-menu-item/states/selectedNavigationMenuItemInEditModeState';
import { useNavigateSidePanel } from '@/side-panel/hooks/useNavigateSidePanel';
import { useSetAtomState } from '@/ui/utilities/state/jotai/hooks/useSetAtomState';
import { SidePanelPages } from 'twenty-shared/types';
import { isDefined } from 'twenty-shared/utils';
import type { IconComponent } from 'twenty-ui/display';
export const useOpenNavigationMenuItemInSidePanel = () => {
const { navigateSidePanel } = useNavigateSidePanel();
const setSelectedNavigationMenuItemInEditMode = useSetAtomState(
selectedNavigationMenuItemInEditModeState,
);
const openNavigationMenuItemInSidePanel = ({
itemId,
pageTitle,
pageIcon,
focusTitleInput = false,
}: {
itemId?: string;
pageTitle: string;
pageIcon: IconComponent;
focusTitleInput?: boolean;
}) => {
if (isDefined(itemId)) {
setSelectedNavigationMenuItemInEditMode(itemId);
}
navigateSidePanel({
page: SidePanelPages.NavigationMenuItemEdit,
pageTitle,

View file

@ -1,17 +1,16 @@
import { useCallback } from 'react';
import { isDefined } from 'twenty-shared/utils';
import {
type CreateNavigationMenuItemInput,
useCreateNavigationMenuItemMutation,
} from '~/generated-metadata/graphql';
import { useCreateNavigationMenuItemMutation } from '~/generated-metadata/graphql';
import { useDeleteNavigationMenuItem } from '@/navigation-menu-item/hooks/useDeleteNavigationMenuItem';
import { useUpdateNavigationMenuItem } from '@/navigation-menu-item/hooks/useUpdateNavigationMenuItem';
import { navigationMenuItemsDraftState } from '@/navigation-menu-item/states/navigationMenuItemsDraftState';
import { buildCreateNavigationMenuItemInput } from '@/navigation-menu-item/utils/buildCreateNavigationMenuItemInput';
import { filterWorkspaceNavigationMenuItems } from '@/navigation-menu-item/utils/filterWorkspaceNavigationMenuItems';
import { isNavigationMenuItemFolder } from '@/navigation-menu-item/utils/isNavigationMenuItemFolder';
import { isNavigationMenuItemLink } from '@/navigation-menu-item/utils/isNavigationMenuItemLink';
import { orderFoldersForCreation } from '@/navigation-menu-item/utils/orderFoldersForCreation';
import { prefetchNavigationMenuItemsState } from '@/prefetch/states/prefetchNavigationMenuItemsState';
import { useStore } from 'jotai';
@ -76,37 +75,39 @@ export const useSaveNavigationMenuItemsDraft = () => {
await deleteNavigationMenuItem(draftItem.id);
}
const idsToCreateIncludingRecreated = [...idsToCreate, ...idsToRecreate];
const itemsToCreate = [...idsToCreate, ...idsToRecreate];
const foldersToCreate = itemsToCreate.filter(isNavigationMenuItemFolder);
const nonFoldersToCreate = itemsToCreate.filter(
(item) => !isNavigationMenuItemFolder(item),
);
for (const draftItem of idsToCreateIncludingRecreated) {
const input: CreateNavigationMenuItemInput = {
position: Math.max(0, Math.round(draftItem.position)),
};
const createdFolderIdByDraftId = new Map<string, string>();
const resolveFolderId = (draftFolderId: string): string =>
createdFolderIdByDraftId.get(draftFolderId) ?? draftFolderId;
if (isNavigationMenuItemFolder(draftItem)) {
input.name = draftItem.name ?? undefined;
input.icon = draftItem.icon ?? null;
} else if (isNavigationMenuItemLink(draftItem)) {
input.name = draftItem.name ?? 'Link';
const linkUrl = (draftItem.link ?? '').trim();
input.link =
linkUrl.startsWith('http://') || linkUrl.startsWith('https://')
? linkUrl
: linkUrl
? `https://${linkUrl}`
: undefined;
} else if (isDefined(draftItem.viewId)) {
input.viewId = draftItem.viewId;
} else if (isDefined(draftItem.targetRecordId)) {
input.targetRecordId = draftItem.targetRecordId;
input.targetObjectMetadataId =
draftItem.targetObjectMetadataId ?? undefined;
}
if (isDefined(draftItem.folderId)) {
input.folderId = draftItem.folderId;
const orderedFolders = orderFoldersForCreation(
foldersToCreate,
prefetchIds,
);
for (const draftItem of orderedFolders) {
const input = buildCreateNavigationMenuItemInput(
draftItem,
resolveFolderId,
);
const result = await createNavigationMenuItemMutation({
variables: { input },
});
const created = result.data?.createNavigationMenuItem;
if (isDefined(created?.id)) {
createdFolderIdByDraftId.set(draftItem.id, created.id);
}
}
for (const draftItem of nonFoldersToCreate) {
const input = buildCreateNavigationMenuItemInput(
draftItem,
resolveFolderId,
);
await createNavigationMenuItemMutation({
variables: { input },
});
@ -150,10 +151,13 @@ export const useSaveNavigationMenuItemsDraft = () => {
} = { id: draftItem.id };
if (positionChanged) {
updateInput.position = Math.max(0, Math.round(draftItem.position));
updateInput.position = draftItem.position;
}
if (folderIdChanged) {
updateInput.folderId = draftItem.folderId ?? null;
updateInput.folderId =
draftItem.folderId != null
? resolveFolderId(draftItem.folderId)
: null;
}
if (nameChanged && isNavigationMenuItemFolder(draftItem)) {
updateInput.name = draftItem.name ?? undefined;

View file

@ -0,0 +1,72 @@
import { useLocation, useNavigate } from 'react-router-dom';
import { isDefined } from 'twenty-shared/utils';
import { useIsMobile } from 'twenty-ui/utilities';
import { isNonEmptyString } from '@sniptt/guards';
import { NavigationMenuItemType } from '@/navigation-menu-item/constants/NavigationMenuItemType';
import { openNavigationMenuItemFolderIdsState } from '@/navigation-menu-item/states/openNavigationMenuItemFolderIdsState';
import { isLocationMatchingNavigationMenuItem } from '@/navigation-menu-item/utils/isLocationMatchingNavigationMenuItem';
import type { ProcessedNavigationMenuItem } from '@/navigation-menu-item/utils/sortNavigationMenuItems';
import { currentNavigationMenuItemFolderIdState } from '@/ui/navigation/navigation-drawer/states/currentNavigationMenuItemFolderIdState';
import { useAtomState } from '@/ui/utilities/state/jotai/hooks/useAtomState';
import { useSetAtomState } from '@/ui/utilities/state/jotai/hooks/useSetAtomState';
type UseWorkspaceFolderOpenStateParams = {
folderId: string;
navigationMenuItems: ProcessedNavigationMenuItem[];
};
export const useWorkspaceFolderOpenState = ({
folderId,
navigationMenuItems,
}: UseWorkspaceFolderOpenStateParams) => {
const location = useLocation();
const navigate = useNavigate();
const currentPath = location.pathname;
const currentViewPath = location.pathname + location.search;
const isMobile = useIsMobile();
const [openNavigationMenuItemFolderIds, setOpenNavigationMenuItemFolderIds] =
useAtomState(openNavigationMenuItemFolderIdsState);
const setCurrentNavigationMenuItemFolderId = useSetAtomState(
currentNavigationMenuItemFolderIdState,
);
const isOpen = openNavigationMenuItemFolderIds.includes(folderId);
const handleToggle = () => {
if (isMobile) {
setCurrentNavigationMenuItemFolderId((prev) =>
prev === folderId ? null : folderId,
);
} else {
setOpenNavigationMenuItemFolderIds((current) =>
isOpen
? current.filter((id) => id !== folderId)
: [...current, folderId],
);
}
if (!isOpen) {
const firstNonLinkItem = navigationMenuItems.find(
(item) =>
item.itemType !== NavigationMenuItemType.LINK &&
isNonEmptyString(item.link),
);
if (isDefined(firstNonLinkItem?.link)) {
navigate(firstNonLinkItem.link);
}
}
};
const selectedNavigationMenuItemIndex = navigationMenuItems.findIndex(
(item) =>
isLocationMatchingNavigationMenuItem(currentPath, currentViewPath, item),
);
return {
isOpen,
handleToggle,
selectedNavigationMenuItemIndex,
};
};

View file

@ -5,8 +5,10 @@ import { NavigationMenuItemType } from '@/navigation-menu-item/constants/Navigat
import { type ProcessedNavigationMenuItem } from '@/navigation-menu-item/types/processed-navigation-menu-item';
import { getObjectMetadataForNavigationMenuItem } from '@/navigation-menu-item/utils/getObjectMetadataForNavigationMenuItem';
import { isNavigationMenuItemFolder } from '@/navigation-menu-item/utils/isNavigationMenuItemFolder';
import { getObjectPermissionsForObject } from '@/object-metadata/utils/getObjectPermissionsForObject';
import { objectMetadataItemsState } from '@/object-metadata/states/objectMetadataItemsState';
import { type ObjectMetadataItem } from '@/object-metadata/types/ObjectMetadataItem';
import { useObjectPermissions } from '@/object-record/hooks/useObjectPermissions';
import { useAtomStateValue } from '@/ui/utilities/state/jotai/hooks/useAtomStateValue';
import { coreViewsState } from '@/views/states/coreViewState';
import { convertCoreViewToView } from '@/views/utils/convertCoreViewToView';
@ -36,6 +38,7 @@ export const useWorkspaceSectionItems = (): FlatWorkspaceItem[] => {
useNavigationMenuItemsByFolder();
const coreViews = useAtomStateValue(coreViewsState);
const objectMetadataItems = useAtomStateValue(objectMetadataItemsState);
const { objectPermissionsByObjectMetadataId } = useObjectPermissions();
const views = coreViews.map(convertCoreViewToView);
@ -76,7 +79,13 @@ export const useWorkspaceSectionItems = (): FlatWorkspaceItem[] => {
objectMetadataItems,
views,
);
if (isDefined(objectMetadataItem)) {
if (
isDefined(objectMetadataItem) &&
getObjectPermissionsForObject(
objectPermissionsByObjectMetadataId,
objectMetadataItem.id,
).canReadObjectRecords
) {
acc.push(processedItem);
}
}

View file

@ -1,4 +1,5 @@
export type AddMenuItemInsertionContext = {
targetFolderId: string | null;
targetIndex: number;
disableDrag?: boolean;
};

View file

@ -0,0 +1,15 @@
import { extractDomainFromUrl } from '@/navigation-menu-item/utils/extractDomainFromUrl';
describe('extractDomainFromUrl', () => {
it('returns hostname and strips www prefix', () => {
expect(extractDomainFromUrl('https://www.example.com/path')).toBe(
'example.com',
);
expect(extractDomainFromUrl('https://example.com')).toBe('example.com');
});
it('returns undefined for invalid URLs', () => {
expect(extractDomainFromUrl('not-a-url')).toBeUndefined();
expect(extractDomainFromUrl('')).toBeUndefined();
});
});

View file

@ -0,0 +1,27 @@
import { getPositionBetween } from '@/navigation-menu-item/utils/getPositionBetween';
describe('getPositionBetween', () => {
it('returns next - 1 when only next is defined (insert before)', () => {
expect(getPositionBetween(null, 10)).toBe(9);
expect(getPositionBetween(undefined, 1)).toBe(0);
});
it('returns prev + 1 when only prev is defined (insert after)', () => {
expect(getPositionBetween(5, null)).toBe(6);
expect(getPositionBetween(0, undefined)).toBe(1);
});
it('returns midpoint when both are defined and different', () => {
expect(getPositionBetween(0, 10)).toBe(5);
expect(getPositionBetween(1, 3)).toBe(2);
});
it('returns prev - 1 when both defined and equal', () => {
expect(getPositionBetween(5, 5)).toBe(4);
});
it('returns 0 when both are null or undefined', () => {
expect(getPositionBetween(null, null)).toBe(0);
expect(getPositionBetween(undefined, undefined)).toBe(0);
});
});

View file

@ -0,0 +1,35 @@
import { orderFoldersForCreation } from '@/navigation-menu-item/utils/orderFoldersForCreation';
describe('orderFoldersForCreation', () => {
it('returns empty array for empty input', () => {
expect(orderFoldersForCreation([], new Set())).toEqual([]);
});
it('orders so parent folders come before their children', () => {
const parent = { id: 'parent', folderId: null };
const child = { id: 'child', folderId: 'parent' };
expect(orderFoldersForCreation([child, parent], new Set())).toEqual([
parent,
child,
]);
});
it('treats folderId in existingIds as already created so child can be placed', () => {
const child = { id: 'child', folderId: 'existing-parent' };
expect(
orderFoldersForCreation([child], new Set(['existing-parent'])),
).toEqual([child]);
});
it('orders multiple levels: root first, then children in dependency order', () => {
const a = { id: 'a', folderId: undefined };
const b = { id: 'b', folderId: 'a' };
const c = { id: 'c', folderId: 'b' };
expect(orderFoldersForCreation([c, a, b], new Set())).toEqual([a, b, c]);
});
it('leaves out folders whose parent is missing and not in existingIds', () => {
const child = { id: 'child', folderId: 'missing-parent' };
expect(orderFoldersForCreation([child], new Set())).toEqual([]);
});
});

View file

@ -0,0 +1,48 @@
import type {
CreateNavigationMenuItemInput,
NavigationMenuItem,
} from '~/generated-metadata/graphql';
import { isDefined } from 'twenty-shared/utils';
import { isNavigationMenuItemFolder } from '@/navigation-menu-item/utils/isNavigationMenuItemFolder';
import { isNavigationMenuItemLink } from '@/navigation-menu-item/utils/isNavigationMenuItemLink';
export const buildCreateNavigationMenuItemInput = (
draftItem: NavigationMenuItem,
resolveFolderId: (draftFolderId: string) => string,
): CreateNavigationMenuItemInput => {
const input: CreateNavigationMenuItemInput = {
position: draftItem.position,
};
if (isNavigationMenuItemFolder(draftItem)) {
input.name = draftItem.name ?? undefined;
input.icon = draftItem.icon ?? null;
} else if (isNavigationMenuItemLink(draftItem)) {
input.name = draftItem.name ?? 'Link';
const linkUrl = (draftItem.link ?? '').trim();
input.link =
linkUrl.startsWith('http://') || linkUrl.startsWith('https://')
? linkUrl
: linkUrl
? `https://${linkUrl}`
: undefined;
} else if (isDefined(draftItem.viewId)) {
input.viewId = draftItem.viewId;
} else if (isDefined(draftItem.targetRecordId)) {
input.targetRecordId = draftItem.targetRecordId;
input.targetObjectMetadataId =
draftItem.targetObjectMetadataId ?? undefined;
}
if (isDefined(draftItem.folderId)) {
input.folderId = resolveFolderId(draftItem.folderId);
}
if (isDefined(draftItem.color)) {
input.color = draftItem.color;
}
return input;
};

View file

@ -0,0 +1,8 @@
export const extractDomainFromUrl = (url: string): string | undefined => {
try {
const hostname = new URL(url).hostname;
return hostname.replace(/^www\./, '') || undefined;
} catch {
return undefined;
}
};

View file

@ -0,0 +1,6 @@
import { DND_KIT_DROP_TARGET_ID_SEPARATOR } from '@/navigation-menu-item/constants/DndKitDropTargetIdSeparator';
export const getDndKitDropTargetId = (
droppableId: string,
index: number,
): string => `${droppableId}${DND_KIT_DROP_TARGET_ID_SEPARATOR}${index}`;

View file

@ -0,0 +1,22 @@
import { NavigationMenuItemDroppableIds } from '@/navigation-menu-item/constants/NavigationMenuItemDroppableIds';
import { NavigationSections } from '@/navigation-menu-item/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;
};

View file

@ -0,0 +1,20 @@
import { getLogoUrlFromDomainName } from 'twenty-shared/utils';
export const getLinkFaviconUrl = (
link: string | null | undefined,
): string | undefined => {
const trimmed = (link ?? '').trim();
if (!trimmed) {
return undefined;
}
const normalized =
trimmed.startsWith('http://') || trimmed.startsWith('https://')
? trimmed
: `https://${trimmed}`;
try {
const hostname = new URL(normalized).hostname;
return getLogoUrlFromDomainName(hostname);
} catch {
return undefined;
}
};

View file

@ -0,0 +1,18 @@
import { isDefined } from 'twenty-shared/utils';
export const getPositionBetween = (
prevPosition: number | null | undefined,
nextPosition: number | null | undefined,
): number => {
if (!isDefined(prevPosition) && isDefined(nextPosition))
return nextPosition - 1;
if (isDefined(prevPosition) && !isDefined(nextPosition))
return prevPosition + 1;
if (isDefined(prevPosition) && isDefined(nextPosition)) {
if (prevPosition === nextPosition) {
return prevPosition - 1;
}
return (prevPosition + nextPosition) / 2;
}
return 0;
};

View file

@ -0,0 +1,24 @@
export const orderFoldersForCreation = <
T extends { id: string; folderId?: string | null },
>(
folders: T[],
existingIds: Set<string>,
): T[] => {
const result: T[] = [];
let remaining = [...folders];
while (remaining.length > 0) {
const readyIndex = remaining.findIndex(
(folder) =>
!folder.folderId ||
existingIds.has(folder.folderId) ||
result.some((r) => r.id === folder.folderId),
);
if (readyIndex === -1) break;
const [ready] = remaining.splice(readyIndex, 1);
result.push(ready);
}
return result;
};

View file

@ -0,0 +1,177 @@
import {
DragDropContext,
type DragStart,
type DropResult,
type OnDragUpdateResponder,
type ResponderProvided,
} from '@hello-pangea/dnd';
import { useState, type ReactNode } from 'react';
import { FavoritesDragContext } from '@/favorites/contexts/FavoritesDragContext';
import { useHandleFavoriteDragAndDrop } from '@/favorites/hooks/useHandleFavoriteDragAndDrop';
import { ADD_TO_NAV_SOURCE_DROPPABLE_ID } from '@/navigation-menu-item/constants/AddToNavSourceDroppableId';
import { NavigationMenuItemDroppableIds } from '@/navigation-menu-item/constants/NavigationMenuItemDroppableIds';
import { NavigationDragSourceContext } from '@/navigation-menu-item/contexts/NavigationDragSourceContext';
import { NavigationDropTargetContext } from '@/navigation-menu-item/contexts/NavigationDropTargetContext';
import { NavigationMenuItemDragContext } from '@/navigation-menu-item/contexts/NavigationMenuItemDragContext';
import { useHandleAddToNavigationDrop } from '@/navigation-menu-item/hooks/useHandleAddToNavigationDrop';
import { useHandleNavigationMenuItemDragAndDrop } from '@/navigation-menu-item/hooks/useHandleNavigationMenuItemDragAndDrop';
import { useNavigationMenuItemsDraftState } from '@/navigation-menu-item/hooks/useNavigationMenuItemsDraftState';
import { addToNavPayloadRegistryState } from '@/navigation-menu-item/states/addToNavPayloadRegistryState';
import { getDropTargetIdFromDestination } from '@/navigation-menu-item/utils/getDropTargetIdFromDestination';
import { getFavoritesDropTargetIdFromDestination } from '@/navigation-menu-item/utils/getFavoritesDropTargetIdFromDestination';
import { isWorkspaceDroppableId } from '@/navigation-menu-item/utils/isWorkspaceDroppableId';
import { validateAndExtractWorkspaceFolderId } from '@/navigation-menu-item/utils/validateAndExtractWorkspaceFolderId';
import { useStore } from 'jotai';
import { isDefined } from 'twenty-shared/utils';
type FavoritesDragDropProviderContentProps = {
children: ReactNode;
};
export const FavoritesDragDropProviderContent = ({
children,
}: FavoritesDragDropProviderContentProps) => {
const [isDragging, setIsDragging] = useState(false);
const [sourceDroppableId, setSourceDroppableId] = useState<string | null>(
null,
);
const [activeDropTargetId, setActiveDropTargetId] = useState<string | null>(
null,
);
const [forbiddenDropTargetId, setForbiddenDropTargetId] = useState<
string | null
>(null);
const [
addToNavigationFallbackDestination,
setAddToNavigationFallbackDestination,
] = useState<{ droppableId: string; index: number } | null>(null);
const store = useStore();
const { workspaceNavigationMenuItems } = useNavigationMenuItemsDraftState();
const { handleAddToNavigationDrop } = useHandleAddToNavigationDrop();
const { handleFavoriteDragAndDrop } = useHandleFavoriteDragAndDrop();
const { handleNavigationMenuItemDragAndDrop } =
useHandleNavigationMenuItemDragAndDrop();
const isFavoritesDroppableId = (droppableId: string) =>
droppableId ===
NavigationMenuItemDroppableIds.ORPHAN_NAVIGATION_MENU_ITEMS ||
droppableId.startsWith('folder-');
const orphanItemCount = workspaceNavigationMenuItems.filter(
(item: { folderId?: string | null }) => !isDefined(item.folderId),
).length;
const handleDragStart = (dragStart: DragStart) => {
setIsDragging(true);
setSourceDroppableId(dragStart.source.droppableId);
if (dragStart.source.droppableId === ADD_TO_NAV_SOURCE_DROPPABLE_ID) {
const defaultDestination = {
droppableId:
NavigationMenuItemDroppableIds.WORKSPACE_ORPHAN_NAVIGATION_MENU_ITEMS,
index: orphanItemCount,
};
setAddToNavigationFallbackDestination(defaultDestination);
setActiveDropTargetId(getDropTargetIdFromDestination(defaultDestination));
}
};
const handleDragUpdate = (update: Parameters<OnDragUpdateResponder>[0]) => {
const { source, destination } = update;
if (source.droppableId === ADD_TO_NAV_SOURCE_DROPPABLE_ID) {
if (
destination !== null &&
isWorkspaceDroppableId(destination.droppableId)
) {
setAddToNavigationFallbackDestination(destination);
const dropTargetId = getDropTargetIdFromDestination(destination);
setActiveDropTargetId(dropTargetId);
const payload =
store
.get(addToNavPayloadRegistryState.atom)
.get(update.draggableId) ?? null;
const folderId = validateAndExtractWorkspaceFolderId(
destination.droppableId,
);
const isFolderOverFolder =
payload?.type === 'folder' && folderId !== null;
setForbiddenDropTargetId(isFolderOverFolder ? dropTargetId : null);
} else {
setForbiddenDropTargetId(null);
const fallback = addToNavigationFallbackDestination;
setActiveDropTargetId(
fallback ? getDropTargetIdFromDestination(fallback) : null,
);
}
return;
}
if (isFavoritesDroppableId(source.droppableId)) {
if (isDefined(destination)) {
const dropTargetId =
getFavoritesDropTargetIdFromDestination(destination);
setActiveDropTargetId(dropTargetId);
} else {
setActiveDropTargetId(null);
}
}
};
const handleDragEnd = (result: DropResult, provided: ResponderProvided) => {
const isAddToNavigationSource =
result.source.droppableId === ADD_TO_NAV_SOURCE_DROPPABLE_ID;
const effectiveResult: DropResult =
isAddToNavigationSource &&
!result.destination &&
addToNavigationFallbackDestination
? { ...result, destination: addToNavigationFallbackDestination }
: result;
setIsDragging(false);
setSourceDroppableId(null);
setActiveDropTargetId(null);
setForbiddenDropTargetId(null);
setAddToNavigationFallbackDestination(null);
if (isAddToNavigationSource) {
handleAddToNavigationDrop(effectiveResult, provided);
return;
}
if (isFavoritesDroppableId(result.source.droppableId)) {
handleNavigationMenuItemDragAndDrop(result, provided);
return;
}
handleFavoriteDragAndDrop(result, provided);
};
return (
<NavigationDragSourceContext.Provider value={{ sourceDroppableId }}>
<NavigationMenuItemDragContext.Provider value={{ isDragging }}>
<FavoritesDragContext.Provider value={{ isDragging }}>
<NavigationDropTargetContext.Provider
value={{
activeDropTargetId,
setActiveDropTargetId,
forbiddenDropTargetId,
setForbiddenDropTargetId,
addToNavigationFallbackDestination,
}}
>
<DragDropContext
onDragStart={handleDragStart}
onDragUpdate={handleDragUpdate}
onDragEnd={handleDragEnd}
>
{children}
</DragDropContext>
</NavigationDropTargetContext.Provider>
</FavoritesDragContext.Provider>
</NavigationMenuItemDragContext.Provider>
</NavigationDragSourceContext.Provider>
);
};

View file

@ -1,9 +1,11 @@
import { NavigationDrawerOpenedSection } from '@/object-metadata/components/NavigationDrawerOpenedSection';
import { NavigationDrawerWorkspaceSectionSkeletonLoader } from '@/object-metadata/components/NavigationDrawerWorkspaceSectionSkeletonLoader';
import { RemoteNavigationDrawerSection } from '@/object-metadata/components/RemoteNavigationDrawerSection';
import { NavigationDrawerOtherSection } from '@/navigation/components/NavigationDrawerOtherSection';
import { styled } from '@linaria/react';
import { lazy, Suspense } from 'react';
import { NavigationDrawerOtherSection } from '@/navigation/components/NavigationDrawerOtherSection';
import { themeCssVariables } from 'twenty-ui/theme-constants';
const CurrentWorkspaceMemberNavigationMenuItemFoldersDispatcher = lazy(() =>
@ -32,10 +34,8 @@ export const MainNavigationDrawerScrollableItems = () => {
return (
<StyledScrollableItemsContainer>
<NavigationDrawerOpenedSection />
<Suspense fallback={null}>
<Suspense fallback={<NavigationDrawerWorkspaceSectionSkeletonLoader />}>
<CurrentWorkspaceMemberNavigationMenuItemFoldersDispatcher />
</Suspense>
<Suspense fallback={null}>
<WorkspaceNavigationMenuItemsDispatcher />
</Suspense>
<RemoteNavigationDrawerSection />

View file

@ -3,6 +3,7 @@ import { useLocation, useNavigate } from 'react-router-dom';
import { SettingsPath } from 'twenty-shared/types';
import { getSettingsPath } from 'twenty-shared/utils';
import { IconHelpCircle, IconSettings } from 'twenty-ui/display';
import { AnimatedExpandableContainer } from 'twenty-ui/layout';
import { currentWorkspaceMemberState } from '@/auth/states/currentWorkspaceMemberState';
import { getDocumentationUrl } from '@/support/utils/getDocumentationUrl';
@ -54,24 +55,29 @@ export const NavigationDrawerOtherSection = () => {
<NavigationDrawerSectionTitle
label={t`Other`}
onClick={toggleNavigationSection}
isOpen={isNavigationSectionOpen}
/>
</NavigationDrawerAnimatedCollapseWrapper>
{isNavigationSectionOpen && (
<>
<NavigationDrawerItem
label={t`Settings`}
Icon={IconSettings}
onClick={handleSettingsClick}
/>
<NavigationDrawerItem
label={t`Documentation`}
to={getDocumentationUrl({
locale: currentWorkspaceMember?.locale,
})}
Icon={IconHelpCircle}
/>
</>
)}
<AnimatedExpandableContainer
isExpanded={isNavigationSectionOpen}
dimension="height"
mode="fit-content"
containAnimation
initial={false}
>
<NavigationDrawerItem
label={t`Settings`}
Icon={IconSettings}
onClick={handleSettingsClick}
/>
<NavigationDrawerItem
label={t`Documentation`}
to={getDocumentationUrl({
locale: currentWorkspaceMember?.locale,
})}
Icon={IconHelpCircle}
/>
</AnimatedExpandableContainer>
</NavigationDrawerSection>
);
};

View file

@ -1,31 +1,12 @@
import {
DragDropContext,
type DragStart,
type DropResult,
type OnDragUpdateResponder,
type ResponderProvided,
} from '@hello-pangea/dnd';
import { type ReactNode, useCallback, useState } from 'react';
import { FeatureFlagKey } from '~/generated-metadata/graphql';
import { lazy, Suspense, useState, type ReactNode } from 'react';
import { FavoritesDragContext } from '@/favorites/contexts/FavoritesDragContext';
import { useHandleFavoriteDragAndDrop } from '@/favorites/hooks/useHandleFavoriteDragAndDrop';
import { ADD_TO_NAV_SOURCE_DROPPABLE_ID } from '@/navigation-menu-item/constants/AddToNavSourceDroppableId';
import { NavigationMenuItemDroppableIds } from '@/navigation-menu-item/constants/NavigationMenuItemDroppableIds';
import { NavigationDragSourceContext } from '@/navigation-menu-item/contexts/NavigationDragSourceContext';
import { NavigationDropTargetContext } from '@/navigation-menu-item/contexts/NavigationDropTargetContext';
import { NavigationMenuItemDragContext } from '@/navigation-menu-item/contexts/NavigationMenuItemDragContext';
import { useHandleAddToNavigationDrop } from '@/navigation-menu-item/hooks/useHandleAddToNavigationDrop';
import { useHandleNavigationMenuItemDragAndDrop } from '@/navigation-menu-item/hooks/useHandleNavigationMenuItemDragAndDrop';
import { useHandleWorkspaceNavigationMenuItemDragAndDrop } from '@/navigation-menu-item/hooks/useHandleWorkspaceNavigationMenuItemDragAndDrop';
import { useNavigationMenuItemsDraftState } from '@/navigation-menu-item/hooks/useNavigationMenuItemsDraftState';
import { addToNavPayloadRegistryState } from '@/navigation-menu-item/states/addToNavPayloadRegistryState';
import { getDropTargetIdFromDestination } from '@/navigation-menu-item/utils/getDropTargetIdFromDestination';
import { isWorkspaceDroppableId } from '@/navigation-menu-item/utils/isWorkspaceDroppableId';
import { validateAndExtractWorkspaceFolderId } from '@/navigation-menu-item/utils/validateAndExtractWorkspaceFolderId';
import { useStore } from 'jotai';
import { useIsFeatureEnabled } from '@/workspace/hooks/useIsFeatureEnabled';
import { isDefined } from 'twenty-shared/utils';
import { PageDragDropProviderMountEffect } from '@/navigation/components/PageDragDropProviderMountEffect';
const LazyWorkspaceDndKitProvider = lazy(() =>
import('@/navigation/components/WorkspaceDndKitProvider').then((m) => ({
default: m.WorkspaceDndKitProvider,
})),
);
type PageDragDropProviderProps = {
children: ReactNode;
@ -34,144 +15,22 @@ type PageDragDropProviderProps = {
export const PageDragDropProvider = ({
children,
}: PageDragDropProviderProps) => {
const isNavigationMenuItemEditingEnabled = useIsFeatureEnabled(
FeatureFlagKey.IS_NAVIGATION_MENU_ITEM_EDITING_ENABLED,
);
const [isDragging, setIsDragging] = useState(false);
const [sourceDroppableId, setSourceDroppableId] = useState<string | null>(
null,
);
const [activeDropTargetId, setActiveDropTargetId] = useState<string | null>(
null,
);
const [forbiddenDropTargetId, setForbiddenDropTargetId] = useState<
string | null
>(null);
const [
addToNavigationFallbackDestination,
setAddToNavigationFallbackDestination,
] = useState<{ droppableId: string; index: number } | null>(null);
const [hasProviderMounted, setHasProviderMounted] = useState(false);
const store = useStore();
const { workspaceNavigationMenuItems } = useNavigationMenuItemsDraftState();
const { handleAddToNavigationDrop } = useHandleAddToNavigationDrop();
const { handleNavigationMenuItemDragAndDrop } =
useHandleNavigationMenuItemDragAndDrop();
const { handleWorkspaceNavigationMenuItemDragAndDrop } =
useHandleWorkspaceNavigationMenuItemDragAndDrop();
const { handleFavoriteDragAndDrop } = useHandleFavoriteDragAndDrop();
const orphanItemCount = workspaceNavigationMenuItems.filter(
(item) => !isDefined(item.folderId),
).length;
const handleDragStart = (dragStart: DragStart) => {
setIsDragging(true);
setSourceDroppableId(dragStart.source.droppableId);
if (dragStart.source.droppableId === ADD_TO_NAV_SOURCE_DROPPABLE_ID) {
const defaultDestination = {
droppableId:
NavigationMenuItemDroppableIds.WORKSPACE_ORPHAN_NAVIGATION_MENU_ITEMS,
index: orphanItemCount,
};
setAddToNavigationFallbackDestination(defaultDestination);
setActiveDropTargetId(getDropTargetIdFromDestination(defaultDestination));
}
};
const handleDragUpdate = useCallback(
((update: Parameters<OnDragUpdateResponder>[0]) => {
const { source, destination } = update;
if (source.droppableId !== ADD_TO_NAV_SOURCE_DROPPABLE_ID) {
return;
}
if (
destination !== null &&
isWorkspaceDroppableId(destination.droppableId)
) {
setAddToNavigationFallbackDestination(destination);
const dropTargetId = getDropTargetIdFromDestination(destination);
setActiveDropTargetId(dropTargetId);
const payload =
store
.get(addToNavPayloadRegistryState.atom)
.get(update.draggableId) ?? null;
const folderId = validateAndExtractWorkspaceFolderId(
destination.droppableId,
);
const isFolderOverFolder =
payload?.type === 'folder' && folderId !== null;
setForbiddenDropTargetId(isFolderOverFolder ? dropTargetId : null);
} else {
setForbiddenDropTargetId(null);
const fallback = addToNavigationFallbackDestination;
setActiveDropTargetId(
fallback ? getDropTargetIdFromDestination(fallback) : null,
);
}
}) as OnDragUpdateResponder,
[addToNavigationFallbackDestination, store],
);
const handleDragEnd = (result: DropResult, provided: ResponderProvided) => {
const isAddToNavigationSource =
result.source.droppableId === ADD_TO_NAV_SOURCE_DROPPABLE_ID;
const effectiveResult: DropResult =
isAddToNavigationSource &&
!result.destination &&
addToNavigationFallbackDestination
? { ...result, destination: addToNavigationFallbackDestination }
: result;
setIsDragging(false);
setSourceDroppableId(null);
setActiveDropTargetId(null);
setForbiddenDropTargetId(null);
setAddToNavigationFallbackDestination(null);
if (isAddToNavigationSource) {
handleAddToNavigationDrop(effectiveResult, provided);
return;
}
if (isNavigationMenuItemEditingEnabled) {
const isWorkspaceDrop =
isWorkspaceDroppableId(result.source?.droppableId) &&
isWorkspaceDroppableId(result.destination?.droppableId);
if (isWorkspaceDrop) {
handleWorkspaceNavigationMenuItemDragAndDrop(result, provided);
} else {
handleNavigationMenuItemDragAndDrop(result, provided);
}
} else {
handleFavoriteDragAndDrop(result, provided);
}
};
if (!hasProviderMounted) {
return (
<>
<PageDragDropProviderMountEffect
onEnterEditMode={() => setHasProviderMounted(true)}
/>
{children}
</>
);
}
return (
<NavigationDragSourceContext.Provider value={{ sourceDroppableId }}>
<NavigationMenuItemDragContext.Provider value={{ isDragging }}>
<FavoritesDragContext.Provider value={{ isDragging }}>
<NavigationDropTargetContext.Provider
value={{
activeDropTargetId,
setActiveDropTargetId,
forbiddenDropTargetId,
setForbiddenDropTargetId,
addToNavigationFallbackDestination,
}}
>
<DragDropContext
onDragStart={handleDragStart}
onDragUpdate={handleDragUpdate}
onDragEnd={handleDragEnd}
>
{children}
</DragDropContext>
</NavigationDropTargetContext.Provider>
</FavoritesDragContext.Provider>
</NavigationMenuItemDragContext.Provider>
</NavigationDragSourceContext.Provider>
<Suspense fallback={<>{children}</>}>
<LazyWorkspaceDndKitProvider>{children}</LazyWorkspaceDndKitProvider>
</Suspense>
);
};

View file

@ -0,0 +1,24 @@
import { useEffect } from 'react';
import { isNavigationMenuInEditModeState } from '@/navigation-menu-item/states/isNavigationMenuInEditModeState';
import { useAtomStateValue } from '@/ui/utilities/state/jotai/hooks/useAtomStateValue';
type PageDragDropProviderMountEffectProps = {
onEnterEditMode: () => void;
};
export const PageDragDropProviderMountEffect = ({
onEnterEditMode,
}: PageDragDropProviderMountEffectProps) => {
const isNavigationMenuInEditMode = useAtomStateValue(
isNavigationMenuInEditModeState,
);
useEffect(() => {
if (isNavigationMenuInEditMode) {
onEnterEditMode();
}
}, [isNavigationMenuInEditMode, onEnterEditMode]);
return null;
};

View file

@ -0,0 +1,50 @@
import { PointerActivationConstraints } from '@dnd-kit/dom';
import {
DragDropProvider,
KeyboardSensor,
PointerSensor,
} from '@dnd-kit/react';
import type { ReactNode } from 'react';
import { NavigationDragSourceContext } from '@/navigation-menu-item/contexts/NavigationDragSourceContext';
import { NavigationDropTargetContext } from '@/navigation-menu-item/contexts/NavigationDropTargetContext';
import { NavigationMenuItemDragContext } from '@/navigation-menu-item/contexts/NavigationMenuItemDragContext';
import type { DraggableData } from '@/navigation/types/workspaceDndKitDraggableData';
import { useWorkspaceDndKit } from '@/navigation/hooks/useWorkspaceDndKit';
const WORKSPACE_DND_SENSORS = [
PointerSensor.configure({
activationConstraints: [
new PointerActivationConstraints.Distance({ value: 8 }),
],
}),
KeyboardSensor,
];
type WorkspaceDndKitProviderProps = {
children: ReactNode;
};
export const WorkspaceDndKitProvider = ({
children,
}: WorkspaceDndKitProviderProps) => {
const { contextValues, handlers } = useWorkspaceDndKit();
return (
<NavigationDragSourceContext.Provider value={contextValues.dragSource}>
<NavigationMenuItemDragContext.Provider value={contextValues.drag}>
<NavigationDropTargetContext.Provider value={contextValues.dropTarget}>
<DragDropProvider<DraggableData>
sensors={WORKSPACE_DND_SENSORS}
onDragStart={handlers.onDragStart}
onDragOver={handlers.onDragOver}
onDragEnd={handlers.onDragEnd}
>
{children}
</DragDropProvider>
</NavigationDropTargetContext.Provider>
</NavigationMenuItemDragContext.Provider>
</NavigationDragSourceContext.Provider>
);
};

View file

@ -0,0 +1,6 @@
export const DROP_RESULT_OPTIONS = {
reason: 'DROP' as const,
combine: null,
mode: 'FLUID' as const,
type: 'DEFAULT' as const,
};

View file

@ -0,0 +1,351 @@
import { type DragDropProvider } from '@dnd-kit/react';
import { isSortable } from '@dnd-kit/react/sortable';
import type { ResponderProvided } from '@hello-pangea/dnd';
import { type ComponentProps, useCallback, useState } from 'react';
import { useStore } from 'jotai';
import { isDefined } from 'twenty-shared/utils';
import { ADD_TO_NAV_SOURCE_DROPPABLE_ID } from '@/navigation-menu-item/constants/AddToNavSourceDroppableId';
import { NavigationMenuItemDroppableIds } from '@/navigation-menu-item/constants/NavigationMenuItemDroppableIds';
import { useHandleAddToNavigationDrop } from '@/navigation-menu-item/hooks/useHandleAddToNavigationDrop';
import { useHandleWorkspaceNavigationMenuItemDragAndDrop } from '@/navigation-menu-item/hooks/useHandleWorkspaceNavigationMenuItemDragAndDrop';
import { useNavigationMenuItemsDraftState } from '@/navigation-menu-item/hooks/useNavigationMenuItemsDraftState';
import { addToNavPayloadRegistryState } from '@/navigation-menu-item/states/addToNavPayloadRegistryState';
import { getDndKitDropTargetId } from '@/navigation-menu-item/utils/getDndKitDropTargetId';
import { isNavigationMenuItemFolder } from '@/navigation-menu-item/utils/isNavigationMenuItemFolder';
import { isWorkspaceDroppableId } from '@/navigation-menu-item/utils/isWorkspaceDroppableId';
import { validateAndExtractWorkspaceFolderId } from '@/navigation-menu-item/utils/validateAndExtractWorkspaceFolderId';
import { DROP_RESULT_OPTIONS } from '@/navigation/constants/workspaceDndKitDropResultOptions';
import type { DraggableData } from '@/navigation/types/workspaceDndKitDraggableData';
import type { DropDestination } from '@/navigation/types/workspaceDndKitDropDestination';
import { isFolderDrag } from '@/navigation/utils/workspaceDndKitIsFolderDrag';
import { resolveDropTarget } from '@/navigation/utils/workspaceDndKitResolveDropTarget';
import { toDropResult } from '@/navigation/utils/workspaceDndKitToDropResult';
type DragStartPayload = Parameters<
NonNullable<
ComponentProps<typeof DragDropProvider<DraggableData>>['onDragStart']
>
>[0];
type DragOverPayload = Parameters<
NonNullable<
ComponentProps<typeof DragDropProvider<DraggableData>>['onDragOver']
>
>[0];
type DragEndPayload = Parameters<
NonNullable<
ComponentProps<typeof DragDropProvider<DraggableData>>['onDragEnd']
>
>[0];
export type WorkspaceDndKitContextValues = {
dragSource: { sourceDroppableId: string | null };
drag: { isDragging: boolean };
dropTarget: {
activeDropTargetId: string | null;
setActiveDropTargetId: (id: string | null) => void;
forbiddenDropTargetId: string | null;
setForbiddenDropTargetId: (id: string | null) => void;
addToNavigationFallbackDestination: DropDestination | null;
};
};
export const useWorkspaceDndKit = (): {
contextValues: WorkspaceDndKitContextValues;
handlers: {
onDragStart: (event: DragStartPayload) => void;
onDragOver: (event: DragOverPayload) => void;
onDragEnd: (event: DragEndPayload) => void;
};
} => {
const store = useStore();
const [isDragging, setIsDragging] = useState(false);
const [sourceDroppableId, setSourceDroppableId] = useState<string | null>(
null,
);
const [activeDropTargetId, setActiveDropTargetId] = useState<string | null>(
null,
);
const [forbiddenDropTargetId, setForbiddenDropTargetId] = useState<
string | null
>(null);
const [
addToNavigationFallbackDestination,
setAddToNavigationFallbackDestination,
] = useState<DropDestination | null>(null);
const { workspaceNavigationMenuItems } = useNavigationMenuItemsDraftState();
const { handleAddToNavigationDrop } = useHandleAddToNavigationDrop();
const { handleWorkspaceNavigationMenuItemDragAndDrop } =
useHandleWorkspaceNavigationMenuItemDragAndDrop();
const orphanItemCount = workspaceNavigationMenuItems.filter(
(item: { folderId?: string | null }) => !isDefined(item.folderId),
).length;
const getNavItemById = useCallback(
(id: string | undefined) =>
id
? workspaceNavigationMenuItems.find((item) => item.id === id)
: undefined,
[workspaceNavigationMenuItems],
);
const applyWorkspaceReorderIfAllowed = (
id: string,
source: DropDestination,
destination: DropDestination,
) => {
const draggedItem = getNavItemById(id);
const destFolderId = validateAndExtractWorkspaceFolderId(
destination.droppableId,
);
if (
isDefined(destFolderId) &&
isDefined(draggedItem) &&
isNavigationMenuItemFolder(draggedItem)
) {
return;
}
const result = toDropResult(
id,
{
sourceDroppableId: source.droppableId,
sourceIndex: source.index,
},
destination,
);
const provided: ResponderProvided = { announce: () => {} };
handleWorkspaceNavigationMenuItemDragAndDrop(
{ ...result, ...DROP_RESULT_OPTIONS },
provided,
);
};
const handleDragStart = (event: DragStartPayload) => {
const { operation } = event;
setIsDragging(true);
const source = operation.source;
const sourceId = source?.data?.sourceDroppableId ?? null;
setSourceDroppableId(sourceId);
if (sourceId === ADD_TO_NAV_SOURCE_DROPPABLE_ID) {
const defaultDestination: DropDestination = {
droppableId:
NavigationMenuItemDroppableIds.WORKSPACE_ORPHAN_NAVIGATION_MENU_ITEMS,
index: orphanItemCount,
};
setAddToNavigationFallbackDestination(defaultDestination);
setActiveDropTargetId(
getDndKitDropTargetId(
defaultDestination.droppableId,
defaultDestination.index,
),
);
}
};
const handleDragOver = useCallback(
(event: DragOverPayload) => {
const { operation } = event;
const source = operation.source;
const target = operation.target;
const isAddToNavDrag =
sourceDroppableId === ADD_TO_NAV_SOURCE_DROPPABLE_ID;
const sourceIsSortable = source !== null && isSortable(source);
const resolved = resolveDropTarget(target, getNavItemById);
const getPayload = () =>
store.get(addToNavPayloadRegistryState.atom).get(String(source?.id)) ??
null;
const getSourceItem = () =>
getNavItemById(source?.id != null ? String(source.id) : undefined);
if (
resolved !== null &&
source !== null &&
target !== null &&
isSortable(source) &&
isSortable(target)
) {
setActiveDropTargetId(resolved.effectiveDropTargetId);
if (isAddToNavDrag) {
setForbiddenDropTargetId(null);
} else {
const destFolderId =
'group' in target
? validateAndExtractWorkspaceFolderId(String(target.group))
: validateAndExtractWorkspaceFolderId(
resolved.destination.droppableId,
);
const folderDrag = isFolderDrag(getPayload(), getSourceItem());
const isFolderOverFolder = resolved.isTargetFolder && folderDrag;
const isFolderOverFolderInList =
!resolved.isTargetFolder && isDefined(destFolderId) && folderDrag;
setForbiddenDropTargetId(
isFolderOverFolder
? resolved.effectiveDropTargetId
: isFolderOverFolderInList
? resolved.dropTargetId
: null,
);
}
return;
}
if (resolved !== null && sourceIsSortable) {
setActiveDropTargetId(resolved.effectiveDropTargetId);
setAddToNavigationFallbackDestination(resolved.destination);
if (!isAddToNavDrag) {
const destFolderId = validateAndExtractWorkspaceFolderId(
resolved.destination.droppableId,
);
const folderDrag = isFolderDrag(null, getSourceItem());
setForbiddenDropTargetId(
isDefined(destFolderId) && folderDrag
? resolved.effectiveDropTargetId
: null,
);
} else {
setForbiddenDropTargetId(null);
}
return;
}
if (!isAddToNavDrag) {
return;
}
if (resolved !== null) {
setAddToNavigationFallbackDestination(resolved.destination);
setActiveDropTargetId(resolved.effectiveDropTargetId);
const folderId = validateAndExtractWorkspaceFolderId(
resolved.destination.droppableId,
);
const folderDrag =
getPayload()?.type === 'folder' && isDefined(folderId);
setForbiddenDropTargetId(
folderDrag ? resolved.effectiveDropTargetId : null,
);
return;
}
const fallback = addToNavigationFallbackDestination;
setActiveDropTargetId(
fallback
? getDndKitDropTargetId(fallback.droppableId, fallback.index)
: null,
);
setForbiddenDropTargetId(null);
},
[
sourceDroppableId,
addToNavigationFallbackDestination,
getNavItemById,
setActiveDropTargetId,
setForbiddenDropTargetId,
setAddToNavigationFallbackDestination,
store,
],
);
const handleDragEnd = (event: DragEndPayload) => {
const { operation } = event;
const source = operation.source;
const target = operation.target;
const draggableId = String(source?.id);
const data = source?.data;
const sourceId = data?.sourceDroppableId ?? null;
const fallback = addToNavigationFallbackDestination;
setIsDragging(false);
setSourceDroppableId(null);
setActiveDropTargetId(null);
setForbiddenDropTargetId(null);
setAddToNavigationFallbackDestination(null);
const sourceIsSortable = source !== null && isSortable(source);
const targetIsSortable = target !== null && isSortable(target);
const sortableToSortable =
sourceIsSortable &&
targetIsSortable &&
isDefined(source) &&
isDefined(target);
const resolved = resolveDropTarget(target, getNavItemById);
if (sortableToSortable && resolved !== null) {
const sourceDraggable = 'initialGroup' in source ? source : null;
const initialGroup = sourceDraggable?.initialGroup ?? '';
const initialIndex = sourceDraggable?.initialIndex ?? 0;
const initialGroupStr = String(initialGroup);
const destGroup = String(target.group ?? '');
const bothWorkspace =
isWorkspaceDroppableId(initialGroupStr) &&
isWorkspaceDroppableId(destGroup);
if (bothWorkspace) {
applyWorkspaceReorderIfAllowed(
draggableId,
{ droppableId: initialGroupStr, index: initialIndex },
resolved.destination,
);
return;
}
}
let destination: DropDestination | null = resolved?.destination ?? null;
if (
destination == null &&
isDefined(fallback) &&
isWorkspaceDroppableId(fallback.droppableId)
) {
destination = fallback;
}
const result = toDropResult(draggableId, data, destination);
const provided: ResponderProvided = { announce: () => {} };
const dropResult = { ...result, ...DROP_RESULT_OPTIONS };
if (sourceId === ADD_TO_NAV_SOURCE_DROPPABLE_ID) {
handleAddToNavigationDrop(dropResult, provided);
return;
}
if (
isDefined(sourceId) &&
isWorkspaceDroppableId(sourceId) &&
isDefined(destination) &&
isWorkspaceDroppableId(destination.droppableId)
) {
applyWorkspaceReorderIfAllowed(
draggableId,
{
droppableId: data?.sourceDroppableId ?? '',
index: data?.sourceIndex ?? 0,
},
destination,
);
}
};
const contextValues: WorkspaceDndKitContextValues = {
dragSource: { sourceDroppableId },
drag: { isDragging },
dropTarget: {
activeDropTargetId,
setActiveDropTargetId,
forbiddenDropTargetId,
setForbiddenDropTargetId,
addToNavigationFallbackDestination,
},
};
return {
contextValues,
handlers: {
onDragStart: handleDragStart,
onDragOver: handleDragOver,
onDragEnd: handleDragEnd,
},
};
};

View file

@ -0,0 +1,16 @@
let preloadScheduled = false;
const preload = () => {
void import('@/navigation/components/WorkspaceDndKitProvider');
void import(
'@/object-metadata/components/NavigationDrawerSectionForWorkspaceItemsListDndKit'
);
};
export const preloadWorkspaceDndKit = (): void => {
if (preloadScheduled) {
return;
}
preloadScheduled = true;
preload();
};

View file

@ -0,0 +1,4 @@
export type DraggableData = {
sourceDroppableId?: string;
sourceIndex?: number;
};

View file

@ -0,0 +1 @@
export type DropDestination = { droppableId: string; index: number };

View file

@ -0,0 +1,4 @@
export type DroppableData = {
droppableId: string;
index: number;
};

View file

@ -0,0 +1,8 @@
import type { DropDestination } from '@/navigation/types/workspaceDndKitDropDestination';
export type SortableTargetDestination = {
destination: DropDestination;
effectiveDropTargetId: string;
isTargetFolder: boolean;
dropTargetId: string;
};

View file

@ -0,0 +1,53 @@
import { isDefined } from 'twenty-shared/utils';
import type { NavigationMenuItem } from '~/generated-metadata/graphql';
import { NavigationMenuItemDroppableIds } from '@/navigation-menu-item/constants/NavigationMenuItemDroppableIds';
import { getDndKitDropTargetId } from '@/navigation-menu-item/utils/getDndKitDropTargetId';
import { isNavigationMenuItemFolder } from '@/navigation-menu-item/utils/isNavigationMenuItemFolder';
import type { DropDestination } from '@/navigation/types/workspaceDndKitDropDestination';
import type { SortableTargetDestination } from '@/navigation/types/workspaceDndKitSortableTargetDestination';
type GetNavItemById = (
id: string | undefined,
) => NavigationMenuItem | undefined;
export const getDestinationFromSortableTarget = (
target: { id: unknown; group?: unknown; index?: unknown },
getNavItemById: GetNavItemById,
): SortableTargetDestination | null => {
const group = target.group;
const rawIndex = target.index;
if (!isDefined(group) || !isDefined(rawIndex)) {
return null;
}
const index = Number(rawIndex);
if (!Number.isInteger(index) || index < 0) {
return null;
}
const destDroppableId = String(group);
const targetItem = getNavItemById(
target.id != null ? String(target.id) : undefined,
);
const isTargetFolder =
isDefined(targetItem) && isNavigationMenuItemFolder(targetItem);
const dropTargetId = getDndKitDropTargetId(destDroppableId, index);
const effectiveDropTargetId = isTargetFolder
? getDndKitDropTargetId(
`${NavigationMenuItemDroppableIds.WORKSPACE_FOLDER_HEADER_PREFIX}${target.id}`,
0,
)
: dropTargetId;
const destination: DropDestination = {
droppableId: isTargetFolder
? `${NavigationMenuItemDroppableIds.WORKSPACE_FOLDER_HEADER_PREFIX}${target.id}`
: destDroppableId,
index: isTargetFolder ? 0 : index,
};
return {
destination,
effectiveDropTargetId,
isTargetFolder,
dropTargetId,
};
};

View file

@ -0,0 +1,13 @@
import type { NavigationMenuItem } from '~/generated-metadata/graphql';
import { isDefined } from 'twenty-shared/utils';
import { isNavigationMenuItemFolder } from '@/navigation-menu-item/utils/isNavigationMenuItemFolder';
type AddToNavPayload = { type?: string } | null;
export const isFolderDrag = (
payload: AddToNavPayload,
sourceItem: NavigationMenuItem | undefined,
): boolean =>
payload?.type === 'folder' ||
(isDefined(sourceItem) && isNavigationMenuItemFolder(sourceItem));

View file

@ -0,0 +1,50 @@
import { isDefined } from 'twenty-shared/utils';
import type { NavigationMenuItem } from '~/generated-metadata/graphql';
import { isWorkspaceDroppableId } from '@/navigation-menu-item/utils/isWorkspaceDroppableId';
import type { DroppableData } from '@/navigation/types/workspaceDndKitDroppableData';
import type { SortableTargetDestination } from '@/navigation/types/workspaceDndKitSortableTargetDestination';
import { getDestinationFromSortableTarget } from '@/navigation/utils/workspaceDndKitGetDestinationFromSortableTarget';
type GetNavItemById = (
id: string | undefined,
) => NavigationMenuItem | undefined;
const isDroppableData = (data: unknown): data is DroppableData =>
typeof data === 'object' &&
data !== null &&
typeof (data as DroppableData).droppableId === 'string' &&
typeof (data as DroppableData).index === 'number';
export const resolveDropTarget = (
target: {
id?: unknown;
group?: unknown;
index?: unknown;
data?: unknown;
} | null,
getNavItemById: GetNavItemById,
): SortableTargetDestination | null => {
if (target === null || target === undefined) {
return null;
}
if (isDefined(target.group) && isDefined(target.index)) {
return getDestinationFromSortableTarget(
{ id: target.id, group: target.group, index: target.index },
getNavItemById,
);
}
if (isDroppableData(target.data)) {
const { droppableId, index } = target.data;
if (isWorkspaceDroppableId(droppableId)) {
return {
destination: { droppableId, index },
effectiveDropTargetId: String(target.id),
isTargetFolder: false,
dropTargetId: String(target.id),
};
}
}
return null;
};

View file

@ -0,0 +1,20 @@
import type { DropDestination } from '@/navigation/types/workspaceDndKitDropDestination';
import type { DraggableData } from '@/navigation/types/workspaceDndKitDraggableData';
export const toDropResult = (
draggableId: string,
data: DraggableData | undefined,
destination: DropDestination | null,
): {
source: DropDestination;
destination: DropDestination | null;
draggableId: string;
} => {
const sourceDroppableId = data?.sourceDroppableId ?? '';
const sourceIndex = data?.sourceIndex ?? 0;
return {
source: { droppableId: sourceDroppableId, index: sourceIndex },
destination,
draggableId,
};
};

View file

@ -0,0 +1,4 @@
export type EditModeProps = {
isSelectedInEditMode: boolean;
onEditModeClick?: () => void;
};

View file

@ -3,6 +3,7 @@ import { contextStoreCurrentViewIdComponentState } from '@/context-store/states/
import { ObjectIconWithViewOverlay } from '@/navigation-menu-item/components/ObjectIconWithViewOverlay';
import { NavigationMenuItemType } from '@/navigation-menu-item/constants/NavigationMenuItemType';
import { useObjectNavItemColor } from '@/navigation-menu-item/hooks/useObjectNavItemColor';
import { isNavigationMenuInEditModeState } from '@/navigation-menu-item/states/isNavigationMenuInEditModeState';
import { getStandardObjectIconColor } from '@/navigation-menu-item/utils/getStandardObjectIconColor';
import { type ProcessedNavigationMenuItem } from '@/navigation-menu-item/utils/sortNavigationMenuItems';
import { lastVisitedViewPerObjectMetadataItemState } from '@/navigation/states/lastVisitedViewPerObjectMetadataItemState';
@ -28,7 +29,6 @@ import { useIsFeatureEnabled } from '@/workspace/hooks/useIsFeatureEnabled';
export type NavigationDrawerItemForObjectMetadataItemProps = {
objectMetadataItem: ObjectMetadataItem;
navigationMenuItem?: ProcessedNavigationMenuItem;
isEditMode?: boolean;
isSelectedInEditMode?: boolean;
onEditModeClick?: () => void;
onActiveItemClickWhenNotInEditMode?: () => void;
@ -38,12 +38,14 @@ export type NavigationDrawerItemForObjectMetadataItemProps = {
export const NavigationDrawerItemForObjectMetadataItem = ({
objectMetadataItem,
navigationMenuItem,
isEditMode = false,
isSelectedInEditMode = false,
onEditModeClick,
onActiveItemClickWhenNotInEditMode,
onActiveItemClickWhenNotInEditMode: _onActiveItemClickWhenNotInEditMode,
isDragging = false,
}: NavigationDrawerItemForObjectMetadataItemProps) => {
const isNavigationMenuInEditMode = useAtomStateValue(
isNavigationMenuInEditModeState,
);
const isNavigationMenuItemEditingEnabled = useIsFeatureEnabled(
FeatureFlagKey.IS_NAVIGATION_MENU_ITEM_EDITING_ENABLED,
);
@ -99,18 +101,9 @@ export const NavigationDrawerItemForObjectMetadataItem = ({
}) + '/',
);
const shouldUseClickHandler = isEditMode
? Boolean(onEditModeClick)
: isActive && Boolean(onActiveItemClickWhenNotInEditMode);
const handleClick = isNavigationMenuInEditMode ? onEditModeClick : undefined;
const handleClick = shouldUseClickHandler
? isEditMode
? onEditModeClick
: onActiveItemClickWhenNotInEditMode
: undefined;
const shouldNavigate =
!isEditMode && !(isActive && onActiveItemClickWhenNotInEditMode);
const shouldNavigate = !isNavigationMenuInEditMode;
const isViewWithCustomName =
isView &&
@ -218,7 +211,7 @@ export const NavigationDrawerItemForObjectMetadataItem = ({
label={label}
secondaryLabel={secondaryLabel}
to={
isEditMode || isDragging
isNavigationMenuInEditMode || isDragging
? undefined
: shouldNavigate
? navigationPath
@ -230,7 +223,7 @@ export const NavigationDrawerItemForObjectMetadataItem = ({
active={isActive}
isSelectedInEditMode={isSelectedInEditMode}
isDragging={isDragging}
triggerEvent={isEditMode ? 'CLICK' : undefined}
triggerEvent={isNavigationMenuInEditMode ? 'CLICK' : undefined}
/>
);
};

View file

@ -3,10 +3,12 @@ import { useParams } from 'react-router-dom';
import { useWorkspaceFavorites } from '@/favorites/hooks/useWorkspaceFavorites';
import { useWorkspaceNavigationMenuItems } from '@/navigation-menu-item/hooks/useWorkspaceNavigationMenuItems';
import { NavigationDrawerSectionForObjectMetadataItems } from '@/object-metadata/components/NavigationDrawerSectionForObjectMetadataItems';
import { NavigationDrawerSectionForObjectMetadataItemsSkeletonLoader } from '@/object-metadata/components/NavigationDrawerSectionForObjectMetadataItemsSkeletonLoader';
import { useFilteredObjectMetadataItems } from '@/object-metadata/hooks/useFilteredObjectMetadataItems';
import { CoreObjectNameSingular } from 'twenty-shared/types';
import { useIsPrefetchLoading } from '@/prefetch/hooks/useIsPrefetchLoading';
import { prefetchIsLoadedFamilyState } from '@/prefetch/states/prefetchIsLoadedFamilyState';
import { PrefetchKey } from '@/prefetch/types/PrefetchKey';
import { useAtomFamilyStateValue } from '@/ui/utilities/state/jotai/hooks/useAtomFamilyStateValue';
import { useIsFeatureEnabled } from '@/workspace/hooks/useIsFeatureEnabled';
import { useLingui } from '@lingui/react/macro';
import { FeatureFlagKey } from '~/generated-metadata/graphql';
@ -24,14 +26,22 @@ export const NavigationDrawerOpenedSection = () => {
const filteredActiveNonSystemObjectMetadataItems =
activeObjectMetadataItems.filter((item) => !item.isRemote);
const loading = useIsPrefetchLoading();
const isPrefetchLoading = useIsPrefetchLoading();
const isNavigationMenuItemEditingEnabled = useIsFeatureEnabled(
FeatureFlagKey.IS_NAVIGATION_MENU_ITEM_EDITING_ENABLED,
);
const prefetchIsLoaded = useAtomFamilyStateValue(
prefetchIsLoadedFamilyState,
PrefetchKey.AllNavigationMenuItems,
);
const loading =
isPrefetchLoading ||
(isNavigationMenuItemEditingEnabled && !prefetchIsLoaded);
const { workspaceFavoritesObjectMetadataItems } = useWorkspaceFavorites();
const { workspaceNavigationMenuItemsObjectMetadataItems } =
useWorkspaceNavigationMenuItems();
const isNavigationMenuItemEditingEnabled = useIsFeatureEnabled(
FeatureFlagKey.IS_NAVIGATION_MENU_ITEM_EDITING_ENABLED,
);
const {
objectNamePlural: currentObjectNamePlural,
@ -67,7 +77,7 @@ export const NavigationDrawerOpenedSection = () => {
.includes(objectMetadataItem.id);
if (loading) {
return <NavigationDrawerSectionForObjectMetadataItemsSkeletonLoader />;
return null;
}
return (

View file

@ -28,7 +28,6 @@ type NavigationDrawerSectionForObjectMetadataItemsProps = {
isRemote: boolean;
objectMetadataItems: ObjectMetadataItem[];
rightIcon?: React.ReactNode;
isEditMode?: boolean;
selectedObjectMetadataItemId?: string | null;
onObjectMetadataItemClick?: (objectMetadataItem: ObjectMetadataItem) => void;
onActiveObjectMetadataItemClick?: (
@ -41,7 +40,6 @@ export const NavigationDrawerSectionForObjectMetadataItems = ({
isRemote,
objectMetadataItems,
rightIcon,
isEditMode = false,
selectedObjectMetadataItemId = null,
onObjectMetadataItemClick,
onActiveObjectMetadataItemClick,
@ -119,6 +117,7 @@ export const NavigationDrawerSectionForObjectMetadataItems = ({
label={sectionTitle}
onClick={() => toggleNavigationSection()}
rightIcon={rightIcon}
isOpen={isNavigationSectionOpen}
/>
</NavigationDrawerAnimatedCollapseWrapper>
{isNavigationSectionOpen &&
@ -127,7 +126,6 @@ export const NavigationDrawerSectionForObjectMetadataItems = ({
<NavigationDrawerItemForObjectMetadataItem
key={`navigation-drawer-item-${objectMetadataItem.id}`}
objectMetadataItem={objectMetadataItem}
isEditMode={isEditMode}
isSelectedInEditMode={
selectedObjectMetadataItemId === objectMetadataItem.id
}

View file

@ -0,0 +1,64 @@
import { NavigationMenuItemType } from '@/navigation-menu-item/constants/NavigationMenuItemType';
import { NavigationDrawerSectionForWorkspaceItemFolderContent } from '@/object-metadata/components/NavigationDrawerSectionForWorkspaceItemFolderContent';
import { NavigationDrawerSectionForWorkspaceItemLinkContent } from '@/object-metadata/components/NavigationDrawerSectionForWorkspaceItemLinkContent';
import { NavigationDrawerSectionForWorkspaceItemObjectContent } from '@/object-metadata/components/NavigationDrawerSectionForWorkspaceItemObjectContent';
import type { WorkspaceSectionItemContentProps } from '@/object-metadata/components/WorkspaceSectionItemContentProps';
type NavigationDrawerSectionForWorkspaceItemContentProps =
WorkspaceSectionItemContentProps;
export const NavigationDrawerSectionForWorkspaceItemContent = ({
item,
editModeProps,
isDragging,
folderChildrenById,
folderCount,
selectedNavigationMenuItemId,
onNavigationMenuItemClick,
onActiveObjectMetadataItemClick,
readOnly,
}: NavigationDrawerSectionForWorkspaceItemContentProps) => {
switch (item.itemType) {
case NavigationMenuItemType.FOLDER:
return (
<NavigationDrawerSectionForWorkspaceItemFolderContent
item={item}
editModeProps={editModeProps}
isDragging={isDragging}
folderChildrenById={folderChildrenById}
folderCount={folderCount}
selectedNavigationMenuItemId={selectedNavigationMenuItemId}
onNavigationMenuItemClick={onNavigationMenuItemClick}
readOnly={readOnly}
/>
);
case NavigationMenuItemType.LINK:
return (
<NavigationDrawerSectionForWorkspaceItemLinkContent
item={item}
editModeProps={editModeProps}
isDragging={isDragging}
folderChildrenById={folderChildrenById}
folderCount={folderCount}
selectedNavigationMenuItemId={selectedNavigationMenuItemId}
onNavigationMenuItemClick={onNavigationMenuItemClick}
onActiveObjectMetadataItemClick={onActiveObjectMetadataItemClick}
readOnly={readOnly}
/>
);
default:
return (
<NavigationDrawerSectionForWorkspaceItemObjectContent
item={item}
editModeProps={editModeProps}
isDragging={isDragging}
folderChildrenById={folderChildrenById}
folderCount={folderCount}
selectedNavigationMenuItemId={selectedNavigationMenuItemId}
onNavigationMenuItemClick={onNavigationMenuItemClick}
onActiveObjectMetadataItemClick={onActiveObjectMetadataItemClick}
readOnly={readOnly}
/>
);
}
};

View file

@ -0,0 +1,72 @@
import { lazy, Suspense } from 'react';
import { WorkspaceFolderReadOnly } from '@/object-metadata/components/WorkspaceFolderReadOnly';
import type { WorkspaceSectionItemContentProps } from '@/object-metadata/components/WorkspaceSectionItemContentProps';
const LazyWorkspaceNavigationMenuItemsFolder = lazy(() =>
import(
'@/navigation-menu-item/components/WorkspaceNavigationMenuItemsFolder'
).then((m) => ({ default: m.WorkspaceNavigationMenuItemsFolder })),
);
type NavigationDrawerSectionForWorkspaceItemFolderContentProps =
WorkspaceSectionItemContentProps;
export const NavigationDrawerSectionForWorkspaceItemFolderContent = ({
item,
editModeProps,
isDragging,
folderChildrenById,
folderCount,
selectedNavigationMenuItemId,
onNavigationMenuItemClick,
readOnly = false,
}: NavigationDrawerSectionForWorkspaceItemFolderContentProps) => {
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 (
<WorkspaceFolderReadOnly
folderId={folderId}
folderName={folderName}
folderIconKey={folderIconKey}
folderColor={folderColor}
navigationMenuItems={navigationMenuItems}
isGroup={isGroup}
/>
);
}
return (
<Suspense
fallback={
<WorkspaceFolderReadOnly
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>
);
};

View file

@ -0,0 +1,45 @@
import { NavigationMenuItemIcon } from '@/navigation-menu-item/components/NavigationMenuItemIcon';
import { isNavigationMenuInEditModeState } from '@/navigation-menu-item/states/isNavigationMenuInEditModeState';
import type { ProcessedNavigationMenuItem } from '@/navigation-menu-item/utils/sortNavigationMenuItems';
import type { WorkspaceSectionItemContentProps } from '@/object-metadata/components/WorkspaceSectionItemContentProps';
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 NavigationDrawerSectionForWorkspaceItemLinkContentProps =
WorkspaceSectionItemContentProps;
export const NavigationDrawerSectionForWorkspaceItemLinkContent = ({
item,
editModeProps,
isDragging,
}: NavigationDrawerSectionForWorkspaceItemLinkContentProps) => {
const isNavigationMenuInEditMode = useAtomStateValue(
isNavigationMenuInEditModeState,
);
const linkItem = item as ProcessedNavigationMenuItem;
return (
<NavigationDrawerItem
label={linkItem.labelIdentifier}
to={isNavigationMenuInEditMode || isDragging ? undefined : linkItem.link}
onClick={
isNavigationMenuInEditMode ? editModeProps.onEditModeClick : undefined
}
Icon={() => <NavigationMenuItemIcon navigationMenuItem={linkItem} />}
active={false}
isSelectedInEditMode={editModeProps.isSelectedInEditMode}
isDragging={isDragging}
triggerEvent="CLICK"
rightOptions={
!isNavigationMenuInEditMode && (
<IconArrowUpRight
size={themeCssVariables.icon.size.sm}
stroke={themeCssVariables.icon.stroke.md}
color={themeCssVariables.font.color.light}
/>
)
}
/>
);
};

View file

@ -0,0 +1,44 @@
import { getObjectMetadataForNavigationMenuItem } from '@/navigation-menu-item/utils/getObjectMetadataForNavigationMenuItem';
import type { ProcessedNavigationMenuItem } from '@/navigation-menu-item/utils/sortNavigationMenuItems';
import { NavigationDrawerItemForObjectMetadataItem } from '@/object-metadata/components/NavigationDrawerItemForObjectMetadataItem';
import type { WorkspaceSectionItemContentProps } from '@/object-metadata/components/WorkspaceSectionItemContentProps';
import { objectMetadataItemsState } from '@/object-metadata/states/objectMetadataItemsState';
import { useAtomStateValue } from '@/ui/utilities/state/jotai/hooks/useAtomStateValue';
import { coreViewsState } from '@/views/states/coreViewState';
import { convertCoreViewToView } from '@/views/utils/convertCoreViewToView';
type NavigationDrawerSectionForWorkspaceItemObjectContentProps =
WorkspaceSectionItemContentProps;
export const NavigationDrawerSectionForWorkspaceItemObjectContent = ({
item,
editModeProps,
isDragging,
onActiveObjectMetadataItemClick,
}: NavigationDrawerSectionForWorkspaceItemObjectContentProps) => {
const objectMetadataItems = useAtomStateValue(objectMetadataItemsState);
const coreViews = useAtomStateValue(coreViewsState);
const views = coreViews.map(convertCoreViewToView);
const objectMetadataItem = getObjectMetadataForNavigationMenuItem(
item as ProcessedNavigationMenuItem,
objectMetadataItems,
views,
);
if (!objectMetadataItem) {
return null;
}
return (
<NavigationDrawerItemForObjectMetadataItem
objectMetadataItem={objectMetadataItem}
navigationMenuItem={item as ProcessedNavigationMenuItem}
isSelectedInEditMode={editModeProps.isSelectedInEditMode}
onEditModeClick={editModeProps.onEditModeClick}
isDragging={isDragging}
onActiveItemClickWhenNotInEditMode={
onActiveObjectMetadataItemClick
? () => onActiveObjectMetadataItemClick(objectMetadataItem, item.id)
: undefined
}
/>
);
};

View file

@ -1,33 +1,25 @@
import { NavigationDropTargetContext } from '@/navigation-menu-item/contexts/NavigationDropTargetContext';
import { styled } from '@linaria/react';
import { Droppable } from '@hello-pangea/dnd';
import { useLingui } from '@lingui/react/macro';
import { useContext } from 'react';
import React, { lazy, Suspense, useContext } from 'react';
import { isDefined } from 'twenty-shared/utils';
import { IconLink, IconPlus } from 'twenty-ui/display';
import { themeCssVariables } from 'twenty-ui/theme-constants';
import { AnimatedExpandableContainer } from 'twenty-ui/layout';
import { NavigationItemDropTarget } from '@/navigation-menu-item/components/NavigationItemDropTarget';
import { WorkspaceNavigationMenuItemsFolder } from '@/navigation-menu-item/components/WorkspaceNavigationMenuItemsFolder';
import { NavigationMenuItemDroppableIds } from '@/navigation-menu-item/constants/NavigationMenuItemDroppableIds';
import { NavigationMenuItemType } from '@/navigation-menu-item/constants/NavigationMenuItemType';
import { NavigationSections } from '@/navigation-menu-item/constants/NavigationSections.constants';
import { NavigationMenuItemDragContext } from '@/navigation-menu-item/contexts/NavigationMenuItemDragContext';
import { useIsDropDisabledForSection } from '@/navigation-menu-item/hooks/useIsDropDisabledForSection';
import {
type FlatWorkspaceItem,
type NavigationMenuItemClickParams,
} from '@/navigation-menu-item/hooks/useWorkspaceSectionItems';
import { isNavigationMenuInEditModeState } from '@/navigation-menu-item/states/isNavigationMenuInEditModeState';
import { getObjectMetadataForNavigationMenuItem } from '@/navigation-menu-item/utils/getObjectMetadataForNavigationMenuItem';
import { type ProcessedNavigationMenuItem } from '@/navigation-menu-item/utils/sortNavigationMenuItems';
import { NavigationDrawerItemForObjectMetadataItem } from '@/object-metadata/components/NavigationDrawerItemForObjectMetadataItem';
import type { EditModeProps } from '@/object-metadata/components/EditModeProps';
import { NavigationDrawerSectionForWorkspaceItemsListReadOnly } from '@/object-metadata/components/NavigationDrawerSectionForWorkspaceItemsListReadOnly';
import { WorkspaceSectionListEditModeFallback } from '@/object-metadata/components/WorkspaceSectionListEditModeFallback';
import { objectMetadataItemsState } from '@/object-metadata/states/objectMetadataItemsState';
import { type ObjectMetadataItem } from '@/object-metadata/types/ObjectMetadataItem';
import { getObjectPermissionsForObject } from '@/object-metadata/utils/getObjectPermissionsForObject';
import { useObjectPermissions } from '@/object-record/hooks/useObjectPermissions';
import { DraggableItem } from '@/ui/layout/draggable-list/components/DraggableItem';
import { NavigationDrawerAnimatedCollapseWrapper } from '@/ui/navigation/navigation-drawer/components/NavigationDrawerAnimatedCollapseWrapper';
import { NavigationDrawerItem } from '@/ui/navigation/navigation-drawer/components/NavigationDrawerItem';
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';
@ -35,18 +27,16 @@ import { useAtomStateValue } from '@/ui/utilities/state/jotai/hooks/useAtomState
import { coreViewsState } from '@/views/states/coreViewState';
import { convertCoreViewToView } from '@/views/utils/convertCoreViewToView';
const StyledWorkspaceDroppableList = styled.div`
display: flex;
flex-direction: column;
gap: ${themeCssVariables.betweenSiblingsGap};
`;
const LazyWorkspaceSectionListDndKit = lazy(() =>
import(
'@/object-metadata/components/NavigationDrawerSectionForWorkspaceItemsListDndKit'
).then((m) => ({ default: m.WorkspaceSectionListDndKit })),
);
type NavigationDrawerSectionForWorkspaceItemsProps = {
sectionTitle: string;
items: FlatWorkspaceItem[];
rightIcon?: React.ReactNode;
onAddMenuItem?: () => void;
isEditMode?: boolean;
selectedNavigationMenuItemId?: string | null;
onNavigationMenuItemClick?: (params: NavigationMenuItemClickParams) => void;
onActiveObjectMetadataItemClick?: (
@ -59,14 +49,13 @@ export const NavigationDrawerSectionForWorkspaceItems = ({
sectionTitle,
items,
rightIcon,
onAddMenuItem,
isEditMode = false,
selectedNavigationMenuItemId = null,
onNavigationMenuItemClick,
onActiveObjectMetadataItemClick,
}: NavigationDrawerSectionForWorkspaceItemsProps) => {
const { t } = useLingui();
const workspaceDropDisabled = useIsDropDisabledForSection(true);
const isNavigationMenuInEditMode = useAtomStateValue(
isNavigationMenuInEditModeState,
);
const { toggleNavigationSection, isNavigationSectionOpen } =
useNavigationSection('Workspace');
const coreViews = useAtomStateValue(coreViewsState);
@ -74,7 +63,6 @@ export const NavigationDrawerSectionForWorkspaceItems = ({
const { objectPermissionsByObjectMetadataId } = useObjectPermissions();
const objectMetadataItems = useAtomStateValue(objectMetadataItemsState);
const { isDragging } = useContext(NavigationMenuItemDragContext);
const { addToNavigationFallbackDestination } = useContext(
NavigationDropTargetContext,
);
@ -95,10 +83,6 @@ export const NavigationDrawerSectionForWorkspaceItems = ({
return acc;
}, new Map());
const folderCount = flatItems.filter(
(item) => item.itemType === NavigationMenuItemType.FOLDER,
).length;
const filteredItems = flatItems.filter((item) => {
const type = item.itemType;
if (
@ -127,7 +111,7 @@ export const NavigationDrawerSectionForWorkspaceItems = ({
return false;
});
const getEditModeProps = (item: FlatWorkspaceItem) => {
const getEditModeProps = (item: FlatWorkspaceItem): EditModeProps => {
const itemId = item.id;
return {
isSelectedInEditMode: selectedNavigationMenuItemId === itemId,
@ -151,9 +135,6 @@ export const NavigationDrawerSectionForWorkspaceItems = ({
};
};
const isAddMenuItemButtonVisible =
isEditMode && isDefined(onAddMenuItem) && !isDragging;
if (flatItems.length === 0 && !isAddToNavigationDropTargetVisible) {
return null;
}
@ -165,193 +146,48 @@ export const NavigationDrawerSectionForWorkspaceItems = ({
label={sectionTitle}
onClick={() => toggleNavigationSection()}
rightIcon={rightIcon}
alwaysShowRightIcon={isEditMode}
alwaysShowRightIcon={isNavigationMenuInEditMode}
isOpen={isNavigationSectionOpen}
/>
</NavigationDrawerAnimatedCollapseWrapper>
{(isNavigationSectionOpen || isAddToNavigationDropTargetVisible) && (
<Droppable
droppableId={
NavigationMenuItemDroppableIds.WORKSPACE_ORPHAN_NAVIGATION_MENU_ITEMS
}
isDropDisabled={workspaceDropDisabled}
>
{(provided) => (
<StyledWorkspaceDroppableList
ref={provided.innerRef}
// oxlint-disable-next-line react/jsx-props-no-spreading
{...provided.droppableProps}
>
{filteredItems.map((item, index) => {
const type = item.itemType;
const editModeProps = getEditModeProps(item);
if (type === 'folder') {
return (
<NavigationItemDropTarget
key={item.id}
folderId={null}
index={index}
sectionId={NavigationSections.WORKSPACE}
>
<DraggableItem
draggableId={item.id}
index={index}
isInsideScrollableContainer
isDragDisabled={!isEditMode}
disableInteractiveElementBlocking={isEditMode}
itemComponent={
<WorkspaceNavigationMenuItemsFolder
folderId={item.id}
folderName={item.name ?? 'Folder'}
folderIconKey={item.Icon}
folderColor={
'color' in item ? item.color : undefined
}
navigationMenuItems={
folderChildrenById.get(item.id) ?? []
}
isGroup={folderCount > 1}
isEditMode={isEditMode}
isSelectedInEditMode={
editModeProps.isSelectedInEditMode
}
onEditModeClick={editModeProps.onEditModeClick}
onNavigationMenuItemClick={
onNavigationMenuItemClick
}
selectedNavigationMenuItemId={
selectedNavigationMenuItemId
}
isDragging={isDragging}
/>
}
/>
</NavigationItemDropTarget>
);
<AnimatedExpandableContainer
isExpanded={
isNavigationSectionOpen || isAddToNavigationDropTargetVisible
}
dimension="height"
mode="fit-content"
containAnimation
initial={false}
>
{isNavigationMenuInEditMode ? (
<Suspense
fallback={
<WorkspaceSectionListEditModeFallback
filteredItems={filteredItems}
folderChildrenById={folderChildrenById}
onActiveObjectMetadataItemClick={
onActiveObjectMetadataItemClick
}
if (type === 'link') {
const linkItem = item as ProcessedNavigationMenuItem;
return (
<NavigationItemDropTarget
key={item.id}
folderId={null}
index={index}
sectionId={NavigationSections.WORKSPACE}
>
<DraggableItem
draggableId={item.id}
index={index}
isInsideScrollableContainer
isDragDisabled={!isEditMode}
disableInteractiveElementBlocking={isEditMode}
itemComponent={
<NavigationDrawerItem
label={linkItem.labelIdentifier}
to={
isEditMode || isDragging
? undefined
: linkItem.link
}
onClick={
isEditMode
? editModeProps.onEditModeClick
: undefined
}
Icon={IconLink}
iconColor={linkItem.color}
active={false}
isSelectedInEditMode={
editModeProps.isSelectedInEditMode
}
isDragging={isDragging}
triggerEvent="CLICK"
/>
}
/>
</NavigationItemDropTarget>
);
}
const objectMetadataItem =
getObjectMetadataForNavigationMenuItem(
item as ProcessedNavigationMenuItem,
objectMetadataItems,
views,
);
if (!objectMetadataItem) return null;
return (
<NavigationItemDropTarget
key={item.id}
folderId={null}
index={index}
sectionId={NavigationSections.WORKSPACE}
>
<DraggableItem
draggableId={item.id}
index={index}
isInsideScrollableContainer
isDragDisabled={!isEditMode}
disableInteractiveElementBlocking={isEditMode}
itemComponent={
<NavigationDrawerItemForObjectMetadataItem
objectMetadataItem={objectMetadataItem}
navigationMenuItem={
item as ProcessedNavigationMenuItem
}
isEditMode={isEditMode}
isSelectedInEditMode={
editModeProps.isSelectedInEditMode
}
onEditModeClick={editModeProps.onEditModeClick}
isDragging={isDragging}
onActiveItemClickWhenNotInEditMode={
onActiveObjectMetadataItemClick
? () =>
onActiveObjectMetadataItemClick(
objectMetadataItem,
item.id,
)
: undefined
}
/>
}
/>
</NavigationItemDropTarget>
);
})}
<NavigationItemDropTarget
folderId={null}
index={filteredItems.length}
sectionId={NavigationSections.WORKSPACE}
compact={!isAddMenuItemButtonVisible}
>
{isAddMenuItemButtonVisible && (
<NavigationDrawerItem
Icon={IconPlus}
label={t`Add menu item`}
onClick={onAddMenuItem}
triggerEvent="CLICK"
/>
)}
</NavigationItemDropTarget>
{addToNavigationFallbackDestination?.droppableId ===
NavigationMenuItemDroppableIds.WORKSPACE_ORPHAN_NAVIGATION_MENU_ITEMS &&
addToNavigationFallbackDestination.index >
filteredItems.length && (
<NavigationItemDropTarget
folderId={null}
index={addToNavigationFallbackDestination.index}
sectionId={NavigationSections.WORKSPACE}
compact
/>
)}
{provided.placeholder}
</StyledWorkspaceDroppableList>
)}
</Droppable>
)}
/>
}
>
<LazyWorkspaceSectionListDndKit
filteredItems={filteredItems}
getEditModeProps={getEditModeProps}
folderChildrenById={folderChildrenById}
selectedNavigationMenuItemId={selectedNavigationMenuItemId}
onNavigationMenuItemClick={onNavigationMenuItemClick}
onActiveObjectMetadataItemClick={onActiveObjectMetadataItemClick}
/>
</Suspense>
) : (
<NavigationDrawerSectionForWorkspaceItemsListReadOnly
filteredItems={filteredItems}
folderChildrenById={folderChildrenById}
onActiveObjectMetadataItemClick={onActiveObjectMetadataItemClick}
/>
)}
</AnimatedExpandableContainer>
</NavigationDrawerSection>
);
};

View file

@ -0,0 +1,109 @@
import { WorkspaceDndKitDroppableSlot } from '@/navigation-menu-item/components/WorkspaceDndKitDroppableSlot';
import { WorkspaceDndKitSortableItem } from '@/navigation-menu-item/components/WorkspaceDndKitSortableItem';
import { NavigationMenuItemDroppableIds } from '@/navigation-menu-item/constants/NavigationMenuItemDroppableIds';
import { NavigationMenuItemType } from '@/navigation-menu-item/constants/NavigationMenuItemType';
import { NavigationDropTargetContext } from '@/navigation-menu-item/contexts/NavigationDropTargetContext';
import { NavigationMenuItemDragContext } from '@/navigation-menu-item/contexts/NavigationMenuItemDragContext';
import { useIsDropDisabledForSection } from '@/navigation-menu-item/hooks/useIsDropDisabledForSection';
import { isNavigationMenuInEditModeState } from '@/navigation-menu-item/states/isNavigationMenuInEditModeState';
import { useAtomStateValue } from '@/ui/utilities/state/jotai/hooks/useAtomStateValue';
import { styled } from '@linaria/react';
import { useContext } from 'react';
import { themeCssVariables } from 'twenty-ui/theme-constants';
import { NavigationDrawerSectionForWorkspaceItemContent } from '@/object-metadata/components/NavigationDrawerSectionForWorkspaceItemContent';
import { WorkspaceOrphanDropTarget } from '@/object-metadata/components/WorkspaceOrphanDropTarget';
import { WorkspaceSectionAddMenuItemButton } from '@/object-metadata/components/WorkspaceSectionAddMenuItemButton';
import type { WorkspaceSectionListDndKitProps } from '@/object-metadata/components/WorkspaceSectionListDndKitProps';
const StyledList = styled.div`
display: flex;
flex-direction: column;
gap: ${themeCssVariables.betweenSiblingsGap};
`;
const StyledListItemRow = styled.div`
display: flex;
flex-direction: column;
gap: 0;
`;
export const WorkspaceSectionListDndKit = ({
filteredItems,
getEditModeProps,
folderChildrenById,
selectedNavigationMenuItemId,
onNavigationMenuItemClick,
onActiveObjectMetadataItemClick,
}: WorkspaceSectionListDndKitProps) => {
const isNavigationMenuInEditMode = useAtomStateValue(
isNavigationMenuInEditModeState,
);
const workspaceDropDisabled = useIsDropDisabledForSection(true);
const { isDragging } = useContext(NavigationMenuItemDragContext);
const { addToNavigationFallbackDestination } = useContext(
NavigationDropTargetContext,
);
const folderCount = filteredItems.filter(
(item) => item.itemType === NavigationMenuItemType.FOLDER,
).length;
const isAddMenuItemButtonVisible = isNavigationMenuInEditMode;
return (
<StyledList>
{filteredItems.map((item, index) => (
<StyledListItemRow key={item.id}>
<WorkspaceOrphanDropTarget index={index} compact />
<WorkspaceDndKitSortableItem
id={item.id}
index={index}
group={
NavigationMenuItemDroppableIds.WORKSPACE_ORPHAN_NAVIGATION_MENU_ITEMS
}
disabled={!isNavigationMenuInEditMode || workspaceDropDisabled}
>
<NavigationDrawerSectionForWorkspaceItemContent
item={item}
editModeProps={getEditModeProps(item)}
isDragging={isDragging}
folderChildrenById={folderChildrenById}
folderCount={folderCount}
selectedNavigationMenuItemId={selectedNavigationMenuItemId}
onNavigationMenuItemClick={onNavigationMenuItemClick}
onActiveObjectMetadataItemClick={onActiveObjectMetadataItemClick}
/>
</WorkspaceDndKitSortableItem>
</StyledListItemRow>
))}
<WorkspaceDndKitDroppableSlot
droppableId={
NavigationMenuItemDroppableIds.WORKSPACE_ORPHAN_NAVIGATION_MENU_ITEMS
}
index={filteredItems.length}
disabled={workspaceDropDisabled}
>
<WorkspaceOrphanDropTarget
index={filteredItems.length}
compact={!isAddMenuItemButtonVisible}
>
{isAddMenuItemButtonVisible && <WorkspaceSectionAddMenuItemButton />}
</WorkspaceOrphanDropTarget>
</WorkspaceDndKitDroppableSlot>
{addToNavigationFallbackDestination?.droppableId ===
NavigationMenuItemDroppableIds.WORKSPACE_ORPHAN_NAVIGATION_MENU_ITEMS &&
addToNavigationFallbackDestination.index > filteredItems.length && (
<WorkspaceDndKitDroppableSlot
droppableId={
NavigationMenuItemDroppableIds.WORKSPACE_ORPHAN_NAVIGATION_MENU_ITEMS
}
index={addToNavigationFallbackDestination.index}
disabled={workspaceDropDisabled}
>
<WorkspaceOrphanDropTarget
index={addToNavigationFallbackDestination.index}
compact
/>
</WorkspaceDndKitDroppableSlot>
)}
</StyledList>
);
};

View file

@ -0,0 +1,51 @@
import { styled } from '@linaria/react';
import { themeCssVariables } from 'twenty-ui/theme-constants';
import { NavigationMenuItemType } from '@/navigation-menu-item/constants/NavigationMenuItemType';
import type { FlatWorkspaceItem } from '@/navigation-menu-item/hooks/useWorkspaceSectionItems';
import type { WorkspaceSectionListDndKitProps } from '@/object-metadata/components/WorkspaceSectionListDndKitProps';
import { NavigationDrawerSectionForWorkspaceItemContent } from '@/object-metadata/components/NavigationDrawerSectionForWorkspaceItemContent';
const StyledList = styled.div`
display: flex;
flex-direction: column;
gap: ${themeCssVariables.betweenSiblingsGap};
`;
type NavigationDrawerSectionForWorkspaceItemsListReadOnlyProps = Pick<
WorkspaceSectionListDndKitProps,
'filteredItems' | 'folderChildrenById' | 'onActiveObjectMetadataItemClick'
>;
const READ_ONLY_EDIT_MODE_PROPS = {
isSelectedInEditMode: false,
onEditModeClick: undefined,
} as const;
export const NavigationDrawerSectionForWorkspaceItemsListReadOnly = ({
filteredItems,
folderChildrenById,
onActiveObjectMetadataItemClick,
}: NavigationDrawerSectionForWorkspaceItemsListReadOnlyProps) => {
const folderCount = filteredItems.filter(
(item) => item.itemType === NavigationMenuItemType.FOLDER,
).length;
return (
<StyledList>
{filteredItems.map((item: FlatWorkspaceItem) => (
<NavigationDrawerSectionForWorkspaceItemContent
key={item.id}
item={item}
editModeProps={READ_ONLY_EDIT_MODE_PROPS}
isDragging={false}
folderChildrenById={folderChildrenById}
folderCount={folderCount}
selectedNavigationMenuItemId={null}
onActiveObjectMetadataItemClick={onActiveObjectMetadataItemClick}
readOnly
/>
))}
</StyledList>
);
};

View file

@ -0,0 +1,53 @@
import { SKELETON_LOADER_HEIGHT_SIZES } from '@/activities/components/SkeletonLoader';
import { NavigationDrawerSection } from '@/ui/navigation/navigation-drawer/components/NavigationDrawerSection';
import { styled } from '@linaria/react';
import Skeleton, { SkeletonTheme } from 'react-loading-skeleton';
import { themeCssVariables } from 'twenty-ui/theme-constants';
const StyledTitleSkeleton = styled.div`
align-items: center;
display: flex;
height: ${themeCssVariables.spacing[5]};
padding-left: ${themeCssVariables.spacing[1]};
padding-right: ${themeCssVariables.spacing['0.5']};
`;
const StyledRowsContainer = styled.div`
display: flex;
flex-direction: column;
gap: ${themeCssVariables.spacing[1]};
padding-left: ${themeCssVariables.spacing[1]};
`;
export const NavigationDrawerWorkspaceSectionSkeletonLoader = () => {
return (
<NavigationDrawerSection>
<SkeletonTheme
baseColor={themeCssVariables.background.tertiary}
highlightColor={themeCssVariables.background.transparent.light}
borderRadius={4}
>
<StyledTitleSkeleton>
<Skeleton
width={72}
height={SKELETON_LOADER_HEIGHT_SIZES.standard.xs}
/>
</StyledTitleSkeleton>
<StyledRowsContainer>
<Skeleton
width={196}
height={SKELETON_LOADER_HEIGHT_SIZES.standard.s}
/>
<Skeleton
width={196}
height={SKELETON_LOADER_HEIGHT_SIZES.standard.s}
/>
<Skeleton
width={196}
height={SKELETON_LOADER_HEIGHT_SIZES.standard.s}
/>
</StyledRowsContainer>
</SkeletonTheme>
</NavigationDrawerSection>
);
};

View file

@ -0,0 +1,120 @@
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 { WorkspaceNavigationMenuItemFolderSubItem } from '@/navigation-menu-item/components/WorkspaceNavigationMenuItemFolderSubItem';
import { FOLDER_ICON_DEFAULT } from '@/navigation-menu-item/constants/FolderIconDefault';
import { DEFAULT_NAVIGATION_MENU_ITEM_COLOR_FOLDER } from '@/navigation-menu-item/constants/NavigationMenuItemDefaultColorFolder';
import { useWorkspaceFolderOpenState } from '@/navigation-menu-item/hooks/useWorkspaceFolderOpenState';
import type { ProcessedNavigationMenuItem } from '@/navigation-menu-item/utils/sortNavigationMenuItems';
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 WorkspaceFolderReadOnlyProps = {
folderId: string;
folderName: string;
folderIconKey?: string | null;
folderColor?: string | null;
navigationMenuItems: ProcessedNavigationMenuItem[];
isGroup: boolean;
};
export const WorkspaceFolderReadOnly = ({
folderId,
folderName,
folderIconKey,
folderColor,
navigationMenuItems,
isGroup,
}: WorkspaceFolderReadOnlyProps) => {
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>
);
};

View file

@ -0,0 +1,31 @@
import type { ReactNode } from 'react';
import { NavigationItemDropTarget } from '@/navigation-menu-item/components/NavigationItemDropTarget';
import { NavigationSections } from '@/navigation-menu-item/constants/NavigationSections.constants';
import { NavigationMenuItemDroppableIds } from '@/navigation-menu-item/constants/NavigationMenuItemDroppableIds';
import { getDndKitDropTargetId } from '@/navigation-menu-item/utils/getDndKitDropTargetId';
type WorkspaceOrphanDropTargetProps = {
index: number;
compact?: boolean;
children?: ReactNode;
};
export const WorkspaceOrphanDropTarget = ({
index,
compact = false,
children,
}: WorkspaceOrphanDropTargetProps) => (
<NavigationItemDropTarget
folderId={null}
index={index}
sectionId={NavigationSections.WORKSPACE}
compact={compact}
dropTargetIdOverride={getDndKitDropTargetId(
NavigationMenuItemDroppableIds.WORKSPACE_ORPHAN_NAVIGATION_MENU_ITEMS,
index,
)}
>
{children}
</NavigationItemDropTarget>
);

View file

@ -0,0 +1,55 @@
import { useLingui } from '@lingui/react/macro';
import React from 'react';
import { SidePanelPages } from 'twenty-shared/types';
import { IconColumnInsertRight, IconPlus } from 'twenty-ui/display';
import { addMenuItemInsertionContextState } from '@/navigation-menu-item/states/addMenuItemInsertionContextState';
import { selectedNavigationMenuItemInEditModeState } from '@/navigation-menu-item/states/selectedNavigationMenuItemInEditModeState';
import { useNavigateSidePanel } from '@/side-panel/hooks/useNavigateSidePanel';
import { sidePanelPageState } from '@/side-panel/states/sidePanelPageState';
import { NavigationDrawerItem } from '@/ui/navigation/navigation-drawer/components/NavigationDrawerItem';
import { useAtomStateValue } from '@/ui/utilities/state/jotai/hooks/useAtomStateValue';
import { useSetAtomState } from '@/ui/utilities/state/jotai/hooks/useSetAtomState';
export const WorkspaceSectionAddMenuItemButton = () => {
const { t } = useLingui();
const { navigateSidePanel } = useNavigateSidePanel();
const sidePanelPage = useAtomStateValue(sidePanelPageState);
const addMenuItemInsertionContext = useAtomStateValue(
addMenuItemInsertionContextState,
);
const setAddMenuItemInsertionContext = useSetAtomState(
addMenuItemInsertionContextState,
);
const setSelectedNavigationMenuItemInEditMode = useSetAtomState(
selectedNavigationMenuItemInEditModeState,
);
const handleClick = (event?: React.MouseEvent) => {
event?.stopPropagation();
setAddMenuItemInsertionContext(null);
setSelectedNavigationMenuItemInEditMode(null);
navigateSidePanel({
page: SidePanelPages.NavigationMenuAddItem,
pageTitle: t`New sidebar item`,
pageIcon: IconColumnInsertRight,
resetNavigationStack: true,
});
};
const isSelected =
sidePanelPage === SidePanelPages.NavigationMenuAddItem &&
addMenuItemInsertionContext === null;
return (
<NavigationDrawerItem
Icon={IconPlus}
label={t`Add menu item`}
onClick={handleClick}
triggerEvent="CLICK"
variant="tertiary"
isSelectedInEditMode={isSelected}
/>
);
};

View file

@ -0,0 +1,23 @@
import type {
FlatWorkspaceItem,
NavigationMenuItemClickParams,
} from '@/navigation-menu-item/hooks/useWorkspaceSectionItems';
import type { ProcessedNavigationMenuItem } from '@/navigation-menu-item/utils/sortNavigationMenuItems';
import type { ObjectMetadataItem } from '@/object-metadata/types/ObjectMetadataItem';
import type { EditModeProps } from '@/object-metadata/components/EditModeProps';
export type WorkspaceSectionItemContentProps = {
item: FlatWorkspaceItem;
editModeProps: EditModeProps;
isDragging: boolean;
folderChildrenById: Map<string, ProcessedNavigationMenuItem[]>;
folderCount: number;
selectedNavigationMenuItemId: string | null;
onNavigationMenuItemClick?: (params: NavigationMenuItemClickParams) => void;
onActiveObjectMetadataItemClick?: (
objectMetadataItem: ObjectMetadataItem,
navigationMenuItemId: string,
) => void;
readOnly?: boolean;
};

View file

@ -0,0 +1,20 @@
import type {
FlatWorkspaceItem,
NavigationMenuItemClickParams,
} from '@/navigation-menu-item/hooks/useWorkspaceSectionItems';
import type { ProcessedNavigationMenuItem } from '@/navigation-menu-item/utils/sortNavigationMenuItems';
import type { ObjectMetadataItem } from '@/object-metadata/types/ObjectMetadataItem';
import type { EditModeProps } from '@/object-metadata/components/EditModeProps';
export type WorkspaceSectionListDndKitProps = {
filteredItems: FlatWorkspaceItem[];
getEditModeProps: (item: FlatWorkspaceItem) => EditModeProps;
folderChildrenById: Map<string, ProcessedNavigationMenuItem[]>;
selectedNavigationMenuItemId: string | null;
onNavigationMenuItemClick?: (params: NavigationMenuItemClickParams) => void;
onActiveObjectMetadataItemClick?: (
objectMetadataItem: ObjectMetadataItem,
navigationMenuItemId: string,
) => void;
};

View file

@ -0,0 +1,32 @@
import { styled } from '@linaria/react';
import { NavigationDrawerSectionForWorkspaceItemsListReadOnly } from '@/object-metadata/components/NavigationDrawerSectionForWorkspaceItemsListReadOnly';
import { WorkspaceSectionAddMenuItemButton } from '@/object-metadata/components/WorkspaceSectionAddMenuItemButton';
import type { WorkspaceSectionListDndKitProps } from '@/object-metadata/components/WorkspaceSectionListDndKitProps';
import { themeCssVariables } from 'twenty-ui/theme-constants';
const StyledFallback = styled.div`
display: flex;
flex-direction: column;
gap: ${themeCssVariables.spacing[1]};
`;
type WorkspaceSectionListEditModeFallbackProps = Pick<
WorkspaceSectionListDndKitProps,
'filteredItems' | 'folderChildrenById' | 'onActiveObjectMetadataItemClick'
>;
export const WorkspaceSectionListEditModeFallback = ({
filteredItems,
folderChildrenById,
onActiveObjectMetadataItemClick,
}: WorkspaceSectionListEditModeFallbackProps) => (
<StyledFallback>
<NavigationDrawerSectionForWorkspaceItemsListReadOnly
filteredItems={filteredItems}
folderChildrenById={folderChildrenById}
onActiveObjectMetadataItemClick={onActiveObjectMetadataItemClick}
/>
<WorkspaceSectionAddMenuItemButton />
</StyledFallback>
);

View file

@ -1,8 +1,10 @@
import { useObjectMetadataItemById } from '@/object-metadata/hooks/useObjectMetadataItemById';
import { getObjectPermissionsForObject } from '@/object-metadata/utils/getObjectPermissionsForObject';
import { isNavigationMenuInEditModeState } from '@/navigation-menu-item/states/isNavigationMenuInEditModeState';
import { useObjectPermissions } from '@/object-record/hooks/useObjectPermissions';
import { isRecordReadOnly } from '@/object-record/read-only/utils/isRecordReadOnly';
import { useIsRecordDeleted } from '@/object-record/record-field/ui/hooks/useIsRecordDeleted';
import { useAtomStateValue } from '@/ui/utilities/state/jotai/hooks/useAtomStateValue';
type UseIsRecordReadOnlyParams = {
recordId: string;
@ -13,6 +15,10 @@ export const useIsRecordReadOnly = ({
recordId,
objectMetadataId,
}: UseIsRecordReadOnlyParams) => {
const isNavigationMenuInEditMode = useAtomStateValue(
isNavigationMenuInEditModeState,
);
const { objectMetadataItem } = useObjectMetadataItemById({
objectId: objectMetadataId,
});
@ -26,9 +32,12 @@ export const useIsRecordReadOnly = ({
const isRecordDeleted = useIsRecordDeleted({ recordId });
return isRecordReadOnly({
objectPermissions,
isRecordDeleted,
objectMetadataItem,
});
return (
isNavigationMenuInEditMode ||
isRecordReadOnly({
objectPermissions,
isRecordDeleted,
objectMetadataItem,
})
);
};

View file

@ -1,3 +1,4 @@
import { isNavigationMenuInEditModeState } from '@/navigation-menu-item/states/isNavigationMenuInEditModeState';
import { useObjectPermissionsForObject } from '@/object-record/hooks/useObjectPermissionsForObject';
import { isObjectMetadataReadOnly } from '@/object-record/read-only/utils/isObjectMetadataReadOnly';
import { hasAnySoftDeleteFilterOnViewComponentSelector } from '@/object-record/record-filter/states/hasAnySoftDeleteFilterOnView';
@ -5,6 +6,7 @@ import { useRecordTableContextOrThrow } from '@/object-record/record-table/conte
import { isRecordTableCreateDisabled } from '@/object-record/record-table/utils/isRecordTableCreateDisabled';
import { useScrollWrapperHTMLElement } from '@/ui/utilities/scroll/hooks/useScrollWrapperHTMLElement';
import { useAtomComponentSelectorValue } from '@/ui/utilities/state/jotai/hooks/useAtomComponentSelectorValue';
import { useAtomStateValue } from '@/ui/utilities/state/jotai/hooks/useAtomStateValue';
import { styled } from '@linaria/react';
import { type IconComponent } from 'twenty-ui/display';
import { Button } from 'twenty-ui/input';
@ -53,10 +55,15 @@ export const RecordTableEmptyStateDisplay = (
const objectPermissions = useObjectPermissionsForObject(
objectMetadataItem.id,
);
const isReadOnly = isObjectMetadataReadOnly({
objectPermissions,
objectMetadataItem,
});
const isNavigationMenuInEditMode = useAtomStateValue(
isNavigationMenuInEditModeState,
);
const isReadOnly =
isNavigationMenuInEditMode ||
isObjectMetadataReadOnly({
objectPermissions,
objectMetadataItem,
});
const hasAnySoftDeleteFilterOnView = useAtomComponentSelectorValue(
hasAnySoftDeleteFilterOnViewComponentSelector,

View file

@ -1,9 +1,11 @@
import { isNavigationMenuInEditModeState } from '@/navigation-menu-item/states/isNavigationMenuInEditModeState';
import { isObjectMetadataReadOnly } from '@/object-record/read-only/utils/isObjectMetadataReadOnly';
import { hasAnySoftDeleteFilterOnViewComponentSelector } from '@/object-record/record-filter/states/hasAnySoftDeleteFilterOnView';
import { useRecordTableContextOrThrow } from '@/object-record/record-table/contexts/RecordTableContext';
import { useCreateNewIndexRecord } from '@/object-record/record-table/hooks/useCreateNewIndexRecord';
import { isRecordTableCreateDisabled } from '@/object-record/record-table/utils/isRecordTableCreateDisabled';
import { useAtomComponentSelectorValue } from '@/ui/utilities/state/jotai/hooks/useAtomComponentSelectorValue';
import { useAtomStateValue } from '@/ui/utilities/state/jotai/hooks/useAtomStateValue';
import { styled } from '@linaria/react';
import { IconPlus } from 'twenty-ui/display';
import { themeCssVariables } from 'twenty-ui/theme-constants';
@ -20,6 +22,9 @@ export const RecordTableHeaderLabelIdentifierCellPlusButton = () => {
useRecordTableContextOrThrow();
const isMobile = useIsMobile();
const isNavigationMenuInEditMode = useAtomStateValue(
isNavigationMenuInEditModeState,
);
const { createNewIndexRecord } = useCreateNewIndexRecord({
objectMetadataItem,
@ -31,10 +36,12 @@ export const RecordTableHeaderLabelIdentifierCellPlusButton = () => {
});
};
const isReadOnly = isObjectMetadataReadOnly({
objectPermissions,
objectMetadataItem,
});
const isReadOnly =
isNavigationMenuInEditMode ||
isObjectMetadataReadOnly({
objectPermissions,
objectMetadataItem,
});
const hasAnySoftDeleteFilterOnView = useAtomComponentSelectorValue(
hasAnySoftDeleteFilterOnViewComponentSelector,

View file

@ -1,11 +1,24 @@
import { Droppable, type DroppableProvided } from '@hello-pangea/dnd';
import { type ReactNode, useContext } from 'react';
import { lazy, Suspense, useContext, type ReactNode } from 'react';
import { ADD_TO_NAV_SOURCE_DROPPABLE_ID } from '@/navigation-menu-item/constants/AddToNavSourceDroppableId';
import { NavigationDragSourceContext } from '@/navigation-menu-item/contexts/NavigationDragSourceContext';
import type { AddToNavDroppableProvided } from '@/command-menu/components/CommandMenuAddToNavDroppableTypes';
const FALLBACK_PROVIDED: AddToNavDroppableProvided = {
innerRef: () => {},
droppableProps: {},
placeholder: null,
};
const CommandMenuAddToNavDroppableDndKit = lazy(() =>
import('@/command-menu/components/CommandMenuAddToNavDroppableDndKit').then(
(m) => ({ default: m.CommandMenuAddToNavDroppableDndKit }),
),
);
type SidePanelAddToNavigationDroppableProps = {
children: (provided: DroppableProvided) => ReactNode;
children: (provided: AddToNavDroppableProvided) => ReactNode;
};
export const SidePanelAddToNavigationDroppable = ({
@ -15,11 +28,11 @@ export const SidePanelAddToNavigationDroppable = ({
const isDropDisabled = sourceDroppableId === ADD_TO_NAV_SOURCE_DROPPABLE_ID;
return (
<Droppable
droppableId={ADD_TO_NAV_SOURCE_DROPPABLE_ID}
isDropDisabled={isDropDisabled}
>
{(provided) => children(provided)}
</Droppable>
<Suspense fallback={children(FALLBACK_PROVIDED)}>
<CommandMenuAddToNavDroppableDndKit
children={children}
isDropDisabled={isDropDisabled}
/>
</Suspense>
);
};

View file

@ -1,23 +1,22 @@
import { styled } from '@linaria/react';
import { Draggable } from '@hello-pangea/dnd';
import { useLingui } from '@lingui/react/macro';
import { type ReactNode, useState } from 'react';
import { type ReactNode, lazy, Suspense, useState } from 'react';
import { isDefined } from 'twenty-shared/utils';
import { type IconComponent } from 'twenty-ui/display';
import { CommandMenuItem } from '@/command-menu/components/CommandMenuItem';
import { AddToNavigationDragHandle } from '@/navigation-menu-item/components/AddToNavigationDragHandle';
import { addToNavPayloadRegistryState } from '@/navigation-menu-item/states/addToNavPayloadRegistryState';
import { useSetAtomState } from '@/ui/utilities/state/jotai/hooks/useSetAtomState';
import type { AddToNavigationDragPayload } from '@/navigation-menu-item/types/add-to-navigation-drag-payload';
import { useSetAtomState } from '@/ui/utilities/state/jotai/hooks/useSetAtomState';
const StyledDraggableMenuItem = styled.div`
cursor: grab;
width: 100%;
&:active {
cursor: grabbing;
}
`;
const CommandMenuItemWithAddToNavigationDragDndKit = lazy(() =>
import(
'@/command-menu/components/CommandMenuItemWithAddToNavigationDragDndKit'
).then((m) => ({
default: m.CommandMenuItemWithAddToNavigationDragDndKit,
})),
);
type SidePanelItemWithAddToNavigationDragProps = {
icon?: IconComponent;
@ -28,8 +27,25 @@ type SidePanelItemWithAddToNavigationDragProps = {
onClick: () => void;
payload: AddToNavigationDragPayload;
dragIndex?: number;
disabled?: boolean;
disableDrag?: boolean;
};
const StyledDraggableMenuItem = styled.div<{
$disabled?: boolean;
$disableDrag?: boolean;
}>`
cursor: ${({ $disabled, $disableDrag }) =>
$disabled || $disableDrag ? 'default' : 'grab'};
pointer-events: ${({ $disabled }) => ($disabled ? 'none' : 'auto')};
width: 100%;
&:active {
cursor: ${({ $disabled, $disableDrag }) =>
$disabled || $disableDrag ? 'default' : 'grabbing'};
}
`;
export const SidePanelItemWithAddToNavigationDrag = ({
icon,
customIconContent,
@ -39,6 +55,8 @@ export const SidePanelItemWithAddToNavigationDrag = ({
onClick,
payload,
dragIndex,
disabled = false,
disableDrag = false,
}: SidePanelItemWithAddToNavigationDragProps) => {
const { t } = useLingui();
const setAddToNavPayloadRegistry = useSetAtomState(
@ -46,7 +64,8 @@ export const SidePanelItemWithAddToNavigationDrag = ({
);
const [isHovered, setIsHovered] = useState(false);
const contextualDescription = isHovered
const showDragAffordance = !disabled && !disableDrag && isHovered;
const contextualDescription = showDragAffordance
? t`Drag to add to navbar`
: description;
@ -55,23 +74,31 @@ export const SidePanelItemWithAddToNavigationDrag = ({
icon={icon}
customIconContent={customIconContent}
payload={payload}
isHovered={isHovered}
isHovered={showDragAffordance}
disabled={disabled}
disableDrag={disableDrag}
/>
);
const registerPayload = () => {
if (dragIndex !== undefined) {
if (!disabled && !disableDrag && isDefined(dragIndex)) {
setAddToNavPayloadRegistry((prev) => new Map(prev).set(id, payload));
}
};
const menuItemContent = (
<StyledDraggableMenuItem
$disabled={disabled}
$disableDrag={disableDrag}
onMouseEnter={() => {
setIsHovered(true);
registerPayload();
if (!disabled && !disableDrag) {
setIsHovered(true);
registerPayload();
}
}}
onMouseLeave={() => {
if (!disabled && !disableDrag) setIsHovered(false);
}}
onMouseLeave={() => setIsHovered(false)}
onMouseDown={registerPayload}
>
<CommandMenuItem
@ -80,27 +107,22 @@ export const SidePanelItemWithAddToNavigationDrag = ({
description={contextualDescription}
id={id}
onClick={onClick}
disabled={disabled}
/>
</StyledDraggableMenuItem>
);
if (dragIndex !== undefined) {
return (
<Draggable draggableId={id} index={dragIndex} isDragDisabled={false}>
{(provided) => (
<div
ref={provided.innerRef}
// oxlint-disable-next-line react/jsx-props-no-spreading
{...provided.draggableProps}
// oxlint-disable-next-line react/jsx-props-no-spreading
{...provided.dragHandleProps}
>
{menuItemContent}
</div>
)}
</Draggable>
);
if (!isDefined(dragIndex) || disableDrag) {
return menuItemContent;
}
return menuItemContent;
return (
<Suspense fallback={menuItemContent}>
<CommandMenuItemWithAddToNavigationDragDndKit
id={id}
dragIndex={dragIndex}
menuItemContent={menuItemContent}
/>
</Suspense>
);
};

View file

@ -1,14 +1,14 @@
import { useLingui } from '@lingui/react/macro';
import { IconLink } from 'twenty-ui/display';
import { IconLink, IconWorld } from 'twenty-ui/display';
import { SidePanelPageInfoLayout } from '@/side-panel/components/SidePanelPageInfoLayout';
import { sidePanelPageInfoState } from '@/side-panel/states/sidePanelPageInfoState';
import { sidePanelShouldFocusTitleInputComponentState } from '@/side-panel/states/sidePanelShouldFocusTitleInputComponentState';
import { NavigationMenuItemStyleIcon } from '@/navigation-menu-item/components/NavigationMenuItemStyleIcon';
import { LinkIconWithLinkOverlay } from '@/navigation-menu-item/components/LinkIconWithLinkOverlay';
import { NavigationMenuItemType } from '@/navigation-menu-item/constants/NavigationMenuItemType';
import { useUpdateLinkInDraft } from '@/navigation-menu-item/hooks/useUpdateLinkInDraft';
import { useWorkspaceSectionItems } from '@/navigation-menu-item/hooks/useWorkspaceSectionItems';
import { selectedNavigationMenuItemInEditModeState } from '@/navigation-menu-item/states/selectedNavigationMenuItemInEditModeState';
import { SidePanelPageInfoLayout } from '@/side-panel/components/SidePanelPageInfoLayout';
import { sidePanelPageInfoState } from '@/side-panel/states/sidePanelPageInfoState';
import { sidePanelShouldFocusTitleInputComponentState } from '@/side-panel/states/sidePanelShouldFocusTitleInputComponentState';
import { TitleInput } from '@/ui/input/components/TitleInput';
import { useAtomComponentState } from '@/ui/utilities/state/jotai/hooks/useAtomComponentState';
import { useAtomStateValue } from '@/ui/utilities/state/jotai/hooks/useAtomStateValue';
@ -59,8 +59,10 @@ export const SidePanelLinkInfo = () => {
return (
<SidePanelPageInfoLayout
icon={
<NavigationMenuItemStyleIcon
Icon={IconLink}
<LinkIconWithLinkOverlay
link={selectedItem.link}
LinkIcon={IconLink}
DefaultIcon={IconWorld}
color={selectedItem.color}
/>
}

View file

@ -8,7 +8,7 @@ import { sidePanelPageState } from '@/side-panel/states/sidePanelPageState';
import { useAtomStateValue } from '@/ui/utilities/state/jotai/hooks/useAtomStateValue';
import { styled } from '@linaria/react';
import { motion } from 'framer-motion';
import { useContext } from 'react';
import React, { useContext } from 'react';
import { isDefined } from 'twenty-shared/utils';
import { ThemeContext } from 'twenty-ui/theme-constants';
@ -21,11 +21,16 @@ export const SidePanelRouter = () => {
const sidePanelPage = useAtomStateValue(sidePanelPageState);
const sidePanelPageInfo = useAtomStateValue(sidePanelPageInfoState);
const sidePanelPageComponent = isDefined(sidePanelPage) ? (
SIDE_PANEL_PAGES_CONFIG.get(sidePanelPage)
) : (
<></>
);
const rawPageComponent = isDefined(sidePanelPage)
? SIDE_PANEL_PAGES_CONFIG.get(sidePanelPage)
: null;
const sidePanelPageComponent =
isDefined(rawPageComponent) && React.isValidElement(rawPageComponent)
? React.cloneElement(rawPageComponent, {
key: sidePanelPageInfo.instanceId,
})
: rawPageComponent;
const { theme } = useContext(ThemeContext);

View file

@ -3,22 +3,24 @@ import { isNonEmptyString } from '@sniptt/guards';
import { useState } from 'react';
import { getAbsoluteUrl } from 'twenty-shared/utils';
import { type ProcessedNavigationMenuItem } from '@/navigation-menu-item/types/processed-navigation-menu-item';
import { extractDomainFromUrl } from '@/navigation-menu-item/utils/extractDomainFromUrl';
import { SidePanelGroup } from '@/side-panel/components/SidePanelGroup';
import { SidePanelList } from '@/side-panel/components/SidePanelList';
import { SidePanelEditColorOption } from '@/side-panel/pages/navigation-menu-item/components/SidePanelEditColorOption';
import {
type OrganizeActionsProps,
SidePanelEditOrganizeActions,
} from '@/side-panel/pages/navigation-menu-item/components/SidePanelEditOrganizeActions';
import { SidePanelEditOwnerSection } from '@/side-panel/pages/navigation-menu-item/components/SidePanelEditOwnerSection';
import { getOrganizeActionsSelectableItemIds } from '@/side-panel/pages/navigation-menu-item/utils/getOrganizeActionsSelectableItemIds';
import { type ProcessedNavigationMenuItem } from '@/navigation-menu-item/types/processed-navigation-menu-item';
import { parseThemeColor } from '@/navigation-menu-item/utils/parseThemeColor';
import { TextInput } from '@/ui/input/components/TextInput';
type SidePanelEditLinkItemViewProps = OrganizeActionsProps & {
selectedItem: ProcessedNavigationMenuItem;
onUpdateLink: (linkId: string, link: string) => void;
onUpdateLink: (
linkId: string,
updates: { link?: string; name?: string },
) => void;
onOpenFolderPicker: () => void;
};
@ -36,28 +38,49 @@ export const SidePanelEditLinkItemView = ({
}: SidePanelEditLinkItemViewProps) => {
const { t } = useLingui();
const [urlEditInput, setUrlEditInput] = useState('');
const [lastAutoSetName, setLastAutoSetName] = useState<string | null>(null);
const defaultLabel = t`Link label`;
const selectableItemIds = getOrganizeActionsSelectableItemIds(true);
const currentName = selectedItem.name ?? defaultLabel;
const currentDomain = selectedItem.link
? extractDomainFromUrl(getAbsoluteUrl(selectedItem.link))
: undefined;
const canAutoUpdateName =
currentName === defaultLabel ||
currentName === currentDomain ||
currentName === lastAutoSetName;
const handleUrlChange = (value: string) => {
setUrlEditInput(value);
if (!canAutoUpdateName) return;
const trimmed = value.trim();
if (!isNonEmptyString(trimmed)) return;
const domain = extractDomainFromUrl(getAbsoluteUrl(trimmed));
if (domain !== undefined) {
setLastAutoSetName(domain);
onUpdateLink(selectedItem.id, { name: domain });
}
};
const handleUrlBlur = (event: React.FocusEvent<HTMLInputElement>) => {
const value = event.target.value.trim();
if (isNonEmptyString(value)) {
onUpdateLink(selectedItem.id, { link: getAbsoluteUrl(value) });
setUrlEditInput('');
}
};
return (
<SidePanelList commandGroups={[]} selectableItemIds={selectableItemIds}>
<SidePanelGroup heading={t`Customize`}>
<SidePanelEditColorOption
navigationMenuItemId={selectedItem.id}
color={parseThemeColor(selectedItem.color)}
/>
<TextInput
fullWidth
placeholder="www.google.com"
value={urlEditInput || selectedItem.link}
onChange={(value) => setUrlEditInput(value)}
onBlur={(event) => {
const value = event.target.value.trim();
if (isNonEmptyString(value)) {
onUpdateLink(selectedItem.id, getAbsoluteUrl(value));
setUrlEditInput('');
}
}}
onChange={handleUrlChange}
onBlur={handleUrlBlur}
/>
</SidePanelGroup>
<SidePanelEditOrganizeActions

View file

@ -1,14 +1,4 @@
import { SidePanelGroup } from '@/side-panel/components/SidePanelGroup';
import { CommandMenuItem } from '@/command-menu/components/CommandMenuItem';
import { SidePanelList } from '@/side-panel/components/SidePanelList';
import { SidePanelEditColorOption } from '@/side-panel/pages/navigation-menu-item/components/SidePanelEditColorOption';
import { SidePanelEditFolderPickerSubView } from '@/side-panel/pages/navigation-menu-item/components/SidePanelEditFolderPickerSubView';
import { SidePanelEditLinkItemView } from '@/side-panel/pages/navigation-menu-item/components/SidePanelEditLinkItemView';
import { SidePanelEditObjectViewBase } from '@/side-panel/pages/navigation-menu-item/components/SidePanelEditObjectViewBase';
import { SidePanelEditOrganizeActions } from '@/side-panel/pages/navigation-menu-item/components/SidePanelEditOrganizeActions';
import { SidePanelEditOwnerSection } from '@/side-panel/pages/navigation-menu-item/components/SidePanelEditOwnerSection';
import { useNavigationMenuItemEditOrganizeActions } from '@/side-panel/pages/navigation-menu-item/hooks/useNavigationMenuItemEditOrganizeActions';
import { getOrganizeActionsSelectableItemIds } from '@/side-panel/pages/navigation-menu-item/utils/getOrganizeActionsSelectableItemIds';
import { NavigationMenuItemType } from '@/navigation-menu-item/constants/NavigationMenuItemType';
import { useNavigationMenuItemsDraftState } from '@/navigation-menu-item/hooks/useNavigationMenuItemsDraftState';
import { useOpenAddItemToFolderPage } from '@/navigation-menu-item/hooks/useOpenAddItemToFolderPage';
@ -18,6 +8,16 @@ import { useSelectedNavigationMenuItemEditItemObjectMetadata } from '@/navigatio
import { useUpdateLinkInDraft } from '@/navigation-menu-item/hooks/useUpdateLinkInDraft';
import { selectedNavigationMenuItemInEditModeState } from '@/navigation-menu-item/states/selectedNavigationMenuItemInEditModeState';
import { parseThemeColor } from '@/navigation-menu-item/utils/parseThemeColor';
import { SidePanelGroup } from '@/side-panel/components/SidePanelGroup';
import { SidePanelList } from '@/side-panel/components/SidePanelList';
import { SidePanelEditColorOption } from '@/side-panel/pages/navigation-menu-item/components/SidePanelEditColorOption';
import { SidePanelEditFolderPickerSubView } from '@/side-panel/pages/navigation-menu-item/components/SidePanelEditFolderPickerSubView';
import { SidePanelEditLinkItemView } from '@/side-panel/pages/navigation-menu-item/components/SidePanelEditLinkItemView';
import { SidePanelEditObjectViewBase } from '@/side-panel/pages/navigation-menu-item/components/SidePanelEditObjectViewBase';
import { SidePanelEditOrganizeActions } from '@/side-panel/pages/navigation-menu-item/components/SidePanelEditOrganizeActions';
import { SidePanelEditOwnerSection } from '@/side-panel/pages/navigation-menu-item/components/SidePanelEditOwnerSection';
import { useNavigationMenuItemEditOrganizeActions } from '@/side-panel/pages/navigation-menu-item/hooks/useNavigationMenuItemEditOrganizeActions';
import { getOrganizeActionsSelectableItemIds } from '@/side-panel/pages/navigation-menu-item/utils/getOrganizeActionsSelectableItemIds';
import { SelectableListItem } from '@/ui/layout/selectable-list/components/SelectableListItem';
import { useAtomStateValue } from '@/ui/utilities/state/jotai/hooks/useAtomStateValue';
import { ViewKey } from '@/views/types/ViewKey';
@ -132,7 +132,9 @@ export const SidePanelNavigationMenuItemEditPage = () => {
<SidePanelEditLinkItemView
key={selectedItem.id}
selectedItem={selectedItem}
onUpdateLink={(linkId, link) => updateLinkInDraft(linkId, { link })}
onUpdateLink={(linkId, updates) =>
updateLinkInDraft(linkId, updates)
}
onOpenFolderPicker={openFolderPicker}
canMoveUp={canMoveUp}
canMoveDown={canMoveDown}

Some files were not shown because too many files have changed in this diff Show more