Integrate NavigationMenuItem with feature flag support (#17268)

## Implement Navigation Menu Items Frontend

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

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

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

<!-- CURSOR_SUMMARY -->
---

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

---------

Co-authored-by: Félix Malfait <felix.malfait@gmail.com>
Co-authored-by: Aman Raj <92664006+araj00@users.noreply.github.com>
Co-authored-by: Félix Malfait <felix@twenty.com>
Co-authored-by: Paul Rastoin <45004772+prastoin@users.noreply.github.com>
Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
Co-authored-by: github-actions <github-actions@twenty.com>
This commit is contained in:
Abdul Rahman
2026-01-27 16:12:27 +05:30
committed by GitHub
parent c79f633f17
commit 2a8f834377
107 changed files with 4518 additions and 92 deletions
@@ -2,16 +2,24 @@ import { Action } from '@/action-menu/actions/components/Action';
import { useSelectedRecordIdOrThrow } from '@/action-menu/actions/record-actions/single-record/hooks/useSelectedRecordIdOrThrow';
import { useContextStoreObjectMetadataItemOrThrow } from '@/context-store/hooks/useContextStoreObjectMetadataItemOrThrow';
import { useCreateFavorite } from '@/favorites/hooks/useCreateFavorite';
import { useCreateNavigationMenuItem } from '@/navigation-menu-item/hooks/useCreateNavigationMenuItem';
import { recordStoreFamilyState } from '@/object-record/record-store/states/recordStoreFamilyState';
import { useIsFeatureEnabled } from '@/workspace/hooks/useIsFeatureEnabled';
import { useRecoilValue } from 'recoil';
import { isDefined } from 'twenty-shared/utils';
import { FeatureFlagKey } from '~/generated/graphql';
export const AddToFavoritesSingleRecordAction = () => {
const { objectMetadataItem } = useContextStoreObjectMetadataItemOrThrow();
const recordId = useSelectedRecordIdOrThrow();
const isNavigationMenuItemEnabled = useIsFeatureEnabled(
FeatureFlagKey.IS_NAVIGATION_MENU_ITEM_ENABLED,
);
const { createFavorite } = useCreateFavorite();
const { createNavigationMenuItem } = useCreateNavigationMenuItem();
const selectedRecord = useRecoilValue(recordStoreFamilyState(recordId));
@@ -20,7 +28,11 @@ export const AddToFavoritesSingleRecordAction = () => {
return;
}
createFavorite(selectedRecord, objectMetadataItem.nameSingular);
if (isNavigationMenuItemEnabled) {
createNavigationMenuItem(selectedRecord, objectMetadataItem.nameSingular);
} else {
createFavorite(selectedRecord, objectMetadataItem.nameSingular);
}
};
return <Action onClick={handleClick} />;
@@ -2,11 +2,15 @@ import { Action } from '@/action-menu/actions/components/Action';
import { useSelectedRecordIdOrThrow } from '@/action-menu/actions/record-actions/single-record/hooks/useSelectedRecordIdOrThrow';
import { useDeleteFavorite } from '@/favorites/hooks/useDeleteFavorite';
import { useFavorites } from '@/favorites/hooks/useFavorites';
import { usePrefetchedNavigationMenuItemsData } from '@/navigation-menu-item/hooks/usePrefetchedNavigationMenuItemsData';
import { useRemoveNavigationMenuItemByTargetRecordId } from '@/navigation-menu-item/hooks/useRemoveNavigationMenuItemByTargetRecordId';
import { useDeleteOneRecord } from '@/object-record/hooks/useDeleteOneRecord';
import { useRemoveSelectedRecordsFromRecordBoard } from '@/object-record/record-board/hooks/useRemoveSelectedRecordsFromRecordBoard';
import { useRecordIndexIdFromCurrentContextStore } from '@/object-record/record-index/hooks/useRecordIndexIdFromCurrentContextStore';
import { useResetTableRowSelection } from '@/object-record/record-table/hooks/internal/useResetTableRowSelection';
import { useIsFeatureEnabled } from '@/workspace/hooks/useIsFeatureEnabled';
import { isDefined } from 'twenty-shared/utils';
import { FeatureFlagKey } from '~/generated/graphql';
export const DeleteSingleRecordAction = () => {
const { recordIndexId, objectMetadataItem } =
@@ -25,6 +29,13 @@ export const DeleteSingleRecordAction = () => {
const { sortedFavorites: favorites } = useFavorites();
const { deleteFavorite } = useDeleteFavorite();
const isNavigationMenuItemEnabled = useIsFeatureEnabled(
FeatureFlagKey.IS_NAVIGATION_MENU_ITEM_ENABLED,
);
const { navigationMenuItems, workspaceNavigationMenuItems } =
usePrefetchedNavigationMenuItemsData();
const { removeNavigationMenuItemsByTargetRecordIds } =
useRemoveNavigationMenuItemByTargetRecordId();
const handleDeleteClick = async () => {
removeSelectedRecordsFromRecordBoard();
@@ -39,6 +50,17 @@ export const DeleteSingleRecordAction = () => {
deleteFavorite(foundFavorite.id);
}
if (isNavigationMenuItemEnabled) {
const foundNavigationMenuItem = [
...navigationMenuItems,
...workspaceNavigationMenuItems,
].find((item) => item.targetRecordId === recordId);
if (isDefined(foundNavigationMenuItem)) {
removeNavigationMenuItemsByTargetRecordIds([recordId]);
}
}
await deleteOneRecord(recordId);
};
@@ -1,21 +1,50 @@
import { Action } from '@/action-menu/actions/components/Action';
import { useSelectedRecordIdOrThrow } from '@/action-menu/actions/record-actions/single-record/hooks/useSelectedRecordIdOrThrow';
import { useContextStoreObjectMetadataItemOrThrow } from '@/context-store/hooks/useContextStoreObjectMetadataItemOrThrow';
import { useDeleteFavorite } from '@/favorites/hooks/useDeleteFavorite';
import { useFavorites } from '@/favorites/hooks/useFavorites';
import { useDeleteNavigationMenuItem } from '@/navigation-menu-item/hooks/useDeleteNavigationMenuItem';
import { usePrefetchedNavigationMenuItemsData } from '@/navigation-menu-item/hooks/usePrefetchedNavigationMenuItemsData';
import { useIsFeatureEnabled } from '@/workspace/hooks/useIsFeatureEnabled';
import { isDefined } from 'twenty-shared/utils';
import { FeatureFlagKey } from '~/generated/graphql';
export const RemoveFromFavoritesSingleRecordAction = () => {
const recordId = useSelectedRecordIdOrThrow();
const { objectMetadataItem } = useContextStoreObjectMetadataItemOrThrow();
const { sortedFavorites: favorites } = useFavorites();
const { navigationMenuItems, workspaceNavigationMenuItems } =
usePrefetchedNavigationMenuItemsData();
const isNavigationMenuItemEnabled = useIsFeatureEnabled(
FeatureFlagKey.IS_NAVIGATION_MENU_ITEM_ENABLED,
);
const { deleteFavorite } = useDeleteFavorite();
const { deleteNavigationMenuItem } = useDeleteNavigationMenuItem();
const foundFavorite = favorites?.find(
(favorite) => favorite.recordId === recordId,
);
const foundNavigationMenuItem = isNavigationMenuItemEnabled
? [...navigationMenuItems, ...workspaceNavigationMenuItems].find(
(item) =>
item.targetRecordId === recordId &&
item.targetObjectMetadataId === objectMetadataItem.id,
)
: undefined;
const handleClick = () => {
if (isNavigationMenuItemEnabled) {
if (!isDefined(foundNavigationMenuItem)) {
return;
}
deleteNavigationMenuItem(foundNavigationMenuItem.id);
return;
}
if (!isDefined(foundFavorite)) {
return;
}
@@ -8,14 +8,18 @@ import { contextStoreNumberOfSelectedRecordsComponentState } from '@/context-sto
import { contextStoreTargetedRecordsRuleComponentState } from '@/context-store/states/contextStoreTargetedRecordsRuleComponentState';
import { ContextStoreViewType } from '@/context-store/types/ContextStoreViewType';
import { useFavorites } from '@/favorites/hooks/useFavorites';
import { usePrefetchedNavigationMenuItemsData } from '@/navigation-menu-item/hooks/usePrefetchedNavigationMenuItemsData';
import { CoreObjectNameSingular } from '@/object-metadata/types/CoreObjectNameSingular';
import { type ObjectMetadataItem } from '@/object-metadata/types/ObjectMetadataItem';
import { useObjectPermissionsForObject } from '@/object-record/hooks/useObjectPermissionsForObject';
import { hasAnySoftDeleteFilterOnViewComponentSelector } from '@/object-record/record-filter/states/hasAnySoftDeleteFilterOnView';
import { recordStoreFamilyState } from '@/object-record/record-store/states/recordStoreFamilyState';
import { useRecoilComponentValue } from '@/ui/utilities/state/component-state/hooks/useRecoilComponentValue';
import { useContext } from 'react';
import { useIsFeatureEnabled } from '@/workspace/hooks/useIsFeatureEnabled';
import { useContext, useMemo } from 'react';
import { useRecoilCallback, useRecoilValue } from 'recoil';
import { isDefined } from 'twenty-shared/utils';
import { FeatureFlagKey } from '~/generated/graphql';
export const useShouldActionBeRegisteredParams = ({
objectMetadataItem,
@@ -23,6 +27,10 @@ export const useShouldActionBeRegisteredParams = ({
objectMetadataItem?: ObjectMetadataItem;
}): ShouldBeRegisteredFunctionParams => {
const { sortedFavorites: favorites } = useFavorites();
const { navigationMenuItems } = usePrefetchedNavigationMenuItemsData();
const isNavigationMenuItemEnabled = useIsFeatureEnabled(
FeatureFlagKey.IS_NAVIGATION_MENU_ITEM_ENABLED,
);
const contextStoreTargetedRecordsRule = useRecoilComponentValue(
contextStoreTargetedRecordsRuleComponentState,
@@ -33,11 +41,31 @@ export const useShouldActionBeRegisteredParams = ({
? contextStoreTargetedRecordsRule.selectedRecordIds[0]
: undefined;
const foundFavorite = favorites?.find(
(favorite) => favorite.recordId === recordId,
);
const isFavorite = useMemo(() => {
if (!isDefined(recordId)) {
return false;
}
const isFavorite = !!foundFavorite;
if (isNavigationMenuItemEnabled && isDefined(objectMetadataItem)) {
const foundNavigationMenuItem = navigationMenuItems?.find(
(item) =>
item.targetRecordId === recordId &&
item.targetObjectMetadataId === objectMetadataItem.id,
);
return !!foundNavigationMenuItem;
}
const foundFavorite = favorites?.find(
(favorite) => favorite.recordId === recordId,
);
return !!foundFavorite;
}, [
recordId,
isNavigationMenuItemEnabled,
objectMetadataItem,
navigationMenuItems,
favorites,
]);
const selectedRecord =
useRecoilValue(recordStoreFamilyState(recordId ?? '')) || undefined;