From 5fc4e810f77ab93639ee955eb3c1554efa14e972 Mon Sep 17 00:00:00 2001 From: Charles Bochet Date: Fri, 16 Jan 2026 17:23:46 +0100 Subject: [PATCH] Front Extensibility: Introduce Front Component Entity (#17175) As part of the extensibility effort, we are introducing a new engine entity called "Front Component". This represents a dynamic react component that will be rendered in CommandMenu actions or in PageLayout widgets This PR introduce the entity and all the necessary boilerplate to make it syncable and cachable in the engine --- .vscode/launch.json | 3 +- .../src/generated-metadata/graphql.ts | 51 ++++ .../twenty-front/src/generated/graphql.ts | 9 + .../hooks/useMetadataErrorHandler.ts | 1 + .../common/1768495429374-addFrontComponent.ts | 33 +++ ...rties-to-compare-and-stringify.constant.ts | 5 + .../all-metadata-relations.constant.ts | 7 + ...quired-metadata-for-validation.constant.ts | 1 + .../all-flat-entity-types-by-metadata-name.ts | 16 ++ ...a-related-metadata-names.util.spec.ts.snap | 4 +- ...-component-editable-properties.constant.ts | 5 + .../flat-front-component.module.ts | 16 ++ ...-flat-front-component-map-cache.service.ts | 44 ++++ .../types/flat-front-component-maps.type.ts | 4 + .../types/flat-front-component.type.ts | 4 + ...-to-flat-front-component-to-create.util.ts | 34 +++ ...t-to-flat-front-component-or-throw.util.ts | 31 +++ ...t-component-to-front-component-dto.util.ts | 13 + ...ent-entity-to-flat-front-component.util.ts | 16 ++ ...front-component-to-update-or-throw.util.ts | 42 ++++ ...kill-input-to-flat-skill-to-create.util.ts | 2 +- .../dtos/create-front-component.input.ts | 18 ++ .../dtos/front-component.dto.ts | 31 +++ .../dtos/update-front-component.input.ts | 37 +++ .../entities/front-component.entity.ts | 27 ++ .../front-component.exception.ts | 40 +++ .../front-component/front-component.module.ts | 29 +++ .../front-component.resolver.ts | 71 ++++++ .../front-component.service.ts | 237 ++++++++++++++++++ ...onent-graphql-api-exception.interceptor.ts | 24 ++ ...nent-graphql-api-exception-handler.util.ts | 29 +++ .../metadata-engine.module.ts | 3 + .../skill/dtos/create-skill.input.ts | 9 +- .../types/workspace-cache-key.type.ts | 1 + ...ce-migration-build-orchestrator.service.ts | 42 ++++ ...e-migration-front-component-action.type.ts | 12 + ...front-component-actions-builder.service.ts | 116 +++++++++ .../flat-front-component-validator.service.ts | 113 +++++++++ ...ace-migration-builder-validators.module.ts | 3 + .../workspace-migration-builder.module.ts | 3 + ...-front-component-action-handler.service.ts | 40 +++ ...-front-component-action-handler.service.ts | 37 +++ ...-front-component-action-handler.service.ts | 39 +++ ...migration-runner-action-handlers.module.ts | 7 + ...ate-action-on-all-flat-entity-maps.util.ts | 1 + ...ete-action-on-all-flat-entity-maps.util.ts | 1 + ...ate-action-on-all-flat-entity-maps.util.ts | 3 +- ...omponent-creation.integration-spec.ts.snap | 89 +++++++ ...omponent-deletion.integration-spec.ts.snap | 13 + ...-component-update.integration-spec.ts.snap | 13 + ...ont-component-creation.integration-spec.ts | 49 ++++ ...ont-component-deletion.integration-spec.ts | 34 +++ ...front-component-update.integration-spec.ts | 41 +++ ...ont-component-creation.integration-spec.ts | 48 ++++ ...ont-component-deletion.integration-spec.ts | 40 +++ ...front-component-update.integration-spec.ts | 50 ++++ ...eate-front-component-query-factory.util.ts | 30 +++ .../utils/create-front-component.util.ts | 43 ++++ ...lete-front-component-query-factory.util.ts | 27 ++ .../utils/delete-front-component.util.ts | 43 ++++ ...find-front-component-query-factory.util.ts | 30 +++ .../utils/find-front-component.util.ts | 43 ++++ ...ind-front-components-query-factory.util.ts | 22 ++ .../utils/find-front-components.util.ts | 39 +++ ...date-front-component-query-factory.util.ts | 30 +++ .../utils/update-front-component.util.ts | 43 ++++ ...cessful-skill-deletion.integration-spec.ts | 5 + .../metadata/all-metadata-name.constant.ts | 1 + 68 files changed, 2042 insertions(+), 5 deletions(-) create mode 100644 packages/twenty-server/src/database/typeorm/core/migrations/common/1768495429374-addFrontComponent.ts create mode 100644 packages/twenty-server/src/engine/metadata-modules/flat-front-component/constants/flat-front-component-editable-properties.constant.ts create mode 100644 packages/twenty-server/src/engine/metadata-modules/flat-front-component/flat-front-component.module.ts create mode 100644 packages/twenty-server/src/engine/metadata-modules/flat-front-component/services/workspace-flat-front-component-map-cache.service.ts create mode 100644 packages/twenty-server/src/engine/metadata-modules/flat-front-component/types/flat-front-component-maps.type.ts create mode 100644 packages/twenty-server/src/engine/metadata-modules/flat-front-component/types/flat-front-component.type.ts create mode 100644 packages/twenty-server/src/engine/metadata-modules/flat-front-component/utils/from-create-front-component-input-to-flat-front-component-to-create.util.ts create mode 100644 packages/twenty-server/src/engine/metadata-modules/flat-front-component/utils/from-delete-front-component-input-to-flat-front-component-or-throw.util.ts create mode 100644 packages/twenty-server/src/engine/metadata-modules/flat-front-component/utils/from-flat-front-component-to-front-component-dto.util.ts create mode 100644 packages/twenty-server/src/engine/metadata-modules/flat-front-component/utils/from-front-component-entity-to-flat-front-component.util.ts create mode 100644 packages/twenty-server/src/engine/metadata-modules/flat-front-component/utils/from-update-front-component-input-to-flat-front-component-to-update-or-throw.util.ts create mode 100644 packages/twenty-server/src/engine/metadata-modules/front-component/dtos/create-front-component.input.ts create mode 100644 packages/twenty-server/src/engine/metadata-modules/front-component/dtos/front-component.dto.ts create mode 100644 packages/twenty-server/src/engine/metadata-modules/front-component/dtos/update-front-component.input.ts create mode 100644 packages/twenty-server/src/engine/metadata-modules/front-component/entities/front-component.entity.ts create mode 100644 packages/twenty-server/src/engine/metadata-modules/front-component/front-component.exception.ts create mode 100644 packages/twenty-server/src/engine/metadata-modules/front-component/front-component.module.ts create mode 100644 packages/twenty-server/src/engine/metadata-modules/front-component/front-component.resolver.ts create mode 100644 packages/twenty-server/src/engine/metadata-modules/front-component/front-component.service.ts create mode 100644 packages/twenty-server/src/engine/metadata-modules/front-component/interceptors/front-component-graphql-api-exception.interceptor.ts create mode 100644 packages/twenty-server/src/engine/metadata-modules/front-component/utils/front-component-graphql-api-exception-handler.util.ts create mode 100644 packages/twenty-server/src/engine/workspace-manager/workspace-migration/workspace-migration-builder/builders/front-component/types/workspace-migration-front-component-action.type.ts create mode 100644 packages/twenty-server/src/engine/workspace-manager/workspace-migration/workspace-migration-builder/builders/front-component/workspace-migration-front-component-actions-builder.service.ts create mode 100644 packages/twenty-server/src/engine/workspace-manager/workspace-migration/workspace-migration-builder/validators/services/flat-front-component-validator.service.ts create mode 100644 packages/twenty-server/src/engine/workspace-manager/workspace-migration/workspace-migration-runner/action-handlers/front-component/services/create-front-component-action-handler.service.ts create mode 100644 packages/twenty-server/src/engine/workspace-manager/workspace-migration/workspace-migration-runner/action-handlers/front-component/services/delete-front-component-action-handler.service.ts create mode 100644 packages/twenty-server/src/engine/workspace-manager/workspace-migration/workspace-migration-runner/action-handlers/front-component/services/update-front-component-action-handler.service.ts create mode 100644 packages/twenty-server/test/integration/metadata/suites/front-component/__snapshots__/failing-front-component-creation.integration-spec.ts.snap create mode 100644 packages/twenty-server/test/integration/metadata/suites/front-component/__snapshots__/failing-front-component-deletion.integration-spec.ts.snap create mode 100644 packages/twenty-server/test/integration/metadata/suites/front-component/__snapshots__/failing-front-component-update.integration-spec.ts.snap create mode 100644 packages/twenty-server/test/integration/metadata/suites/front-component/failing-front-component-creation.integration-spec.ts create mode 100644 packages/twenty-server/test/integration/metadata/suites/front-component/failing-front-component-deletion.integration-spec.ts create mode 100644 packages/twenty-server/test/integration/metadata/suites/front-component/failing-front-component-update.integration-spec.ts create mode 100644 packages/twenty-server/test/integration/metadata/suites/front-component/successful-front-component-creation.integration-spec.ts create mode 100644 packages/twenty-server/test/integration/metadata/suites/front-component/successful-front-component-deletion.integration-spec.ts create mode 100644 packages/twenty-server/test/integration/metadata/suites/front-component/successful-front-component-update.integration-spec.ts create mode 100644 packages/twenty-server/test/integration/metadata/suites/front-component/utils/create-front-component-query-factory.util.ts create mode 100644 packages/twenty-server/test/integration/metadata/suites/front-component/utils/create-front-component.util.ts create mode 100644 packages/twenty-server/test/integration/metadata/suites/front-component/utils/delete-front-component-query-factory.util.ts create mode 100644 packages/twenty-server/test/integration/metadata/suites/front-component/utils/delete-front-component.util.ts create mode 100644 packages/twenty-server/test/integration/metadata/suites/front-component/utils/find-front-component-query-factory.util.ts create mode 100644 packages/twenty-server/test/integration/metadata/suites/front-component/utils/find-front-component.util.ts create mode 100644 packages/twenty-server/test/integration/metadata/suites/front-component/utils/find-front-components-query-factory.util.ts create mode 100644 packages/twenty-server/test/integration/metadata/suites/front-component/utils/find-front-components.util.ts create mode 100644 packages/twenty-server/test/integration/metadata/suites/front-component/utils/update-front-component-query-factory.util.ts create mode 100644 packages/twenty-server/test/integration/metadata/suites/front-component/utils/update-front-component.util.ts diff --git a/.vscode/launch.json b/.vscode/launch.json index 01396f38eb6..9ff10b3883a 100644 --- a/.vscode/launch.json +++ b/.vscode/launch.json @@ -73,7 +73,8 @@ "console": "integratedTerminal", "internalConsoleOptions": "neverOpen", "env": { - "NODE_ENV": "test" + "NODE_ENV": "test", + "NODE_OPTIONS": "--max-old-space-size=12288 --import tsx/esm" } }, { diff --git a/packages/twenty-front/src/generated-metadata/graphql.ts b/packages/twenty-front/src/generated-metadata/graphql.ts index 236a51bbfd0..a65d5252f7f 100644 --- a/packages/twenty-front/src/generated-metadata/graphql.ts +++ b/packages/twenty-front/src/generated-metadata/graphql.ts @@ -875,6 +875,11 @@ export type CreateFieldInput = { type: FieldMetadataType; }; +export type CreateFrontComponentInput = { + id?: InputMaybe; + name: Scalars['String']; +}; + export type CreateObjectInput = { description?: InputMaybe; icon?: InputMaybe; @@ -986,6 +991,7 @@ export type CreateSkillInput = { content: Scalars['String']; description?: InputMaybe; icon?: InputMaybe; + id?: InputMaybe; label: Scalars['String']; name: Scalars['String']; }; @@ -1533,6 +1539,15 @@ export type FindAvailableSsoidpOutput = { workspace: WorkspaceNameAndId; }; +export type FrontComponent = { + __typename?: 'FrontComponent'; + applicationId: Scalars['UUID']; + createdAt: Scalars['DateTime']; + id: Scalars['UUID']; + name: Scalars['String']; + updatedAt: Scalars['DateTime']; +}; + export type FullName = { __typename?: 'FullName'; firstName: Scalars['String']; @@ -1873,6 +1888,7 @@ export type Mutation = { createDraftFromWorkflowVersion: WorkflowVersionDto; createEmailingDomain: EmailingDomain; createFile: File; + createFrontComponent: FrontComponent; createManyCoreViewFields: Array; createManyCoreViewGroups: Array; createOIDCIdentityProvider: SetupSsoOutput; @@ -1911,6 +1927,7 @@ export type Mutation = { deleteDatabaseConfigVariable: Scalars['Boolean']; deleteEmailingDomain: Scalars['Boolean']; deleteFile: File; + deleteFrontComponent: FrontComponent; deleteJobs: DeleteJobsResponse; deleteOneAgent: Agent; deleteOneCronTrigger: CronTrigger; @@ -1997,6 +2014,7 @@ export type Mutation = { updateCoreViewGroup: CoreViewGroup; updateCoreViewSort: CoreViewSort; updateDatabaseConfigVariable: Scalars['Boolean']; + updateFrontComponent: FrontComponent; updateLabPublicFeatureFlag: FeatureFlagDto; updateOneAgent: Agent; updateOneApplicationVariable: Scalars['Boolean']; @@ -2159,6 +2177,11 @@ export type MutationCreateFileArgs = { }; +export type MutationCreateFrontComponentArgs = { + input: CreateFrontComponentInput; +}; + + export type MutationCreateManyCoreViewFieldsArgs = { inputs: Array; }; @@ -2348,6 +2371,11 @@ export type MutationDeleteFileArgs = { }; +export type MutationDeleteFrontComponentArgs = { + id: Scalars['UUID']; +}; + + export type MutationDeleteJobsArgs = { jobIds: Array; queueName: Scalars['String']; @@ -2767,6 +2795,11 @@ export type MutationUpdateDatabaseConfigVariableArgs = { }; +export type MutationUpdateFrontComponentArgs = { + input: UpdateFrontComponentInput; +}; + + export type MutationUpdateLabPublicFeatureFlagArgs = { input: UpdateLabPublicFeatureFlagInput; }; @@ -3377,6 +3410,8 @@ export type Query = { findOneServerlessFunction: ServerlessFunction; findWorkspaceFromInviteHash: Workspace; findWorkspaceInvitations: Array; + frontComponent?: Maybe; + frontComponents: Array; getAddressDetails: PlaceDetailsResult; getApprovedAccessDomains: Array; getAutoCompleteAddress: Array; @@ -3520,6 +3555,11 @@ export type QueryFindWorkspaceFromInviteHashArgs = { }; +export type QueryFrontComponentArgs = { + id: Scalars['UUID']; +}; + + export type QueryGetAddressDetailsArgs = { placeId: Scalars['String']; token: Scalars['String']; @@ -4493,6 +4533,17 @@ export type UpdateFieldInput = { settings?: InputMaybe; }; +export type UpdateFrontComponentInput = { + /** The id of the front component to update */ + id: Scalars['UUID']; + /** The front component fields to update */ + update: UpdateFrontComponentInputUpdates; +}; + +export type UpdateFrontComponentInputUpdates = { + name?: InputMaybe; +}; + export type UpdateLabPublicFeatureFlagInput = { publicFeatureFlag: Scalars['String']; value: Scalars['Boolean']; diff --git a/packages/twenty-front/src/generated/graphql.ts b/packages/twenty-front/src/generated/graphql.ts index 0ddca5f1603..a71e0540469 100644 --- a/packages/twenty-front/src/generated/graphql.ts +++ b/packages/twenty-front/src/generated/graphql.ts @@ -1516,6 +1516,15 @@ export type FindAvailableSsoidpOutput = { workspace: WorkspaceNameAndId; }; +export type FrontComponent = { + __typename?: 'FrontComponent'; + applicationId: Scalars['UUID']; + createdAt: Scalars['DateTime']; + id: Scalars['UUID']; + name: Scalars['String']; + updatedAt: Scalars['DateTime']; +}; + export type FullName = { __typename?: 'FullName'; firstName: 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 596aa8953f9..e5e3cac0f4b 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`, + frontComponent: t`front component`, } as const satisfies Record; const handleMetadataError = ( diff --git a/packages/twenty-server/src/database/typeorm/core/migrations/common/1768495429374-addFrontComponent.ts b/packages/twenty-server/src/database/typeorm/core/migrations/common/1768495429374-addFrontComponent.ts new file mode 100644 index 00000000000..faf584cfe83 --- /dev/null +++ b/packages/twenty-server/src/database/typeorm/core/migrations/common/1768495429374-addFrontComponent.ts @@ -0,0 +1,33 @@ +import { type MigrationInterface, type QueryRunner } from 'typeorm'; + +export class AddFrontComponent1768495429374 implements MigrationInterface { + name = 'AddFrontComponent1768495429374'; + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query( + `CREATE TABLE "core"."frontComponent" ("workspaceId" uuid NOT NULL, "universalIdentifier" uuid NOT NULL, "applicationId" uuid NOT NULL, "id" uuid NOT NULL DEFAULT uuid_generate_v4(), "name" character varying NOT NULL, "createdAt" TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT now(), "updatedAt" TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT now(), CONSTRAINT "PK_843479d93ef40e58dc4587339aa" PRIMARY KEY ("id"))`, + ); + await queryRunner.query( + `CREATE UNIQUE INDEX "IDX_a1413f7f0e71cb5825ac40c4fa" ON "core"."frontComponent" ("workspaceId", "universalIdentifier") `, + ); + await queryRunner.query( + `ALTER TABLE "core"."frontComponent" ADD CONSTRAINT "FK_b5e4eea33659f066e865ab6afe0" FOREIGN KEY ("workspaceId") REFERENCES "core"."workspace"("id") ON DELETE CASCADE ON UPDATE NO ACTION`, + ); + await queryRunner.query( + `ALTER TABLE "core"."frontComponent" ADD CONSTRAINT "FK_63e430d5f8e554c4282e7b48876" FOREIGN KEY ("applicationId") REFERENCES "core"."application"("id") ON DELETE CASCADE ON UPDATE NO ACTION`, + ); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query( + `ALTER TABLE "core"."frontComponent" DROP CONSTRAINT "FK_63e430d5f8e554c4282e7b48876"`, + ); + await queryRunner.query( + `ALTER TABLE "core"."frontComponent" DROP CONSTRAINT "FK_b5e4eea33659f066e865ab6afe0"`, + ); + await queryRunner.query( + `DROP INDEX "core"."IDX_a1413f7f0e71cb5825ac40c4fa"`, + ); + await queryRunner.query(`DROP TABLE "core"."frontComponent"`); + } +} 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 82595066306..1fd36d5070f 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 @@ -5,6 +5,7 @@ import { FLAT_DATABASE_EVENT_TRIGGER_EDITABLE_PROPERTIES } from 'src/engine/meta import { FLAT_AGENT_EDITABLE_PROPERTIES } from 'src/engine/metadata-modules/flat-agent/constants/flat-agent-editable-properties.constant'; import { type MetadataFlatEntity } from 'src/engine/metadata-modules/flat-entity/types/metadata-flat-entity.type'; import { FLAT_FIELD_METADATA_EDITABLE_PROPERTIES } from 'src/engine/metadata-modules/flat-field-metadata/constants/flat-field-metadata-editable-properties.constant'; +import { FLAT_FRONT_COMPONENT_EDITABLE_PROPERTIES } from 'src/engine/metadata-modules/flat-front-component/constants/flat-front-component-editable-properties.constant'; import { FLAT_OBJECT_METADATA_EDITABLE_PROPERTIES } from 'src/engine/metadata-modules/flat-object-metadata/constants/flat-object-metadata-editable-properties.constant'; import { FLAT_PAGE_LAYOUT_TAB_EDITABLE_PROPERTIES } from 'src/engine/metadata-modules/flat-page-layout-tab/constants/flat-page-layout-tab-editable-properties.constant'; import { FLAT_PAGE_LAYOUT_WIDGET_EDITABLE_PROPERTIES } from 'src/engine/metadata-modules/flat-page-layout-widget/constants/flat-page-layout-widget-editable-properties.constant'; @@ -162,6 +163,10 @@ export const ALL_FLAT_ENTITY_PROPERTIES_TO_COMPARE_AND_STRINGIFY = { ], propertiesToStringify: [], }, + frontComponent: { + propertiesToCompare: [...FLAT_FRONT_COMPONENT_EDITABLE_PROPERTIES], + propertiesToStringify: [], + }, } as const satisfies { [P in AllMetadataName]: OneFlatEntityConfiguration

; }; 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 f9ed0f0b1c6..3f9067d7542 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 @@ -436,6 +436,13 @@ export const ALL_METADATA_RELATIONS = { }, }, }, + frontComponent: { + manyToOne: { + workspace: null, + application: null, + }, + oneToMany: {}, + }, } as const satisfies MetadataRelationsProperties; // Note: satisfies with complex mapped types involving nested generics doesn't always catch missing required keys 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 dc2c1cd6487..455a6a99e9a 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 @@ -82,4 +82,5 @@ export const ALL_METADATA_REQUIRED_METADATA_FOR_VALIDATION = { role: true, objectMetadata: true, }, + frontComponent: {}, } as const satisfies MetadataRequiredForValidation; 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 3d2711d4c6f..3db62244a2f 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,4 +1,6 @@ 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 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'; @@ -145,6 +147,11 @@ 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: { @@ -336,4 +343,13 @@ export type AllFlatEntityTypesByMetadataName = { flatEntity: FlatPageLayoutTab; entity: PageLayoutTabEntity; }; + frontComponent: { + actions: { + create: CreateFrontComponentAction; + update: UpdateFrontComponentAction; + delete: DeleteFrontComponentAction; + }; + flatEntity: FlatFrontComponent; + entity: FrontComponentEntity; + }; }; 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 5474ef65f54..a6c50fc2b5a 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 @@ -1,4 +1,4 @@ -// Jest Snapshot v1, https://jestjs.io/docs/snapshot-testing +// Jest Snapshot v1, https://goo.gl/fbAQLP exports[`getMetadataRelatedMetadataNames should return related metadata names for agent 1`] = `[]`; @@ -24,6 +24,8 @@ exports[`getMetadataRelatedMetadataNames should return related metadata names fo ] `; +exports[`getMetadataRelatedMetadataNames should return related metadata names for frontComponent 1`] = `[]`; + exports[`getMetadataRelatedMetadataNames should return related metadata names for index 1`] = ` [ "objectMetadata", diff --git a/packages/twenty-server/src/engine/metadata-modules/flat-front-component/constants/flat-front-component-editable-properties.constant.ts b/packages/twenty-server/src/engine/metadata-modules/flat-front-component/constants/flat-front-component-editable-properties.constant.ts new file mode 100644 index 00000000000..b3f904377d0 --- /dev/null +++ b/packages/twenty-server/src/engine/metadata-modules/flat-front-component/constants/flat-front-component-editable-properties.constant.ts @@ -0,0 +1,5 @@ +import { type FlatFrontComponent } from 'src/engine/metadata-modules/flat-front-component/types/flat-front-component.type'; + +export const FLAT_FRONT_COMPONENT_EDITABLE_PROPERTIES = [ + 'name', +] as const satisfies (keyof FlatFrontComponent)[]; diff --git a/packages/twenty-server/src/engine/metadata-modules/flat-front-component/flat-front-component.module.ts b/packages/twenty-server/src/engine/metadata-modules/flat-front-component/flat-front-component.module.ts new file mode 100644 index 00000000000..aeaacf17c3b --- /dev/null +++ b/packages/twenty-server/src/engine/metadata-modules/flat-front-component/flat-front-component.module.ts @@ -0,0 +1,16 @@ +import { Module } from '@nestjs/common'; +import { TypeOrmModule } from '@nestjs/typeorm'; + +import { WorkspaceManyOrAllFlatEntityMapsCacheModule } from 'src/engine/metadata-modules/flat-entity/services/workspace-many-or-all-flat-entity-maps-cache.module'; +import { WorkspaceFlatFrontComponentMapCacheService } from 'src/engine/metadata-modules/flat-front-component/services/workspace-flat-front-component-map-cache.service'; +import { FrontComponentEntity } from 'src/engine/metadata-modules/front-component/entities/front-component.entity'; + +@Module({ + imports: [ + TypeOrmModule.forFeature([FrontComponentEntity]), + WorkspaceManyOrAllFlatEntityMapsCacheModule, + ], + providers: [WorkspaceFlatFrontComponentMapCacheService], + exports: [WorkspaceFlatFrontComponentMapCacheService], +}) +export class FlatFrontComponentModule {} diff --git a/packages/twenty-server/src/engine/metadata-modules/flat-front-component/services/workspace-flat-front-component-map-cache.service.ts b/packages/twenty-server/src/engine/metadata-modules/flat-front-component/services/workspace-flat-front-component-map-cache.service.ts new file mode 100644 index 00000000000..1c64005e601 --- /dev/null +++ b/packages/twenty-server/src/engine/metadata-modules/flat-front-component/services/workspace-flat-front-component-map-cache.service.ts @@ -0,0 +1,44 @@ +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 { createEmptyFlatEntityMaps } from 'src/engine/metadata-modules/flat-entity/constant/create-empty-flat-entity-maps.constant'; +import { type FlatFrontComponentMaps } from 'src/engine/metadata-modules/flat-front-component/types/flat-front-component-maps.type'; +import { fromFrontComponentEntityToFlatFrontComponent } from 'src/engine/metadata-modules/flat-front-component/utils/from-front-component-entity-to-flat-front-component.util'; +import { FrontComponentEntity } from 'src/engine/metadata-modules/front-component/entities/front-component.entity'; +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('flatFrontComponentMaps') +export class WorkspaceFlatFrontComponentMapCacheService extends WorkspaceCacheProvider { + constructor( + @InjectRepository(FrontComponentEntity) + private readonly frontComponentRepository: Repository, + ) { + super(); + } + + async computeForCache(workspaceId: string): Promise { + const frontComponents = await this.frontComponentRepository.find({ + where: { workspaceId }, + }); + + const flatFrontComponentMaps = createEmptyFlatEntityMaps(); + + for (const frontComponentEntity of frontComponents) { + const flatFrontComponent = + fromFrontComponentEntityToFlatFrontComponent(frontComponentEntity); + + addFlatEntityToFlatEntityMapsThroughMutationOrThrow({ + flatEntity: flatFrontComponent, + flatEntityMapsToMutate: flatFrontComponentMaps, + }); + } + + return flatFrontComponentMaps; + } +} diff --git a/packages/twenty-server/src/engine/metadata-modules/flat-front-component/types/flat-front-component-maps.type.ts b/packages/twenty-server/src/engine/metadata-modules/flat-front-component/types/flat-front-component-maps.type.ts new file mode 100644 index 00000000000..6a5ac1daa27 --- /dev/null +++ b/packages/twenty-server/src/engine/metadata-modules/flat-front-component/types/flat-front-component-maps.type.ts @@ -0,0 +1,4 @@ +import { type FlatEntityMaps } from 'src/engine/metadata-modules/flat-entity/types/flat-entity-maps.type'; +import { type FlatFrontComponent } from 'src/engine/metadata-modules/flat-front-component/types/flat-front-component.type'; + +export type FlatFrontComponentMaps = FlatEntityMaps; diff --git a/packages/twenty-server/src/engine/metadata-modules/flat-front-component/types/flat-front-component.type.ts b/packages/twenty-server/src/engine/metadata-modules/flat-front-component/types/flat-front-component.type.ts new file mode 100644 index 00000000000..a8907b346d5 --- /dev/null +++ b/packages/twenty-server/src/engine/metadata-modules/flat-front-component/types/flat-front-component.type.ts @@ -0,0 +1,4 @@ +import { type FlatEntityFrom } from 'src/engine/metadata-modules/flat-entity/types/flat-entity.type'; +import { type FrontComponentEntity } from 'src/engine/metadata-modules/front-component/entities/front-component.entity'; + +export type FlatFrontComponent = FlatEntityFrom; diff --git a/packages/twenty-server/src/engine/metadata-modules/flat-front-component/utils/from-create-front-component-input-to-flat-front-component-to-create.util.ts b/packages/twenty-server/src/engine/metadata-modules/flat-front-component/utils/from-create-front-component-input-to-flat-front-component-to-create.util.ts new file mode 100644 index 00000000000..2d7ec5e9639 --- /dev/null +++ b/packages/twenty-server/src/engine/metadata-modules/flat-front-component/utils/from-create-front-component-input-to-flat-front-component-to-create.util.ts @@ -0,0 +1,34 @@ +import { trimAndRemoveDuplicatedWhitespacesFromObjectStringProperties } from 'twenty-shared/utils'; +import { v4 } from 'uuid'; + +import { type FlatFrontComponent } from 'src/engine/metadata-modules/flat-front-component/types/flat-front-component.type'; +import { type CreateFrontComponentInput } from 'src/engine/metadata-modules/front-component/dtos/create-front-component.input'; + +export const fromCreateFrontComponentInputToFlatFrontComponentToCreate = ({ + createFrontComponentInput, + workspaceId, + applicationId, +}: { + createFrontComponentInput: CreateFrontComponentInput; + workspaceId: string; + applicationId: string; +}): FlatFrontComponent => { + const now = new Date().toISOString(); + + const { name } = trimAndRemoveDuplicatedWhitespacesFromObjectStringProperties( + createFrontComponentInput, + ['name'], + ); + + const id = createFrontComponentInput.id ?? v4(); + + return { + id, + name, + workspaceId, + createdAt: now, + updatedAt: now, + universalIdentifier: id, + applicationId, + }; +}; diff --git a/packages/twenty-server/src/engine/metadata-modules/flat-front-component/utils/from-delete-front-component-input-to-flat-front-component-or-throw.util.ts b/packages/twenty-server/src/engine/metadata-modules/flat-front-component/utils/from-delete-front-component-input-to-flat-front-component-or-throw.util.ts new file mode 100644 index 00000000000..974bd890377 --- /dev/null +++ b/packages/twenty-server/src/engine/metadata-modules/flat-front-component/utils/from-delete-front-component-input-to-flat-front-component-or-throw.util.ts @@ -0,0 +1,31 @@ +import { isDefined } from 'twenty-shared/utils'; + +import { type FlatEntityMaps } from 'src/engine/metadata-modules/flat-entity/types/flat-entity-maps.type'; +import { findFlatEntityByIdInFlatEntityMaps } from 'src/engine/metadata-modules/flat-entity/utils/find-flat-entity-by-id-in-flat-entity-maps.util'; +import { type FlatFrontComponent } from 'src/engine/metadata-modules/flat-front-component/types/flat-front-component.type'; +import { + FrontComponentException, + FrontComponentExceptionCode, +} from 'src/engine/metadata-modules/front-component/front-component.exception'; + +export const fromDeleteFrontComponentInputToFlatFrontComponentOrThrow = ({ + flatFrontComponentMaps, + frontComponentId, +}: { + flatFrontComponentMaps: FlatEntityMaps; + frontComponentId: string; +}): FlatFrontComponent => { + const existingFlatFrontComponent = findFlatEntityByIdInFlatEntityMaps({ + flatEntityId: frontComponentId, + flatEntityMaps: flatFrontComponentMaps, + }); + + if (!isDefined(existingFlatFrontComponent)) { + throw new FrontComponentException( + 'Front component not found', + FrontComponentExceptionCode.FRONT_COMPONENT_NOT_FOUND, + ); + } + + return existingFlatFrontComponent; +}; diff --git a/packages/twenty-server/src/engine/metadata-modules/flat-front-component/utils/from-flat-front-component-to-front-component-dto.util.ts b/packages/twenty-server/src/engine/metadata-modules/flat-front-component/utils/from-flat-front-component-to-front-component-dto.util.ts new file mode 100644 index 00000000000..7a66631c63d --- /dev/null +++ b/packages/twenty-server/src/engine/metadata-modules/flat-front-component/utils/from-flat-front-component-to-front-component-dto.util.ts @@ -0,0 +1,13 @@ +import { type FlatFrontComponent } from 'src/engine/metadata-modules/flat-front-component/types/flat-front-component.type'; +import { type FrontComponentDTO } from 'src/engine/metadata-modules/front-component/dtos/front-component.dto'; + +export const fromFlatFrontComponentToFrontComponentDto = ( + flatFrontComponent: FlatFrontComponent, +): FrontComponentDTO => ({ + id: flatFrontComponent.id, + name: flatFrontComponent.name, + workspaceId: flatFrontComponent.workspaceId, + applicationId: flatFrontComponent.applicationId, + createdAt: new Date(flatFrontComponent.createdAt), + updatedAt: new Date(flatFrontComponent.updatedAt), +}); diff --git a/packages/twenty-server/src/engine/metadata-modules/flat-front-component/utils/from-front-component-entity-to-flat-front-component.util.ts b/packages/twenty-server/src/engine/metadata-modules/flat-front-component/utils/from-front-component-entity-to-flat-front-component.util.ts new file mode 100644 index 00000000000..bb413e054be --- /dev/null +++ b/packages/twenty-server/src/engine/metadata-modules/flat-front-component/utils/from-front-component-entity-to-flat-front-component.util.ts @@ -0,0 +1,16 @@ +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'; + +export const fromFrontComponentEntityToFlatFrontComponent = ( + frontComponentEntity: FrontComponentEntity, +): FlatFrontComponent => { + return { + id: frontComponentEntity.id, + name: frontComponentEntity.name, + workspaceId: frontComponentEntity.workspaceId, + universalIdentifier: frontComponentEntity.universalIdentifier, + applicationId: frontComponentEntity.applicationId, + createdAt: frontComponentEntity.createdAt.toISOString(), + updatedAt: frontComponentEntity.updatedAt.toISOString(), + }; +}; diff --git a/packages/twenty-server/src/engine/metadata-modules/flat-front-component/utils/from-update-front-component-input-to-flat-front-component-to-update-or-throw.util.ts b/packages/twenty-server/src/engine/metadata-modules/flat-front-component/utils/from-update-front-component-input-to-flat-front-component-to-update-or-throw.util.ts new file mode 100644 index 00000000000..a3bd6882a34 --- /dev/null +++ b/packages/twenty-server/src/engine/metadata-modules/flat-front-component/utils/from-update-front-component-input-to-flat-front-component-to-update-or-throw.util.ts @@ -0,0 +1,42 @@ +import { isDefined } from 'twenty-shared/utils'; + +import { type FlatEntityMaps } from 'src/engine/metadata-modules/flat-entity/types/flat-entity-maps.type'; +import { findFlatEntityByIdInFlatEntityMaps } from 'src/engine/metadata-modules/flat-entity/utils/find-flat-entity-by-id-in-flat-entity-maps.util'; +import { FLAT_FRONT_COMPONENT_EDITABLE_PROPERTIES } from 'src/engine/metadata-modules/flat-front-component/constants/flat-front-component-editable-properties.constant'; +import { type FlatFrontComponent } from 'src/engine/metadata-modules/flat-front-component/types/flat-front-component.type'; +import { type UpdateFrontComponentInput } from 'src/engine/metadata-modules/front-component/dtos/update-front-component.input'; +import { + FrontComponentException, + FrontComponentExceptionCode, +} from 'src/engine/metadata-modules/front-component/front-component.exception'; +import { mergeUpdateInExistingRecord } from 'src/utils/merge-update-in-existing-record.util'; + +export const fromUpdateFrontComponentInputToFlatFrontComponentToUpdateOrThrow = + ({ + flatFrontComponentMaps, + updateFrontComponentInput, + }: { + flatFrontComponentMaps: FlatEntityMaps; + updateFrontComponentInput: UpdateFrontComponentInput; + }): FlatFrontComponent => { + const existingFlatFrontComponent = findFlatEntityByIdInFlatEntityMaps({ + flatEntityId: updateFrontComponentInput.id, + flatEntityMaps: flatFrontComponentMaps, + }); + + if (!isDefined(existingFlatFrontComponent)) { + throw new FrontComponentException( + 'Front component not found', + FrontComponentExceptionCode.FRONT_COMPONENT_NOT_FOUND, + ); + } + + return { + ...mergeUpdateInExistingRecord({ + existing: existingFlatFrontComponent, + properties: [...FLAT_FRONT_COMPONENT_EDITABLE_PROPERTIES], + update: updateFrontComponentInput.update, + }), + updatedAt: new Date().toISOString(), + }; + }; diff --git a/packages/twenty-server/src/engine/metadata-modules/flat-skill/utils/from-create-skill-input-to-flat-skill-to-create.util.ts b/packages/twenty-server/src/engine/metadata-modules/flat-skill/utils/from-create-skill-input-to-flat-skill-to-create.util.ts index 86837e30ed9..57d799551b3 100644 --- a/packages/twenty-server/src/engine/metadata-modules/flat-skill/utils/from-create-skill-input-to-flat-skill-to-create.util.ts +++ b/packages/twenty-server/src/engine/metadata-modules/flat-skill/utils/from-create-skill-input-to-flat-skill-to-create.util.ts @@ -24,7 +24,7 @@ export const fromCreateSkillInputToFlatSkillToCreate = ({ // Content is markdown - only trim, don't collapse whitespace (preserve newlines) const content = createSkillInput.content.trim(); - const id = v4(); + const id = createSkillInput.id ?? v4(); return { id, diff --git a/packages/twenty-server/src/engine/metadata-modules/front-component/dtos/create-front-component.input.ts b/packages/twenty-server/src/engine/metadata-modules/front-component/dtos/create-front-component.input.ts new file mode 100644 index 00000000000..a5f2961bff5 --- /dev/null +++ b/packages/twenty-server/src/engine/metadata-modules/front-component/dtos/create-front-component.input.ts @@ -0,0 +1,18 @@ +import { Field, InputType } from '@nestjs/graphql'; + +import { IsNotEmpty, IsOptional, IsString, IsUUID } from 'class-validator'; + +import { UUIDScalarType } from 'src/engine/api/graphql/workspace-schema-builder/graphql-types/scalars'; + +@InputType() +export class CreateFrontComponentInput { + @IsUUID() + @IsOptional() + @Field(() => UUIDScalarType, { nullable: true }) + id?: string; + + @IsString() + @IsNotEmpty() + @Field() + name: string; +} diff --git a/packages/twenty-server/src/engine/metadata-modules/front-component/dtos/front-component.dto.ts b/packages/twenty-server/src/engine/metadata-modules/front-component/dtos/front-component.dto.ts new file mode 100644 index 00000000000..f773b1a2653 --- /dev/null +++ b/packages/twenty-server/src/engine/metadata-modules/front-component/dtos/front-component.dto.ts @@ -0,0 +1,31 @@ +import { Field, HideField, ObjectType } from '@nestjs/graphql'; + +import { IsDateString, IsNotEmpty, IsString, IsUUID } from 'class-validator'; + +import { UUIDScalarType } from 'src/engine/api/graphql/workspace-schema-builder/graphql-types/scalars'; + +@ObjectType('FrontComponent') +export class FrontComponentDTO { + @IsUUID() + @IsNotEmpty() + @Field(() => UUIDScalarType) + id: string; + + @IsString() + @Field() + name: string; + + @HideField() + workspaceId: string; + + @Field(() => UUIDScalarType) + applicationId: string; + + @IsDateString() + @Field() + createdAt: Date; + + @IsDateString() + @Field() + updatedAt: Date; +} diff --git a/packages/twenty-server/src/engine/metadata-modules/front-component/dtos/update-front-component.input.ts b/packages/twenty-server/src/engine/metadata-modules/front-component/dtos/update-front-component.input.ts new file mode 100644 index 00000000000..561b8eb41a5 --- /dev/null +++ b/packages/twenty-server/src/engine/metadata-modules/front-component/dtos/update-front-component.input.ts @@ -0,0 +1,37 @@ +import { Field, InputType } from '@nestjs/graphql'; + +import { Type } from 'class-transformer'; +import { + IsNotEmpty, + IsOptional, + IsString, + IsUUID, + ValidateNested, +} from 'class-validator'; + +import { UUIDScalarType } from 'src/engine/api/graphql/workspace-schema-builder/graphql-types/scalars'; + +@InputType() +export class UpdateFrontComponentInputUpdates { + @IsOptional() + @IsString() + @Field({ nullable: true }) + name?: string; +} + +@InputType() +export class UpdateFrontComponentInput { + @IsUUID() + @IsNotEmpty() + @Field(() => UUIDScalarType, { + description: 'The id of the front component to update', + }) + id: string; + + @Type(() => UpdateFrontComponentInputUpdates) + @ValidateNested() + @Field(() => UpdateFrontComponentInputUpdates, { + description: 'The front component fields to update', + }) + update: UpdateFrontComponentInputUpdates; +} diff --git a/packages/twenty-server/src/engine/metadata-modules/front-component/entities/front-component.entity.ts b/packages/twenty-server/src/engine/metadata-modules/front-component/entities/front-component.entity.ts new file mode 100644 index 00000000000..c8f18f559b3 --- /dev/null +++ b/packages/twenty-server/src/engine/metadata-modules/front-component/entities/front-component.entity.ts @@ -0,0 +1,27 @@ +import { + Column, + CreateDateColumn, + Entity, + PrimaryGeneratedColumn, + UpdateDateColumn, +} from 'typeorm'; + +import { SyncableEntityRequired } from 'src/engine/workspace-manager/types/syncable-entity-required.interface'; + +@Entity('frontComponent') +export class FrontComponentEntity + extends SyncableEntityRequired + implements Required +{ + @PrimaryGeneratedColumn('uuid') + id: string; + + @Column({ nullable: false }) + name: string; + + @CreateDateColumn({ type: 'timestamptz' }) + createdAt: Date; + + @UpdateDateColumn({ type: 'timestamptz' }) + updatedAt: Date; +} diff --git a/packages/twenty-server/src/engine/metadata-modules/front-component/front-component.exception.ts b/packages/twenty-server/src/engine/metadata-modules/front-component/front-component.exception.ts new file mode 100644 index 00000000000..15c52c161f0 --- /dev/null +++ b/packages/twenty-server/src/engine/metadata-modules/front-component/front-component.exception.ts @@ -0,0 +1,40 @@ +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 FrontComponentExceptionCode { + FRONT_COMPONENT_NOT_FOUND = 'FRONT_COMPONENT_NOT_FOUND', + FRONT_COMPONENT_ALREADY_EXISTS = 'FRONT_COMPONENT_ALREADY_EXISTS', + INVALID_FRONT_COMPONENT_INPUT = 'INVALID_FRONT_COMPONENT_INPUT', +} + +const getFrontComponentExceptionUserFriendlyMessage = ( + code: FrontComponentExceptionCode, +) => { + switch (code) { + case FrontComponentExceptionCode.FRONT_COMPONENT_NOT_FOUND: + return msg`Front component not found.`; + case FrontComponentExceptionCode.FRONT_COMPONENT_ALREADY_EXISTS: + return msg`A front component with this name already exists.`; + case FrontComponentExceptionCode.INVALID_FRONT_COMPONENT_INPUT: + return msg`Invalid front component input.`; + default: + assertUnreachable(code); + } +}; + +export class FrontComponentException extends CustomException { + constructor( + message: string, + code: FrontComponentExceptionCode, + { userFriendlyMessage }: { userFriendlyMessage?: MessageDescriptor } = {}, + ) { + super(message, code, { + userFriendlyMessage: + userFriendlyMessage ?? + getFrontComponentExceptionUserFriendlyMessage(code), + }); + } +} diff --git a/packages/twenty-server/src/engine/metadata-modules/front-component/front-component.module.ts b/packages/twenty-server/src/engine/metadata-modules/front-component/front-component.module.ts new file mode 100644 index 00000000000..cd4d9b38061 --- /dev/null +++ b/packages/twenty-server/src/engine/metadata-modules/front-component/front-component.module.ts @@ -0,0 +1,29 @@ +import { Module } from '@nestjs/common'; + +import { ApplicationModule } from 'src/engine/core-modules/application/application.module'; +import { WorkspaceManyOrAllFlatEntityMapsCacheModule } from 'src/engine/metadata-modules/flat-entity/services/workspace-many-or-all-flat-entity-maps-cache.module'; +import { FlatFrontComponentModule } from 'src/engine/metadata-modules/flat-front-component/flat-front-component.module'; +import { FrontComponentResolver } from 'src/engine/metadata-modules/front-component/front-component.resolver'; +import { FrontComponentService } from 'src/engine/metadata-modules/front-component/front-component.service'; +import { FrontComponentGraphqlApiExceptionInterceptor } from 'src/engine/metadata-modules/front-component/interceptors/front-component-graphql-api-exception.interceptor'; +import { PermissionsModule } from 'src/engine/metadata-modules/permissions/permissions.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, + PermissionsModule, + FlatFrontComponentModule, + ], + providers: [ + FrontComponentService, + FrontComponentResolver, + FrontComponentGraphqlApiExceptionInterceptor, + WorkspaceMigrationBuilderGraphqlApiExceptionInterceptor, + ], + exports: [FrontComponentService], +}) +export class FrontComponentModule {} diff --git a/packages/twenty-server/src/engine/metadata-modules/front-component/front-component.resolver.ts b/packages/twenty-server/src/engine/metadata-modules/front-component/front-component.resolver.ts new file mode 100644 index 00000000000..a17006880cc --- /dev/null +++ b/packages/twenty-server/src/engine/metadata-modules/front-component/front-component.resolver.ts @@ -0,0 +1,71 @@ +import { UseGuards, UseInterceptors } from '@nestjs/common'; +import { Args, Mutation, Query, Resolver } from '@nestjs/graphql'; + +import { PermissionFlagType } from 'twenty-shared/constants'; + +import { UUIDScalarType } from 'src/engine/api/graphql/workspace-schema-builder/graphql-types/scalars'; +import { WorkspaceEntity } from 'src/engine/core-modules/workspace/workspace.entity'; +import { AuthWorkspace } from 'src/engine/decorators/auth/auth-workspace.decorator'; +import { NoPermissionGuard } from 'src/engine/guards/no-permission.guard'; +import { SettingsPermissionGuard } from 'src/engine/guards/settings-permission.guard'; +import { WorkspaceAuthGuard } from 'src/engine/guards/workspace-auth.guard'; +import { CreateFrontComponentInput } from 'src/engine/metadata-modules/front-component/dtos/create-front-component.input'; +import { FrontComponentDTO } from 'src/engine/metadata-modules/front-component/dtos/front-component.dto'; +import { UpdateFrontComponentInput } from 'src/engine/metadata-modules/front-component/dtos/update-front-component.input'; +import { FrontComponentService } from 'src/engine/metadata-modules/front-component/front-component.service'; +import { FrontComponentGraphqlApiExceptionInterceptor } from 'src/engine/metadata-modules/front-component/interceptors/front-component-graphql-api-exception.interceptor'; +import { WorkspaceMigrationBuilderGraphqlApiExceptionInterceptor } from 'src/engine/workspace-manager/workspace-migration/interceptors/workspace-migration-builder-graphql-api-exception.interceptor'; + +@UseGuards(WorkspaceAuthGuard) +@UseInterceptors( + WorkspaceMigrationBuilderGraphqlApiExceptionInterceptor, + FrontComponentGraphqlApiExceptionInterceptor, +) +@Resolver(() => FrontComponentDTO) +export class FrontComponentResolver { + constructor(private readonly frontComponentService: FrontComponentService) {} + + @Query(() => [FrontComponentDTO]) + @UseGuards(NoPermissionGuard) + async frontComponents( + @AuthWorkspace() workspace: WorkspaceEntity, + ): Promise { + return await this.frontComponentService.findAll(workspace.id); + } + + @Query(() => FrontComponentDTO, { nullable: true }) + @UseGuards(NoPermissionGuard) + async frontComponent( + @Args('id', { type: () => UUIDScalarType }) id: string, + @AuthWorkspace() workspace: WorkspaceEntity, + ): Promise { + return await this.frontComponentService.findById(id, workspace.id); + } + + @Mutation(() => FrontComponentDTO) + @UseGuards(SettingsPermissionGuard(PermissionFlagType.APPLICATIONS)) + async createFrontComponent( + @Args('input') input: CreateFrontComponentInput, + @AuthWorkspace() workspace: WorkspaceEntity, + ): Promise { + return await this.frontComponentService.create(input, workspace.id); + } + + @Mutation(() => FrontComponentDTO) + @UseGuards(SettingsPermissionGuard(PermissionFlagType.APPLICATIONS)) + async updateFrontComponent( + @Args('input') input: UpdateFrontComponentInput, + @AuthWorkspace() workspace: WorkspaceEntity, + ): Promise { + return await this.frontComponentService.update(input, workspace.id); + } + + @Mutation(() => FrontComponentDTO) + @UseGuards(SettingsPermissionGuard(PermissionFlagType.APPLICATIONS)) + async deleteFrontComponent( + @Args('id', { type: () => UUIDScalarType }) id: string, + @AuthWorkspace() workspace: WorkspaceEntity, + ): Promise { + return await this.frontComponentService.delete(id, workspace.id); + } +} diff --git a/packages/twenty-server/src/engine/metadata-modules/front-component/front-component.service.ts b/packages/twenty-server/src/engine/metadata-modules/front-component/front-component.service.ts new file mode 100644 index 00000000000..be34be565ae --- /dev/null +++ b/packages/twenty-server/src/engine/metadata-modules/front-component/front-component.service.ts @@ -0,0 +1,237 @@ +import { Injectable } from '@nestjs/common'; + +import { isDefined } from 'twenty-shared/utils'; + +import { ApplicationService } from 'src/engine/core-modules/application/application.service'; +import { WorkspaceManyOrAllFlatEntityMapsCacheService } from 'src/engine/metadata-modules/flat-entity/services/workspace-many-or-all-flat-entity-maps-cache.service'; +import { 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 { fromCreateFrontComponentInputToFlatFrontComponentToCreate } from 'src/engine/metadata-modules/flat-front-component/utils/from-create-front-component-input-to-flat-front-component-to-create.util'; +import { fromDeleteFrontComponentInputToFlatFrontComponentOrThrow } from 'src/engine/metadata-modules/flat-front-component/utils/from-delete-front-component-input-to-flat-front-component-or-throw.util'; +import { fromFlatFrontComponentToFrontComponentDto } from 'src/engine/metadata-modules/flat-front-component/utils/from-flat-front-component-to-front-component-dto.util'; +import { fromUpdateFrontComponentInputToFlatFrontComponentToUpdateOrThrow } from 'src/engine/metadata-modules/flat-front-component/utils/from-update-front-component-input-to-flat-front-component-to-update-or-throw.util'; +import { type CreateFrontComponentInput } from 'src/engine/metadata-modules/front-component/dtos/create-front-component.input'; +import { type FrontComponentDTO } from 'src/engine/metadata-modules/front-component/dtos/front-component.dto'; +import { type UpdateFrontComponentInput } from 'src/engine/metadata-modules/front-component/dtos/update-front-component.input'; +import { + FrontComponentException, + FrontComponentExceptionCode, +} from 'src/engine/metadata-modules/front-component/front-component.exception'; +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 FrontComponentService { + constructor( + private readonly workspaceMigrationValidateBuildAndRunService: WorkspaceMigrationValidateBuildAndRunService, + private readonly workspaceManyOrAllFlatEntityMapsCacheService: WorkspaceManyOrAllFlatEntityMapsCacheService, + private readonly applicationService: ApplicationService, + ) {} + + async findAll(workspaceId: string): Promise { + const { flatFrontComponentMaps } = + await this.workspaceManyOrAllFlatEntityMapsCacheService.getOrRecomputeManyOrAllFlatEntityMaps( + { + workspaceId, + flatMapsKeys: ['flatFrontComponentMaps'], + }, + ); + + return Object.values(flatFrontComponentMaps.byId) + .filter(isDefined) + .sort((a, b) => a.name.localeCompare(b.name)) + .map(fromFlatFrontComponentToFrontComponentDto); + } + + async findById( + id: string, + workspaceId: string, + ): Promise { + const { flatFrontComponentMaps } = + await this.workspaceManyOrAllFlatEntityMapsCacheService.getOrRecomputeManyOrAllFlatEntityMaps( + { + workspaceId, + flatMapsKeys: ['flatFrontComponentMaps'], + }, + ); + + const flatFrontComponent = findFlatEntityByIdInFlatEntityMaps({ + flatEntityId: id, + flatEntityMaps: flatFrontComponentMaps, + }); + + if (!isDefined(flatFrontComponent)) { + return null; + } + + return fromFlatFrontComponentToFrontComponentDto(flatFrontComponent); + } + + async create( + input: CreateFrontComponentInput, + workspaceId: string, + ): Promise { + const { workspaceCustomFlatApplication } = + await this.applicationService.findWorkspaceTwentyStandardAndCustomApplicationOrThrow( + { workspaceId }, + ); + + const flatFrontComponentToCreate = + fromCreateFrontComponentInputToFlatFrontComponentToCreate({ + createFrontComponentInput: input, + workspaceId, + applicationId: workspaceCustomFlatApplication.id, + }); + + const validateAndBuildResult = + await this.workspaceMigrationValidateBuildAndRunService.validateBuildAndRunWorkspaceMigration( + { + allFlatEntityOperationByMetadataName: { + frontComponent: { + flatEntityToCreate: [flatFrontComponentToCreate], + flatEntityToDelete: [], + flatEntityToUpdate: [], + }, + }, + workspaceId, + isSystemBuild: false, + }, + ); + + if (isDefined(validateAndBuildResult)) { + throw new WorkspaceMigrationBuilderException( + validateAndBuildResult, + 'Multiple validation errors occurred while creating front component', + ); + } + + const { flatFrontComponentMaps: recomputedFlatFrontComponentMaps } = + await this.workspaceManyOrAllFlatEntityMapsCacheService.getOrRecomputeManyOrAllFlatEntityMaps( + { + workspaceId, + flatMapsKeys: ['flatFrontComponentMaps'], + }, + ); + + return fromFlatFrontComponentToFrontComponentDto( + findFlatEntityByIdInFlatEntityMapsOrThrow({ + flatEntityId: flatFrontComponentToCreate.id, + flatEntityMaps: recomputedFlatFrontComponentMaps, + }), + ); + } + + async update( + input: UpdateFrontComponentInput, + workspaceId: string, + ): Promise { + const { flatFrontComponentMaps: existingFlatFrontComponentMaps } = + await this.workspaceManyOrAllFlatEntityMapsCacheService.getOrRecomputeManyOrAllFlatEntityMaps( + { + workspaceId, + flatMapsKeys: ['flatFrontComponentMaps'], + }, + ); + + const flatFrontComponentToUpdate = + fromUpdateFrontComponentInputToFlatFrontComponentToUpdateOrThrow({ + flatFrontComponentMaps: existingFlatFrontComponentMaps, + updateFrontComponentInput: input, + }); + + const validateAndBuildResult = + await this.workspaceMigrationValidateBuildAndRunService.validateBuildAndRunWorkspaceMigration( + { + allFlatEntityOperationByMetadataName: { + frontComponent: { + flatEntityToCreate: [], + flatEntityToDelete: [], + flatEntityToUpdate: [flatFrontComponentToUpdate], + }, + }, + workspaceId, + isSystemBuild: false, + }, + ); + + if (isDefined(validateAndBuildResult)) { + throw new WorkspaceMigrationBuilderException( + validateAndBuildResult, + 'Multiple validation errors occurred while updating front component', + ); + } + + const { flatFrontComponentMaps: recomputedFlatFrontComponentMaps } = + await this.workspaceManyOrAllFlatEntityMapsCacheService.getOrRecomputeManyOrAllFlatEntityMaps( + { + workspaceId, + flatMapsKeys: ['flatFrontComponentMaps'], + }, + ); + + return fromFlatFrontComponentToFrontComponentDto( + findFlatEntityByIdInFlatEntityMapsOrThrow({ + flatEntityId: input.id, + flatEntityMaps: recomputedFlatFrontComponentMaps, + }), + ); + } + + async delete(id: string, workspaceId: string): Promise { + const { flatFrontComponentMaps: existingFlatFrontComponentMaps } = + await this.workspaceManyOrAllFlatEntityMapsCacheService.getOrRecomputeManyOrAllFlatEntityMaps( + { + workspaceId, + flatMapsKeys: ['flatFrontComponentMaps'], + }, + ); + + const flatFrontComponentToDelete = + fromDeleteFrontComponentInputToFlatFrontComponentOrThrow({ + flatFrontComponentMaps: existingFlatFrontComponentMaps, + frontComponentId: id, + }); + + const validateAndBuildResult = + await this.workspaceMigrationValidateBuildAndRunService.validateBuildAndRunWorkspaceMigration( + { + allFlatEntityOperationByMetadataName: { + frontComponent: { + flatEntityToCreate: [], + flatEntityToDelete: [flatFrontComponentToDelete], + flatEntityToUpdate: [], + }, + }, + workspaceId, + isSystemBuild: false, + }, + ); + + if (isDefined(validateAndBuildResult)) { + throw new WorkspaceMigrationBuilderException( + validateAndBuildResult, + 'Multiple validation errors occurred while deleting front component', + ); + } + + return fromFlatFrontComponentToFrontComponentDto( + flatFrontComponentToDelete, + ); + } + + async findByIdOrThrow( + id: string, + workspaceId: string, + ): Promise { + const frontComponent = await this.findById(id, workspaceId); + + if (!isDefined(frontComponent)) { + throw new FrontComponentException( + 'Front component not found', + FrontComponentExceptionCode.FRONT_COMPONENT_NOT_FOUND, + ); + } + + return frontComponent; + } +} diff --git a/packages/twenty-server/src/engine/metadata-modules/front-component/interceptors/front-component-graphql-api-exception.interceptor.ts b/packages/twenty-server/src/engine/metadata-modules/front-component/interceptors/front-component-graphql-api-exception.interceptor.ts new file mode 100644 index 00000000000..fffe3dd5138 --- /dev/null +++ b/packages/twenty-server/src/engine/metadata-modules/front-component/interceptors/front-component-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 { frontComponentGraphqlApiExceptionHandler } from 'src/engine/metadata-modules/front-component/utils/front-component-graphql-api-exception-handler.util'; + +@Injectable() +export class FrontComponentGraphqlApiExceptionInterceptor + implements NestInterceptor +{ + intercept( + _context: ExecutionContext, + next: CallHandler, + ): Observable { + return next + .handle() + .pipe(catchError(frontComponentGraphqlApiExceptionHandler)); + } +} diff --git a/packages/twenty-server/src/engine/metadata-modules/front-component/utils/front-component-graphql-api-exception-handler.util.ts b/packages/twenty-server/src/engine/metadata-modules/front-component/utils/front-component-graphql-api-exception-handler.util.ts new file mode 100644 index 00000000000..b33f42a2d35 --- /dev/null +++ b/packages/twenty-server/src/engine/metadata-modules/front-component/utils/front-component-graphql-api-exception-handler.util.ts @@ -0,0 +1,29 @@ +import { assertUnreachable } from 'twenty-shared/utils'; + +import { + ConflictError, + NotFoundError, + UserInputError, +} from 'src/engine/core-modules/graphql/utils/graphql-errors.util'; +import { + FrontComponentException, + FrontComponentExceptionCode, +} from 'src/engine/metadata-modules/front-component/front-component.exception'; + +export const frontComponentGraphqlApiExceptionHandler = (error: Error) => { + if (error instanceof FrontComponentException) { + switch (error.code) { + case FrontComponentExceptionCode.FRONT_COMPONENT_NOT_FOUND: + throw new NotFoundError(error); + case FrontComponentExceptionCode.INVALID_FRONT_COMPONENT_INPUT: + throw new UserInputError(error); + case FrontComponentExceptionCode.FRONT_COMPONENT_ALREADY_EXISTS: + throw new ConflictError(error); + default: { + return assertUnreachable(error.code); + } + } + } + + throw error; +}; 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 5238f2d8bd3..79b8e5f11a5 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 @@ -7,6 +7,7 @@ import { CronTriggerModule } from 'src/engine/metadata-modules/cron-trigger/cron 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'; import { FieldMetadataModule } from 'src/engine/metadata-modules/field-metadata/field-metadata.module'; +import { FrontComponentModule } from 'src/engine/metadata-modules/front-component/front-component.module'; import { ObjectMetadataModule } from 'src/engine/metadata-modules/object-metadata/object-metadata.module'; import { PermissionsModule } from 'src/engine/metadata-modules/permissions/permissions.module'; import { RoleModule } from 'src/engine/metadata-modules/role/role.module'; @@ -22,6 +23,7 @@ import { WorkspaceMetadataVersionModule } from 'src/engine/metadata-modules/work imports: [ DataSourceModule, FieldMetadataModule, + FrontComponentModule, ObjectMetadataModule, SearchFieldMetadataModule, ServerlessFunctionModule, @@ -42,6 +44,7 @@ import { WorkspaceMetadataVersionModule } from 'src/engine/metadata-modules/work exports: [ DataSourceModule, FieldMetadataModule, + FrontComponentModule, ObjectMetadataModule, SearchFieldMetadataModule, ServerlessFunctionModule, diff --git a/packages/twenty-server/src/engine/metadata-modules/skill/dtos/create-skill.input.ts b/packages/twenty-server/src/engine/metadata-modules/skill/dtos/create-skill.input.ts index 62a654af89d..668b157c248 100644 --- a/packages/twenty-server/src/engine/metadata-modules/skill/dtos/create-skill.input.ts +++ b/packages/twenty-server/src/engine/metadata-modules/skill/dtos/create-skill.input.ts @@ -1,9 +1,16 @@ import { Field, InputType } from '@nestjs/graphql'; -import { IsNotEmpty, IsOptional, IsString } from 'class-validator'; +import { IsNotEmpty, IsOptional, IsString, IsUUID } from 'class-validator'; + +import { UUIDScalarType } from 'src/engine/api/graphql/workspace-schema-builder/graphql-types/scalars'; @InputType() export class CreateSkillInput { + @IsUUID() + @IsOptional() + @Field(() => UUIDScalarType, { nullable: true }) + id?: string; + @IsString() @IsNotEmpty() @Field() 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 e49e1ee3243..6140171f39f 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 @@ -40,6 +40,7 @@ export const WORKSPACE_CACHE_KEYS_V2 = { 'flat-maps:row-level-permission-predicate', flatRowLevelPermissionPredicateGroupMaps: 'flat-maps:row-level-permission-predicate-group', + flatFrontComponentMaps: 'flat-maps:front-component', } as const satisfies Record; export type AdditionalCacheDataMaps = { 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 d35fb4f3682..1a9198b0204 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 @@ -18,6 +18,7 @@ import { WorkspaceMigrationAgentActionsBuilderService } from 'src/engine/workspa 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'; +import { WorkspaceMigrationFrontComponentActionsBuilderService } from 'src/engine/workspace-manager/workspace-migration/workspace-migration-builder/builders/front-component/workspace-migration-front-component-actions-builder.service'; import { WorkspaceMigrationIndexActionsBuilderService } from 'src/engine/workspace-manager/workspace-migration/workspace-migration-builder/builders/index/workspace-migration-index-actions-builder.service'; import { WorkspaceMigrationObjectActionsBuilderService } from 'src/engine/workspace-manager/workspace-migration/workspace-migration-builder/builders/object/workspace-migration-object-actions-builder.service'; import { WorkspaceMigrationPageLayoutTabActionsBuilderService } from 'src/engine/workspace-manager/workspace-migration/workspace-migration-builder/builders/page-layout-tab/workspace-migration-page-layout-tab-actions-builder.service'; @@ -60,6 +61,7 @@ export class WorkspaceMigrationBuildOrchestratorService { private readonly workspaceMigrationPageLayoutTabActionsBuilderService: WorkspaceMigrationPageLayoutTabActionsBuilderService, private readonly workspaceMigrationRowLevelPermissionPredicateActionsBuilderService: WorkspaceMigrationRowLevelPermissionPredicateActionsBuilderService, private readonly workspaceMigrationRowLevelPermissionPredicateGroupActionsBuilderService: WorkspaceMigrationRowLevelPermissionPredicateGroupActionsBuilderService, + private readonly workspaceMigrationFrontComponentActionsBuilderService: WorkspaceMigrationFrontComponentActionsBuilderService, ) {} private setupOptimisticCache({ @@ -159,6 +161,7 @@ export class WorkspaceMigrationBuildOrchestratorService { flatPageLayoutMaps, flatPageLayoutWidgetMaps, flatPageLayoutTabMaps, + flatFrontComponentMaps, } = fromToAllFlatEntityMaps; if (isDefined(flatObjectMetadataMaps)) { @@ -928,6 +931,39 @@ export class WorkspaceMigrationBuildOrchestratorService { } } + if (isDefined(flatFrontComponentMaps)) { + const { from: fromFlatFrontComponentMaps, to: toFlatFrontComponentMaps } = + flatFrontComponentMaps; + + const frontComponentResult = + await this.workspaceMigrationFrontComponentActionsBuilderService.validateAndBuild( + { + additionalCacheDataMaps, + from: fromFlatFrontComponentMaps, + to: toFlatFrontComponentMaps, + buildOptions, + dependencyOptimisticFlatEntityMaps: undefined, + workspaceId, + }, + ); + + this.mergeFlatEntityMapsAndRelatedFlatEntityMapsInAllFlatEntityMapsThroughMutation( + { + allFlatEntityMaps: optimisticAllFlatEntityMaps, + flatEntityMapsAndRelatedFlatEntityMaps: + frontComponentResult.optimisticFlatEntityMapsAndRelatedFlatEntityMaps, + }, + ); + + if (frontComponentResult.status === 'fail') { + orchestratorFailureReport.frontComponent.push( + ...frontComponentResult.errors, + ); + } else { + orchestratorActionsReport.frontComponent = frontComponentResult.actions; + } + } + const allErrors = Object.values(orchestratorFailureReport); if (allErrors.some((report) => report.length > 0)) { @@ -1069,6 +1105,12 @@ export class WorkspaceMigrationBuildOrchestratorService { ...aggregatedOrchestratorActionsReport.rowLevelPermissionPredicate .update, /// + + // Front components + ...aggregatedOrchestratorActionsReport.frontComponent.delete, + ...aggregatedOrchestratorActionsReport.frontComponent.create, + ...aggregatedOrchestratorActionsReport.frontComponent.update, + /// ], workspaceId, }, diff --git a/packages/twenty-server/src/engine/workspace-manager/workspace-migration/workspace-migration-builder/builders/front-component/types/workspace-migration-front-component-action.type.ts b/packages/twenty-server/src/engine/workspace-manager/workspace-migration/workspace-migration-builder/builders/front-component/types/workspace-migration-front-component-action.type.ts new file mode 100644 index 00000000000..fe18c355789 --- /dev/null +++ b/packages/twenty-server/src/engine/workspace-manager/workspace-migration/workspace-migration-builder/builders/front-component/types/workspace-migration-front-component-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 CreateFrontComponentAction = + BaseCreateWorkspaceMigrationAction<'frontComponent'>; + +export type UpdateFrontComponentAction = + BaseUpdateWorkspaceMigrationAction<'frontComponent'>; + +export type DeleteFrontComponentAction = + BaseDeleteWorkspaceMigrationAction<'frontComponent'>; diff --git a/packages/twenty-server/src/engine/workspace-manager/workspace-migration/workspace-migration-builder/builders/front-component/workspace-migration-front-component-actions-builder.service.ts b/packages/twenty-server/src/engine/workspace-manager/workspace-migration/workspace-migration-builder/builders/front-component/workspace-migration-front-component-actions-builder.service.ts new file mode 100644 index 00000000000..d102284427f --- /dev/null +++ b/packages/twenty-server/src/engine/workspace-manager/workspace-migration/workspace-migration-builder/builders/front-component/workspace-migration-front-component-actions-builder.service.ts @@ -0,0 +1,116 @@ +import { Injectable } from '@nestjs/common'; + +import { ALL_METADATA_NAME } from 'twenty-shared/metadata'; + +import { UpdateFrontComponentAction } from 'src/engine/workspace-manager/workspace-migration/workspace-migration-builder/builders/front-component/types/workspace-migration-front-component-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 { FlatFrontComponentValidatorService } from 'src/engine/workspace-manager/workspace-migration/workspace-migration-builder/validators/services/flat-front-component-validator.service'; + +@Injectable() +export class WorkspaceMigrationFrontComponentActionsBuilderService extends WorkspaceEntityMigrationBuilderService< + typeof ALL_METADATA_NAME.frontComponent +> { + constructor( + private readonly flatFrontComponentValidatorService: FlatFrontComponentValidatorService, + ) { + super(ALL_METADATA_NAME.frontComponent); + } + + protected validateFlatEntityCreation( + args: FlatEntityValidationArgs, + ): FlatEntityValidationReturnType< + typeof ALL_METADATA_NAME.frontComponent, + 'create' + > { + const validationResult = + this.flatFrontComponentValidatorService.validateFlatFrontComponentCreation( + args, + ); + + if (validationResult.errors.length > 0) { + return { + status: 'fail', + ...validationResult, + }; + } + + const { flatEntityToValidate: flatFrontComponentToValidate } = args; + + return { + status: 'success', + action: { + type: 'create', + metadataName: 'frontComponent', + flatEntity: flatFrontComponentToValidate, + }, + }; + } + + protected validateFlatEntityDeletion( + args: FlatEntityValidationArgs, + ): FlatEntityValidationReturnType< + typeof ALL_METADATA_NAME.frontComponent, + 'delete' + > { + const validationResult = + this.flatFrontComponentValidatorService.validateFlatFrontComponentDeletion( + args, + ); + + if (validationResult.errors.length > 0) { + return { + status: 'fail', + ...validationResult, + }; + } + + const { flatEntityToValidate: flatFrontComponentToValidate } = args; + + return { + status: 'success', + action: { + type: 'delete', + metadataName: 'frontComponent', + entityId: flatFrontComponentToValidate.id, + }, + }; + } + + protected validateFlatEntityUpdate( + args: FlatEntityUpdateValidationArgs< + typeof ALL_METADATA_NAME.frontComponent + >, + ): FlatEntityValidationReturnType< + typeof ALL_METADATA_NAME.frontComponent, + 'update' + > { + const validationResult = + this.flatFrontComponentValidatorService.validateFlatFrontComponentUpdate( + args, + ); + + if (validationResult.errors.length > 0) { + return { + status: 'fail', + ...validationResult, + }; + } + + const { flatEntityId, flatEntityUpdates } = args; + + const updateFrontComponentAction: UpdateFrontComponentAction = { + type: 'update', + metadataName: 'frontComponent', + entityId: flatEntityId, + updates: flatEntityUpdates, + }; + + return { + status: 'success', + action: updateFrontComponentAction, + }; + } +} diff --git a/packages/twenty-server/src/engine/workspace-manager/workspace-migration/workspace-migration-builder/validators/services/flat-front-component-validator.service.ts b/packages/twenty-server/src/engine/workspace-manager/workspace-migration/workspace-migration-builder/validators/services/flat-front-component-validator.service.ts new file mode 100644 index 00000000000..3429c36c4a7 --- /dev/null +++ b/packages/twenty-server/src/engine/workspace-manager/workspace-migration/workspace-migration-builder/validators/services/flat-front-component-validator.service.ts @@ -0,0 +1,113 @@ +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 { findFlatEntityByIdInFlatEntityMaps } from 'src/engine/metadata-modules/flat-entity/utils/find-flat-entity-by-id-in-flat-entity-maps.util'; +import { FrontComponentExceptionCode } from 'src/engine/metadata-modules/front-component/front-component.exception'; +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 FlatFrontComponentValidatorService { + public validateFlatFrontComponentCreation({ + flatEntityToValidate: flatFrontComponent, + }: FlatEntityValidationArgs< + typeof ALL_METADATA_NAME.frontComponent + >): FailedFlatEntityValidation<'frontComponent', 'create'> { + const validationResult = getEmptyFlatEntityValidationError({ + flatEntityMinimalInformation: { + id: flatFrontComponent.id, + universalIdentifier: flatFrontComponent.universalIdentifier, + name: flatFrontComponent.name, + }, + metadataName: 'frontComponent', + type: 'create', + }); + + if (!isNonEmptyString(flatFrontComponent.name)) { + validationResult.errors.push({ + code: FrontComponentExceptionCode.INVALID_FRONT_COMPONENT_INPUT, + message: t`Front component name is required`, + userFriendlyMessage: msg`Front component name is required`, + }); + } + + return validationResult; + } + + public validateFlatFrontComponentDeletion({ + flatEntityToValidate, + optimisticFlatEntityMapsAndRelatedFlatEntityMaps: { + flatFrontComponentMaps: optimisticFlatFrontComponentMaps, + }, + }: FlatEntityValidationArgs< + typeof ALL_METADATA_NAME.frontComponent + >): FailedFlatEntityValidation<'frontComponent', 'delete'> { + const validationResult = getEmptyFlatEntityValidationError({ + flatEntityMinimalInformation: { + id: flatEntityToValidate.id, + universalIdentifier: flatEntityToValidate.universalIdentifier, + name: flatEntityToValidate.name, + }, + metadataName: 'frontComponent', + type: 'delete', + }); + + const existingFrontComponent = findFlatEntityByIdInFlatEntityMaps({ + flatEntityId: flatEntityToValidate.id, + flatEntityMaps: optimisticFlatFrontComponentMaps, + }); + + if (!isDefined(existingFrontComponent)) { + validationResult.errors.push({ + code: FrontComponentExceptionCode.FRONT_COMPONENT_NOT_FOUND, + message: t`Front component not found`, + userFriendlyMessage: msg`Front component not found`, + }); + + return validationResult; + } + + return validationResult; + } + + public validateFlatFrontComponentUpdate({ + flatEntityId, + optimisticFlatEntityMapsAndRelatedFlatEntityMaps: { + flatFrontComponentMaps: optimisticFlatFrontComponentMaps, + }, + }: FlatEntityUpdateValidationArgs< + typeof ALL_METADATA_NAME.frontComponent + >): FailedFlatEntityValidation<'frontComponent', 'update'> { + const fromFlatFrontComponent = findFlatEntityByIdInFlatEntityMaps({ + flatEntityId, + flatEntityMaps: optimisticFlatFrontComponentMaps, + }); + + const validationResult = getEmptyFlatEntityValidationError({ + flatEntityMinimalInformation: { + id: flatEntityId, + universalIdentifier: fromFlatFrontComponent?.universalIdentifier, + }, + metadataName: 'frontComponent', + type: 'update', + }); + + if (!isDefined(fromFlatFrontComponent)) { + validationResult.errors.push({ + code: FrontComponentExceptionCode.FRONT_COMPONENT_NOT_FOUND, + message: t`Front component not found`, + userFriendlyMessage: msg`Front component not found`, + }); + + return validationResult; + } + + 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 318f53b2ab7..cf560137a29 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 @@ -19,6 +19,7 @@ import { FlatRowLevelPermissionPredicateGroupValidatorService } from 'src/engine 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 { 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'; import { FlatViewFilterGroupValidatorService } from 'src/engine/workspace-manager/workspace-migration/workspace-migration-builder/validators/services/flat-view-filter-group-validator.service'; import { FlatViewFilterValidatorService } from 'src/engine/workspace-manager/workspace-migration/workspace-migration-builder/validators/services/flat-view-filter-validator.service'; @@ -51,6 +52,7 @@ import { FlatViewValidatorService } from 'src/engine/workspace-manager/workspace FlatPageLayoutTabValidatorService, FlatRowLevelPermissionPredicateValidatorService, FlatRowLevelPermissionPredicateGroupValidatorService, + FlatFrontComponentValidatorService, ], exports: [ FlatViewValidatorService, @@ -75,6 +77,7 @@ import { FlatViewValidatorService } from 'src/engine/workspace-manager/workspace FlatPageLayoutTabValidatorService, FlatRowLevelPermissionPredicateValidatorService, FlatRowLevelPermissionPredicateGroupValidatorService, + FlatFrontComponentValidatorService, ], }) export class WorkspaceMigrationBuilderValidatorsModule {} 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 5323879d714..66e3f0636b8 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 @@ -18,6 +18,7 @@ import { WorkspaceMigrationRowLevelPermissionPredicateGroupActionsBuilderService import { WorkspaceMigrationRowLevelPermissionPredicateActionsBuilderService } from 'src/engine/workspace-manager/workspace-migration/workspace-migration-builder/builders/row-level-permission-predicate/workspace-migration-row-level-permission-predicate-actions-builder.service'; import { WorkspaceMigrationServerlessFunctionActionsBuilderService } from 'src/engine/workspace-manager/workspace-migration/workspace-migration-builder/builders/serverless-function/workspace-migration-serverless-function-actions-builder.service'; import { WorkspaceMigrationSkillActionsBuilderService } from 'src/engine/workspace-manager/workspace-migration/workspace-migration-builder/builders/skill/workspace-migration-skill-actions-builder.service'; +import { WorkspaceMigrationFrontComponentActionsBuilderService } from 'src/engine/workspace-manager/workspace-migration/workspace-migration-builder/builders/front-component/workspace-migration-front-component-actions-builder.service'; import { WorkspaceMigrationViewFieldActionsBuilderService } from 'src/engine/workspace-manager/workspace-migration/workspace-migration-builder/builders/view-field/workspace-migration-view-field-actions-builder.service'; import { WorkspaceMigrationViewFilterGroupActionsBuilderService } from 'src/engine/workspace-manager/workspace-migration/workspace-migration-builder/builders/view-filter-group/workspace-migration-view-filter-group-actions-builder.service'; import { WorkspaceMigrationViewFilterActionsBuilderService } from 'src/engine/workspace-manager/workspace-migration/workspace-migration-builder/builders/view-filter/workspace-migration-view-filter-actions-builder.service'; @@ -50,6 +51,7 @@ import { WorkspaceMigrationBuilderValidatorsModule } from 'src/engine/workspace- WorkspaceMigrationPageLayoutTabActionsBuilderService, WorkspaceMigrationRowLevelPermissionPredicateActionsBuilderService, WorkspaceMigrationRowLevelPermissionPredicateGroupActionsBuilderService, + WorkspaceMigrationFrontComponentActionsBuilderService, ], exports: [ WorkspaceMigrationViewActionsBuilderService, @@ -74,6 +76,7 @@ import { WorkspaceMigrationBuilderValidatorsModule } from 'src/engine/workspace- WorkspaceMigrationRowLevelPermissionPredicateActionsBuilderService, WorkspaceMigrationRowLevelPermissionPredicateGroupActionsBuilderService, FlatFieldMetadataTypeValidatorService, + WorkspaceMigrationFrontComponentActionsBuilderService, ], }) export class WorkspaceMigrationBuilderModule {} diff --git a/packages/twenty-server/src/engine/workspace-manager/workspace-migration/workspace-migration-runner/action-handlers/front-component/services/create-front-component-action-handler.service.ts b/packages/twenty-server/src/engine/workspace-manager/workspace-migration/workspace-migration-runner/action-handlers/front-component/services/create-front-component-action-handler.service.ts new file mode 100644 index 00000000000..34aedf3ce31 --- /dev/null +++ b/packages/twenty-server/src/engine/workspace-manager/workspace-migration/workspace-migration-runner/action-handlers/front-component/services/create-front-component-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 { FrontComponentEntity } from 'src/engine/metadata-modules/front-component/entities/front-component.entity'; +import { CreateFrontComponentAction } from 'src/engine/workspace-manager/workspace-migration/workspace-migration-builder/builders/front-component/types/workspace-migration-front-component-action.type'; +import { WorkspaceMigrationActionRunnerArgs } from 'src/engine/workspace-manager/workspace-migration/workspace-migration-runner/types/workspace-migration-action-runner-args.type'; + +@Injectable() +export class CreateFrontComponentActionHandlerService extends WorkspaceMigrationRunnerActionHandler( + 'create', + 'frontComponent', +) { + constructor() { + super(); + } + + async executeForMetadata( + context: WorkspaceMigrationActionRunnerArgs, + ): Promise { + const { action, queryRunner, workspaceId } = context; + const { flatEntity } = action; + + const frontComponentRepository = + queryRunner.manager.getRepository( + FrontComponentEntity, + ); + + await frontComponentRepository.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/front-component/services/delete-front-component-action-handler.service.ts b/packages/twenty-server/src/engine/workspace-manager/workspace-migration/workspace-migration-runner/action-handlers/front-component/services/delete-front-component-action-handler.service.ts new file mode 100644 index 00000000000..67373ed4836 --- /dev/null +++ b/packages/twenty-server/src/engine/workspace-manager/workspace-migration/workspace-migration-runner/action-handlers/front-component/services/delete-front-component-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 { FrontComponentEntity } from 'src/engine/metadata-modules/front-component/entities/front-component.entity'; +import { DeleteFrontComponentAction } from 'src/engine/workspace-manager/workspace-migration/workspace-migration-builder/builders/front-component/types/workspace-migration-front-component-action.type'; +import { WorkspaceMigrationActionRunnerArgs } from 'src/engine/workspace-manager/workspace-migration/workspace-migration-runner/types/workspace-migration-action-runner-args.type'; + +@Injectable() +export class DeleteFrontComponentActionHandlerService extends WorkspaceMigrationRunnerActionHandler( + 'delete', + 'frontComponent', +) { + constructor() { + super(); + } + + async executeForMetadata( + context: WorkspaceMigrationActionRunnerArgs, + ): Promise { + const { action, queryRunner, workspaceId } = context; + const { entityId } = action; + + const frontComponentRepository = + queryRunner.manager.getRepository( + FrontComponentEntity, + ); + + await frontComponentRepository.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/front-component/services/update-front-component-action-handler.service.ts b/packages/twenty-server/src/engine/workspace-manager/workspace-migration/workspace-migration-runner/action-handlers/front-component/services/update-front-component-action-handler.service.ts new file mode 100644 index 00000000000..72bb30dc657 --- /dev/null +++ b/packages/twenty-server/src/engine/workspace-manager/workspace-migration/workspace-migration-runner/action-handlers/front-component/services/update-front-component-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 { FrontComponentEntity } from 'src/engine/metadata-modules/front-component/entities/front-component.entity'; +import { UpdateFrontComponentAction } from 'src/engine/workspace-manager/workspace-migration/workspace-migration-builder/builders/front-component/types/workspace-migration-front-component-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 UpdateFrontComponentActionHandlerService extends WorkspaceMigrationRunnerActionHandler( + 'update', + 'frontComponent', +) { + async executeForMetadata( + context: WorkspaceMigrationActionRunnerArgs, + ): Promise { + const { action, queryRunner, workspaceId } = context; + const { entityId, updates } = action; + + const frontComponentRepository = + queryRunner.manager.getRepository( + FrontComponentEntity, + ); + + await frontComponentRepository.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 df9d06ae548..2d2206529d5 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 @@ -48,6 +48,9 @@ import { DeleteServerlessFunctionActionHandlerService } from 'src/engine/workspa import { UpdateServerlessFunctionActionHandlerService } from 'src/engine/workspace-manager/workspace-migration/workspace-migration-runner/action-handlers/serverless-function/services/update-serverless-function-action-handler.service'; import { CreateSkillActionHandlerService } from 'src/engine/workspace-manager/workspace-migration/workspace-migration-runner/action-handlers/skill/services/create-skill-action-handler.service'; import { DeleteSkillActionHandlerService } from 'src/engine/workspace-manager/workspace-migration/workspace-migration-runner/action-handlers/skill/services/delete-skill-action-handler.service'; +import { CreateFrontComponentActionHandlerService } from 'src/engine/workspace-manager/workspace-migration/workspace-migration-runner/action-handlers/front-component/services/create-front-component-action-handler.service'; +import { UpdateFrontComponentActionHandlerService } from 'src/engine/workspace-manager/workspace-migration/workspace-migration-runner/action-handlers/front-component/services/update-front-component-action-handler.service'; +import { DeleteFrontComponentActionHandlerService } from 'src/engine/workspace-manager/workspace-migration/workspace-migration-runner/action-handlers/front-component/services/delete-front-component-action-handler.service'; import { UpdateSkillActionHandlerService } from 'src/engine/workspace-manager/workspace-migration/workspace-migration-runner/action-handlers/skill/services/update-skill-action-handler.service'; import { CreateViewFieldActionHandlerService } from 'src/engine/workspace-manager/workspace-migration/workspace-migration-runner/action-handlers/view-field/services/create-view-field-action-handler.service'; import { DeleteViewFieldActionHandlerService } from 'src/engine/workspace-manager/workspace-migration/workspace-migration-runner/action-handlers/view-field/services/delete-view-field-action-handler.service'; @@ -151,6 +154,10 @@ import { UpdateViewActionHandlerService } from 'src/engine/workspace-manager/wor CreateRowLevelPermissionPredicateGroupActionHandlerService, UpdateRowLevelPermissionPredicateGroupActionHandlerService, DeleteRowLevelPermissionPredicateGroupActionHandlerService, + + CreateFrontComponentActionHandlerService, + UpdateFrontComponentActionHandlerService, + DeleteFrontComponentActionHandlerService, ], }) export class WorkspaceSchemaMigrationRunnerActionHandlersModule {} 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 716c82e862a..924fb9fb630 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,6 +68,7 @@ export const optimisticallyApplyCreateActionOnAllFlatEntityMaps = < case 'skill': case 'pageLayout': case 'pageLayoutWidget': + case 'frontComponent': case 'pageLayoutTab': { addFlatEntityToFlatEntityAndRelatedEntityMapsThroughMutationOrThrow({ flatEntity: action.flatEntity, 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 bde51d585e8..c5e30ddd2f0 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,6 +45,7 @@ export const optimisticallyApplyDeleteActionOnAllFlatEntityMaps = < case 'skill': case 'pageLayout': case 'pageLayoutWidget': + case 'frontComponent': case 'pageLayoutTab': { 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 dc0bc7912c5..c7cda3dd30d 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 @@ -67,7 +67,8 @@ export const optimisticallyApplyUpdateActionOnAllFlatEntityMaps = < case 'skill': case 'pageLayout': case 'pageLayoutWidget': - case 'pageLayoutTab': { + case 'pageLayoutTab': + case 'frontComponent': { const flatEntityMapsKey = getMetadataFlatEntityMapsKey( action.metadataName, ); diff --git a/packages/twenty-server/test/integration/metadata/suites/front-component/__snapshots__/failing-front-component-creation.integration-spec.ts.snap b/packages/twenty-server/test/integration/metadata/suites/front-component/__snapshots__/failing-front-component-creation.integration-spec.ts.snap new file mode 100644 index 00000000000..9bbf4c1ff50 --- /dev/null +++ b/packages/twenty-server/test/integration/metadata/suites/front-component/__snapshots__/failing-front-component-creation.integration-spec.ts.snap @@ -0,0 +1,89 @@ +// Jest Snapshot v1, https://jestjs.io/docs/snapshot-testing + +exports[`Front component creation should fail when name is empty 1`] = ` +{ + "extensions": { + "code": "METADATA_VALIDATION_FAILED", + "errors": { + "frontComponent": [ + { + "errors": [ + { + "code": "INVALID_FRONT_COMPONENT_INPUT", + "message": "Front component name is required", + "userFriendlyMessage": "Front component name is required", + }, + ], + "flatEntityMinimalInformation": { + "id": Any, + "name": "", + "universalIdentifier": Any, + }, + "metadataName": "frontComponent", + "status": "fail", + "type": "create", + }, + ], + }, + "message": "Validation failed for 1 frontComponent", + "summary": { + "frontComponent": 1, + "totalErrors": 1, + }, + "userFriendlyMessage": "Metadata validation failed", + }, + "message": "Multiple validation errors occurred while creating front component", + "name": "GraphQLError", +} +`; + +exports[`Front component creation should fail when name is too long 1`] = ` +{ + "extensions": { + "code": "BAD_USER_INPUT", + "http": { + "status": 400, + }, + "userFriendlyMessage": "An error occurred.", + }, + "message": "Expected non-nullable type "String!" not to be null.", + "name": "GraphQLError", +} +`; + +exports[`Front component creation should fail when name is whitespace-only 1`] = ` +{ + "extensions": { + "code": "METADATA_VALIDATION_FAILED", + "errors": { + "frontComponent": [ + { + "errors": [ + { + "code": "INVALID_FRONT_COMPONENT_INPUT", + "message": "Front component name is required", + "userFriendlyMessage": "Front component name is required", + }, + ], + "flatEntityMinimalInformation": { + "id": Any, + "name": "", + "universalIdentifier": Any, + }, + "metadataName": "frontComponent", + "status": "fail", + "type": "create", + }, + ], + }, + "message": "Validation failed for 1 frontComponent", + "summary": { + "frontComponent": 1, + "totalErrors": 1, + }, + "userFriendlyMessage": "Metadata validation failed", + }, + "message": "Multiple validation errors occurred while creating front component", + "name": "GraphQLError", +} +`; diff --git a/packages/twenty-server/test/integration/metadata/suites/front-component/__snapshots__/failing-front-component-deletion.integration-spec.ts.snap b/packages/twenty-server/test/integration/metadata/suites/front-component/__snapshots__/failing-front-component-deletion.integration-spec.ts.snap new file mode 100644 index 00000000000..00b996b093c --- /dev/null +++ b/packages/twenty-server/test/integration/metadata/suites/front-component/__snapshots__/failing-front-component-deletion.integration-spec.ts.snap @@ -0,0 +1,13 @@ +// Jest Snapshot v1, https://jestjs.io/docs/snapshot-testing + +exports[`Front component deletion should fail when front component does not exist 1`] = ` +{ + "extensions": { + "code": "NOT_FOUND", + "subCode": "FRONT_COMPONENT_NOT_FOUND", + "userFriendlyMessage": "Front component not found.", + }, + "message": "Front component not found", + "name": "NotFoundError", +} +`; diff --git a/packages/twenty-server/test/integration/metadata/suites/front-component/__snapshots__/failing-front-component-update.integration-spec.ts.snap b/packages/twenty-server/test/integration/metadata/suites/front-component/__snapshots__/failing-front-component-update.integration-spec.ts.snap new file mode 100644 index 00000000000..ef8437738f6 --- /dev/null +++ b/packages/twenty-server/test/integration/metadata/suites/front-component/__snapshots__/failing-front-component-update.integration-spec.ts.snap @@ -0,0 +1,13 @@ +// Jest Snapshot v1, https://jestjs.io/docs/snapshot-testing + +exports[`Front component update should fail when front component does not exist 1`] = ` +{ + "extensions": { + "code": "NOT_FOUND", + "subCode": "FRONT_COMPONENT_NOT_FOUND", + "userFriendlyMessage": "Front component not found.", + }, + "message": "Front component not found", + "name": "NotFoundError", +} +`; diff --git a/packages/twenty-server/test/integration/metadata/suites/front-component/failing-front-component-creation.integration-spec.ts b/packages/twenty-server/test/integration/metadata/suites/front-component/failing-front-component-creation.integration-spec.ts new file mode 100644 index 00000000000..01ab086ec45 --- /dev/null +++ b/packages/twenty-server/test/integration/metadata/suites/front-component/failing-front-component-creation.integration-spec.ts @@ -0,0 +1,49 @@ +import { expectOneNotInternalServerErrorSnapshot } from 'test/integration/graphql/utils/expect-one-not-internal-server-error-snapshot.util'; +import { createFrontComponent } from 'test/integration/metadata/suites/front-component/utils/create-front-component.util'; +import { + type EachTestingContext, + eachTestingContextFilter, +} from 'twenty-shared/testing'; + +import { type CreateFrontComponentInput } from 'src/engine/metadata-modules/front-component/dtos/create-front-component.input'; + +type TestContext = { + name: string | null; +}; + +const FAILING_TEST_CASES: EachTestingContext[] = [ + { + title: 'when name is empty', + context: { + name: '', + }, + }, + { + title: 'when name is whitespace-only', + context: { + name: ' ', + }, + }, + { + title: 'when name is too long', + context: { + name: null, + }, + }, +]; + +describe('Front component creation should fail', () => { + it.each(eachTestingContextFilter(FAILING_TEST_CASES))( + '$title', + async ({ context }) => { + const { errors } = await createFrontComponent({ + expectToFail: true, + input: { + name: context.name, + } as CreateFrontComponentInput, + }); + + expectOneNotInternalServerErrorSnapshot({ errors }); + }, + ); +}); diff --git a/packages/twenty-server/test/integration/metadata/suites/front-component/failing-front-component-deletion.integration-spec.ts b/packages/twenty-server/test/integration/metadata/suites/front-component/failing-front-component-deletion.integration-spec.ts new file mode 100644 index 00000000000..1e0434b6c7e --- /dev/null +++ b/packages/twenty-server/test/integration/metadata/suites/front-component/failing-front-component-deletion.integration-spec.ts @@ -0,0 +1,34 @@ +import { expectOneNotInternalServerErrorSnapshot } from 'test/integration/graphql/utils/expect-one-not-internal-server-error-snapshot.util'; +import { deleteFrontComponent } from 'test/integration/metadata/suites/front-component/utils/delete-front-component.util'; +import { + type EachTestingContext, + eachTestingContextFilter, +} from 'twenty-shared/testing'; +import { v4 } from 'uuid'; + +type TestContext = { + id: string; +}; + +const FAILING_TEST_CASES: EachTestingContext[] = [ + { + title: 'when front component does not exist', + context: { + id: v4(), + }, + }, +]; + +describe('Front component deletion should fail', () => { + it.each(eachTestingContextFilter(FAILING_TEST_CASES))( + '$title', + async ({ context }) => { + const { errors } = await deleteFrontComponent({ + expectToFail: true, + input: { id: context.id }, + }); + + expectOneNotInternalServerErrorSnapshot({ errors }); + }, + ); +}); diff --git a/packages/twenty-server/test/integration/metadata/suites/front-component/failing-front-component-update.integration-spec.ts b/packages/twenty-server/test/integration/metadata/suites/front-component/failing-front-component-update.integration-spec.ts new file mode 100644 index 00000000000..8a5369cdb3e --- /dev/null +++ b/packages/twenty-server/test/integration/metadata/suites/front-component/failing-front-component-update.integration-spec.ts @@ -0,0 +1,41 @@ +import { expectOneNotInternalServerErrorSnapshot } from 'test/integration/graphql/utils/expect-one-not-internal-server-error-snapshot.util'; +import { updateFrontComponent } from 'test/integration/metadata/suites/front-component/utils/update-front-component.util'; +import { + type EachTestingContext, + eachTestingContextFilter, +} from 'twenty-shared/testing'; +import { v4 } from 'uuid'; + +type TestContext = { + id: string; + name: string; +}; + +const FAILING_TEST_CASES: EachTestingContext[] = [ + { + title: 'when front component does not exist', + context: { + id: v4(), + name: 'updatedName', + }, + }, +]; + +describe('Front component update should fail', () => { + it.each(eachTestingContextFilter(FAILING_TEST_CASES))( + '$title', + async ({ context }) => { + const { errors } = await updateFrontComponent({ + expectToFail: true, + input: { + id: context.id, + update: { + name: context.name, + }, + }, + }); + + expectOneNotInternalServerErrorSnapshot({ errors }); + }, + ); +}); diff --git a/packages/twenty-server/test/integration/metadata/suites/front-component/successful-front-component-creation.integration-spec.ts b/packages/twenty-server/test/integration/metadata/suites/front-component/successful-front-component-creation.integration-spec.ts new file mode 100644 index 00000000000..0e808668900 --- /dev/null +++ b/packages/twenty-server/test/integration/metadata/suites/front-component/successful-front-component-creation.integration-spec.ts @@ -0,0 +1,48 @@ +import { createFrontComponent } from 'test/integration/metadata/suites/front-component/utils/create-front-component.util'; +import { deleteFrontComponent } from 'test/integration/metadata/suites/front-component/utils/delete-front-component.util'; + +describe('Front component creation should succeed', () => { + let createdFrontComponentId: string | undefined; + + afterEach(async () => { + if (createdFrontComponentId) { + await deleteFrontComponent({ + expectToFail: false, + input: { id: createdFrontComponentId }, + }); + createdFrontComponentId = undefined; + } + }); + + it('should create a basic front component with minimal input', async () => { + const { data } = await createFrontComponent({ + expectToFail: false, + input: { + name: 'testFrontComponent', + }, + }); + + createdFrontComponentId = data?.createFrontComponent?.id; + + expect(data.createFrontComponent).toMatchObject({ + id: expect.any(String), + name: 'testFrontComponent', + }); + }); + + it('should sanitize input by trimming whitespace', async () => { + const { data } = await createFrontComponent({ + expectToFail: false, + input: { + name: ' frontComponentWithSpaces ', + }, + }); + + createdFrontComponentId = data?.createFrontComponent?.id; + + expect(data.createFrontComponent).toMatchObject({ + id: expect.any(String), + name: 'frontComponentWithSpaces', + }); + }); +}); diff --git a/packages/twenty-server/test/integration/metadata/suites/front-component/successful-front-component-deletion.integration-spec.ts b/packages/twenty-server/test/integration/metadata/suites/front-component/successful-front-component-deletion.integration-spec.ts new file mode 100644 index 00000000000..7c088e7bdab --- /dev/null +++ b/packages/twenty-server/test/integration/metadata/suites/front-component/successful-front-component-deletion.integration-spec.ts @@ -0,0 +1,40 @@ +import { createFrontComponent } from 'test/integration/metadata/suites/front-component/utils/create-front-component.util'; +import { deleteFrontComponent } from 'test/integration/metadata/suites/front-component/utils/delete-front-component.util'; +import { findFrontComponent } from 'test/integration/metadata/suites/front-component/utils/find-front-component.util'; + +describe('Front component deletion should succeed', () => { + it('should successfully delete a front component', async () => { + const { data: createData } = await createFrontComponent({ + expectToFail: false, + input: { + name: 'frontComponentToDelete', + }, + }); + + const frontComponentId = createData.createFrontComponent.id; + + const { data: findBeforeData } = await findFrontComponent({ + expectToFail: false, + input: { id: frontComponentId }, + }); + + expect(findBeforeData.frontComponent.id).toBe(frontComponentId); + + const { data: deleteData } = await deleteFrontComponent({ + expectToFail: false, + input: { id: frontComponentId }, + }); + + expect(deleteData.deleteFrontComponent).toMatchObject({ + id: frontComponentId, + name: 'frontComponentToDelete', + }); + + const { data: findAfterData } = await findFrontComponent({ + expectToFail: false, + input: { id: frontComponentId }, + }); + + expect(findAfterData.frontComponent).toBeNull(); + }); +}); diff --git a/packages/twenty-server/test/integration/metadata/suites/front-component/successful-front-component-update.integration-spec.ts b/packages/twenty-server/test/integration/metadata/suites/front-component/successful-front-component-update.integration-spec.ts new file mode 100644 index 00000000000..2d2b5d34a38 --- /dev/null +++ b/packages/twenty-server/test/integration/metadata/suites/front-component/successful-front-component-update.integration-spec.ts @@ -0,0 +1,50 @@ +import { createFrontComponent } from 'test/integration/metadata/suites/front-component/utils/create-front-component.util'; +import { deleteFrontComponent } from 'test/integration/metadata/suites/front-component/utils/delete-front-component.util'; +import { updateFrontComponent } from 'test/integration/metadata/suites/front-component/utils/update-front-component.util'; +import { isDefined } from 'twenty-shared/utils'; + +describe('Front component update should succeed', () => { + let testFrontComponentId: string | undefined; + + beforeEach(async () => { + const { data } = await createFrontComponent({ + expectToFail: false, + input: { + name: 'testFrontComponentToUpdate', + }, + }); + + testFrontComponentId = data.createFrontComponent.id; + }); + + afterEach(async () => { + if (isDefined(testFrontComponentId)) { + await deleteFrontComponent({ + expectToFail: false, + input: { id: testFrontComponentId }, + }); + testFrontComponentId = undefined; + } + }); + + it('should update front component name', async () => { + if (!isDefined(testFrontComponentId)) { + throw new Error('testFrontComponentId should be defined'); + } + + const { data } = await updateFrontComponent({ + expectToFail: false, + input: { + id: testFrontComponentId, + update: { + name: 'updatedFrontComponentName', + }, + }, + }); + + expect(data.updateFrontComponent).toMatchObject({ + id: testFrontComponentId, + name: 'updatedFrontComponentName', + }); + }); +}); diff --git a/packages/twenty-server/test/integration/metadata/suites/front-component/utils/create-front-component-query-factory.util.ts b/packages/twenty-server/test/integration/metadata/suites/front-component/utils/create-front-component-query-factory.util.ts new file mode 100644 index 00000000000..77ee3144ead --- /dev/null +++ b/packages/twenty-server/test/integration/metadata/suites/front-component/utils/create-front-component-query-factory.util.ts @@ -0,0 +1,30 @@ +import gql from 'graphql-tag'; +import { type PerformMetadataQueryParams } from 'test/integration/metadata/types/perform-metadata-query.type'; + +import { type CreateFrontComponentInput } from 'src/engine/metadata-modules/front-component/dtos/create-front-component.input'; + +export type CreateFrontComponentFactoryInput = CreateFrontComponentInput; + +const DEFAULT_FRONT_COMPONENT_GQL_FIELDS = ` + id + name + applicationId + createdAt + updatedAt +`; + +export const createFrontComponentQueryFactory = ({ + input, + gqlFields = DEFAULT_FRONT_COMPONENT_GQL_FIELDS, +}: PerformMetadataQueryParams) => ({ + query: gql` + mutation CreateFrontComponent($input: CreateFrontComponentInput!) { + createFrontComponent(input: $input) { + ${gqlFields} + } + } + `, + variables: { + input, + }, +}); diff --git a/packages/twenty-server/test/integration/metadata/suites/front-component/utils/create-front-component.util.ts b/packages/twenty-server/test/integration/metadata/suites/front-component/utils/create-front-component.util.ts new file mode 100644 index 00000000000..67fbd3283ac --- /dev/null +++ b/packages/twenty-server/test/integration/metadata/suites/front-component/utils/create-front-component.util.ts @@ -0,0 +1,43 @@ +import { + type CreateFrontComponentFactoryInput, + createFrontComponentQueryFactory, +} from 'test/integration/metadata/suites/front-component/utils/create-front-component-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 FrontComponentDTO } from 'src/engine/metadata-modules/front-component/dtos/front-component.dto'; + +export const createFrontComponent = async ({ + input, + gqlFields, + expectToFail = false, + token, +}: PerformMetadataQueryParams): CommonResponseBody<{ + createFrontComponent: FrontComponentDTO; +}> => { + const graphqlOperation = createFrontComponentQueryFactory({ + input, + gqlFields, + }); + + const response = await makeMetadataAPIRequest(graphqlOperation, token); + + if (expectToFail === true) { + warnIfNoErrorButExpectedToFail({ + response, + errorMessage: 'Front component creation should have failed but did not', + }); + } + + if (expectToFail === false) { + warnIfErrorButNotExpectedToFail({ + response, + errorMessage: 'Front component 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/front-component/utils/delete-front-component-query-factory.util.ts b/packages/twenty-server/test/integration/metadata/suites/front-component/utils/delete-front-component-query-factory.util.ts new file mode 100644 index 00000000000..7d1a63a083f --- /dev/null +++ b/packages/twenty-server/test/integration/metadata/suites/front-component/utils/delete-front-component-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 DeleteFrontComponentFactoryInput = { + id: string; +}; + +const DEFAULT_FRONT_COMPONENT_GQL_FIELDS = ` + id + name +`; + +export const deleteFrontComponentQueryFactory = ({ + input, + gqlFields = DEFAULT_FRONT_COMPONENT_GQL_FIELDS, +}: PerformMetadataQueryParams) => ({ + query: gql` + mutation DeleteFrontComponent($id: UUID!) { + deleteFrontComponent(id: $id) { + ${gqlFields} + } + } + `, + variables: { + id: input.id, + }, +}); diff --git a/packages/twenty-server/test/integration/metadata/suites/front-component/utils/delete-front-component.util.ts b/packages/twenty-server/test/integration/metadata/suites/front-component/utils/delete-front-component.util.ts new file mode 100644 index 00000000000..17b3c61eee2 --- /dev/null +++ b/packages/twenty-server/test/integration/metadata/suites/front-component/utils/delete-front-component.util.ts @@ -0,0 +1,43 @@ +import { + type DeleteFrontComponentFactoryInput, + deleteFrontComponentQueryFactory, +} from 'test/integration/metadata/suites/front-component/utils/delete-front-component-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 FrontComponentDTO } from 'src/engine/metadata-modules/front-component/dtos/front-component.dto'; + +export const deleteFrontComponent = async ({ + input, + gqlFields, + expectToFail = false, + token, +}: PerformMetadataQueryParams): CommonResponseBody<{ + deleteFrontComponent: FrontComponentDTO; +}> => { + const graphqlOperation = deleteFrontComponentQueryFactory({ + input, + gqlFields, + }); + + const response = await makeMetadataAPIRequest(graphqlOperation, token); + + if (expectToFail === true) { + warnIfNoErrorButExpectedToFail({ + response, + errorMessage: 'Front component deletion should have failed but did not', + }); + } + + if (expectToFail === false) { + warnIfErrorButNotExpectedToFail({ + response, + errorMessage: 'Front component 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/front-component/utils/find-front-component-query-factory.util.ts b/packages/twenty-server/test/integration/metadata/suites/front-component/utils/find-front-component-query-factory.util.ts new file mode 100644 index 00000000000..5b8f6194ca5 --- /dev/null +++ b/packages/twenty-server/test/integration/metadata/suites/front-component/utils/find-front-component-query-factory.util.ts @@ -0,0 +1,30 @@ +import gql from 'graphql-tag'; +import { type PerformMetadataQueryParams } from 'test/integration/metadata/types/perform-metadata-query.type'; + +export type FindFrontComponentFactoryInput = { + id: string; +}; + +const DEFAULT_FRONT_COMPONENT_GQL_FIELDS = ` + id + name + applicationId + createdAt + updatedAt +`; + +export const findFrontComponentQueryFactory = ({ + input, + gqlFields = DEFAULT_FRONT_COMPONENT_GQL_FIELDS, +}: PerformMetadataQueryParams) => ({ + query: gql` + query FrontComponent($id: UUID!) { + frontComponent(id: $id) { + ${gqlFields} + } + } + `, + variables: { + id: input.id, + }, +}); diff --git a/packages/twenty-server/test/integration/metadata/suites/front-component/utils/find-front-component.util.ts b/packages/twenty-server/test/integration/metadata/suites/front-component/utils/find-front-component.util.ts new file mode 100644 index 00000000000..09daeaa2869 --- /dev/null +++ b/packages/twenty-server/test/integration/metadata/suites/front-component/utils/find-front-component.util.ts @@ -0,0 +1,43 @@ +import { + type FindFrontComponentFactoryInput, + findFrontComponentQueryFactory, +} from 'test/integration/metadata/suites/front-component/utils/find-front-component-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 FrontComponentDTO } from 'src/engine/metadata-modules/front-component/dtos/front-component.dto'; + +export const findFrontComponent = async ({ + input, + gqlFields, + expectToFail = false, + token, +}: PerformMetadataQueryParams): CommonResponseBody<{ + frontComponent: FrontComponentDTO; +}> => { + const graphqlOperation = findFrontComponentQueryFactory({ + input, + gqlFields, + }); + + const response = await makeMetadataAPIRequest(graphqlOperation, token); + + if (expectToFail === true) { + warnIfNoErrorButExpectedToFail({ + response, + errorMessage: 'Finding front component should have failed but did not', + }); + } + + if (expectToFail === false) { + warnIfErrorButNotExpectedToFail({ + response, + errorMessage: 'Finding front component has failed but should not', + }); + } + + return { data: response.body.data, errors: response.body.errors }; +}; diff --git a/packages/twenty-server/test/integration/metadata/suites/front-component/utils/find-front-components-query-factory.util.ts b/packages/twenty-server/test/integration/metadata/suites/front-component/utils/find-front-components-query-factory.util.ts new file mode 100644 index 00000000000..9ba70d82941 --- /dev/null +++ b/packages/twenty-server/test/integration/metadata/suites/front-component/utils/find-front-components-query-factory.util.ts @@ -0,0 +1,22 @@ +import gql from 'graphql-tag'; +import { type PerformMetadataQueryParams } from 'test/integration/metadata/types/perform-metadata-query.type'; + +const DEFAULT_FRONT_COMPONENT_GQL_FIELDS = ` + id + name + applicationId + createdAt + updatedAt +`; + +export const findFrontComponentsQueryFactory = ({ + gqlFields = DEFAULT_FRONT_COMPONENT_GQL_FIELDS, +}: PerformMetadataQueryParams) => ({ + query: gql` + query FrontComponents { + frontComponents { + ${gqlFields} + } + } + `, +}); diff --git a/packages/twenty-server/test/integration/metadata/suites/front-component/utils/find-front-components.util.ts b/packages/twenty-server/test/integration/metadata/suites/front-component/utils/find-front-components.util.ts new file mode 100644 index 00000000000..3d8ff86c2cf --- /dev/null +++ b/packages/twenty-server/test/integration/metadata/suites/front-component/utils/find-front-components.util.ts @@ -0,0 +1,39 @@ +import { findFrontComponentsQueryFactory } from 'test/integration/metadata/suites/front-component/utils/find-front-components-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 FrontComponentDTO } from 'src/engine/metadata-modules/front-component/dtos/front-component.dto'; + +export const findFrontComponents = async ({ + gqlFields, + expectToFail = false, + token, +}: PerformMetadataQueryParams): CommonResponseBody<{ + frontComponents: FrontComponentDTO[]; +}> => { + const graphqlOperation = findFrontComponentsQueryFactory({ + gqlFields, + input: undefined, + }); + + const response = await makeMetadataAPIRequest(graphqlOperation, token); + + if (expectToFail === true) { + warnIfNoErrorButExpectedToFail({ + response, + errorMessage: 'Finding front components should have failed but did not', + }); + } + + if (expectToFail === false) { + warnIfErrorButNotExpectedToFail({ + response, + errorMessage: 'Finding front components has failed but should not', + }); + } + + return { data: response.body.data, errors: response.body.errors }; +}; diff --git a/packages/twenty-server/test/integration/metadata/suites/front-component/utils/update-front-component-query-factory.util.ts b/packages/twenty-server/test/integration/metadata/suites/front-component/utils/update-front-component-query-factory.util.ts new file mode 100644 index 00000000000..cbed940530f --- /dev/null +++ b/packages/twenty-server/test/integration/metadata/suites/front-component/utils/update-front-component-query-factory.util.ts @@ -0,0 +1,30 @@ +import gql from 'graphql-tag'; +import { type PerformMetadataQueryParams } from 'test/integration/metadata/types/perform-metadata-query.type'; + +import { type UpdateFrontComponentInput } from 'src/engine/metadata-modules/front-component/dtos/update-front-component.input'; + +export type UpdateFrontComponentFactoryInput = UpdateFrontComponentInput; + +const DEFAULT_FRONT_COMPONENT_GQL_FIELDS = ` + id + name + applicationId + createdAt + updatedAt +`; + +export const updateFrontComponentQueryFactory = ({ + gqlFields = DEFAULT_FRONT_COMPONENT_GQL_FIELDS, + input, +}: PerformMetadataQueryParams) => ({ + query: gql` + mutation UpdateFrontComponent($input: UpdateFrontComponentInput!) { + updateFrontComponent(input: $input) { + ${gqlFields} + } + } + `, + variables: { + input, + }, +}); diff --git a/packages/twenty-server/test/integration/metadata/suites/front-component/utils/update-front-component.util.ts b/packages/twenty-server/test/integration/metadata/suites/front-component/utils/update-front-component.util.ts new file mode 100644 index 00000000000..18e9bed2db3 --- /dev/null +++ b/packages/twenty-server/test/integration/metadata/suites/front-component/utils/update-front-component.util.ts @@ -0,0 +1,43 @@ +import { + type UpdateFrontComponentFactoryInput, + updateFrontComponentQueryFactory, +} from 'test/integration/metadata/suites/front-component/utils/update-front-component-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 FrontComponentDTO } from 'src/engine/metadata-modules/front-component/dtos/front-component.dto'; + +export const updateFrontComponent = async ({ + input, + gqlFields, + expectToFail = false, + token, +}: PerformMetadataQueryParams): CommonResponseBody<{ + updateFrontComponent: FrontComponentDTO; +}> => { + const graphqlOperation = updateFrontComponentQueryFactory({ + input, + gqlFields, + }); + + const response = await makeMetadataAPIRequest(graphqlOperation, token); + + if (expectToFail === true) { + warnIfNoErrorButExpectedToFail({ + response, + errorMessage: 'Front component update should have failed but did not', + }); + } + + if (expectToFail === false) { + warnIfErrorButNotExpectedToFail({ + response, + errorMessage: 'Front component update has failed but should not', + }); + } + + return { data: response.body.data, errors: response.body.errors }; +}; diff --git a/packages/twenty-server/test/integration/metadata/suites/skill/successful-skill-deletion.integration-spec.ts b/packages/twenty-server/test/integration/metadata/suites/skill/successful-skill-deletion.integration-spec.ts index 641d882ccb1..2b0c3b3a27e 100644 --- a/packages/twenty-server/test/integration/metadata/suites/skill/successful-skill-deletion.integration-spec.ts +++ b/packages/twenty-server/test/integration/metadata/suites/skill/successful-skill-deletion.integration-spec.ts @@ -1,6 +1,7 @@ import { createSkill } from 'test/integration/metadata/suites/skill/utils/create-skill.util'; import { deleteSkill } from 'test/integration/metadata/suites/skill/utils/delete-skill.util'; import { findSkill } from 'test/integration/metadata/suites/skill/utils/find-skill.util'; +import { isDefined } from 'twenty-shared/utils'; describe('Skill deletion should succeed', () => { it('should successfully delete a custom skill', async () => { @@ -15,6 +16,10 @@ describe('Skill deletion should succeed', () => { const skillId = createData.createSkill.id; + if (!isDefined(skillId)) { + return; + } + const { data: findBeforeData } = await findSkill({ expectToFail: false, input: { id: skillId }, 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 3f34d17484c..6a42d664905 100644 --- a/packages/twenty-shared/src/metadata/all-metadata-name.constant.ts +++ b/packages/twenty-shared/src/metadata/all-metadata-name.constant.ts @@ -20,4 +20,5 @@ export const ALL_METADATA_NAME = { pageLayout: 'pageLayout', pageLayoutWidget: 'pageLayoutWidget', pageLayoutTab: 'pageLayoutTab', + frontComponent: 'frontComponent', } as const;