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
@@ -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;
|
||||
}
|
||||
|
||||
+21
@@ -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;
|
||||
}
|
||||
+8
@@ -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,
|
||||
|
||||
+29
@@ -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,
|
||||
);
|
||||
}
|
||||
}
|
||||
+41
@@ -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,
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
+12
@@ -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,
|
||||
|
||||
+35
-1
@@ -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,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
+163
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+75
@@ -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',
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
+75
@@ -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;
|
||||
};
|
||||
Reference in New Issue
Block a user