From 4f9fd6f67447c6ff1ca3d2ad0d9aae6f37645fb7 Mon Sep 17 00:00:00 2001 From: martmull Date: Mon, 27 Jul 2026 08:52:04 +0200 Subject: [PATCH] feat(applications): restore the application custom settings tab (#23256) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Summary Restores the application **custom settings tab** feature that was removed in #22156. This reverts that removal so applications can again expose a custom settings tab via a front component. ## Changes - Restore the `SettingsApplicationCustomTab` component and its tab entry/rendering in `SettingsApplicationDetails`. - `ApplicationManifestMigrationService` syncs `settingsCustomTabFrontComponent` from application manifests again (`syncDefaultRoleAndSettingsCustomTab`), resolving the front component from `settingsCustomTabFrontComponentUniversalIdentifier`. - Remove the deprecation annotations added by #22156: - `ApplicationDTO.settingsCustomTabFrontComponentId` (drop GraphQL `@deprecated`) - `ApplicationManifest.settingsCustomTabFrontComponentUniversalIdentifier` - the `settingsCustomTabFrontComponentId` column comment on `ApplicationEntity` - Regenerate the corresponding GraphQL schema/types to drop the `@deprecated` reason. The DB column was never dropped, so no schema migration is required. --- _Generated by [Claude Code](https://claude.ai/code/session_01A6aoLa5kZjba9C3uwo6nay)_ Review in cubic --------- Co-authored-by: Félix Malfait --- .../src/metadata/generated/schema.graphql | 2 +- .../src/metadata/generated/schema.ts | 2 - .../extend/apps/config/application.mdx | 1 + .../extend/apps/layout/front-components.mdx | 34 +++++++++- .../extend/apps/layout/overview.mdx | 1 + .../src/generated-metadata/graphql.ts | 1 - .../SettingsApplicationDetails.tsx | 4 ++ ...ttingsApplicationCustomSettingsSection.tsx | 38 ++++++++++++ .../SettingsApplicationDetailSettingsTab.tsx | 36 +++++++---- ...stub-twenty-sdk-define.plugin.spec.ts.snap | 1 + .../__tests__/manifest-validate.spec.ts | 26 ++++++++ .../build/manifest/manifest-build.ts | 23 +++++++ .../build/manifest/manifest-extract-config.ts | 3 + .../build/manifest/manifest-validate.ts | 3 +- .../define/application/application-config.ts | 1 + .../define-settings-front-component.spec.ts | 62 +++++++++++++++++++ .../define-settings-front-component.ts | 24 +++++++ .../settings-front-component-config.ts | 8 +++ packages/twenty-sdk/src/sdk/define/index.ts | 2 + .../application-manifest-migration.service.ts | 43 ++++++++++--- ...-to-universal-flat-front-component.util.ts | 7 ++- ...-all-universal-flat-entity-maps.service.ts | 6 ++ .../application/application.entity.ts | 5 -- .../application/dtos/application.dto.ts | 10 +-- .../src/application/applicationType.ts | 7 ++- .../twenty-shared/src/application/index.ts | 1 + .../settingsFrontComponentApplicationType.ts | 3 + 27 files changed, 307 insertions(+), 47 deletions(-) create mode 100644 packages/twenty-front/src/pages/settings/applications/tabs/SettingsApplicationCustomSettingsSection.tsx create mode 100644 packages/twenty-sdk/src/sdk/define/front-component/__tests__/define-settings-front-component.spec.ts create mode 100644 packages/twenty-sdk/src/sdk/define/front-component/define-settings-front-component.ts create mode 100644 packages/twenty-sdk/src/sdk/define/front-component/settings-front-component-config.ts create mode 100644 packages/twenty-shared/src/application/settingsFrontComponentApplicationType.ts diff --git a/packages/twenty-client-sdk/src/metadata/generated/schema.graphql b/packages/twenty-client-sdk/src/metadata/generated/schema.graphql index 63cd099d1e..d465a1c03c 100644 --- a/packages/twenty-client-sdk/src/metadata/generated/schema.graphql +++ b/packages/twenty-client-sdk/src/metadata/generated/schema.graphql @@ -592,7 +592,7 @@ type Application { canBeUninstalled: Boolean! autoUpgrade: Boolean! defaultRoleId: String - settingsCustomTabFrontComponentId: UUID @deprecated(reason: "Custom settings tabs are no longer supported. This field is ignored.") + settingsCustomTabFrontComponentId: UUID defaultLogicFunctionRole: Role agents: [Agent!]! frontComponents: [FrontComponent!]! diff --git a/packages/twenty-client-sdk/src/metadata/generated/schema.ts b/packages/twenty-client-sdk/src/metadata/generated/schema.ts index 11f21c72fc..8b68d86075 100644 --- a/packages/twenty-client-sdk/src/metadata/generated/schema.ts +++ b/packages/twenty-client-sdk/src/metadata/generated/schema.ts @@ -405,7 +405,6 @@ export interface Application { canBeUninstalled: Scalars['Boolean'] autoUpgrade: Scalars['Boolean'] defaultRoleId?: Scalars['String'] - /** @deprecated Custom settings tabs are no longer supported. This field is ignored. */ settingsCustomTabFrontComponentId?: Scalars['UUID'] defaultLogicFunctionRole?: Role agents: Agent[] @@ -3531,7 +3530,6 @@ export interface ApplicationGenqlSelection{ canBeUninstalled?: boolean | number autoUpgrade?: boolean | number defaultRoleId?: boolean | number - /** @deprecated Custom settings tabs are no longer supported. This field is ignored. */ settingsCustomTabFrontComponentId?: boolean | number defaultLogicFunctionRole?: RoleGenqlSelection agents?: AgentGenqlSelection diff --git a/packages/twenty-docs/developers/extend/apps/config/application.mdx b/packages/twenty-docs/developers/extend/apps/config/application.mdx index 6c5e0b46b1..a12261f868 100644 --- a/packages/twenty-docs/developers/extend/apps/config/application.mdx +++ b/packages/twenty-docs/developers/extend/apps/config/application.mdx @@ -36,6 +36,7 @@ Notes: - Pre-install, post-install, and uninstall functions are detected automatically during the manifest build — you do not need to reference them in `defineApplication()`. - Passing `defaultRoleUniversalIdentifier` explicitly is still supported for backward compatibility, but is deprecated in favor of `defineApplicationRole()`. - `serverVariables` are instance-scoped configuration and secrets (e.g. API keys). Unlike `applicationVariables`, they declare no value in the manifest — the workspace operator fills them in from the app's settings, and they are injected into logic functions only once set. +- To render a custom configuration UI inside the app's **Settings** tab (in place of the default variable configuration section), declare a front component with [`defineSettingsFrontComponent()`](/developers/extend/apps/layout/front-components#custom-settings-component) in its own file. Only one is allowed per app. System-managed sections (auto-upgrade, App URL, connections) always remain visible. ## Variable types diff --git a/packages/twenty-docs/developers/extend/apps/layout/front-components.mdx b/packages/twenty-docs/developers/extend/apps/layout/front-components.mdx index 7cd31a71ff..b6244699f0 100644 --- a/packages/twenty-docs/developers/extend/apps/layout/front-components.mdx +++ b/packages/twenty-docs/developers/extend/apps/layout/front-components.mdx @@ -8,15 +8,17 @@ Front components are React components that render directly inside Twenty's UI. T ## Where front components can be used -Front components can render in two locations within Twenty: +Front components can render in three locations within Twenty: - **Side panel** — Non-headless front components open in the right-hand side panel. This is the default behavior when a front component is triggered from the command menu. - **Widgets (dashboards and record pages)** — Front components can be embedded as widgets inside [page layouts](/developers/extend/apps/layout/page-layouts). When configuring a dashboard or a record page layout, users can add a front component widget. +- **App settings** — Defined with [`defineSettingsFrontComponent()`](#custom-settings-component), the front component renders as a section inside the app's **Settings** tab, in place of the default variable configuration UI. -A front component on its own isn't reachable from the UI — you need to *surface* it. The two ways to do that are: +A front component on its own isn't reachable from the UI — you need to *surface* it. The three ways to do that are: - **Pair it with a [command menu item](/developers/extend/apps/layout/command-menu-items)** — registers it in the command menu (Cmd+K) and, optionally, as a pinned quick-action. - **Embed it as a widget in a [page layout](/developers/extend/apps/layout/page-layouts)** — places it on a record's detail page or dashboard. +- **Define it with [`defineSettingsFrontComponent()`](#custom-settings-component)** — renders it as a section inside the app's **Settings** tab, in place of the default variable configuration UI. ## Basic example @@ -77,6 +79,34 @@ Click it to render the component inline. Beyond commands, you can embed a front component directly into a record page by adding it as a widget in a **page layout**. See [Page Layouts](/developers/extend/apps/layout/page-layouts) for details. +## Custom settings component + +To replace the auto-generated variable configuration UI in your app's **Settings** tab with your own component, define it with `defineSettingsFrontComponent` instead of `defineFrontComponent`. It takes the same [configuration fields](#configuration-fields) (except `isHeadless`, which is not accepted since a settings component always renders visible UI) and additionally marks the component as the app's settings UI. + +The component renders as a section **inside** the Settings tab, not as a replacement for the whole tab. Twenty's system-managed sections — auto-upgrade, App URL, and connections — always render above it and cannot be overridden by the app. + +```tsx src/front-components/app-settings.tsx +import { defineSettingsFrontComponent } from 'twenty-sdk/define'; + +const AppSettings = () => { + return ( +
+

My app settings

+ {/* render your own configuration UI here */} +
+ ); +}; + +export default defineSettingsFrontComponent({ + universalIdentifier: 'a1b2c3d4-e5f6-7890-abcd-ef1234567890', + name: 'app-settings', + description: "Custom UI for the app's Settings tab", + component: AppSettings, +}); +``` + +Only one settings front component is allowed per app; declaring more than one fails the build. When present, the app's **Settings** tab renders this component in place of the default variable configuration UI. + ## Headless vs non-headless Front components come in two rendering modes controlled by the `isHeadless` option: diff --git a/packages/twenty-docs/developers/extend/apps/layout/overview.mdx b/packages/twenty-docs/developers/extend/apps/layout/overview.mdx index bb92c9c390..4ff017e4ae 100644 --- a/packages/twenty-docs/developers/extend/apps/layout/overview.mdx +++ b/packages/twenty-docs/developers/extend/apps/layout/overview.mdx @@ -51,6 +51,7 @@ A Twenty app's **layout layer** is everything the user sees: where the app surfa | **Record list** | A saved configuration for an object — visible columns, order, filters, groups | `defineView` | | **Record detail page** | The tabs and widgets on a record page (your own object's, or a standard one) | `definePageLayout`, `definePageLayoutTab` | | **Inside any of the above** | A custom React widget — buttons, forms, dashboards, integrations | `defineFrontComponent` | +| **App settings** | A custom configuration section inside the app's Settings tab, in place of the default variables UI | `defineSettingsFrontComponent` | | **Command menu (Cmd+K)** | A pinned quick action or hidden command | `defineCommandMenuItem` | Front components run inside an isolated Web Worker using Remote DOM — they render *natively* in the page (not inside an iframe), but cannot reach the host page or DOM directly. Communication with Twenty happens through a message-passing host API. diff --git a/packages/twenty-front/src/generated-metadata/graphql.ts b/packages/twenty-front/src/generated-metadata/graphql.ts index 57a53965e3..dddc94b764 100644 --- a/packages/twenty-front/src/generated-metadata/graphql.ts +++ b/packages/twenty-front/src/generated-metadata/graphql.ts @@ -315,7 +315,6 @@ export type Application = { objects: Array; packageJsonChecksum?: Maybe; packageJsonFileId?: Maybe; - /** @deprecated Custom settings tabs are no longer supported. This field is ignored. */ settingsCustomTabFrontComponentId?: Maybe; universalIdentifier: Scalars['String']['output']; version?: Maybe; diff --git a/packages/twenty-front/src/pages/settings/applications/SettingsApplicationDetails.tsx b/packages/twenty-front/src/pages/settings/applications/SettingsApplicationDetails.tsx index e66523a7de..8f8c81c48d 100644 --- a/packages/twenty-front/src/pages/settings/applications/SettingsApplicationDetails.tsx +++ b/packages/twenty-front/src/pages/settings/applications/SettingsApplicationDetails.tsx @@ -252,10 +252,14 @@ export const SettingsApplicationDetails = () => { const hasHttpTriggeredFunctions = applicationHasHttpTriggeredFunctions(application); const canShowFunctionDomain = hasHttpTriggeredFunctions; + const hasSettingsFrontComponent = isDefined( + application?.settingsCustomTabFrontComponentId, + ); const hasNothingToConfigure = !hasVariables && !hasConnectionProviders && !canShowFunctionDomain && + !hasSettingsFrontComponent && !isUpgradableApplicationSourceType(sourceType); return { diff --git a/packages/twenty-front/src/pages/settings/applications/tabs/SettingsApplicationCustomSettingsSection.tsx b/packages/twenty-front/src/pages/settings/applications/tabs/SettingsApplicationCustomSettingsSection.tsx new file mode 100644 index 0000000000..f8c76fa309 --- /dev/null +++ b/packages/twenty-front/src/pages/settings/applications/tabs/SettingsApplicationCustomSettingsSection.tsx @@ -0,0 +1,38 @@ +import { styled } from '@linaria/react'; +import { Suspense, lazy } from 'react'; +import { Section } from 'twenty-ui/layout'; + +import { FrontComponentSkeletonLoader } from '@/front-components/components/FrontComponentSkeletonLoader'; + +const FrontComponentRenderer = lazy(() => + import('@/front-components/components/FrontComponentRenderer').then( + (module) => ({ default: module.FrontComponentRenderer }), + ), +); + +const StyledRendererContainer = styled.div` + display: flex; + min-height: 400px; + width: 100%; +`; + +type SettingsApplicationCustomSettingsSectionProps = { + frontComponentId: string; +}; + +export const SettingsApplicationCustomSettingsSection = ({ + frontComponentId, +}: SettingsApplicationCustomSettingsSectionProps) => { + return ( +
+ + }> + } + /> + + +
+ ); +}; diff --git a/packages/twenty-front/src/pages/settings/applications/tabs/SettingsApplicationDetailSettingsTab.tsx b/packages/twenty-front/src/pages/settings/applications/tabs/SettingsApplicationDetailSettingsTab.tsx index 539de69a98..8b7f3a8de4 100644 --- a/packages/twenty-front/src/pages/settings/applications/tabs/SettingsApplicationDetailSettingsTab.tsx +++ b/packages/twenty-front/src/pages/settings/applications/tabs/SettingsApplicationDetailSettingsTab.tsx @@ -1,6 +1,8 @@ +import { isDefined } from 'twenty-shared/utils'; import { type Application } from '~/generated-metadata/graphql'; import { useUpdateOneApplicationVariable } from '~/pages/settings/applications/hooks/useUpdateOneApplicationVariable'; import { SettingsApplicationConnectionsSection } from '~/pages/settings/applications/tabs/SettingsApplicationConnectionsSection'; +import { SettingsApplicationCustomSettingsSection } from '~/pages/settings/applications/tabs/SettingsApplicationCustomSettingsSection'; import { SettingsApplicationDetailEnvironmentVariablesTable } from '~/pages/settings/applications/tabs/SettingsApplicationDetailEnvironmentVariablesTable'; import { SettingsApplicationFunctionDomainSection } from '~/pages/settings/applications/tabs/SettingsApplicationFunctionDomainSection'; import { SettingsApplicationGeneralSection } from '~/pages/settings/applications/tabs/SettingsApplicationGeneralSection'; @@ -19,6 +21,7 @@ export const SettingsApplicationDetailSettingsTab = ({ | 'autoUpgrade' | 'applicationRegistration' | 'logicFunctions' + | 'settingsCustomTabFrontComponentId' >; }) => { const { updateOneApplicationVariable } = useUpdateOneApplicationVariable(); @@ -34,6 +37,9 @@ export const SettingsApplicationDetailSettingsTab = ({ application?.applicationRegistration?.sourceType, ); + const settingsFrontComponentId = + application?.settingsCustomTabFrontComponentId; + return ( <> {isUpgradable && application?.id && ( @@ -50,18 +56,24 @@ export const SettingsApplicationDetailSettingsTab = ({ {application?.id && ( )} - - application?.id - ? updateOneApplicationVariable({ - key, - value, - applicationId: application.id, - }) - : null - } - /> + {isDefined(settingsFrontComponentId) ? ( + + ) : ( + + application?.id + ? updateOneApplicationVariable({ + key, + value, + applicationId: application.id, + }) + : null + } + /> + )} ); }; diff --git a/packages/twenty-sdk/src/cli/utilities/build/common/plugins/__tests__/__snapshots__/stub-twenty-sdk-define.plugin.spec.ts.snap b/packages/twenty-sdk/src/cli/utilities/build/common/plugins/__tests__/__snapshots__/stub-twenty-sdk-define.plugin.spec.ts.snap index 27fc529f27..0c7f2dd594 100644 --- a/packages/twenty-sdk/src/cli/utilities/build/common/plugins/__tests__/__snapshots__/stub-twenty-sdk-define.plugin.spec.ts.snap +++ b/packages/twenty-sdk/src/cli/utilities/build/common/plugins/__tests__/__snapshots__/stub-twenty-sdk-define.plugin.spec.ts.snap @@ -84,6 +84,7 @@ exports[`stub-twenty-sdk-define plugin > matches the recorded export partition 1 "definePostInstallLogicFunction", "definePreInstallLogicFunction", "defineRole", + "defineSettingsFrontComponent", "defineSkill", "defineUninstallLogicFunction", "defineView", diff --git a/packages/twenty-sdk/src/cli/utilities/build/manifest/__tests__/manifest-validate.spec.ts b/packages/twenty-sdk/src/cli/utilities/build/manifest/__tests__/manifest-validate.spec.ts index 0252f51d47..83b913a80d 100644 --- a/packages/twenty-sdk/src/cli/utilities/build/manifest/__tests__/manifest-validate.spec.ts +++ b/packages/twenty-sdk/src/cli/utilities/build/manifest/__tests__/manifest-validate.spec.ts @@ -227,6 +227,32 @@ describe('manifestValidate', () => { expect(result.isValid).toBe(true); expect(result.errors).toHaveLength(0); }); + + it('should not flag a front component referenced via settingsFrontComponent as a duplicate', () => { + const frontComponentId = '550e8400-e29b-41d4-a716-446655440050'; + + const frontComponent = { + universalIdentifier: frontComponentId, + name: 'app-settings', + componentName: 'AppSettings', + sourceComponentPath: 'src/front-components/app-settings.tsx', + builtComponentPath: 'dist/app-settings.mjs', + builtComponentChecksum: '00000000-0000-4000-8000-000000000000', + isHeadless: false, + } as unknown as Manifest['frontComponents'][number]; + + const result = manifestValidate({ + ...validManifest, + application: { + ...validApplication, + settingsFrontComponent: { universalIdentifier: frontComponentId }, + }, + frontComponents: [frontComponent], + }); + + expect(result.isValid).toBe(true); + expect(result.errors).toHaveLength(0); + }); }); describe('relation field validation', () => { diff --git a/packages/twenty-sdk/src/cli/utilities/build/manifest/manifest-build.ts b/packages/twenty-sdk/src/cli/utilities/build/manifest/manifest-build.ts index fabfa7b04d..ab3cef101d 100644 --- a/packages/twenty-sdk/src/cli/utilities/build/manifest/manifest-build.ts +++ b/packages/twenty-sdk/src/cli/utilities/build/manifest/manifest-build.ts @@ -122,6 +122,7 @@ export const buildManifest = async ( []; const uninstallLogicFunctions: UninstallLogicFunctionApplicationManifest[] = []; + const settingsFrontComponentUniversalIdentifiers: string[] = []; const applicationRoleUniversalIdentifiers: string[] = []; const applicationFilePaths: string[] = []; const objectsFilePaths: string[] = []; @@ -377,6 +378,14 @@ export const buildManifest = async ( frontComponents.push(config); frontComponentsFilePaths.push(relativePath); + if ( + targetFunctionName === TargetFunction.DefineSettingsFrontComponent + ) { + settingsFrontComponentUniversalIdentifiers.push( + extract.config.universalIdentifier, + ); + } + break; } case ManifestEntityKey.Views: { @@ -559,6 +568,12 @@ export const buildManifest = async ( ); } + if (settingsFrontComponentUniversalIdentifiers.length > 1) { + errors.push( + 'Only one settings front component is allowed per application', + ); + } + if (applicationRoleUniversalIdentifiers.length > 1) { errors.push('Only one defineApplicationRole is allowed per application'); } @@ -611,6 +626,14 @@ export const buildManifest = async ( ...(uninstallLogicFunctions.length >= 1 ? { uninstallLogicFunction: uninstallLogicFunctions[0] } : {}), + ...(settingsFrontComponentUniversalIdentifiers.length >= 1 + ? { + settingsFrontComponent: { + universalIdentifier: + settingsFrontComponentUniversalIdentifiers[0], + }, + } + : {}), }; })() : undefined; diff --git a/packages/twenty-sdk/src/cli/utilities/build/manifest/manifest-extract-config.ts b/packages/twenty-sdk/src/cli/utilities/build/manifest/manifest-extract-config.ts index 3e4cf8496a..6f7135b4c6 100644 --- a/packages/twenty-sdk/src/cli/utilities/build/manifest/manifest-extract-config.ts +++ b/packages/twenty-sdk/src/cli/utilities/build/manifest/manifest-extract-config.ts @@ -16,6 +16,7 @@ export enum TargetFunction { DefineAgent = 'defineAgent', DefineConnectionProvider = 'defineConnectionProvider', DefineFrontComponent = 'defineFrontComponent', + DefineSettingsFrontComponent = 'defineSettingsFrontComponent', DefineView = 'defineView', DefineViewField = 'defineViewField', DefineNavigationMenuItem = 'defineNavigationMenuItem', @@ -70,6 +71,8 @@ export const TARGET_FUNCTION_TO_ENTITY_KEY_MAPPING: Record< [TargetFunction.DefineConnectionProvider]: ManifestEntityKey.ConnectionProviders, [TargetFunction.DefineFrontComponent]: ManifestEntityKey.FrontComponents, + [TargetFunction.DefineSettingsFrontComponent]: + ManifestEntityKey.FrontComponents, [TargetFunction.DefineView]: ManifestEntityKey.Views, [TargetFunction.DefineViewField]: ManifestEntityKey.ViewFields, [TargetFunction.DefineNavigationMenuItem]: diff --git a/packages/twenty-sdk/src/cli/utilities/build/manifest/manifest-validate.ts b/packages/twenty-sdk/src/cli/utilities/build/manifest/manifest-validate.ts index 3eccadc26f..498e45d837 100644 --- a/packages/twenty-sdk/src/cli/utilities/build/manifest/manifest-validate.ts +++ b/packages/twenty-sdk/src/cli/utilities/build/manifest/manifest-validate.ts @@ -74,7 +74,8 @@ const findUniversalIdentifiers = (obj: object): string[] => { key === 'postInstallLogicFunction' || key === 'preInstallLogicFunction' || key === 'uninstallLogicFunction' || - key === 'onConnectLogicFunction' + key === 'onConnectLogicFunction' || + key === 'settingsFrontComponent' ) { continue; } diff --git a/packages/twenty-sdk/src/sdk/define/application/application-config.ts b/packages/twenty-sdk/src/sdk/define/application/application-config.ts index 1ea4747979..7124afedc6 100644 --- a/packages/twenty-sdk/src/sdk/define/application/application-config.ts +++ b/packages/twenty-sdk/src/sdk/define/application/application-config.ts @@ -7,6 +7,7 @@ export type ApplicationConfig = Omit< | 'requiredServerVersionRange' | 'postInstallLogicFunction' | 'preInstallLogicFunction' + | 'settingsFrontComponent' | 'defaultRoleUniversalIdentifier' | 'aboutDescription' > & { diff --git a/packages/twenty-sdk/src/sdk/define/front-component/__tests__/define-settings-front-component.spec.ts b/packages/twenty-sdk/src/sdk/define/front-component/__tests__/define-settings-front-component.spec.ts new file mode 100644 index 0000000000..d78a614dee --- /dev/null +++ b/packages/twenty-sdk/src/sdk/define/front-component/__tests__/define-settings-front-component.spec.ts @@ -0,0 +1,62 @@ +import { defineSettingsFrontComponent } from '@/sdk/define'; + +const MockComponent = () => null; + +describe('defineSettingsFrontComponent', () => { + const validConfig = { + universalIdentifier: 'e56d363b-0bdc-4d8a-a393-6f0d1c75bdcf', + name: 'App Settings', + component: MockComponent, + }; + + it('should return successful validation result when valid', () => { + const result = defineSettingsFrontComponent(validConfig); + + expect(result.success).toBe(true); + expect(result.config).toEqual(validConfig); + expect(result.errors).toEqual([]); + }); + + it('should return error when universalIdentifier is missing', () => { + const config = { + name: 'App Settings', + component: MockComponent, + }; + + const result = defineSettingsFrontComponent(config as any); + + expect(result.success).toBe(false); + expect(result.errors).toContain( + 'Settings front component must have a universalIdentifier', + ); + }); + + it('should return error when component is missing', () => { + const config = { + universalIdentifier: 'e56d363b-0bdc-4d8a-a393-6f0d1c75bdcf', + name: 'App Settings', + }; + + const result = defineSettingsFrontComponent(config as any); + + expect(result.success).toBe(false); + expect(result.errors).toEqual([ + 'Settings front component must have a component', + ]); + }); + + it('should return error when component is not a function', () => { + const config = { + universalIdentifier: 'e56d363b-0bdc-4d8a-a393-6f0d1c75bdcf', + name: 'App Settings', + component: 'not-a-function', + }; + + const result = defineSettingsFrontComponent(config as any); + + expect(result.success).toBe(false); + expect(result.errors).toContain( + 'Settings front component component must be a React component', + ); + }); +}); diff --git a/packages/twenty-sdk/src/sdk/define/front-component/define-settings-front-component.ts b/packages/twenty-sdk/src/sdk/define/front-component/define-settings-front-component.ts new file mode 100644 index 0000000000..e5b16df89e --- /dev/null +++ b/packages/twenty-sdk/src/sdk/define/front-component/define-settings-front-component.ts @@ -0,0 +1,24 @@ +import { createValidationResult } from '@/sdk/define/common/utils/create-validation-result'; +import type { DefineEntity } from '@/sdk/define/common/types/define-entity.type'; +import { type SettingsFrontComponentConfig } from '@/sdk/define/front-component/settings-front-component-config'; + +export const defineSettingsFrontComponent: DefineEntity< + SettingsFrontComponentConfig +> = (config) => { + const errors = []; + + if (!config.universalIdentifier) { + errors.push('Settings front component must have a universalIdentifier'); + } + + if (!config.component) { + errors.push('Settings front component must have a component'); + } else if (typeof config.component !== 'function') { + errors.push('Settings front component component must be a React component'); + } + + return createValidationResult({ + config, + errors, + }); +}; diff --git a/packages/twenty-sdk/src/sdk/define/front-component/settings-front-component-config.ts b/packages/twenty-sdk/src/sdk/define/front-component/settings-front-component-config.ts new file mode 100644 index 0000000000..85efc7a272 --- /dev/null +++ b/packages/twenty-sdk/src/sdk/define/front-component/settings-front-component-config.ts @@ -0,0 +1,8 @@ +import { type FrontComponentConfig } from '@/sdk/define/front-component/front-component-config'; + +// A settings front component always renders visible UI, so `isHeadless` +// is not configurable. +export type SettingsFrontComponentConfig = Omit< + FrontComponentConfig, + 'isHeadless' +>; diff --git a/packages/twenty-sdk/src/sdk/define/index.ts b/packages/twenty-sdk/src/sdk/define/index.ts index 82bd10881e..88be5b6152 100644 --- a/packages/twenty-sdk/src/sdk/define/index.ts +++ b/packages/twenty-sdk/src/sdk/define/index.ts @@ -70,10 +70,12 @@ export { } from '@/sdk/define/conditional-availability/conditional-availability-variables'; export { defineFrontComponent } from '@/sdk/define/front-component/define-front-component'; +export { defineSettingsFrontComponent } from '@/sdk/define/front-component/define-settings-front-component'; export type { FrontComponentConfig, FrontComponentType, } from '@/sdk/define/front-component/front-component-config'; +export type { SettingsFrontComponentConfig } from '@/sdk/define/front-component/settings-front-component-config'; export { defineIndex } from '@/sdk/define/indexes/define-index'; export type { IndexConfig } from '@/sdk/define/indexes/index-config'; diff --git a/packages/twenty-server/src/engine/core-modules/application/application-manifest/application-manifest-migration.service.ts b/packages/twenty-server/src/engine/core-modules/application/application-manifest/application-manifest-migration.service.ts index 23ec8afd45..d9df8780bc 100644 --- a/packages/twenty-server/src/engine/core-modules/application/application-manifest/application-manifest-migration.service.ts +++ b/packages/twenty-server/src/engine/core-modules/application/application-manifest/application-manifest-migration.service.ts @@ -249,7 +249,7 @@ export class ApplicationManifestMigrationService { ); if (!dryRun) { - await this.syncDefaultRole({ + await this.syncDefaultRoleAndSettingsFrontComponent({ manifest, workspaceId, ownerFlatApplication, @@ -262,7 +262,7 @@ export class ApplicationManifestMigrationService { }; } - private async syncDefaultRole({ + private async syncDefaultRoleAndSettingsFrontComponent({ manifest, workspaceId, ownerFlatApplication, @@ -271,10 +271,13 @@ export class ApplicationManifestMigrationService { workspaceId: string; ownerFlatApplication: FlatApplication; }) { - const { flatRoleMaps: refreshedFlatRoleMaps } = - await this.workspaceCacheService.getOrRecompute(workspaceId, [ - 'flatRoleMaps', - ]); + const { + flatRoleMaps: refreshedFlatRoleMaps, + flatFrontComponentMaps: refreshedFlatFrontComponentMaps, + } = await this.workspaceCacheService.getOrRecompute(workspaceId, [ + 'flatRoleMaps', + 'flatFrontComponentMaps', + ]); let defaultRoleId: string | null = null; @@ -299,11 +302,31 @@ export class ApplicationManifestMigrationService { } } - if (isDefined(defaultRoleId)) { - await this.applicationService.update(ownerFlatApplication.id, { - workspaceId, - defaultRoleId, + let settingsCustomTabFrontComponentId: string | null = null; + + const settingsFrontComponentUniversalIdentifier = + manifest.application.settingsFrontComponent?.universalIdentifier; + + if (isDefined(settingsFrontComponentUniversalIdentifier)) { + const flatFrontComponent = findFlatEntityByUniversalIdentifier({ + flatEntityMaps: refreshedFlatFrontComponentMaps, + universalIdentifier: settingsFrontComponentUniversalIdentifier, }); + + if (!isDefined(flatFrontComponent)) { + throw new ApplicationException( + `Failed to resolve front component for settings front component universalIdentifier ${settingsFrontComponentUniversalIdentifier}`, + ApplicationExceptionCode.ENTITY_NOT_FOUND, + ); + } + + settingsCustomTabFrontComponentId = flatFrontComponent.id; } + + await this.applicationService.update(ownerFlatApplication.id, { + workspaceId, + settingsCustomTabFrontComponentId, + ...(isDefined(defaultRoleId) ? { defaultRoleId } : {}), + }); } } diff --git a/packages/twenty-server/src/engine/core-modules/application/application-manifest/converters/from-front-component-manifest-to-universal-flat-front-component.util.ts b/packages/twenty-server/src/engine/core-modules/application/application-manifest/converters/from-front-component-manifest-to-universal-flat-front-component.util.ts index be476be591..c42d7f535b 100644 --- a/packages/twenty-server/src/engine/core-modules/application/application-manifest/converters/from-front-component-manifest-to-universal-flat-front-component.util.ts +++ b/packages/twenty-server/src/engine/core-modules/application/application-manifest/converters/from-front-component-manifest-to-universal-flat-front-component.util.ts @@ -5,10 +5,12 @@ import { type UniversalFlatFrontComponent } from 'src/engine/workspace-manager/w export const fromFrontComponentManifestToUniversalFlatFrontComponent = ({ frontComponentManifest, applicationUniversalIdentifier, + isSettingsFrontComponent, now, }: { frontComponentManifest: FrontComponentManifest; applicationUniversalIdentifier: string; + isSettingsFrontComponent: boolean; now: string; }): UniversalFlatFrontComponent => { return { @@ -20,7 +22,10 @@ export const fromFrontComponentManifestToUniversalFlatFrontComponent = ({ builtComponentPath: frontComponentManifest.builtComponentPath, componentName: frontComponentManifest.componentName, builtComponentChecksum: frontComponentManifest.builtComponentChecksum, - isHeadless: frontComponentManifest.isHeadless ?? false, + // A settings front component always renders visible UI. + isHeadless: isSettingsFrontComponent + ? false + : (frontComponentManifest.isHeadless ?? false), usesSdkClient: frontComponentManifest.usesSdkClient ?? false, createdAt: now, updatedAt: now, diff --git a/packages/twenty-server/src/engine/core-modules/application/application-manifest/services/compute-application-manifest-all-universal-flat-entity-maps.service.ts b/packages/twenty-server/src/engine/core-modules/application/application-manifest/services/compute-application-manifest-all-universal-flat-entity-maps.service.ts index 7fb43b9476..cdf2fb5e18 100644 --- a/packages/twenty-server/src/engine/core-modules/application/application-manifest/services/compute-application-manifest-all-universal-flat-entity-maps.service.ts +++ b/packages/twenty-server/src/engine/core-modules/application/application-manifest/services/compute-application-manifest-all-universal-flat-entity-maps.service.ts @@ -215,12 +215,18 @@ export class ComputeApplicationManifestAllUniversalFlatEntityMapsService { }); } + const settingsFrontComponentUniversalIdentifier = + manifest.application.settingsFrontComponent?.universalIdentifier; + for (const frontComponentManifest of manifest.frontComponents) { addUniversalFlatEntityToUniversalFlatEntityMapsThroughMutationOrThrow({ universalFlatEntity: fromFrontComponentManifestToUniversalFlatFrontComponent({ frontComponentManifest, applicationUniversalIdentifier, + isSettingsFrontComponent: + frontComponentManifest.universalIdentifier === + settingsFrontComponentUniversalIdentifier, now, }), universalFlatEntityMapsToMutate: 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 9975c20b33..6e648449c8 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 @@ -113,11 +113,6 @@ export class ApplicationEntity extends WorkspaceRelatedEntity { @Field(() => RoleDTO, { nullable: true }) defaultRole: RoleDTO | null; - /** - * @deprecated Custom settings tabs are no longer supported. The column is - * kept (not dropped) so existing installations upgrade cleanly, but the - * value is no longer read or synced from manifests. - */ @Column({ nullable: true, type: 'uuid' }) settingsCustomTabFrontComponentId: string | null; 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 6067439286..719c2d9ca7 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 @@ -95,17 +95,9 @@ export class ApplicationDTO { @Field({ nullable: true }) defaultRoleId?: string; - /** - * @deprecated Custom settings tabs are no longer supported. Kept for - * backward compatibility with existing installations; the value is ignored. - */ @IsOptional() @IsUUID() - @Field(() => UUIDScalarType, { - nullable: true, - deprecationReason: - 'Custom settings tabs are no longer supported. This field is ignored.', - }) + @Field(() => UUIDScalarType, { nullable: true }) settingsCustomTabFrontComponentId?: string; @IsOptional() diff --git a/packages/twenty-shared/src/application/applicationType.ts b/packages/twenty-shared/src/application/applicationType.ts index b28e2de847..9ee1e10820 100644 --- a/packages/twenty-shared/src/application/applicationType.ts +++ b/packages/twenty-shared/src/application/applicationType.ts @@ -1,5 +1,6 @@ import { type PostInstallLogicFunctionApplicationManifest } from '@/application/postInstallLogicFunctionApplicationType'; import { type PreInstallLogicFunctionApplicationManifest } from '@/application/preInstallLogicFunctionApplicationType'; +import { type SettingsFrontComponentApplicationManifest } from '@/application/settingsFrontComponentApplicationType'; import { type UninstallLogicFunctionApplicationManifest } from '@/application/uninstallLogicFunctionApplicationType'; import { type ApplicationCategory } from './applicationCategoryType'; import { type ApplicationVariables } from './applicationVariablesType'; @@ -32,10 +33,10 @@ export type ApplicationManifest = SyncableEntityOptions & { postInstallLogicFunction?: PostInstallLogicFunctionApplicationManifest; preInstallLogicFunction?: PreInstallLogicFunctionApplicationManifest; uninstallLogicFunction?: UninstallLogicFunctionApplicationManifest; + settingsFrontComponent?: SettingsFrontComponentApplicationManifest; /** - * @deprecated Custom settings tabs are no longer supported. This property is - * kept for backward compatibility with older manifests but is now ignored. - * Use typed `applicationVariables` / `serverVariables` instead. + * @deprecated Use `defineSettingsFrontComponent()` (exposed on the manifest + * as `settingsFrontComponent`) instead. This property is ignored. */ settingsCustomTabFrontComponentUniversalIdentifier?: string; packageJsonChecksum: string | null; diff --git a/packages/twenty-shared/src/application/index.ts b/packages/twenty-shared/src/application/index.ts index c1d4437cc2..e637ec716e 100644 --- a/packages/twenty-shared/src/application/index.ts +++ b/packages/twenty-shared/src/application/index.ts @@ -135,6 +135,7 @@ export type { RunAgentInput, RunAgentResult } from './runAgentType'; export type { ServerVariables } from './server-variables.type'; export type { ServerRouteDispatchResult } from './serverRouteDispatchResultType'; export type { ServerRouteTriggerSettings } from './serverRouteTriggerSettingsType'; +export type { SettingsFrontComponentApplicationManifest } from './settingsFrontComponentApplicationType'; export type { SkillManifest } from './skillManifestType'; export type { StoredOAuthConnectionProviderConfig } from './storedOAuthConnectionProviderConfigType'; export type { SyncableEntityOptions } from './syncableEntityOptionsType'; diff --git a/packages/twenty-shared/src/application/settingsFrontComponentApplicationType.ts b/packages/twenty-shared/src/application/settingsFrontComponentApplicationType.ts new file mode 100644 index 0000000000..1c9721d5bb --- /dev/null +++ b/packages/twenty-shared/src/application/settingsFrontComponentApplicationType.ts @@ -0,0 +1,3 @@ +export type SettingsFrontComponentApplicationManifest = { + universalIdentifier: string; +};