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
@@ -16,7 +16,10 @@ import { useUpsertRecordsInStore } from '@/object-record/record-store/hooks/useU
import { type ObjectRecord } from '@/object-record/types/ObjectRecord';
import { dispatchObjectRecordOperationBrowserEvent } from '@/object-record/utils/dispatchObjectRecordOperationBrowserEvent';
import { getDeleteManyRecordsMutationResponseField } from '@/object-record/utils/getDeleteManyRecordsMutationResponseField';
import { useRemoveNavigationMenuItemByTargetRecordId } from '@/navigation-menu-item/hooks/useRemoveNavigationMenuItemByTargetRecordId';
import { useIsFeatureEnabled } from '@/workspace/hooks/useIsFeatureEnabled';
import { useRecoilValue } from 'recoil';
import { FeatureFlagKey } from '~/generated/graphql';
import { isDefined } from 'twenty-shared/utils';
import { sleep } from '~/utils/sleep';
@@ -57,6 +60,11 @@ export const useDeleteManyRecords = ({
const { objectMetadataItems } = useObjectMetadataItems();
const { objectPermissionsByObjectMetadataId } = useObjectPermissions();
const { refetchAggregateQueries } = useRefetchAggregateQueries();
const isNavigationMenuItemEnabled = useIsFeatureEnabled(
FeatureFlagKey.IS_NAVIGATION_MENU_ITEM_ENABLED,
);
const { removeNavigationMenuItemsByTargetRecordIds } =
useRemoveNavigationMenuItemByTargetRecordId();
const mutationResponseField = getDeleteManyRecordsMutationResponseField(
objectMetadataItem.namePlural,
@@ -223,6 +231,10 @@ export const useDeleteManyRecords = ({
objectMetadataNamePlural: objectMetadataItem.namePlural,
});
if (isNavigationMenuItemEnabled) {
removeNavigationMenuItemsByTargetRecordIds(recordIdsToDelete);
}
dispatchObjectRecordOperationBrowserEvent({
objectMetadataItem,
operation: {
@@ -13,7 +13,10 @@ import { useUpsertRecordsInStore } from '@/object-record/record-store/hooks/useU
import { type ObjectRecord } from '@/object-record/types/ObjectRecord';
import { dispatchObjectRecordOperationBrowserEvent } from '@/object-record/utils/dispatchObjectRecordOperationBrowserEvent';
import { getDestroyManyRecordsMutationResponseField } from '@/object-record/utils/getDestroyManyRecordsMutationResponseField';
import { useRemoveNavigationMenuItemByTargetRecordId } from '@/navigation-menu-item/hooks/useRemoveNavigationMenuItemByTargetRecordId';
import { useIsFeatureEnabled } from '@/workspace/hooks/useIsFeatureEnabled';
import { useRecoilValue } from 'recoil';
import { FeatureFlagKey } from '~/generated/graphql';
import { capitalize, isDefined } from 'twenty-shared/utils';
import { sleep } from '~/utils/sleep';
@@ -52,6 +55,11 @@ export const useDestroyManyRecords = ({
const { objectMetadataItems } = useObjectMetadataItems();
const { objectPermissionsByObjectMetadataId } = useObjectPermissions();
const { refetchAggregateQueries } = useRefetchAggregateQueries();
const isNavigationMenuItemEnabled = useIsFeatureEnabled(
FeatureFlagKey.IS_NAVIGATION_MENU_ITEM_ENABLED,
);
const { removeNavigationMenuItemsByTargetRecordIds } =
useRemoveNavigationMenuItemByTargetRecordId();
const mutationResponseField = getDestroyManyRecordsMutationResponseField(
objectMetadataItem.namePlural,
@@ -144,6 +152,10 @@ export const useDestroyManyRecords = ({
objectMetadataNamePlural: objectMetadataItem.namePlural,
});
if (isNavigationMenuItemEnabled) {
removeNavigationMenuItemsByTargetRecordIds(recordIdsToDestroy);
}
dispatchObjectRecordOperationBrowserEvent({
objectMetadataItem,
operation: {
@@ -16,7 +16,10 @@ import { useRefetchAggregateQueries } from '@/object-record/hooks/useRefetchAggr
import { useUpsertRecordsInStore } from '@/object-record/record-store/hooks/useUpsertRecordsInStore';
import { type ObjectRecord } from '@/object-record/types/ObjectRecord';
import { dispatchObjectRecordOperationBrowserEvent } from '@/object-record/utils/dispatchObjectRecordOperationBrowserEvent';
import { useRemoveNavigationMenuItemByTargetRecordId } from '@/navigation-menu-item/hooks/useRemoveNavigationMenuItemByTargetRecordId';
import { useIsFeatureEnabled } from '@/workspace/hooks/useIsFeatureEnabled';
import { useCallback } from 'react';
import { FeatureFlagKey } from '~/generated/graphql';
import { isDefined } from 'twenty-shared/utils';
import { sleep } from '~/utils/sleep';
@@ -61,6 +64,11 @@ export const useIncrementalDeleteManyRecords = <T>({
const { objectMetadataItems } = useObjectMetadataItems();
const { objectPermissionsByObjectMetadataId } = useObjectPermissions();
const { refetchAggregateQueries } = useRefetchAggregateQueries();
const isNavigationMenuItemEnabled = useIsFeatureEnabled(
FeatureFlagKey.IS_NAVIGATION_MENU_ITEM_ENABLED,
);
const { removeNavigationMenuItemsByTargetRecordIds } =
useRemoveNavigationMenuItemByTargetRecordId();
const { incrementalFetchAndMutate, progress, isProcessing, updateProgress } =
useIncrementalFetchAndMutateRecords<T>({
@@ -219,11 +227,13 @@ export const useIncrementalDeleteManyRecords = <T>({
const incrementalDeleteManyRecords = async () => {
let totalDeletedCount = 0;
const allDeletedRecordIds: string[] = [];
await incrementalFetchAndMutate(
async ({ recordIds, totalCount, abortSignal }) => {
await deleteManyRecordsBatch(recordIds, abortSignal);
allDeletedRecordIds.push(...recordIds);
totalDeletedCount += recordIds.length;
updateProgress(totalDeletedCount, totalCount);
@@ -234,6 +244,10 @@ export const useIncrementalDeleteManyRecords = <T>({
objectMetadataNamePlural: objectMetadataItem.namePlural,
});
if (isNavigationMenuItemEnabled) {
removeNavigationMenuItemsByTargetRecordIds(allDeletedRecordIds);
}
dispatchObjectRecordOperationBrowserEvent({
objectMetadataItem,
operation: {