Fix dropping workspace nav items at the bottom of the list (#18989)

https://github.com/user-attachments/assets/64abd955-892e-48c8-bf7b-d4d8645cf33e

---------

Co-authored-by: Etienne <45695613+etiennejouan@users.noreply.github.com>
This commit is contained in:
Abdul Rahman
2026-03-31 18:36:31 +05:30
committed by GitHub
parent c3b969ab74
commit ec283b8f2d
10 changed files with 152 additions and 69 deletions
@@ -1,9 +1,7 @@
import { commandMenuItemsDraftState } from '@/command-menu-item/server-items/edit/states/commandMenuItemsDraftState';
import { activeCustomizationPageLayoutIdsState } from '@/layout-customization/states/activeCustomizationPageLayoutIdsState';
import { isLayoutCustomizationModeEnabledState } from '@/layout-customization/states/isLayoutCustomizationModeEnabledState';
import { navigationMenuItemsDraftState } from '@/navigation-menu-item/common/states/navigationMenuItemsDraftState';
import { selectedNavigationMenuItemIdInEditModeState } from '@/navigation-menu-item/common/states/selectedNavigationMenuItemIdInEditModeState';
import { currentPageLayoutIdState } from '@/page-layout/states/currentPageLayoutIdState';
import { useSidePanelMenu } from '@/side-panel/hooks/useSidePanelMenu';
import { useSetAtomState } from '@/ui/utilities/state/jotai/hooks/useSetAtomState';
import { useStore } from 'jotai';
@@ -11,6 +9,7 @@ import { useCallback } from 'react';
export const useExitLayoutCustomizationMode = () => {
const store = useStore();
const { closeSidePanelMenu } = useSidePanelMenu();
const setNavigationMenuItemsDraft = useSetAtomState(
@@ -27,9 +26,6 @@ export const useExitLayoutCustomizationMode = () => {
setNavigationMenuItemsDraft(null);
setSelectedNavigationMenuItemIdInEditMode(null);
store.set(commandMenuItemsDraftState.atom, null);
store.set(currentPageLayoutIdState.atom, null);
store.set(activeCustomizationPageLayoutIdsState.atom, []);
setIsLayoutCustomizationModeEnabled(false);
closeSidePanelMenu();
}, [
@@ -18,11 +18,11 @@ export const computeDndReorderPosition = ({
const listWithoutDragged = sortedList.filter(
(item) => item.id !== draggableId,
);
const adjustedIndex =
sourceIndexInList < destinationIndex &&
destinationIndex <= listWithoutDragged.length
? destinationIndex - 1
: destinationIndex;
let adjustedIndex = destinationIndex;
if (sourceIndexInList < destinationIndex) {
adjustedIndex -= 1;
}
adjustedIndex = Math.min(adjustedIndex, listWithoutDragged.length);
const prevItem = listWithoutDragged[adjustedIndex - 1];
const nextItem = listWithoutDragged[adjustedIndex];
@@ -5,7 +5,10 @@ import { themeCssVariables } from 'twenty-ui/theme-constants';
import { type NavigationSections } from '@/navigation-menu-item/common/constants/NavigationSections.constants';
import { NavigationDropTargetContext } from '@/navigation-menu-item/common/contexts/NavigationDropTargetContext';
const StyledDropTarget = styled.div<{ $compact?: boolean }>`
const StyledDropTarget = styled.div<{
$compact?: boolean;
$highlightPosition?: 'top' | 'bottom';
}>`
min-height: ${({ $compact }) =>
$compact ? 0 : themeCssVariables.spacing[2]};
position: relative;
@@ -17,13 +20,22 @@ const StyledDropTarget = styled.div<{ $compact?: boolean }>`
&::before {
content: '';
position: absolute;
bottom: 0;
left: 0;
width: 100%;
height: 2px;
background-color: ${themeCssVariables.color.blue};
${({ $highlightPosition }) =>
$highlightPosition === 'top'
? `
top: 0;
border-radius: 0 0 ${themeCssVariables.border.radius.sm}
${themeCssVariables.border.radius.sm};
`
: `
bottom: 0;
border-radius: ${themeCssVariables.border.radius.sm}
${themeCssVariables.border.radius.sm} 0 0;
`}
}
}
@@ -39,6 +51,7 @@ type NavigationItemDropTargetProps = {
children?: ReactNode;
compact?: boolean;
dropTargetIdOverride?: string;
highlightPosition?: 'top' | 'bottom';
};
export const NavigationItemDropTarget = ({
@@ -48,6 +61,7 @@ export const NavigationItemDropTarget = ({
children,
compact = false,
dropTargetIdOverride,
highlightPosition = 'bottom',
}: NavigationItemDropTargetProps) => {
const { activeDropTargetId, forbiddenDropTargetId } = useContext(
NavigationDropTargetContext,
@@ -60,6 +74,7 @@ export const NavigationItemDropTarget = ({
return (
<StyledDropTarget
$compact={compact}
$highlightPosition={highlightPosition}
data-drag-over={isDragOver && !isDropForbidden ? 'true' : undefined}
data-drop-forbidden={isDropForbidden ? 'true' : undefined}
>
@@ -1,6 +1,7 @@
import { NavigationMenuItemType } from 'twenty-shared/types';
import { type NavigationMenuItem } from '~/generated-metadata/graphql';
import { isLayoutCustomizationModeEnabledState } from '@/layout-customization/states/isLayoutCustomizationModeEnabledState';
import { getWorkspaceSidebarOrphanItemsInDisplayOrder } from '@/navigation-menu-item/display/utils/getWorkspaceSidebarOrphanItemsInDisplayOrder';
import { objectMetadataItemsSelector } from '@/object-metadata/states/objectMetadataItemsSelector';
import { type EnrichedObjectMetadataItem } from '@/object-metadata/types/EnrichedObjectMetadataItem';
@@ -22,6 +23,9 @@ export const useNavigationMenuItemSectionItems = (): NavigationMenuItem[] => {
const { workspaceNavigationMenuItemsSorted } = useSortedNavigationMenuItems();
const { workspaceNavigationMenuItemsByFolder } =
useNavigationMenuItemsByFolder();
const isLayoutCustomizationModeEnabled = useAtomStateValue(
isLayoutCustomizationModeEnabledState,
);
const views = useAtomStateValue(viewsSelector);
const objectMetadataItems = useAtomStateValue(objectMetadataItemsSelector);
const { objectPermissionsByObjectMetadataId } = useObjectPermissions();
@@ -39,6 +43,7 @@ export const useNavigationMenuItemSectionItems = (): NavigationMenuItem[] => {
objectMetadataItems,
views,
objectPermissionsByObjectMetadataId,
includeInaccessibleObjectBackedItems: isLayoutCustomizationModeEnabled,
});
return flatItems.flatMap((item) =>
@@ -1,14 +1,18 @@
import type { ReactNode } from 'react';
import { t } from '@lingui/core/macro';
import { isNonEmptyString } from '@sniptt/guards';
import { Fragment, type ReactNode, useContext } from 'react';
import { isLayoutCustomizationModeEnabledState } from '@/layout-customization/states/isLayoutCustomizationModeEnabledState';
import { ObjectIconWithViewOverlay } from '@/navigation-menu-item/display/view/components/ObjectIconWithViewOverlay';
import { getObjectColorWithFallback } from '@/object-metadata/utils/getObjectColorWithFallback';
import { recordIdentifierToObjectRecordIdentifier } from '@/navigation-menu-item/common/utils/recordIdentifierToObjectRecordIdentifier';
import { getNavigationMenuItemComputedLink } from '@/navigation-menu-item/display/utils/getNavigationMenuItemComputedLink';
import { getNavigationMenuItemLabel } from '@/navigation-menu-item/display/utils/getNavigationMenuItemLabel';
import { recordIdentifierToObjectRecordIdentifier } from '@/navigation-menu-item/common/utils/recordIdentifierToObjectRecordIdentifier';
import { ObjectIconWithViewOverlay } from '@/navigation-menu-item/display/view/components/ObjectIconWithViewOverlay';
import { lastVisitedViewPerObjectMetadataItemState } from '@/navigation/states/lastVisitedViewPerObjectMetadataItemState';
import { objectMetadataItemsSelector } from '@/object-metadata/states/objectMetadataItemsSelector';
import { type EnrichedObjectMetadataItem } from '@/object-metadata/types/EnrichedObjectMetadataItem';
import { getObjectColorWithFallback } from '@/object-metadata/utils/getObjectColorWithFallback';
import { getObjectPermissionsForObject } from '@/object-metadata/utils/getObjectPermissionsForObject';
import { useObjectPermissions } from '@/object-record/hooks/useObjectPermissions';
import { NavigationDrawerItem } from '@/ui/navigation/navigation-drawer/components/NavigationDrawerItem';
import { useAtomStateValue } from '@/ui/utilities/state/jotai/hooks/useAtomStateValue';
import { viewsSelector } from '@/views/states/selectors/viewsSelector';
@@ -19,7 +23,8 @@ import {
NavigationMenuItemType,
} from 'twenty-shared/types';
import { getAppPath, isDefined } from 'twenty-shared/utils';
import { Avatar, useIcons } from 'twenty-ui/display';
import { Avatar, IconLock, useIcons } from 'twenty-ui/display';
import { ThemeContext, themeCssVariables } from 'twenty-ui/theme-constants';
import { type NavigationMenuItem } from '~/generated-metadata/graphql';
export type NavigationDrawerItemForObjectMetadataItemProps = {
@@ -44,12 +49,19 @@ export const NavigationDrawerItemForObjectMetadataItem = ({
const isLayoutCustomizationModeEnabled = useAtomStateValue(
isLayoutCustomizationModeEnabledState,
);
const { objectPermissionsByObjectMetadataId } = useObjectPermissions();
const { theme } = useContext(ThemeContext);
const lastVisitedViewPerObjectMetadataItem = useAtomStateValue(
lastVisitedViewPerObjectMetadataItemState,
);
const objectMetadataItems = useAtomStateValue(objectMetadataItemsSelector);
const views = useAtomStateValue(viewsSelector);
const canReadObjectRecords = getObjectPermissionsForObject(
objectPermissionsByObjectMetadataId,
objectMetadataItem.id,
).canReadObjectRecords;
const lastVisitedViewId =
lastVisitedViewPerObjectMetadataItem?.[objectMetadataItem.id];
@@ -107,12 +119,19 @@ export const NavigationDrawerItemForObjectMetadataItem = ({
? getNavigationMenuItemLabel(navigationMenuItem, objectMetadataItems, views)
: objectMetadataItem.labelPlural;
const label = isRecord
? itemLabel
: isViewWithResolvedView
const primaryLabel =
isRecord || isViewWithResolvedView
? itemLabel
: objectMetadataItem.labelPlural;
const needsInaccessibleRecordPlaceholder =
isLayoutCustomizationModeEnabled &&
isRecord &&
!canReadObjectRecords &&
!isNonEmptyString(primaryLabel.trim());
const label = needsInaccessibleRecordPlaceholder ? t`Record` : primaryLabel;
const recordIdentifier =
isRecord && isDefined(navigationMenuItem?.targetRecordIdentifier)
? recordIdentifierToObjectRecordIdentifier({
@@ -151,6 +170,9 @@ export const NavigationDrawerItemForObjectMetadataItem = ({
? objectMetadataItem.labelSingular
: undefined;
const showInaccessibleLock =
isLayoutCustomizationModeEnabled && !canReadObjectRecords;
return (
<NavigationDrawerItem
label={label}
@@ -169,7 +191,20 @@ export const NavigationDrawerItemForObjectMetadataItem = ({
isSelectedInEditMode={isSelectedInEditMode}
isDragging={isDragging}
triggerEvent={isLayoutCustomizationModeEnabled ? 'CLICK' : undefined}
rightOptions={rightOptions}
alwaysShowRightOptions={showInaccessibleLock}
rightOptions={
showInaccessibleLock ? (
<Fragment>
<IconLock
size={theme.icon.size.sm}
stroke={theme.icon.stroke.sm}
color={themeCssVariables.font.color.tertiary}
/>
</Fragment>
) : (
rightOptions
)
}
/>
);
};
@@ -11,6 +11,7 @@ type NavigationMenuItemOrphanDropTargetProps = {
children?: ReactNode;
sectionId?: NavigationSections;
droppableId?: string;
highlightPosition?: 'top' | 'bottom';
};
export const NavigationMenuItemOrphanDropTarget = ({
@@ -19,6 +20,7 @@ export const NavigationMenuItemOrphanDropTarget = ({
children,
sectionId = NavigationSections.WORKSPACE,
droppableId = NavigationMenuItemDroppableIds.WORKSPACE_ORPHAN_NAVIGATION_MENU_ITEMS,
highlightPosition = 'bottom',
}: NavigationMenuItemOrphanDropTargetProps) => (
<NavigationItemDropTarget
folderId={null}
@@ -26,6 +28,7 @@ export const NavigationMenuItemOrphanDropTarget = ({
sectionId={sectionId}
compact={compact}
dropTargetIdOverride={getDndKitDropTargetId(droppableId, index)}
highlightPosition={highlightPosition}
>
{children}
</NavigationItemDropTarget>
@@ -111,6 +111,10 @@ export const WorkspaceSectionContainer = ({
return false;
});
const workspaceOrphanItemsForSection = isLayoutCustomizationModeEnabled
? flatItems
: filteredItems;
const getEditModeProps = (item: NavigationMenuItem): EditModeProps => {
const itemId = item.id;
return {
@@ -159,14 +163,14 @@ export const WorkspaceSectionContainer = ({
<Suspense
fallback={
<WorkspaceSectionListEditModeFallback
filteredItems={filteredItems}
filteredItems={workspaceOrphanItemsForSection}
folderChildrenById={folderChildrenById}
onActiveObjectMetadataItemClick={onActiveObjectMetadataItemClick}
/>
}
>
<LazyWorkspaceSectionListDndKit
filteredItems={filteredItems}
filteredItems={workspaceOrphanItemsForSection}
getEditModeProps={getEditModeProps}
folderChildrenById={folderChildrenById}
onNavigationMenuItemClick={onNavigationMenuItemClick}
@@ -1,20 +1,23 @@
import { NavigationMenuItemDroppableSlot } from '@/navigation-menu-item/display/dnd/components/NavigationMenuItemDroppableSlot';
import { NavigationMenuItemSortableItem } from '@/navigation-menu-item/display/dnd/components/NavigationMenuItemSortableItem';
import { isLayoutCustomizationModeEnabledState } from '@/layout-customization/states/isLayoutCustomizationModeEnabledState';
import { NavigationMenuItemDroppableIds } from '@/navigation-menu-item/common/constants/NavigationMenuItemDroppableIds';
import { NavigationMenuItemType } from 'twenty-shared/types';
import { NavigationDropTargetContext } from '@/navigation-menu-item/common/contexts/NavigationDropTargetContext';
import {
FOLDER_HEADER_SLOT_COLLISION_PRIORITY,
NavigationMenuItemDroppableSlot,
} from '@/navigation-menu-item/display/dnd/components/NavigationMenuItemDroppableSlot';
import { NavigationMenuItemSortableItem } from '@/navigation-menu-item/display/dnd/components/NavigationMenuItemSortableItem';
import { useIsDropDisabledForSection } from '@/navigation-menu-item/display/dnd/hooks/useIsDropDisabledForSection';
import { useAtomStateValue } from '@/ui/utilities/state/jotai/hooks/useAtomStateValue';
import { styled } from '@linaria/react';
import { useContext } from 'react';
import { NavigationMenuItemType } from 'twenty-shared/types';
import { themeCssVariables } from 'twenty-ui/theme-constants';
import { NavigationMenuItemDragContext } from '@/navigation-menu-item/common/contexts/NavigationMenuItemDragContext';
import { NavigationMenuItemDisplay } from '@/navigation-menu-item/display/components/NavigationMenuItemDisplay';
import { NavigationMenuItemOrphanDropTarget } from '@/navigation-menu-item/display/sections/components/NavigationMenuItemOrphanDropTarget';
import { WorkspaceSectionAddMenuItemButton } from '@/navigation-menu-item/edit/components/WorkspaceSectionAddMenuItemButton';
import type { NavigationMenuItemSectionListDndKitProps } from '@/navigation-menu-item/display/sections/types/NavigationMenuItemSectionListDndKitProps';
import { WorkspaceSectionAddMenuItemButton } from '@/navigation-menu-item/edit/components/WorkspaceSectionAddMenuItemButton';
type WorkspaceSectionListDndKitProps = NavigationMenuItemSectionListDndKitProps;
@@ -31,6 +34,10 @@ const StyledListItemRow = styled.div`
gap: 0;
`;
const StyledOrphanAppendSlotOverlap = styled.div`
margin-top: calc(-1 * ${themeCssVariables.betweenSiblingsGap});
`;
export const WorkspaceSectionListDndKit = ({
filteredItems,
getEditModeProps,
@@ -50,6 +57,7 @@ export const WorkspaceSectionListDndKit = ({
(item) => item.type === NavigationMenuItemType.FOLDER,
).length;
const isAddMenuItemButtonVisible = isLayoutCustomizationModeEnabled;
const orphanAppendDndIndex = filteredItems.length;
return (
<StyledList>
{filteredItems.map((item, index) => (
@@ -78,35 +86,41 @@ export const WorkspaceSectionListDndKit = ({
</NavigationMenuItemSortableItem>
</StyledListItemRow>
))}
<NavigationMenuItemDroppableSlot
droppableId={
NavigationMenuItemDroppableIds.WORKSPACE_ORPHAN_NAVIGATION_MENU_ITEMS
}
index={filteredItems.length}
disabled={workspaceDropDisabled}
>
<NavigationMenuItemOrphanDropTarget
index={filteredItems.length}
compact={!isAddMenuItemButtonVisible}
<StyledOrphanAppendSlotOverlap>
<NavigationMenuItemDroppableSlot
droppableId={
NavigationMenuItemDroppableIds.WORKSPACE_ORPHAN_NAVIGATION_MENU_ITEMS
}
index={orphanAppendDndIndex}
disabled={workspaceDropDisabled}
collisionPriority={FOLDER_HEADER_SLOT_COLLISION_PRIORITY}
>
<NavigationMenuItemOrphanDropTarget
index={orphanAppendDndIndex}
compact
highlightPosition="top"
/>
{isAddMenuItemButtonVisible && <WorkspaceSectionAddMenuItemButton />}
</NavigationMenuItemOrphanDropTarget>
</NavigationMenuItemDroppableSlot>
</NavigationMenuItemDroppableSlot>
</StyledOrphanAppendSlotOverlap>
{addToNavigationFallbackDestination?.droppableId ===
NavigationMenuItemDroppableIds.WORKSPACE_ORPHAN_NAVIGATION_MENU_ITEMS &&
addToNavigationFallbackDestination.index > filteredItems.length && (
<NavigationMenuItemDroppableSlot
droppableId={
NavigationMenuItemDroppableIds.WORKSPACE_ORPHAN_NAVIGATION_MENU_ITEMS
}
index={addToNavigationFallbackDestination.index}
disabled={workspaceDropDisabled}
>
<NavigationMenuItemOrphanDropTarget
addToNavigationFallbackDestination.index > orphanAppendDndIndex && (
<StyledOrphanAppendSlotOverlap>
<NavigationMenuItemDroppableSlot
droppableId={
NavigationMenuItemDroppableIds.WORKSPACE_ORPHAN_NAVIGATION_MENU_ITEMS
}
index={addToNavigationFallbackDestination.index}
compact
/>
</NavigationMenuItemDroppableSlot>
disabled={workspaceDropDisabled}
collisionPriority={FOLDER_HEADER_SLOT_COLLISION_PRIORITY}
>
<NavigationMenuItemOrphanDropTarget
index={addToNavigationFallbackDestination.index}
compact
/>
</NavigationMenuItemDroppableSlot>
</StyledOrphanAppendSlotOverlap>
)}
</StyledList>
);
@@ -17,6 +17,7 @@ type GetWorkspaceSidebarOrphanItemsInDisplayOrderArgs = {
objectPermissionsByObjectMetadataId: Parameters<
typeof getObjectPermissionsForObject
>[0];
includeInaccessibleObjectBackedItems?: boolean;
};
export const getWorkspaceSidebarOrphanItemsInDisplayOrder = ({
@@ -25,6 +26,7 @@ export const getWorkspaceSidebarOrphanItemsInDisplayOrder = ({
objectMetadataItems,
views,
objectPermissionsByObjectMetadataId,
includeInaccessibleObjectBackedItems = false,
}: GetWorkspaceSidebarOrphanItemsInDisplayOrderArgs): NavigationMenuItem[] => {
const flatWorkspaceItems = workspaceNavigationMenuItems
.filter((item) => !isDefined(item.folderId))
@@ -42,26 +44,34 @@ export const getWorkspaceSidebarOrphanItemsInDisplayOrder = ({
});
} else {
const validItem = processedItemsById.get(item.id);
if (!isDefined(validItem)) {
if (!isDefined(validItem) && !includeInaccessibleObjectBackedItems) {
return acc;
}
if (validItem.type === NavigationMenuItemType.LINK) {
acc.push(validItem);
} else {
const objectMetadataItem = getObjectMetadataForNavigationMenuItem(
validItem,
objectMetadataItems,
views,
);
if (
isDefined(objectMetadataItem) &&
getObjectPermissionsForObject(
objectPermissionsByObjectMetadataId,
objectMetadataItem.id,
).canReadObjectRecords
) {
acc.push(validItem);
}
const rowSource = isDefined(validItem) ? validItem : item;
if (rowSource.type === NavigationMenuItemType.LINK) {
acc.push(rowSource);
return acc;
}
if (includeInaccessibleObjectBackedItems) {
acc.push(rowSource);
return acc;
}
const objectMetadataItem = getObjectMetadataForNavigationMenuItem(
rowSource,
objectMetadataItems,
views,
);
if (
isDefined(objectMetadataItem) &&
getObjectPermissionsForObject(
objectPermissionsByObjectMetadataId,
objectMetadataItem.id,
).canReadObjectRecords
) {
acc.push(rowSource);
}
}
return acc;
@@ -30,6 +30,7 @@ const StyledIndexContainer = styled.div`
export const RecordIndexContainerGater = () => {
const store = useStore();
const { recordIndexId, objectMetadataItem } =
useRecordIndexIdFromCurrentContextStore();