From 5a617e471832dd17d1c609ad1fdf2a90ae9a476e Mon Sep 17 00:00:00 2001 From: Abdul Rahman <81605929+abdulrahmancodes@users.noreply.github.com> Date: Sat, 17 Jan 2026 00:32:07 +0530 Subject: [PATCH] Add command menu item entity (#17181) --- .../src/generated-metadata/graphql.ts | 64 ++++ .../twenty-front/src/generated/graphql.ts | 64 ++++ .../hooks/useMetadataErrorHandler.ts | 1 + .../1768503887441-addCommandMenuItemEntity.ts | 53 ++++ .../enums/feature-flag-key.enum.ts | 1 + .../command-menu-item.exception.ts | 37 +++ .../command-menu-item.module.ts | 29 ++ .../command-menu-item.resolver.ts | 80 +++++ .../command-menu-item.service.ts | 279 ++++++++++++++++++ .../dtos/command-menu-item.dto.ts | 73 +++++ .../dtos/create-command-menu-item.input.ts | 46 +++ .../dtos/update-command-menu-item.input.ts | 46 +++ .../entities/command-menu-item.entity.ts | 69 +++++ ...-item-graphql-api-exception.interceptor.ts | 24 ++ ...item-graphql-api-exception-handler.util.ts | 26 ++ ...-menu-item-editable-properties.constant.ts | 9 + .../flat-command-menu-item.module.ts | 16 + ...lat-command-menu-item-map-cache.service.ts | 45 +++ .../types/flat-command-menu-item-maps.type.ts | 4 + .../types/flat-command-menu-item.type.ts | 4 + ...m-entity-to-flat-command-menu-item.util.ts | 22 ++ ...o-flat-command-menu-item-to-create.util.ts | 36 +++ ...to-flat-command-menu-item-or-throw.util.ts | 31 ++ ...menu-item-to-command-menu-item-dto.util.ts | 19 ++ ...mmand-menu-item-to-update-or-throw.util.ts | 44 +++ ...rties-to-compare-and-stringify.constant.ts | 5 + .../all-metadata-relations.constant.ts | 12 + ...quired-metadata-for-validation.constant.ts | 3 + .../all-flat-entity-types-by-metadata-name.ts | 30 +- ...a-related-metadata-names.util.spec.ts.snap | 6 + .../metadata-engine.module.ts | 3 + .../workspace-entity-manager.spec.ts | 1 + .../types/workspace-cache-key.type.ts | 1 + ...ce-migration-build-orchestrator.service.ts | 48 +++ ...migration-command-menu-item-action.type.ts | 12 + ...mmand-menu-item-actions-builder.service.ts | 116 ++++++++ ...lat-command-menu-item-validator.service.ts | 124 ++++++++ ...ace-migration-builder-validators.module.ts | 3 + .../workspace-migration-builder.module.ts | 3 + ...ommand-menu-item-action-handler.service.ts | 40 +++ ...ommand-menu-item-action-handler.service.ts | 37 +++ ...ommand-menu-item-action-handler.service.ts | 39 +++ ...migration-runner-action-handlers.module.ts | 7 + ...ate-action-on-all-flat-entity-maps.util.ts | 5 +- ...ete-action-on-all-flat-entity-maps.util.ts | 5 +- ...ate-action-on-all-flat-entity-maps.util.ts | 1 + .../workflow-trigger.module.ts | 4 + .../workflow-trigger.workspace-service.ts | 106 ++++++- ...enu-item-creation.integration-spec.ts.snap | 95 ++++++ ...enu-item-deletion.integration-spec.ts.snap | 57 ++++ ...-menu-item-update.integration-spec.ts.snap | 93 ++++++ ...and-menu-item-creation.integration-spec.ts | 94 ++++++ ...and-menu-item-deletion.integration-spec.ts | 81 +++++ ...mmand-menu-item-update.integration-spec.ts | 133 +++++++++ ...and-menu-item-creation.integration-spec.ts | 165 +++++++++++ ...and-menu-item-deletion.integration-spec.ts | 60 ++++ ...mmand-menu-item-update.integration-spec.ts | 171 +++++++++++ ...te-command-menu-item-query-factory.util.ts | 35 +++ .../utils/create-command-menu-item.util.ts | 43 +++ ...te-command-menu-item-query-factory.util.ts | 27 ++ .../utils/delete-command-menu-item.util.ts | 43 +++ ...d-command-menu-items-query-factory.util.ts | 27 ++ .../utils/find-command-menu-items.util.ts | 39 +++ ...te-command-menu-item-query-factory.util.ts | 35 +++ .../utils/update-command-menu-item.util.ts | 43 +++ .../metadata/all-metadata-name.constant.ts | 1 + 66 files changed, 2960 insertions(+), 15 deletions(-) create mode 100644 packages/twenty-server/src/database/typeorm/core/migrations/common/1768503887441-addCommandMenuItemEntity.ts create mode 100644 packages/twenty-server/src/engine/metadata-modules/command-menu-item/command-menu-item.exception.ts create mode 100644 packages/twenty-server/src/engine/metadata-modules/command-menu-item/command-menu-item.module.ts create mode 100644 packages/twenty-server/src/engine/metadata-modules/command-menu-item/command-menu-item.resolver.ts create mode 100644 packages/twenty-server/src/engine/metadata-modules/command-menu-item/command-menu-item.service.ts create mode 100644 packages/twenty-server/src/engine/metadata-modules/command-menu-item/dtos/command-menu-item.dto.ts create mode 100644 packages/twenty-server/src/engine/metadata-modules/command-menu-item/dtos/create-command-menu-item.input.ts create mode 100644 packages/twenty-server/src/engine/metadata-modules/command-menu-item/dtos/update-command-menu-item.input.ts create mode 100644 packages/twenty-server/src/engine/metadata-modules/command-menu-item/entities/command-menu-item.entity.ts create mode 100644 packages/twenty-server/src/engine/metadata-modules/command-menu-item/interceptors/command-menu-item-graphql-api-exception.interceptor.ts create mode 100644 packages/twenty-server/src/engine/metadata-modules/command-menu-item/utils/command-menu-item-graphql-api-exception-handler.util.ts create mode 100644 packages/twenty-server/src/engine/metadata-modules/flat-command-menu-item/constants/flat-command-menu-item-editable-properties.constant.ts create mode 100644 packages/twenty-server/src/engine/metadata-modules/flat-command-menu-item/flat-command-menu-item.module.ts create mode 100644 packages/twenty-server/src/engine/metadata-modules/flat-command-menu-item/services/workspace-flat-command-menu-item-map-cache.service.ts create mode 100644 packages/twenty-server/src/engine/metadata-modules/flat-command-menu-item/types/flat-command-menu-item-maps.type.ts create mode 100644 packages/twenty-server/src/engine/metadata-modules/flat-command-menu-item/types/flat-command-menu-item.type.ts create mode 100644 packages/twenty-server/src/engine/metadata-modules/flat-command-menu-item/utils/from-command-menu-item-entity-to-flat-command-menu-item.util.ts create mode 100644 packages/twenty-server/src/engine/metadata-modules/flat-command-menu-item/utils/from-create-command-menu-item-input-to-flat-command-menu-item-to-create.util.ts create mode 100644 packages/twenty-server/src/engine/metadata-modules/flat-command-menu-item/utils/from-delete-command-menu-item-input-to-flat-command-menu-item-or-throw.util.ts create mode 100644 packages/twenty-server/src/engine/metadata-modules/flat-command-menu-item/utils/from-flat-command-menu-item-to-command-menu-item-dto.util.ts create mode 100644 packages/twenty-server/src/engine/metadata-modules/flat-command-menu-item/utils/from-update-command-menu-item-input-to-flat-command-menu-item-to-update-or-throw.util.ts create mode 100644 packages/twenty-server/src/engine/workspace-manager/workspace-migration/workspace-migration-builder/builders/command-menu-item/types/workspace-migration-command-menu-item-action.type.ts create mode 100644 packages/twenty-server/src/engine/workspace-manager/workspace-migration/workspace-migration-builder/builders/command-menu-item/workspace-migration-command-menu-item-actions-builder.service.ts create mode 100644 packages/twenty-server/src/engine/workspace-manager/workspace-migration/workspace-migration-builder/validators/services/flat-command-menu-item-validator.service.ts create mode 100644 packages/twenty-server/src/engine/workspace-manager/workspace-migration/workspace-migration-runner/action-handlers/command-menu-item/services/create-command-menu-item-action-handler.service.ts create mode 100644 packages/twenty-server/src/engine/workspace-manager/workspace-migration/workspace-migration-runner/action-handlers/command-menu-item/services/delete-command-menu-item-action-handler.service.ts create mode 100644 packages/twenty-server/src/engine/workspace-manager/workspace-migration/workspace-migration-runner/action-handlers/command-menu-item/services/update-command-menu-item-action-handler.service.ts create mode 100644 packages/twenty-server/test/integration/metadata/suites/command-menu-item/__snapshots__/failing-command-menu-item-creation.integration-spec.ts.snap create mode 100644 packages/twenty-server/test/integration/metadata/suites/command-menu-item/__snapshots__/failing-command-menu-item-deletion.integration-spec.ts.snap create mode 100644 packages/twenty-server/test/integration/metadata/suites/command-menu-item/__snapshots__/failing-command-menu-item-update.integration-spec.ts.snap create mode 100644 packages/twenty-server/test/integration/metadata/suites/command-menu-item/failing-command-menu-item-creation.integration-spec.ts create mode 100644 packages/twenty-server/test/integration/metadata/suites/command-menu-item/failing-command-menu-item-deletion.integration-spec.ts create mode 100644 packages/twenty-server/test/integration/metadata/suites/command-menu-item/failing-command-menu-item-update.integration-spec.ts create mode 100644 packages/twenty-server/test/integration/metadata/suites/command-menu-item/successful-command-menu-item-creation.integration-spec.ts create mode 100644 packages/twenty-server/test/integration/metadata/suites/command-menu-item/successful-command-menu-item-deletion.integration-spec.ts create mode 100644 packages/twenty-server/test/integration/metadata/suites/command-menu-item/successful-command-menu-item-update.integration-spec.ts create mode 100644 packages/twenty-server/test/integration/metadata/suites/command-menu-item/utils/create-command-menu-item-query-factory.util.ts create mode 100644 packages/twenty-server/test/integration/metadata/suites/command-menu-item/utils/create-command-menu-item.util.ts create mode 100644 packages/twenty-server/test/integration/metadata/suites/command-menu-item/utils/delete-command-menu-item-query-factory.util.ts create mode 100644 packages/twenty-server/test/integration/metadata/suites/command-menu-item/utils/delete-command-menu-item.util.ts create mode 100644 packages/twenty-server/test/integration/metadata/suites/command-menu-item/utils/find-command-menu-items-query-factory.util.ts create mode 100644 packages/twenty-server/test/integration/metadata/suites/command-menu-item/utils/find-command-menu-items.util.ts create mode 100644 packages/twenty-server/test/integration/metadata/suites/command-menu-item/utils/update-command-menu-item-query-factory.util.ts create mode 100644 packages/twenty-server/test/integration/metadata/suites/command-menu-item/utils/update-command-menu-item.util.ts diff --git a/packages/twenty-front/src/generated-metadata/graphql.ts b/packages/twenty-front/src/generated-metadata/graphql.ts index a65d5252f7f..26fc6533fb3 100644 --- a/packages/twenty-front/src/generated-metadata/graphql.ts +++ b/packages/twenty-front/src/generated-metadata/graphql.ts @@ -609,6 +609,26 @@ export type ClientAiModelConfig = { provider: ModelProvider; }; +export type CommandMenuItem = { + __typename?: 'CommandMenuItem'; + applicationId?: Maybe; + availabilityObjectMetadataId?: Maybe; + availabilityType: CommandMenuItemAvailabilityType; + createdAt: Scalars['DateTime']; + icon?: Maybe; + id: Scalars['UUID']; + isPinned: Scalars['Boolean']; + label: Scalars['String']; + updatedAt: Scalars['DateTime']; + workflowVersionId: Scalars['UUID']; +}; + +export enum CommandMenuItemAvailabilityType { + BULK_RECORDS = 'BULK_RECORDS', + GLOBAL = 'GLOBAL', + SINGLE_RECORD = 'SINGLE_RECORD' +} + export type ComputeStepOutputSchemaInput = { /** Step JSON format */ step: Scalars['JSON']; @@ -836,6 +856,15 @@ export type CreateApprovedAccessDomainInput = { email: Scalars['String']; }; +export type CreateCommandMenuItemInput = { + availabilityObjectMetadataId?: InputMaybe; + availabilityType?: InputMaybe; + icon?: InputMaybe; + isPinned?: InputMaybe; + label: Scalars['String']; + workflowVersionId: Scalars['UUID']; +}; + export type CreateCronTriggerInput = { serverlessFunctionId: Scalars['String']; settings: Scalars['JSON']; @@ -1373,6 +1402,7 @@ export type FeatureFlagDto = { export enum FeatureFlagKey { IS_AI_ENABLED = 'IS_AI_ENABLED', IS_APPLICATION_ENABLED = 'IS_APPLICATION_ENABLED', + IS_COMMAND_MENU_ITEM_ENABLED = 'IS_COMMAND_MENU_ITEM_ENABLED', IS_DASHBOARD_V2_ENABLED = 'IS_DASHBOARD_V2_ENABLED', IS_EMAILING_DOMAIN_ENABLED = 'IS_EMAILING_DOMAIN_ENABLED', IS_IF_ELSE_ENABLED = 'IS_IF_ELSE_ENABLED', @@ -1878,6 +1908,7 @@ export type Mutation = { createApiKey: ApiKey; createApprovedAccessDomain: ApprovedAccessDomain; createChatThread: AgentChatThread; + createCommandMenuItem: CommandMenuItem; createCoreView: CoreView; createCoreViewField: CoreViewField; createCoreViewFilter: CoreViewFilter; @@ -1917,6 +1948,7 @@ export type Mutation = { deactivateSkill: Skill; deactivateWorkflowVersion: Scalars['Boolean']; deleteApprovedAccessDomain: Scalars['Boolean']; + deleteCommandMenuItem: CommandMenuItem; deleteCoreView: Scalars['Boolean']; deleteCoreViewField: CoreViewField; deleteCoreViewFilter: CoreViewFilter; @@ -2007,6 +2039,7 @@ export type Mutation = { trackAnalytics: Analytics; uninstallApplication: Scalars['Boolean']; updateApiKey?: Maybe; + updateCommandMenuItem: CommandMenuItem; updateCoreView: CoreView; updateCoreViewField: CoreViewField; updateCoreViewFilter: CoreViewFilter; @@ -2125,6 +2158,11 @@ export type MutationCreateApprovedAccessDomainArgs = { }; +export type MutationCreateCommandMenuItemArgs = { + input: CreateCommandMenuItemInput; +}; + + export type MutationCreateCoreViewArgs = { input: CreateViewInput; }; @@ -2326,6 +2364,11 @@ export type MutationDeleteApprovedAccessDomainArgs = { }; +export type MutationDeleteCommandMenuItemArgs = { + id: Scalars['UUID']; +}; + + export type MutationDeleteCoreViewArgs = { id: Scalars['String']; }; @@ -2756,6 +2799,11 @@ export type MutationUpdateApiKeyArgs = { }; +export type MutationUpdateCommandMenuItemArgs = { + input: UpdateCommandMenuItemInput; +}; + + export type MutationUpdateCoreViewArgs = { id: Scalars['String']; input: UpdateViewInput; @@ -3391,6 +3439,8 @@ export type Query = { chatThreads: Array; checkUserExists: CheckUserExistOutput; checkWorkspaceInviteHashIsValid: WorkspaceInviteHashValidOutput; + commandMenuItem?: Maybe; + commandMenuItems: Array; currentUser: User; currentWorkspace: Workspace; field: Field; @@ -3509,6 +3559,11 @@ export type QueryCheckWorkspaceInviteHashIsValidArgs = { }; +export type QueryCommandMenuItemArgs = { + id: Scalars['UUID']; +}; + + export type QueryFieldArgs = { id: Scalars['UUID']; }; @@ -4494,6 +4549,15 @@ export type UpdateApiKeyInput = { revokedAt?: InputMaybe; }; +export type UpdateCommandMenuItemInput = { + availabilityObjectMetadataId?: InputMaybe; + availabilityType?: InputMaybe; + icon?: InputMaybe; + id: Scalars['UUID']; + isPinned?: InputMaybe; + label?: InputMaybe; +}; + export type UpdateCronTriggerInput = { /** The id of the cron trigger to update */ id: Scalars['String']; diff --git a/packages/twenty-front/src/generated/graphql.ts b/packages/twenty-front/src/generated/graphql.ts index a71e0540469..405c337305c 100644 --- a/packages/twenty-front/src/generated/graphql.ts +++ b/packages/twenty-front/src/generated/graphql.ts @@ -609,6 +609,26 @@ export type ClientAiModelConfig = { provider: ModelProvider; }; +export type CommandMenuItem = { + __typename?: 'CommandMenuItem'; + applicationId?: Maybe; + availabilityObjectMetadataId?: Maybe; + availabilityType: CommandMenuItemAvailabilityType; + createdAt: Scalars['DateTime']; + icon?: Maybe; + id: Scalars['UUID']; + isPinned: Scalars['Boolean']; + label: Scalars['String']; + updatedAt: Scalars['DateTime']; + workflowVersionId: Scalars['UUID']; +}; + +export enum CommandMenuItemAvailabilityType { + BULK_RECORDS = 'BULK_RECORDS', + GLOBAL = 'GLOBAL', + SINGLE_RECORD = 'SINGLE_RECORD' +} + export type ComputeStepOutputSchemaInput = { /** Step JSON format */ step: Scalars['JSON']; @@ -832,6 +852,15 @@ export type CreateApprovedAccessDomainInput = { email: Scalars['String']; }; +export type CreateCommandMenuItemInput = { + availabilityObjectMetadataId?: InputMaybe; + availabilityType?: InputMaybe; + icon?: InputMaybe; + isPinned?: InputMaybe; + label: Scalars['String']; + workflowVersionId: Scalars['UUID']; +}; + export type CreateCronTriggerInput = { serverlessFunctionId: Scalars['String']; settings: Scalars['JSON']; @@ -1350,6 +1379,7 @@ export type FeatureFlagDto = { export enum FeatureFlagKey { IS_AI_ENABLED = 'IS_AI_ENABLED', IS_APPLICATION_ENABLED = 'IS_APPLICATION_ENABLED', + IS_COMMAND_MENU_ITEM_ENABLED = 'IS_COMMAND_MENU_ITEM_ENABLED', IS_DASHBOARD_V2_ENABLED = 'IS_DASHBOARD_V2_ENABLED', IS_EMAILING_DOMAIN_ENABLED = 'IS_EMAILING_DOMAIN_ENABLED', IS_IF_ELSE_ENABLED = 'IS_IF_ELSE_ENABLED', @@ -1853,6 +1883,7 @@ export type Mutation = { computeStepOutputSchema: Scalars['JSON']; createApiKey: ApiKey; createApprovedAccessDomain: ApprovedAccessDomain; + createCommandMenuItem: CommandMenuItem; createCoreView: CoreView; createCoreViewField: CoreViewField; createCoreViewFilter: CoreViewFilter; @@ -1889,6 +1920,7 @@ export type Mutation = { createWorkflowVersionStep: WorkflowVersionStepChanges; deactivateWorkflowVersion: Scalars['Boolean']; deleteApprovedAccessDomain: Scalars['Boolean']; + deleteCommandMenuItem: CommandMenuItem; deleteCoreView: Scalars['Boolean']; deleteCoreViewField: CoreViewField; deleteCoreViewFilter: CoreViewFilter; @@ -1975,6 +2007,7 @@ export type Mutation = { trackAnalytics: Analytics; uninstallApplication: Scalars['Boolean']; updateApiKey?: Maybe; + updateCommandMenuItem: CommandMenuItem; updateCoreView: CoreView; updateCoreViewField: CoreViewField; updateCoreViewFilter: CoreViewFilter; @@ -2086,6 +2119,11 @@ export type MutationCreateApprovedAccessDomainArgs = { }; +export type MutationCreateCommandMenuItemArgs = { + input: CreateCommandMenuItemInput; +}; + + export type MutationCreateCoreViewArgs = { input: CreateViewInput; }; @@ -2267,6 +2305,11 @@ export type MutationDeleteApprovedAccessDomainArgs = { }; +export type MutationDeleteCommandMenuItemArgs = { + id: Scalars['UUID']; +}; + + export type MutationDeleteCoreViewArgs = { id: Scalars['String']; }; @@ -2676,6 +2719,11 @@ export type MutationUpdateApiKeyArgs = { }; +export type MutationUpdateCommandMenuItemArgs = { + input: UpdateCommandMenuItemInput; +}; + + export type MutationUpdateCoreViewArgs = { id: Scalars['String']; input: UpdateViewInput; @@ -3297,6 +3345,8 @@ export type Query = { billingPortalSession: BillingSessionOutput; checkUserExists: CheckUserExistOutput; checkWorkspaceInviteHashIsValid: WorkspaceInviteHashValidOutput; + commandMenuItem?: Maybe; + commandMenuItems: Array; currentUser: User; currentWorkspace: Workspace; field: Field; @@ -3396,6 +3446,11 @@ export type QueryCheckWorkspaceInviteHashIsValidArgs = { }; +export type QueryCommandMenuItemArgs = { + id: Scalars['UUID']; +}; + + export type QueryFindOneAgentArgs = { input: AgentIdInput; }; @@ -4338,6 +4393,15 @@ export type UpdateApiKeyInput = { revokedAt?: InputMaybe; }; +export type UpdateCommandMenuItemInput = { + availabilityObjectMetadataId?: InputMaybe; + availabilityType?: InputMaybe; + icon?: InputMaybe; + id: Scalars['UUID']; + isPinned?: InputMaybe; + label?: InputMaybe; +}; + export type UpdateCronTriggerInput = { /** The id of the cron trigger to update */ id: Scalars['String']; diff --git a/packages/twenty-front/src/modules/metadata-error-handler/hooks/useMetadataErrorHandler.ts b/packages/twenty-front/src/modules/metadata-error-handler/hooks/useMetadataErrorHandler.ts index e5e3cac0f4b..edcf41c8bd3 100644 --- a/packages/twenty-front/src/modules/metadata-error-handler/hooks/useMetadataErrorHandler.ts +++ b/packages/twenty-front/src/modules/metadata-error-handler/hooks/useMetadataErrorHandler.ts @@ -42,6 +42,7 @@ export const useMetadataErrorHandler = () => { rowLevelPermissionPredicate: t`row level permission predicate`, rowLevelPermissionPredicateGroup: t`row level permission predicate group`, viewFilterGroup: t`view filter group`, + commandMenuItem: t`command menu item`, frontComponent: t`front component`, } as const satisfies Record; diff --git a/packages/twenty-server/src/database/typeorm/core/migrations/common/1768503887441-addCommandMenuItemEntity.ts b/packages/twenty-server/src/database/typeorm/core/migrations/common/1768503887441-addCommandMenuItemEntity.ts new file mode 100644 index 00000000000..4b8cd2ed2db --- /dev/null +++ b/packages/twenty-server/src/database/typeorm/core/migrations/common/1768503887441-addCommandMenuItemEntity.ts @@ -0,0 +1,53 @@ +import { type MigrationInterface, type QueryRunner } from 'typeorm'; + +export class AddCommandMenuItemEntity1768503887441 + implements MigrationInterface +{ + name = 'AddCommandMenuItemEntity1768503887441'; + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query( + `CREATE TYPE "core"."commandMenuItem_availabilitytype_enum" AS ENUM('GLOBAL', 'SINGLE_RECORD', 'BULK_RECORDS')`, + ); + await queryRunner.query( + `CREATE TABLE "core"."commandMenuItem" ("workspaceId" uuid NOT NULL, "universalIdentifier" uuid NOT NULL, "applicationId" uuid NOT NULL, "id" uuid NOT NULL DEFAULT uuid_generate_v4(), "workflowVersionId" uuid NOT NULL, "label" character varying NOT NULL, "icon" character varying, "isPinned" boolean NOT NULL DEFAULT false, "availabilityType" "core"."commandMenuItem_availabilitytype_enum" NOT NULL DEFAULT 'GLOBAL', "availabilityObjectMetadataId" uuid, "createdAt" TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT now(), "updatedAt" TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT now(), CONSTRAINT "PK_fd076dc869e721593133fe8a007" PRIMARY KEY ("id"))`, + ); + await queryRunner.query( + `CREATE UNIQUE INDEX "IDX_a3a5976e1b580ba1086c595802" ON "core"."commandMenuItem" ("workspaceId", "universalIdentifier")`, + ); + await queryRunner.query( + `CREATE INDEX "IDX_COMMAND_MENU_ITEM_WORKFLOW_VERSION_ID_WORKSPACE_ID" ON "core"."commandMenuItem" ("workflowVersionId", "workspaceId")`, + ); + await queryRunner.query( + `ALTER TABLE "core"."commandMenuItem" ADD CONSTRAINT "FK_94947770f00413f134a1ec01dd7" FOREIGN KEY ("workspaceId") REFERENCES "core"."workspace"("id") ON DELETE CASCADE ON UPDATE NO ACTION`, + ); + await queryRunner.query( + `ALTER TABLE "core"."commandMenuItem" ADD CONSTRAINT "FK_ad42dd64b117491a38120466d65" FOREIGN KEY ("applicationId") REFERENCES "core"."application"("id") ON DELETE CASCADE ON UPDATE NO ACTION`, + ); + await queryRunner.query( + `ALTER TABLE "core"."commandMenuItem" ADD CONSTRAINT "FK_6e050fb56a8385718123a4f8bc6" FOREIGN KEY ("availabilityObjectMetadataId") REFERENCES "core"."objectMetadata"("id") ON DELETE CASCADE ON UPDATE NO ACTION`, + ); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query( + `ALTER TABLE "core"."commandMenuItem" DROP CONSTRAINT "FK_6e050fb56a8385718123a4f8bc6"`, + ); + await queryRunner.query( + `ALTER TABLE "core"."commandMenuItem" DROP CONSTRAINT "FK_ad42dd64b117491a38120466d65"`, + ); + await queryRunner.query( + `ALTER TABLE "core"."commandMenuItem" DROP CONSTRAINT "FK_94947770f00413f134a1ec01dd7"`, + ); + await queryRunner.query( + `DROP INDEX "core"."IDX_COMMAND_MENU_ITEM_WORKFLOW_VERSION_ID_WORKSPACE_ID"`, + ); + await queryRunner.query( + `DROP INDEX "core"."IDX_a3a5976e1b580ba1086c595802"`, + ); + await queryRunner.query(`DROP TABLE "core"."commandMenuItem"`); + await queryRunner.query( + `DROP TYPE "core"."commandMenuItem_availabilitytype_enum"`, + ); + } +} diff --git a/packages/twenty-server/src/engine/core-modules/feature-flag/enums/feature-flag-key.enum.ts b/packages/twenty-server/src/engine/core-modules/feature-flag/enums/feature-flag-key.enum.ts index fbbf4b90f53..2bf2cd2faa0 100644 --- a/packages/twenty-server/src/engine/core-modules/feature-flag/enums/feature-flag-key.enum.ts +++ b/packages/twenty-server/src/engine/core-modules/feature-flag/enums/feature-flag-key.enum.ts @@ -12,4 +12,5 @@ export enum FeatureFlagKey { IS_ROW_LEVEL_PERMISSION_PREDICATES_ENABLED = 'IS_ROW_LEVEL_PERMISSION_PREDICATES_ENABLED', IS_IF_ELSE_ENABLED = 'IS_IF_ELSE_ENABLED', IS_SSE_DB_EVENTS_ENABLED = 'IS_SSE_DB_EVENTS_ENABLED', + IS_COMMAND_MENU_ITEM_ENABLED = 'IS_COMMAND_MENU_ITEM_ENABLED', } diff --git a/packages/twenty-server/src/engine/metadata-modules/command-menu-item/command-menu-item.exception.ts b/packages/twenty-server/src/engine/metadata-modules/command-menu-item/command-menu-item.exception.ts new file mode 100644 index 00000000000..bbe41058839 --- /dev/null +++ b/packages/twenty-server/src/engine/metadata-modules/command-menu-item/command-menu-item.exception.ts @@ -0,0 +1,37 @@ +import { type MessageDescriptor } from '@lingui/core'; +import { msg } from '@lingui/core/macro'; +import { assertUnreachable } from 'twenty-shared/utils'; + +import { CustomException } from 'src/utils/custom-exception'; + +export enum CommandMenuItemExceptionCode { + COMMAND_MENU_ITEM_NOT_FOUND = 'COMMAND_MENU_ITEM_NOT_FOUND', + INVALID_COMMAND_MENU_ITEM_INPUT = 'INVALID_COMMAND_MENU_ITEM_INPUT', +} + +const getCommandMenuItemExceptionUserFriendlyMessage = ( + code: CommandMenuItemExceptionCode, +) => { + switch (code) { + case CommandMenuItemExceptionCode.COMMAND_MENU_ITEM_NOT_FOUND: + return msg`Command menu item not found.`; + case CommandMenuItemExceptionCode.INVALID_COMMAND_MENU_ITEM_INPUT: + return msg`Invalid command menu item input.`; + default: + assertUnreachable(code); + } +}; + +export class CommandMenuItemException extends CustomException { + constructor( + message: string, + code: CommandMenuItemExceptionCode, + { userFriendlyMessage }: { userFriendlyMessage?: MessageDescriptor } = {}, + ) { + super(message, code, { + userFriendlyMessage: + userFriendlyMessage ?? + getCommandMenuItemExceptionUserFriendlyMessage(code), + }); + } +} diff --git a/packages/twenty-server/src/engine/metadata-modules/command-menu-item/command-menu-item.module.ts b/packages/twenty-server/src/engine/metadata-modules/command-menu-item/command-menu-item.module.ts new file mode 100644 index 00000000000..6499e54a4e5 --- /dev/null +++ b/packages/twenty-server/src/engine/metadata-modules/command-menu-item/command-menu-item.module.ts @@ -0,0 +1,29 @@ +import { Module } from '@nestjs/common'; + +import { ApplicationModule } from 'src/engine/core-modules/application/application.module'; +import { FeatureFlagModule } from 'src/engine/core-modules/feature-flag/feature-flag.module'; +import { CommandMenuItemResolver } from 'src/engine/metadata-modules/command-menu-item/command-menu-item.resolver'; +import { CommandMenuItemService } from 'src/engine/metadata-modules/command-menu-item/command-menu-item.service'; +import { CommandMenuItemGraphqlApiExceptionInterceptor } from 'src/engine/metadata-modules/command-menu-item/interceptors/command-menu-item-graphql-api-exception.interceptor'; +import { FlatCommandMenuItemModule } from 'src/engine/metadata-modules/flat-command-menu-item/flat-command-menu-item.module'; +import { WorkspaceManyOrAllFlatEntityMapsCacheModule } from 'src/engine/metadata-modules/flat-entity/services/workspace-many-or-all-flat-entity-maps-cache.module'; +import { WorkspaceMigrationBuilderGraphqlApiExceptionInterceptor } from 'src/engine/workspace-manager/workspace-migration/interceptors/workspace-migration-builder-graphql-api-exception.interceptor'; +import { WorkspaceMigrationModule } from 'src/engine/workspace-manager/workspace-migration/workspace-migration.module'; + +@Module({ + imports: [ + WorkspaceManyOrAllFlatEntityMapsCacheModule, + WorkspaceMigrationModule, + ApplicationModule, + FlatCommandMenuItemModule, + FeatureFlagModule, + ], + providers: [ + CommandMenuItemService, + CommandMenuItemResolver, + CommandMenuItemGraphqlApiExceptionInterceptor, + WorkspaceMigrationBuilderGraphqlApiExceptionInterceptor, + ], + exports: [CommandMenuItemService], +}) +export class CommandMenuItemModule {} diff --git a/packages/twenty-server/src/engine/metadata-modules/command-menu-item/command-menu-item.resolver.ts b/packages/twenty-server/src/engine/metadata-modules/command-menu-item/command-menu-item.resolver.ts new file mode 100644 index 00000000000..cd3ac0505b4 --- /dev/null +++ b/packages/twenty-server/src/engine/metadata-modules/command-menu-item/command-menu-item.resolver.ts @@ -0,0 +1,80 @@ +import { UseGuards, UseInterceptors } from '@nestjs/common'; +import { Args, Mutation, Query, Resolver } from '@nestjs/graphql'; + +import { UUIDScalarType } from 'src/engine/api/graphql/workspace-schema-builder/graphql-types/scalars'; +import { FeatureFlagKey } from 'src/engine/core-modules/feature-flag/enums/feature-flag-key.enum'; +import { WorkspaceEntity } from 'src/engine/core-modules/workspace/workspace.entity'; +import { AuthWorkspace } from 'src/engine/decorators/auth/auth-workspace.decorator'; +import { + FeatureFlagGuard, + RequireFeatureFlag, +} from 'src/engine/guards/feature-flag.guard'; +import { NoPermissionGuard } from 'src/engine/guards/no-permission.guard'; +import { WorkspaceAuthGuard } from 'src/engine/guards/workspace-auth.guard'; +import { CommandMenuItemService } from 'src/engine/metadata-modules/command-menu-item/command-menu-item.service'; +import { CommandMenuItemDTO } from 'src/engine/metadata-modules/command-menu-item/dtos/command-menu-item.dto'; +import { CreateCommandMenuItemInput } from 'src/engine/metadata-modules/command-menu-item/dtos/create-command-menu-item.input'; +import { UpdateCommandMenuItemInput } from 'src/engine/metadata-modules/command-menu-item/dtos/update-command-menu-item.input'; +import { CommandMenuItemGraphqlApiExceptionInterceptor } from 'src/engine/metadata-modules/command-menu-item/interceptors/command-menu-item-graphql-api-exception.interceptor'; +import { WorkspaceMigrationBuilderGraphqlApiExceptionInterceptor } from 'src/engine/workspace-manager/workspace-migration/interceptors/workspace-migration-builder-graphql-api-exception.interceptor'; + +@UseGuards(WorkspaceAuthGuard, FeatureFlagGuard) +@UseInterceptors( + WorkspaceMigrationBuilderGraphqlApiExceptionInterceptor, + CommandMenuItemGraphqlApiExceptionInterceptor, +) +@Resolver(() => CommandMenuItemDTO) +export class CommandMenuItemResolver { + constructor( + private readonly commandMenuItemService: CommandMenuItemService, + ) {} + + @Query(() => [CommandMenuItemDTO]) + @UseGuards(NoPermissionGuard) + @RequireFeatureFlag(FeatureFlagKey.IS_COMMAND_MENU_ITEM_ENABLED) + async commandMenuItems( + @AuthWorkspace() workspace: WorkspaceEntity, + ): Promise { + return await this.commandMenuItemService.findAll(workspace.id); + } + + @Query(() => CommandMenuItemDTO, { nullable: true }) + @UseGuards(NoPermissionGuard) + @RequireFeatureFlag(FeatureFlagKey.IS_COMMAND_MENU_ITEM_ENABLED) + async commandMenuItem( + @Args('id', { type: () => UUIDScalarType }) id: string, + @AuthWorkspace() workspace: WorkspaceEntity, + ): Promise { + return await this.commandMenuItemService.findById(id, workspace.id); + } + + @Mutation(() => CommandMenuItemDTO) + @UseGuards(NoPermissionGuard) + @RequireFeatureFlag(FeatureFlagKey.IS_COMMAND_MENU_ITEM_ENABLED) + async createCommandMenuItem( + @Args('input') input: CreateCommandMenuItemInput, + @AuthWorkspace() workspace: WorkspaceEntity, + ): Promise { + return await this.commandMenuItemService.create(input, workspace.id); + } + + @Mutation(() => CommandMenuItemDTO) + @UseGuards(NoPermissionGuard) + @RequireFeatureFlag(FeatureFlagKey.IS_COMMAND_MENU_ITEM_ENABLED) + async updateCommandMenuItem( + @Args('input') input: UpdateCommandMenuItemInput, + @AuthWorkspace() workspace: WorkspaceEntity, + ): Promise { + return await this.commandMenuItemService.update(input, workspace.id); + } + + @Mutation(() => CommandMenuItemDTO) + @UseGuards(NoPermissionGuard) + @RequireFeatureFlag(FeatureFlagKey.IS_COMMAND_MENU_ITEM_ENABLED) + async deleteCommandMenuItem( + @Args('id', { type: () => UUIDScalarType }) id: string, + @AuthWorkspace() workspace: WorkspaceEntity, + ): Promise { + return await this.commandMenuItemService.delete(id, workspace.id); + } +} diff --git a/packages/twenty-server/src/engine/metadata-modules/command-menu-item/command-menu-item.service.ts b/packages/twenty-server/src/engine/metadata-modules/command-menu-item/command-menu-item.service.ts new file mode 100644 index 00000000000..dc9a9262f0b --- /dev/null +++ b/packages/twenty-server/src/engine/metadata-modules/command-menu-item/command-menu-item.service.ts @@ -0,0 +1,279 @@ +import { Injectable } from '@nestjs/common'; + +import { isDefined } from 'twenty-shared/utils'; + +import { ApplicationService } from 'src/engine/core-modules/application/application.service'; +import { + CommandMenuItemException, + CommandMenuItemExceptionCode, +} from 'src/engine/metadata-modules/command-menu-item/command-menu-item.exception'; +import { type CommandMenuItemDTO } from 'src/engine/metadata-modules/command-menu-item/dtos/command-menu-item.dto'; +import { type CreateCommandMenuItemInput } from 'src/engine/metadata-modules/command-menu-item/dtos/create-command-menu-item.input'; +import { type UpdateCommandMenuItemInput } from 'src/engine/metadata-modules/command-menu-item/dtos/update-command-menu-item.input'; +import { type FlatCommandMenuItem } from 'src/engine/metadata-modules/flat-command-menu-item/types/flat-command-menu-item.type'; +import { fromCreateCommandMenuItemInputToFlatCommandMenuItemToCreate } from 'src/engine/metadata-modules/flat-command-menu-item/utils/from-create-command-menu-item-input-to-flat-command-menu-item-to-create.util'; +import { fromDeleteCommandMenuItemInputToFlatCommandMenuItemOrThrow } from 'src/engine/metadata-modules/flat-command-menu-item/utils/from-delete-command-menu-item-input-to-flat-command-menu-item-or-throw.util'; +import { fromFlatCommandMenuItemToCommandMenuItemDto } from 'src/engine/metadata-modules/flat-command-menu-item/utils/from-flat-command-menu-item-to-command-menu-item-dto.util'; +import { fromUpdateCommandMenuItemInputToFlatCommandMenuItemToUpdateOrThrow } from 'src/engine/metadata-modules/flat-command-menu-item/utils/from-update-command-menu-item-input-to-flat-command-menu-item-to-update-or-throw.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'; +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 CommandMenuItemService { + constructor( + private readonly workspaceMigrationValidateBuildAndRunService: WorkspaceMigrationValidateBuildAndRunService, + private readonly workspaceManyOrAllFlatEntityMapsCacheService: WorkspaceManyOrAllFlatEntityMapsCacheService, + private readonly applicationService: ApplicationService, + ) {} + + async findAll(workspaceId: string): Promise { + const { flatCommandMenuItemMaps } = + await this.workspaceManyOrAllFlatEntityMapsCacheService.getOrRecomputeManyOrAllFlatEntityMaps( + { + workspaceId, + flatMapsKeys: ['flatCommandMenuItemMaps'], + }, + ); + + return Object.values(flatCommandMenuItemMaps.byId) + .filter(isDefined) + .sort((a, b) => a.label.localeCompare(b.label)) + .map(fromFlatCommandMenuItemToCommandMenuItemDto); + } + + async findById( + id: string, + workspaceId: string, + ): Promise { + const { flatCommandMenuItemMaps } = + await this.workspaceManyOrAllFlatEntityMapsCacheService.getOrRecomputeManyOrAllFlatEntityMaps( + { + workspaceId, + flatMapsKeys: ['flatCommandMenuItemMaps'], + }, + ); + + const flatCommandMenuItem = findFlatEntityByIdInFlatEntityMaps({ + flatEntityId: id, + flatEntityMaps: flatCommandMenuItemMaps, + }); + + if (!isDefined(flatCommandMenuItem)) { + return null; + } + + return fromFlatCommandMenuItemToCommandMenuItemDto(flatCommandMenuItem); + } + + async findByIdOrThrow( + id: string, + workspaceId: string, + ): Promise { + const commandMenuItem = await this.findById(id, workspaceId); + + if (!isDefined(commandMenuItem)) { + throw new CommandMenuItemException( + 'Command menu item not found', + CommandMenuItemExceptionCode.COMMAND_MENU_ITEM_NOT_FOUND, + ); + } + + return commandMenuItem; + } + + async create( + input: CreateCommandMenuItemInput, + workspaceId: string, + ): Promise { + const { workspaceCustomFlatApplication } = + await this.applicationService.findWorkspaceTwentyStandardAndCustomApplicationOrThrow( + { workspaceId }, + ); + + const flatCommandMenuItemToCreate = + fromCreateCommandMenuItemInputToFlatCommandMenuItemToCreate({ + createCommandMenuItemInput: input, + workspaceId, + applicationId: workspaceCustomFlatApplication.id, + }); + + const validateAndBuildResult = + await this.workspaceMigrationValidateBuildAndRunService.validateBuildAndRunWorkspaceMigration( + { + allFlatEntityOperationByMetadataName: { + commandMenuItem: { + flatEntityToCreate: [flatCommandMenuItemToCreate], + flatEntityToDelete: [], + flatEntityToUpdate: [], + }, + }, + workspaceId, + isSystemBuild: false, + }, + ); + + if (isDefined(validateAndBuildResult)) { + throw new WorkspaceMigrationBuilderException( + validateAndBuildResult, + 'Multiple validation errors occurred while creating command menu item', + ); + } + + const { flatCommandMenuItemMaps: recomputedFlatCommandMenuItemMaps } = + await this.workspaceManyOrAllFlatEntityMapsCacheService.getOrRecomputeManyOrAllFlatEntityMaps( + { + workspaceId, + flatMapsKeys: ['flatCommandMenuItemMaps'], + }, + ); + + return fromFlatCommandMenuItemToCommandMenuItemDto( + findFlatEntityByIdInFlatEntityMapsOrThrow({ + flatEntityId: flatCommandMenuItemToCreate.id, + flatEntityMaps: recomputedFlatCommandMenuItemMaps, + }), + ); + } + + async update( + input: UpdateCommandMenuItemInput, + workspaceId: string, + ): Promise { + const { flatCommandMenuItemMaps: existingFlatCommandMenuItemMaps } = + await this.workspaceManyOrAllFlatEntityMapsCacheService.getOrRecomputeManyOrAllFlatEntityMaps( + { + workspaceId, + flatMapsKeys: ['flatCommandMenuItemMaps'], + }, + ); + + const flatCommandMenuItemToUpdate = + fromUpdateCommandMenuItemInputToFlatCommandMenuItemToUpdateOrThrow({ + flatCommandMenuItemMaps: existingFlatCommandMenuItemMaps, + updateCommandMenuItemInput: input, + }); + + const validateAndBuildResult = + await this.workspaceMigrationValidateBuildAndRunService.validateBuildAndRunWorkspaceMigration( + { + allFlatEntityOperationByMetadataName: { + commandMenuItem: { + flatEntityToCreate: [], + flatEntityToDelete: [], + flatEntityToUpdate: [flatCommandMenuItemToUpdate], + }, + }, + workspaceId, + isSystemBuild: false, + }, + ); + + if (isDefined(validateAndBuildResult)) { + throw new WorkspaceMigrationBuilderException( + validateAndBuildResult, + 'Multiple validation errors occurred while updating command menu item', + ); + } + + const { flatCommandMenuItemMaps: recomputedFlatCommandMenuItemMaps } = + await this.workspaceManyOrAllFlatEntityMapsCacheService.getOrRecomputeManyOrAllFlatEntityMaps( + { + workspaceId, + flatMapsKeys: ['flatCommandMenuItemMaps'], + }, + ); + + return fromFlatCommandMenuItemToCommandMenuItemDto( + findFlatEntityByIdInFlatEntityMapsOrThrow({ + flatEntityId: input.id, + flatEntityMaps: recomputedFlatCommandMenuItemMaps, + }), + ); + } + + async delete(id: string, workspaceId: string): Promise { + const { flatCommandMenuItemMaps: existingFlatCommandMenuItemMaps } = + await this.workspaceManyOrAllFlatEntityMapsCacheService.getOrRecomputeManyOrAllFlatEntityMaps( + { + workspaceId, + flatMapsKeys: ['flatCommandMenuItemMaps'], + }, + ); + + const flatCommandMenuItemToDelete = + fromDeleteCommandMenuItemInputToFlatCommandMenuItemOrThrow({ + flatCommandMenuItemMaps: existingFlatCommandMenuItemMaps, + commandMenuItemId: id, + }); + + const validateAndBuildResult = + await this.workspaceMigrationValidateBuildAndRunService.validateBuildAndRunWorkspaceMigration( + { + allFlatEntityOperationByMetadataName: { + commandMenuItem: { + flatEntityToCreate: [], + flatEntityToDelete: [flatCommandMenuItemToDelete], + flatEntityToUpdate: [], + }, + }, + workspaceId, + isSystemBuild: false, + }, + ); + + if (isDefined(validateAndBuildResult)) { + throw new WorkspaceMigrationBuilderException( + validateAndBuildResult, + 'Multiple validation errors occurred while deleting command menu item', + ); + } + + return fromFlatCommandMenuItemToCommandMenuItemDto( + flatCommandMenuItemToDelete, + ); + } + + async findAllFlatCommandMenuItems( + workspaceId: string, + ): Promise { + const { flatCommandMenuItemMaps } = + await this.workspaceManyOrAllFlatEntityMapsCacheService.getOrRecomputeManyOrAllFlatEntityMaps( + { + workspaceId, + flatMapsKeys: ['flatCommandMenuItemMaps'], + }, + ); + + return Object.values(flatCommandMenuItemMaps.byId) + .filter(isDefined) + .sort((a, b) => a.label.localeCompare(b.label)); + } + + async findByWorkflowVersionId( + workflowVersionId: string, + workspaceId: string, + ): Promise { + const { flatCommandMenuItemMaps } = + await this.workspaceManyOrAllFlatEntityMapsCacheService.getOrRecomputeManyOrAllFlatEntityMaps( + { + workspaceId, + flatMapsKeys: ['flatCommandMenuItemMaps'], + }, + ); + + const flatCommandMenuItem = Object.values( + flatCommandMenuItemMaps.byId, + ).find( + (item) => isDefined(item) && item.workflowVersionId === workflowVersionId, + ); + + if (!isDefined(flatCommandMenuItem)) { + return null; + } + + return fromFlatCommandMenuItemToCommandMenuItemDto(flatCommandMenuItem); + } +} diff --git a/packages/twenty-server/src/engine/metadata-modules/command-menu-item/dtos/command-menu-item.dto.ts b/packages/twenty-server/src/engine/metadata-modules/command-menu-item/dtos/command-menu-item.dto.ts new file mode 100644 index 00000000000..12a6e2f11a4 --- /dev/null +++ b/packages/twenty-server/src/engine/metadata-modules/command-menu-item/dtos/command-menu-item.dto.ts @@ -0,0 +1,73 @@ +import { + Field, + HideField, + ObjectType, + registerEnumType, +} from '@nestjs/graphql'; + +import { + IsBoolean, + IsDateString, + IsEnum, + IsNotEmpty, + IsOptional, + IsString, + IsUUID, +} from 'class-validator'; + +import { UUIDScalarType } from 'src/engine/api/graphql/workspace-schema-builder/graphql-types/scalars'; +import { CommandMenuItemAvailabilityType } from 'src/engine/metadata-modules/command-menu-item/entities/command-menu-item.entity'; + +registerEnumType(CommandMenuItemAvailabilityType, { + name: 'CommandMenuItemAvailabilityType', +}); + +@ObjectType('CommandMenuItem') +export class CommandMenuItemDTO { + @IsUUID() + @IsNotEmpty() + @Field(() => UUIDScalarType) + id: string; + + @IsUUID() + @IsNotEmpty() + @Field(() => UUIDScalarType) + workflowVersionId: string; + + @IsString() + @IsNotEmpty() + @Field() + label: string; + + @IsString() + @IsOptional() + @Field({ nullable: true }) + icon?: string; + + @IsBoolean() + @Field() + isPinned: boolean; + + @IsEnum(CommandMenuItemAvailabilityType) + @Field(() => CommandMenuItemAvailabilityType) + availabilityType: CommandMenuItemAvailabilityType; + + @IsUUID() + @IsOptional() + @Field(() => UUIDScalarType, { nullable: true }) + availabilityObjectMetadataId?: string; + + @HideField() + workspaceId: string; + + @Field(() => UUIDScalarType, { nullable: true }) + applicationId?: string; + + @IsDateString() + @Field() + createdAt: Date; + + @IsDateString() + @Field() + updatedAt: Date; +} diff --git a/packages/twenty-server/src/engine/metadata-modules/command-menu-item/dtos/create-command-menu-item.input.ts b/packages/twenty-server/src/engine/metadata-modules/command-menu-item/dtos/create-command-menu-item.input.ts new file mode 100644 index 00000000000..e24926d0ef6 --- /dev/null +++ b/packages/twenty-server/src/engine/metadata-modules/command-menu-item/dtos/create-command-menu-item.input.ts @@ -0,0 +1,46 @@ +import { Field, InputType } from '@nestjs/graphql'; + +import { + IsBoolean, + IsEnum, + IsNotEmpty, + IsOptional, + IsString, + IsUUID, +} from 'class-validator'; + +import { UUIDScalarType } from 'src/engine/api/graphql/workspace-schema-builder/graphql-types/scalars'; +import { CommandMenuItemAvailabilityType } from 'src/engine/metadata-modules/command-menu-item/entities/command-menu-item.entity'; + +@InputType() +export class CreateCommandMenuItemInput { + @IsUUID() + @IsNotEmpty() + @Field(() => UUIDScalarType) + workflowVersionId: string; + + @IsString() + @IsNotEmpty() + @Field() + label: string; + + @IsString() + @IsOptional() + @Field({ nullable: true }) + icon?: string; + + @IsBoolean() + @IsOptional() + @Field({ nullable: true }) + isPinned?: boolean; + + @IsEnum(CommandMenuItemAvailabilityType) + @IsOptional() + @Field(() => CommandMenuItemAvailabilityType, { nullable: true }) + availabilityType?: CommandMenuItemAvailabilityType; + + @IsUUID() + @IsOptional() + @Field(() => UUIDScalarType, { nullable: true }) + availabilityObjectMetadataId?: string; +} diff --git a/packages/twenty-server/src/engine/metadata-modules/command-menu-item/dtos/update-command-menu-item.input.ts b/packages/twenty-server/src/engine/metadata-modules/command-menu-item/dtos/update-command-menu-item.input.ts new file mode 100644 index 00000000000..6bbba6cdae9 --- /dev/null +++ b/packages/twenty-server/src/engine/metadata-modules/command-menu-item/dtos/update-command-menu-item.input.ts @@ -0,0 +1,46 @@ +import { Field, InputType } from '@nestjs/graphql'; + +import { + IsBoolean, + IsEnum, + IsNotEmpty, + IsOptional, + IsString, + IsUUID, +} from 'class-validator'; + +import { UUIDScalarType } from 'src/engine/api/graphql/workspace-schema-builder/graphql-types/scalars'; +import { CommandMenuItemAvailabilityType } from 'src/engine/metadata-modules/command-menu-item/entities/command-menu-item.entity'; + +@InputType() +export class UpdateCommandMenuItemInput { + @IsUUID() + @IsNotEmpty() + @Field(() => UUIDScalarType) + id: string; + + @IsString() + @IsOptional() + @Field({ nullable: true }) + label?: string; + + @IsString() + @IsOptional() + @Field({ nullable: true }) + icon?: string; + + @IsBoolean() + @IsOptional() + @Field({ nullable: true }) + isPinned?: boolean; + + @IsEnum(CommandMenuItemAvailabilityType) + @IsOptional() + @Field(() => CommandMenuItemAvailabilityType, { nullable: true }) + availabilityType?: CommandMenuItemAvailabilityType; + + @IsUUID() + @IsOptional() + @Field(() => UUIDScalarType, { nullable: true }) + availabilityObjectMetadataId?: string; +} diff --git a/packages/twenty-server/src/engine/metadata-modules/command-menu-item/entities/command-menu-item.entity.ts b/packages/twenty-server/src/engine/metadata-modules/command-menu-item/entities/command-menu-item.entity.ts new file mode 100644 index 00000000000..e2443913d8b --- /dev/null +++ b/packages/twenty-server/src/engine/metadata-modules/command-menu-item/entities/command-menu-item.entity.ts @@ -0,0 +1,69 @@ +import { + Column, + CreateDateColumn, + Entity, + Index, + JoinColumn, + ManyToOne, + PrimaryGeneratedColumn, + type Relation, + UpdateDateColumn, +} from 'typeorm'; + +import { ObjectMetadataEntity } from 'src/engine/metadata-modules/object-metadata/object-metadata.entity'; +import { SyncableEntityRequired } from 'src/engine/workspace-manager/types/syncable-entity-required.interface'; + +export enum CommandMenuItemAvailabilityType { + GLOBAL = 'GLOBAL', + SINGLE_RECORD = 'SINGLE_RECORD', + BULK_RECORDS = 'BULK_RECORDS', +} + +@Entity({ name: 'commandMenuItem', schema: 'core' }) +@Index('IDX_COMMAND_MENU_ITEM_WORKFLOW_VERSION_ID_WORKSPACE_ID', [ + 'workflowVersionId', + 'workspaceId', +]) +export class CommandMenuItemEntity + extends SyncableEntityRequired + implements Required +{ + @PrimaryGeneratedColumn('uuid') + id: string; + + @Column({ nullable: false, type: 'uuid' }) + workflowVersionId: string; + + @Column({ nullable: false }) + label: string; + + @Column({ nullable: true, type: 'varchar' }) + icon: string | null; + + @Column({ default: false }) + isPinned: boolean; + + @Column({ + type: 'enum', + enum: CommandMenuItemAvailabilityType, + nullable: false, + default: CommandMenuItemAvailabilityType.GLOBAL, + }) + availabilityType: CommandMenuItemAvailabilityType; + + @Column({ nullable: true, type: 'uuid' }) + availabilityObjectMetadataId: string | null; + + @ManyToOne(() => ObjectMetadataEntity, { + onDelete: 'CASCADE', + nullable: true, + }) + @JoinColumn({ name: 'availabilityObjectMetadataId' }) + availabilityObjectMetadata: Relation | null; + + @CreateDateColumn({ type: 'timestamptz' }) + createdAt: Date; + + @UpdateDateColumn({ type: 'timestamptz' }) + updatedAt: Date; +} diff --git a/packages/twenty-server/src/engine/metadata-modules/command-menu-item/interceptors/command-menu-item-graphql-api-exception.interceptor.ts b/packages/twenty-server/src/engine/metadata-modules/command-menu-item/interceptors/command-menu-item-graphql-api-exception.interceptor.ts new file mode 100644 index 00000000000..245bdb40312 --- /dev/null +++ b/packages/twenty-server/src/engine/metadata-modules/command-menu-item/interceptors/command-menu-item-graphql-api-exception.interceptor.ts @@ -0,0 +1,24 @@ +import { + type CallHandler, + type ExecutionContext, + Injectable, + type NestInterceptor, +} from '@nestjs/common'; + +import { type Observable, catchError } from 'rxjs'; + +import { commandMenuItemGraphqlApiExceptionHandler } from 'src/engine/metadata-modules/command-menu-item/utils/command-menu-item-graphql-api-exception-handler.util'; + +@Injectable() +export class CommandMenuItemGraphqlApiExceptionInterceptor + implements NestInterceptor +{ + intercept( + _context: ExecutionContext, + next: CallHandler, + ): Observable { + return next + .handle() + .pipe(catchError(commandMenuItemGraphqlApiExceptionHandler)); + } +} diff --git a/packages/twenty-server/src/engine/metadata-modules/command-menu-item/utils/command-menu-item-graphql-api-exception-handler.util.ts b/packages/twenty-server/src/engine/metadata-modules/command-menu-item/utils/command-menu-item-graphql-api-exception-handler.util.ts new file mode 100644 index 00000000000..764e3990545 --- /dev/null +++ b/packages/twenty-server/src/engine/metadata-modules/command-menu-item/utils/command-menu-item-graphql-api-exception-handler.util.ts @@ -0,0 +1,26 @@ +import { assertUnreachable } from 'twenty-shared/utils'; + +import { + NotFoundError, + UserInputError, +} from 'src/engine/core-modules/graphql/utils/graphql-errors.util'; +import { + CommandMenuItemException, + CommandMenuItemExceptionCode, +} from 'src/engine/metadata-modules/command-menu-item/command-menu-item.exception'; + +export const commandMenuItemGraphqlApiExceptionHandler = (error: Error) => { + if (error instanceof CommandMenuItemException) { + switch (error.code) { + case CommandMenuItemExceptionCode.COMMAND_MENU_ITEM_NOT_FOUND: + throw new NotFoundError(error); + case CommandMenuItemExceptionCode.INVALID_COMMAND_MENU_ITEM_INPUT: + throw new UserInputError(error); + default: { + return assertUnreachable(error.code); + } + } + } + + throw error; +}; diff --git a/packages/twenty-server/src/engine/metadata-modules/flat-command-menu-item/constants/flat-command-menu-item-editable-properties.constant.ts b/packages/twenty-server/src/engine/metadata-modules/flat-command-menu-item/constants/flat-command-menu-item-editable-properties.constant.ts new file mode 100644 index 00000000000..564822f475e --- /dev/null +++ b/packages/twenty-server/src/engine/metadata-modules/flat-command-menu-item/constants/flat-command-menu-item-editable-properties.constant.ts @@ -0,0 +1,9 @@ +import { type FlatCommandMenuItem } from 'src/engine/metadata-modules/flat-command-menu-item/types/flat-command-menu-item.type'; + +export const FLAT_COMMAND_MENU_ITEM_EDITABLE_PROPERTIES = [ + 'label', + 'icon', + 'isPinned', + 'availabilityType', + 'availabilityObjectMetadataId', +] as const satisfies (keyof FlatCommandMenuItem)[]; diff --git a/packages/twenty-server/src/engine/metadata-modules/flat-command-menu-item/flat-command-menu-item.module.ts b/packages/twenty-server/src/engine/metadata-modules/flat-command-menu-item/flat-command-menu-item.module.ts new file mode 100644 index 00000000000..7ef73edcc12 --- /dev/null +++ b/packages/twenty-server/src/engine/metadata-modules/flat-command-menu-item/flat-command-menu-item.module.ts @@ -0,0 +1,16 @@ +import { Module } from '@nestjs/common'; +import { TypeOrmModule } from '@nestjs/typeorm'; + +import { CommandMenuItemEntity } from 'src/engine/metadata-modules/command-menu-item/entities/command-menu-item.entity'; +import { WorkspaceFlatCommandMenuItemMapCacheService } from 'src/engine/metadata-modules/flat-command-menu-item/services/workspace-flat-command-menu-item-map-cache.service'; +import { WorkspaceManyOrAllFlatEntityMapsCacheModule } from 'src/engine/metadata-modules/flat-entity/services/workspace-many-or-all-flat-entity-maps-cache.module'; + +@Module({ + imports: [ + TypeOrmModule.forFeature([CommandMenuItemEntity]), + WorkspaceManyOrAllFlatEntityMapsCacheModule, + ], + providers: [WorkspaceFlatCommandMenuItemMapCacheService], + exports: [WorkspaceFlatCommandMenuItemMapCacheService], +}) +export class FlatCommandMenuItemModule {} diff --git a/packages/twenty-server/src/engine/metadata-modules/flat-command-menu-item/services/workspace-flat-command-menu-item-map-cache.service.ts b/packages/twenty-server/src/engine/metadata-modules/flat-command-menu-item/services/workspace-flat-command-menu-item-map-cache.service.ts new file mode 100644 index 00000000000..336deb7ea0f --- /dev/null +++ b/packages/twenty-server/src/engine/metadata-modules/flat-command-menu-item/services/workspace-flat-command-menu-item-map-cache.service.ts @@ -0,0 +1,45 @@ +import { Injectable } from '@nestjs/common'; +import { InjectRepository } from '@nestjs/typeorm'; + +import { Repository } from 'typeorm'; + +import { WorkspaceCacheProvider } from 'src/engine/workspace-cache/interfaces/workspace-cache-provider.service'; + +import { CommandMenuItemEntity } from 'src/engine/metadata-modules/command-menu-item/entities/command-menu-item.entity'; +import { type FlatCommandMenuItemMaps } from 'src/engine/metadata-modules/flat-command-menu-item/types/flat-command-menu-item-maps.type'; +import { fromCommandMenuItemEntityToFlatCommandMenuItem } from 'src/engine/metadata-modules/flat-command-menu-item/utils/from-command-menu-item-entity-to-flat-command-menu-item.util'; +import { createEmptyFlatEntityMaps } from 'src/engine/metadata-modules/flat-entity/constant/create-empty-flat-entity-maps.constant'; +import { WorkspaceCache } from 'src/engine/workspace-cache/decorators/workspace-cache.decorator'; +import { addFlatEntityToFlatEntityMapsThroughMutationOrThrow } from 'src/engine/workspace-manager/workspace-migration/utils/add-flat-entity-to-flat-entity-maps-through-mutation-or-throw.util'; + +@Injectable() +@WorkspaceCache('flatCommandMenuItemMaps') +export class WorkspaceFlatCommandMenuItemMapCacheService extends WorkspaceCacheProvider { + constructor( + @InjectRepository(CommandMenuItemEntity) + private readonly commandMenuItemRepository: Repository, + ) { + super(); + } + + async computeForCache(workspaceId: string): Promise { + const commandMenuItems = await this.commandMenuItemRepository.find({ + where: { workspaceId }, + withDeleted: true, + }); + + const flatCommandMenuItemMaps = createEmptyFlatEntityMaps(); + + for (const commandMenuItemEntity of commandMenuItems) { + const flatCommandMenuItem = + fromCommandMenuItemEntityToFlatCommandMenuItem(commandMenuItemEntity); + + addFlatEntityToFlatEntityMapsThroughMutationOrThrow({ + flatEntity: flatCommandMenuItem, + flatEntityMapsToMutate: flatCommandMenuItemMaps, + }); + } + + return flatCommandMenuItemMaps; + } +} diff --git a/packages/twenty-server/src/engine/metadata-modules/flat-command-menu-item/types/flat-command-menu-item-maps.type.ts b/packages/twenty-server/src/engine/metadata-modules/flat-command-menu-item/types/flat-command-menu-item-maps.type.ts new file mode 100644 index 00000000000..34425726e88 --- /dev/null +++ b/packages/twenty-server/src/engine/metadata-modules/flat-command-menu-item/types/flat-command-menu-item-maps.type.ts @@ -0,0 +1,4 @@ +import { type FlatCommandMenuItem } from 'src/engine/metadata-modules/flat-command-menu-item/types/flat-command-menu-item.type'; +import { type FlatEntityMaps } from 'src/engine/metadata-modules/flat-entity/types/flat-entity-maps.type'; + +export type FlatCommandMenuItemMaps = FlatEntityMaps; diff --git a/packages/twenty-server/src/engine/metadata-modules/flat-command-menu-item/types/flat-command-menu-item.type.ts b/packages/twenty-server/src/engine/metadata-modules/flat-command-menu-item/types/flat-command-menu-item.type.ts new file mode 100644 index 00000000000..8a09761bc84 --- /dev/null +++ b/packages/twenty-server/src/engine/metadata-modules/flat-command-menu-item/types/flat-command-menu-item.type.ts @@ -0,0 +1,4 @@ +import { type CommandMenuItemEntity } from 'src/engine/metadata-modules/command-menu-item/entities/command-menu-item.entity'; +import { type FlatEntityFrom } from 'src/engine/metadata-modules/flat-entity/types/flat-entity.type'; + +export type FlatCommandMenuItem = FlatEntityFrom; diff --git a/packages/twenty-server/src/engine/metadata-modules/flat-command-menu-item/utils/from-command-menu-item-entity-to-flat-command-menu-item.util.ts b/packages/twenty-server/src/engine/metadata-modules/flat-command-menu-item/utils/from-command-menu-item-entity-to-flat-command-menu-item.util.ts new file mode 100644 index 00000000000..429fdc1bc3d --- /dev/null +++ b/packages/twenty-server/src/engine/metadata-modules/flat-command-menu-item/utils/from-command-menu-item-entity-to-flat-command-menu-item.util.ts @@ -0,0 +1,22 @@ +import { type CommandMenuItemEntity } from 'src/engine/metadata-modules/command-menu-item/entities/command-menu-item.entity'; +import { type FlatCommandMenuItem } from 'src/engine/metadata-modules/flat-command-menu-item/types/flat-command-menu-item.type'; + +export const fromCommandMenuItemEntityToFlatCommandMenuItem = ( + commandMenuItemEntity: CommandMenuItemEntity, +): FlatCommandMenuItem => { + return { + id: commandMenuItemEntity.id, + workflowVersionId: commandMenuItemEntity.workflowVersionId, + label: commandMenuItemEntity.label, + icon: commandMenuItemEntity.icon, + isPinned: commandMenuItemEntity.isPinned, + availabilityType: commandMenuItemEntity.availabilityType, + availabilityObjectMetadataId: + commandMenuItemEntity.availabilityObjectMetadataId, + workspaceId: commandMenuItemEntity.workspaceId, + universalIdentifier: commandMenuItemEntity.universalIdentifier, + applicationId: commandMenuItemEntity.applicationId, + createdAt: commandMenuItemEntity.createdAt.toISOString(), + updatedAt: commandMenuItemEntity.updatedAt.toISOString(), + }; +}; diff --git a/packages/twenty-server/src/engine/metadata-modules/flat-command-menu-item/utils/from-create-command-menu-item-input-to-flat-command-menu-item-to-create.util.ts b/packages/twenty-server/src/engine/metadata-modules/flat-command-menu-item/utils/from-create-command-menu-item-input-to-flat-command-menu-item-to-create.util.ts new file mode 100644 index 00000000000..e3bd03c926a --- /dev/null +++ b/packages/twenty-server/src/engine/metadata-modules/flat-command-menu-item/utils/from-create-command-menu-item-input-to-flat-command-menu-item-to-create.util.ts @@ -0,0 +1,36 @@ +import { v4 as uuidv4 } from 'uuid'; + +import { type CreateCommandMenuItemInput } from 'src/engine/metadata-modules/command-menu-item/dtos/create-command-menu-item.input'; +import { CommandMenuItemAvailabilityType } from 'src/engine/metadata-modules/command-menu-item/entities/command-menu-item.entity'; +import { type FlatCommandMenuItem } from 'src/engine/metadata-modules/flat-command-menu-item/types/flat-command-menu-item.type'; + +export const fromCreateCommandMenuItemInputToFlatCommandMenuItemToCreate = ({ + createCommandMenuItemInput, + workspaceId, + applicationId, +}: { + createCommandMenuItemInput: CreateCommandMenuItemInput; + workspaceId: string; + applicationId: string; +}): FlatCommandMenuItem => { + const id = uuidv4(); + const now = new Date().toISOString(); + + return { + id, + universalIdentifier: id, + workflowVersionId: createCommandMenuItemInput.workflowVersionId, + label: createCommandMenuItemInput.label, + icon: createCommandMenuItemInput.icon ?? null, + isPinned: createCommandMenuItemInput.isPinned ?? false, + availabilityType: + createCommandMenuItemInput.availabilityType ?? + CommandMenuItemAvailabilityType.GLOBAL, + availabilityObjectMetadataId: + createCommandMenuItemInput.availabilityObjectMetadataId ?? null, + workspaceId, + applicationId, + createdAt: now, + updatedAt: now, + }; +}; diff --git a/packages/twenty-server/src/engine/metadata-modules/flat-command-menu-item/utils/from-delete-command-menu-item-input-to-flat-command-menu-item-or-throw.util.ts b/packages/twenty-server/src/engine/metadata-modules/flat-command-menu-item/utils/from-delete-command-menu-item-input-to-flat-command-menu-item-or-throw.util.ts new file mode 100644 index 00000000000..9a07e74c86a --- /dev/null +++ b/packages/twenty-server/src/engine/metadata-modules/flat-command-menu-item/utils/from-delete-command-menu-item-input-to-flat-command-menu-item-or-throw.util.ts @@ -0,0 +1,31 @@ +import { isDefined } from 'twenty-shared/utils'; + +import { + CommandMenuItemException, + CommandMenuItemExceptionCode, +} from 'src/engine/metadata-modules/command-menu-item/command-menu-item.exception'; +import { type FlatCommandMenuItemMaps } from 'src/engine/metadata-modules/flat-command-menu-item/types/flat-command-menu-item-maps.type'; +import { type FlatCommandMenuItem } from 'src/engine/metadata-modules/flat-command-menu-item/types/flat-command-menu-item.type'; +import { findFlatEntityByIdInFlatEntityMaps } from 'src/engine/metadata-modules/flat-entity/utils/find-flat-entity-by-id-in-flat-entity-maps.util'; + +export const fromDeleteCommandMenuItemInputToFlatCommandMenuItemOrThrow = ({ + flatCommandMenuItemMaps, + commandMenuItemId, +}: { + flatCommandMenuItemMaps: FlatCommandMenuItemMaps; + commandMenuItemId: string; +}): FlatCommandMenuItem => { + const existingFlatCommandMenuItem = findFlatEntityByIdInFlatEntityMaps({ + flatEntityId: commandMenuItemId, + flatEntityMaps: flatCommandMenuItemMaps, + }); + + if (!isDefined(existingFlatCommandMenuItem)) { + throw new CommandMenuItemException( + 'Command menu item not found', + CommandMenuItemExceptionCode.COMMAND_MENU_ITEM_NOT_FOUND, + ); + } + + return existingFlatCommandMenuItem; +}; diff --git a/packages/twenty-server/src/engine/metadata-modules/flat-command-menu-item/utils/from-flat-command-menu-item-to-command-menu-item-dto.util.ts b/packages/twenty-server/src/engine/metadata-modules/flat-command-menu-item/utils/from-flat-command-menu-item-to-command-menu-item-dto.util.ts new file mode 100644 index 00000000000..c24a31e5f73 --- /dev/null +++ b/packages/twenty-server/src/engine/metadata-modules/flat-command-menu-item/utils/from-flat-command-menu-item-to-command-menu-item-dto.util.ts @@ -0,0 +1,19 @@ +import { type CommandMenuItemDTO } from 'src/engine/metadata-modules/command-menu-item/dtos/command-menu-item.dto'; +import { type FlatCommandMenuItem } from 'src/engine/metadata-modules/flat-command-menu-item/types/flat-command-menu-item.type'; + +export const fromFlatCommandMenuItemToCommandMenuItemDto = ( + flatCommandMenuItem: FlatCommandMenuItem, +): CommandMenuItemDTO => ({ + id: flatCommandMenuItem.id, + workflowVersionId: flatCommandMenuItem.workflowVersionId, + label: flatCommandMenuItem.label, + icon: flatCommandMenuItem.icon ?? undefined, + isPinned: flatCommandMenuItem.isPinned, + availabilityType: flatCommandMenuItem.availabilityType, + availabilityObjectMetadataId: + flatCommandMenuItem.availabilityObjectMetadataId ?? undefined, + workspaceId: flatCommandMenuItem.workspaceId, + applicationId: flatCommandMenuItem.applicationId ?? undefined, + createdAt: new Date(flatCommandMenuItem.createdAt), + updatedAt: new Date(flatCommandMenuItem.updatedAt), +}); diff --git a/packages/twenty-server/src/engine/metadata-modules/flat-command-menu-item/utils/from-update-command-menu-item-input-to-flat-command-menu-item-to-update-or-throw.util.ts b/packages/twenty-server/src/engine/metadata-modules/flat-command-menu-item/utils/from-update-command-menu-item-input-to-flat-command-menu-item-to-update-or-throw.util.ts new file mode 100644 index 00000000000..e2d0c12f038 --- /dev/null +++ b/packages/twenty-server/src/engine/metadata-modules/flat-command-menu-item/utils/from-update-command-menu-item-input-to-flat-command-menu-item-to-update-or-throw.util.ts @@ -0,0 +1,44 @@ +import { isDefined } from 'twenty-shared/utils'; + +import { + CommandMenuItemException, + CommandMenuItemExceptionCode, +} from 'src/engine/metadata-modules/command-menu-item/command-menu-item.exception'; +import { type UpdateCommandMenuItemInput } from 'src/engine/metadata-modules/command-menu-item/dtos/update-command-menu-item.input'; +import { FLAT_COMMAND_MENU_ITEM_EDITABLE_PROPERTIES } from 'src/engine/metadata-modules/flat-command-menu-item/constants/flat-command-menu-item-editable-properties.constant'; +import { type FlatCommandMenuItemMaps } from 'src/engine/metadata-modules/flat-command-menu-item/types/flat-command-menu-item-maps.type'; +import { type FlatCommandMenuItem } from 'src/engine/metadata-modules/flat-command-menu-item/types/flat-command-menu-item.type'; +import { findFlatEntityByIdInFlatEntityMaps } from 'src/engine/metadata-modules/flat-entity/utils/find-flat-entity-by-id-in-flat-entity-maps.util'; +import { mergeUpdateInExistingRecord } from 'src/utils/merge-update-in-existing-record.util'; + +export const fromUpdateCommandMenuItemInputToFlatCommandMenuItemToUpdateOrThrow = + ({ + flatCommandMenuItemMaps, + updateCommandMenuItemInput, + }: { + flatCommandMenuItemMaps: FlatCommandMenuItemMaps; + updateCommandMenuItemInput: UpdateCommandMenuItemInput; + }): FlatCommandMenuItem => { + const existingFlatCommandMenuItem = findFlatEntityByIdInFlatEntityMaps({ + flatEntityId: updateCommandMenuItemInput.id, + flatEntityMaps: flatCommandMenuItemMaps, + }); + + if (!isDefined(existingFlatCommandMenuItem)) { + throw new CommandMenuItemException( + 'Command menu item not found', + CommandMenuItemExceptionCode.COMMAND_MENU_ITEM_NOT_FOUND, + ); + } + + const { id: _id, ...updates } = updateCommandMenuItemInput; + + return { + ...mergeUpdateInExistingRecord({ + existing: existingFlatCommandMenuItem, + properties: [...FLAT_COMMAND_MENU_ITEM_EDITABLE_PROPERTIES], + update: updates, + }), + updatedAt: new Date().toISOString(), + }; + }; diff --git a/packages/twenty-server/src/engine/metadata-modules/flat-entity/constant/all-flat-entity-properties-to-compare-and-stringify.constant.ts b/packages/twenty-server/src/engine/metadata-modules/flat-entity/constant/all-flat-entity-properties-to-compare-and-stringify.constant.ts index 1fd36d5070f..c5210b5e5ee 100644 --- a/packages/twenty-server/src/engine/metadata-modules/flat-entity/constant/all-flat-entity-properties-to-compare-and-stringify.constant.ts +++ b/packages/twenty-server/src/engine/metadata-modules/flat-entity/constant/all-flat-entity-properties-to-compare-and-stringify.constant.ts @@ -14,6 +14,7 @@ import { FLAT_ROLE_TARGET_EDITABLE_PROPERTIES } from 'src/engine/metadata-module import { FLAT_ROLE_EDITABLE_PROPERTIES } from 'src/engine/metadata-modules/flat-role/constants/flat-role-editable-properties.constant'; import { FLAT_ROW_LEVEL_PERMISSION_PREDICATE_GROUP_EDITABLE_PROPERTIES } from 'src/engine/metadata-modules/flat-row-level-permission-predicate-group/constants/flat-row-level-permission-predicate-group-editable-properties.constant'; import { FLAT_ROW_LEVEL_PERMISSION_PREDICATE_EDITABLE_PROPERTIES } from 'src/engine/metadata-modules/flat-row-level-permission-predicate/constants/flat-row-level-permission-predicate-editable-properties.constant'; +import { FLAT_COMMAND_MENU_ITEM_EDITABLE_PROPERTIES } from 'src/engine/metadata-modules/flat-command-menu-item/constants/flat-command-menu-item-editable-properties.constant'; import { FLAT_SKILL_EDITABLE_PROPERTIES } from 'src/engine/metadata-modules/flat-skill/constants/flat-skill-editable-properties.constant'; import { FLAT_VIEW_FIELD_EDITABLE_PROPERTIES } from 'src/engine/metadata-modules/flat-view-field/constants/flat-view-field-editable-properties.constant'; import { FLAT_VIEW_FILTER_GROUP_EDITABLE_PROPERTIES } from 'src/engine/metadata-modules/flat-view-filter-group/constants/flat-view-filter-group-editable-properties.constant'; @@ -141,6 +142,10 @@ export const ALL_FLAT_ENTITY_PROPERTIES_TO_COMPARE_AND_STRINGIFY = { propertiesToCompare: [...FLAT_SKILL_EDITABLE_PROPERTIES], propertiesToStringify: [], }, + commandMenuItem: { + propertiesToCompare: [...FLAT_COMMAND_MENU_ITEM_EDITABLE_PROPERTIES], + propertiesToStringify: [], + }, rowLevelPermissionPredicate: { propertiesToCompare: [ ...FLAT_ROW_LEVEL_PERMISSION_PREDICATE_EDITABLE_PROPERTIES, diff --git a/packages/twenty-server/src/engine/metadata-modules/flat-entity/constant/all-metadata-relations.constant.ts b/packages/twenty-server/src/engine/metadata-modules/flat-entity/constant/all-metadata-relations.constant.ts index 3f9067d7542..99916d3e56f 100644 --- a/packages/twenty-server/src/engine/metadata-modules/flat-entity/constant/all-metadata-relations.constant.ts +++ b/packages/twenty-server/src/engine/metadata-modules/flat-entity/constant/all-metadata-relations.constant.ts @@ -79,6 +79,18 @@ export const ALL_METADATA_RELATIONS = { }, oneToMany: {}, }, + commandMenuItem: { + manyToOne: { + workspace: null, + application: null, + availabilityObjectMetadata: { + metadataName: 'objectMetadata', + flatEntityForeignKeyAggregator: null, + foreignKey: 'availabilityObjectMetadataId', + }, + }, + oneToMany: {}, + }, fieldMetadata: { manyToOne: { object: { diff --git a/packages/twenty-server/src/engine/metadata-modules/flat-entity/constant/all-metadata-required-metadata-for-validation.constant.ts b/packages/twenty-server/src/engine/metadata-modules/flat-entity/constant/all-metadata-required-metadata-for-validation.constant.ts index 455a6a99e9a..3180525dd11 100644 --- a/packages/twenty-server/src/engine/metadata-modules/flat-entity/constant/all-metadata-required-metadata-for-validation.constant.ts +++ b/packages/twenty-server/src/engine/metadata-modules/flat-entity/constant/all-metadata-required-metadata-for-validation.constant.ts @@ -62,6 +62,9 @@ export const ALL_METADATA_REQUIRED_METADATA_FOR_VALIDATION = { role: true, }, skill: {}, + commandMenuItem: { + objectMetadata: true, + }, pageLayout: { objectMetadata: true, }, diff --git a/packages/twenty-server/src/engine/metadata-modules/flat-entity/types/all-flat-entity-types-by-metadata-name.ts b/packages/twenty-server/src/engine/metadata-modules/flat-entity/types/all-flat-entity-types-by-metadata-name.ts index 3db62244a2f..a1d3b5ca25f 100644 --- a/packages/twenty-server/src/engine/metadata-modules/flat-entity/types/all-flat-entity-types-by-metadata-name.ts +++ b/packages/twenty-server/src/engine/metadata-modules/flat-entity/types/all-flat-entity-types-by-metadata-name.ts @@ -1,13 +1,14 @@ import { type AgentEntity } from 'src/engine/metadata-modules/ai/ai-agent/entities/agent.entity'; -import { type FlatFrontComponent } from 'src/engine/metadata-modules/flat-front-component/types/flat-front-component.type'; -import { type FrontComponentEntity } from 'src/engine/metadata-modules/front-component/entities/front-component.entity'; +import { type CommandMenuItemEntity } from 'src/engine/metadata-modules/command-menu-item/entities/command-menu-item.entity'; import { type CronTriggerEntity } from 'src/engine/metadata-modules/cron-trigger/entities/cron-trigger.entity'; import { type FlatCronTrigger } from 'src/engine/metadata-modules/cron-trigger/types/flat-cron-trigger.type'; import { type DatabaseEventTriggerEntity } from 'src/engine/metadata-modules/database-event-trigger/entities/database-event-trigger.entity'; import { type FlatDatabaseEventTrigger } from 'src/engine/metadata-modules/database-event-trigger/types/flat-database-event-trigger.type'; import { type FieldMetadataEntity } from 'src/engine/metadata-modules/field-metadata/field-metadata.entity'; import { type FlatAgent } from 'src/engine/metadata-modules/flat-agent/types/flat-agent.type'; +import { type FlatCommandMenuItem } from 'src/engine/metadata-modules/flat-command-menu-item/types/flat-command-menu-item.type'; import { type FlatFieldMetadata } from 'src/engine/metadata-modules/flat-field-metadata/types/flat-field-metadata.type'; +import { type FlatFrontComponent } from 'src/engine/metadata-modules/flat-front-component/types/flat-front-component.type'; import { type FlatIndexMetadata } from 'src/engine/metadata-modules/flat-index-metadata/types/flat-index-metadata.type'; import { type FlatObjectMetadata } from 'src/engine/metadata-modules/flat-object-metadata/types/flat-object-metadata.type'; import { type FlatPageLayoutTab } from 'src/engine/metadata-modules/flat-page-layout-tab/types/flat-page-layout-tab.type'; @@ -21,6 +22,7 @@ import { type FlatViewFilterGroup } from 'src/engine/metadata-modules/flat-view- import { type FlatViewFilter } from 'src/engine/metadata-modules/flat-view-filter/types/flat-view-filter.type'; import { type FlatViewGroup } from 'src/engine/metadata-modules/flat-view-group/types/flat-view-group.type'; import { type FlatView } from 'src/engine/metadata-modules/flat-view/types/flat-view.type'; +import { type FrontComponentEntity } from 'src/engine/metadata-modules/front-component/entities/front-component.entity'; import { type IndexMetadataEntity } from 'src/engine/metadata-modules/index-metadata/index-metadata.entity'; import { type ObjectMetadataEntity } from 'src/engine/metadata-modules/object-metadata/object-metadata.entity'; import { type PageLayoutTabEntity } from 'src/engine/metadata-modules/page-layout-tab/entities/page-layout-tab.entity'; @@ -47,6 +49,11 @@ import { type DeleteAgentAction, type UpdateAgentAction, } from 'src/engine/workspace-manager/workspace-migration/workspace-migration-builder/builders/agent/types/workspace-migration-agent-action-builder.service'; +import { + type CreateCommandMenuItemAction, + type DeleteCommandMenuItemAction, + type UpdateCommandMenuItemAction, +} from 'src/engine/workspace-manager/workspace-migration/workspace-migration-builder/builders/command-menu-item/types/workspace-migration-command-menu-item-action.type'; import { type CreateCronTriggerAction, type DeleteCronTriggerAction, @@ -62,6 +69,11 @@ import { type DeleteFieldAction, type UpdateFieldAction, } from 'src/engine/workspace-manager/workspace-migration/workspace-migration-builder/builders/field/types/workspace-migration-field-action'; +import { + type CreateFrontComponentAction, + type DeleteFrontComponentAction, + type UpdateFrontComponentAction, +} from 'src/engine/workspace-manager/workspace-migration/workspace-migration-builder/builders/front-component/types/workspace-migration-front-component-action.type'; import { type CreateIndexAction, type DeleteIndexAction, @@ -147,11 +159,6 @@ import { type DeleteViewAction, type UpdateViewAction, } from 'src/engine/workspace-manager/workspace-migration/workspace-migration-builder/builders/view/types/workspace-migration-view-action.type'; -import { - type CreateFrontComponentAction, - type DeleteFrontComponentAction, - type UpdateFrontComponentAction, -} from 'src/engine/workspace-manager/workspace-migration/workspace-migration-builder/builders/front-component/types/workspace-migration-front-component-action.type'; export type AllFlatEntityTypesByMetadataName = { fieldMetadata: { @@ -316,6 +323,15 @@ export type AllFlatEntityTypesByMetadataName = { flatEntity: FlatSkill; entity: SkillEntity; }; + commandMenuItem: { + actions: { + create: CreateCommandMenuItemAction; + update: UpdateCommandMenuItemAction; + delete: DeleteCommandMenuItemAction; + }; + flatEntity: FlatCommandMenuItem; + entity: CommandMenuItemEntity; + }; pageLayout: { actions: { create: CreatePageLayoutAction; diff --git a/packages/twenty-server/src/engine/metadata-modules/flat-entity/utils/__tests__/__snapshots__/get-metadata-related-metadata-names.util.spec.ts.snap b/packages/twenty-server/src/engine/metadata-modules/flat-entity/utils/__tests__/__snapshots__/get-metadata-related-metadata-names.util.spec.ts.snap index a6c50fc2b5a..eda7c7ff72a 100644 --- a/packages/twenty-server/src/engine/metadata-modules/flat-entity/utils/__tests__/__snapshots__/get-metadata-related-metadata-names.util.spec.ts.snap +++ b/packages/twenty-server/src/engine/metadata-modules/flat-entity/utils/__tests__/__snapshots__/get-metadata-related-metadata-names.util.spec.ts.snap @@ -2,6 +2,12 @@ exports[`getMetadataRelatedMetadataNames should return related metadata names for agent 1`] = `[]`; +exports[`getMetadataRelatedMetadataNames should return related metadata names for commandMenuItem 1`] = ` +[ + "objectMetadata", +] +`; + exports[`getMetadataRelatedMetadataNames should return related metadata names for cronTrigger 1`] = ` [ "serverlessFunction", diff --git a/packages/twenty-server/src/engine/metadata-modules/metadata-engine.module.ts b/packages/twenty-server/src/engine/metadata-modules/metadata-engine.module.ts index 79b8e5f11a5..2a7fd72fb79 100644 --- a/packages/twenty-server/src/engine/metadata-modules/metadata-engine.module.ts +++ b/packages/twenty-server/src/engine/metadata-modules/metadata-engine.module.ts @@ -3,6 +3,7 @@ import { Module } from '@nestjs/common'; import { AiAgentMonitorModule } from 'src/engine/metadata-modules/ai/ai-agent-monitor/ai-agent-monitor.module'; import { AiAgentModule } from 'src/engine/metadata-modules/ai/ai-agent/ai-agent.module'; import { AiChatModule } from 'src/engine/metadata-modules/ai/ai-chat/ai-chat.module'; +import { CommandMenuItemModule } from 'src/engine/metadata-modules/command-menu-item/command-menu-item.module'; import { CronTriggerModule } from 'src/engine/metadata-modules/cron-trigger/cron-trigger.module'; import { DataSourceModule } from 'src/engine/metadata-modules/data-source/data-source.module'; import { DatabaseEventTriggerModule } from 'src/engine/metadata-modules/database-event-trigger/database-event-trigger.module'; @@ -29,6 +30,7 @@ import { WorkspaceMetadataVersionModule } from 'src/engine/metadata-modules/work ServerlessFunctionModule, ServerlessFunctionLayerModule, SkillModule, + CommandMenuItemModule, AiAgentModule, AiAgentMonitorModule, AiChatModule, @@ -49,6 +51,7 @@ import { WorkspaceMetadataVersionModule } from 'src/engine/metadata-modules/work SearchFieldMetadataModule, ServerlessFunctionModule, SkillModule, + CommandMenuItemModule, AiAgentModule, AiChatModule, ViewModule, diff --git a/packages/twenty-server/src/engine/twenty-orm/entity-manager/workspace-entity-manager.spec.ts b/packages/twenty-server/src/engine/twenty-orm/entity-manager/workspace-entity-manager.spec.ts index 6a0d73ec485..b4d0e25d2bf 100644 --- a/packages/twenty-server/src/engine/twenty-orm/entity-manager/workspace-entity-manager.spec.ts +++ b/packages/twenty-server/src/engine/twenty-orm/entity-manager/workspace-entity-manager.spec.ts @@ -222,6 +222,7 @@ describe('WorkspaceEntityManager', () => { IS_ROW_LEVEL_PERMISSION_PREDICATES_ENABLED: false, IS_IF_ELSE_ENABLED: false, IS_SSE_DB_EVENTS_ENABLED: false, + IS_COMMAND_MENU_ITEM_ENABLED: false, }, userWorkspaceRoleMap: {}, eventEmitterService: { diff --git a/packages/twenty-server/src/engine/workspace-cache/types/workspace-cache-key.type.ts b/packages/twenty-server/src/engine/workspace-cache/types/workspace-cache-key.type.ts index 6140171f39f..88ea9302a13 100644 --- a/packages/twenty-server/src/engine/workspace-cache/types/workspace-cache-key.type.ts +++ b/packages/twenty-server/src/engine/workspace-cache/types/workspace-cache-key.type.ts @@ -32,6 +32,7 @@ export const WORKSPACE_CACHE_KEYS_V2 = { ORMEntityMetadatas: 'orm:entity-metadatas', flatAgentMaps: 'flat-maps:agent', flatSkillMaps: 'flat-maps:skill', + flatCommandMenuItemMaps: 'flat-maps:command-menu-item', flatRoleTargetByAgentIdMaps: 'flat-maps:flatRoleTargetByAgentId', flatPageLayoutMaps: 'flat-maps:page-layout', flatPageLayoutWidgetMaps: 'flat-maps:page-layout-widget', diff --git a/packages/twenty-server/src/engine/workspace-manager/workspace-migration/services/workspace-migration-build-orchestrator.service.ts b/packages/twenty-server/src/engine/workspace-manager/workspace-migration/services/workspace-migration-build-orchestrator.service.ts index 1a9198b0204..d85dcb17bad 100644 --- a/packages/twenty-server/src/engine/workspace-manager/workspace-migration/services/workspace-migration-build-orchestrator.service.ts +++ b/packages/twenty-server/src/engine/workspace-manager/workspace-migration/services/workspace-migration-build-orchestrator.service.ts @@ -15,6 +15,7 @@ import { } from 'src/engine/workspace-manager/workspace-migration/types/workspace-migration-orchestrator.type'; import { aggregateOrchestratorActionsReport } from 'src/engine/workspace-manager/workspace-migration/utils/aggregate-orchestrator-actions-report.util'; import { WorkspaceMigrationAgentActionsBuilderService } from 'src/engine/workspace-manager/workspace-migration/workspace-migration-builder/builders/agent/workspace-migration-agent-actions-builder.service'; +import { WorkspaceMigrationCommandMenuItemActionsBuilderService } from 'src/engine/workspace-manager/workspace-migration/workspace-migration-builder/builders/command-menu-item/workspace-migration-command-menu-item-actions-builder.service'; import { WorkspaceMigrationCronTriggerActionsBuilderService } from 'src/engine/workspace-manager/workspace-migration/workspace-migration-builder/builders/cron-trigger/workspace-migration-cron-trigger-action-builder.service'; import { WorkspaceMigrationDatabaseEventTriggerActionsBuilderService } from 'src/engine/workspace-manager/workspace-migration/workspace-migration-builder/builders/database-event-trigger/workspace-migration-database-event-trigger-actions-builder.service'; import { WorkspaceMigrationFieldActionsBuilderService } from 'src/engine/workspace-manager/workspace-migration/workspace-migration-builder/builders/field/workspace-migration-field-actions-builder.service'; @@ -56,6 +57,7 @@ export class WorkspaceMigrationBuildOrchestratorService { private readonly workspaceMigrationRoleActionsBuilderService: WorkspaceMigrationRoleActionsBuilderService, private readonly workspaceMigrationAgentActionsBuilderService: WorkspaceMigrationAgentActionsBuilderService, private readonly workspaceMigrationSkillActionsBuilderService: WorkspaceMigrationSkillActionsBuilderService, + private readonly workspaceMigrationCommandMenuItemActionsBuilderService: WorkspaceMigrationCommandMenuItemActionsBuilderService, private readonly workspaceMigrationPageLayoutActionsBuilderService: WorkspaceMigrationPageLayoutActionsBuilderService, private readonly workspaceMigrationPageLayoutWidgetActionsBuilderService: WorkspaceMigrationPageLayoutWidgetActionsBuilderService, private readonly workspaceMigrationPageLayoutTabActionsBuilderService: WorkspaceMigrationPageLayoutTabActionsBuilderService, @@ -158,6 +160,7 @@ export class WorkspaceMigrationBuildOrchestratorService { flatRoleTargetMaps, flatAgentMaps, flatSkillMaps, + flatCommandMenuItemMaps, flatPageLayoutMaps, flatPageLayoutWidgetMaps, flatPageLayoutTabMaps, @@ -817,6 +820,45 @@ export class WorkspaceMigrationBuildOrchestratorService { } } + if (isDefined(flatCommandMenuItemMaps)) { + const { + from: fromFlatCommandMenuItemMaps, + to: toFlatCommandMenuItemMaps, + } = flatCommandMenuItemMaps; + + const commandMenuItemResult = + await this.workspaceMigrationCommandMenuItemActionsBuilderService.validateAndBuild( + { + additionalCacheDataMaps, + from: fromFlatCommandMenuItemMaps, + to: toFlatCommandMenuItemMaps, + buildOptions, + dependencyOptimisticFlatEntityMaps: { + flatObjectMetadataMaps: + optimisticAllFlatEntityMaps.flatObjectMetadataMaps, + }, + workspaceId, + }, + ); + + this.mergeFlatEntityMapsAndRelatedFlatEntityMapsInAllFlatEntityMapsThroughMutation( + { + allFlatEntityMaps: optimisticAllFlatEntityMaps, + flatEntityMapsAndRelatedFlatEntityMaps: + commandMenuItemResult.optimisticFlatEntityMapsAndRelatedFlatEntityMaps, + }, + ); + + if (commandMenuItemResult.status === 'fail') { + orchestratorFailureReport.commandMenuItem.push( + ...commandMenuItemResult.errors, + ); + } else { + orchestratorActionsReport.commandMenuItem = + commandMenuItemResult.actions; + } + } + if (isDefined(flatPageLayoutMaps)) { const { from: fromFlatPageLayoutMaps, to: toFlatPageLayoutMaps } = flatPageLayoutMaps; @@ -1070,6 +1112,12 @@ export class WorkspaceMigrationBuildOrchestratorService { ...aggregatedOrchestratorActionsReport.skill.update, /// + // Command Menu Items + ...aggregatedOrchestratorActionsReport.commandMenuItem.delete, + ...aggregatedOrchestratorActionsReport.commandMenuItem.create, + ...aggregatedOrchestratorActionsReport.commandMenuItem.update, + /// + // Page layouts ...aggregatedOrchestratorActionsReport.pageLayout.delete, ...aggregatedOrchestratorActionsReport.pageLayout.create, diff --git a/packages/twenty-server/src/engine/workspace-manager/workspace-migration/workspace-migration-builder/builders/command-menu-item/types/workspace-migration-command-menu-item-action.type.ts b/packages/twenty-server/src/engine/workspace-manager/workspace-migration/workspace-migration-builder/builders/command-menu-item/types/workspace-migration-command-menu-item-action.type.ts new file mode 100644 index 00000000000..0531c27df60 --- /dev/null +++ b/packages/twenty-server/src/engine/workspace-manager/workspace-migration/workspace-migration-builder/builders/command-menu-item/types/workspace-migration-command-menu-item-action.type.ts @@ -0,0 +1,12 @@ +import { type BaseCreateWorkspaceMigrationAction } from 'src/engine/workspace-manager/workspace-migration/workspace-migration-builder/types/base-create-workspace-migration-action.type'; +import { type BaseDeleteWorkspaceMigrationAction } from 'src/engine/workspace-manager/workspace-migration/workspace-migration-builder/types/base-delete-workspace-migration-action.type'; +import { type BaseUpdateWorkspaceMigrationAction } from 'src/engine/workspace-manager/workspace-migration/workspace-migration-builder/types/base-update-workspace-migration-action.type'; + +export type CreateCommandMenuItemAction = + BaseCreateWorkspaceMigrationAction<'commandMenuItem'>; + +export type UpdateCommandMenuItemAction = + BaseUpdateWorkspaceMigrationAction<'commandMenuItem'>; + +export type DeleteCommandMenuItemAction = + BaseDeleteWorkspaceMigrationAction<'commandMenuItem'>; diff --git a/packages/twenty-server/src/engine/workspace-manager/workspace-migration/workspace-migration-builder/builders/command-menu-item/workspace-migration-command-menu-item-actions-builder.service.ts b/packages/twenty-server/src/engine/workspace-manager/workspace-migration/workspace-migration-builder/builders/command-menu-item/workspace-migration-command-menu-item-actions-builder.service.ts new file mode 100644 index 00000000000..e6f6971de87 --- /dev/null +++ b/packages/twenty-server/src/engine/workspace-manager/workspace-migration/workspace-migration-builder/builders/command-menu-item/workspace-migration-command-menu-item-actions-builder.service.ts @@ -0,0 +1,116 @@ +import { Injectable } from '@nestjs/common'; + +import { ALL_METADATA_NAME } from 'twenty-shared/metadata'; + +import { UpdateCommandMenuItemAction } from 'src/engine/workspace-manager/workspace-migration/workspace-migration-builder/builders/command-menu-item/types/workspace-migration-command-menu-item-action.type'; +import { WorkspaceEntityMigrationBuilderService } from 'src/engine/workspace-manager/workspace-migration/workspace-migration-builder/services/workspace-entity-migration-builder.service'; +import { FlatEntityUpdateValidationArgs } from 'src/engine/workspace-manager/workspace-migration/workspace-migration-builder/types/flat-entity-update-validation-args.type'; +import { FlatEntityValidationArgs } from 'src/engine/workspace-manager/workspace-migration/workspace-migration-builder/types/flat-entity-validation-args.type'; +import { FlatEntityValidationReturnType } from 'src/engine/workspace-manager/workspace-migration/workspace-migration-builder/types/flat-entity-validation-result.type'; +import { FlatCommandMenuItemValidatorService } from 'src/engine/workspace-manager/workspace-migration/workspace-migration-builder/validators/services/flat-command-menu-item-validator.service'; + +@Injectable() +export class WorkspaceMigrationCommandMenuItemActionsBuilderService extends WorkspaceEntityMigrationBuilderService< + typeof ALL_METADATA_NAME.commandMenuItem +> { + constructor( + private readonly flatCommandMenuItemValidatorService: FlatCommandMenuItemValidatorService, + ) { + super(ALL_METADATA_NAME.commandMenuItem); + } + + protected validateFlatEntityCreation( + args: FlatEntityValidationArgs, + ): FlatEntityValidationReturnType< + typeof ALL_METADATA_NAME.commandMenuItem, + 'create' + > { + const validationResult = + this.flatCommandMenuItemValidatorService.validateFlatCommandMenuItemCreation( + args, + ); + + if (validationResult.errors.length > 0) { + return { + status: 'fail', + ...validationResult, + }; + } + + const { flatEntityToValidate: flatCommandMenuItemToValidate } = args; + + return { + status: 'success', + action: { + type: 'create', + metadataName: 'commandMenuItem', + flatEntity: flatCommandMenuItemToValidate, + }, + }; + } + + protected validateFlatEntityDeletion( + args: FlatEntityValidationArgs, + ): FlatEntityValidationReturnType< + typeof ALL_METADATA_NAME.commandMenuItem, + 'delete' + > { + const validationResult = + this.flatCommandMenuItemValidatorService.validateFlatCommandMenuItemDeletion( + args, + ); + + if (validationResult.errors.length > 0) { + return { + status: 'fail', + ...validationResult, + }; + } + + const { flatEntityToValidate: flatCommandMenuItemToValidate } = args; + + return { + status: 'success', + action: { + type: 'delete', + metadataName: 'commandMenuItem', + entityId: flatCommandMenuItemToValidate.id, + }, + }; + } + + protected validateFlatEntityUpdate( + args: FlatEntityUpdateValidationArgs< + typeof ALL_METADATA_NAME.commandMenuItem + >, + ): FlatEntityValidationReturnType< + typeof ALL_METADATA_NAME.commandMenuItem, + 'update' + > { + const validationResult = + this.flatCommandMenuItemValidatorService.validateFlatCommandMenuItemUpdate( + args, + ); + + if (validationResult.errors.length > 0) { + return { + status: 'fail', + ...validationResult, + }; + } + + const { flatEntityId, flatEntityUpdates } = args; + + const updateCommandMenuItemAction: UpdateCommandMenuItemAction = { + type: 'update', + metadataName: 'commandMenuItem', + entityId: flatEntityId, + updates: flatEntityUpdates, + }; + + return { + status: 'success', + action: updateCommandMenuItemAction, + }; + } +} diff --git a/packages/twenty-server/src/engine/workspace-manager/workspace-migration/workspace-migration-builder/validators/services/flat-command-menu-item-validator.service.ts b/packages/twenty-server/src/engine/workspace-manager/workspace-migration/workspace-migration-builder/validators/services/flat-command-menu-item-validator.service.ts new file mode 100644 index 00000000000..e7b4c041ebd --- /dev/null +++ b/packages/twenty-server/src/engine/workspace-manager/workspace-migration/workspace-migration-builder/validators/services/flat-command-menu-item-validator.service.ts @@ -0,0 +1,124 @@ +import { Injectable } from '@nestjs/common'; + +import { msg, t } from '@lingui/core/macro'; +import { isNonEmptyString } from '@sniptt/guards'; +import { ALL_METADATA_NAME } from 'twenty-shared/metadata'; +import { isDefined } from 'twenty-shared/utils'; + +import { CommandMenuItemExceptionCode } from 'src/engine/metadata-modules/command-menu-item/command-menu-item.exception'; +import { findFlatEntityByIdInFlatEntityMaps } from 'src/engine/metadata-modules/flat-entity/utils/find-flat-entity-by-id-in-flat-entity-maps.util'; +import { findFlatEntityPropertyUpdate } from 'src/engine/workspace-manager/workspace-migration/utils/find-flat-entity-property-update.util'; +import { type FailedFlatEntityValidation } from 'src/engine/workspace-manager/workspace-migration/workspace-migration-builder/builders/types/failed-flat-entity-validation.type'; +import { getEmptyFlatEntityValidationError } from 'src/engine/workspace-manager/workspace-migration/workspace-migration-builder/builders/utils/get-flat-entity-validation-error.util'; +import { type FlatEntityUpdateValidationArgs } from 'src/engine/workspace-manager/workspace-migration/workspace-migration-builder/types/flat-entity-update-validation-args.type'; +import { type FlatEntityValidationArgs } from 'src/engine/workspace-manager/workspace-migration/workspace-migration-builder/types/flat-entity-validation-args.type'; + +@Injectable() +export class FlatCommandMenuItemValidatorService { + public validateFlatCommandMenuItemCreation({ + flatEntityToValidate: flatCommandMenuItem, + }: FlatEntityValidationArgs< + typeof ALL_METADATA_NAME.commandMenuItem + >): FailedFlatEntityValidation<'commandMenuItem', 'create'> { + const validationResult = getEmptyFlatEntityValidationError({ + flatEntityMinimalInformation: { + id: flatCommandMenuItem.id, + universalIdentifier: flatCommandMenuItem.universalIdentifier, + }, + metadataName: 'commandMenuItem', + type: 'create', + }); + + if (!isNonEmptyString(flatCommandMenuItem.label)) { + validationResult.errors.push({ + code: CommandMenuItemExceptionCode.INVALID_COMMAND_MENU_ITEM_INPUT, + message: t`Label is required`, + userFriendlyMessage: msg`Label is required`, + }); + } + + return validationResult; + } + + public validateFlatCommandMenuItemDeletion({ + flatEntityToValidate, + optimisticFlatEntityMapsAndRelatedFlatEntityMaps: { + flatCommandMenuItemMaps: optimisticFlatCommandMenuItemMaps, + }, + }: FlatEntityValidationArgs< + typeof ALL_METADATA_NAME.commandMenuItem + >): FailedFlatEntityValidation<'commandMenuItem', 'delete'> { + const validationResult = getEmptyFlatEntityValidationError({ + flatEntityMinimalInformation: { + id: flatEntityToValidate.id, + universalIdentifier: flatEntityToValidate.universalIdentifier, + }, + metadataName: 'commandMenuItem', + type: 'delete', + }); + + const existingCommandMenuItem = findFlatEntityByIdInFlatEntityMaps({ + flatEntityId: flatEntityToValidate.id, + flatEntityMaps: optimisticFlatCommandMenuItemMaps, + }); + + if (!isDefined(existingCommandMenuItem)) { + validationResult.errors.push({ + code: CommandMenuItemExceptionCode.COMMAND_MENU_ITEM_NOT_FOUND, + message: t`Command menu item not found`, + userFriendlyMessage: msg`Command menu item not found`, + }); + } + + return validationResult; + } + + public validateFlatCommandMenuItemUpdate({ + flatEntityId, + flatEntityUpdates, + optimisticFlatEntityMapsAndRelatedFlatEntityMaps: { + flatCommandMenuItemMaps: optimisticFlatCommandMenuItemMaps, + }, + }: FlatEntityUpdateValidationArgs< + typeof ALL_METADATA_NAME.commandMenuItem + >): FailedFlatEntityValidation<'commandMenuItem', 'update'> { + const fromFlatCommandMenuItem = findFlatEntityByIdInFlatEntityMaps({ + flatEntityId, + flatEntityMaps: optimisticFlatCommandMenuItemMaps, + }); + + const validationResult = getEmptyFlatEntityValidationError({ + flatEntityMinimalInformation: { + id: flatEntityId, + universalIdentifier: fromFlatCommandMenuItem?.universalIdentifier, + }, + metadataName: 'commandMenuItem', + type: 'update', + }); + + if (!isDefined(fromFlatCommandMenuItem)) { + validationResult.errors.push({ + code: CommandMenuItemExceptionCode.COMMAND_MENU_ITEM_NOT_FOUND, + message: t`Command menu item not found`, + userFriendlyMessage: msg`Command menu item not found`, + }); + + return validationResult; + } + + const labelUpdate = findFlatEntityPropertyUpdate({ + flatEntityUpdates, + property: 'label', + }); + + if (isDefined(labelUpdate) && !isNonEmptyString(labelUpdate.to)) { + validationResult.errors.push({ + code: CommandMenuItemExceptionCode.INVALID_COMMAND_MENU_ITEM_INPUT, + message: t`Label is required`, + userFriendlyMessage: msg`Label is required`, + }); + } + + return validationResult; + } +} diff --git a/packages/twenty-server/src/engine/workspace-manager/workspace-migration/workspace-migration-builder/validators/workspace-migration-builder-validators.module.ts b/packages/twenty-server/src/engine/workspace-manager/workspace-migration/workspace-migration-builder/validators/workspace-migration-builder-validators.module.ts index cf560137a29..257791dbbe8 100644 --- a/packages/twenty-server/src/engine/workspace-manager/workspace-migration/workspace-migration-builder/validators/workspace-migration-builder-validators.module.ts +++ b/packages/twenty-server/src/engine/workspace-manager/workspace-migration/workspace-migration-builder/validators/workspace-migration-builder-validators.module.ts @@ -18,6 +18,7 @@ import { FlatRouteTriggerValidatorService } from 'src/engine/workspace-manager/w import { FlatRowLevelPermissionPredicateGroupValidatorService } from 'src/engine/workspace-manager/workspace-migration/workspace-migration-builder/validators/services/flat-row-level-permission-predicate-group-validator.service'; import { FlatRowLevelPermissionPredicateValidatorService } from 'src/engine/workspace-manager/workspace-migration/workspace-migration-builder/validators/services/flat-row-level-permission-predicate-validator.service'; import { FlatServerlessFunctionValidatorService } from 'src/engine/workspace-manager/workspace-migration/workspace-migration-builder/validators/services/flat-serverless-function-validator.service'; +import { FlatCommandMenuItemValidatorService } from 'src/engine/workspace-manager/workspace-migration/workspace-migration-builder/validators/services/flat-command-menu-item-validator.service'; import { FlatSkillValidatorService } from 'src/engine/workspace-manager/workspace-migration/workspace-migration-builder/validators/services/flat-skill-validator.service'; import { FlatFrontComponentValidatorService } from 'src/engine/workspace-manager/workspace-migration/workspace-migration-builder/validators/services/flat-front-component-validator.service'; import { FlatViewFieldValidatorService } from 'src/engine/workspace-manager/workspace-migration/workspace-migration-builder/validators/services/flat-view-field-validator.service'; @@ -47,6 +48,7 @@ import { FlatViewValidatorService } from 'src/engine/workspace-manager/workspace FlatRoleTargetValidatorService, FlatAgentValidatorService, FlatSkillValidatorService, + FlatCommandMenuItemValidatorService, FlatPageLayoutValidatorService, FlatPageLayoutWidgetValidatorService, FlatPageLayoutTabValidatorService, @@ -72,6 +74,7 @@ import { FlatViewValidatorService } from 'src/engine/workspace-manager/workspace FlatRoleTargetValidatorService, FlatAgentValidatorService, FlatSkillValidatorService, + FlatCommandMenuItemValidatorService, FlatPageLayoutValidatorService, FlatPageLayoutWidgetValidatorService, FlatPageLayoutTabValidatorService, diff --git a/packages/twenty-server/src/engine/workspace-manager/workspace-migration/workspace-migration-builder/workspace-migration-builder.module.ts b/packages/twenty-server/src/engine/workspace-manager/workspace-migration/workspace-migration-builder/workspace-migration-builder.module.ts index 66e3f0636b8..d0935d59b10 100644 --- a/packages/twenty-server/src/engine/workspace-manager/workspace-migration/workspace-migration-builder/workspace-migration-builder.module.ts +++ b/packages/twenty-server/src/engine/workspace-manager/workspace-migration/workspace-migration-builder/workspace-migration-builder.module.ts @@ -3,6 +3,7 @@ import { Module } from '@nestjs/common'; import { FeatureFlagModule } from 'src/engine/core-modules/feature-flag/feature-flag.module'; import { FlatFieldMetadataTypeValidatorService } from 'src/engine/metadata-modules/flat-field-metadata/services/flat-field-metadata-type-validator.service'; import { WorkspaceMigrationAgentActionsBuilderService } from 'src/engine/workspace-manager/workspace-migration/workspace-migration-builder/builders/agent/workspace-migration-agent-actions-builder.service'; +import { WorkspaceMigrationCommandMenuItemActionsBuilderService } from 'src/engine/workspace-manager/workspace-migration/workspace-migration-builder/builders/command-menu-item/workspace-migration-command-menu-item-actions-builder.service'; import { WorkspaceMigrationCronTriggerActionsBuilderService } from 'src/engine/workspace-manager/workspace-migration/workspace-migration-builder/builders/cron-trigger/workspace-migration-cron-trigger-action-builder.service'; import { WorkspaceMigrationDatabaseEventTriggerActionsBuilderService } from 'src/engine/workspace-manager/workspace-migration/workspace-migration-builder/builders/database-event-trigger/workspace-migration-database-event-trigger-actions-builder.service'; import { WorkspaceMigrationFieldActionsBuilderService } from 'src/engine/workspace-manager/workspace-migration/workspace-migration-builder/builders/field/workspace-migration-field-actions-builder.service'; @@ -46,6 +47,7 @@ import { WorkspaceMigrationBuilderValidatorsModule } from 'src/engine/workspace- WorkspaceMigrationRoleTargetActionsBuilderService, WorkspaceMigrationAgentActionsBuilderService, WorkspaceMigrationSkillActionsBuilderService, + WorkspaceMigrationCommandMenuItemActionsBuilderService, WorkspaceMigrationPageLayoutActionsBuilderService, WorkspaceMigrationPageLayoutWidgetActionsBuilderService, WorkspaceMigrationPageLayoutTabActionsBuilderService, @@ -70,6 +72,7 @@ import { WorkspaceMigrationBuilderValidatorsModule } from 'src/engine/workspace- WorkspaceMigrationRoleTargetActionsBuilderService, WorkspaceMigrationAgentActionsBuilderService, WorkspaceMigrationSkillActionsBuilderService, + WorkspaceMigrationCommandMenuItemActionsBuilderService, WorkspaceMigrationPageLayoutActionsBuilderService, WorkspaceMigrationPageLayoutWidgetActionsBuilderService, WorkspaceMigrationPageLayoutTabActionsBuilderService, diff --git a/packages/twenty-server/src/engine/workspace-manager/workspace-migration/workspace-migration-runner/action-handlers/command-menu-item/services/create-command-menu-item-action-handler.service.ts b/packages/twenty-server/src/engine/workspace-manager/workspace-migration/workspace-migration-runner/action-handlers/command-menu-item/services/create-command-menu-item-action-handler.service.ts new file mode 100644 index 00000000000..ecf9fdabf09 --- /dev/null +++ b/packages/twenty-server/src/engine/workspace-manager/workspace-migration/workspace-migration-runner/action-handlers/command-menu-item/services/create-command-menu-item-action-handler.service.ts @@ -0,0 +1,40 @@ +import { Injectable } from '@nestjs/common'; + +import { WorkspaceMigrationRunnerActionHandler } from 'src/engine/workspace-manager/workspace-migration/workspace-migration-runner/interfaces/workspace-migration-runner-action-handler-service.interface'; + +import { CommandMenuItemEntity } from 'src/engine/metadata-modules/command-menu-item/entities/command-menu-item.entity'; +import { CreateCommandMenuItemAction } from 'src/engine/workspace-manager/workspace-migration/workspace-migration-builder/builders/command-menu-item/types/workspace-migration-command-menu-item-action.type'; +import { WorkspaceMigrationActionRunnerArgs } from 'src/engine/workspace-manager/workspace-migration/workspace-migration-runner/types/workspace-migration-action-runner-args.type'; + +@Injectable() +export class CreateCommandMenuItemActionHandlerService extends WorkspaceMigrationRunnerActionHandler( + 'create', + 'commandMenuItem', +) { + constructor() { + super(); + } + + async executeForMetadata( + context: WorkspaceMigrationActionRunnerArgs, + ): Promise { + const { action, queryRunner, workspaceId } = context; + const { flatEntity } = action; + + const commandMenuItemRepository = + queryRunner.manager.getRepository( + CommandMenuItemEntity, + ); + + await commandMenuItemRepository.insert({ + ...flatEntity, + workspaceId, + }); + } + + async executeForWorkspaceSchema( + _context: WorkspaceMigrationActionRunnerArgs, + ): Promise { + return; + } +} diff --git a/packages/twenty-server/src/engine/workspace-manager/workspace-migration/workspace-migration-runner/action-handlers/command-menu-item/services/delete-command-menu-item-action-handler.service.ts b/packages/twenty-server/src/engine/workspace-manager/workspace-migration/workspace-migration-runner/action-handlers/command-menu-item/services/delete-command-menu-item-action-handler.service.ts new file mode 100644 index 00000000000..9402a7ca8b8 --- /dev/null +++ b/packages/twenty-server/src/engine/workspace-manager/workspace-migration/workspace-migration-runner/action-handlers/command-menu-item/services/delete-command-menu-item-action-handler.service.ts @@ -0,0 +1,37 @@ +import { Injectable } from '@nestjs/common'; + +import { WorkspaceMigrationRunnerActionHandler } from 'src/engine/workspace-manager/workspace-migration/workspace-migration-runner/interfaces/workspace-migration-runner-action-handler-service.interface'; + +import { CommandMenuItemEntity } from 'src/engine/metadata-modules/command-menu-item/entities/command-menu-item.entity'; +import { DeleteCommandMenuItemAction } from 'src/engine/workspace-manager/workspace-migration/workspace-migration-builder/builders/command-menu-item/types/workspace-migration-command-menu-item-action.type'; +import { WorkspaceMigrationActionRunnerArgs } from 'src/engine/workspace-manager/workspace-migration/workspace-migration-runner/types/workspace-migration-action-runner-args.type'; + +@Injectable() +export class DeleteCommandMenuItemActionHandlerService extends WorkspaceMigrationRunnerActionHandler( + 'delete', + 'commandMenuItem', +) { + constructor() { + super(); + } + + async executeForMetadata( + context: WorkspaceMigrationActionRunnerArgs, + ): Promise { + const { action, queryRunner, workspaceId } = context; + const { entityId } = action; + + const commandMenuItemRepository = + queryRunner.manager.getRepository( + CommandMenuItemEntity, + ); + + await commandMenuItemRepository.delete({ id: entityId, workspaceId }); + } + + async executeForWorkspaceSchema( + _context: WorkspaceMigrationActionRunnerArgs, + ): Promise { + return; + } +} diff --git a/packages/twenty-server/src/engine/workspace-manager/workspace-migration/workspace-migration-runner/action-handlers/command-menu-item/services/update-command-menu-item-action-handler.service.ts b/packages/twenty-server/src/engine/workspace-manager/workspace-migration/workspace-migration-runner/action-handlers/command-menu-item/services/update-command-menu-item-action-handler.service.ts new file mode 100644 index 00000000000..30ae8f3282b --- /dev/null +++ b/packages/twenty-server/src/engine/workspace-manager/workspace-migration/workspace-migration-runner/action-handlers/command-menu-item/services/update-command-menu-item-action-handler.service.ts @@ -0,0 +1,39 @@ +import { Injectable } from '@nestjs/common'; + +import { WorkspaceMigrationRunnerActionHandler } from 'src/engine/workspace-manager/workspace-migration/workspace-migration-runner/interfaces/workspace-migration-runner-action-handler-service.interface'; + +import { CommandMenuItemEntity } from 'src/engine/metadata-modules/command-menu-item/entities/command-menu-item.entity'; +import { UpdateCommandMenuItemAction } from 'src/engine/workspace-manager/workspace-migration/workspace-migration-builder/builders/command-menu-item/types/workspace-migration-command-menu-item-action.type'; +import { WorkspaceMigrationActionRunnerArgs } from 'src/engine/workspace-manager/workspace-migration/workspace-migration-runner/types/workspace-migration-action-runner-args.type'; +import { fromFlatEntityPropertiesUpdatesToPartialFlatEntity } from 'src/engine/workspace-manager/workspace-migration/workspace-migration-runner/utils/from-flat-entity-properties-updates-to-partial-flat-entity'; + +@Injectable() +export class UpdateCommandMenuItemActionHandlerService extends WorkspaceMigrationRunnerActionHandler( + 'update', + 'commandMenuItem', +) { + async executeForMetadata( + context: WorkspaceMigrationActionRunnerArgs, + ): Promise { + const { action, queryRunner, workspaceId } = context; + const { entityId, updates } = action; + + const commandMenuItemRepository = + queryRunner.manager.getRepository( + CommandMenuItemEntity, + ); + + await commandMenuItemRepository.update( + { id: entityId, workspaceId }, + fromFlatEntityPropertiesUpdatesToPartialFlatEntity({ + updates, + }), + ); + } + + async executeForWorkspaceSchema( + _context: WorkspaceMigrationActionRunnerArgs, + ): Promise { + return; + } +} diff --git a/packages/twenty-server/src/engine/workspace-manager/workspace-migration/workspace-migration-runner/action-handlers/workspace-schema-migration-runner-action-handlers.module.ts b/packages/twenty-server/src/engine/workspace-manager/workspace-migration/workspace-migration-runner/action-handlers/workspace-schema-migration-runner-action-handlers.module.ts index 2d2206529d5..9cba7bd19c6 100644 --- a/packages/twenty-server/src/engine/workspace-manager/workspace-migration/workspace-migration-runner/action-handlers/workspace-schema-migration-runner-action-handlers.module.ts +++ b/packages/twenty-server/src/engine/workspace-manager/workspace-migration/workspace-migration-runner/action-handlers/workspace-schema-migration-runner-action-handlers.module.ts @@ -4,6 +4,9 @@ import { WorkspaceSchemaManagerModule } from 'src/engine/twenty-orm/workspace-sc import { CreateAgentActionHandlerService } from 'src/engine/workspace-manager/workspace-migration/workspace-migration-runner/action-handlers/agent/services/create-agent-action-handler.service'; import { DeleteAgentActionHandlerService } from 'src/engine/workspace-manager/workspace-migration/workspace-migration-runner/action-handlers/agent/services/delete-agent-action-handler.service'; import { UpdateAgentActionHandlerService } from 'src/engine/workspace-manager/workspace-migration/workspace-migration-runner/action-handlers/agent/services/update-agent-action-handler.service'; +import { CreateCommandMenuItemActionHandlerService } from 'src/engine/workspace-manager/workspace-migration/workspace-migration-runner/action-handlers/command-menu-item/services/create-command-menu-item-action-handler.service'; +import { DeleteCommandMenuItemActionHandlerService } from 'src/engine/workspace-manager/workspace-migration/workspace-migration-runner/action-handlers/command-menu-item/services/delete-command-menu-item-action-handler.service'; +import { UpdateCommandMenuItemActionHandlerService } from 'src/engine/workspace-manager/workspace-migration/workspace-migration-runner/action-handlers/command-menu-item/services/update-command-menu-item-action-handler.service'; import { CreateCronTriggerActionHandlerService } from 'src/engine/workspace-manager/workspace-migration/workspace-migration-runner/action-handlers/cron-trigger/services/create-cron-trigger-action-handler.service'; import { DeleteCronTriggerActionHandlerService } from 'src/engine/workspace-manager/workspace-migration/workspace-migration-runner/action-handlers/cron-trigger/services/delete-cron-trigger-action-handler.service'; import { UpdateCronTriggerActionHandlerService } from 'src/engine/workspace-manager/workspace-migration/workspace-migration-runner/action-handlers/cron-trigger/services/update-cron-trigger-action-handler.service'; @@ -135,6 +138,10 @@ import { UpdateViewActionHandlerService } from 'src/engine/workspace-manager/wor UpdateSkillActionHandlerService, DeleteSkillActionHandlerService, + CreateCommandMenuItemActionHandlerService, + UpdateCommandMenuItemActionHandlerService, + DeleteCommandMenuItemActionHandlerService, + CreatePageLayoutActionHandlerService, UpdatePageLayoutActionHandlerService, DeletePageLayoutActionHandlerService, diff --git a/packages/twenty-server/src/engine/workspace-manager/workspace-migration/workspace-migration-runner/utils/optimistically-apply-create-action-on-all-flat-entity-maps.util.ts b/packages/twenty-server/src/engine/workspace-manager/workspace-migration/workspace-migration-runner/utils/optimistically-apply-create-action-on-all-flat-entity-maps.util.ts index 924fb9fb630..47d10b28e2c 100644 --- a/packages/twenty-server/src/engine/workspace-manager/workspace-migration/workspace-migration-runner/utils/optimistically-apply-create-action-on-all-flat-entity-maps.util.ts +++ b/packages/twenty-server/src/engine/workspace-manager/workspace-migration/workspace-migration-runner/utils/optimistically-apply-create-action-on-all-flat-entity-maps.util.ts @@ -68,8 +68,9 @@ export const optimisticallyApplyCreateActionOnAllFlatEntityMaps = < case 'skill': case 'pageLayout': case 'pageLayoutWidget': - case 'frontComponent': - case 'pageLayoutTab': { + case 'pageLayoutTab': + case 'commandMenuItem': + case 'frontComponent': { addFlatEntityToFlatEntityAndRelatedEntityMapsThroughMutationOrThrow({ flatEntity: action.flatEntity, flatEntityAndRelatedMapsToMutate: allFlatEntityMaps, diff --git a/packages/twenty-server/src/engine/workspace-manager/workspace-migration/workspace-migration-runner/utils/optimistically-apply-delete-action-on-all-flat-entity-maps.util.ts b/packages/twenty-server/src/engine/workspace-manager/workspace-migration/workspace-migration-runner/utils/optimistically-apply-delete-action-on-all-flat-entity-maps.util.ts index c5e30ddd2f0..7243c778323 100644 --- a/packages/twenty-server/src/engine/workspace-manager/workspace-migration/workspace-migration-runner/utils/optimistically-apply-delete-action-on-all-flat-entity-maps.util.ts +++ b/packages/twenty-server/src/engine/workspace-manager/workspace-migration/workspace-migration-runner/utils/optimistically-apply-delete-action-on-all-flat-entity-maps.util.ts @@ -45,8 +45,9 @@ export const optimisticallyApplyDeleteActionOnAllFlatEntityMaps = < case 'skill': case 'pageLayout': case 'pageLayoutWidget': - case 'frontComponent': - case 'pageLayoutTab': { + case 'pageLayoutTab': + case 'commandMenuItem': + case 'frontComponent': { const flatEntityToDelete = findFlatEntityByIdInFlatEntityMapsOrThrow< MetadataFlatEntity >({ diff --git a/packages/twenty-server/src/engine/workspace-manager/workspace-migration/workspace-migration-runner/utils/optimistically-apply-update-action-on-all-flat-entity-maps.util.ts b/packages/twenty-server/src/engine/workspace-manager/workspace-migration/workspace-migration-runner/utils/optimistically-apply-update-action-on-all-flat-entity-maps.util.ts index c7cda3dd30d..0075036a5b7 100644 --- a/packages/twenty-server/src/engine/workspace-manager/workspace-migration/workspace-migration-runner/utils/optimistically-apply-update-action-on-all-flat-entity-maps.util.ts +++ b/packages/twenty-server/src/engine/workspace-manager/workspace-migration/workspace-migration-runner/utils/optimistically-apply-update-action-on-all-flat-entity-maps.util.ts @@ -68,6 +68,7 @@ export const optimisticallyApplyUpdateActionOnAllFlatEntityMaps = < case 'pageLayout': case 'pageLayoutWidget': case 'pageLayoutTab': + case 'commandMenuItem': case 'frontComponent': { const flatEntityMapsKey = getMetadataFlatEntityMapsKey( action.metadataName, diff --git a/packages/twenty-server/src/modules/workflow/workflow-trigger/workflow-trigger.module.ts b/packages/twenty-server/src/modules/workflow/workflow-trigger/workflow-trigger.module.ts index 095d6f9fe19..bb960b5e6c2 100644 --- a/packages/twenty-server/src/modules/workflow/workflow-trigger/workflow-trigger.module.ts +++ b/packages/twenty-server/src/modules/workflow/workflow-trigger/workflow-trigger.module.ts @@ -2,6 +2,8 @@ import { Module } from '@nestjs/common'; import { NestjsQueryTypeOrmModule } from '@ptc-org/nestjs-query-typeorm'; +import { FeatureFlagModule } from 'src/engine/core-modules/feature-flag/feature-flag.module'; +import { CommandMenuItemModule } from 'src/engine/metadata-modules/command-menu-item/command-menu-item.module'; import { ObjectMetadataEntity } from 'src/engine/metadata-modules/object-metadata/object-metadata.entity'; import { WorkflowCommonModule } from 'src/modules/workflow/common/workflow-common.module'; import { WorkflowRunnerModule } from 'src/modules/workflow/workflow-runner/workflow-runner.module'; @@ -14,6 +16,8 @@ import { WorkflowTriggerWorkspaceService } from 'src/modules/workflow/workflow-t WorkflowCommonModule, WorkflowRunnerModule, AutomatedTriggerModule, + CommandMenuItemModule, + FeatureFlagModule, NestjsQueryTypeOrmModule.forFeature([ObjectMetadataEntity]), ], providers: [WorkflowTriggerWorkspaceService, WorkflowTriggerJob], diff --git a/packages/twenty-server/src/modules/workflow/workflow-trigger/workspace-services/workflow-trigger.workspace-service.ts b/packages/twenty-server/src/modules/workflow/workflow-trigger/workspace-services/workflow-trigger.workspace-service.ts index 33d786214f0..c93e4c419dd 100644 --- a/packages/twenty-server/src/modules/workflow/workflow-trigger/workspace-services/workflow-trigger.workspace-service.ts +++ b/packages/twenty-server/src/modules/workflow/workflow-trigger/workspace-services/workflow-trigger.workspace-service.ts @@ -3,6 +3,10 @@ import { Injectable } from '@nestjs/common'; import { msg } from '@lingui/core/macro'; import { type ActorMetadata } from 'twenty-shared/types'; +import { FeatureFlagKey } from 'src/engine/core-modules/feature-flag/enums/feature-flag-key.enum'; +import { FeatureFlagService } from 'src/engine/core-modules/feature-flag/services/feature-flag.service'; +import { CommandMenuItemService } from 'src/engine/metadata-modules/command-menu-item/command-menu-item.service'; +import { CommandMenuItemAvailabilityType } from 'src/engine/metadata-modules/command-menu-item/entities/command-menu-item.entity'; import { GlobalWorkspaceOrmManager } from 'src/engine/twenty-orm/global-workspace-datasource/global-workspace-orm.manager'; import { type WorkspaceRepository } from 'src/engine/twenty-orm/repository/workspace.repository'; import { buildSystemAuthContext } from 'src/engine/twenty-orm/utils/build-system-auth-context.util'; @@ -24,7 +28,10 @@ import { WorkflowTriggerException, WorkflowTriggerExceptionCode, } from 'src/modules/workflow/workflow-trigger/exceptions/workflow-trigger.exception'; -import { WorkflowTriggerType } from 'src/modules/workflow/workflow-trigger/types/workflow-trigger.type'; +import { + type WorkflowManualTrigger, + WorkflowTriggerType, +} from 'src/modules/workflow/workflow-trigger/types/workflow-trigger.type'; import { assertVersionCanBeActivated } from 'src/modules/workflow/workflow-trigger/utils/assert-version-can-be-activated.util'; import { computeCronPatternFromSchedule } from 'src/modules/workflow/workflow-trigger/utils/compute-cron-pattern-from-schedule'; import { assertNever } from 'src/utils/assert'; @@ -37,6 +44,8 @@ export class WorkflowTriggerWorkspaceService { private readonly workflowRunnerWorkspaceService: WorkflowRunnerWorkspaceService, private readonly automatedTriggerWorkspaceService: AutomatedTriggerWorkspaceService, private readonly workspaceEventEmitter: WorkspaceEventEmitter, + private readonly commandMenuItemService: CommandMenuItemService, + private readonly featureFlagService: FeatureFlagService, ) {} async runWorkflowVersion({ @@ -191,7 +200,7 @@ export class WorkflowTriggerWorkspaceService { workspaceId, ); - await this.enableTrigger(workflowVersion, workspaceId); + await this.enableTrigger(workflow, workflowVersion, workspaceId); } private async performDeactivationSteps( @@ -306,13 +315,77 @@ export class WorkflowTriggerWorkspaceService { } private async enableTrigger( + workflow: WorkflowWorkspaceEntity, workflowVersion: WorkflowVersionWorkspaceEntity, workspaceId: string, ) { assertWorkflowVersionTriggerIsDefined(workflowVersion); switch (workflowVersion.trigger.type) { - case WorkflowTriggerType.MANUAL: + case WorkflowTriggerType.MANUAL: { + const isCommandMenuItemEnabled = + await this.featureFlagService.isFeatureEnabled( + FeatureFlagKey.IS_COMMAND_MENU_ITEM_ENABLED, + workspaceId, + ); + + if (!isCommandMenuItemEnabled) { + return; + } + + const trigger = workflowVersion.trigger as WorkflowManualTrigger; + + const availability = trigger.settings.availability; + + let availabilityType = CommandMenuItemAvailabilityType.GLOBAL; + let availabilityObjectMetadataId: string | undefined; + + if (availability) { + switch (availability.type) { + case 'GLOBAL': + availabilityType = CommandMenuItemAvailabilityType.GLOBAL; + break; + case 'SINGLE_RECORD': + case 'BULK_RECORDS': { + availabilityType = + availability.type === 'SINGLE_RECORD' + ? CommandMenuItemAvailabilityType.SINGLE_RECORD + : CommandMenuItemAvailabilityType.BULK_RECORDS; + + const { objectIdByNameSingular } = + await this.workflowCommonWorkspaceService.getFlatEntityMaps( + workspaceId, + ); + + const objectId = + objectIdByNameSingular[availability.objectNameSingular]; + + if (!objectId) { + throw new WorkflowTriggerException( + `Object metadata not found for object: ${availability.objectNameSingular}`, + WorkflowTriggerExceptionCode.INVALID_WORKFLOW_VERSION, + ); + } + + availabilityObjectMetadataId = objectId; + break; + } + } + } + + await this.commandMenuItemService.create( + { + workflowVersionId: workflowVersion.id, + label: workflow.name ?? 'Manual Trigger', + icon: trigger.settings.icon, + availabilityType, + availabilityObjectMetadataId, + }, + workspaceId, + ); + + return; + } case WorkflowTriggerType.WEBHOOK: return; case WorkflowTriggerType.DATABASE_EVENT: { @@ -361,7 +434,32 @@ export class WorkflowTriggerWorkspaceService { }); return; - case WorkflowTriggerType.MANUAL: + case WorkflowTriggerType.MANUAL: { + const isCommandMenuItemEnabled = + await this.featureFlagService.isFeatureEnabled( + FeatureFlagKey.IS_COMMAND_MENU_ITEM_ENABLED, + workspaceId, + ); + + if (!isCommandMenuItemEnabled) { + return; + } + + const existingCommandMenuItem = + await this.commandMenuItemService.findByWorkflowVersionId( + workflowVersion.id, + workspaceId, + ); + + if (existingCommandMenuItem) { + await this.commandMenuItemService.delete( + existingCommandMenuItem.id, + workspaceId, + ); + } + + return; + } case WorkflowTriggerType.WEBHOOK: return; default: diff --git a/packages/twenty-server/test/integration/metadata/suites/command-menu-item/__snapshots__/failing-command-menu-item-creation.integration-spec.ts.snap b/packages/twenty-server/test/integration/metadata/suites/command-menu-item/__snapshots__/failing-command-menu-item-creation.integration-spec.ts.snap new file mode 100644 index 00000000000..f689bd51a61 --- /dev/null +++ b/packages/twenty-server/test/integration/metadata/suites/command-menu-item/__snapshots__/failing-command-menu-item-creation.integration-spec.ts.snap @@ -0,0 +1,95 @@ +// Jest Snapshot v1, https://jestjs.io/docs/snapshot-testing + +exports[`CommandMenuItem creation should fail when creating with empty label 1`] = ` +{ + "extensions": { + "code": "METADATA_VALIDATION_FAILED", + "errors": { + "commandMenuItem": [ + { + "errors": [ + { + "code": "INVALID_COMMAND_MENU_ITEM_INPUT", + "message": "Label is required", + "userFriendlyMessage": "Label is required", + }, + ], + "flatEntityMinimalInformation": { + "id": Any, + "universalIdentifier": Any, + }, + "metadataName": "commandMenuItem", + "status": "fail", + "type": "create", + }, + ], + }, + "message": "Validation failed for 1 commandMenuItem", + "summary": { + "commandMenuItem": 1, + "totalErrors": 1, + }, + "userFriendlyMessage": "Metadata validation failed", + }, + "message": "Multiple validation errors occurred while creating command menu item", + "name": "GraphQLError", +} +`; + +exports[`CommandMenuItem creation should fail when creating with missing label 1`] = ` +{ + "extensions": { + "code": "BAD_USER_INPUT", + "http": { + "status": 400, + }, + "userFriendlyMessage": "An error occurred.", + }, + "message": "Field "label" of required type "String!" was not provided.", + "name": "GraphQLError", +} +`; + +exports[`CommandMenuItem creation should fail when creating with missing workflowVersionId 1`] = ` +{ + "extensions": { + "code": "BAD_USER_INPUT", + "http": { + "status": 400, + }, + "userFriendlyMessage": "An error occurred.", + }, + "message": "Field "workflowVersionId" of required type "UUID!" was not provided.", + "name": "GraphQLError", +} +`; + +exports[`CommandMenuItem creation should fail when creating with empty workflowVersionId 1`] = ` +{ + "extensions": { + "code": "GRAPHQL_VALIDATION_FAILED", + "http": { + "status": 400, + }, + "userFriendlyMessage": "An error occurred.", + "value": "", + }, + "message": "Invalid UUID", + "name": "ValidationError", +} +`; + +exports[`CommandMenuItem creation should fail when creating with invalid workflowVersionId (not a UUID) 1`] = ` +{ + "extensions": { + "code": "GRAPHQL_VALIDATION_FAILED", + "http": { + "status": 400, + }, + "userFriendlyMessage": "An error occurred.", + "value": "not-a-valid-uuid", + }, + "message": "Invalid UUID", + "name": "ValidationError", +} +`; diff --git a/packages/twenty-server/test/integration/metadata/suites/command-menu-item/__snapshots__/failing-command-menu-item-deletion.integration-spec.ts.snap b/packages/twenty-server/test/integration/metadata/suites/command-menu-item/__snapshots__/failing-command-menu-item-deletion.integration-spec.ts.snap new file mode 100644 index 00000000000..68557bb78f0 --- /dev/null +++ b/packages/twenty-server/test/integration/metadata/suites/command-menu-item/__snapshots__/failing-command-menu-item-deletion.integration-spec.ts.snap @@ -0,0 +1,57 @@ +// Jest Snapshot v1, https://jestjs.io/docs/snapshot-testing + +exports[`CommandMenuItem deletion should fail when deleting a non-existent command menu item 1`] = ` +{ + "extensions": { + "code": "NOT_FOUND", + "subCode": "COMMAND_MENU_ITEM_NOT_FOUND", + "userFriendlyMessage": "Command menu item not found.", + }, + "message": "Command menu item not found", + "name": "NotFoundError", +} +`; + +exports[`CommandMenuItem deletion should fail when deleting with missing id 1`] = ` +{ + "extensions": { + "code": "BAD_USER_INPUT", + "http": { + "status": 400, + }, + "userFriendlyMessage": "An error occurred.", + }, + "message": "Variable "$id" of required type "UUID!" was not provided.", + "name": "GraphQLError", +} +`; + +exports[`CommandMenuItem deletion should fail when deleting with empty id 1`] = ` +{ + "extensions": { + "code": "GRAPHQL_VALIDATION_FAILED", + "http": { + "status": 400, + }, + "userFriendlyMessage": "An error occurred.", + "value": "", + }, + "message": "Invalid UUID", + "name": "ValidationError", +} +`; + +exports[`CommandMenuItem deletion should fail when deleting with invalid id (not a UUID) 1`] = ` +{ + "extensions": { + "code": "GRAPHQL_VALIDATION_FAILED", + "http": { + "status": 400, + }, + "userFriendlyMessage": "An error occurred.", + "value": "not-a-valid-uuid", + }, + "message": "Invalid UUID", + "name": "ValidationError", +} +`; diff --git a/packages/twenty-server/test/integration/metadata/suites/command-menu-item/__snapshots__/failing-command-menu-item-update.integration-spec.ts.snap b/packages/twenty-server/test/integration/metadata/suites/command-menu-item/__snapshots__/failing-command-menu-item-update.integration-spec.ts.snap new file mode 100644 index 00000000000..e68eb0a3fb5 --- /dev/null +++ b/packages/twenty-server/test/integration/metadata/suites/command-menu-item/__snapshots__/failing-command-menu-item-update.integration-spec.ts.snap @@ -0,0 +1,93 @@ +// Jest Snapshot v1, https://jestjs.io/docs/snapshot-testing + +exports[`CommandMenuItem update should fail when updating with empty label 1`] = ` +{ + "extensions": { + "code": "METADATA_VALIDATION_FAILED", + "errors": { + "commandMenuItem": [ + { + "errors": [ + { + "code": "INVALID_COMMAND_MENU_ITEM_INPUT", + "message": "Label is required", + "userFriendlyMessage": "Label is required", + }, + ], + "flatEntityMinimalInformation": { + "id": Any, + "universalIdentifier": Any, + }, + "metadataName": "commandMenuItem", + "status": "fail", + "type": "update", + }, + ], + }, + "message": "Validation failed for 1 commandMenuItem", + "summary": { + "commandMenuItem": 1, + "totalErrors": 1, + }, + "userFriendlyMessage": "Metadata validation failed", + }, + "message": "Multiple validation errors occurred while updating command menu item", + "name": "GraphQLError", +} +`; + +exports[`CommandMenuItem update should fail when updating with missing id 1`] = ` +{ + "extensions": { + "code": "BAD_USER_INPUT", + "http": { + "status": 400, + }, + "userFriendlyMessage": "An error occurred.", + }, + "message": "Field "id" of required type "UUID!" was not provided.", + "name": "GraphQLError", +} +`; + +exports[`CommandMenuItem update should fail when updating with empty id 1`] = ` +{ + "extensions": { + "code": "GRAPHQL_VALIDATION_FAILED", + "http": { + "status": 400, + }, + "userFriendlyMessage": "An error occurred.", + "value": "", + }, + "message": "Invalid UUID", + "name": "ValidationError", +} +`; + +exports[`CommandMenuItem update should fail when updating with invalid id (not a UUID) 1`] = ` +{ + "extensions": { + "code": "GRAPHQL_VALIDATION_FAILED", + "http": { + "status": 400, + }, + "userFriendlyMessage": "An error occurred.", + "value": "not-a-valid-uuid", + }, + "message": "Invalid UUID", + "name": "ValidationError", +} +`; + +exports[`CommandMenuItem update should fail when updating a non-existent command menu item 1`] = ` +{ + "extensions": { + "code": "NOT_FOUND", + "subCode": "COMMAND_MENU_ITEM_NOT_FOUND", + "userFriendlyMessage": "Command menu item not found.", + }, + "message": "Command menu item not found", + "name": "NotFoundError", +} +`; diff --git a/packages/twenty-server/test/integration/metadata/suites/command-menu-item/failing-command-menu-item-creation.integration-spec.ts b/packages/twenty-server/test/integration/metadata/suites/command-menu-item/failing-command-menu-item-creation.integration-spec.ts new file mode 100644 index 00000000000..e3387bda857 --- /dev/null +++ b/packages/twenty-server/test/integration/metadata/suites/command-menu-item/failing-command-menu-item-creation.integration-spec.ts @@ -0,0 +1,94 @@ +import { faker } from '@faker-js/faker'; +import { expectOneNotInternalServerErrorSnapshot } from 'test/integration/graphql/utils/expect-one-not-internal-server-error-snapshot.util'; +import { updateFeatureFlag } from 'test/integration/metadata/suites/utils/update-feature-flag.util'; +import { createCommandMenuItem } from 'test/integration/metadata/suites/command-menu-item/utils/create-command-menu-item.util'; +import { + eachTestingContextFilter, + type EachTestingContext, +} from 'twenty-shared/testing'; + +import { FeatureFlagKey } from 'src/engine/core-modules/feature-flag/enums/feature-flag-key.enum'; +import { type CreateCommandMenuItemInput } from 'src/engine/metadata-modules/command-menu-item/dtos/create-command-menu-item.input'; + +type TestContext = { + input: CreateCommandMenuItemInput; +}; + +const failingCommandMenuItemCreationTestCases: EachTestingContext[] = + [ + { + title: 'when creating with empty label', + context: { + input: { + workflowVersionId: faker.string.uuid(), + label: '', + }, + }, + }, + { + title: 'when creating with missing label', + context: { + input: { + workflowVersionId: faker.string.uuid(), + } as CreateCommandMenuItemInput, + }, + }, + { + title: 'when creating with missing workflowVersionId', + context: { + input: { + label: 'Test Label', + } as CreateCommandMenuItemInput, + }, + }, + { + title: 'when creating with empty workflowVersionId', + context: { + input: { + workflowVersionId: '', + label: 'Test Label', + }, + }, + }, + { + title: 'when creating with invalid workflowVersionId (not a UUID)', + context: { + input: { + workflowVersionId: 'not-a-valid-uuid', + label: 'Test Label', + }, + }, + }, + ]; + +describe('CommandMenuItem creation should fail', () => { + beforeAll(async () => { + await updateFeatureFlag({ + featureFlag: FeatureFlagKey.IS_COMMAND_MENU_ITEM_ENABLED, + value: true, + expectToFail: false, + }); + }); + + afterAll(async () => { + await updateFeatureFlag({ + featureFlag: FeatureFlagKey.IS_COMMAND_MENU_ITEM_ENABLED, + value: false, + expectToFail: false, + }); + }); + + it.each(eachTestingContextFilter(failingCommandMenuItemCreationTestCases))( + '$title', + async ({ context }) => { + const { errors } = await createCommandMenuItem({ + expectToFail: true, + input: context.input, + }); + + expectOneNotInternalServerErrorSnapshot({ + errors, + }); + }, + ); +}); diff --git a/packages/twenty-server/test/integration/metadata/suites/command-menu-item/failing-command-menu-item-deletion.integration-spec.ts b/packages/twenty-server/test/integration/metadata/suites/command-menu-item/failing-command-menu-item-deletion.integration-spec.ts new file mode 100644 index 00000000000..35e248460f2 --- /dev/null +++ b/packages/twenty-server/test/integration/metadata/suites/command-menu-item/failing-command-menu-item-deletion.integration-spec.ts @@ -0,0 +1,81 @@ +import { faker } from '@faker-js/faker'; +import { expectOneNotInternalServerErrorSnapshot } from 'test/integration/graphql/utils/expect-one-not-internal-server-error-snapshot.util'; +import { updateFeatureFlag } from 'test/integration/metadata/suites/utils/update-feature-flag.util'; +import { type DeleteCommandMenuItemFactoryInput } from 'test/integration/metadata/suites/command-menu-item/utils/delete-command-menu-item-query-factory.util'; +import { deleteCommandMenuItem } from 'test/integration/metadata/suites/command-menu-item/utils/delete-command-menu-item.util'; +import { + eachTestingContextFilter, + type EachTestingContext, +} from 'twenty-shared/testing'; + +import { FeatureFlagKey } from 'src/engine/core-modules/feature-flag/enums/feature-flag-key.enum'; + +type TestContext = { + input: DeleteCommandMenuItemFactoryInput; +}; + +const failingCommandMenuItemDeletionTestCases: EachTestingContext[] = + [ + { + title: 'when deleting a non-existent command menu item', + context: { + input: { + id: faker.string.uuid(), + }, + }, + }, + { + title: 'when deleting with missing id', + context: { + input: {} as DeleteCommandMenuItemFactoryInput, + }, + }, + { + title: 'when deleting with empty id', + context: { + input: { + id: '', + }, + }, + }, + { + title: 'when deleting with invalid id (not a UUID)', + context: { + input: { + id: 'not-a-valid-uuid', + }, + }, + }, + ]; + +describe('CommandMenuItem deletion should fail', () => { + beforeAll(async () => { + await updateFeatureFlag({ + featureFlag: FeatureFlagKey.IS_COMMAND_MENU_ITEM_ENABLED, + value: true, + expectToFail: false, + }); + }); + + afterAll(async () => { + await updateFeatureFlag({ + featureFlag: FeatureFlagKey.IS_COMMAND_MENU_ITEM_ENABLED, + value: false, + expectToFail: false, + }); + }); + + it.each(eachTestingContextFilter(failingCommandMenuItemDeletionTestCases))( + '$title', + async ({ context }) => { + const { errors } = await deleteCommandMenuItem({ + expectToFail: true, + input: context.input, + }); + + expectOneNotInternalServerErrorSnapshot({ + errors, + }); + }, + ); +}); diff --git a/packages/twenty-server/test/integration/metadata/suites/command-menu-item/failing-command-menu-item-update.integration-spec.ts b/packages/twenty-server/test/integration/metadata/suites/command-menu-item/failing-command-menu-item-update.integration-spec.ts new file mode 100644 index 00000000000..06c53a2072e --- /dev/null +++ b/packages/twenty-server/test/integration/metadata/suites/command-menu-item/failing-command-menu-item-update.integration-spec.ts @@ -0,0 +1,133 @@ +import { faker } from '@faker-js/faker'; +import { expectOneNotInternalServerErrorSnapshot } from 'test/integration/graphql/utils/expect-one-not-internal-server-error-snapshot.util'; +import { updateFeatureFlag } from 'test/integration/metadata/suites/utils/update-feature-flag.util'; +import { createCommandMenuItem } from 'test/integration/metadata/suites/command-menu-item/utils/create-command-menu-item.util'; +import { deleteCommandMenuItem } from 'test/integration/metadata/suites/command-menu-item/utils/delete-command-menu-item.util'; +import { updateCommandMenuItem } from 'test/integration/metadata/suites/command-menu-item/utils/update-command-menu-item.util'; +import { + eachTestingContextFilter, + type EachTestingContext, +} from 'twenty-shared/testing'; + +import { FeatureFlagKey } from 'src/engine/core-modules/feature-flag/enums/feature-flag-key.enum'; +import { type UpdateCommandMenuItemInput } from 'src/engine/metadata-modules/command-menu-item/dtos/update-command-menu-item.input'; + +type TestContext = { + input: (testSetup: TestSetup) => UpdateCommandMenuItemInput; +}; + +type TestSetup = { + testCommandMenuItemId: string; +}; + +describe('CommandMenuItem update should fail', () => { + let testCommandMenuItemId: string; + + beforeAll(async () => { + await updateFeatureFlag({ + featureFlag: FeatureFlagKey.IS_COMMAND_MENU_ITEM_ENABLED, + value: true, + expectToFail: false, + }); + }); + + afterAll(async () => { + await updateFeatureFlag({ + featureFlag: FeatureFlagKey.IS_COMMAND_MENU_ITEM_ENABLED, + value: false, + expectToFail: false, + }); + }); + + beforeEach(async () => { + const { data } = await createCommandMenuItem({ + expectToFail: false, + input: { + workflowVersionId: faker.string.uuid(), + label: 'Test Command Menu Item To Update', + icon: 'IconOriginal', + isPinned: false, + }, + }); + + testCommandMenuItemId = data.createCommandMenuItem.id; + }); + + afterEach(async () => { + if (testCommandMenuItemId) { + await deleteCommandMenuItem({ + expectToFail: false, + input: { id: testCommandMenuItemId }, + }); + } + }); + + const failingCommandMenuItemUpdateTestCases: EachTestingContext[] = + [ + { + title: 'when updating with empty label', + context: { + input: (testSetup) => ({ + id: testSetup.testCommandMenuItemId, + label: '', + }), + }, + }, + { + title: 'when updating with missing id', + context: { + input: () => + ({ + label: 'Updated Label', + }) as UpdateCommandMenuItemInput, + }, + }, + { + title: 'when updating with empty id', + context: { + input: () => ({ + id: '', + label: 'Updated Label', + }), + }, + }, + { + title: 'when updating with invalid id (not a UUID)', + context: { + input: () => ({ + id: 'not-a-valid-uuid', + label: 'Updated Label', + }), + }, + }, + { + title: 'when updating a non-existent command menu item', + context: { + input: () => ({ + id: faker.string.uuid(), + label: 'Updated Label', + }), + }, + }, + ]; + + it.each(eachTestingContextFilter(failingCommandMenuItemUpdateTestCases))( + '$title', + async ({ context }) => { + const testSetup: TestSetup = { + testCommandMenuItemId, + }; + + const input = context.input(testSetup); + + const { errors } = await updateCommandMenuItem({ + expectToFail: true, + input, + }); + + expectOneNotInternalServerErrorSnapshot({ + errors, + }); + }, + ); +}); diff --git a/packages/twenty-server/test/integration/metadata/suites/command-menu-item/successful-command-menu-item-creation.integration-spec.ts b/packages/twenty-server/test/integration/metadata/suites/command-menu-item/successful-command-menu-item-creation.integration-spec.ts new file mode 100644 index 00000000000..1379bdc139e --- /dev/null +++ b/packages/twenty-server/test/integration/metadata/suites/command-menu-item/successful-command-menu-item-creation.integration-spec.ts @@ -0,0 +1,165 @@ +import { faker } from '@faker-js/faker'; +import { createCommandMenuItem } from 'test/integration/metadata/suites/command-menu-item/utils/create-command-menu-item.util'; +import { deleteCommandMenuItem } from 'test/integration/metadata/suites/command-menu-item/utils/delete-command-menu-item.util'; +import { findManyObjectMetadata } from 'test/integration/metadata/suites/object-metadata/utils/find-many-object-metadata.util'; +import { updateFeatureFlag } from 'test/integration/metadata/suites/utils/update-feature-flag.util'; +import { jestExpectToBeDefined } from 'test/utils/jest-expect-to-be-defined.util.test'; + +import { FeatureFlagKey } from 'src/engine/core-modules/feature-flag/enums/feature-flag-key.enum'; +import { CommandMenuItemAvailabilityType } from 'src/engine/metadata-modules/command-menu-item/entities/command-menu-item.entity'; + +describe('CommandMenuItem creation should succeed', () => { + let createdCommandMenuItemId: string; + let companyObjectMetadataId: string; + let personObjectMetadataId: string; + + beforeAll(async () => { + await updateFeatureFlag({ + featureFlag: FeatureFlagKey.IS_COMMAND_MENU_ITEM_ENABLED, + value: true, + expectToFail: false, + }); + + const { objects } = await findManyObjectMetadata({ + expectToFail: false, + input: { + filter: {}, + paging: { first: 1000 }, + }, + gqlFields: ` + id + nameSingular + `, + }); + + jestExpectToBeDefined(objects); + + const companyObjectMetadata = objects.find( + (object: { nameSingular: string }) => object.nameSingular === 'company', + ); + const personObjectMetadata = objects.find( + (object: { nameSingular: string }) => object.nameSingular === 'person', + ); + + jestExpectToBeDefined(companyObjectMetadata); + jestExpectToBeDefined(personObjectMetadata); + + companyObjectMetadataId = companyObjectMetadata.id; + personObjectMetadataId = personObjectMetadata.id; + }); + + afterAll(async () => { + await updateFeatureFlag({ + featureFlag: FeatureFlagKey.IS_COMMAND_MENU_ITEM_ENABLED, + value: false, + expectToFail: false, + }); + }); + + afterEach(async () => { + if (createdCommandMenuItemId) { + await deleteCommandMenuItem({ + expectToFail: false, + input: { id: createdCommandMenuItemId }, + }); + createdCommandMenuItemId = undefined as unknown as string; + } + }); + + it('should create a basic command menu item with minimal input', async () => { + const workflowVersionId = faker.string.uuid(); + + const { data } = await createCommandMenuItem({ + expectToFail: false, + input: { + workflowVersionId, + label: 'Test Command Menu Item', + }, + }); + + createdCommandMenuItemId = data?.createCommandMenuItem?.id; + + expect(data.createCommandMenuItem).toMatchObject({ + id: expect.any(String), + workflowVersionId, + label: 'Test Command Menu Item', + icon: null, + isPinned: false, + availabilityType: CommandMenuItemAvailabilityType.GLOBAL, + availabilityObjectMetadataId: null, + }); + }); + + it('should create command menu item with all optional fields', async () => { + const workflowVersionId = faker.string.uuid(); + + const { data } = await createCommandMenuItem({ + expectToFail: false, + input: { + workflowVersionId, + label: 'Full Command Menu Item', + icon: 'IconSparkles', + isPinned: true, + availabilityType: CommandMenuItemAvailabilityType.SINGLE_RECORD, + availabilityObjectMetadataId: companyObjectMetadataId, + }, + }); + + createdCommandMenuItemId = data?.createCommandMenuItem?.id; + + expect(data.createCommandMenuItem).toMatchObject({ + id: expect.any(String), + workflowVersionId, + label: 'Full Command Menu Item', + icon: 'IconSparkles', + isPinned: true, + availabilityType: CommandMenuItemAvailabilityType.SINGLE_RECORD, + availabilityObjectMetadataId: companyObjectMetadataId, + }); + }); + + it('should create command menu item with BULK_RECORDS availability', async () => { + const workflowVersionId = faker.string.uuid(); + + const { data } = await createCommandMenuItem({ + expectToFail: false, + input: { + workflowVersionId, + label: 'Bulk Records Command', + availabilityType: CommandMenuItemAvailabilityType.BULK_RECORDS, + availabilityObjectMetadataId: personObjectMetadataId, + }, + }); + + createdCommandMenuItemId = data?.createCommandMenuItem?.id; + + expect(data.createCommandMenuItem).toMatchObject({ + id: expect.any(String), + workflowVersionId, + label: 'Bulk Records Command', + availabilityType: CommandMenuItemAvailabilityType.BULK_RECORDS, + availabilityObjectMetadataId: personObjectMetadataId, + }); + }); + + it('should create command menu item with GLOBAL availability (default)', async () => { + const workflowVersionId = faker.string.uuid(); + + const { data } = await createCommandMenuItem({ + expectToFail: false, + input: { + workflowVersionId, + label: 'Global Command', + }, + }); + + createdCommandMenuItemId = data?.createCommandMenuItem?.id; + + expect(data.createCommandMenuItem).toMatchObject({ + id: expect.any(String), + workflowVersionId, + label: 'Global Command', + availabilityType: CommandMenuItemAvailabilityType.GLOBAL, + }); + }); +}); diff --git a/packages/twenty-server/test/integration/metadata/suites/command-menu-item/successful-command-menu-item-deletion.integration-spec.ts b/packages/twenty-server/test/integration/metadata/suites/command-menu-item/successful-command-menu-item-deletion.integration-spec.ts new file mode 100644 index 00000000000..ada8444ed82 --- /dev/null +++ b/packages/twenty-server/test/integration/metadata/suites/command-menu-item/successful-command-menu-item-deletion.integration-spec.ts @@ -0,0 +1,60 @@ +import { faker } from '@faker-js/faker'; +import { updateFeatureFlag } from 'test/integration/metadata/suites/utils/update-feature-flag.util'; +import { createCommandMenuItem } from 'test/integration/metadata/suites/command-menu-item/utils/create-command-menu-item.util'; +import { deleteCommandMenuItem } from 'test/integration/metadata/suites/command-menu-item/utils/delete-command-menu-item.util'; +import { findCommandMenuItems } from 'test/integration/metadata/suites/command-menu-item/utils/find-command-menu-items.util'; + +import { FeatureFlagKey } from 'src/engine/core-modules/feature-flag/enums/feature-flag-key.enum'; + +describe('CommandMenuItem deletion should succeed', () => { + beforeAll(async () => { + await updateFeatureFlag({ + featureFlag: FeatureFlagKey.IS_COMMAND_MENU_ITEM_ENABLED, + value: true, + expectToFail: false, + }); + }); + + afterAll(async () => { + await updateFeatureFlag({ + featureFlag: FeatureFlagKey.IS_COMMAND_MENU_ITEM_ENABLED, + value: false, + expectToFail: false, + }); + }); + + it('should delete an existing command menu item', async () => { + const workflowVersionId = faker.string.uuid(); + + const { data: createData } = await createCommandMenuItem({ + expectToFail: false, + input: { + workflowVersionId, + label: 'Command Menu Item To Delete', + }, + }); + + const createdId = createData.createCommandMenuItem.id; + + const { data: deleteData } = await deleteCommandMenuItem({ + expectToFail: false, + input: { id: createdId }, + }); + + expect(deleteData.deleteCommandMenuItem).toMatchObject({ + id: createdId, + label: 'Command Menu Item To Delete', + }); + + const { data: findData } = await findCommandMenuItems({ + expectToFail: false, + input: undefined, + }); + + const deletedItem = findData.commandMenuItems.find( + (item) => item.id === createdId, + ); + + expect(deletedItem).toBeUndefined(); + }); +}); diff --git a/packages/twenty-server/test/integration/metadata/suites/command-menu-item/successful-command-menu-item-update.integration-spec.ts b/packages/twenty-server/test/integration/metadata/suites/command-menu-item/successful-command-menu-item-update.integration-spec.ts new file mode 100644 index 00000000000..8777f9718ba --- /dev/null +++ b/packages/twenty-server/test/integration/metadata/suites/command-menu-item/successful-command-menu-item-update.integration-spec.ts @@ -0,0 +1,171 @@ +import { faker } from '@faker-js/faker'; +import { createCommandMenuItem } from 'test/integration/metadata/suites/command-menu-item/utils/create-command-menu-item.util'; +import { deleteCommandMenuItem } from 'test/integration/metadata/suites/command-menu-item/utils/delete-command-menu-item.util'; +import { findManyObjectMetadata } from 'test/integration/metadata/suites/object-metadata/utils/find-many-object-metadata.util'; +import { updateCommandMenuItem } from 'test/integration/metadata/suites/command-menu-item/utils/update-command-menu-item.util'; +import { updateFeatureFlag } from 'test/integration/metadata/suites/utils/update-feature-flag.util'; +import { jestExpectToBeDefined } from 'test/utils/jest-expect-to-be-defined.util.test'; + +import { FeatureFlagKey } from 'src/engine/core-modules/feature-flag/enums/feature-flag-key.enum'; +import { CommandMenuItemAvailabilityType } from 'src/engine/metadata-modules/command-menu-item/entities/command-menu-item.entity'; + +describe('CommandMenuItem update should succeed', () => { + let createdCommandMenuItemId: string; + let companyObjectMetadataId: string; + let personObjectMetadataId: string; + + beforeAll(async () => { + await updateFeatureFlag({ + featureFlag: FeatureFlagKey.IS_COMMAND_MENU_ITEM_ENABLED, + value: true, + expectToFail: false, + }); + + const { objects } = await findManyObjectMetadata({ + expectToFail: false, + input: { + filter: {}, + paging: { first: 1000 }, + }, + gqlFields: ` + id + nameSingular + `, + }); + + jestExpectToBeDefined(objects); + + const companyObjectMetadata = objects.find( + (object: { nameSingular: string }) => object.nameSingular === 'company', + ); + const personObjectMetadata = objects.find( + (object: { nameSingular: string }) => object.nameSingular === 'person', + ); + + jestExpectToBeDefined(companyObjectMetadata); + jestExpectToBeDefined(personObjectMetadata); + + companyObjectMetadataId = companyObjectMetadata.id; + personObjectMetadataId = personObjectMetadata.id; + }); + + afterAll(async () => { + await updateFeatureFlag({ + featureFlag: FeatureFlagKey.IS_COMMAND_MENU_ITEM_ENABLED, + value: false, + expectToFail: false, + }); + }); + + beforeEach(async () => { + const workflowVersionId = faker.string.uuid(); + + const { data } = await createCommandMenuItem({ + expectToFail: false, + input: { + workflowVersionId, + label: 'Original Label', + icon: 'IconOriginal', + isPinned: false, + availabilityType: CommandMenuItemAvailabilityType.GLOBAL, + }, + }); + + createdCommandMenuItemId = data.createCommandMenuItem.id; + }); + + afterEach(async () => { + if (createdCommandMenuItemId) { + await deleteCommandMenuItem({ + expectToFail: false, + input: { id: createdCommandMenuItemId }, + }); + createdCommandMenuItemId = undefined as unknown as string; + } + }); + + it('should update the label', async () => { + const { data } = await updateCommandMenuItem({ + expectToFail: false, + input: { + id: createdCommandMenuItemId, + label: 'Updated Label', + }, + }); + + expect(data.updateCommandMenuItem).toMatchObject({ + id: createdCommandMenuItemId, + label: 'Updated Label', + }); + }); + + it('should update the icon', async () => { + const { data } = await updateCommandMenuItem({ + expectToFail: false, + input: { + id: createdCommandMenuItemId, + icon: 'IconUpdated', + }, + }); + + expect(data.updateCommandMenuItem).toMatchObject({ + id: createdCommandMenuItemId, + icon: 'IconUpdated', + }); + }); + + it('should update isPinned', async () => { + const { data } = await updateCommandMenuItem({ + expectToFail: false, + input: { + id: createdCommandMenuItemId, + isPinned: true, + }, + }); + + expect(data.updateCommandMenuItem).toMatchObject({ + id: createdCommandMenuItemId, + isPinned: true, + }); + }); + + it('should update availabilityType and availabilityObjectMetadataId', async () => { + const { data } = await updateCommandMenuItem({ + expectToFail: false, + input: { + id: createdCommandMenuItemId, + availabilityType: CommandMenuItemAvailabilityType.SINGLE_RECORD, + availabilityObjectMetadataId: companyObjectMetadataId, + }, + }); + + expect(data.updateCommandMenuItem).toMatchObject({ + id: createdCommandMenuItemId, + availabilityType: CommandMenuItemAvailabilityType.SINGLE_RECORD, + availabilityObjectMetadataId: companyObjectMetadataId, + }); + }); + + it('should update multiple fields at once', async () => { + const { data } = await updateCommandMenuItem({ + expectToFail: false, + input: { + id: createdCommandMenuItemId, + label: 'Fully Updated Label', + icon: 'IconNew', + isPinned: true, + availabilityType: CommandMenuItemAvailabilityType.BULK_RECORDS, + availabilityObjectMetadataId: personObjectMetadataId, + }, + }); + + expect(data.updateCommandMenuItem).toMatchObject({ + id: createdCommandMenuItemId, + label: 'Fully Updated Label', + icon: 'IconNew', + isPinned: true, + availabilityType: CommandMenuItemAvailabilityType.BULK_RECORDS, + availabilityObjectMetadataId: personObjectMetadataId, + }); + }); +}); diff --git a/packages/twenty-server/test/integration/metadata/suites/command-menu-item/utils/create-command-menu-item-query-factory.util.ts b/packages/twenty-server/test/integration/metadata/suites/command-menu-item/utils/create-command-menu-item-query-factory.util.ts new file mode 100644 index 00000000000..0cd3b54585b --- /dev/null +++ b/packages/twenty-server/test/integration/metadata/suites/command-menu-item/utils/create-command-menu-item-query-factory.util.ts @@ -0,0 +1,35 @@ +import gql from 'graphql-tag'; +import { type PerformMetadataQueryParams } from 'test/integration/metadata/types/perform-metadata-query.type'; + +import { type CreateCommandMenuItemInput } from 'src/engine/metadata-modules/command-menu-item/dtos/create-command-menu-item.input'; + +export type CreateCommandMenuItemFactoryInput = CreateCommandMenuItemInput; + +const DEFAULT_COMMAND_MENU_ITEM_GQL_FIELDS = ` + id + workflowVersionId + label + icon + isPinned + availabilityType + availabilityObjectMetadataId + applicationId + createdAt + updatedAt +`; + +export const createCommandMenuItemQueryFactory = ({ + input, + gqlFields = DEFAULT_COMMAND_MENU_ITEM_GQL_FIELDS, +}: PerformMetadataQueryParams) => ({ + query: gql` + mutation CreateCommandMenuItem($input: CreateCommandMenuItemInput!) { + createCommandMenuItem(input: $input) { + ${gqlFields} + } + } + `, + variables: { + input, + }, +}); diff --git a/packages/twenty-server/test/integration/metadata/suites/command-menu-item/utils/create-command-menu-item.util.ts b/packages/twenty-server/test/integration/metadata/suites/command-menu-item/utils/create-command-menu-item.util.ts new file mode 100644 index 00000000000..26685e9133d --- /dev/null +++ b/packages/twenty-server/test/integration/metadata/suites/command-menu-item/utils/create-command-menu-item.util.ts @@ -0,0 +1,43 @@ +import { + type CreateCommandMenuItemFactoryInput, + createCommandMenuItemQueryFactory, +} from 'test/integration/metadata/suites/command-menu-item/utils/create-command-menu-item-query-factory.util'; +import { makeMetadataAPIRequest } from 'test/integration/metadata/suites/utils/make-metadata-api-request.util'; +import { type CommonResponseBody } from 'test/integration/metadata/types/common-response-body.type'; +import { type PerformMetadataQueryParams } from 'test/integration/metadata/types/perform-metadata-query.type'; +import { warnIfErrorButNotExpectedToFail } from 'test/integration/metadata/utils/warn-if-error-but-not-expected-to-fail.util'; +import { warnIfNoErrorButExpectedToFail } from 'test/integration/metadata/utils/warn-if-no-error-but-expected-to-fail.util'; + +import { type CommandMenuItemDTO } from 'src/engine/metadata-modules/command-menu-item/dtos/command-menu-item.dto'; + +export const createCommandMenuItem = async ({ + input, + gqlFields, + expectToFail = false, + token, +}: PerformMetadataQueryParams): CommonResponseBody<{ + createCommandMenuItem: CommandMenuItemDTO; +}> => { + const graphqlOperation = createCommandMenuItemQueryFactory({ + input, + gqlFields, + }); + + const response = await makeMetadataAPIRequest(graphqlOperation, token); + + if (expectToFail === true) { + warnIfNoErrorButExpectedToFail({ + response, + errorMessage: 'CommandMenuItem creation should have failed but did not', + }); + } + + if (expectToFail === false) { + warnIfErrorButNotExpectedToFail({ + response, + errorMessage: 'CommandMenuItem creation has failed but should not', + }); + } + + return { data: response.body.data, errors: response.body.errors }; +}; diff --git a/packages/twenty-server/test/integration/metadata/suites/command-menu-item/utils/delete-command-menu-item-query-factory.util.ts b/packages/twenty-server/test/integration/metadata/suites/command-menu-item/utils/delete-command-menu-item-query-factory.util.ts new file mode 100644 index 00000000000..7f80f60a084 --- /dev/null +++ b/packages/twenty-server/test/integration/metadata/suites/command-menu-item/utils/delete-command-menu-item-query-factory.util.ts @@ -0,0 +1,27 @@ +import gql from 'graphql-tag'; +import { type PerformMetadataQueryParams } from 'test/integration/metadata/types/perform-metadata-query.type'; + +export type DeleteCommandMenuItemFactoryInput = { + id: string; +}; + +const DEFAULT_COMMAND_MENU_ITEM_GQL_FIELDS = ` + id + label +`; + +export const deleteCommandMenuItemQueryFactory = ({ + input, + gqlFields = DEFAULT_COMMAND_MENU_ITEM_GQL_FIELDS, +}: PerformMetadataQueryParams) => ({ + query: gql` + mutation DeleteCommandMenuItem($id: UUID!) { + deleteCommandMenuItem(id: $id) { + ${gqlFields} + } + } + `, + variables: { + id: input.id, + }, +}); diff --git a/packages/twenty-server/test/integration/metadata/suites/command-menu-item/utils/delete-command-menu-item.util.ts b/packages/twenty-server/test/integration/metadata/suites/command-menu-item/utils/delete-command-menu-item.util.ts new file mode 100644 index 00000000000..347ca2a99d7 --- /dev/null +++ b/packages/twenty-server/test/integration/metadata/suites/command-menu-item/utils/delete-command-menu-item.util.ts @@ -0,0 +1,43 @@ +import { + type DeleteCommandMenuItemFactoryInput, + deleteCommandMenuItemQueryFactory, +} from 'test/integration/metadata/suites/command-menu-item/utils/delete-command-menu-item-query-factory.util'; +import { makeMetadataAPIRequest } from 'test/integration/metadata/suites/utils/make-metadata-api-request.util'; +import { type CommonResponseBody } from 'test/integration/metadata/types/common-response-body.type'; +import { type PerformMetadataQueryParams } from 'test/integration/metadata/types/perform-metadata-query.type'; +import { warnIfErrorButNotExpectedToFail } from 'test/integration/metadata/utils/warn-if-error-but-not-expected-to-fail.util'; +import { warnIfNoErrorButExpectedToFail } from 'test/integration/metadata/utils/warn-if-no-error-but-expected-to-fail.util'; + +import { type CommandMenuItemDTO } from 'src/engine/metadata-modules/command-menu-item/dtos/command-menu-item.dto'; + +export const deleteCommandMenuItem = async ({ + input, + gqlFields, + expectToFail = false, + token, +}: PerformMetadataQueryParams): CommonResponseBody<{ + deleteCommandMenuItem: CommandMenuItemDTO; +}> => { + const graphqlOperation = deleteCommandMenuItemQueryFactory({ + input, + gqlFields, + }); + + const response = await makeMetadataAPIRequest(graphqlOperation, token); + + if (expectToFail === true) { + warnIfNoErrorButExpectedToFail({ + response, + errorMessage: 'CommandMenuItem deletion should have failed but did not', + }); + } + + if (expectToFail === false) { + warnIfErrorButNotExpectedToFail({ + response, + errorMessage: 'CommandMenuItem deletion has failed but should not', + }); + } + + return { data: response.body.data, errors: response.body.errors }; +}; diff --git a/packages/twenty-server/test/integration/metadata/suites/command-menu-item/utils/find-command-menu-items-query-factory.util.ts b/packages/twenty-server/test/integration/metadata/suites/command-menu-item/utils/find-command-menu-items-query-factory.util.ts new file mode 100644 index 00000000000..62db82738f9 --- /dev/null +++ b/packages/twenty-server/test/integration/metadata/suites/command-menu-item/utils/find-command-menu-items-query-factory.util.ts @@ -0,0 +1,27 @@ +import gql from 'graphql-tag'; +import { type PerformMetadataQueryParams } from 'test/integration/metadata/types/perform-metadata-query.type'; + +const DEFAULT_COMMAND_MENU_ITEM_GQL_FIELDS = ` + id + workflowVersionId + label + icon + isPinned + availabilityType + availabilityObjectMetadataId + applicationId + createdAt + updatedAt +`; + +export const findCommandMenuItemsQueryFactory = ({ + gqlFields = DEFAULT_COMMAND_MENU_ITEM_GQL_FIELDS, +}: PerformMetadataQueryParams) => ({ + query: gql` + query CommandMenuItems { + commandMenuItems { + ${gqlFields} + } + } + `, +}); diff --git a/packages/twenty-server/test/integration/metadata/suites/command-menu-item/utils/find-command-menu-items.util.ts b/packages/twenty-server/test/integration/metadata/suites/command-menu-item/utils/find-command-menu-items.util.ts new file mode 100644 index 00000000000..53c6305951a --- /dev/null +++ b/packages/twenty-server/test/integration/metadata/suites/command-menu-item/utils/find-command-menu-items.util.ts @@ -0,0 +1,39 @@ +import { findCommandMenuItemsQueryFactory } from 'test/integration/metadata/suites/command-menu-item/utils/find-command-menu-items-query-factory.util'; +import { makeMetadataAPIRequest } from 'test/integration/metadata/suites/utils/make-metadata-api-request.util'; +import { type CommonResponseBody } from 'test/integration/metadata/types/common-response-body.type'; +import { type PerformMetadataQueryParams } from 'test/integration/metadata/types/perform-metadata-query.type'; +import { warnIfErrorButNotExpectedToFail } from 'test/integration/metadata/utils/warn-if-error-but-not-expected-to-fail.util'; +import { warnIfNoErrorButExpectedToFail } from 'test/integration/metadata/utils/warn-if-no-error-but-expected-to-fail.util'; + +import { type CommandMenuItemDTO } from 'src/engine/metadata-modules/command-menu-item/dtos/command-menu-item.dto'; + +export const findCommandMenuItems = async ({ + gqlFields, + expectToFail = false, + token, +}: PerformMetadataQueryParams): CommonResponseBody<{ + commandMenuItems: CommandMenuItemDTO[]; +}> => { + const graphqlOperation = findCommandMenuItemsQueryFactory({ + input: undefined, + gqlFields, + }); + + const response = await makeMetadataAPIRequest(graphqlOperation, token); + + if (expectToFail === true) { + warnIfNoErrorButExpectedToFail({ + response, + errorMessage: 'CommandMenuItems query should have failed but did not', + }); + } + + if (expectToFail === false) { + warnIfErrorButNotExpectedToFail({ + response, + errorMessage: 'CommandMenuItems query has failed but should not', + }); + } + + return { data: response.body.data, errors: response.body.errors }; +}; diff --git a/packages/twenty-server/test/integration/metadata/suites/command-menu-item/utils/update-command-menu-item-query-factory.util.ts b/packages/twenty-server/test/integration/metadata/suites/command-menu-item/utils/update-command-menu-item-query-factory.util.ts new file mode 100644 index 00000000000..d31391c2529 --- /dev/null +++ b/packages/twenty-server/test/integration/metadata/suites/command-menu-item/utils/update-command-menu-item-query-factory.util.ts @@ -0,0 +1,35 @@ +import gql from 'graphql-tag'; +import { type PerformMetadataQueryParams } from 'test/integration/metadata/types/perform-metadata-query.type'; + +import { type UpdateCommandMenuItemInput } from 'src/engine/metadata-modules/command-menu-item/dtos/update-command-menu-item.input'; + +export type UpdateCommandMenuItemFactoryInput = UpdateCommandMenuItemInput; + +const DEFAULT_COMMAND_MENU_ITEM_GQL_FIELDS = ` + id + workflowVersionId + label + icon + isPinned + availabilityType + availabilityObjectMetadataId + applicationId + createdAt + updatedAt +`; + +export const updateCommandMenuItemQueryFactory = ({ + input, + gqlFields = DEFAULT_COMMAND_MENU_ITEM_GQL_FIELDS, +}: PerformMetadataQueryParams) => ({ + query: gql` + mutation UpdateCommandMenuItem($input: UpdateCommandMenuItemInput!) { + updateCommandMenuItem(input: $input) { + ${gqlFields} + } + } + `, + variables: { + input, + }, +}); diff --git a/packages/twenty-server/test/integration/metadata/suites/command-menu-item/utils/update-command-menu-item.util.ts b/packages/twenty-server/test/integration/metadata/suites/command-menu-item/utils/update-command-menu-item.util.ts new file mode 100644 index 00000000000..c81d0fad09f --- /dev/null +++ b/packages/twenty-server/test/integration/metadata/suites/command-menu-item/utils/update-command-menu-item.util.ts @@ -0,0 +1,43 @@ +import { + type UpdateCommandMenuItemFactoryInput, + updateCommandMenuItemQueryFactory, +} from 'test/integration/metadata/suites/command-menu-item/utils/update-command-menu-item-query-factory.util'; +import { makeMetadataAPIRequest } from 'test/integration/metadata/suites/utils/make-metadata-api-request.util'; +import { type CommonResponseBody } from 'test/integration/metadata/types/common-response-body.type'; +import { type PerformMetadataQueryParams } from 'test/integration/metadata/types/perform-metadata-query.type'; +import { warnIfErrorButNotExpectedToFail } from 'test/integration/metadata/utils/warn-if-error-but-not-expected-to-fail.util'; +import { warnIfNoErrorButExpectedToFail } from 'test/integration/metadata/utils/warn-if-no-error-but-expected-to-fail.util'; + +import { type CommandMenuItemDTO } from 'src/engine/metadata-modules/command-menu-item/dtos/command-menu-item.dto'; + +export const updateCommandMenuItem = async ({ + input, + gqlFields, + expectToFail = false, + token, +}: PerformMetadataQueryParams): CommonResponseBody<{ + updateCommandMenuItem: CommandMenuItemDTO; +}> => { + const graphqlOperation = updateCommandMenuItemQueryFactory({ + input, + gqlFields, + }); + + const response = await makeMetadataAPIRequest(graphqlOperation, token); + + if (expectToFail === true) { + warnIfNoErrorButExpectedToFail({ + response, + errorMessage: 'CommandMenuItem update should have failed but did not', + }); + } + + if (expectToFail === false) { + warnIfErrorButNotExpectedToFail({ + response, + errorMessage: 'CommandMenuItem update has failed but should not', + }); + } + + return { data: response.body.data, errors: response.body.errors }; +}; diff --git a/packages/twenty-shared/src/metadata/all-metadata-name.constant.ts b/packages/twenty-shared/src/metadata/all-metadata-name.constant.ts index 6a42d664905..2227821d210 100644 --- a/packages/twenty-shared/src/metadata/all-metadata-name.constant.ts +++ b/packages/twenty-shared/src/metadata/all-metadata-name.constant.ts @@ -20,5 +20,6 @@ export const ALL_METADATA_NAME = { pageLayout: 'pageLayout', pageLayoutWidget: 'pageLayoutWidget', pageLayoutTab: 'pageLayoutTab', + commandMenuItem: 'commandMenuItem', frontComponent: 'frontComponent', } as const;