From 3bbaff801a4e6ab696a7d675ab36b88d5f65459c Mon Sep 17 00:00:00 2001 From: Weiko Date: Thu, 26 Feb 2026 16:26:49 +0100 Subject: [PATCH] Add Twenty app settings custom app (#18273) ## Context This PR adds the ability to define a front-component as a custom tab for the application settings, allowing app creators to inject some logic/rendering the their app settings. ## Example ```typescript // packages/twenty-apps/my-test-app/src/front-components/settings-custom-tab.tsx import { defineFrontComponent } from 'twenty-sdk'; export const SETTINGS_CUSTOM_TAB_UNIVERSAL_IDENTIFIER = 'a42a88a8-21ce-4d22-bc44-d5146da64726'; export const SettingsCustomTab = () => { return (

My Test App Settings

This is a custom settings tab provided by My Test App.

Application creators can customize this component freely.

); }; export default defineFrontComponent({ universalIdentifier: SETTINGS_CUSTOM_TAB_UNIVERSAL_IDENTIFIER, name: 'settings-custom-tab', description: 'Custom settings tab for the application', component: SettingsCustomTab, }); ``` ```typescript // packages/twenty-apps/my-test-app/src/application-config.ts export default defineApplication({ universalIdentifier: '52870ce6-e584-4bd4-bc1a-6c63c508982e', displayName: 'My test app', description: '', defaultRoleUniversalIdentifier: DEFAULT_ROLE_UNIVERSAL_IDENTIFIER, settingsCustomTabFrontComponentUniversalIdentifier: SETTINGS_CUSTOM_TAB_UNIVERSAL_IDENTIFIER, }); ``` Screenshot 2026-02-26 at 14 54 09 --- .../src/generated-metadata/graphql.ts | 6 ++-- .../graphql/fragments/applicationFragment.ts | 1 + .../SettingsApplicationDetails.tsx | 18 +++++++++++ .../tabs/SettingsApplicationCustomTab.tsx | 32 +++++++++++++++++++ ...m-tab-front-component-id-to-application.ts | 19 +++++++++++ .../application/application.entity.ts | 3 ++ .../application/dtos/application.dto.ts | 5 +++ .../application-manifest-migration.service.ts | 29 +++++++++++++++-- ...lat-application-to-application-dto.util.ts | 3 ++ ...-to-flat-field-metadatas-to-create.spec.ts | 1 + ...-relation-flat-field-metadata-pair.spec.ts | 1 + .../src/application/applicationType.ts | 1 + 12 files changed, 114 insertions(+), 5 deletions(-) create mode 100644 packages/twenty-front/src/pages/settings/applications/tabs/SettingsApplicationCustomTab.tsx create mode 100644 packages/twenty-server/src/database/typeorm/core/migrations/common/1771840510113-add-settings-custom-tab-front-component-id-to-application.ts diff --git a/packages/twenty-front/src/generated-metadata/graphql.ts b/packages/twenty-front/src/generated-metadata/graphql.ts index 2186663c7d..8a1f9d38eb 100644 --- a/packages/twenty-front/src/generated-metadata/graphql.ts +++ b/packages/twenty-front/src/generated-metadata/graphql.ts @@ -304,6 +304,7 @@ export type Application = { objects: Array; packageJsonChecksum?: Maybe; packageJsonFileId?: Maybe; + settingsCustomTabFrontComponentId?: Maybe; universalIdentifier: Scalars['String']; version: Scalars['String']; yarnLockChecksum?: Maybe; @@ -5527,7 +5528,7 @@ export type UpdateOneApplicationVariableMutationVariables = Exact<{ export type UpdateOneApplicationVariableMutation = { __typename?: 'Mutation', updateOneApplicationVariable: boolean }; -export type ApplicationFieldsFragment = { __typename?: 'Application', id: string, name: string, description: string, version: string, universalIdentifier: string, canBeUninstalled: boolean, defaultRoleId?: string | null, availablePackages: any, applicationVariables: Array<{ __typename?: 'ApplicationVariable', id: string, key: string, value: string, description: string, isSecret: boolean }>, agents: Array<{ __typename?: 'Agent', id: string, name: string, label: string, description?: string | null, icon?: string | null, prompt: string, modelId: string, responseFormat?: any | null, roleId?: string | null, isCustom: boolean, modelConfiguration?: any | null, evaluationInputs: Array, applicationId?: string | null, createdAt: string, updatedAt: string }>, objects: Array<{ __typename?: 'Object', id: string, universalIdentifier: string, nameSingular: string, namePlural: string, labelSingular: string, labelPlural: string, description?: string | null, icon?: string | null, isCustom: boolean, isRemote: boolean, isActive: boolean, isSystem: boolean, isUIReadOnly: boolean, createdAt: string, updatedAt: string, labelIdentifierFieldMetadataId?: string | null, imageIdentifierFieldMetadataId?: string | null, applicationId: string, shortcut?: string | null, isLabelSyncedWithName: boolean, isSearchable: boolean, duplicateCriteria?: Array> | null, indexMetadataList: Array<{ __typename?: 'Index', id: string, createdAt: string, updatedAt: string, name: string, indexWhereClause?: string | null, indexType: IndexType, isUnique: boolean, isCustom?: boolean | null, indexFieldMetadataList: Array<{ __typename?: 'IndexField', id: string, fieldMetadataId: string, createdAt: string, updatedAt: string, order: number }> }>, fieldsList: Array<{ __typename?: 'Field', id: string, universalIdentifier: string, type: FieldMetadataType, name: string, label: string, description?: string | null, icon?: string | null, isCustom?: boolean | null, isActive?: boolean | null, isSystem?: boolean | null, isUIReadOnly?: boolean | null, isNullable?: boolean | null, isUnique?: boolean | null, createdAt: string, updatedAt: string, defaultValue?: any | null, options?: any | null, settings?: any | null, isLabelSyncedWithName?: boolean | null, morphId?: string | null, applicationId: string, relation?: { __typename?: 'Relation', type: RelationType, sourceObjectMetadata: { __typename?: 'Object', id: string, nameSingular: string, namePlural: string }, targetObjectMetadata: { __typename?: 'Object', id: string, nameSingular: string, namePlural: string }, sourceFieldMetadata: { __typename?: 'Field', id: string, name: string }, targetFieldMetadata: { __typename?: 'Field', id: string, name: string } } | null, morphRelations?: Array<{ __typename?: 'Relation', type: RelationType, sourceObjectMetadata: { __typename?: 'Object', id: string, nameSingular: string, namePlural: string }, targetObjectMetadata: { __typename?: 'Object', id: string, nameSingular: string, namePlural: string }, sourceFieldMetadata: { __typename?: 'Field', id: string, name: string }, targetFieldMetadata: { __typename?: 'Field', id: string, name: string } }> | null }> }>, logicFunctions: Array<{ __typename?: 'LogicFunction', id: string, name: string, description?: string | null, runtime: string, timeoutSeconds: number, sourceHandlerPath: string, handlerName: string, toolInputSchema?: any | null, isTool: boolean, applicationId?: string | null, createdAt: string, updatedAt: string }> }; +export type ApplicationFieldsFragment = { __typename?: 'Application', id: string, name: string, description: string, version: string, universalIdentifier: string, canBeUninstalled: boolean, defaultRoleId?: string | null, settingsCustomTabFrontComponentId?: string | null, availablePackages: any, applicationVariables: Array<{ __typename?: 'ApplicationVariable', id: string, key: string, value: string, description: string, isSecret: boolean }>, agents: Array<{ __typename?: 'Agent', id: string, name: string, label: string, description?: string | null, icon?: string | null, prompt: string, modelId: string, responseFormat?: any | null, roleId?: string | null, isCustom: boolean, modelConfiguration?: any | null, evaluationInputs: Array, applicationId?: string | null, createdAt: string, updatedAt: string }>, objects: Array<{ __typename?: 'Object', id: string, universalIdentifier: string, nameSingular: string, namePlural: string, labelSingular: string, labelPlural: string, description?: string | null, icon?: string | null, isCustom: boolean, isRemote: boolean, isActive: boolean, isSystem: boolean, isUIReadOnly: boolean, createdAt: string, updatedAt: string, labelIdentifierFieldMetadataId?: string | null, imageIdentifierFieldMetadataId?: string | null, applicationId: string, shortcut?: string | null, isLabelSyncedWithName: boolean, isSearchable: boolean, duplicateCriteria?: Array> | null, indexMetadataList: Array<{ __typename?: 'Index', id: string, createdAt: string, updatedAt: string, name: string, indexWhereClause?: string | null, indexType: IndexType, isUnique: boolean, isCustom?: boolean | null, indexFieldMetadataList: Array<{ __typename?: 'IndexField', id: string, fieldMetadataId: string, createdAt: string, updatedAt: string, order: number }> }>, fieldsList: Array<{ __typename?: 'Field', id: string, universalIdentifier: string, type: FieldMetadataType, name: string, label: string, description?: string | null, icon?: string | null, isCustom?: boolean | null, isActive?: boolean | null, isSystem?: boolean | null, isUIReadOnly?: boolean | null, isNullable?: boolean | null, isUnique?: boolean | null, createdAt: string, updatedAt: string, defaultValue?: any | null, options?: any | null, settings?: any | null, isLabelSyncedWithName?: boolean | null, morphId?: string | null, applicationId: string, relation?: { __typename?: 'Relation', type: RelationType, sourceObjectMetadata: { __typename?: 'Object', id: string, nameSingular: string, namePlural: string }, targetObjectMetadata: { __typename?: 'Object', id: string, nameSingular: string, namePlural: string }, sourceFieldMetadata: { __typename?: 'Field', id: string, name: string }, targetFieldMetadata: { __typename?: 'Field', id: string, name: string } } | null, morphRelations?: Array<{ __typename?: 'Relation', type: RelationType, sourceObjectMetadata: { __typename?: 'Object', id: string, nameSingular: string, namePlural: string }, targetObjectMetadata: { __typename?: 'Object', id: string, nameSingular: string, namePlural: string }, sourceFieldMetadata: { __typename?: 'Field', id: string, name: string }, targetFieldMetadata: { __typename?: 'Field', id: string, name: string } }> | null }> }>, logicFunctions: Array<{ __typename?: 'LogicFunction', id: string, name: string, description?: string | null, runtime: string, timeoutSeconds: number, sourceHandlerPath: string, handlerName: string, toolInputSchema?: any | null, isTool: boolean, applicationId?: string | null, createdAt: string, updatedAt: string }> }; export type FindManyApplicationsQueryVariables = Exact<{ [key: string]: never; }>; @@ -5539,7 +5540,7 @@ export type FindOneApplicationQueryVariables = Exact<{ }>; -export type FindOneApplicationQuery = { __typename?: 'Query', findOneApplication: { __typename?: 'Application', id: string, name: string, description: string, version: string, universalIdentifier: string, canBeUninstalled: boolean, defaultRoleId?: string | null, availablePackages: any, applicationVariables: Array<{ __typename?: 'ApplicationVariable', id: string, key: string, value: string, description: string, isSecret: boolean }>, agents: Array<{ __typename?: 'Agent', id: string, name: string, label: string, description?: string | null, icon?: string | null, prompt: string, modelId: string, responseFormat?: any | null, roleId?: string | null, isCustom: boolean, modelConfiguration?: any | null, evaluationInputs: Array, applicationId?: string | null, createdAt: string, updatedAt: string }>, objects: Array<{ __typename?: 'Object', id: string, universalIdentifier: string, nameSingular: string, namePlural: string, labelSingular: string, labelPlural: string, description?: string | null, icon?: string | null, isCustom: boolean, isRemote: boolean, isActive: boolean, isSystem: boolean, isUIReadOnly: boolean, createdAt: string, updatedAt: string, labelIdentifierFieldMetadataId?: string | null, imageIdentifierFieldMetadataId?: string | null, applicationId: string, shortcut?: string | null, isLabelSyncedWithName: boolean, isSearchable: boolean, duplicateCriteria?: Array> | null, indexMetadataList: Array<{ __typename?: 'Index', id: string, createdAt: string, updatedAt: string, name: string, indexWhereClause?: string | null, indexType: IndexType, isUnique: boolean, isCustom?: boolean | null, indexFieldMetadataList: Array<{ __typename?: 'IndexField', id: string, fieldMetadataId: string, createdAt: string, updatedAt: string, order: number }> }>, fieldsList: Array<{ __typename?: 'Field', id: string, universalIdentifier: string, type: FieldMetadataType, name: string, label: string, description?: string | null, icon?: string | null, isCustom?: boolean | null, isActive?: boolean | null, isSystem?: boolean | null, isUIReadOnly?: boolean | null, isNullable?: boolean | null, isUnique?: boolean | null, createdAt: string, updatedAt: string, defaultValue?: any | null, options?: any | null, settings?: any | null, isLabelSyncedWithName?: boolean | null, morphId?: string | null, applicationId: string, relation?: { __typename?: 'Relation', type: RelationType, sourceObjectMetadata: { __typename?: 'Object', id: string, nameSingular: string, namePlural: string }, targetObjectMetadata: { __typename?: 'Object', id: string, nameSingular: string, namePlural: string }, sourceFieldMetadata: { __typename?: 'Field', id: string, name: string }, targetFieldMetadata: { __typename?: 'Field', id: string, name: string } } | null, morphRelations?: Array<{ __typename?: 'Relation', type: RelationType, sourceObjectMetadata: { __typename?: 'Object', id: string, nameSingular: string, namePlural: string }, targetObjectMetadata: { __typename?: 'Object', id: string, nameSingular: string, namePlural: string }, sourceFieldMetadata: { __typename?: 'Field', id: string, name: string }, targetFieldMetadata: { __typename?: 'Field', id: string, name: string } }> | null }> }>, logicFunctions: Array<{ __typename?: 'LogicFunction', id: string, name: string, description?: string | null, runtime: string, timeoutSeconds: number, sourceHandlerPath: string, handlerName: string, toolInputSchema?: any | null, isTool: boolean, applicationId?: string | null, createdAt: string, updatedAt: string }> } }; +export type FindOneApplicationQuery = { __typename?: 'Query', findOneApplication: { __typename?: 'Application', id: string, name: string, description: string, version: string, universalIdentifier: string, canBeUninstalled: boolean, defaultRoleId?: string | null, settingsCustomTabFrontComponentId?: string | null, availablePackages: any, applicationVariables: Array<{ __typename?: 'ApplicationVariable', id: string, key: string, value: string, description: string, isSecret: boolean }>, agents: Array<{ __typename?: 'Agent', id: string, name: string, label: string, description?: string | null, icon?: string | null, prompt: string, modelId: string, responseFormat?: any | null, roleId?: string | null, isCustom: boolean, modelConfiguration?: any | null, evaluationInputs: Array, applicationId?: string | null, createdAt: string, updatedAt: string }>, objects: Array<{ __typename?: 'Object', id: string, universalIdentifier: string, nameSingular: string, namePlural: string, labelSingular: string, labelPlural: string, description?: string | null, icon?: string | null, isCustom: boolean, isRemote: boolean, isActive: boolean, isSystem: boolean, isUIReadOnly: boolean, createdAt: string, updatedAt: string, labelIdentifierFieldMetadataId?: string | null, imageIdentifierFieldMetadataId?: string | null, applicationId: string, shortcut?: string | null, isLabelSyncedWithName: boolean, isSearchable: boolean, duplicateCriteria?: Array> | null, indexMetadataList: Array<{ __typename?: 'Index', id: string, createdAt: string, updatedAt: string, name: string, indexWhereClause?: string | null, indexType: IndexType, isUnique: boolean, isCustom?: boolean | null, indexFieldMetadataList: Array<{ __typename?: 'IndexField', id: string, fieldMetadataId: string, createdAt: string, updatedAt: string, order: number }> }>, fieldsList: Array<{ __typename?: 'Field', id: string, universalIdentifier: string, type: FieldMetadataType, name: string, label: string, description?: string | null, icon?: string | null, isCustom?: boolean | null, isActive?: boolean | null, isSystem?: boolean | null, isUIReadOnly?: boolean | null, isNullable?: boolean | null, isUnique?: boolean | null, createdAt: string, updatedAt: string, defaultValue?: any | null, options?: any | null, settings?: any | null, isLabelSyncedWithName?: boolean | null, morphId?: string | null, applicationId: string, relation?: { __typename?: 'Relation', type: RelationType, sourceObjectMetadata: { __typename?: 'Object', id: string, nameSingular: string, namePlural: string }, targetObjectMetadata: { __typename?: 'Object', id: string, nameSingular: string, namePlural: string }, sourceFieldMetadata: { __typename?: 'Field', id: string, name: string }, targetFieldMetadata: { __typename?: 'Field', id: string, name: string } } | null, morphRelations?: Array<{ __typename?: 'Relation', type: RelationType, sourceObjectMetadata: { __typename?: 'Object', id: string, nameSingular: string, namePlural: string }, targetObjectMetadata: { __typename?: 'Object', id: string, nameSingular: string, namePlural: string }, sourceFieldMetadata: { __typename?: 'Field', id: string, name: string }, targetFieldMetadata: { __typename?: 'Field', id: string, name: string } }> | null }> }>, logicFunctions: Array<{ __typename?: 'LogicFunction', id: string, name: string, description?: string | null, runtime: string, timeoutSeconds: number, sourceHandlerPath: string, handlerName: string, toolInputSchema?: any | null, isTool: boolean, applicationId?: string | null, createdAt: string, updatedAt: string }> } }; export type UploadFileMutationVariables = Exact<{ file: Scalars['Upload']; @@ -7164,6 +7165,7 @@ export const ApplicationFieldsFragmentDoc = gql` universalIdentifier canBeUninstalled defaultRoleId + settingsCustomTabFrontComponentId availablePackages applicationVariables { id diff --git a/packages/twenty-front/src/modules/applications/graphql/fragments/applicationFragment.ts b/packages/twenty-front/src/modules/applications/graphql/fragments/applicationFragment.ts index a5131f2a5c..882e253fb4 100644 --- a/packages/twenty-front/src/modules/applications/graphql/fragments/applicationFragment.ts +++ b/packages/twenty-front/src/modules/applications/graphql/fragments/applicationFragment.ts @@ -15,6 +15,7 @@ export const APPLICATION_FRAGMENT = gql` universalIdentifier canBeUninstalled defaultRoleId + settingsCustomTabFrontComponentId availablePackages applicationVariables { id diff --git a/packages/twenty-front/src/pages/settings/applications/SettingsApplicationDetails.tsx b/packages/twenty-front/src/pages/settings/applications/SettingsApplicationDetails.tsx index 9ebeb558c5..f47ccc2242 100644 --- a/packages/twenty-front/src/pages/settings/applications/SettingsApplicationDetails.tsx +++ b/packages/twenty-front/src/pages/settings/applications/SettingsApplicationDetails.tsx @@ -6,6 +6,7 @@ import { useParams } from 'react-router-dom'; import { useFindOneApplicationQuery } from '~/generated-metadata/graphql'; import { SettingsPageContainer } from '@/settings/components/SettingsPageContainer'; import { + IconApps, IconBox, IconInfoCircle, IconLock, @@ -19,6 +20,7 @@ import { SettingsApplicationDetailContentTab } from '~/pages/settings/applicatio import { SettingsApplicationDetailAboutTab } from '~/pages/settings/applications/tabs/SettingsApplicationDetailAboutTab'; import { SettingsApplicationDetailSettingsTab } from '~/pages/settings/applications/tabs/SettingsApplicationDetailSettingsTab'; import { SettingsApplicationPermissionsTab } from '~/pages/settings/applications/tabs/SettingsApplicationPermissionsTab'; +import { SettingsApplicationCustomTab } from '~/pages/settings/applications/tabs/SettingsApplicationCustomTab'; const APPLICATION_DETAIL_ID = 'application-detail-id'; @@ -43,11 +45,17 @@ export const SettingsApplicationDetails = () => { ? t`Application details` : applicationName; + const settingsCustomTabFrontComponentId = + application?.settingsCustomTabFrontComponentId; + const tabs = [ { id: 'about', title: t`About`, Icon: IconInfoCircle }, { id: 'content', title: t`Content`, Icon: IconBox }, { id: 'permissions', title: t`Permissions`, Icon: IconLock }, { id: 'settings', title: t`Settings`, Icon: IconSettings }, + ...(isDefined(settingsCustomTabFrontComponentId) + ? [{ id: 'custom', title: t`Custom`, Icon: IconApps }] + : []), ]; const renderActiveTabContent = () => { @@ -68,6 +76,16 @@ export const SettingsApplicationDetails = () => { return ( ); + case 'custom': + return isDefined(settingsCustomTabFrontComponentId) ? ( + + ) : ( + <> + ); default: return <>; } diff --git a/packages/twenty-front/src/pages/settings/applications/tabs/SettingsApplicationCustomTab.tsx b/packages/twenty-front/src/pages/settings/applications/tabs/SettingsApplicationCustomTab.tsx new file mode 100644 index 0000000000..26d121c413 --- /dev/null +++ b/packages/twenty-front/src/pages/settings/applications/tabs/SettingsApplicationCustomTab.tsx @@ -0,0 +1,32 @@ +import styled from '@emotion/styled'; +import { Suspense, lazy } from 'react'; + +const FrontComponentRenderer = lazy(() => + import('@/front-components/components/FrontComponentRenderer').then( + (module) => ({ default: module.FrontComponentRenderer }), + ), +); + +const StyledContainer = styled.div` + height: 100%; + overflow: auto; + width: 100%; +`; + +type SettingsApplicationCustomTabProps = { + settingsCustomTabFrontComponentId: string; +}; + +export const SettingsApplicationCustomTab = ({ + settingsCustomTabFrontComponentId, +}: SettingsApplicationCustomTabProps) => { + return ( + + + + + + ); +}; diff --git a/packages/twenty-server/src/database/typeorm/core/migrations/common/1771840510113-add-settings-custom-tab-front-component-id-to-application.ts b/packages/twenty-server/src/database/typeorm/core/migrations/common/1771840510113-add-settings-custom-tab-front-component-id-to-application.ts new file mode 100644 index 0000000000..845fe9641b --- /dev/null +++ b/packages/twenty-server/src/database/typeorm/core/migrations/common/1771840510113-add-settings-custom-tab-front-component-id-to-application.ts @@ -0,0 +1,19 @@ +import { type MigrationInterface, type QueryRunner } from 'typeorm'; + +export class AddSettingsCustomTabFrontComponentIdToApplication1771840510113 + implements MigrationInterface +{ + name = 'AddSettingsCustomTabFrontComponentIdToApplication1771840510113'; + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query( + `ALTER TABLE "core"."application" ADD "settingsCustomTabFrontComponentId" uuid`, + ); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query( + `ALTER TABLE "core"."application" DROP COLUMN "settingsCustomTabFrontComponentId"`, + ); + } +} diff --git a/packages/twenty-server/src/engine/core-modules/application/application.entity.ts b/packages/twenty-server/src/engine/core-modules/application/application.entity.ts index d9e0bb940c..19928dc1e2 100644 --- a/packages/twenty-server/src/engine/core-modules/application/application.entity.ts +++ b/packages/twenty-server/src/engine/core-modules/application/application.entity.ts @@ -88,6 +88,9 @@ export class ApplicationEntity extends WorkspaceRelatedEntity { @Field(() => RoleDTO, { nullable: true }) defaultRole: RoleDTO | null; + @Column({ nullable: true, type: 'uuid' }) + settingsCustomTabFrontComponentId: string | null; + @Column({ nullable: false, type: 'boolean', default: true }) canBeUninstalled: boolean; diff --git a/packages/twenty-server/src/engine/core-modules/application/dtos/application.dto.ts b/packages/twenty-server/src/engine/core-modules/application/dtos/application.dto.ts index dcfe95a8a2..06958ab54f 100644 --- a/packages/twenty-server/src/engine/core-modules/application/dtos/application.dto.ts +++ b/packages/twenty-server/src/engine/core-modules/application/dtos/application.dto.ts @@ -73,6 +73,11 @@ export class ApplicationDTO { @Field({ nullable: true }) defaultRoleId?: string; + @IsOptional() + @IsUUID() + @Field(() => UUIDScalarType, { nullable: true }) + settingsCustomTabFrontComponentId?: string; + @IsOptional() @Field(() => RoleDTO, { nullable: true }) defaultLogicFunctionRole?: RoleDTO; diff --git a/packages/twenty-server/src/engine/core-modules/application/services/application-manifest-migration.service.ts b/packages/twenty-server/src/engine/core-modules/application/services/application-manifest-migration.service.ts index 139a279251..1c830aaf16 100644 --- a/packages/twenty-server/src/engine/core-modules/application/services/application-manifest-migration.service.ts +++ b/packages/twenty-server/src/engine/core-modules/application/services/application-manifest-migration.service.ts @@ -144,10 +144,12 @@ export class ApplicationManifestMigrationService { flatRoleMaps: refreshedFlatRoleMaps, flatObjectMetadataMaps: refreshedFlatObjectMetadataMaps, flatFieldMetadataMaps: refreshedFlatFieldMetadataMaps, + flatFrontComponentMaps: refreshedFlatFrontComponentMaps, } = await this.workspaceCacheService.getOrRecompute(workspaceId, [ 'flatRoleMaps', 'flatObjectMetadataMaps', 'flatFieldMetadataMaps', + 'flatFrontComponentMaps', ]); let defaultRoleId: string | null = null; @@ -181,11 +183,32 @@ export class ApplicationManifestMigrationService { } } - if (isDefined(defaultRoleId)) { - await this.applicationService.update(ownerFlatApplication.id, { - defaultRoleId, + let settingsCustomTabFrontComponentId: string | null = null; + + const settingsCustomTabUniversalIdentifier = + manifest.application.settingsCustomTabFrontComponentUniversalIdentifier; + + if (isDefined(settingsCustomTabUniversalIdentifier)) { + const flatFrontComponent = findFlatEntityByUniversalIdentifier({ + flatEntityMaps: refreshedFlatFrontComponentMaps, + universalIdentifier: settingsCustomTabUniversalIdentifier, }); + + if (!isDefined(flatFrontComponent)) { + throw new ApplicationException( + `Failed to resolve front component for settingsCustomTabFrontComponentUniversalIdentifier ${settingsCustomTabUniversalIdentifier}`, + ApplicationExceptionCode.ENTITY_NOT_FOUND, + ); + } + + settingsCustomTabFrontComponentId = flatFrontComponent.id; } + + await this.applicationService.update(ownerFlatApplication.id, { + workspaceId, + settingsCustomTabFrontComponentId, + ...(isDefined(defaultRoleId) ? { defaultRoleId } : {}), + }); } private async syncApplicationRolePermissions({ diff --git a/packages/twenty-server/src/engine/core-modules/application/utils/from-flat-application-to-application-dto.util.ts b/packages/twenty-server/src/engine/core-modules/application/utils/from-flat-application-to-application-dto.util.ts index 1124d497d8..87c699674d 100644 --- a/packages/twenty-server/src/engine/core-modules/application/utils/from-flat-application-to-application-dto.util.ts +++ b/packages/twenty-server/src/engine/core-modules/application/utils/from-flat-application-to-application-dto.util.ts @@ -13,6 +13,7 @@ export const fromFlatApplicationToApplicationDto = ({ availablePackages, universalIdentifier, version, + settingsCustomTabFrontComponentId, }: FlatApplication): ApplicationDTO => { return { canBeUninstalled, @@ -27,5 +28,7 @@ export const fromFlatApplicationToApplicationDto = ({ availablePackages: availablePackages ?? {}, universalIdentifier, version: version ?? undefined, + settingsCustomTabFrontComponentId: + settingsCustomTabFrontComponentId ?? undefined, }; }; diff --git a/packages/twenty-server/src/engine/metadata-modules/flat-field-metadata/__tests__/morph-relation-from-create-field-input-to-flat-field-metadatas-to-create.spec.ts b/packages/twenty-server/src/engine/metadata-modules/flat-field-metadata/__tests__/morph-relation-from-create-field-input-to-flat-field-metadatas-to-create.spec.ts index c61aa5cfe8..cf38a96ec7 100644 --- a/packages/twenty-server/src/engine/metadata-modules/flat-field-metadata/__tests__/morph-relation-from-create-field-input-to-flat-field-metadatas-to-create.spec.ts +++ b/packages/twenty-server/src/engine/metadata-modules/flat-field-metadata/__tests__/morph-relation-from-create-field-input-to-flat-field-metadatas-to-create.spec.ts @@ -36,6 +36,7 @@ const MOCK_FLAT_APPLICATION: FlatApplication = { logicFunctionLayerId: null, defaultRoleId: null, defaultRole: null, + settingsCustomTabFrontComponentId: null, canBeUninstalled: false, createdAt: new Date(), updatedAt: new Date(), diff --git a/packages/twenty-server/src/engine/metadata-modules/flat-field-metadata/utils/__tests__/generate-morph-or-relation-flat-field-metadata-pair.spec.ts b/packages/twenty-server/src/engine/metadata-modules/flat-field-metadata/utils/__tests__/generate-morph-or-relation-flat-field-metadata-pair.spec.ts index 2d79c2ef03..a1c0fec51f 100644 --- a/packages/twenty-server/src/engine/metadata-modules/flat-field-metadata/utils/__tests__/generate-morph-or-relation-flat-field-metadata-pair.spec.ts +++ b/packages/twenty-server/src/engine/metadata-modules/flat-field-metadata/utils/__tests__/generate-morph-or-relation-flat-field-metadata-pair.spec.ts @@ -31,6 +31,7 @@ const MOCK_FLAT_APPLICATION: FlatApplication = { logicFunctionLayerId: null, defaultRoleId: null, defaultRole: null, + settingsCustomTabFrontComponentId: null, canBeUninstalled: false, createdAt: new Date(), updatedAt: new Date(), diff --git a/packages/twenty-shared/src/application/applicationType.ts b/packages/twenty-shared/src/application/applicationType.ts index e5fd0aa808..b898e03cd8 100644 --- a/packages/twenty-shared/src/application/applicationType.ts +++ b/packages/twenty-shared/src/application/applicationType.ts @@ -21,6 +21,7 @@ export type ApplicationManifest = SyncableEntityOptions & { marketplaceData?: ApplicationMarketplaceData; preInstallLogicFunctionUniversalIdentifier?: string; postInstallLogicFunctionUniversalIdentifier?: string; + settingsCustomTabFrontComponentUniversalIdentifier?: string; packageJsonChecksum: string | null; yarnLockChecksum: string | null; apiClientChecksum: string | null;