Integrate NavigationMenuItem with feature flag support (#17268)

## Implement Navigation Menu Items Frontend

Implements the frontend for navigation menu items, the new system
replacing favorites.

### Changes
- Added GraphQL fragments and queries for navigation menu items
- Added hooks for managing navigation menu items (create, update,
delete, sorting, filtering)
- Updated components to use navigation menu items instead of favorites
- Added test coverage for utility functions

### Migration Note
The favorites and navigation menu item modules currently exist in
parallel. The favorites code will be removed once all data has been
migrated to navigation menu items.

<!-- CURSOR_SUMMARY -->
---

> [!NOTE]
> Replaces Favorites with feature-flagged `NavigationMenuItem` across
frontend and backend, while keeping Favorites as fallback until
migration completes.
> 
> - UI: new `navigation-menu-item` components (folders, orphan items,
drag provider/droppable, icons, skeleton), dispatcher components to
switch from Favorites, and updated “Add to favorites” action to create
`NavigationMenuItem` when `IS_NAVIGATION_MENU_ITEM_ENABLED`
> - DnD: shared `validateAndExtractFolderId` and droppable id utils
moved to `ui/layout/draggable-list`; favorites DnD updated to use shared
utils
> - GraphQL (client): add fragments, queries, mutations, hooks
(create/update/delete/find), and generated types; added
`RecordIdentifier` and `targetRecordIdentifier` on `NavigationMenuItem`
> - Prefetch: new prefetch state/effect for navigation menu items; skip
favorites prefetch when flag enabled
> - Backend: add DTOs (`NavigationMenuItem`, `RecordIdentifier`),
resolver `targetRecordIdentifier` field, service logic to fetch record
identifiers with permission-aware access and image signing,
`getRecordImageIdentifier` util, entity relation to `view`, and
migration adding FK on `viewId`
> - Feature flags & seeding: add `IS_NAVIGATION_MENU_ITEM_ENABLED` to
enums, dev seeder enables it; standard app seeds workspace navigation
menu items instead of favorites when flag on
> - Tests: add unit tests for sorting/labels/folder id and related utils
> 
> <sup>Written by [Cursor
Bugbot](https://cursor.com/dashboard?tab=bugbot) for commit
c99746f08b9f84fc8cec4fcc3a7d7afb8ea92db7. This will update automatically
on new commits. Configure
[here](https://cursor.com/dashboard?tab=bugbot).</sup>
<!-- /CURSOR_SUMMARY -->

---------

Co-authored-by: Félix Malfait <felix.malfait@gmail.com>
Co-authored-by: Aman Raj <92664006+araj00@users.noreply.github.com>
Co-authored-by: Félix Malfait <felix@twenty.com>
Co-authored-by: Paul Rastoin <45004772+prastoin@users.noreply.github.com>
Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
Co-authored-by: github-actions <github-actions@twenty.com>
This commit is contained in:
Abdul Rahman
2026-01-27 16:12:27 +05:30
committed by GitHub
parent c79f633f17
commit 2a8f834377
107 changed files with 4518 additions and 92 deletions
@@ -2,16 +2,24 @@ import { Action } from '@/action-menu/actions/components/Action';
import { useSelectedRecordIdOrThrow } from '@/action-menu/actions/record-actions/single-record/hooks/useSelectedRecordIdOrThrow';
import { useContextStoreObjectMetadataItemOrThrow } from '@/context-store/hooks/useContextStoreObjectMetadataItemOrThrow';
import { useCreateFavorite } from '@/favorites/hooks/useCreateFavorite';
import { useCreateNavigationMenuItem } from '@/navigation-menu-item/hooks/useCreateNavigationMenuItem';
import { recordStoreFamilyState } from '@/object-record/record-store/states/recordStoreFamilyState';
import { useIsFeatureEnabled } from '@/workspace/hooks/useIsFeatureEnabled';
import { useRecoilValue } from 'recoil';
import { isDefined } from 'twenty-shared/utils';
import { FeatureFlagKey } from '~/generated/graphql';
export const AddToFavoritesSingleRecordAction = () => {
const { objectMetadataItem } = useContextStoreObjectMetadataItemOrThrow();
const recordId = useSelectedRecordIdOrThrow();
const isNavigationMenuItemEnabled = useIsFeatureEnabled(
FeatureFlagKey.IS_NAVIGATION_MENU_ITEM_ENABLED,
);
const { createFavorite } = useCreateFavorite();
const { createNavigationMenuItem } = useCreateNavigationMenuItem();
const selectedRecord = useRecoilValue(recordStoreFamilyState(recordId));
@@ -20,7 +28,11 @@ export const AddToFavoritesSingleRecordAction = () => {
return;
}
createFavorite(selectedRecord, objectMetadataItem.nameSingular);
if (isNavigationMenuItemEnabled) {
createNavigationMenuItem(selectedRecord, objectMetadataItem.nameSingular);
} else {
createFavorite(selectedRecord, objectMetadataItem.nameSingular);
}
};
return <Action onClick={handleClick} />;
@@ -2,11 +2,15 @@ import { Action } from '@/action-menu/actions/components/Action';
import { useSelectedRecordIdOrThrow } from '@/action-menu/actions/record-actions/single-record/hooks/useSelectedRecordIdOrThrow';
import { useDeleteFavorite } from '@/favorites/hooks/useDeleteFavorite';
import { useFavorites } from '@/favorites/hooks/useFavorites';
import { usePrefetchedNavigationMenuItemsData } from '@/navigation-menu-item/hooks/usePrefetchedNavigationMenuItemsData';
import { useRemoveNavigationMenuItemByTargetRecordId } from '@/navigation-menu-item/hooks/useRemoveNavigationMenuItemByTargetRecordId';
import { useDeleteOneRecord } from '@/object-record/hooks/useDeleteOneRecord';
import { useRemoveSelectedRecordsFromRecordBoard } from '@/object-record/record-board/hooks/useRemoveSelectedRecordsFromRecordBoard';
import { useRecordIndexIdFromCurrentContextStore } from '@/object-record/record-index/hooks/useRecordIndexIdFromCurrentContextStore';
import { useResetTableRowSelection } from '@/object-record/record-table/hooks/internal/useResetTableRowSelection';
import { useIsFeatureEnabled } from '@/workspace/hooks/useIsFeatureEnabled';
import { isDefined } from 'twenty-shared/utils';
import { FeatureFlagKey } from '~/generated/graphql';
export const DeleteSingleRecordAction = () => {
const { recordIndexId, objectMetadataItem } =
@@ -25,6 +29,13 @@ export const DeleteSingleRecordAction = () => {
const { sortedFavorites: favorites } = useFavorites();
const { deleteFavorite } = useDeleteFavorite();
const isNavigationMenuItemEnabled = useIsFeatureEnabled(
FeatureFlagKey.IS_NAVIGATION_MENU_ITEM_ENABLED,
);
const { navigationMenuItems, workspaceNavigationMenuItems } =
usePrefetchedNavigationMenuItemsData();
const { removeNavigationMenuItemsByTargetRecordIds } =
useRemoveNavigationMenuItemByTargetRecordId();
const handleDeleteClick = async () => {
removeSelectedRecordsFromRecordBoard();
@@ -39,6 +50,17 @@ export const DeleteSingleRecordAction = () => {
deleteFavorite(foundFavorite.id);
}
if (isNavigationMenuItemEnabled) {
const foundNavigationMenuItem = [
...navigationMenuItems,
...workspaceNavigationMenuItems,
].find((item) => item.targetRecordId === recordId);
if (isDefined(foundNavigationMenuItem)) {
removeNavigationMenuItemsByTargetRecordIds([recordId]);
}
}
await deleteOneRecord(recordId);
};
@@ -1,21 +1,50 @@
import { Action } from '@/action-menu/actions/components/Action';
import { useSelectedRecordIdOrThrow } from '@/action-menu/actions/record-actions/single-record/hooks/useSelectedRecordIdOrThrow';
import { useContextStoreObjectMetadataItemOrThrow } from '@/context-store/hooks/useContextStoreObjectMetadataItemOrThrow';
import { useDeleteFavorite } from '@/favorites/hooks/useDeleteFavorite';
import { useFavorites } from '@/favorites/hooks/useFavorites';
import { useDeleteNavigationMenuItem } from '@/navigation-menu-item/hooks/useDeleteNavigationMenuItem';
import { usePrefetchedNavigationMenuItemsData } from '@/navigation-menu-item/hooks/usePrefetchedNavigationMenuItemsData';
import { useIsFeatureEnabled } from '@/workspace/hooks/useIsFeatureEnabled';
import { isDefined } from 'twenty-shared/utils';
import { FeatureFlagKey } from '~/generated/graphql';
export const RemoveFromFavoritesSingleRecordAction = () => {
const recordId = useSelectedRecordIdOrThrow();
const { objectMetadataItem } = useContextStoreObjectMetadataItemOrThrow();
const { sortedFavorites: favorites } = useFavorites();
const { navigationMenuItems, workspaceNavigationMenuItems } =
usePrefetchedNavigationMenuItemsData();
const isNavigationMenuItemEnabled = useIsFeatureEnabled(
FeatureFlagKey.IS_NAVIGATION_MENU_ITEM_ENABLED,
);
const { deleteFavorite } = useDeleteFavorite();
const { deleteNavigationMenuItem } = useDeleteNavigationMenuItem();
const foundFavorite = favorites?.find(
(favorite) => favorite.recordId === recordId,
);
const foundNavigationMenuItem = isNavigationMenuItemEnabled
? [...navigationMenuItems, ...workspaceNavigationMenuItems].find(
(item) =>
item.targetRecordId === recordId &&
item.targetObjectMetadataId === objectMetadataItem.id,
)
: undefined;
const handleClick = () => {
if (isNavigationMenuItemEnabled) {
if (!isDefined(foundNavigationMenuItem)) {
return;
}
deleteNavigationMenuItem(foundNavigationMenuItem.id);
return;
}
if (!isDefined(foundFavorite)) {
return;
}
@@ -8,14 +8,18 @@ import { contextStoreNumberOfSelectedRecordsComponentState } from '@/context-sto
import { contextStoreTargetedRecordsRuleComponentState } from '@/context-store/states/contextStoreTargetedRecordsRuleComponentState';
import { ContextStoreViewType } from '@/context-store/types/ContextStoreViewType';
import { useFavorites } from '@/favorites/hooks/useFavorites';
import { usePrefetchedNavigationMenuItemsData } from '@/navigation-menu-item/hooks/usePrefetchedNavigationMenuItemsData';
import { CoreObjectNameSingular } from '@/object-metadata/types/CoreObjectNameSingular';
import { type ObjectMetadataItem } from '@/object-metadata/types/ObjectMetadataItem';
import { useObjectPermissionsForObject } from '@/object-record/hooks/useObjectPermissionsForObject';
import { hasAnySoftDeleteFilterOnViewComponentSelector } from '@/object-record/record-filter/states/hasAnySoftDeleteFilterOnView';
import { recordStoreFamilyState } from '@/object-record/record-store/states/recordStoreFamilyState';
import { useRecoilComponentValue } from '@/ui/utilities/state/component-state/hooks/useRecoilComponentValue';
import { useContext } from 'react';
import { useIsFeatureEnabled } from '@/workspace/hooks/useIsFeatureEnabled';
import { useContext, useMemo } from 'react';
import { useRecoilCallback, useRecoilValue } from 'recoil';
import { isDefined } from 'twenty-shared/utils';
import { FeatureFlagKey } from '~/generated/graphql';
export const useShouldActionBeRegisteredParams = ({
objectMetadataItem,
@@ -23,6 +27,10 @@ export const useShouldActionBeRegisteredParams = ({
objectMetadataItem?: ObjectMetadataItem;
}): ShouldBeRegisteredFunctionParams => {
const { sortedFavorites: favorites } = useFavorites();
const { navigationMenuItems } = usePrefetchedNavigationMenuItemsData();
const isNavigationMenuItemEnabled = useIsFeatureEnabled(
FeatureFlagKey.IS_NAVIGATION_MENU_ITEM_ENABLED,
);
const contextStoreTargetedRecordsRule = useRecoilComponentValue(
contextStoreTargetedRecordsRuleComponentState,
@@ -33,11 +41,31 @@ export const useShouldActionBeRegisteredParams = ({
? contextStoreTargetedRecordsRule.selectedRecordIds[0]
: undefined;
const foundFavorite = favorites?.find(
(favorite) => favorite.recordId === recordId,
);
const isFavorite = useMemo(() => {
if (!isDefined(recordId)) {
return false;
}
const isFavorite = !!foundFavorite;
if (isNavigationMenuItemEnabled && isDefined(objectMetadataItem)) {
const foundNavigationMenuItem = navigationMenuItems?.find(
(item) =>
item.targetRecordId === recordId &&
item.targetObjectMetadataId === objectMetadataItem.id,
);
return !!foundNavigationMenuItem;
}
const foundFavorite = favorites?.find(
(favorite) => favorite.recordId === recordId,
);
return !!foundFavorite;
}, [
recordId,
isNavigationMenuItemEnabled,
objectMetadataItem,
navigationMenuItems,
favorites,
]);
const selectedRecord =
useRecoilValue(recordStoreFamilyState(recordId ?? '')) || undefined;
@@ -1,5 +1,7 @@
import { FOLDER_DROPPABLE_IDS } from '@/ui/layout/draggable-list/utils/folderDroppableIds';
export const FAVORITE_DROPPABLE_IDS = {
ORPHAN_FAVORITES: 'orphan-favorites',
FOLDER_PREFIX: 'folder-',
FOLDER_HEADER_PREFIX: 'folder-header-',
};
FOLDER_PREFIX: FOLDER_DROPPABLE_IDS.FOLDER_PREFIX,
FOLDER_HEADER_PREFIX: FOLDER_DROPPABLE_IDS.FOLDER_HEADER_PREFIX,
} as const;
@@ -1,10 +1,10 @@
import { FAVORITE_DROPPABLE_IDS } from '@/favorites/constants/FavoriteDroppableIds';
import { useSortedFavorites } from '@/favorites/hooks/useSortedFavorites';
import { openFavoriteFolderIdsState } from '@/favorites/states/openFavoriteFolderIdsState';
import { calculateNewPosition } from '@/favorites/utils/calculateNewPosition';
import { validateAndExtractFolderId } from '@/favorites/utils/validateAndExtractFolderId';
import { CoreObjectNameSingular } from '@/object-metadata/types/CoreObjectNameSingular';
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 { type OnDragEndResponder } from '@hello-pangea/dnd';
import { useSetRecoilState } from 'recoil';
import { usePrefetchedFavoritesData } from './usePrefetchedFavoritesData';
@@ -43,10 +43,14 @@ export const useHandleFavoriteDragAndDrop = () => {
const draggedFavorite = favorites.find((f) => f.id === draggableId);
if (!draggedFavorite) return;
const destinationFolderId = validateAndExtractFolderId(
destination.droppableId,
);
const sourceFolderId = validateAndExtractFolderId(source.droppableId);
const destinationFolderId = validateAndExtractFolderId({
droppableId: destination.droppableId,
orphanDroppableId: FAVORITE_DROPPABLE_IDS.ORPHAN_FAVORITES,
});
const sourceFolderId = validateAndExtractFolderId({
droppableId: source.droppableId,
orphanDroppableId: FAVORITE_DROPPABLE_IDS.ORPHAN_FAVORITES,
});
if (
destination.droppableId.startsWith(
@@ -0,0 +1,37 @@
import { getObjectMetadataNamePluralFromViewId } from '@/favorites/utils/getObjectMetadataNamePluralFromViewId';
import { type View } from '@/views/types/View';
import { generatedMockObjectMetadataItems } from '~/testing/utils/generatedMockObjectMetadataItems';
describe('getObjectMetadataNamePluralFromViewId', () => {
it('should return namePlural and view for matching objectMetadataId', () => {
const view: Pick<View, 'id' | 'name' | 'objectMetadataId'> = {
id: 'view-id',
name: 'All People',
objectMetadataId:
generatedMockObjectMetadataItems[0]?.id ?? 'metadata-id',
};
const result = getObjectMetadataNamePluralFromViewId(
view,
generatedMockObjectMetadataItems,
);
expect(result.namePlural).toBeDefined();
expect(result.view).toEqual(view);
});
it('should throw error when objectMetadataItem is not found', () => {
const view: Pick<View, 'id' | 'name' | 'objectMetadataId'> = {
id: 'view-id',
name: 'All People',
objectMetadataId: 'non-existent-id',
};
expect(() => {
getObjectMetadataNamePluralFromViewId(
view,
generatedMockObjectMetadataItems,
);
}).toThrow('Object metadata item not found for id non-existent-id');
});
});
@@ -0,0 +1,233 @@
import { type Favorite } from '@/favorites/types/Favorite';
import { sortFavorites } from '@/favorites/utils/sortFavorites';
import { type FieldMetadataItem } from '@/object-metadata/types/FieldMetadataItem';
import { type ObjectMetadataItem } from '@/object-metadata/types/ObjectMetadataItem';
import { type ObjectRecord } from '@/object-record/types/ObjectRecord';
import { type ObjectRecordIdentifier } from '@/object-record/types/ObjectRecordIdentifier';
import { type View } from '@/views/types/View';
import { FieldMetadataType } from '~/generated-metadata/graphql';
jest.mock('@/favorites/utils/getObjectMetadataNamePluralFromViewId', () => ({
getObjectMetadataNamePluralFromViewId: jest.fn(
(
view: Pick<View, 'id' | 'name' | 'objectMetadataId'>,
items: ObjectMetadataItem[],
) => {
const item = items.find((item) => item.id === view.objectMetadataId);
return { namePlural: item?.namePlural ?? 'items' };
},
),
}));
jest.mock('twenty-shared/utils', () => {
const actual = jest.requireActual('twenty-shared/utils');
return {
...actual,
getAppPath: jest.fn((path, params, query) => {
const basePath = `/app/objects/${params.objectNamePlural}`;
const viewId = query?.viewId;
if (viewId !== undefined && viewId !== null) {
return `${basePath}?viewId=${viewId}`;
}
return basePath;
}),
};
});
describe('sortFavorites', () => {
const mockView: Pick<View, 'id' | 'name' | 'objectMetadataId' | 'icon'> = {
id: 'view-id',
name: 'All People',
objectMetadataId: 'metadata-id',
icon: 'IconUser',
};
const mockObjectMetadataItem: ObjectMetadataItem = {
id: 'metadata-id',
nameSingular: 'person',
namePlural: 'people',
} as ObjectMetadataItem;
const mockObjectRecord: ObjectRecord = {
__typename: 'ObjectRecord',
id: 'record-id',
name: 'John Doe',
} as ObjectRecord;
const mockObjectRecordIdentifier: ObjectRecordIdentifier = {
id: 'record-id',
name: 'John Doe',
avatarUrl: 'https://example.com/avatar.jpg',
avatarType: 'rounded',
linkToShowPage: '/app/objects/people/record-id',
};
const mockRelationField: FieldMetadataItem = {
name: 'person',
type: FieldMetadataType.RELATION,
relation: {
targetObjectMetadata: {
nameSingular: 'person',
},
},
} as FieldMetadataItem;
const getObjectRecordIdentifierByNameSingular = jest.fn(
(
record: ObjectRecord,
objectNameSingular: string,
): ObjectRecordIdentifier => {
if (objectNameSingular === 'person') {
return mockObjectRecordIdentifier;
}
return {
id: record.id,
name: 'Unknown',
};
},
);
beforeEach(() => {
jest.clearAllMocks();
});
it('should process favorite with viewId', () => {
const favorite = {
id: 'favorite-id',
viewId: 'view-id',
position: 1,
} as unknown as Favorite;
const result = sortFavorites(
[favorite],
[],
getObjectRecordIdentifierByNameSingular,
true,
[mockView],
[mockObjectMetadataItem],
);
expect(result).toHaveLength(1);
expect(result[0]).toMatchObject({
id: 'favorite-id',
objectNameSingular: 'view',
Icon: 'IconUser',
});
});
it('should handle favorite with viewId when view is not found', () => {
const favorite = {
id: 'favorite-id',
viewId: 'non-existent-view-id',
position: 1,
} as unknown as Favorite;
const result = sortFavorites(
[favorite],
[],
getObjectRecordIdentifierByNameSingular,
true,
[],
[mockObjectMetadataItem],
);
expect(result).toHaveLength(1);
expect(result[0].objectNameSingular).toBe('view');
});
it('should process favorite with relation field', () => {
const favorite = {
id: 'favorite-id',
person: mockObjectRecord,
position: 2,
} as unknown as Favorite;
const result = sortFavorites(
[favorite],
[mockRelationField],
getObjectRecordIdentifierByNameSingular,
true,
[],
[],
);
expect(result).toHaveLength(1);
expect(result[0]).toMatchObject({
id: 'favorite-id',
objectNameSingular: 'person',
labelIdentifier: 'John Doe',
});
});
it('should return empty link when hasLinkToShowPage is false', () => {
const favorite = {
id: 'favorite-id',
person: mockObjectRecord,
position: 2,
} as unknown as Favorite;
const result = sortFavorites(
[favorite],
[mockRelationField],
getObjectRecordIdentifierByNameSingular,
false,
[],
[],
);
expect(result).toHaveLength(1);
expect(result[0].link).toBe('');
});
it('should sort favorites by position', () => {
const favorites = [
{
id: 'favorite-3',
viewId: 'view-id',
position: 3,
},
{
id: 'favorite-1',
viewId: 'view-id',
position: 1,
},
{
id: 'favorite-2',
viewId: 'view-id',
position: 2,
},
] as unknown as Favorite[];
const result = sortFavorites(
favorites,
[],
getObjectRecordIdentifierByNameSingular,
true,
[mockView],
[mockObjectMetadataItem],
);
expect(result).toHaveLength(3);
expect(result[0].id).toBe('favorite-1');
expect(result[1].id).toBe('favorite-2');
expect(result[2].id).toBe('favorite-3');
});
it('should filter out favorites with no viewId and no relation fields', () => {
const favorite = {
id: 'favorite-id',
position: 1,
} as unknown as Favorite;
const result = sortFavorites(
[favorite],
[],
getObjectRecordIdentifierByNameSingular,
true,
[],
[],
);
expect(result).toHaveLength(0);
});
});
@@ -1,11 +1,12 @@
import { FAVORITE_DROPPABLE_IDS } from '@/favorites/constants/FavoriteDroppableIds';
import { validateAndExtractFolderId } from '@/favorites/utils/validateAndExtractFolderId';
import { validateAndExtractFolderId } from '@/ui/layout/draggable-list/utils/validateAndExtractFolderId';
describe('validateAndExtractFolderId', () => {
it('should return null for orphan favorites', () => {
const result = validateAndExtractFolderId(
FAVORITE_DROPPABLE_IDS.ORPHAN_FAVORITES,
);
const result = validateAndExtractFolderId({
droppableId: FAVORITE_DROPPABLE_IDS.ORPHAN_FAVORITES,
orphanDroppableId: FAVORITE_DROPPABLE_IDS.ORPHAN_FAVORITES,
});
expect(result).toBeNull();
});
@@ -13,7 +14,10 @@ describe('validateAndExtractFolderId', () => {
const folderId = '123-456';
const droppableId = `${FAVORITE_DROPPABLE_IDS.FOLDER_PREFIX}${folderId}`;
const result = validateAndExtractFolderId(droppableId);
const result = validateAndExtractFolderId({
droppableId,
orphanDroppableId: FAVORITE_DROPPABLE_IDS.ORPHAN_FAVORITES,
});
expect(result).toBe(folderId);
});
@@ -21,25 +25,37 @@ describe('validateAndExtractFolderId', () => {
const folderId = '123-456';
const droppableId = `${FAVORITE_DROPPABLE_IDS.FOLDER_HEADER_PREFIX}${folderId}`;
const result = validateAndExtractFolderId(droppableId);
const result = validateAndExtractFolderId({
droppableId,
orphanDroppableId: FAVORITE_DROPPABLE_IDS.ORPHAN_FAVORITES,
});
expect(result).toBe(folderId);
});
it('should throw error for invalid droppable id format', () => {
expect(() => {
validateAndExtractFolderId('invalid-id');
validateAndExtractFolderId({
droppableId: 'invalid-id',
orphanDroppableId: FAVORITE_DROPPABLE_IDS.ORPHAN_FAVORITES,
});
}).toThrow('Invalid droppable ID format: invalid-id');
});
it('should throw error for empty folder id in folder format', () => {
expect(() => {
validateAndExtractFolderId(FAVORITE_DROPPABLE_IDS.FOLDER_PREFIX);
validateAndExtractFolderId({
droppableId: FAVORITE_DROPPABLE_IDS.FOLDER_PREFIX,
orphanDroppableId: FAVORITE_DROPPABLE_IDS.ORPHAN_FAVORITES,
});
}).toThrow(`Invalid folder ID: ${FAVORITE_DROPPABLE_IDS.FOLDER_PREFIX}`);
});
it('should throw error for empty folder id in folder header format', () => {
expect(() => {
validateAndExtractFolderId(FAVORITE_DROPPABLE_IDS.FOLDER_HEADER_PREFIX);
validateAndExtractFolderId({
droppableId: FAVORITE_DROPPABLE_IDS.FOLDER_HEADER_PREFIX,
orphanDroppableId: FAVORITE_DROPPABLE_IDS.ORPHAN_FAVORITES,
});
}).toThrow(
`Invalid folder header ID: ${FAVORITE_DROPPABLE_IDS.FOLDER_HEADER_PREFIX}`,
);
@@ -1,41 +0,0 @@
import { FAVORITE_DROPPABLE_IDS } from '@/favorites/constants/FavoriteDroppableIds';
import { CustomError } from 'twenty-shared/utils';
export const validateAndExtractFolderId = (
droppableId: string,
): string | null => {
if (droppableId === FAVORITE_DROPPABLE_IDS.ORPHAN_FAVORITES) {
return null;
}
if (droppableId.startsWith(FAVORITE_DROPPABLE_IDS.FOLDER_HEADER_PREFIX)) {
const folderId = droppableId.replace(
FAVORITE_DROPPABLE_IDS.FOLDER_HEADER_PREFIX,
'',
);
if (!folderId)
throw new CustomError(
`Invalid folder header ID: ${droppableId}`,
'INVALID_FOLDER_HEADER_ID',
);
return folderId;
}
if (droppableId.startsWith(FAVORITE_DROPPABLE_IDS.FOLDER_PREFIX)) {
const folderId = droppableId.replace(
FAVORITE_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',
);
};
@@ -0,0 +1,84 @@
import { useLingui } from '@lingui/react/macro';
import { useRecoilState, useRecoilValue } from 'recoil';
import { isDefined } from 'twenty-shared/utils';
import { IconFolderPlus } from 'twenty-ui/display';
import { LightIconButton } from 'twenty-ui/input';
import { currentWorkspaceMemberState } from '@/auth/states/currentWorkspaceMemberState';
import { CurrentWorkspaceMemberOrphanNavigationMenuItems } from '@/navigation-menu-item/components/CurrentWorkspaceMemberOrphanNavigationMenuItems';
import { NavigationMenuItemDragProvider } from '@/navigation-menu-item/components/NavigationMenuItemDragProvider';
import { NavigationMenuItemFolders } from '@/navigation-menu-item/components/NavigationMenuItemFolders';
import { NavigationMenuItemSkeletonLoader } from '@/navigation-menu-item/components/NavigationMenuItemSkeletonLoader';
import { useNavigationMenuItemsByFolder } from '@/navigation-menu-item/hooks/useNavigationMenuItemsByFolder';
import { useSortedNavigationMenuItems } from '@/navigation-menu-item/hooks/useSortedNavigationMenuItems';
import { isNavigationMenuItemFolderCreatingState } from '@/navigation-menu-item/states/isNavigationMenuItemFolderCreatingState';
import { useIsPrefetchLoading } from '@/prefetch/hooks/useIsPrefetchLoading';
import { NavigationDrawerAnimatedCollapseWrapper } from '@/ui/navigation/navigation-drawer/components/NavigationDrawerAnimatedCollapseWrapper';
import { NavigationDrawerSection } from '@/ui/navigation/navigation-drawer/components/NavigationDrawerSection';
import { NavigationDrawerSectionTitle } from '@/ui/navigation/navigation-drawer/components/NavigationDrawerSectionTitle';
import { useNavigationSection } from '@/ui/navigation/navigation-drawer/hooks/useNavigationSection';
export const CurrentWorkspaceMemberNavigationMenuItemFolders = () => {
const currentWorkspaceMember = useRecoilValue(currentWorkspaceMemberState);
const { navigationMenuItemsSorted } = useSortedNavigationMenuItems();
const { navigationMenuItemsByFolder } = useNavigationMenuItemsByFolder();
const [
isNavigationMenuItemFolderCreating,
setIsNavigationMenuItemFolderCreating,
] = useRecoilState(isNavigationMenuItemFolderCreatingState);
const loading = useIsPrefetchLoading();
const { t } = useLingui();
const {
toggleNavigationSection,
isNavigationSectionOpenState,
openNavigationSection,
} = useNavigationSection('Favorites');
const isNavigationSectionOpen = useRecoilValue(isNavigationSectionOpenState);
const toggleNewFolder = () => {
openNavigationSection();
setIsNavigationMenuItemFolderCreating((current) => !current);
};
if (loading && isDefined(currentWorkspaceMember)) {
return <NavigationMenuItemSkeletonLoader />;
}
if (
(!navigationMenuItemsSorted || navigationMenuItemsSorted.length === 0) &&
!isNavigationMenuItemFolderCreating &&
(!navigationMenuItemsByFolder || navigationMenuItemsByFolder.length === 0)
) {
return null;
}
return (
<NavigationDrawerSection>
<NavigationDrawerAnimatedCollapseWrapper>
<NavigationDrawerSectionTitle
label={t`Favorites`}
onClick={toggleNavigationSection}
rightIcon={
<LightIconButton
Icon={IconFolderPlus}
onClick={toggleNewFolder}
accent="tertiary"
/>
}
/>
</NavigationDrawerAnimatedCollapseWrapper>
{isNavigationSectionOpen && (
<NavigationMenuItemDragProvider>
<NavigationMenuItemFolders
isNavigationSectionOpen={isNavigationSectionOpen}
/>
<CurrentWorkspaceMemberOrphanNavigationMenuItems />
</NavigationMenuItemDragProvider>
)}
</NavigationDrawerSection>
);
};
@@ -0,0 +1,17 @@
import { FeatureFlagKey } from '~/generated/graphql';
import { CurrentWorkspaceMemberFavoritesFolders } from '@/favorites/components/CurrentWorkspaceMemberFavoritesFolders';
import { CurrentWorkspaceMemberNavigationMenuItemFolders } from '@/navigation-menu-item/components/CurrentWorkspaceMemberNavigationMenuItemFolders';
import { useIsFeatureEnabled } from '@/workspace/hooks/useIsFeatureEnabled';
export const CurrentWorkspaceMemberNavigationMenuItemFoldersDispatcher = () => {
const isNavigationMenuItemEnabled = useIsFeatureEnabled(
FeatureFlagKey.IS_NAVIGATION_MENU_ITEM_ENABLED,
);
if (isNavigationMenuItemEnabled) {
return <CurrentWorkspaceMemberNavigationMenuItemFolders />;
}
return <CurrentWorkspaceMemberFavoritesFolders />;
};
@@ -0,0 +1,289 @@
import { Droppable } from '@hello-pangea/dnd';
import { useLingui } from '@lingui/react/macro';
import { useContext, useState } from 'react';
import { createPortal } from 'react-dom';
import { useLocation } from 'react-router-dom';
import { useRecoilState, useRecoilValue, useSetRecoilState } from 'recoil';
import { IconFolder, IconFolderOpen, IconHeartOff } from 'twenty-ui/display';
import { LightIconButton } from 'twenty-ui/input';
import { AnimatedExpandableContainer } from 'twenty-ui/layout';
import { useIsMobile } from 'twenty-ui/utilities';
import { NavigationMenuItemDroppable } from '@/navigation-menu-item/components/NavigationMenuItemDroppable';
import { NavigationMenuItemFolderNavigationDrawerItemDropdown } from '@/navigation-menu-item/components/NavigationMenuItemFolderNavigationDrawerItemDropdown';
import { NavigationMenuItemIcon } from '@/navigation-menu-item/components/NavigationMenuItemIcon';
import { NAVIGATION_MENU_ITEM_FOLDER_DELETE_MODAL_ID } from '@/navigation-menu-item/constants/NavigationMenuItemFolderDeleteModalId';
import { NavigationMenuItemDragContext } from '@/navigation-menu-item/contexts/NavigationMenuItemDragContext';
import { useDeleteNavigationMenuItem } from '@/navigation-menu-item/hooks/useDeleteNavigationMenuItem';
import { useDeleteNavigationMenuItemFolder } from '@/navigation-menu-item/hooks/useDeleteNavigationMenuItemFolder';
import { useRenameNavigationMenuItemFolder } from '@/navigation-menu-item/hooks/useRenameNavigationMenuItemFolder';
import { openNavigationMenuItemFolderIdsState } from '@/navigation-menu-item/states/openNavigationMenuItemFolderIdsState';
import { getNavigationMenuItemSecondaryLabel } from '@/navigation-menu-item/utils/getNavigationMenuItemSecondaryLabel';
import { isLocationMatchingNavigationMenuItem } from '@/navigation-menu-item/utils/isLocationMatchingNavigationMenuItem';
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 { useCloseDropdown } from '@/ui/layout/dropdown/hooks/useCloseDropdown';
import { isDropdownOpenComponentState } from '@/ui/layout/dropdown/states/isDropdownOpenComponentState';
import { ConfirmationModal } from '@/ui/layout/modal/components/ConfirmationModal';
import { useModal } from '@/ui/layout/modal/hooks/useModal';
import { isModalOpenedComponentState } from '@/ui/layout/modal/states/isModalOpenedComponentState';
import { NavigationDrawerInput } from '@/ui/navigation/navigation-drawer/components/NavigationDrawerInput';
import { NavigationDrawerItem } from '@/ui/navigation/navigation-drawer/components/NavigationDrawerItem';
import { NavigationDrawerItemsCollapsableContainer } from '@/ui/navigation/navigation-drawer/components/NavigationDrawerItemsCollapsableContainer';
import { NavigationDrawerSubItem } from '@/ui/navigation/navigation-drawer/components/NavigationDrawerSubItem';
import { currentNavigationMenuItemFolderIdState } from '@/ui/navigation/navigation-drawer/states/currentNavigationMenuItemFolderIdState';
import { getNavigationSubItemLeftAdornment } from '@/ui/navigation/navigation-drawer/utils/getNavigationSubItemLeftAdornment';
import { useRecoilComponentValue } from '@/ui/utilities/state/component-state/hooks/useRecoilComponentValue';
type CurrentWorkspaceMemberNavigationMenuItemsProps = {
folder: {
folderId: string;
folderName: string;
navigationMenuItems: ProcessedNavigationMenuItem[];
};
isGroup: boolean;
};
export const CurrentWorkspaceMemberNavigationMenuItems = ({
folder,
isGroup,
}: CurrentWorkspaceMemberNavigationMenuItemsProps) => {
const { t } = useLingui();
const objectMetadataItems = useRecoilValue(objectMetadataItemsState);
const currentPath = useLocation().pathname;
const currentViewPath = useLocation().pathname + useLocation().search;
const { isDragging } = useContext(NavigationMenuItemDragContext);
const [
isNavigationMenuItemFolderRenaming,
setIsNavigationMenuItemFolderRenaming,
] = useState(false);
const [navigationMenuItemFolderName, setNavigationMenuItemFolderName] =
useState(folder.folderName);
const { openModal } = useModal();
const isMobile = useIsMobile();
const [openNavigationMenuItemFolderIds, setOpenNavigationMenuItemFolderIds] =
useRecoilState(openNavigationMenuItemFolderIdsState);
const setCurrentFolderId = useSetRecoilState(
currentNavigationMenuItemFolderIdState,
);
const isOpen = openNavigationMenuItemFolderIds.includes(folder.folderId);
const handleToggle = () => {
if (isMobile) {
setCurrentFolderId((prev) =>
prev === folder.folderId ? null : folder.folderId,
);
} else {
setOpenNavigationMenuItemFolderIds((currentOpenFolders) => {
if (isOpen) {
return currentOpenFolders.filter((id) => id !== folder.folderId);
} else {
return [...currentOpenFolders, folder.folderId];
}
});
}
};
const { renameNavigationMenuItemFolder } =
useRenameNavigationMenuItemFolder();
const { deleteNavigationMenuItemFolder } =
useDeleteNavigationMenuItemFolder();
const dropdownId = `navigation-menu-item-folder-edit-${folder.folderId}`;
const isDropdownOpenComponent = useRecoilComponentValue(
isDropdownOpenComponentState,
dropdownId,
);
const { closeDropdown } = useCloseDropdown();
const selectedNavigationMenuItemIndex = folder.navigationMenuItems.findIndex(
(item) =>
isLocationMatchingNavigationMenuItem(currentPath, currentViewPath, item),
);
const { deleteNavigationMenuItem } = useDeleteNavigationMenuItem();
const navigationMenuItemFolderContentLength =
folder.navigationMenuItems.length;
const handleSubmitRename = async (value: string) => {
if (value === '') return;
await renameNavigationMenuItemFolder(folder.folderId, value);
setIsNavigationMenuItemFolderRenaming(false);
return true;
};
const handleCancelRename = () => {
setNavigationMenuItemFolderName(folder.folderName);
setIsNavigationMenuItemFolderRenaming(false);
};
const handleClickOutside = async (
_event: MouseEvent | TouchEvent,
value: string,
) => {
if (!value) {
setIsNavigationMenuItemFolderRenaming(false);
return;
}
await renameNavigationMenuItemFolder(folder.folderId, value);
setIsNavigationMenuItemFolderRenaming(false);
};
const modalId = `${NAVIGATION_MENU_ITEM_FOLDER_DELETE_MODAL_ID}-${folder.folderId}`;
const handleNavigationMenuItemFolderDelete = async () => {
if (folder.navigationMenuItems.length > 0) {
openModal(modalId);
closeDropdown(dropdownId);
} else {
await deleteNavigationMenuItemFolder(folder.folderId);
closeDropdown(dropdownId);
}
};
const handleConfirmDelete = async () => {
await deleteNavigationMenuItemFolder(folder.folderId);
};
const rightOptions = (
<NavigationMenuItemFolderNavigationDrawerItemDropdown
folderId={folder.folderId}
onRename={() => setIsNavigationMenuItemFolderRenaming(true)}
onDelete={handleNavigationMenuItemFolderDelete}
closeDropdown={() => {
closeDropdown(dropdownId);
}}
/>
);
const isModalOpened = useRecoilComponentValue(
isModalOpenedComponentState,
modalId,
);
const navigationMenuItemCount = folder.navigationMenuItems.length;
return (
<>
<NavigationDrawerItemsCollapsableContainer
key={folder.folderId}
isGroup={isGroup}
>
{isNavigationMenuItemFolderRenaming ? (
<NavigationDrawerInput
Icon={IconFolder}
value={navigationMenuItemFolderName}
onChange={setNavigationMenuItemFolderName}
onSubmit={handleSubmitRename}
onCancel={handleCancelRename}
onClickOutside={handleClickOutside}
/>
) : (
<NavigationMenuItemDroppable
droppableId={`folder-header-${folder.folderId}`}
>
<NavigationDrawerItem
label={folder.folderName}
Icon={isOpen ? IconFolderOpen : IconFolder}
onClick={handleToggle}
rightOptions={rightOptions}
className="navigation-drawer-item"
isRightOptionsDropdownOpen={isDropdownOpenComponent}
triggerEvent="CLICK"
preventCollapseOnMobile={isMobile}
/>
</NavigationMenuItemDroppable>
)}
<AnimatedExpandableContainer
isExpanded={isOpen}
dimension="height"
mode="fit-content"
containAnimation
>
<Droppable droppableId={`folder-${folder.folderId}`}>
{(provided) => (
<div
ref={provided.innerRef}
// eslint-disable-next-line react/jsx-props-no-spreading
{...provided.droppableProps}
>
{folder.navigationMenuItems.map((navigationMenuItem, index) => (
<DraggableItem
key={navigationMenuItem.id}
draggableId={navigationMenuItem.id}
index={index}
isInsideScrollableContainer
itemComponent={
<NavigationDrawerSubItem
secondaryLabel={getNavigationMenuItemSecondaryLabel({
objectMetadataItems,
navigationMenuItemObjectNameSingular:
navigationMenuItem.objectNameSingular,
})}
label={navigationMenuItem.labelIdentifier}
Icon={() => (
<NavigationMenuItemIcon
navigationMenuItem={navigationMenuItem}
/>
)}
to={isDragging ? undefined : navigationMenuItem.link}
active={index === selectedNavigationMenuItemIndex}
subItemState={getNavigationSubItemLeftAdornment({
index,
arrayLength: navigationMenuItemFolderContentLength,
selectedIndex: selectedNavigationMenuItemIndex,
})}
rightOptions={
<LightIconButton
Icon={IconHeartOff}
onClick={() =>
deleteNavigationMenuItem(navigationMenuItem.id)
}
accent="tertiary"
/>
}
isDragging={isDragging}
triggerEvent="CLICK"
/>
}
/>
))}
{provided.placeholder}
</div>
)}
</Droppable>
</AnimatedExpandableContainer>
</NavigationDrawerItemsCollapsableContainer>
{isModalOpened &&
createPortal(
<ConfirmationModal
modalId={modalId}
title={
folder.navigationMenuItems.length > 1
? t`Remove ${navigationMenuItemCount} navigation menu items?`
: t`Remove ${navigationMenuItemCount} navigation menu item?`
}
subtitle={
folder.navigationMenuItems.length > 1
? t`This action will delete this folder and all ${navigationMenuItemCount} navigation menu items inside. Do you want to continue?`
: t`This action will delete this folder and the navigation menu item inside. Do you want to continue?`
}
onConfirmClick={handleConfirmDelete}
confirmButtonText={t`Delete Folder`}
/>,
document.body,
)}
</>
);
};
@@ -0,0 +1,92 @@
import styled from '@emotion/styled';
import { useContext } from 'react';
import { useLocation } from 'react-router-dom';
import { useRecoilValue } from 'recoil';
import { IconHeartOff } from 'twenty-ui/display';
import { LightIconButton } from 'twenty-ui/input';
import { NavigationMenuItemDroppable } from '@/navigation-menu-item/components/NavigationMenuItemDroppable';
import { NavigationMenuItemIcon } from '@/navigation-menu-item/components/NavigationMenuItemIcon';
import { ORPHAN_NAVIGATION_MENU_ITEMS_DROPPABLE_ID } from '@/navigation-menu-item/constants/NavigationMenuItemDroppableIds';
import { NavigationMenuItemDragContext } from '@/navigation-menu-item/contexts/NavigationMenuItemDragContext';
import { useDeleteNavigationMenuItem } from '@/navigation-menu-item/hooks/useDeleteNavigationMenuItem';
import { useSortedNavigationMenuItems } from '@/navigation-menu-item/hooks/useSortedNavigationMenuItems';
import { getNavigationMenuItemSecondaryLabel } from '@/navigation-menu-item/utils/getNavigationMenuItemSecondaryLabel';
import { isLocationMatchingNavigationMenuItem } from '@/navigation-menu-item/utils/isLocationMatchingNavigationMenuItem';
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';
const StyledEmptyContainer = styled.div`
width: 100%;
`;
const StyledOrphanNavigationMenuItemsContainer = styled.div`
margin-bottom: ${({ theme }) => theme.betweenSiblingsGap};
`;
export const CurrentWorkspaceMemberOrphanNavigationMenuItems = () => {
const objectMetadataItems = useRecoilValue(objectMetadataItemsState);
const { navigationMenuItemsSorted } = useSortedNavigationMenuItems();
const { deleteNavigationMenuItem } = useDeleteNavigationMenuItem();
const currentPath = useLocation().pathname;
const currentViewPath = useLocation().pathname + useLocation().search;
const { isDragging } = useContext(NavigationMenuItemDragContext);
const orphanNavigationMenuItems = navigationMenuItemsSorted.filter(
(item) => !item.folderId,
);
return (
<NavigationMenuItemDroppable
droppableId={ORPHAN_NAVIGATION_MENU_ITEMS_DROPPABLE_ID}
>
{orphanNavigationMenuItems.length > 0 ? (
orphanNavigationMenuItems.map((navigationMenuItem, index) => (
<DraggableItem
key={navigationMenuItem.id}
draggableId={navigationMenuItem.id}
index={index}
isInsideScrollableContainer={true}
itemComponent={
<StyledOrphanNavigationMenuItemsContainer>
<NavigationDrawerItem
secondaryLabel={getNavigationMenuItemSecondaryLabel({
objectMetadataItems,
navigationMenuItemObjectNameSingular:
navigationMenuItem.objectNameSingular,
})}
label={navigationMenuItem.labelIdentifier}
Icon={() => (
<NavigationMenuItemIcon
navigationMenuItem={navigationMenuItem}
/>
)}
active={isLocationMatchingNavigationMenuItem(
currentPath,
currentViewPath,
navigationMenuItem,
)}
to={isDragging ? undefined : navigationMenuItem.link}
rightOptions={
<LightIconButton
Icon={IconHeartOff}
onClick={() =>
deleteNavigationMenuItem(navigationMenuItem.id)
}
accent="tertiary"
/>
}
isDragging={isDragging}
triggerEvent="CLICK"
/>
</StyledOrphanNavigationMenuItemsContainer>
}
/>
))
) : (
<StyledEmptyContainer style={{ height: isDragging ? '24px' : '1px' }} />
)}
</NavigationMenuItemDroppable>
);
};
@@ -0,0 +1,59 @@
import { useTheme } from '@emotion/react';
import styled from '@emotion/styled';
import { useSetRecoilState } from 'recoil';
import { IconX } from 'twenty-ui/display';
import { currentNavigationMenuItemFolderIdState } from '@/ui/navigation/navigation-drawer/states/currentNavigationMenuItemFolderIdState';
const StyledBackButton = styled.button`
display: flex;
align-items: center;
gap: ${({ theme }) => theme.spacing(2)};
padding-left: ${({ theme }) => theme.spacing(1)};
padding-right: ${({ theme }) => theme.spacing(0.5)};
padding-top: ${({ theme }) => theme.spacing(1)};
padding-bottom: ${({ theme }) => theme.spacing(1)};
width: 100%;
background: transparent;
border: none;
color: ${({ theme }) => theme.font.color.secondary};
font-weight: ${({ theme }) => theme.font.weight.medium};
font-family: ${({ theme }) => theme.font.family};
cursor: pointer;
text-align: left;
&:hover {
background: ${({ theme }) => theme.background.transparent.light};
border-radius: ${({ theme }) => theme.border.radius.sm};
}
`;
type NavigationMenuItemBackButtonProps = {
folderName: string;
};
export const NavigationMenuItemBackButton = ({
folderName,
}: NavigationMenuItemBackButtonProps) => {
const theme = useTheme();
const setCurrentFolderId = useSetRecoilState(
currentNavigationMenuItemFolderIdState,
);
const handleClick = (e: React.MouseEvent) => {
e.preventDefault();
e.stopPropagation();
setCurrentFolderId(null);
};
return (
<StyledBackButton onClick={handleClick}>
<IconX
size={theme.icon.size.md}
stroke={theme.icon.stroke.lg}
color={theme.font.color.tertiary}
/>
<span>{folderName}</span>
</StyledBackButton>
);
};
@@ -0,0 +1,39 @@
import {
DragDropContext,
type DragStart,
type DropResult,
type ResponderProvided,
} from '@hello-pangea/dnd';
import { type ReactNode, useState } from 'react';
import { NavigationMenuItemDragContext } from '@/navigation-menu-item/contexts/NavigationMenuItemDragContext';
import { useHandleNavigationMenuItemDragAndDrop } from '@/navigation-menu-item/hooks/useHandleNavigationMenuItemDragAndDrop';
type NavigationMenuItemDragProviderProps = {
children: ReactNode;
};
export const NavigationMenuItemDragProvider = ({
children,
}: NavigationMenuItemDragProviderProps) => {
const [isDragging, setIsDragging] = useState(false);
const { handleNavigationMenuItemDragAndDrop } =
useHandleNavigationMenuItemDragAndDrop();
const handleDragStart = (_: DragStart) => {
setIsDragging(true);
};
const handleDragEnd = (result: DropResult, provided: ResponderProvided) => {
setIsDragging(false);
handleNavigationMenuItemDragAndDrop(result, provided);
};
return (
<NavigationMenuItemDragContext.Provider value={{ isDragging }}>
<DragDropContext onDragEnd={handleDragEnd} onDragStart={handleDragStart}>
{children}
</DragDropContext>
</NavigationMenuItemDragContext.Provider>
);
};
@@ -0,0 +1,70 @@
import styled from '@emotion/styled';
import { Droppable } from '@hello-pangea/dnd';
type NavigationMenuItemDroppableProps = {
droppableId: string;
children: React.ReactNode;
isDragIndicatorVisible?: boolean;
showDropLine?: boolean;
};
const StyledDroppableWrapper = styled.div<{
isDraggingOver: boolean;
isDragIndicatorVisible: boolean;
showDropLine: boolean;
}>`
position: relative;
transition: all 150ms ease-in-out;
width: 100%;
${({ isDraggingOver, isDragIndicatorVisible, showDropLine, theme }) =>
isDraggingOver &&
isDragIndicatorVisible &&
`
background-color: ${theme.background.transparent.blue};
${
showDropLine &&
`
&::before {
content: '';
position: absolute;
bottom: 0;
left: 0;
width: 100%;
height: 2px;
background-color: ${theme.color.blue};
border-radius: ${theme.border.radius.sm} ${theme.border.radius.sm} 0 0;
}
`
}
`}
`;
export const NavigationMenuItemDroppable = ({
droppableId,
children,
isDragIndicatorVisible = true,
showDropLine = true,
}: NavigationMenuItemDroppableProps) => {
return (
<Droppable droppableId={droppableId}>
{(provided, snapshot) => (
<StyledDroppableWrapper
isDraggingOver={snapshot.isDraggingOver}
isDragIndicatorVisible={isDragIndicatorVisible}
showDropLine={showDropLine}
>
<div
ref={provided.innerRef}
// eslint-disable-next-line react/jsx-props-no-spreading
{...provided.droppableProps}
>
{children}
{provided.placeholder}
</div>
</StyledDroppableWrapper>
)}
</Droppable>
);
};
@@ -0,0 +1,82 @@
import { Droppable } from '@hello-pangea/dnd';
import { useRecoilValue } from 'recoil';
import { IconHeartOff } from 'twenty-ui/display';
import { LightIconButton } from 'twenty-ui/input';
import { NavigationMenuItemBackButton } from '@/navigation-menu-item/components/NavigationMenuItemBackButton';
import { NavigationMenuItemDragProvider } from '@/navigation-menu-item/components/NavigationMenuItemDragProvider';
import { NavigationMenuItemIcon } from '@/navigation-menu-item/components/NavigationMenuItemIcon';
import { useDeleteNavigationMenuItem } from '@/navigation-menu-item/hooks/useDeleteNavigationMenuItem';
import { getNavigationMenuItemSecondaryLabel } from '@/navigation-menu-item/utils/getNavigationMenuItemSecondaryLabel';
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';
type NavigationMenuItemFolderContentProps = {
folderId: string;
folderName: string;
navigationMenuItems: ProcessedNavigationMenuItem[];
};
export const NavigationMenuItemFolderContent = ({
folderName,
folderId,
navigationMenuItems,
}: NavigationMenuItemFolderContentProps) => {
const objectMetadataItems = useRecoilValue(objectMetadataItemsState);
const { deleteNavigationMenuItem } = useDeleteNavigationMenuItem();
return (
<>
<NavigationMenuItemBackButton folderName={folderName} />
<NavigationMenuItemDragProvider>
<Droppable droppableId={`folder-${folderId}`}>
{(provided) => (
<div
ref={provided.innerRef}
// eslint-disable-next-line react/jsx-props-no-spreading
{...provided.droppableProps}
>
{navigationMenuItems.map((navigationMenuItem, index) => (
<DraggableItem
key={navigationMenuItem.id}
draggableId={navigationMenuItem.id}
index={index}
isInsideScrollableContainer
itemComponent={
<NavigationDrawerItem
secondaryLabel={getNavigationMenuItemSecondaryLabel({
objectMetadataItems,
navigationMenuItemObjectNameSingular:
navigationMenuItem.objectNameSingular,
})}
label={navigationMenuItem.labelIdentifier}
Icon={() => (
<NavigationMenuItemIcon
navigationMenuItem={navigationMenuItem}
/>
)}
rightOptions={
<LightIconButton
Icon={IconHeartOff}
onClick={() =>
deleteNavigationMenuItem(navigationMenuItem.id)
}
accent="tertiary"
/>
}
triggerEvent="CLICK"
to={navigationMenuItem.link}
/>
}
/>
))}
{provided.placeholder}
</div>
)}
</Droppable>
</NavigationMenuItemDragProvider>
</>
);
};
@@ -0,0 +1,48 @@
import { isDefined } from 'twenty-shared/utils';
import { FeatureFlagKey } from '~/generated/graphql';
import { FavoritesFolderContent } from '@/favorites/components/FavoritesFolderContent';
import { type ProcessedFavorite } from '@/favorites/utils/sortFavorites';
import { NavigationMenuItemFolderContent } from '@/navigation-menu-item/components/NavigationMenuItemFolderContent';
import { type ProcessedNavigationMenuItem } from '@/navigation-menu-item/utils/sortNavigationMenuItems';
import { useIsFeatureEnabled } from '@/workspace/hooks/useIsFeatureEnabled';
type NavigationMenuItemFolderContentDispatcherEffectProps = {
folderName: string;
folderId: string;
favorites?: ProcessedFavorite[];
navigationMenuItems?: ProcessedNavigationMenuItem[];
};
export const NavigationMenuItemFolderContentDispatcherEffect = ({
folderName,
folderId,
favorites,
navigationMenuItems,
}: NavigationMenuItemFolderContentDispatcherEffectProps) => {
const isNavigationMenuItemEnabled = useIsFeatureEnabled(
FeatureFlagKey.IS_NAVIGATION_MENU_ITEM_ENABLED,
);
if (isNavigationMenuItemEnabled && isDefined(navigationMenuItems)) {
return (
<NavigationMenuItemFolderContent
folderId={folderId}
folderName={folderName}
navigationMenuItems={navigationMenuItems}
/>
);
}
if (isDefined(favorites)) {
return (
<FavoritesFolderContent
folderName={folderName}
folderId={folderId}
favorites={favorites}
/>
);
}
return null;
};
@@ -0,0 +1,63 @@
import { useLingui } from '@lingui/react/macro';
import { IconDotsVertical, IconPencil, IconTrash } from 'twenty-ui/display';
import { LightIconButton } from 'twenty-ui/input';
import { MenuItem } from 'twenty-ui/navigation';
import { Dropdown } from '@/ui/layout/dropdown/components/Dropdown';
import { DropdownContent } from '@/ui/layout/dropdown/components/DropdownContent';
import { DropdownMenuItemsContainer } from '@/ui/layout/dropdown/components/DropdownMenuItemsContainer';
import { GenericDropdownContentWidth } from '@/ui/layout/dropdown/constants/GenericDropdownContentWidth';
type NavigationMenuItemFolderNavigationDrawerItemDropdownProps = {
folderId: string;
onRename: () => void;
onDelete: () => void;
closeDropdown: () => void;
};
export const NavigationMenuItemFolderNavigationDrawerItemDropdown = ({
folderId,
onRename,
onDelete,
closeDropdown,
}: NavigationMenuItemFolderNavigationDrawerItemDropdownProps) => {
const { t } = useLingui();
const handleRename = () => {
closeDropdown();
onRename();
};
const handleDelete = () => {
closeDropdown();
onDelete();
};
return (
<Dropdown
dropdownId={`navigation-menu-item-folder-edit-${folderId}`}
data-select-disable
clickableComponent={
<LightIconButton Icon={IconDotsVertical} accent="tertiary" />
}
dropdownPlacement="bottom-start"
dropdownComponents={
<DropdownContent widthInPixels={GenericDropdownContentWidth.Narrow}>
<DropdownMenuItemsContainer>
<MenuItem
LeftIcon={IconPencil}
onClick={handleRename}
accent="default"
text={t`Rename`}
/>
<MenuItem
LeftIcon={IconTrash}
onClick={handleDelete}
accent="danger"
text={t`Delete`}
/>
</DropdownMenuItemsContainer>
</DropdownContent>
}
/>
);
};
@@ -0,0 +1,91 @@
import { useState } from 'react';
import { useRecoilState } from 'recoil';
import { IconFolder } from 'twenty-ui/display';
import { CurrentWorkspaceMemberNavigationMenuItems } from '@/navigation-menu-item/components/CurrentWorkspaceMemberNavigationMenuItems';
import { useCreateNavigationMenuItemFolder } from '@/navigation-menu-item/hooks/useCreateNavigationMenuItemFolder';
import { useNavigationMenuItemsByFolder } from '@/navigation-menu-item/hooks/useNavigationMenuItemsByFolder';
import { isNavigationMenuItemFolderCreatingState } from '@/navigation-menu-item/states/isNavigationMenuItemFolderCreatingState';
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) => {
const [newFolderName, setNewFolderName] = useState('');
const { navigationMenuItemsByFolder } = useNavigationMenuItemsByFolder();
const { createNewNavigationMenuItemFolder } =
useCreateNavigationMenuItemFolder();
const [
isNavigationMenuItemFolderCreating,
setIsNavigationMenuItemFolderCreating,
] = useRecoilState(isNavigationMenuItemFolderCreatingState);
const handleNavigationMenuItemFolderNameChange = (value: string) => {
setNewFolderName(value);
};
const handleSubmitNavigationMenuItemFolderCreation = async (
value: string,
) => {
if (value === '') return;
setIsNavigationMenuItemFolderCreating(false);
setNewFolderName('');
await createNewNavigationMenuItemFolder(value);
return true;
};
const handleClickOutside = async (
_event: MouseEvent | TouchEvent,
value: string,
) => {
if (!value) {
setIsNavigationMenuItemFolderCreating(false);
return;
}
setIsNavigationMenuItemFolderCreating(false);
setNewFolderName('');
await createNewNavigationMenuItemFolder(value);
};
const handleCancelNavigationMenuItemFolderCreation = () => {
setNewFolderName('');
setIsNavigationMenuItemFolderCreating(false);
};
if (!isNavigationSectionOpen) {
return null;
}
return (
<>
{isNavigationMenuItemFolderCreating && (
<NavigationDrawerAnimatedCollapseWrapper>
<NavigationDrawerInput
Icon={IconFolder}
value={newFolderName}
onChange={handleNavigationMenuItemFolderNameChange}
onSubmit={handleSubmitNavigationMenuItemFolderCreation}
onCancel={handleCancelNavigationMenuItemFolderCreation}
onClickOutside={handleClickOutside}
/>
</NavigationDrawerAnimatedCollapseWrapper>
)}
{navigationMenuItemsByFolder.map((folder) => (
<CurrentWorkspaceMemberNavigationMenuItems
key={folder.folderId}
folder={folder}
isGroup={navigationMenuItemsByFolder.length > 1}
/>
))}
</>
);
};
@@ -0,0 +1,35 @@
import { useTheme } from '@emotion/react';
import { Avatar, useIcons } from 'twenty-ui/display';
import { type ProcessedNavigationMenuItem } from '@/navigation-menu-item/utils/sortNavigationMenuItems';
import { useGetStandardObjectIcon } from '@/object-metadata/hooks/useGetStandardObjectIcon';
export const NavigationMenuItemIcon = ({
navigationMenuItem,
}: {
navigationMenuItem: ProcessedNavigationMenuItem;
}) => {
const theme = useTheme();
const { getIcon } = useIcons();
const { Icon: StandardIcon, IconColor } = useGetStandardObjectIcon(
navigationMenuItem.objectNameSingular || '',
);
const IconToUse =
StandardIcon ||
(navigationMenuItem.Icon ? getIcon(navigationMenuItem.Icon) : undefined);
const iconColorToUse = StandardIcon ? IconColor : theme.font.color.secondary;
const placeholderColorSeed = navigationMenuItem.targetRecordId ?? undefined;
return (
<Avatar
size="md"
type={navigationMenuItem.avatarType}
Icon={IconToUse}
iconColor={iconColorToUse}
avatarUrl={navigationMenuItem.avatarUrl}
placeholder={navigationMenuItem.labelIdentifier}
placeholderColorSeed={placeholderColorSeed}
/>
);
};
@@ -0,0 +1,46 @@
import { SKELETON_LOADER_HEIGHT_SIZES } from '@/activities/components/SkeletonLoader';
import { useTheme } from '@emotion/react';
import styled from '@emotion/styled';
import Skeleton, { SkeletonTheme } from 'react-loading-skeleton';
const StyledSkeletonContainer = styled.div`
display: flex;
flex-direction: column;
gap: ${({ theme }) => theme.spacing(2)};
height: 71px;
padding-left: ${({ theme }) => theme.spacing(1)};
`;
const StyledSkeletonColumn = styled.div`
display: flex;
flex-direction: column;
gap: ${({ theme }) => theme.spacing(1)};
`;
export const NavigationMenuItemSkeletonLoader = () => {
const theme = useTheme();
return (
<SkeletonTheme
baseColor={theme.background.tertiary}
highlightColor={theme.background.transparent.lighter}
borderRadius={4}
>
<StyledSkeletonContainer>
<Skeleton
width={56}
height={SKELETON_LOADER_HEIGHT_SIZES.standard.xs}
/>
<StyledSkeletonColumn>
<Skeleton
width={196}
height={SKELETON_LOADER_HEIGHT_SIZES.standard.s}
/>
<Skeleton
width={196}
height={SKELETON_LOADER_HEIGHT_SIZES.standard.s}
/>
</StyledSkeletonColumn>
</StyledSkeletonContainer>
</SkeletonTheme>
);
};
@@ -0,0 +1,26 @@
import { useLingui } from '@lingui/react/macro';
import { useWorkspaceNavigationMenuItems } from '@/navigation-menu-item/hooks/useWorkspaceNavigationMenuItems';
import { NavigationDrawerSectionForObjectMetadataItems } from '@/object-metadata/components/NavigationDrawerSectionForObjectMetadataItems';
import { NavigationDrawerSectionForObjectMetadataItemsSkeletonLoader } from '@/object-metadata/components/NavigationDrawerSectionForObjectMetadataItemsSkeletonLoader';
import { useIsPrefetchLoading } from '@/prefetch/hooks/useIsPrefetchLoading';
export const WorkspaceNavigationMenuItems = () => {
const { workspaceNavigationMenuItemsObjectMetadataItems } =
useWorkspaceNavigationMenuItems();
const loading = useIsPrefetchLoading();
const { t } = useLingui();
if (loading) {
return <NavigationDrawerSectionForObjectMetadataItemsSkeletonLoader />;
}
return (
<NavigationDrawerSectionForObjectMetadataItems
sectionTitle={t`Workspace`}
objectMetadataItems={workspaceNavigationMenuItemsObjectMetadataItems}
isRemote={false}
/>
);
};
@@ -0,0 +1,17 @@
import { FeatureFlagKey } from '~/generated/graphql';
import { WorkspaceFavorites } from '@/favorites/components/WorkspaceFavorites';
import { WorkspaceNavigationMenuItems } from '@/navigation-menu-item/components/WorkspaceNavigationMenuItems';
import { useIsFeatureEnabled } from '@/workspace/hooks/useIsFeatureEnabled';
export const WorkspaceNavigationMenuItemsDispatcher = () => {
const isNavigationMenuItemEnabled = useIsFeatureEnabled(
FeatureFlagKey.IS_NAVIGATION_MENU_ITEM_ENABLED,
);
if (isNavigationMenuItemEnabled) {
return <WorkspaceNavigationMenuItems />;
}
return <WorkspaceFavorites />;
};
@@ -0,0 +1,2 @@
export const ORPHAN_NAVIGATION_MENU_ITEMS_DROPPABLE_ID =
'orphan-navigation-menu-items';
@@ -0,0 +1,2 @@
export const NAVIGATION_MENU_ITEM_FOLDER_DELETE_MODAL_ID =
'navigation-menu-item-folder-delete-modal';
@@ -0,0 +1,10 @@
import { createContext } from 'react';
type NavigationMenuItemDragContextType = {
isDragging: boolean;
};
export const NavigationMenuItemDragContext =
createContext<NavigationMenuItemDragContextType>({
isDragging: false,
});
@@ -0,0 +1,17 @@
import { gql } from '@apollo/client';
export const NAVIGATION_MENU_ITEM_FRAGMENT = gql`
fragment NavigationMenuItemFields on NavigationMenuItem {
id
userWorkspaceId
targetRecordId
targetObjectMetadataId
viewId
folderId
name
position
applicationId
createdAt
updatedAt
}
`;
@@ -0,0 +1,15 @@
import { gql } from '@apollo/client';
import { NAVIGATION_MENU_ITEM_FRAGMENT } from './navigationMenuItemFragment';
export const NAVIGATION_MENU_ITEM_QUERY_FRAGMENT = gql`
${NAVIGATION_MENU_ITEM_FRAGMENT}
fragment NavigationMenuItemQueryFields on NavigationMenuItem {
...NavigationMenuItemFields
targetRecordIdentifier {
id
labelIdentifier
imageIdentifier
}
}
`;
@@ -0,0 +1,11 @@
import { gql } from '@apollo/client';
import { NAVIGATION_MENU_ITEM_FRAGMENT } from '@/navigation-menu-item/graphql/fragments/navigationMenuItemFragment';
export const CREATE_NAVIGATION_MENU_ITEM = gql`
${NAVIGATION_MENU_ITEM_FRAGMENT}
mutation CreateNavigationMenuItem($input: CreateNavigationMenuItemInput!) {
createNavigationMenuItem(input: $input) {
...NavigationMenuItemFields
}
}
`;
@@ -0,0 +1,11 @@
import { gql } from '@apollo/client';
import { NAVIGATION_MENU_ITEM_FRAGMENT } from '@/navigation-menu-item/graphql/fragments/navigationMenuItemFragment';
export const DELETE_NAVIGATION_MENU_ITEM = gql`
${NAVIGATION_MENU_ITEM_FRAGMENT}
mutation DeleteNavigationMenuItem($id: UUID!) {
deleteNavigationMenuItem(id: $id) {
...NavigationMenuItemFields
}
}
`;
@@ -0,0 +1,11 @@
import { gql } from '@apollo/client';
import { NAVIGATION_MENU_ITEM_FRAGMENT } from '@/navigation-menu-item/graphql/fragments/navigationMenuItemFragment';
export const UPDATE_NAVIGATION_MENU_ITEM = gql`
${NAVIGATION_MENU_ITEM_FRAGMENT}
mutation UpdateNavigationMenuItem($input: UpdateOneNavigationMenuItemInput!) {
updateNavigationMenuItem(input: $input) {
...NavigationMenuItemFields
}
}
`;
@@ -0,0 +1,11 @@
import { NAVIGATION_MENU_ITEM_QUERY_FRAGMENT } from '@/navigation-menu-item/graphql/fragments/navigationMenuItemQueryFragment';
import { gql } from '@apollo/client';
export const FIND_MANY_NAVIGATION_MENU_ITEMS = gql`
${NAVIGATION_MENU_ITEM_QUERY_FRAGMENT}
query FindManyNavigationMenuItems {
navigationMenuItems {
...NavigationMenuItemQueryFields
}
}
`;
@@ -0,0 +1,11 @@
import { NAVIGATION_MENU_ITEM_QUERY_FRAGMENT } from '@/navigation-menu-item/graphql/fragments/navigationMenuItemQueryFragment';
import { gql } from '@apollo/client';
export const FIND_ONE_NAVIGATION_MENU_ITEM = gql`
${NAVIGATION_MENU_ITEM_QUERY_FRAGMENT}
query FindOneNavigationMenuItem($id: UUID!) {
navigationMenuItem(id: $id) {
...NavigationMenuItemQueryFields
}
}
`;
@@ -0,0 +1,85 @@
import { useRecoilValue } from 'recoil';
import { isDefined } from 'twenty-shared/utils';
import { useCreateNavigationMenuItemMutation } from '~/generated-metadata/graphql';
import { usePrefetchedNavigationMenuItemsData } from '@/navigation-menu-item/hooks/usePrefetchedNavigationMenuItemsData';
import { objectMetadataItemsState } from '@/object-metadata/states/objectMetadataItemsState';
import { type ObjectRecord } from '@/object-record/types/ObjectRecord';
export const useCreateNavigationMenuItem = () => {
const { navigationMenuItems, currentWorkspaceMemberId } =
usePrefetchedNavigationMenuItemsData();
const objectMetadataItems = useRecoilValue(objectMetadataItemsState);
const [createNavigationMenuItemMutation] =
useCreateNavigationMenuItemMutation({
refetchQueries: ['FindManyNavigationMenuItems'],
});
const createNavigationMenuItem = async (
targetRecord: ObjectRecord,
targetObjectNameSingular: string,
folderId?: string,
) => {
const isView = targetObjectNameSingular === 'view';
if (isView) {
const relevantItems = folderId
? navigationMenuItems.filter((item) => item.folderId === folderId)
: navigationMenuItems.filter(
(item) => !item.folderId && item.userWorkspaceId,
);
const maxPosition = Math.max(
...relevantItems.map((item) => item.position),
0,
);
await createNavigationMenuItemMutation({
variables: {
input: {
viewId: targetRecord.id,
userWorkspaceId: currentWorkspaceMemberId,
folderId,
position: maxPosition + 1,
},
},
});
} else {
const objectMetadataItem = objectMetadataItems.find(
(item) => item.nameSingular === targetObjectNameSingular,
);
if (!isDefined(objectMetadataItem)) {
throw new Error(
`Object metadata item not found for nameSingular: ${targetObjectNameSingular}`,
);
}
const relevantItems = folderId
? navigationMenuItems.filter((item) => item.folderId === folderId)
: navigationMenuItems.filter(
(item) => !item.folderId && item.userWorkspaceId,
);
const maxPosition = Math.max(
...relevantItems.map((item) => item.position),
0,
);
await createNavigationMenuItemMutation({
variables: {
input: {
targetRecordId: targetRecord.id,
targetObjectMetadataId: objectMetadataItem.id,
userWorkspaceId: currentWorkspaceMemberId,
folderId,
position: maxPosition + 1,
},
},
});
}
};
return { createNavigationMenuItem };
};
@@ -0,0 +1,52 @@
import { isDefined } from 'twenty-shared/utils';
import { useCreateNavigationMenuItemMutation } from '~/generated-metadata/graphql';
import { usePrefetchedNavigationMenuItemsData } from '@/navigation-menu-item/hooks/usePrefetchedNavigationMenuItemsData';
export const useCreateNavigationMenuItemFolder = () => {
const { navigationMenuItems, currentWorkspaceMemberId } =
usePrefetchedNavigationMenuItemsData();
const [createNavigationMenuItemMutation] =
useCreateNavigationMenuItemMutation({
refetchQueries: ['FindManyNavigationMenuItems'],
});
const createNewNavigationMenuItemFolder = async (
name: string,
): Promise<void> => {
if (!name || !currentWorkspaceMemberId) {
return;
}
const folderNavigationMenuItems = navigationMenuItems.filter(
(item) =>
isDefined(item.name) &&
!item.folderId &&
!item.targetRecordId &&
!item.targetObjectMetadataId &&
!item.viewId &&
item.userWorkspaceId === currentWorkspaceMemberId,
);
const maxPosition = Math.max(
...folderNavigationMenuItems.map((item) => item.position),
0,
);
await createNavigationMenuItemMutation({
variables: {
input: {
name,
targetRecordId: null,
targetObjectMetadataId: null,
userWorkspaceId: currentWorkspaceMemberId,
folderId: null,
position: maxPosition + 1,
},
},
});
};
return { createNewNavigationMenuItemFolder };
};
@@ -0,0 +1,16 @@
import { useDeleteNavigationMenuItemMutation } from '~/generated-metadata/graphql';
export const useDeleteNavigationMenuItem = () => {
const [deleteNavigationMenuItemMutation] =
useDeleteNavigationMenuItemMutation({
refetchQueries: ['FindManyNavigationMenuItems'],
});
const deleteNavigationMenuItem = async (id: string) => {
await deleteNavigationMenuItemMutation({
variables: { id },
});
};
return { deleteNavigationMenuItem };
};
@@ -0,0 +1,15 @@
import { useDeleteNavigationMenuItem } from '@/navigation-menu-item/hooks/useDeleteNavigationMenuItem';
export const useDeleteNavigationMenuItemFolder = () => {
const { deleteNavigationMenuItem } = useDeleteNavigationMenuItem();
const deleteNavigationMenuItemFolder = async (
folderId: string,
): Promise<void> => {
await deleteNavigationMenuItem(folderId);
};
return {
deleteNavigationMenuItemFolder,
};
};
@@ -0,0 +1,143 @@
import { type OnDragEndResponder } from '@hello-pangea/dnd';
import { useSetRecoilState } from 'recoil';
import { ORPHAN_NAVIGATION_MENU_ITEMS_DROPPABLE_ID } from '@/navigation-menu-item/constants/NavigationMenuItemDroppableIds';
import { useSortedNavigationMenuItems } from '@/navigation-menu-item/hooks/useSortedNavigationMenuItems';
import { useUpdateNavigationMenuItem } from '@/navigation-menu-item/hooks/useUpdateNavigationMenuItem';
import { openNavigationMenuItemFolderIdsState } from '@/navigation-menu-item/states/openNavigationMenuItemFolderIdsState';
import { calculateNewPosition } from '@/ui/layout/draggable-list/utils/calculateNewPosition';
import { FOLDER_DROPPABLE_IDS } from '@/ui/layout/draggable-list/utils/folderDroppableIds';
import { validateAndExtractFolderId } from '@/ui/layout/draggable-list/utils/validateAndExtractFolderId';
import { usePrefetchedNavigationMenuItemsData } from './usePrefetchedNavigationMenuItemsData';
export const useHandleNavigationMenuItemDragAndDrop = () => {
const { navigationMenuItems } = usePrefetchedNavigationMenuItemsData();
const { navigationMenuItemsSorted } = useSortedNavigationMenuItems();
const { updateNavigationMenuItem } = useUpdateNavigationMenuItem();
const setOpenNavigationMenuItemFolderIds = useSetRecoilState(
openNavigationMenuItemFolderIdsState,
);
const openDestinationFolder = (folderId: string | null) => {
if (!folderId) {
return;
}
setOpenNavigationMenuItemFolderIds((current) => {
if (!current.includes(folderId)) {
return [...current, folderId];
}
return current;
});
};
const handleNavigationMenuItemDragAndDrop: OnDragEndResponder = async (
result,
) => {
const { destination, source, draggableId } = result;
if (!destination) {
return;
}
if (
destination.droppableId === source.droppableId &&
destination.index === source.index
) {
return;
}
const draggedNavigationMenuItem = navigationMenuItems.find(
(item) => item.id === draggableId,
);
if (!draggedNavigationMenuItem) {
return;
}
const destinationFolderId = validateAndExtractFolderId({
droppableId: destination.droppableId,
orphanDroppableId: ORPHAN_NAVIGATION_MENU_ITEMS_DROPPABLE_ID,
});
const sourceFolderId = validateAndExtractFolderId({
droppableId: source.droppableId,
orphanDroppableId: ORPHAN_NAVIGATION_MENU_ITEMS_DROPPABLE_ID,
});
if (
destination.droppableId.startsWith(
FOLDER_DROPPABLE_IDS.FOLDER_HEADER_PREFIX,
)
) {
if (destinationFolderId === null)
throw new Error('Invalid folder header ID');
const folderNavigationMenuItems = navigationMenuItemsSorted.filter(
(item) => item.folderId === destinationFolderId,
);
const newPosition =
folderNavigationMenuItems.length === 0
? 1
: folderNavigationMenuItems[folderNavigationMenuItems.length - 1]
.position + 1;
await updateNavigationMenuItem({
id: draggableId,
folderId: destinationFolderId,
position: newPosition,
});
openDestinationFolder(destinationFolderId);
return;
}
if (destination.droppableId !== source.droppableId) {
const destinationNavigationMenuItems = navigationMenuItemsSorted.filter(
(item) => item.folderId === destinationFolderId,
);
let newPosition;
if (destinationNavigationMenuItems.length === 0) {
newPosition = 1;
} else if (destination.index === 0) {
newPosition = destinationNavigationMenuItems[0].position - 1;
} else if (destination.index >= destinationNavigationMenuItems.length) {
newPosition =
destinationNavigationMenuItems[
destinationNavigationMenuItems.length - 1
].position + 1;
} else {
newPosition = calculateNewPosition({
destinationIndex: destination.index,
sourceIndex: -1,
items: destinationNavigationMenuItems,
});
}
await updateNavigationMenuItem({
id: draggableId,
folderId: destinationFolderId ?? null,
position: newPosition,
});
return;
}
const navigationMenuItemsInSameList = navigationMenuItemsSorted
.filter((item) => item.folderId === sourceFolderId)
.filter((item) => item.id !== draggableId);
const newPosition = calculateNewPosition({
destinationIndex: destination.index,
sourceIndex: source.index,
items: navigationMenuItemsInSameList,
});
await updateNavigationMenuItem({
id: draggableId,
position: newPosition,
});
};
return { handleNavigationMenuItemDragAndDrop };
};
@@ -0,0 +1,111 @@
import { useRecoilValue } from 'recoil';
import { isDefined } from 'twenty-shared/utils';
import { type NavigationMenuItem } from '~/generated-metadata/graphql';
import { recordIdentifierToObjectRecordIdentifier } from '@/navigation-menu-item/utils/recordIdentifierToObjectRecordIdentifier';
import { sortNavigationMenuItems } from '@/navigation-menu-item/utils/sortNavigationMenuItems';
import { objectMetadataItemsState } from '@/object-metadata/states/objectMetadataItemsState';
import { type ObjectRecordIdentifier } from '@/object-record/types/ObjectRecordIdentifier';
import { coreViewsState } from '@/views/states/coreViewState';
import { convertCoreViewToView } from '@/views/utils/convertCoreViewToView';
import { usePrefetchedNavigationMenuItemsData } from './usePrefetchedNavigationMenuItemsData';
type NavigationMenuItemFolder = {
folderId: string;
folderName: string;
navigationMenuItems: ReturnType<typeof sortNavigationMenuItems>[number][];
};
export const useNavigationMenuItemsByFolder = () => {
const coreViews = useRecoilValue(coreViewsState);
const objectMetadataItems = useRecoilValue(objectMetadataItemsState);
const { navigationMenuItems } = usePrefetchedNavigationMenuItemsData();
const views = coreViews.map(convertCoreViewToView);
const { folders, itemsByFolderId } = navigationMenuItems.reduce<{
folders: Array<{ id: string; name: string }>;
itemsByFolderId: Map<string, NavigationMenuItem[]>;
}>(
(acc, item) => {
const isFolder =
isDefined(item.name) &&
!isDefined(item.folderId) &&
!isDefined(item.targetRecordId) &&
!isDefined(item.targetObjectMetadataId) &&
!isDefined(item.viewId);
if (isFolder) {
acc.folders.push({ id: item.id, name: item.name || 'Folder' });
} else if (isDefined(item.folderId)) {
const existingItems = acc.itemsByFolderId.get(item.folderId);
if (isDefined(existingItems)) {
existingItems.push(item);
} else {
acc.itemsByFolderId.set(item.folderId, [item]);
}
}
return acc;
},
{ folders: [], itemsByFolderId: new Map() },
);
const navigationMenuItemsByFolder = folders.reduce<
NavigationMenuItemFolder[]
>((acc, folder) => {
const itemsInFolder = itemsByFolderId.get(folder.id) || [];
const targetRecordIdentifiersMap = itemsInFolder.reduce<
Map<string, ObjectRecordIdentifier>
>((map, item) => {
const itemTargetRecordId = item.targetRecordId;
if (!isDefined(itemTargetRecordId) || isDefined(item.viewId)) {
return map;
}
const targetRecordIdentifier = item.targetRecordIdentifier;
if (!isDefined(targetRecordIdentifier)) {
return map;
}
const itemObjectMetadata = objectMetadataItems.find(
(meta) => meta.id === item.targetObjectMetadataId,
);
if (isDefined(itemObjectMetadata)) {
const objectRecordIdentifier = recordIdentifierToObjectRecordIdentifier(
{
recordIdentifier: targetRecordIdentifier,
objectMetadataItem: itemObjectMetadata,
},
);
map.set(itemTargetRecordId, objectRecordIdentifier);
}
return map;
}, new Map());
const sortedItems = sortNavigationMenuItems(
itemsInFolder,
true,
views,
objectMetadataItems,
targetRecordIdentifiersMap,
);
acc.push({
folderId: folder.id,
folderName: folder.name,
navigationMenuItems: sortedItems,
});
return acc;
}, []);
return { navigationMenuItemsByFolder };
};
@@ -0,0 +1,34 @@
import { currentWorkspaceMemberState } from '@/auth/states/currentWorkspaceMemberState';
import { prefetchNavigationMenuItemsState } from '@/prefetch/states/prefetchNavigationMenuItemsState';
import { useRecoilValue } from 'recoil';
import { isDefined } from 'twenty-shared/utils';
import { type NavigationMenuItem } from '~/generated-metadata/graphql';
type PrefetchedNavigationMenuItemsData = {
navigationMenuItems: NavigationMenuItem[];
workspaceNavigationMenuItems: NavigationMenuItem[];
currentWorkspaceMemberId: string | undefined;
};
export const usePrefetchedNavigationMenuItemsData =
(): PrefetchedNavigationMenuItemsData => {
const currentWorkspaceMember = useRecoilValue(currentWorkspaceMemberState);
const currentWorkspaceMemberId = currentWorkspaceMember?.id;
const prefetchNavigationMenuItems = useRecoilValue(
prefetchNavigationMenuItemsState,
);
const navigationMenuItems = prefetchNavigationMenuItems.filter((item) =>
isDefined(item.userWorkspaceId),
);
const workspaceNavigationMenuItems = prefetchNavigationMenuItems.filter(
(item) => !isDefined(item.userWorkspaceId),
);
return {
navigationMenuItems,
workspaceNavigationMenuItems,
currentWorkspaceMemberId,
};
};
@@ -0,0 +1,48 @@
import { useRecoilCallback } from 'recoil';
import { FIND_MANY_NAVIGATION_MENU_ITEMS } from '@/navigation-menu-item/graphql/queries/findManyNavigationMenuItems';
import { useApolloCoreClient } from '@/object-metadata/hooks/useApolloCoreClient';
import { prefetchNavigationMenuItemsState } from '@/prefetch/states/prefetchNavigationMenuItemsState';
import { isDefined } from 'twenty-shared/utils';
export const useRemoveNavigationMenuItemByTargetRecordId = () => {
const apolloCoreClient = useApolloCoreClient();
const cache = apolloCoreClient.cache;
const removeNavigationMenuItemsByTargetRecordIds = useRecoilCallback(
({ set, snapshot }) =>
(targetRecordIds: string[]) => {
const targetRecordIdsSet = new Set(targetRecordIds);
const currentNavigationMenuItems = snapshot
.getLoadable(prefetchNavigationMenuItemsState)
.getValue();
const updatedNavigationMenuItems = currentNavigationMenuItems.filter(
(item) =>
!isDefined(item.targetRecordId) ||
!targetRecordIdsSet.has(item.targetRecordId),
);
set(prefetchNavigationMenuItemsState, updatedNavigationMenuItems);
cache.updateQuery(
{ query: FIND_MANY_NAVIGATION_MENU_ITEMS },
(data) => {
if (!isDefined(data?.navigationMenuItems)) {
return data;
}
return {
...data,
navigationMenuItems: updatedNavigationMenuItems,
};
},
);
},
[cache],
);
return {
removeNavigationMenuItemsByTargetRecordIds,
};
};
@@ -0,0 +1,23 @@
import { useUpdateNavigationMenuItem } from '@/navigation-menu-item/hooks/useUpdateNavigationMenuItem';
export const useRenameNavigationMenuItemFolder = () => {
const { updateNavigationMenuItem } = useUpdateNavigationMenuItem();
const renameNavigationMenuItemFolder = async (
folderId: string,
newName: string,
): Promise<void> => {
if (!newName) {
return;
}
await updateNavigationMenuItem({
id: folderId,
name: newName,
});
};
return {
renameNavigationMenuItemFolder,
};
};
@@ -0,0 +1,109 @@
import { useMemo } from 'react';
import { useRecoilValue } from 'recoil';
import { isDefined } from 'twenty-shared/utils';
import { recordIdentifierToObjectRecordIdentifier } from '@/navigation-menu-item/utils/recordIdentifierToObjectRecordIdentifier';
import { sortNavigationMenuItems } from '@/navigation-menu-item/utils/sortNavigationMenuItems';
import { objectMetadataItemsState } from '@/object-metadata/states/objectMetadataItemsState';
import { type ObjectRecordIdentifier } from '@/object-record/types/ObjectRecordIdentifier';
import { coreViewsState } from '@/views/states/coreViewState';
import { convertCoreViewToView } from '@/views/utils/convertCoreViewToView';
import { usePrefetchedNavigationMenuItemsData } from './usePrefetchedNavigationMenuItemsData';
export const useSortedNavigationMenuItems = () => {
const { navigationMenuItems, workspaceNavigationMenuItems } =
usePrefetchedNavigationMenuItemsData();
const coreViews = useRecoilValue(coreViewsState).map(convertCoreViewToView);
const objectMetadataItems = useRecoilValue(objectMetadataItemsState);
const targetRecordIdentifiers = useMemo(() => {
const identifiersMap = new Map<string, ObjectRecordIdentifier>();
[...navigationMenuItems, ...workspaceNavigationMenuItems].forEach(
(navigationMenuItem) => {
if (isDefined(navigationMenuItem.viewId)) {
return;
}
const itemTargetRecordId = navigationMenuItem.targetRecordId;
if (!isDefined(itemTargetRecordId)) {
return;
}
const targetRecordIdentifier =
navigationMenuItem.targetRecordIdentifier;
if (!isDefined(targetRecordIdentifier)) {
return;
}
const objectMetadataItem = objectMetadataItems.find(
(item) => item.id === navigationMenuItem.targetObjectMetadataId,
);
if (!isDefined(objectMetadataItem)) {
return;
}
const objectRecordIdentifier = recordIdentifierToObjectRecordIdentifier(
{
recordIdentifier: targetRecordIdentifier,
objectMetadataItem,
},
);
identifiersMap.set(itemTargetRecordId, objectRecordIdentifier);
},
);
return identifiersMap;
}, [navigationMenuItems, workspaceNavigationMenuItems, objectMetadataItems]);
const navigationMenuItemsSorted = useMemo(() => {
return sortNavigationMenuItems(
navigationMenuItems,
true,
coreViews,
objectMetadataItems,
targetRecordIdentifiers,
);
}, [
navigationMenuItems,
coreViews,
objectMetadataItems,
targetRecordIdentifiers,
]);
const workspaceNavigationMenuItemsSorted = useMemo(() => {
const filtered = workspaceNavigationMenuItems.filter((item) => {
if (isDefined(item.viewId)) {
return coreViews.some((view) => view.id === item.viewId);
}
const itemTargetRecordId = item.targetRecordId;
if (!isDefined(itemTargetRecordId)) {
return false;
}
const matchesTargetRecord =
targetRecordIdentifiers.has(itemTargetRecordId);
return matchesTargetRecord;
});
return sortNavigationMenuItems(
filtered,
false,
coreViews,
objectMetadataItems,
targetRecordIdentifiers,
);
}, [
workspaceNavigationMenuItems,
coreViews,
objectMetadataItems,
targetRecordIdentifiers,
]);
return {
navigationMenuItemsSorted,
workspaceNavigationMenuItemsSorted,
};
};
@@ -0,0 +1,28 @@
import {
useUpdateNavigationMenuItemMutation,
type UpdateNavigationMenuItemInput,
type UpdateOneNavigationMenuItemInput,
} from '~/generated-metadata/graphql';
export const useUpdateNavigationMenuItem = () => {
const [updateNavigationMenuItemMutation] =
useUpdateNavigationMenuItemMutation({
refetchQueries: ['FindManyNavigationMenuItems'],
});
const updateNavigationMenuItem = async (
input: UpdateNavigationMenuItemInput & { id: string },
) => {
const { id, ...update } = input;
const updateOneInput: UpdateOneNavigationMenuItemInput = {
id,
update,
};
await updateNavigationMenuItemMutation({
variables: { input: updateOneInput },
});
};
return { updateNavigationMenuItem };
};
@@ -0,0 +1,60 @@
import { useRecoilValue } from 'recoil';
import { useFilteredObjectMetadataItems } from '@/object-metadata/hooks/useFilteredObjectMetadataItems';
import { type ObjectMetadataItem } from '@/object-metadata/types/ObjectMetadataItem';
import { coreViewsState } from '@/views/states/coreViewState';
import { convertCoreViewToView } from '@/views/utils/convertCoreViewToView';
import { isDefined } from 'twenty-shared/utils';
import { usePrefetchedNavigationMenuItemsData } from './usePrefetchedNavigationMenuItemsData';
import { useSortedNavigationMenuItems } from './useSortedNavigationMenuItems';
export const useWorkspaceNavigationMenuItems = (): {
workspaceNavigationMenuItemsObjectMetadataItems: ObjectMetadataItem[];
} => {
const { workspaceNavigationMenuItemsSorted } = useSortedNavigationMenuItems();
const { workspaceNavigationMenuItems: rawWorkspaceNavigationMenuItems } =
usePrefetchedNavigationMenuItemsData();
const coreViews = useRecoilValue(coreViewsState);
const views = coreViews.map(convertCoreViewToView);
const workspaceNavigationMenuItemViewIds = new Set(
workspaceNavigationMenuItemsSorted
.map((item) => item.viewId)
.filter((viewId) => isDefined(viewId)),
);
const navigationMenuItemViewObjectMetadataIds = new Set(
views.reduce<string[]>((acc, view) => {
if (workspaceNavigationMenuItemViewIds.has(view.id)) {
acc.push(view.objectMetadataId);
}
return acc;
}, []),
);
const navigationMenuItemRecordObjectMetadataIds = new Set(
rawWorkspaceNavigationMenuItems
.map((item) => item.targetObjectMetadataId)
.filter((objectMetadataId) => isDefined(objectMetadataId)),
);
const allNavigationMenuItemObjectMetadataIds = new Set([
...navigationMenuItemViewObjectMetadataIds,
...navigationMenuItemRecordObjectMetadataIds,
]);
const { activeNonSystemObjectMetadataItems } =
useFilteredObjectMetadataItems();
const activeNonSystemObjectMetadataItemsInWorkspaceNavigationMenuItems: ObjectMetadataItem[] =
activeNonSystemObjectMetadataItems.filter((item: ObjectMetadataItem) =>
allNavigationMenuItemObjectMetadataIds.has(item.id),
);
return {
workspaceNavigationMenuItemsObjectMetadataItems:
activeNonSystemObjectMetadataItemsInWorkspaceNavigationMenuItems,
};
};
@@ -0,0 +1,6 @@
import { atom } from 'recoil';
export const isNavigationMenuItemFolderCreatingState = atom<boolean>({
key: 'isNavigationMenuItemFolderCreatingState',
default: false,
});
@@ -0,0 +1,6 @@
import { atom } from 'recoil';
export const openNavigationMenuItemFolderIdsState = atom<string[]>({
key: 'openNavigationMenuItemFolderIdsState',
default: [],
});
@@ -0,0 +1,154 @@
import { computeNavigationMenuItemDisplayFields } from '@/navigation-menu-item/utils/computeNavigationMenuItemDisplayFields';
import { type ObjectMetadataItem } from '@/object-metadata/types/ObjectMetadataItem';
import { type ObjectRecordIdentifier } from '@/object-record/types/ObjectRecordIdentifier';
describe('computeNavigationMenuItemDisplayFields', () => {
const mockObjectMetadataItem: ObjectMetadataItem = {
id: 'metadata-id',
nameSingular: 'person',
namePlural: 'people',
labelSingular: 'Person',
labelPlural: 'People',
} as ObjectMetadataItem;
const mockObjectRecordIdentifier: ObjectRecordIdentifier = {
id: 'record-id',
name: 'John Doe',
avatarUrl: 'https://example.com/avatar.jpg',
avatarType: 'rounded',
linkToShowPage: '/app/objects/people/record-id',
};
it('should return null when objectMetadataItem is null', () => {
const result = computeNavigationMenuItemDisplayFields(
null,
mockObjectRecordIdentifier,
);
expect(result).toBeNull();
});
it('should return null when objectRecordIdentifier is null', () => {
const result = computeNavigationMenuItemDisplayFields(
mockObjectMetadataItem,
null,
);
expect(result).toBeNull();
});
it('should return null when both objectMetadataItem and objectRecordIdentifier are null', () => {
const result = computeNavigationMenuItemDisplayFields(null, null);
expect(result).toBeNull();
});
it('should return complete display fields when all parameters are provided', () => {
const result = computeNavigationMenuItemDisplayFields(
mockObjectMetadataItem,
mockObjectRecordIdentifier,
);
expect(result).toEqual({
labelIdentifier: 'John Doe',
avatarUrl: 'https://example.com/avatar.jpg',
avatarType: 'rounded',
link: '/app/objects/people/record-id',
objectNameSingular: 'person',
});
});
it('should handle objectRecordIdentifier with undefined optional fields', () => {
const identifierWithoutOptionalFields: ObjectRecordIdentifier = {
id: 'record-id',
name: 'Jane Doe',
};
const result = computeNavigationMenuItemDisplayFields(
mockObjectMetadataItem,
identifierWithoutOptionalFields,
);
expect(result).toEqual({
labelIdentifier: 'Jane Doe',
avatarUrl: '',
avatarType: 'icon',
link: '',
objectNameSingular: 'person',
});
});
it('should use objectMetadataItem nameSingular for objectNameSingular', () => {
const customMetadataItem: ObjectMetadataItem = {
...mockObjectMetadataItem,
nameSingular: 'company',
} as ObjectMetadataItem;
const result = computeNavigationMenuItemDisplayFields(
customMetadataItem,
mockObjectRecordIdentifier,
);
expect(result?.objectNameSingular).toBe('company');
});
it('should handle objectRecordIdentifier with null avatarType', () => {
const identifierWithNullAvatarType: ObjectRecordIdentifier = {
id: 'record-id',
name: 'Test User',
avatarType: null,
};
const result = computeNavigationMenuItemDisplayFields(
mockObjectMetadataItem,
identifierWithNullAvatarType,
);
expect(result?.avatarType).toBe('icon');
});
it('should handle objectRecordIdentifier with undefined linkToShowPage', () => {
const identifierWithoutLink: ObjectRecordIdentifier = {
id: 'record-id',
name: 'Test User',
linkToShowPage: undefined,
};
const result = computeNavigationMenuItemDisplayFields(
mockObjectMetadataItem,
identifierWithoutLink,
);
expect(result?.link).toBe('');
});
it('should handle objectRecordIdentifier with undefined avatarUrl', () => {
const identifierWithoutAvatarUrl: ObjectRecordIdentifier = {
id: 'record-id',
name: 'Test User',
avatarUrl: undefined,
};
const result = computeNavigationMenuItemDisplayFields(
mockObjectMetadataItem,
identifierWithoutAvatarUrl,
);
expect(result?.avatarUrl).toBe('');
});
it('should handle objectRecordIdentifier with undefined avatarType', () => {
const identifierWithoutAvatarType: ObjectRecordIdentifier = {
id: 'record-id',
name: 'Test User',
avatarType: undefined,
};
const result = computeNavigationMenuItemDisplayFields(
mockObjectMetadataItem,
identifierWithoutAvatarType,
);
expect(result?.avatarType).toBe('icon');
});
});
@@ -0,0 +1,31 @@
import { generatedMockObjectMetadataItems } from '~/testing/utils/generatedMockObjectMetadataItems';
import { getNavigationMenuItemSecondaryLabel } from '@/navigation-menu-item/utils/getNavigationMenuItemSecondaryLabel';
describe('getNavigationMenuItemSecondaryLabel', () => {
it('should return "View" for view object', () => {
const result = getNavigationMenuItemSecondaryLabel({
objectMetadataItems: generatedMockObjectMetadataItems,
navigationMenuItemObjectNameSingular: 'view',
});
expect(result).toBe('View');
});
it('should return labelSingular for matching object metadata item', () => {
const result = getNavigationMenuItemSecondaryLabel({
objectMetadataItems: generatedMockObjectMetadataItems,
navigationMenuItemObjectNameSingular: 'person',
});
expect(result).toBe('Person');
});
it('should return undefined when object metadata item is not found', () => {
const result = getNavigationMenuItemSecondaryLabel({
objectMetadataItems: generatedMockObjectMetadataItems,
navigationMenuItemObjectNameSingular: 'nonexistent',
});
expect(result).toBeUndefined();
});
});
@@ -0,0 +1,88 @@
import { isLocationMatchingNavigationMenuItem } from '@/navigation-menu-item/utils/isLocationMatchingNavigationMenuItem';
describe('isLocationMatchingNavigationMenuItem', () => {
it('should return true if navigation menu item link matches current path for non-view items', () => {
const currentPath = '/app/objects/people';
const currentViewPath = '/app/objects/people?viewId=123';
const navigationMenuItem = {
objectNameSingular: 'person',
link: '/app/objects/people',
};
expect(
isLocationMatchingNavigationMenuItem(
currentPath,
currentViewPath,
navigationMenuItem,
),
).toBe(true);
});
it('should return true if navigation menu item link matches current view path for view items', () => {
const currentPath = '/app/objects/companies';
const currentViewPath = '/app/objects/companies?viewId=123';
const navigationMenuItem = {
objectNameSingular: 'view',
link: '/app/objects/companies?viewId=123',
};
expect(
isLocationMatchingNavigationMenuItem(
currentPath,
currentViewPath,
navigationMenuItem,
),
).toBe(true);
});
it('should return false if navigation menu item link does not match current path for non-view items', () => {
const currentPath = '/app/objects/people';
const currentViewPath = '/app/objects/people?viewId=123';
const navigationMenuItem = {
objectNameSingular: 'person',
link: '/app/objects/company',
};
expect(
isLocationMatchingNavigationMenuItem(
currentPath,
currentViewPath,
navigationMenuItem,
),
).toBe(false);
});
it('should return false if navigation menu item link does not match current view path for view items', () => {
const currentPath = '/app/objects/companies';
const currentViewPath = '/app/objects/companies?viewId=123';
const navigationMenuItem = {
objectNameSingular: 'view',
link: '/app/objects/companies?viewId=456',
};
expect(
isLocationMatchingNavigationMenuItem(
currentPath,
currentViewPath,
navigationMenuItem,
),
).toBe(false);
});
it('should use current path for non-view items even if view path is different', () => {
const currentPath = '/app/objects/people';
const currentViewPath = '/app/objects/people?viewId=999';
const navigationMenuItem = {
objectNameSingular: 'person',
link: '/app/objects/people',
};
expect(
isLocationMatchingNavigationMenuItem(
currentPath,
currentViewPath,
navigationMenuItem,
),
).toBe(true);
});
});
@@ -0,0 +1,364 @@
import { sortNavigationMenuItems } from '@/navigation-menu-item/utils/sortNavigationMenuItems';
import { type ObjectMetadataItem } from '@/object-metadata/types/ObjectMetadataItem';
import { type ObjectRecordIdentifier } from '@/object-record/types/ObjectRecordIdentifier';
import { type View } from '@/views/types/View';
import { type NavigationMenuItem } from '~/generated-metadata/graphql';
jest.mock('@/favorites/utils/getObjectMetadataNamePluralFromViewId', () => ({
getObjectMetadataNamePluralFromViewId: jest.fn(
(
view: Pick<View, 'id' | 'name' | 'objectMetadataId'>,
items: ObjectMetadataItem[],
) => {
const item = items.find((item) => item.id === view.objectMetadataId);
return { namePlural: item?.namePlural ?? 'items' };
},
),
}));
jest.mock('twenty-shared/utils', () => {
const actual = jest.requireActual('twenty-shared/utils');
return {
...actual,
getAppPath: jest.fn((path, params, query) => {
const basePath = `/app/objects/${params.objectNamePlural}`;
const viewId = query?.viewId;
if (viewId !== undefined && viewId !== null) {
return `${basePath}?viewId=${viewId}`;
}
return basePath;
}),
};
});
describe('sortNavigationMenuItems', () => {
const mockObjectMetadataItem: ObjectMetadataItem = {
id: 'metadata-id',
nameSingular: 'person',
namePlural: 'people',
} as ObjectMetadataItem;
const mockView: Pick<View, 'id' | 'name' | 'objectMetadataId' | 'icon'> = {
id: 'view-id',
name: 'All People',
objectMetadataId: 'metadata-id',
icon: 'IconUser',
};
const mockObjectRecordIdentifier: ObjectRecordIdentifier = {
id: 'record-id',
name: 'John Doe',
avatarUrl: 'https://example.com/avatar.jpg',
avatarType: 'rounded',
linkToShowPage: '/app/objects/people/record-id',
};
beforeEach(() => {
jest.clearAllMocks();
});
it('should return empty array when navigationMenuItems is empty', () => {
const result = sortNavigationMenuItems([], true, [], [], new Map());
expect(result).toEqual([]);
});
it('should process view link navigation menu items', () => {
const navigationMenuItem: NavigationMenuItem = {
id: 'item-id',
viewId: 'view-id',
position: 1,
} as NavigationMenuItem;
const result = sortNavigationMenuItems(
[navigationMenuItem],
true,
[mockView],
[mockObjectMetadataItem],
new Map(),
);
expect(result).toHaveLength(1);
expect(result[0]).toMatchObject({
id: 'item-id',
viewId: 'view-id',
position: 1,
labelIdentifier: 'All People',
objectNameSingular: 'view',
Icon: 'IconUser',
});
expect(result[0].link).toContain('viewId=view-id');
});
it('should return null for view link when view is not found', () => {
const navigationMenuItem: NavigationMenuItem = {
id: 'item-id',
viewId: 'non-existent-view-id',
position: 1,
} as NavigationMenuItem;
const result = sortNavigationMenuItems(
[navigationMenuItem],
true,
[],
[mockObjectMetadataItem],
new Map(),
);
expect(result).toEqual([]);
});
it('should process record link navigation menu items', () => {
const navigationMenuItem: NavigationMenuItem = {
id: 'item-id',
targetRecordId: 'record-id',
targetObjectMetadataId: 'metadata-id',
position: 2,
} as NavigationMenuItem;
const targetRecordIdentifiers = new Map([
['record-id', mockObjectRecordIdentifier],
]);
const result = sortNavigationMenuItems(
[navigationMenuItem],
true,
[],
[mockObjectMetadataItem],
targetRecordIdentifiers,
);
expect(result).toHaveLength(1);
expect(result[0]).toMatchObject({
id: 'item-id',
targetRecordId: 'record-id',
targetObjectMetadataId: 'metadata-id',
position: 2,
labelIdentifier: 'John Doe',
avatarUrl: 'https://example.com/avatar.jpg',
avatarType: 'rounded',
link: '/app/objects/people/record-id',
objectNameSingular: 'person',
});
});
it('should return null for record link when targetRecordId is not defined', () => {
const navigationMenuItem: NavigationMenuItem = {
id: 'item-id',
targetObjectMetadataId: 'metadata-id',
position: 1,
} as NavigationMenuItem;
const result = sortNavigationMenuItems(
[navigationMenuItem],
true,
[],
[mockObjectMetadataItem],
new Map(),
);
expect(result).toEqual([]);
});
it('should return null for record link when objectMetadataItem is not found', () => {
const navigationMenuItem: NavigationMenuItem = {
id: 'item-id',
targetRecordId: 'record-id',
targetObjectMetadataId: 'non-existent-metadata-id',
position: 1,
} as NavigationMenuItem;
const targetRecordIdentifiers = new Map([
['record-id', mockObjectRecordIdentifier],
]);
const result = sortNavigationMenuItems(
[navigationMenuItem],
true,
[],
[mockObjectMetadataItem],
targetRecordIdentifiers,
);
expect(result).toEqual([]);
});
it('should return null for record link when targetRecordIdentifier is not found', () => {
const navigationMenuItem: NavigationMenuItem = {
id: 'item-id',
targetRecordId: 'non-existent-record-id',
targetObjectMetadataId: 'metadata-id',
position: 1,
} as NavigationMenuItem;
const result = sortNavigationMenuItems(
[navigationMenuItem],
true,
[],
[mockObjectMetadataItem],
new Map(),
);
expect(result).toEqual([]);
});
it('should return empty link when hasLinkToShowPage is false', () => {
const navigationMenuItem: NavigationMenuItem = {
id: 'item-id',
targetRecordId: 'record-id',
targetObjectMetadataId: 'metadata-id',
position: 2,
} as NavigationMenuItem;
const targetRecordIdentifiers = new Map([
['record-id', mockObjectRecordIdentifier],
]);
const result = sortNavigationMenuItems(
[navigationMenuItem],
false,
[],
[mockObjectMetadataItem],
targetRecordIdentifiers,
);
expect(result).toHaveLength(1);
expect(result[0].link).toBe('');
});
it('should sort navigation menu items by position', () => {
const navigationMenuItems: NavigationMenuItem[] = [
{
id: 'item-3',
targetRecordId: 'record-id-3',
targetObjectMetadataId: 'metadata-id',
position: 3,
},
{
id: 'item-1',
targetRecordId: 'record-id-1',
targetObjectMetadataId: 'metadata-id',
position: 1,
},
{
id: 'item-2',
targetRecordId: 'record-id-2',
targetObjectMetadataId: 'metadata-id',
position: 2,
},
] as NavigationMenuItem[];
const targetRecordIdentifiers = new Map([
['record-id-1', { ...mockObjectRecordIdentifier, id: 'record-id-1' }],
['record-id-2', { ...mockObjectRecordIdentifier, id: 'record-id-2' }],
['record-id-3', { ...mockObjectRecordIdentifier, id: 'record-id-3' }],
]);
const result = sortNavigationMenuItems(
navigationMenuItems,
true,
[],
[mockObjectMetadataItem],
targetRecordIdentifiers,
);
expect(result).toHaveLength(3);
expect(result[0].id).toBe('item-1');
expect(result[1].id).toBe('item-2');
expect(result[2].id).toBe('item-3');
});
it('should handle mixed view and record link items', () => {
const navigationMenuItems: NavigationMenuItem[] = [
{
id: 'view-item',
viewId: 'view-id',
position: 1,
},
{
id: 'record-item',
targetRecordId: 'record-id',
targetObjectMetadataId: 'metadata-id',
position: 2,
},
] as NavigationMenuItem[];
const targetRecordIdentifiers = new Map([
['record-id', mockObjectRecordIdentifier],
]);
const result = sortNavigationMenuItems(
navigationMenuItems,
true,
[mockView],
[mockObjectMetadataItem],
targetRecordIdentifiers,
);
expect(result).toHaveLength(2);
expect(result[0].id).toBe('view-item');
expect(result[0].objectNameSingular).toBe('view');
expect(result[1].id).toBe('record-item');
expect(result[1].objectNameSingular).toBe('person');
});
it('should handle empty targetRecordIdentifiers map', () => {
const navigationMenuItem: NavigationMenuItem = {
id: 'item-id',
targetRecordId: 'non-existent-record-id',
targetObjectMetadataId: 'metadata-id',
position: 1,
} as NavigationMenuItem;
const result = sortNavigationMenuItems(
[navigationMenuItem],
true,
[],
[mockObjectMetadataItem],
new Map(),
);
expect(result).toHaveLength(0);
});
it('should handle navigationMenuItem with both viewId and targetRecordId (viewId takes precedence)', () => {
const navigationMenuItem: NavigationMenuItem = {
id: 'item-id',
viewId: 'view-id',
targetRecordId: 'record-id',
targetObjectMetadataId: 'metadata-id',
position: 1,
} as NavigationMenuItem;
const result = sortNavigationMenuItems(
[navigationMenuItem],
true,
[mockView],
[mockObjectMetadataItem],
new Map(),
);
expect(result).toHaveLength(1);
expect(result[0].objectNameSingular).toBe('view');
expect(result[0].viewId).toBe('view-id');
});
it('should handle navigationMenuItem with undefined targetRecordId', () => {
const navigationMenuItem: NavigationMenuItem = {
id: 'item-id',
targetRecordId: undefined,
targetObjectMetadataId: 'metadata-id',
position: 1,
} as NavigationMenuItem;
const result = sortNavigationMenuItems(
[navigationMenuItem],
true,
[],
[mockObjectMetadataItem],
new Map(),
);
expect(result).toHaveLength(0);
});
});
@@ -0,0 +1,31 @@
import { type ObjectMetadataItem } from '@/object-metadata/types/ObjectMetadataItem';
import { type ObjectRecordIdentifier } from '@/object-record/types/ObjectRecordIdentifier';
import { isDefined } from 'twenty-shared/utils';
export type NavigationMenuItemDisplayFields = {
labelIdentifier: string;
avatarUrl: string;
avatarType: 'icon' | 'rounded' | 'squared';
link: string;
objectNameSingular: string;
Icon?: string;
};
export const computeNavigationMenuItemDisplayFields = (
objectMetadataItem: ObjectMetadataItem | null,
objectRecordIdentifier: ObjectRecordIdentifier | null,
): NavigationMenuItemDisplayFields | null => {
if (!isDefined(objectMetadataItem) || !isDefined(objectRecordIdentifier)) {
return null;
}
const objectNameSingular = objectMetadataItem.nameSingular;
return {
labelIdentifier: objectRecordIdentifier.name,
avatarUrl: objectRecordIdentifier.avatarUrl ?? '',
avatarType: objectRecordIdentifier.avatarType ?? 'icon',
link: objectRecordIdentifier.linkToShowPage ?? '',
objectNameSingular,
};
};
@@ -0,0 +1,23 @@
import { type ObjectMetadataItem } from '@/object-metadata/types/ObjectMetadataItem';
type GetNavigationMenuItemSecondaryLabelProps = {
objectMetadataItems: Pick<
ObjectMetadataItem,
'nameSingular' | 'labelSingular'
>[];
navigationMenuItemObjectNameSingular: string;
};
export const getNavigationMenuItemSecondaryLabel = ({
objectMetadataItems,
navigationMenuItemObjectNameSingular,
}: GetNavigationMenuItemSecondaryLabelProps) => {
if (navigationMenuItemObjectNameSingular === 'view') {
return 'View';
}
return objectMetadataItems.find(
(objectMetadataItem) =>
objectMetadataItem.nameSingular === navigationMenuItemObjectNameSingular,
)?.labelSingular;
};
@@ -0,0 +1,14 @@
import { type ProcessedNavigationMenuItem } from '@/navigation-menu-item/utils/sortNavigationMenuItems';
export const isLocationMatchingNavigationMenuItem = (
currentPath: string,
currentViewPath: string,
navigationMenuItem: Pick<
ProcessedNavigationMenuItem,
'objectNameSingular' | 'link'
>,
) => {
return navigationMenuItem.objectNameSingular === 'view'
? navigationMenuItem.link === currentViewPath
: navigationMenuItem.link === currentPath;
};
@@ -0,0 +1,51 @@
import { CoreObjectNameSingular } from '@/object-metadata/types/CoreObjectNameSingular';
import { type ObjectMetadataItem } from '@/object-metadata/types/ObjectMetadataItem';
import { getAvatarType } from '@/object-metadata/utils/getAvatarType';
import { getBasePathToShowPage } from '@/object-metadata/utils/getBasePathToShowPage';
import { type ObjectRecordIdentifier } from '@/object-record/types/ObjectRecordIdentifier';
import { isDefined } from 'twenty-shared/utils';
type RecordIdentifierDTO = {
id: string;
labelIdentifier: string;
imageIdentifier?: string | null;
};
export const recordIdentifierToObjectRecordIdentifier = ({
recordIdentifier,
objectMetadataItem,
}: {
recordIdentifier: RecordIdentifierDTO;
objectMetadataItem: ObjectMetadataItem;
}): ObjectRecordIdentifier => {
const avatarType = getAvatarType(objectMetadataItem.nameSingular);
const basePathToShowPage = getBasePathToShowPage({
objectNameSingular: objectMetadataItem.nameSingular,
});
const isWorkspaceMemberObjectMetadata =
objectMetadataItem.nameSingular === CoreObjectNameSingular.WorkspaceMember;
let linkToShowPage = '';
if (
objectMetadataItem.nameSingular === CoreObjectNameSingular.NoteTarget ||
objectMetadataItem.nameSingular === CoreObjectNameSingular.TaskTarget
) {
linkToShowPage = '';
} else if (
!isWorkspaceMemberObjectMetadata &&
isDefined(recordIdentifier.id)
) {
linkToShowPage = `${basePathToShowPage}${recordIdentifier.id}`;
}
return {
id: recordIdentifier.id,
name: recordIdentifier.labelIdentifier,
avatarUrl: recordIdentifier.imageIdentifier ?? undefined,
avatarType,
linkToShowPage,
};
};
@@ -0,0 +1,96 @@
import { type ObjectMetadataItem } from '@/object-metadata/types/ObjectMetadataItem';
import { type ObjectRecordIdentifier } from '@/object-record/types/ObjectRecordIdentifier';
import { type View } from '@/views/types/View';
import { AppPath } from 'twenty-shared/types';
import { getAppPath, isDefined } from 'twenty-shared/utils';
import { type NavigationMenuItem } from '~/generated-metadata/graphql';
import { getObjectMetadataNamePluralFromViewId } from '@/favorites/utils/getObjectMetadataNamePluralFromViewId';
import {
computeNavigationMenuItemDisplayFields,
type NavigationMenuItemDisplayFields,
} from './computeNavigationMenuItemDisplayFields';
export type ProcessedNavigationMenuItem = NavigationMenuItem &
NavigationMenuItemDisplayFields;
export const sortNavigationMenuItems = (
navigationMenuItems: NavigationMenuItem[],
hasLinkToShowPage: boolean,
views: Pick<View, 'id' | 'name' | 'objectMetadataId' | 'icon'>[],
objectMetadataItems: ObjectMetadataItem[],
targetRecordIdentifiers: Map<string, ObjectRecordIdentifier>,
): ProcessedNavigationMenuItem[] => {
return navigationMenuItems
.map((navigationMenuItem) => {
if (isDefined(navigationMenuItem.viewId)) {
const view = views.find(
(view) => view.id === navigationMenuItem.viewId,
);
if (isDefined(view)) {
const { namePlural } = getObjectMetadataNamePluralFromViewId(
view,
objectMetadataItems,
);
const displayFields: NavigationMenuItemDisplayFields = {
labelIdentifier: view.name,
avatarUrl: '',
avatarType: 'icon',
link: getAppPath(
AppPath.RecordIndexPage,
{ objectNamePlural: namePlural },
{ viewId: navigationMenuItem.viewId },
),
objectNameSingular: 'view',
Icon: view.icon,
};
return {
...navigationMenuItem,
...displayFields,
};
}
return null;
}
if (!isDefined(navigationMenuItem.targetRecordId)) {
return null;
}
const objectMetadataItem = objectMetadataItems.find(
(item) => item.id === navigationMenuItem.targetObjectMetadataId,
);
if (!isDefined(objectMetadataItem)) {
return null;
}
const objectRecordIdentifier = targetRecordIdentifiers.get(
navigationMenuItem.targetRecordId,
);
if (!isDefined(objectRecordIdentifier)) {
return null;
}
const displayFields = computeNavigationMenuItemDisplayFields(
objectMetadataItem,
objectRecordIdentifier,
);
if (!isDefined(displayFields)) {
return null;
}
return {
...navigationMenuItem,
...displayFields,
link: hasLinkToShowPage ? displayFields.link : '',
};
})
.filter(isDefined)
.sort((a, b) => a.position - b.position);
};
@@ -1,23 +1,43 @@
import { useRecoilValue } from 'recoil';
import { currentWorkspaceState } from '@/auth/states/currentWorkspaceState';
import { FavoritesFolderContent } from '@/favorites/components/FavoritesFolderContent';
import { useFavoritesByFolder } from '@/favorites/hooks/useFavoritesByFolder';
import { NavigationMenuItemFolderContentDispatcherEffect } from '@/navigation-menu-item/components/NavigationMenuItemFolderContentDispatcher';
import { useNavigationMenuItemsByFolder } from '@/navigation-menu-item/hooks/useNavigationMenuItemsByFolder';
import { MainNavigationDrawerFixedItems } from '@/navigation/components/MainNavigationDrawerFixedItems';
import { MainNavigationDrawerScrollableItems } from '@/navigation/components/MainNavigationDrawerScrollableItems';
import { NavigationDrawer } from '@/ui/navigation/navigation-drawer/components/NavigationDrawer';
import { NavigationDrawerFixedContent } from '@/ui/navigation/navigation-drawer/components/NavigationDrawerFixedContent';
import { NavigationDrawerScrollableContent } from '@/ui/navigation/navigation-drawer/components/NavigationDrawerScrollableContent';
import { currentFavoriteFolderIdState } from '@/ui/navigation/navigation-drawer/states/currentFavoriteFolderIdState';
import { currentNavigationMenuItemFolderIdState } from '@/ui/navigation/navigation-drawer/states/currentNavigationMenuItemFolderIdState';
import { useIsFeatureEnabled } from '@/workspace/hooks/useIsFeatureEnabled';
import { FeatureFlagKey } from '~/generated/graphql';
export const MainNavigationDrawer = ({ className }: { className?: string }) => {
const currentWorkspace = useRecoilValue(currentWorkspaceState);
const currentFavoriteFolderId = useRecoilValue(currentFavoriteFolderIdState);
const currentNavigationMenuItemFolderId = useRecoilValue(
currentNavigationMenuItemFolderIdState,
);
const { favoritesByFolder } = useFavoritesByFolder();
const openedFolder = favoritesByFolder.find(
const { navigationMenuItemsByFolder } = useNavigationMenuItemsByFolder();
const isNavigationMenuItemEnabled = useIsFeatureEnabled(
FeatureFlagKey.IS_NAVIGATION_MENU_ITEM_ENABLED,
);
const openedFavoriteFolder = favoritesByFolder.find(
(f) => f.folderId === currentFavoriteFolderId,
);
const openedNavigationMenuItemFolder = navigationMenuItemsByFolder.find(
(f) => f.folderId === currentNavigationMenuItemFolderId,
);
const openedFolder = isNavigationMenuItemEnabled
? openedNavigationMenuItemFolder
: openedFavoriteFolder;
return (
<NavigationDrawer
className={className}
@@ -28,11 +48,14 @@ export const MainNavigationDrawer = ({ className }: { className?: string }) => {
</NavigationDrawerFixedContent>
<NavigationDrawerScrollableContent>
{currentFavoriteFolderId && openedFolder ? (
<FavoritesFolderContent
{openedFolder ? (
<NavigationMenuItemFolderContentDispatcherEffect
folderName={openedFolder.folderName}
folderId={openedFolder.folderId}
favorites={openedFolder.favorites}
favorites={openedFavoriteFolder?.favorites}
navigationMenuItems={
openedNavigationMenuItemFolder?.navigationMenuItems
}
/>
) : (
<MainNavigationDrawerScrollableItems />
@@ -1,8 +1,23 @@
import { CurrentWorkspaceMemberFavoritesFolders } from '@/favorites/components/CurrentWorkspaceMemberFavoritesFolders';
import { WorkspaceFavorites } from '@/favorites/components/WorkspaceFavorites';
import { NavigationDrawerOpenedSection } from '@/object-metadata/components/NavigationDrawerOpenedSection';
import { RemoteNavigationDrawerSection } from '@/object-metadata/components/RemoteNavigationDrawerSection';
import styled from '@emotion/styled';
import { lazy, Suspense } from 'react';
const CurrentWorkspaceMemberNavigationMenuItemFoldersDispatcher = lazy(() =>
import(
'@/navigation-menu-item/components/CurrentWorkspaceMemberNavigationMenuItemFoldersDispatcher'
).then((module) => ({
default: module.CurrentWorkspaceMemberNavigationMenuItemFoldersDispatcher,
})),
);
const WorkspaceNavigationMenuItemsDispatcher = lazy(() =>
import(
'@/navigation-menu-item/components/WorkspaceNavigationMenuItemsDispatcher'
).then((module) => ({
default: module.WorkspaceNavigationMenuItemsDispatcher,
})),
);
const StyledScrollableItemsContainer = styled.div`
display: flex;
@@ -14,8 +29,12 @@ export const MainNavigationDrawerScrollableItems = () => {
return (
<StyledScrollableItemsContainer>
<NavigationDrawerOpenedSection />
<CurrentWorkspaceMemberFavoritesFolders />
<WorkspaceFavorites />
<Suspense fallback={null}>
<CurrentWorkspaceMemberNavigationMenuItemFoldersDispatcher />
</Suspense>
<Suspense fallback={null}>
<WorkspaceNavigationMenuItemsDispatcher />
</Suspense>
<RemoteNavigationDrawerSection />
</StyledScrollableItemsContainer>
);
@@ -1,11 +1,14 @@
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 { useIsPrefetchLoading } from '@/prefetch/hooks/useIsPrefetchLoading';
import { useIsFeatureEnabled } from '@/workspace/hooks/useIsFeatureEnabled';
import { useLingui } from '@lingui/react/macro';
import { FeatureFlagKey } from '~/generated/graphql';
export const NavigationDrawerOpenedSection = () => {
const { t } = useLingui();
@@ -17,6 +20,11 @@ export const NavigationDrawerOpenedSection = () => {
const loading = useIsPrefetchLoading();
const { workspaceFavoritesObjectMetadataItems } = useWorkspaceFavorites();
const { workspaceNavigationMenuItemsObjectMetadataItems } =
useWorkspaceNavigationMenuItems();
const isNavigationMenuItemEnabled = useIsFeatureEnabled(
FeatureFlagKey.IS_NAVIGATION_MENU_ITEM_ENABLED,
);
const {
objectNamePlural: currentObjectNamePlural,
@@ -37,10 +45,13 @@ export const NavigationDrawerOpenedSection = () => {
return;
}
const shouldDisplayObjectInOpenedSection =
!workspaceFavoritesObjectMetadataItems
.map((item) => item.id)
.includes(objectMetadataItem.id);
const workspaceItemsToExclude = isNavigationMenuItemEnabled
? workspaceNavigationMenuItemsObjectMetadataItems
: workspaceFavoritesObjectMetadataItems;
const shouldDisplayObjectInOpenedSection = !workspaceItemsToExclude
.map((item) => item.id)
.includes(objectMetadataItem.id);
if (loading) {
return <NavigationDrawerSectionForObjectMetadataItemsSkeletonLoader />;
@@ -16,7 +16,10 @@ import { useUpsertRecordsInStore } from '@/object-record/record-store/hooks/useU
import { type ObjectRecord } from '@/object-record/types/ObjectRecord';
import { dispatchObjectRecordOperationBrowserEvent } from '@/object-record/utils/dispatchObjectRecordOperationBrowserEvent';
import { getDeleteManyRecordsMutationResponseField } from '@/object-record/utils/getDeleteManyRecordsMutationResponseField';
import { useRemoveNavigationMenuItemByTargetRecordId } from '@/navigation-menu-item/hooks/useRemoveNavigationMenuItemByTargetRecordId';
import { useIsFeatureEnabled } from '@/workspace/hooks/useIsFeatureEnabled';
import { useRecoilValue } from 'recoil';
import { FeatureFlagKey } from '~/generated/graphql';
import { isDefined } from 'twenty-shared/utils';
import { sleep } from '~/utils/sleep';
@@ -57,6 +60,11 @@ export const useDeleteManyRecords = ({
const { objectMetadataItems } = useObjectMetadataItems();
const { objectPermissionsByObjectMetadataId } = useObjectPermissions();
const { refetchAggregateQueries } = useRefetchAggregateQueries();
const isNavigationMenuItemEnabled = useIsFeatureEnabled(
FeatureFlagKey.IS_NAVIGATION_MENU_ITEM_ENABLED,
);
const { removeNavigationMenuItemsByTargetRecordIds } =
useRemoveNavigationMenuItemByTargetRecordId();
const mutationResponseField = getDeleteManyRecordsMutationResponseField(
objectMetadataItem.namePlural,
@@ -223,6 +231,10 @@ export const useDeleteManyRecords = ({
objectMetadataNamePlural: objectMetadataItem.namePlural,
});
if (isNavigationMenuItemEnabled) {
removeNavigationMenuItemsByTargetRecordIds(recordIdsToDelete);
}
dispatchObjectRecordOperationBrowserEvent({
objectMetadataItem,
operation: {
@@ -13,7 +13,10 @@ import { useUpsertRecordsInStore } from '@/object-record/record-store/hooks/useU
import { type ObjectRecord } from '@/object-record/types/ObjectRecord';
import { dispatchObjectRecordOperationBrowserEvent } from '@/object-record/utils/dispatchObjectRecordOperationBrowserEvent';
import { getDestroyManyRecordsMutationResponseField } from '@/object-record/utils/getDestroyManyRecordsMutationResponseField';
import { useRemoveNavigationMenuItemByTargetRecordId } from '@/navigation-menu-item/hooks/useRemoveNavigationMenuItemByTargetRecordId';
import { useIsFeatureEnabled } from '@/workspace/hooks/useIsFeatureEnabled';
import { useRecoilValue } from 'recoil';
import { FeatureFlagKey } from '~/generated/graphql';
import { capitalize, isDefined } from 'twenty-shared/utils';
import { sleep } from '~/utils/sleep';
@@ -52,6 +55,11 @@ export const useDestroyManyRecords = ({
const { objectMetadataItems } = useObjectMetadataItems();
const { objectPermissionsByObjectMetadataId } = useObjectPermissions();
const { refetchAggregateQueries } = useRefetchAggregateQueries();
const isNavigationMenuItemEnabled = useIsFeatureEnabled(
FeatureFlagKey.IS_NAVIGATION_MENU_ITEM_ENABLED,
);
const { removeNavigationMenuItemsByTargetRecordIds } =
useRemoveNavigationMenuItemByTargetRecordId();
const mutationResponseField = getDestroyManyRecordsMutationResponseField(
objectMetadataItem.namePlural,
@@ -144,6 +152,10 @@ export const useDestroyManyRecords = ({
objectMetadataNamePlural: objectMetadataItem.namePlural,
});
if (isNavigationMenuItemEnabled) {
removeNavigationMenuItemsByTargetRecordIds(recordIdsToDestroy);
}
dispatchObjectRecordOperationBrowserEvent({
objectMetadataItem,
operation: {
@@ -16,7 +16,10 @@ import { useRefetchAggregateQueries } from '@/object-record/hooks/useRefetchAggr
import { useUpsertRecordsInStore } from '@/object-record/record-store/hooks/useUpsertRecordsInStore';
import { type ObjectRecord } from '@/object-record/types/ObjectRecord';
import { dispatchObjectRecordOperationBrowserEvent } from '@/object-record/utils/dispatchObjectRecordOperationBrowserEvent';
import { useRemoveNavigationMenuItemByTargetRecordId } from '@/navigation-menu-item/hooks/useRemoveNavigationMenuItemByTargetRecordId';
import { useIsFeatureEnabled } from '@/workspace/hooks/useIsFeatureEnabled';
import { useCallback } from 'react';
import { FeatureFlagKey } from '~/generated/graphql';
import { isDefined } from 'twenty-shared/utils';
import { sleep } from '~/utils/sleep';
@@ -61,6 +64,11 @@ export const useIncrementalDeleteManyRecords = <T>({
const { objectMetadataItems } = useObjectMetadataItems();
const { objectPermissionsByObjectMetadataId } = useObjectPermissions();
const { refetchAggregateQueries } = useRefetchAggregateQueries();
const isNavigationMenuItemEnabled = useIsFeatureEnabled(
FeatureFlagKey.IS_NAVIGATION_MENU_ITEM_ENABLED,
);
const { removeNavigationMenuItemsByTargetRecordIds } =
useRemoveNavigationMenuItemByTargetRecordId();
const { incrementalFetchAndMutate, progress, isProcessing, updateProgress } =
useIncrementalFetchAndMutateRecords<T>({
@@ -219,11 +227,13 @@ export const useIncrementalDeleteManyRecords = <T>({
const incrementalDeleteManyRecords = async () => {
let totalDeletedCount = 0;
const allDeletedRecordIds: string[] = [];
await incrementalFetchAndMutate(
async ({ recordIds, totalCount, abortSignal }) => {
await deleteManyRecordsBatch(recordIds, abortSignal);
allDeletedRecordIds.push(...recordIds);
totalDeletedCount += recordIds.length;
updateProgress(totalDeletedCount, totalCount);
@@ -234,6 +244,10 @@ export const useIncrementalDeleteManyRecords = <T>({
objectMetadataNamePlural: objectMetadataItem.namePlural,
});
if (isNavigationMenuItemEnabled) {
removeNavigationMenuItemsByTargetRecordIds(allDeletedRecordIds);
}
dispatchObjectRecordOperationBrowserEvent({
objectMetadataItem,
operation: {
@@ -5,12 +5,12 @@ import { useMemo, useState } from 'react';
import { RecoilRoot } from 'recoil';
import { ComponentWithRouterDecorator } from 'twenty-ui/testing';
import { calculateNewPosition } from '@/favorites/utils/calculateNewPosition';
import { PageLayoutTabList } from '@/page-layout/components/PageLayoutTabList';
import { PAGE_LAYOUT_TAB_LIST_DROPPABLE_IDS } from '@/page-layout/components/PageLayoutTabListDroppableIds';
import { PageLayoutTabListEffect } from '@/page-layout/components/PageLayoutTabListEffect';
import { PageLayoutComponentInstanceContext } from '@/page-layout/states/contexts/PageLayoutComponentInstanceContext';
import { type PageLayoutTab } from '@/page-layout/types/PageLayoutTab';
import { calculateNewPosition } from '@/ui/layout/draggable-list/utils/calculateNewPosition';
const StyledContainer = styled.div`
border: 1px solid ${({ theme }) => theme.border.color.strong};
@@ -1,7 +1,6 @@
import { useCommandMenu } from '@/command-menu/hooks/useCommandMenu';
import { useNavigatePageLayoutCommandMenu } from '@/command-menu/pages/page-layout/hooks/useNavigatePageLayoutCommandMenu';
import { CommandMenuPages } from '@/command-menu/types/CommandMenuPages';
import { calculateNewPosition } from '@/favorites/utils/calculateNewPosition';
import { PageLayoutComponentInstanceContext } from '@/page-layout/states/contexts/PageLayoutComponentInstanceContext';
import { pageLayoutCurrentLayoutsComponentState } from '@/page-layout/states/pageLayoutCurrentLayoutsComponentState';
import { pageLayoutDraftComponentState } from '@/page-layout/states/pageLayoutDraftComponentState';
@@ -10,6 +9,7 @@ import { type PageLayoutTab } from '@/page-layout/types/PageLayoutTab';
import { generateDuplicatedTimestamps } from '@/page-layout/utils/generateDuplicatedTimestamps';
import { getTabListInstanceIdFromPageLayoutId } from '@/page-layout/utils/getTabListInstanceIdFromPageLayoutId';
import { sortTabsByPosition } from '@/page-layout/utils/sortTabsByPosition';
import { calculateNewPosition } from '@/ui/layout/draggable-list/utils/calculateNewPosition';
import { activeTabIdComponentState } from '@/ui/layout/tab-list/states/activeTabIdComponentState';
import { useAvailableComponentInstanceIdOrThrow } from '@/ui/utilities/state/component-state/hooks/useAvailableComponentInstanceIdOrThrow';
import { useRecoilComponentCallbackState } from '@/ui/utilities/state/component-state/hooks/useRecoilComponentCallbackState';
@@ -1,7 +1,7 @@
import { calculateNewPosition } from '@/favorites/utils/calculateNewPosition';
import { PageLayoutComponentInstanceContext } from '@/page-layout/states/contexts/PageLayoutComponentInstanceContext';
import { pageLayoutDraftComponentState } from '@/page-layout/states/pageLayoutDraftComponentState';
import { sortTabsByPosition } from '@/page-layout/utils/sortTabsByPosition';
import { calculateNewPosition } from '@/ui/layout/draggable-list/utils/calculateNewPosition';
import { useAvailableComponentInstanceIdOrThrow } from '@/ui/utilities/state/component-state/hooks/useAvailableComponentInstanceIdOrThrow';
import { useRecoilComponentCallbackState } from '@/ui/utilities/state/component-state/hooks/useRecoilComponentCallbackState';
import { useRecoilCallback } from 'recoil';
@@ -1,8 +1,8 @@
import { calculateNewPosition } from '@/favorites/utils/calculateNewPosition';
import { PAGE_LAYOUT_TAB_LIST_DROPPABLE_IDS } from '@/page-layout/components/PageLayoutTabListDroppableIds';
import { useCurrentPageLayout } from '@/page-layout/hooks/useCurrentPageLayout';
import { usePageLayoutDraftState } from '@/page-layout/hooks/usePageLayoutDraftState';
import { PageLayoutComponentInstanceContext } from '@/page-layout/states/contexts/PageLayoutComponentInstanceContext';
import { calculateNewPosition } from '@/ui/layout/draggable-list/utils/calculateNewPosition';
import { useAvailableComponentInstanceIdOrThrow } from '@/ui/utilities/state/component-state/hooks/useAvailableComponentInstanceIdOrThrow';
import { type DropResult } from '@hello-pangea/dnd';
import { useCallback } from 'react';
@@ -1,10 +1,12 @@
import { PrefetchRunFavoriteQueriesEffect } from '@/prefetch/components/PrefetchRunFavoriteQueriesEffect';
import { PrefetchRunNavigationMenuItemQueriesEffect } from '@/prefetch/components/PrefetchRunNavigationMenuItemQueriesEffect';
import React from 'react';
export const PrefetchDataProvider = ({ children }: React.PropsWithChildren) => {
return (
<>
<PrefetchRunFavoriteQueriesEffect />
<PrefetchRunNavigationMenuItemQueriesEffect />
{children}
</>
);
@@ -16,11 +16,16 @@ import { prefetchFavoritesState } from '@/prefetch/states/prefetchFavoritesState
import { prefetchIsLoadedFamilyState } from '@/prefetch/states/prefetchIsLoadedFamilyState';
import { PrefetchKey } from '@/prefetch/types/PrefetchKey';
import { useShowAuthModal } from '@/ui/layout/hooks/useShowAuthModal';
import { useIsFeatureEnabled } from '@/workspace/hooks/useIsFeatureEnabled';
import { isDefined } from 'twenty-shared/utils';
import { WorkspaceActivationStatus } from 'twenty-shared/workspace';
import { FeatureFlagKey } from '~/generated/graphql';
import { isDeeplyEqual } from '~/utils/isDeeplyEqual';
export const PrefetchRunFavoriteQueriesEffect = () => {
const isNavigationMenuItemEnabled = useIsFeatureEnabled(
FeatureFlagKey.IS_NAVIGATION_MENU_ITEM_ENABLED,
);
const showAuthModal = useShowAuthModal();
const isSettingsPage = useIsSettingsPage();
const currentWorkspace = useRecoilValue(currentWorkspaceState);
@@ -55,14 +60,22 @@ export const PrefetchRunFavoriteQueriesEffect = () => {
objectNameSingular: CoreObjectNameSingular.Favorite,
filter: findAllFavoritesOperationSignature.variables.filter,
recordGqlFields: findAllFavoritesOperationSignature.fields,
skip: showAuthModal || isSettingsPage || !isWorkspaceActive,
skip:
showAuthModal ||
isSettingsPage ||
!isWorkspaceActive ||
isNavigationMenuItemEnabled,
});
const { records: favoriteFolders } = useFindManyRecords({
objectNameSingular: CoreObjectNameSingular.FavoriteFolder,
filter: findAllFavoriteFoldersOperationSignature.variables.filter,
recordGqlFields: findAllFavoriteFoldersOperationSignature.fields,
skip: showAuthModal || isSettingsPage || !isWorkspaceActive,
skip:
showAuthModal ||
isSettingsPage ||
!isWorkspaceActive ||
isNavigationMenuItemEnabled,
});
const setPrefetchFavoritesState = useRecoilCallback(
@@ -0,0 +1,71 @@
import { useEffect } from 'react';
import { useRecoilCallback, useRecoilValue, useSetRecoilState } from 'recoil';
import { currentWorkspaceState } from '@/auth/states/currentWorkspaceState';
import { useIsSettingsPage } from '@/navigation/hooks/useIsSettingsPage';
import { prefetchIsLoadedFamilyState } from '@/prefetch/states/prefetchIsLoadedFamilyState';
import { prefetchNavigationMenuItemsState } from '@/prefetch/states/prefetchNavigationMenuItemsState';
import { PrefetchKey } from '@/prefetch/types/PrefetchKey';
import { useShowAuthModal } from '@/ui/layout/hooks/useShowAuthModal';
import { useIsFeatureEnabled } from '@/workspace/hooks/useIsFeatureEnabled';
import { isDefined } from 'twenty-shared/utils';
import { WorkspaceActivationStatus } from 'twenty-shared/workspace';
import {
useFindManyNavigationMenuItemsQuery,
type NavigationMenuItem,
} from '~/generated-metadata/graphql';
import { FeatureFlagKey } from '~/generated/graphql';
import { isDeeplyEqual } from '~/utils/isDeeplyEqual';
export const PrefetchRunNavigationMenuItemQueriesEffect = () => {
const isNavigationMenuItemEnabled = useIsFeatureEnabled(
FeatureFlagKey.IS_NAVIGATION_MENU_ITEM_ENABLED,
);
const showAuthModal = useShowAuthModal();
const isSettingsPage = useIsSettingsPage();
const currentWorkspace = useRecoilValue(currentWorkspaceState);
const isWorkspaceActive =
currentWorkspace?.activationStatus === WorkspaceActivationStatus.ACTIVE;
const setIsPrefetchNavigationMenuItemsLoaded = useSetRecoilState(
prefetchIsLoadedFamilyState(PrefetchKey.AllNavigationMenuItems),
);
const { data, loading } = useFindManyNavigationMenuItemsQuery({
skip:
showAuthModal ||
isSettingsPage ||
!isWorkspaceActive ||
!isNavigationMenuItemEnabled,
fetchPolicy: 'cache-and-network',
});
const setPrefetchNavigationMenuItemsState = useRecoilCallback(
({ set, snapshot }) =>
(navigationMenuItems: NavigationMenuItem[]) => {
const existingNavigationMenuItems = snapshot
.getLoadable(prefetchNavigationMenuItemsState)
.getValue();
if (!isDeeplyEqual(existingNavigationMenuItems, navigationMenuItems)) {
set(prefetchNavigationMenuItemsState, navigationMenuItems);
}
},
[],
);
useEffect(() => {
if (!loading && isDefined(data?.navigationMenuItems)) {
setPrefetchNavigationMenuItemsState(data.navigationMenuItems);
setIsPrefetchNavigationMenuItemsLoaded(true);
}
}, [
data,
loading,
setPrefetchNavigationMenuItemsState,
setIsPrefetchNavigationMenuItemsLoaded,
]);
return <></>;
};
@@ -0,0 +1,9 @@
import { createState } from 'twenty-ui/utilities';
import { type NavigationMenuItem } from '~/generated-metadata/graphql';
export const prefetchNavigationMenuItemsState = createState<
NavigationMenuItem[]
>({
key: 'prefetchNavigationMenuItemsState',
defaultValue: [],
});
@@ -1,4 +1,5 @@
export enum PrefetchKey {
AllFavorites = 'ALL_FAVORITES',
AllFavoritesFolders = 'ALL_FAVORITES_FOLDERS',
AllNavigationMenuItems = 'ALL_NAVIGATION_MENU_ITEMS',
}
@@ -0,0 +1,4 @@
export const FOLDER_DROPPABLE_IDS = {
FOLDER_PREFIX: 'folder-',
FOLDER_HEADER_PREFIX: 'folder-header-',
} as const;
@@ -0,0 +1,48 @@
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',
);
};
@@ -0,0 +1,6 @@
import { atom } from 'recoil';
export const currentNavigationMenuItemFolderIdState = atom<string | null>({
key: 'currentNavigationMenuItemFolderIdState',
default: null,
});
@@ -1,5 +1,7 @@
import { useCreateFavorite } from '@/favorites/hooks/useCreateFavorite';
import { useFavorites } from '@/favorites/hooks/useFavorites';
import { useCreateNavigationMenuItem } from '@/navigation-menu-item/hooks/useCreateNavigationMenuItem';
import { usePrefetchedNavigationMenuItemsData } from '@/navigation-menu-item/hooks/usePrefetchedNavigationMenuItemsData';
import { useHasPermissionFlag } from '@/settings/roles/hooks/useHasPermissionFlag';
import { DropdownContent } from '@/ui/layout/dropdown/components/DropdownContent';
import { DropdownMenuItemsContainer } from '@/ui/layout/dropdown/components/DropdownMenuItemsContainer';
@@ -10,6 +12,7 @@ import { type View } from '@/views/types/View';
import { useDestroyViewFromCurrentState } from '@/views/view-picker/hooks/useDestroyViewFromCurrentState';
import { useViewPickerMode } from '@/views/view-picker/hooks/useViewPickerMode';
import { viewPickerReferenceViewIdComponentState } from '@/views/view-picker/states/viewPickerReferenceViewIdComponentState';
import { useIsFeatureEnabled } from '@/workspace/hooks/useIsFeatureEnabled';
import { useLingui } from '@lingui/react/macro';
import {
IconHeart,
@@ -19,7 +22,7 @@ import {
useIcons,
} from 'twenty-ui/display';
import { MenuItem } from 'twenty-ui/navigation';
import { ViewVisibility } from '~/generated-metadata/graphql';
import { FeatureFlagKey, ViewVisibility } from '~/generated-metadata/graphql';
import { PermissionFlagType } from '~/generated/graphql';
type ViewPickerOptionDropdownProps = {
@@ -57,16 +60,28 @@ export const ViewPickerOptionDropdown = ({
const { sortedFavorites: favorites } = useFavorites();
const { createFavorite } = useCreateFavorite();
const isNavigationMenuItemEnabled = useIsFeatureEnabled(
FeatureFlagKey.IS_NAVIGATION_MENU_ITEM_ENABLED,
);
const { createNavigationMenuItem } = useCreateNavigationMenuItem();
const { navigationMenuItems, currentWorkspaceMemberId } =
usePrefetchedNavigationMenuItemsData();
// Users with VIEWS permission can edit all views
// Users without VIEWS permission can only edit unlisted views (which are always their own, filtered by backend)
const canEditView =
hasViewsPermission || view.visibility === ViewVisibility.UNLISTED;
const isFavorite = favorites.some(
(favorite) =>
favorite.recordId === view.id && favorite.forWorkspaceMemberId,
);
const isFavorite = isNavigationMenuItemEnabled
? navigationMenuItems.some(
(item) =>
item.viewId === view.id &&
item.userWorkspaceId === currentWorkspaceMemberId,
)
: favorites.some(
(favorite) =>
favorite.recordId === view.id && favorite.forWorkspaceMemberId,
);
const handleDelete = () => {
setViewPickerReferenceViewId(view.id);
@@ -76,7 +91,11 @@ export const ViewPickerOptionDropdown = ({
const handleAddToFavorites = () => {
if (!isFavorite) {
createFavorite(view, 'view');
if (isNavigationMenuItemEnabled) {
createNavigationMenuItem(view, 'view');
} else {
createFavorite(view, 'view');
}
} else {
setViewPickerReferenceViewId(view.id);
setViewPickerMode('favorite-folders-picker');