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
@@ -81,6 +81,11 @@ export const seedFeatureFlags = async ({
workspaceId: workspaceId,
value: true,
},
{
key: FeatureFlagKey.IS_NAVIGATION_MENU_ITEM_ENABLED,
workspaceId: workspaceId,
value: true,
},
{
key: FeatureFlagKey.IS_FILES_FIELD_ENABLED,
workspaceId: workspaceId,
@@ -0,0 +1,46 @@
import { STANDARD_OBJECTS } from './standard-object.constant';
export const STANDARD_NAVIGATION_MENU_ITEMS = {
allCompanies: {
universalIdentifier: '20202020-b001-4b01-8b01-c0aba11c0001',
viewUniversalIdentifier:
STANDARD_OBJECTS.company.views.allCompanies.universalIdentifier,
position: 0,
},
allDashboards: {
universalIdentifier: '20202020-b002-4b02-8b02-c0aba11c0002',
viewUniversalIdentifier:
STANDARD_OBJECTS.dashboard.views.allDashboards.universalIdentifier,
position: 1,
},
allNotes: {
universalIdentifier: '20202020-b003-4b03-8b03-c0aba11c0003',
viewUniversalIdentifier:
STANDARD_OBJECTS.note.views.allNotes.universalIdentifier,
position: 2,
},
allOpportunities: {
universalIdentifier: '20202020-b004-4b04-8b04-c0aba11c0004',
viewUniversalIdentifier:
STANDARD_OBJECTS.opportunity.views.allOpportunities.universalIdentifier,
position: 3,
},
allPeople: {
universalIdentifier: '20202020-b005-4b05-8b05-c0aba11c0005',
viewUniversalIdentifier:
STANDARD_OBJECTS.person.views.allPeople.universalIdentifier,
position: 4,
},
allTasks: {
universalIdentifier: '20202020-b006-4b06-8b06-c0aba11c0006',
viewUniversalIdentifier:
STANDARD_OBJECTS.task.views.allTasks.universalIdentifier,
position: 5,
},
allWorkflows: {
universalIdentifier: '20202020-b007-4b07-8b07-c0aba11c0007',
viewUniversalIdentifier:
STANDARD_OBJECTS.workflow.views.allWorkflows.universalIdentifier,
position: 6,
},
} as const;
@@ -8,6 +8,7 @@ export const TWENTY_STANDARD_ALL_METADATA_NAME = [
'viewFilter',
'viewGroup',
'view',
'navigationMenuItem',
'role',
'agent',
'skill',
@@ -3,10 +3,12 @@ import { Injectable } from '@nestjs/common';
import { isDefined } from 'twenty-shared/utils';
import { ApplicationService } from 'src/engine/core-modules/application/application.service';
import { WorkspaceManyOrAllFlatEntityMapsCacheService } from 'src/engine/metadata-modules/flat-entity/services/workspace-many-or-all-flat-entity-maps-cache.service';
import { MetadataFlatEntity } from 'src/engine/metadata-modules/flat-entity/types/metadata-flat-entity.type';
import { findFlatEntityByUniversalIdentifier } from 'src/engine/metadata-modules/flat-entity/utils/find-flat-entity-by-universal-identifier.util';
import { getMetadataFlatEntityMapsKey } from 'src/engine/metadata-modules/flat-entity/utils/get-metadata-flat-entity-maps-key.util';
import { getSubFlatEntityMapsByApplicationIdOrThrow } from 'src/engine/metadata-modules/flat-entity/utils/get-sub-flat-entity-maps-by-application-id-or-throw.util';
import { fromCreateNavigationMenuItemInputToFlatNavigationMenuItemToCreate } from 'src/engine/metadata-modules/flat-navigation-menu-item/utils/from-create-navigation-menu-item-input-to-flat-navigation-menu-item-to-create.util';
import { FlatView } from 'src/engine/metadata-modules/flat-view/types/flat-view.type';
import { GlobalWorkspaceOrmManager } from 'src/engine/twenty-orm/global-workspace-datasource/global-workspace-orm.manager';
import { buildSystemAuthContext } from 'src/engine/twenty-orm/utils/build-system-auth-context.util';
@@ -26,6 +28,7 @@ export class TwentyStandardApplicationService {
private readonly workspaceMigrationValidateBuildAndRunService: WorkspaceMigrationValidateBuildAndRunService,
private readonly workspaceCacheService: WorkspaceCacheService,
private readonly globalWorkspaceOrmManager: GlobalWorkspaceOrmManager,
private readonly workspaceManyOrAllFlatEntityMapsCacheService: WorkspaceManyOrAllFlatEntityMapsCacheService,
) {}
// Note: To remove and handle natively in validateBuildAndRun after favorite migration to metadata
@@ -58,6 +61,80 @@ export class TwentyStandardApplicationService {
);
}
private async createManyNavigationMenuItem({
flatViews,
workspaceId,
}: {
flatViews: FlatView[];
workspaceId: string;
}) {
const { workspaceCustomFlatApplication } =
await this.applicationService.findWorkspaceTwentyStandardAndCustomApplicationOrThrow(
{ workspaceId },
);
const { flatNavigationMenuItemMaps: existingFlatNavigationMenuItemMaps } =
await this.workspaceManyOrAllFlatEntityMapsCacheService.getOrRecomputeManyOrAllFlatEntityMaps(
{
workspaceId,
flatMapsKeys: ['flatNavigationMenuItemMaps'],
},
);
const existingWorkspaceItems = Object.values(
existingFlatNavigationMenuItemMaps.byId,
).filter(
(item) =>
isDefined(item) &&
item.workspaceId === workspaceId &&
item.userWorkspaceId === null,
);
const maxPosition = existingWorkspaceItems.reduce(
(max, item) => Math.max(max, item?.position ?? 0),
0,
);
const flatNavigationMenuItemsToCreate = flatViews.map((flatView, index) => {
return fromCreateNavigationMenuItemInputToFlatNavigationMenuItemToCreate({
createNavigationMenuItemInput: {
viewId: flatView.id,
userWorkspaceId: undefined,
position: maxPosition + index + 1,
},
workspaceId,
applicationId: workspaceCustomFlatApplication.id,
flatNavigationMenuItemMaps: existingFlatNavigationMenuItemMaps,
});
});
if (flatNavigationMenuItemsToCreate.length === 0) {
return;
}
const validateAndBuildResult =
await this.workspaceMigrationValidateBuildAndRunService.validateBuildAndRunWorkspaceMigration(
{
allFlatEntityOperationByMetadataName: {
navigationMenuItem: {
flatEntityToCreate: flatNavigationMenuItemsToCreate,
flatEntityToDelete: [],
flatEntityToUpdate: [],
},
},
workspaceId,
isSystemBuild: true,
},
);
if (isDefined(validateAndBuildResult)) {
throw new WorkspaceMigrationBuilderException(
validateAndBuildResult,
'Multiple validation errors occurred while creating workspace navigation menu items',
);
}
}
async synchronizeTwentyStandardApplicationOrThrow({
workspaceId,
}: {
@@ -1,6 +1,7 @@
import { Module } from '@nestjs/common';
import { ApplicationModule } from 'src/engine/core-modules/application/application.module';
import { WorkspaceManyOrAllFlatEntityMapsCacheModule } from 'src/engine/metadata-modules/flat-entity/services/workspace-many-or-all-flat-entity-maps-cache.module';
import { GlobalWorkspaceDataSourceModule } from 'src/engine/twenty-orm/global-workspace-datasource/global-workspace-datasource.module';
import { WorkspaceCacheModule } from 'src/engine/workspace-cache/workspace-cache.module';
import { WorkspaceMigrationModule } from 'src/engine/workspace-manager/workspace-migration/workspace-migration.module';
@@ -14,6 +15,7 @@ import { TwentyStandardApplicationService } from './services/twenty-standard-app
WorkspaceCacheModule,
WorkspaceMigrationModule,
GlobalWorkspaceDataSourceModule,
WorkspaceManyOrAllFlatEntityMapsCacheModule,
],
exports: [TwentyStandardApplicationService],
})
@@ -0,0 +1,58 @@
import { v4 } from 'uuid';
import { createEmptyFlatEntityMaps } from 'src/engine/metadata-modules/flat-entity/constant/create-empty-flat-entity-maps.constant';
import { type FlatEntityMaps } from 'src/engine/metadata-modules/flat-entity/types/flat-entity-maps.type';
import { type FlatNavigationMenuItemMaps } from 'src/engine/metadata-modules/flat-navigation-menu-item/types/flat-navigation-menu-item-maps.type';
import { addFlatNavigationMenuItemToMapsAndUpdateIndex } from 'src/engine/metadata-modules/flat-navigation-menu-item/utils/add-flat-navigation-menu-item-to-maps-and-update-index.util';
import { type FlatView } from 'src/engine/metadata-modules/flat-view/types/flat-view.type';
import { STANDARD_NAVIGATION_MENU_ITEMS } from 'src/engine/workspace-manager/twenty-standard-application/constants/standard-navigation-menu-item.constant';
import { createStandardNavigationMenuItemFlatMetadata } from 'src/engine/workspace-manager/twenty-standard-application/utils/navigation-menu-item/create-standard-navigation-menu-item-flat-metadata.util';
export const buildStandardFlatNavigationMenuItemMaps = ({
now,
workspaceId,
twentyStandardApplicationId,
dependencyFlatEntityMaps: { flatViewMaps },
}: {
now: string;
workspaceId: string;
twentyStandardApplicationId: string;
dependencyFlatEntityMaps: {
flatViewMaps: FlatEntityMaps<FlatView>;
};
}): FlatNavigationMenuItemMaps => {
const flatNavigationMenuItemMaps: FlatNavigationMenuItemMaps = {
...createEmptyFlatEntityMaps(),
byUserWorkspaceIdAndFolderId: {},
};
for (const navigationMenuItemName of Object.keys(
STANDARD_NAVIGATION_MENU_ITEMS,
) as Array<keyof typeof STANDARD_NAVIGATION_MENU_ITEMS>) {
const navigationMenuItemDefinition =
STANDARD_NAVIGATION_MENU_ITEMS[navigationMenuItemName];
const flatNavigationMenuItem = createStandardNavigationMenuItemFlatMetadata(
{
workspaceId,
navigationMenuItemName,
viewUniversalIdentifier:
navigationMenuItemDefinition.viewUniversalIdentifier,
position: navigationMenuItemDefinition.position,
navigationMenuItemId: v4(),
dependencyFlatEntityMaps: {
flatViewMaps,
},
twentyStandardApplicationId,
now,
},
);
addFlatNavigationMenuItemToMapsAndUpdateIndex({
flatNavigationMenuItem,
flatNavigationMenuItemMaps,
});
}
return flatNavigationMenuItemMaps;
};
@@ -0,0 +1,65 @@
import { isDefined } from 'twenty-shared/utils';
import { type FlatEntityMaps } from 'src/engine/metadata-modules/flat-entity/types/flat-entity-maps.type';
import { findFlatEntityByUniversalIdentifier } from 'src/engine/metadata-modules/flat-entity/utils/find-flat-entity-by-universal-identifier.util';
import { type FlatNavigationMenuItem } from 'src/engine/metadata-modules/flat-navigation-menu-item/types/flat-navigation-menu-item.type';
import { type FlatView } from 'src/engine/metadata-modules/flat-view/types/flat-view.type';
import { STANDARD_NAVIGATION_MENU_ITEMS } from 'src/engine/workspace-manager/twenty-standard-application/constants/standard-navigation-menu-item.constant';
export const createStandardNavigationMenuItemFlatMetadata = ({
workspaceId,
navigationMenuItemName,
viewUniversalIdentifier,
position,
navigationMenuItemId,
dependencyFlatEntityMaps: { flatViewMaps },
twentyStandardApplicationId,
now,
}: {
workspaceId: string;
navigationMenuItemName: keyof typeof STANDARD_NAVIGATION_MENU_ITEMS;
viewUniversalIdentifier: string;
position: number;
navigationMenuItemId: string;
dependencyFlatEntityMaps: {
flatViewMaps: FlatEntityMaps<FlatView>;
};
twentyStandardApplicationId: string;
now: string;
}): FlatNavigationMenuItem => {
const navigationMenuItemDefinition =
STANDARD_NAVIGATION_MENU_ITEMS[navigationMenuItemName];
if (!isDefined(navigationMenuItemDefinition)) {
throw new Error(
`Invalid navigation menu item configuration ${navigationMenuItemName}`,
);
}
const flatView = findFlatEntityByUniversalIdentifier({
flatEntityMaps: flatViewMaps,
universalIdentifier: viewUniversalIdentifier,
});
if (!isDefined(flatView)) {
throw new Error(
`View not found for universal identifier ${viewUniversalIdentifier}`,
);
}
return {
id: navigationMenuItemId,
universalIdentifier: navigationMenuItemDefinition.universalIdentifier,
applicationId: twentyStandardApplicationId,
workspaceId,
userWorkspaceId: null,
targetRecordId: null,
targetObjectMetadataId: null,
viewId: flatView.id,
folderId: null,
name: null,
position,
createdAt: now,
updatedAt: now,
};
};
@@ -5,6 +5,7 @@ import { buildStandardFlatFieldMetadataMaps } from 'src/engine/workspace-manager
import { getStandardObjectMetadataRelatedEntityIds } from 'src/engine/workspace-manager/twenty-standard-application/utils/get-standard-object-metadata-related-entity-ids.util';
import { getStandardPageLayoutMetadataRelatedEntityIds } from 'src/engine/workspace-manager/twenty-standard-application/utils/get-standard-page-layout-metadata-related-entity-ids.util';
import { buildStandardFlatIndexMetadataMaps } from 'src/engine/workspace-manager/twenty-standard-application/utils/index/build-standard-flat-index-metadata-maps.util';
import { buildStandardFlatNavigationMenuItemMaps } from 'src/engine/workspace-manager/twenty-standard-application/utils/navigation-menu-item/build-standard-flat-navigation-menu-item-maps.util';
import { buildStandardFlatObjectMetadataMaps } from 'src/engine/workspace-manager/twenty-standard-application/utils/object-metadata/build-standard-flat-object-metadata-maps.util';
import { buildStandardFlatPageLayoutTabMetadataMaps } from 'src/engine/workspace-manager/twenty-standard-application/utils/page-layout-tab/build-standard-flat-page-layout-tab-metadata-maps.util';
import { buildStandardFlatPageLayoutWidgetMetadataMaps } from 'src/engine/workspace-manager/twenty-standard-application/utils/page-layout-widget/build-standard-flat-page-layout-widget-metadata-maps.util';
@@ -161,6 +162,15 @@ export const computeTwentyStandardApplicationAllFlatEntityMaps = ({
standardPageLayoutMetadataRelatedEntityIds,
});
const flatNavigationMenuItemMaps = buildStandardFlatNavigationMenuItemMaps({
now,
workspaceId,
twentyStandardApplicationId,
dependencyFlatEntityMaps: {
flatViewMaps,
},
});
return {
flatViewFieldMaps,
flatViewFilterMaps,
@@ -169,6 +179,7 @@ export const computeTwentyStandardApplicationAllFlatEntityMaps = ({
flatIndexMaps,
flatFieldMetadataMaps,
flatObjectMetadataMaps,
flatNavigationMenuItemMaps,
flatRoleMaps,
flatAgentMaps,
flatSkillMaps,