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
@@ -14,5 +14,6 @@ export enum FeatureFlagKey {
IS_JUNCTION_RELATIONS_ENABLED = 'IS_JUNCTION_RELATIONS_ENABLED',
IS_SSE_DB_EVENTS_ENABLED = 'IS_SSE_DB_EVENTS_ENABLED',
IS_COMMAND_MENU_ITEM_ENABLED = 'IS_COMMAND_MENU_ITEM_ENABLED',
IS_NAVIGATION_MENU_ITEM_ENABLED = 'IS_NAVIGATION_MENU_ITEM_ENABLED',
IS_FILES_FIELD_ENABLED = 'IS_FILES_FIELD_ENABLED',
}
@@ -34,6 +34,7 @@ import { CalendarEventParticipantManagerModule } from 'src/modules/calendar/cale
import { CalendarModule } from 'src/modules/calendar/calendar.module';
import { AutoCompaniesAndContactsCreationJobModule } from 'src/modules/contact-creation-manager/jobs/auto-companies-and-contacts-creation-job.module';
import { FavoriteModule } from 'src/modules/favorite/favorite.module';
import { NavigationMenuItemModule } from 'src/engine/metadata-modules/navigation-menu-item/navigation-menu-item.module';
import { MessagingModule } from 'src/modules/messaging/messaging.module';
import { TimelineJobModule } from 'src/modules/timeline/jobs/timeline-job.module';
import { TimelineActivityModule } from 'src/modules/timeline/timeline-activity.module';
@@ -62,6 +63,7 @@ import { WorkflowModule } from 'src/modules/workflow/workflow.module';
WebhookJobModule,
WorkflowModule,
FavoriteModule,
NavigationMenuItemModule,
WorkspaceCleanerModule,
SubscriptionsModule,
AuditJobModule,
@@ -0,0 +1,67 @@
import { isNonEmptyString } from '@sniptt/guards';
import { getLogoUrlFromDomainName, isDefined } from 'twenty-shared/utils';
import { type FlatEntityMaps } from 'src/engine/metadata-modules/flat-entity/types/flat-entity-maps.type';
import { findFlatEntityByIdInFlatEntityMaps } from 'src/engine/metadata-modules/flat-entity/utils/find-flat-entity-by-id-in-flat-entity-maps.util';
import { type FlatFieldMetadata } from 'src/engine/metadata-modules/flat-field-metadata/types/flat-field-metadata.type';
import { type FlatObjectMetadata } from 'src/engine/metadata-modules/flat-object-metadata/types/flat-object-metadata.type';
type GetRecordImageIdentifierOptions = {
record: Record<string, unknown>;
flatObjectMetadata: FlatObjectMetadata;
flatFieldMetadataMaps: FlatEntityMaps<FlatFieldMetadata>;
signUrl?: (url: string) => string | null;
};
export const getRecordImageIdentifier = ({
record,
flatObjectMetadata,
flatFieldMetadataMaps,
signUrl,
}: GetRecordImageIdentifierOptions): string | null => {
if (flatObjectMetadata.nameSingular === 'company') {
const domainNameObj = record.domainName as
| { primaryLinkUrl?: string }
| undefined;
const domainNamePrimaryLinkUrl = domainNameObj?.primaryLinkUrl;
return domainNamePrimaryLinkUrl
? getLogoUrlFromDomainName(domainNamePrimaryLinkUrl) || null
: null;
}
if (!isDefined(flatObjectMetadata.imageIdentifierFieldMetadataId)) {
return null;
}
const imageIdentifierField = findFlatEntityByIdInFlatEntityMaps({
flatEntityMaps: flatFieldMetadataMaps,
flatEntityId: flatObjectMetadata.imageIdentifierFieldMetadataId,
});
if (!isDefined(imageIdentifierField)) {
return null;
}
const imageValue = record[imageIdentifierField.name];
if (!isDefined(imageValue)) {
return null;
}
const rawImageValue = String(imageValue);
if (!isNonEmptyString(rawImageValue)) {
return null;
}
if (
signUrl &&
(flatObjectMetadata.nameSingular === 'person' ||
flatObjectMetadata.nameSingular === 'workspaceMember')
) {
return signUrl(rawImageValue);
}
return rawImageValue;
};
@@ -106,6 +106,11 @@ export const ALL_METADATA_RELATIONS = {
flatEntityForeignKeyAggregator: null,
foreignKey: 'folderId',
},
view: {
metadataName: 'view',
flatEntityForeignKeyAggregator: null,
foreignKey: 'viewId',
},
},
oneToMany: {},
},
@@ -42,6 +42,7 @@ exports[`getMetadataRelatedMetadataNames should return related metadata names fo
[
"objectMetadata",
"navigationMenuItem",
"view",
]
`;
@@ -3,8 +3,8 @@
exports[`sortMetadataNamesChildrenFirst should return metadata names sorted with children first (most manyToOne relations first) 1`] = `
[
"rowLevelPermissionPredicate",
"viewFilter",
"navigationMenuItem",
"viewFilter",
"pageLayoutWidget",
"viewField",
"commandMenuItem",
@@ -10,6 +10,8 @@ import {
import { UUIDScalarType } from 'src/engine/api/graphql/workspace-schema-builder/graphql-types/scalars';
import { RecordIdentifierDTO } from './record-identifier.dto';
@ObjectType('NavigationMenuItem')
export class NavigationMenuItemDTO {
@IsUUID()
@@ -64,4 +66,7 @@ export class NavigationMenuItemDTO {
@IsDateString()
@Field()
updatedAt: Date;
@Field(() => RecordIdentifierDTO, { nullable: true })
targetRecordIdentifier?: RecordIdentifierDTO | null;
}
@@ -0,0 +1,21 @@
import { Field, ObjectType } from '@nestjs/graphql';
import { IsOptional, IsString, IsUUID } from 'class-validator';
import { UUIDScalarType } from 'src/engine/api/graphql/workspace-schema-builder/graphql-types/scalars';
@ObjectType('RecordIdentifier')
export class RecordIdentifierDTO {
@IsUUID()
@Field(() => UUIDScalarType)
id: string;
@IsString()
@Field(() => String)
labelIdentifier: string;
@IsOptional()
@IsString()
@Field(() => String, { nullable: true })
imageIdentifier?: string | null;
}
@@ -13,6 +13,7 @@ import {
import { UserWorkspaceEntity } from 'src/engine/core-modules/user-workspace/user-workspace.entity';
import { ObjectMetadataEntity } from 'src/engine/metadata-modules/object-metadata/object-metadata.entity';
import { ViewEntity } from 'src/engine/metadata-modules/view/entities/view.entity';
import { SyncableEntity } from 'src/engine/workspace-manager/types/syncable-entity.interface';
@Entity({ name: 'navigationMenuItem', schema: 'core' })
@@ -63,6 +64,13 @@ export class NavigationMenuItemEntity
@Column({ nullable: true, type: 'uuid' })
viewId: string | null;
@ManyToOne(() => ViewEntity, {
onDelete: 'CASCADE',
nullable: true,
})
@JoinColumn({ name: 'viewId' })
view: Relation<ViewEntity> | null;
@ManyToOne(() => ObjectMetadataEntity, {
onDelete: 'CASCADE',
nullable: true,
@@ -0,0 +1,29 @@
import { Scope } from '@nestjs/common';
import { Process } from 'src/engine/core-modules/message-queue/decorators/process.decorator';
import { Processor } from 'src/engine/core-modules/message-queue/decorators/processor.decorator';
import { MessageQueue } from 'src/engine/core-modules/message-queue/message-queue.constants';
import { NavigationMenuItemDeletionService } from 'src/engine/metadata-modules/navigation-menu-item/services/navigation-menu-item-deletion.service';
export type NavigationMenuItemDeletionJobData = {
workspaceId: string;
deletedRecordIds: string[];
};
@Processor({
queueName: MessageQueue.deleteCascadeQueue,
scope: Scope.REQUEST,
})
export class NavigationMenuItemDeletionJob {
constructor(
private readonly navigationMenuItemDeletionService: NavigationMenuItemDeletionService,
) {}
@Process(NavigationMenuItemDeletionJob.name)
async handle(data: NavigationMenuItemDeletionJobData): Promise<void> {
await this.navigationMenuItemDeletionService.deleteNavigationMenuItemsForDeletedRecords(
data.deletedRecordIds,
data.workspaceId,
);
}
}
@@ -0,0 +1,41 @@
import { Injectable } from '@nestjs/common';
import { type ObjectRecordDeleteEvent } from 'twenty-shared/database-events';
import { OnDatabaseBatchEvent } from 'src/engine/api/graphql/graphql-query-runner/decorators/on-database-batch-event.decorator';
import { DatabaseEventAction } from 'src/engine/api/graphql/graphql-query-runner/enums/database-event-action';
import { InjectMessageQueue } from 'src/engine/core-modules/message-queue/decorators/message-queue.decorator';
import { MessageQueue } from 'src/engine/core-modules/message-queue/message-queue.constants';
import { MessageQueueService } from 'src/engine/core-modules/message-queue/services/message-queue.service';
import {
NavigationMenuItemDeletionJob,
type NavigationMenuItemDeletionJobData,
} from 'src/engine/metadata-modules/navigation-menu-item/jobs/navigation-menu-item-deletion.job';
import { WorkspaceEventBatch } from 'src/engine/workspace-event-emitter/types/workspace-event-batch.type';
@Injectable()
export class NavigationMenuItemDeletionListener {
constructor(
@InjectMessageQueue(MessageQueue.deleteCascadeQueue)
private readonly messageQueueService: MessageQueueService,
) {}
@OnDatabaseBatchEvent('*', DatabaseEventAction.DELETED)
async handleDeletedEvent(
payload: WorkspaceEventBatch<ObjectRecordDeleteEvent>,
) {
const deletedRecordIds = payload.events.map(({ recordId }) => recordId);
if (deletedRecordIds.length === 0) {
return;
}
await this.messageQueueService.add<NavigationMenuItemDeletionJobData>(
NavigationMenuItemDeletionJob.name,
{
workspaceId: payload.workspaceId,
deletedRecordIds,
},
);
}
}
@@ -1,13 +1,19 @@
import { Module } from '@nestjs/common';
import { ApiKeyModule } from 'src/engine/core-modules/api-key/api-key.module';
import { ApplicationModule } from 'src/engine/core-modules/application/application.module';
import { FileModule } from 'src/engine/core-modules/file/file.module';
import { WorkspaceManyOrAllFlatEntityMapsCacheModule } from 'src/engine/metadata-modules/flat-entity/services/workspace-many-or-all-flat-entity-maps-cache.module';
import { FlatNavigationMenuItemModule } from 'src/engine/metadata-modules/flat-navigation-menu-item/flat-navigation-menu-item.module';
import { NavigationMenuItemGraphqlApiExceptionInterceptor } from 'src/engine/metadata-modules/navigation-menu-item/interceptors/navigation-menu-item-graphql-api-exception.interceptor';
import { NavigationMenuItemDeletionJob } from 'src/engine/metadata-modules/navigation-menu-item/jobs/navigation-menu-item-deletion.job';
import { NavigationMenuItemDeletionListener } from 'src/engine/metadata-modules/navigation-menu-item/listeners/navigation-menu-item-deletion.listener';
import { NavigationMenuItemResolver } from 'src/engine/metadata-modules/navigation-menu-item/navigation-menu-item.resolver';
import { NavigationMenuItemService } from 'src/engine/metadata-modules/navigation-menu-item/navigation-menu-item.service';
import { NavigationMenuItemAccessService } from 'src/engine/metadata-modules/navigation-menu-item/services/navigation-menu-item-access.service';
import { NavigationMenuItemDeletionService } from 'src/engine/metadata-modules/navigation-menu-item/services/navigation-menu-item-deletion.service';
import { PermissionsModule } from 'src/engine/metadata-modules/permissions/permissions.module';
import { UserRoleModule } from 'src/engine/metadata-modules/user-role/user-role.module';
import { WorkspaceMigrationGraphqlApiExceptionInterceptor } from 'src/engine/workspace-manager/workspace-migration/interceptors/workspace-migration-graphql-api-exception.interceptor';
import { WorkspaceMigrationModule } from 'src/engine/workspace-manager/workspace-migration/workspace-migration.module';
@@ -18,10 +24,16 @@ import { WorkspaceMigrationModule } from 'src/engine/workspace-manager/workspace
ApplicationModule,
FlatNavigationMenuItemModule,
PermissionsModule,
FileModule,
UserRoleModule,
ApiKeyModule,
],
providers: [
NavigationMenuItemService,
NavigationMenuItemAccessService,
NavigationMenuItemDeletionService,
NavigationMenuItemDeletionListener,
NavigationMenuItemDeletionJob,
NavigationMenuItemResolver,
NavigationMenuItemGraphqlApiExceptionInterceptor,
WorkspaceMigrationGraphqlApiExceptionInterceptor,
@@ -1,5 +1,17 @@
import { UseGuards, UseInterceptors } from '@nestjs/common';
import { Args, Context, Mutation, Query, Resolver } from '@nestjs/graphql';
import {
Args,
Context,
Mutation,
Parent,
Query,
ResolveField,
Resolver,
} from '@nestjs/graphql';
import { isDefined } from 'twenty-shared/utils';
import { type WorkspaceAuthContext } from 'src/engine/api/common/interfaces/workspace-auth-context.interface';
import { UUIDScalarType } from 'src/engine/api/graphql/workspace-schema-builder/graphql-types/scalars';
import { ApiKeyEntity } from 'src/engine/core-modules/api-key/api-key.entity';
@@ -12,6 +24,7 @@ import { NoPermissionGuard } from 'src/engine/guards/no-permission.guard';
import { WorkspaceAuthGuard } from 'src/engine/guards/workspace-auth.guard';
import { CreateNavigationMenuItemInput } from 'src/engine/metadata-modules/navigation-menu-item/dtos/create-navigation-menu-item.input';
import { NavigationMenuItemDTO } from 'src/engine/metadata-modules/navigation-menu-item/dtos/navigation-menu-item.dto';
import { RecordIdentifierDTO } from 'src/engine/metadata-modules/navigation-menu-item/dtos/record-identifier.dto';
import { UpdateOneNavigationMenuItemInput } from 'src/engine/metadata-modules/navigation-menu-item/dtos/update-navigation-menu-item.input';
import { NavigationMenuItemGraphqlApiExceptionInterceptor } from 'src/engine/metadata-modules/navigation-menu-item/interceptors/navigation-menu-item-graphql-api-exception.interceptor';
import { NavigationMenuItemService } from 'src/engine/metadata-modules/navigation-menu-item/navigation-menu-item.service';
@@ -105,4 +118,25 @@ export class NavigationMenuItemResolver {
authApplicationId: context.req.application?.id,
});
}
@ResolveField(() => RecordIdentifierDTO, { nullable: true })
async targetRecordIdentifier(
@Parent() navigationMenuItem: NavigationMenuItemDTO,
@AuthWorkspace() workspace: WorkspaceEntity,
@Context() context: { req: WorkspaceAuthContext },
): Promise<RecordIdentifierDTO | null> {
if (
!isDefined(navigationMenuItem.targetRecordId) ||
!isDefined(navigationMenuItem.targetObjectMetadataId)
) {
return null;
}
return await this.navigationMenuItemService.findTargetRecord({
targetRecordId: navigationMenuItem.targetRecordId,
targetObjectMetadataId: navigationMenuItem.targetObjectMetadataId,
workspaceId: workspace.id,
authContext: context.req,
});
}
}
@@ -2,7 +2,13 @@ import { Injectable } from '@nestjs/common';
import { isDefined } from 'twenty-shared/utils';
import { type WorkspaceAuthContext } from 'src/engine/api/common/interfaces/workspace-auth-context.interface';
import { ApiKeyRoleService } from 'src/engine/core-modules/api-key/services/api-key-role.service';
import { ApplicationService } from 'src/engine/core-modules/application/application.service';
import { FileService } from 'src/engine/core-modules/file/services/file.service';
import { getRecordDisplayName } from 'src/engine/core-modules/record-crud/utils/get-record-display-name.util';
import { getRecordImageIdentifier } from 'src/engine/core-modules/record-crud/utils/get-record-image-identifier.util';
import { WorkspaceManyOrAllFlatEntityMapsCacheService } from 'src/engine/metadata-modules/flat-entity/services/workspace-many-or-all-flat-entity-maps-cache.service';
import { findFlatEntityByIdInFlatEntityMapsOrThrow } from 'src/engine/metadata-modules/flat-entity/utils/find-flat-entity-by-id-in-flat-entity-maps-or-throw.util';
import { findFlatEntityByIdInFlatEntityMaps } from 'src/engine/metadata-modules/flat-entity/utils/find-flat-entity-by-id-in-flat-entity-maps.util';
@@ -12,12 +18,19 @@ import { fromFlatNavigationMenuItemToNavigationMenuItemDto } from 'src/engine/me
import { fromUpdateNavigationMenuItemInputToFlatNavigationMenuItemToUpdateOrThrow } from 'src/engine/metadata-modules/flat-navigation-menu-item/utils/from-update-navigation-menu-item-input-to-flat-navigation-menu-item-to-update-or-throw.util';
import { type CreateNavigationMenuItemInput } from 'src/engine/metadata-modules/navigation-menu-item/dtos/create-navigation-menu-item.input';
import { type NavigationMenuItemDTO } from 'src/engine/metadata-modules/navigation-menu-item/dtos/navigation-menu-item.dto';
import { RecordIdentifierDTO } from 'src/engine/metadata-modules/navigation-menu-item/dtos/record-identifier.dto';
import { type UpdateNavigationMenuItemInput } from 'src/engine/metadata-modules/navigation-menu-item/dtos/update-navigation-menu-item.input';
import {
NavigationMenuItemException,
NavigationMenuItemExceptionCode,
} from 'src/engine/metadata-modules/navigation-menu-item/navigation-menu-item.exception';
import { NavigationMenuItemAccessService } from 'src/engine/metadata-modules/navigation-menu-item/services/navigation-menu-item-access.service';
import { getMinimalSelectForRecordIdentifier } from 'src/engine/metadata-modules/navigation-menu-item/utils/get-minimal-select-for-record-identifier.util';
import { PermissionsException } from 'src/engine/metadata-modules/permissions/permissions.exception';
import { UserRoleService } from 'src/engine/metadata-modules/user-role/user-role.service';
import { GlobalWorkspaceOrmManager } from 'src/engine/twenty-orm/global-workspace-datasource/global-workspace-orm.manager';
import { type RolePermissionConfig } from 'src/engine/twenty-orm/types/role-permission-config';
import { formatResult } from 'src/engine/twenty-orm/utils/format-result.util';
import { WorkspaceMigrationBuilderException } from 'src/engine/workspace-manager/workspace-migration/exceptions/workspace-migration-builder-exception';
import { WorkspaceMigrationValidateBuildAndRunService } from 'src/engine/workspace-manager/workspace-migration/services/workspace-migration-validate-build-and-run-service';
@@ -28,6 +41,10 @@ export class NavigationMenuItemService {
private readonly workspaceManyOrAllFlatEntityMapsCacheService: WorkspaceManyOrAllFlatEntityMapsCacheService,
private readonly applicationService: ApplicationService,
private readonly navigationMenuItemAccessService: NavigationMenuItemAccessService,
private readonly globalWorkspaceOrmManager: GlobalWorkspaceOrmManager,
private readonly fileService: FileService,
private readonly userRoleService: UserRoleService,
private readonly apiKeyRoleService: ApiKeyRoleService,
) {}
async findAll({
@@ -334,4 +351,150 @@ export class NavigationMenuItemService {
flatNavigationMenuItemToDelete,
);
}
private async getRoleId(
authContext: WorkspaceAuthContext,
workspaceId: string,
): Promise<string | undefined> {
if (isDefined(authContext.apiKey)) {
return this.apiKeyRoleService.getRoleIdForApiKeyId(
authContext.apiKey.id,
workspaceId,
);
}
if (isDefined(authContext.application?.defaultServerlessFunctionRoleId)) {
return authContext.application.defaultServerlessFunctionRoleId;
}
if (isDefined(authContext.userWorkspaceId)) {
try {
return await this.userRoleService.getRoleIdForUserWorkspace({
userWorkspaceId: authContext.userWorkspaceId,
workspaceId,
});
} catch (error: unknown) {
if (error instanceof PermissionsException) {
return undefined;
}
throw error;
}
}
return undefined;
}
async findTargetRecord({
targetRecordId,
targetObjectMetadataId,
workspaceId,
authContext,
}: {
targetRecordId: string;
targetObjectMetadataId: string;
workspaceId: string;
authContext: WorkspaceAuthContext;
}): Promise<RecordIdentifierDTO | null> {
const { flatObjectMetadataMaps, flatFieldMetadataMaps } =
await this.workspaceManyOrAllFlatEntityMapsCacheService.getOrRecomputeManyOrAllFlatEntityMaps(
{
workspaceId,
flatMapsKeys: ['flatObjectMetadataMaps', 'flatFieldMetadataMaps'],
},
);
const objectMetadata = flatObjectMetadataMaps.byId[targetObjectMetadataId];
if (!isDefined(objectMetadata)) {
return null;
}
try {
const roleId = await this.getRoleId(authContext, workspaceId);
if (!isDefined(roleId)) {
return null;
}
const rolePermissionConfig: RolePermissionConfig = {
unionOf: [roleId],
};
const minimalSelectColumns = getMinimalSelectForRecordIdentifier({
flatObjectMetadata: objectMetadata,
flatFieldMetadataMaps,
});
const record =
await this.globalWorkspaceOrmManager.executeInWorkspaceContext(
authContext,
async () => {
const repository =
await this.globalWorkspaceOrmManager.getRepository(
workspaceId,
objectMetadata.nameSingular,
rolePermissionConfig,
);
const alias = objectMetadata.nameSingular;
const queryBuilder = repository.createQueryBuilder(alias);
queryBuilder.select([]);
for (const column of minimalSelectColumns) {
queryBuilder.addSelect(`"${alias}"."${column}"`, column);
}
const rawResult = await queryBuilder
.where(`${alias}.id = :id`, { id: targetRecordId })
.getRawOne();
if (!isDefined(rawResult)) {
return null;
}
const formattedRecord = formatResult<Record<string, unknown>>(
rawResult,
objectMetadata,
flatObjectMetadataMaps,
flatFieldMetadataMaps,
);
return formattedRecord;
},
);
if (!isDefined(record)) {
return null;
}
const labelIdentifier = getRecordDisplayName(
record,
objectMetadata,
flatFieldMetadataMaps,
);
const imageIdentifier = getRecordImageIdentifier({
record,
flatObjectMetadata: objectMetadata,
flatFieldMetadataMaps,
signUrl: (url: string) =>
this.fileService.signFileUrl({
url,
workspaceId,
}),
});
return {
id: record.id as string,
labelIdentifier,
imageIdentifier,
};
} catch (error: unknown) {
if (error instanceof PermissionsException) {
return null;
}
throw error;
}
}
}
@@ -0,0 +1,75 @@
import { Injectable } from '@nestjs/common';
import { isDefined } from 'twenty-shared/utils';
import { WorkspaceManyOrAllFlatEntityMapsCacheService } from 'src/engine/metadata-modules/flat-entity/services/workspace-many-or-all-flat-entity-maps-cache.service';
import { fromDeleteNavigationMenuItemInputToFlatNavigationMenuItemOrThrow } from 'src/engine/metadata-modules/flat-navigation-menu-item/utils/from-delete-navigation-menu-item-input-to-flat-navigation-menu-item-or-throw.util';
import { WorkspaceMigrationBuilderException } from 'src/engine/workspace-manager/workspace-migration/exceptions/workspace-migration-builder-exception';
import { WorkspaceMigrationValidateBuildAndRunService } from 'src/engine/workspace-manager/workspace-migration/services/workspace-migration-validate-build-and-run-service';
@Injectable()
export class NavigationMenuItemDeletionService {
constructor(
private readonly workspaceManyOrAllFlatEntityMapsCacheService: WorkspaceManyOrAllFlatEntityMapsCacheService,
private readonly workspaceMigrationValidateBuildAndRunService: WorkspaceMigrationValidateBuildAndRunService,
) {}
async deleteNavigationMenuItemsForDeletedRecords(
deletedRecordIds: string[],
workspaceId: string,
): Promise<void> {
const { flatNavigationMenuItemMaps } =
await this.workspaceManyOrAllFlatEntityMapsCacheService.getOrRecomputeManyOrAllFlatEntityMaps(
{
workspaceId,
flatMapsKeys: ['flatNavigationMenuItemMaps'],
},
);
const deletedRecordIdsSet = new Set(deletedRecordIds);
const navigationMenuItemsToDelete = Object.values(
flatNavigationMenuItemMaps.byId,
).filter(
(item): item is NonNullable<typeof item> =>
isDefined(item) &&
isDefined(item.targetRecordId) &&
!isDefined(item.viewId) &&
deletedRecordIdsSet.has(item.targetRecordId),
);
if (navigationMenuItemsToDelete.length === 0) {
return;
}
const flatNavigationMenuItemsToDelete = navigationMenuItemsToDelete.map(
(item) =>
fromDeleteNavigationMenuItemInputToFlatNavigationMenuItemOrThrow({
flatNavigationMenuItemMaps,
navigationMenuItemId: item.id,
}),
);
const validateAndBuildResult =
await this.workspaceMigrationValidateBuildAndRunService.validateBuildAndRunWorkspaceMigration(
{
allFlatEntityOperationByMetadataName: {
navigationMenuItem: {
flatEntityToCreate: [],
flatEntityToDelete: flatNavigationMenuItemsToDelete,
flatEntityToUpdate: [],
},
},
workspaceId,
isSystemBuild: true,
},
);
if (isDefined(validateAndBuildResult)) {
throw new WorkspaceMigrationBuilderException(
validateAndBuildResult,
'Multiple validation errors occurred while deleting navigation menu items for deleted records',
);
}
}
}
@@ -0,0 +1,75 @@
import { compositeTypeDefinitions } from 'twenty-shared/types';
import { isDefined } from 'twenty-shared/utils';
import { computeCompositeColumnName } from 'src/engine/metadata-modules/field-metadata/utils/compute-column-name.util';
import { isCompositeFieldMetadataType } from 'src/engine/metadata-modules/field-metadata/utils/is-composite-field-metadata-type.util';
import { type FlatEntityMaps } from 'src/engine/metadata-modules/flat-entity/types/flat-entity-maps.type';
import { findFlatEntityByIdInFlatEntityMaps } from 'src/engine/metadata-modules/flat-entity/utils/find-flat-entity-by-id-in-flat-entity-maps.util';
import { type FlatFieldMetadata } from 'src/engine/metadata-modules/flat-field-metadata/types/flat-field-metadata.type';
import { type FlatObjectMetadata } from 'src/engine/metadata-modules/flat-object-metadata/types/flat-object-metadata.type';
const ID_FIELD = 'id' as const;
const COMPANY_AVATAR_COLUMN = 'domainNamePrimaryLinkUrl' as const;
export const getMinimalSelectForRecordIdentifier = ({
flatObjectMetadata,
flatFieldMetadataMaps,
}: {
flatObjectMetadata: FlatObjectMetadata;
flatFieldMetadataMaps: FlatEntityMaps<FlatFieldMetadata>;
}): string[] => {
const selectColumns: string[] = [ID_FIELD];
const labelField = isDefined(
flatObjectMetadata.labelIdentifierFieldMetadataId,
)
? findFlatEntityByIdInFlatEntityMaps({
flatEntityMaps: flatFieldMetadataMaps,
flatEntityId: flatObjectMetadata.labelIdentifierFieldMetadataId,
})
: undefined;
if (isDefined(labelField)) {
const labelCompositeType = isCompositeFieldMetadataType(labelField.type)
? compositeTypeDefinitions.get(labelField.type)
: undefined;
if (isDefined(labelCompositeType)) {
for (const compositeProperty of labelCompositeType.properties) {
selectColumns.push(
computeCompositeColumnName(labelField.name, compositeProperty),
);
}
} else {
selectColumns.push(labelField.name);
}
}
if (flatObjectMetadata.nameSingular === 'company') {
selectColumns.push(COMPANY_AVATAR_COLUMN);
} else if (isDefined(flatObjectMetadata.imageIdentifierFieldMetadataId)) {
const imageField = findFlatEntityByIdInFlatEntityMaps({
flatEntityMaps: flatFieldMetadataMaps,
flatEntityId: flatObjectMetadata.imageIdentifierFieldMetadataId,
});
if (isDefined(imageField)) {
const imageCompositeType = isCompositeFieldMetadataType(imageField.type)
? compositeTypeDefinitions.get(imageField.type)
: undefined;
if (isDefined(imageCompositeType)) {
for (const compositeProperty of imageCompositeType.properties) {
selectColumns.push(
computeCompositeColumnName(imageField.name, compositeProperty),
);
}
} else {
selectColumns.push(imageField.name);
}
}
}
return selectColumns;
};
@@ -223,6 +223,7 @@ describe('WorkspaceEntityManager', () => {
IS_JUNCTION_RELATIONS_ENABLED: false,
IS_SSE_DB_EVENTS_ENABLED: false,
IS_COMMAND_MENU_ITEM_ENABLED: false,
IS_NAVIGATION_MENU_ITEM_ENABLED: false,
IS_FILES_FIELD_ENABLED: false,
IS_APPLICATION_INSTALLATION_FROM_TARBALL_ENABLED: false,
},
@@ -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,