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

## Summary

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

### Key changes

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

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

## Test plan

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

Made with [Cursor](https://cursor.com)
This commit is contained in:
Charles Bochet
2026-03-17 19:01:09 +01:00
committed by GitHub
parent 5a51764b8e
commit 9f7c29bce8
77 changed files with 1995 additions and 2255 deletions
@@ -66,11 +66,10 @@ describe('calculateNewPosition', () => {
items,
});
expect(result).toBe(Math.round(30 + (20 - 30) / 2));
expect(result).toBe(25);
});
it('should round fractional midpoints', () => {
it('should return fractional midpoints for adjacent positions', () => {
const items = createItems([1, 2, 5]);
const result = calculateNewPosition({
@@ -79,8 +78,7 @@ describe('calculateNewPosition', () => {
items,
});
expect(result).toBe(Math.round(5 + (2 - 5) / 2));
expect(result).toBe(4);
expect(result).toBe(3.5);
});
});
@@ -94,11 +92,10 @@ describe('calculateNewPosition', () => {
items,
});
expect(result).toBe(Math.round(20 - (20 - 10) / 2));
expect(result).toBe(15);
});
it('should round fractional midpoints', () => {
it('should return fractional midpoints for adjacent positions', () => {
const items = createItems([1, 4, 10]);
const result = calculateNewPosition({
@@ -107,8 +104,21 @@ describe('calculateNewPosition', () => {
items,
});
expect(result).toBe(Math.round(4 - (4 - 1) / 2));
expect(result).toBe(3);
expect(result).toBe(2.5);
});
it('should produce unique position for sequential integers', () => {
const items = createItems([1, 2]);
const result = calculateNewPosition({
destinationIndex: 1,
sourceIndex: 2,
items,
});
expect(result).toBe(1.5);
expect(result).not.toBe(items[0].position);
expect(result).not.toBe(items[1].position);
});
it('should handle destinationIndex equal to sourceIndex', () => {
@@ -18,18 +18,17 @@ export const calculateNewPosition = ({
}
if (destinationIndex > sourceIndex) {
return Math.round(
return (
items[destinationIndex].position +
(items[destinationIndex - 1].position -
items[destinationIndex].position) /
2,
(items[destinationIndex - 1].position -
items[destinationIndex].position) /
2
);
}
return Math.round(
return (
items[destinationIndex].position -
(items[destinationIndex].position -
items[destinationIndex - 1].position) /
2,
(items[destinationIndex].position - items[destinationIndex - 1].position) /
2
);
};
@@ -1,4 +0,0 @@
export const FOLDER_DROPPABLE_IDS = {
FOLDER_PREFIX: 'folder-',
FOLDER_HEADER_PREFIX: 'folder-header-',
} as const;
@@ -1,48 +0,0 @@
import { CustomError } from 'twenty-shared/utils';
import { FOLDER_DROPPABLE_IDS } from './folderDroppableIds';
type ValidateAndExtractFolderIdParams = {
droppableId: string;
// TODO: Remove orphanDroppableId prop when deleting all favorites code
orphanDroppableId: string;
};
export const validateAndExtractFolderId = ({
droppableId,
orphanDroppableId,
}: ValidateAndExtractFolderIdParams): string | null => {
if (droppableId === orphanDroppableId) {
return null;
}
if (droppableId.startsWith(FOLDER_DROPPABLE_IDS.FOLDER_HEADER_PREFIX)) {
const folderId = droppableId.replace(
FOLDER_DROPPABLE_IDS.FOLDER_HEADER_PREFIX,
'',
);
if (!folderId)
throw new CustomError(
`Invalid folder header ID: ${droppableId}`,
'INVALID_FOLDER_HEADER_ID',
);
return folderId;
}
if (droppableId.startsWith(FOLDER_DROPPABLE_IDS.FOLDER_PREFIX)) {
const folderId = droppableId.replace(
FOLDER_DROPPABLE_IDS.FOLDER_PREFIX,
'',
);
if (!folderId)
throw new CustomError(
`Invalid folder ID: ${droppableId}`,
'INVALID_FOLDER_ID',
);
return folderId;
}
throw new CustomError(
`Invalid droppable ID format: ${droppableId}`,
'INVALID_DROPPABLE_ID_FORMAT',
);
};
@@ -8,7 +8,7 @@ import { KeyboardShortcutMenu } from '@/keyboard-shortcut-menu/components/Keyboa
import { LayoutCustomizationBar } from '@/layout-customization/components/LayoutCustomizationBar';
import { AppNavigationDrawer } from '@/navigation/components/AppNavigationDrawer';
import { MobileNavigationBar } from '@/navigation/components/MobileNavigationBar';
import { PageDragDropProvider } from '@/navigation/components/PageDragDropProvider';
import { PageDragDropProvider } from '@/navigation-menu-item/display/dnd/providers/PageDragDropProvider';
import { useIsSettingsPage } from '@/navigation/hooks/useIsSettingsPage';
import { OBJECT_SETTINGS_WIDTH } from '@/settings/data-model/constants/ObjectSettings';
import { SignInAppNavigationDrawerMock } from '@/sign-in-background-mock/components/SignInAppNavigationDrawerMock';