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:
+13
-1
@@ -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} />;
|
||||
|
||||
+22
@@ -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);
|
||||
};
|
||||
|
||||
|
||||
+29
@@ -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;
|
||||
}
|
||||
|
||||
+33
-5
@@ -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(
|
||||
|
||||
+37
@@ -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);
|
||||
});
|
||||
});
|
||||
+25
-9
@@ -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',
|
||||
);
|
||||
};
|
||||
+84
@@ -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>
|
||||
);
|
||||
};
|
||||
+17
@@ -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 />;
|
||||
};
|
||||
+289
@@ -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,
|
||||
)}
|
||||
</>
|
||||
);
|
||||
};
|
||||
+92
@@ -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>
|
||||
);
|
||||
};
|
||||
+59
@@ -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>
|
||||
);
|
||||
};
|
||||
+39
@@ -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>
|
||||
);
|
||||
};
|
||||
+70
@@ -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>
|
||||
);
|
||||
};
|
||||
+82
@@ -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>
|
||||
</>
|
||||
);
|
||||
};
|
||||
+48
@@ -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;
|
||||
};
|
||||
+63
@@ -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>
|
||||
}
|
||||
/>
|
||||
);
|
||||
};
|
||||
+91
@@ -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}
|
||||
/>
|
||||
))}
|
||||
</>
|
||||
);
|
||||
};
|
||||
+35
@@ -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}
|
||||
/>
|
||||
);
|
||||
};
|
||||
+46
@@ -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>
|
||||
);
|
||||
};
|
||||
+26
@@ -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}
|
||||
/>
|
||||
);
|
||||
};
|
||||
+17
@@ -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 />;
|
||||
};
|
||||
+2
@@ -0,0 +1,2 @@
|
||||
export const ORPHAN_NAVIGATION_MENU_ITEMS_DROPPABLE_ID =
|
||||
'orphan-navigation-menu-items';
|
||||
+2
@@ -0,0 +1,2 @@
|
||||
export const NAVIGATION_MENU_ITEM_FOLDER_DELETE_MODAL_ID =
|
||||
'navigation-menu-item-folder-delete-modal';
|
||||
+10
@@ -0,0 +1,10 @@
|
||||
import { createContext } from 'react';
|
||||
|
||||
type NavigationMenuItemDragContextType = {
|
||||
isDragging: boolean;
|
||||
};
|
||||
|
||||
export const NavigationMenuItemDragContext =
|
||||
createContext<NavigationMenuItemDragContextType>({
|
||||
isDragging: false,
|
||||
});
|
||||
+17
@@ -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
|
||||
}
|
||||
`;
|
||||
+15
@@ -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
|
||||
}
|
||||
}
|
||||
`;
|
||||
+11
@@ -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
|
||||
}
|
||||
}
|
||||
`;
|
||||
+11
@@ -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
|
||||
}
|
||||
}
|
||||
`;
|
||||
+11
@@ -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
|
||||
}
|
||||
}
|
||||
`;
|
||||
+11
@@ -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
|
||||
}
|
||||
}
|
||||
`;
|
||||
+11
@@ -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
|
||||
}
|
||||
}
|
||||
`;
|
||||
+85
@@ -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 };
|
||||
};
|
||||
+52
@@ -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 };
|
||||
};
|
||||
+16
@@ -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 };
|
||||
};
|
||||
+15
@@ -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,
|
||||
};
|
||||
};
|
||||
+143
@@ -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 };
|
||||
};
|
||||
+111
@@ -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 };
|
||||
};
|
||||
+34
@@ -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,
|
||||
};
|
||||
};
|
||||
+48
@@ -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,
|
||||
};
|
||||
};
|
||||
+23
@@ -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,
|
||||
};
|
||||
};
|
||||
+109
@@ -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,
|
||||
};
|
||||
};
|
||||
+28
@@ -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 };
|
||||
};
|
||||
+60
@@ -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,
|
||||
};
|
||||
};
|
||||
+6
@@ -0,0 +1,6 @@
|
||||
import { atom } from 'recoil';
|
||||
|
||||
export const isNavigationMenuItemFolderCreatingState = atom<boolean>({
|
||||
key: 'isNavigationMenuItemFolderCreatingState',
|
||||
default: false,
|
||||
});
|
||||
+6
@@ -0,0 +1,6 @@
|
||||
import { atom } from 'recoil';
|
||||
|
||||
export const openNavigationMenuItemFolderIdsState = atom<string[]>({
|
||||
key: 'openNavigationMenuItemFolderIdsState',
|
||||
default: [],
|
||||
});
|
||||
+154
@@ -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');
|
||||
});
|
||||
});
|
||||
+31
@@ -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();
|
||||
});
|
||||
});
|
||||
+88
@@ -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);
|
||||
});
|
||||
});
|
||||
+364
@@ -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);
|
||||
});
|
||||
});
|
||||
+31
@@ -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,
|
||||
};
|
||||
};
|
||||
+23
@@ -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;
|
||||
};
|
||||
+14
@@ -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;
|
||||
};
|
||||
+51
@@ -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,
|
||||
};
|
||||
};
|
||||
+96
@@ -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 />
|
||||
|
||||
+23
-4
@@ -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>
|
||||
);
|
||||
|
||||
+15
-4
@@ -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: {
|
||||
|
||||
+14
@@ -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: {
|
||||
|
||||
+1
-1
@@ -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}
|
||||
</>
|
||||
);
|
||||
|
||||
+15
-2
@@ -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(
|
||||
|
||||
+71
@@ -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;
|
||||
+48
@@ -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',
|
||||
);
|
||||
};
|
||||
+6
@@ -0,0 +1,6 @@
|
||||
import { atom } from 'recoil';
|
||||
|
||||
export const currentNavigationMenuItemFolderIdState = atom<string | null>({
|
||||
key: 'currentNavigationMenuItemFolderIdState',
|
||||
default: null,
|
||||
});
|
||||
+25
-6
@@ -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');
|
||||
|
||||
Reference in New Issue
Block a user