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:
+5
@@ -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,
|
||||
|
||||
+46
@@ -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;
|
||||
+1
@@ -8,6 +8,7 @@ export const TWENTY_STANDARD_ALL_METADATA_NAME = [
|
||||
'viewFilter',
|
||||
'viewGroup',
|
||||
'view',
|
||||
'navigationMenuItem',
|
||||
'role',
|
||||
'agent',
|
||||
'skill',
|
||||
|
||||
+77
@@ -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,
|
||||
}: {
|
||||
|
||||
+2
@@ -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],
|
||||
})
|
||||
|
||||
+58
@@ -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;
|
||||
};
|
||||
+65
@@ -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,
|
||||
};
|
||||
};
|
||||
+11
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user