From 4ce7fc69878859e9ea3c65b6671b35f852326ee1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?F=C3=A9lix=20Malfait?= Date: Tue, 27 May 2025 00:06:48 +0200 Subject: [PATCH] Client config not render blocking (#12300) Changes for performance improvement. The primary improvements include replacing GraphQL queries with REST-based client configuration fetching and making the client config non render-blocking --- .../utils/__tests__/downloadFile.test.ts | 6 - .../components/ClientConfigProvider.tsx | 17 +- .../components/ClientConfigProviderEffect.tsx | 12 +- .../client-config/hooks/useClientConfig.ts | 43 +++ .../utils/__tests__/clientConfigUtils.test.ts | 163 +++++++++++ .../client-config/utils/clientConfigUtils.ts | 38 +++ .../hooks/useConfigVariableActions.ts | 10 - .../twenty-front/src/testing/graphqlMocks.ts | 3 + .../src/testing/mock-data/config.ts | 7 - .../captcha/interfaces/captcha.interface.ts | 1 - .../client-config.controller.spec.ts | 96 +++++++ .../client-config/client-config.controller.ts | 14 + .../client-config/client-config.module.ts | 6 +- .../client-config.resolver.spec.ts | 13 +- .../client-config/client-config.resolver.ts | 98 +------ .../services/client-config.service.spec.ts | 252 ++++++++++++++++++ .../services/client-config.service.ts | 144 ++++++++++ packages/twenty-ui/src/utilities/index.ts | 1 + 18 files changed, 776 insertions(+), 148 deletions(-) create mode 100644 packages/twenty-front/src/modules/client-config/hooks/useClientConfig.ts create mode 100644 packages/twenty-front/src/modules/client-config/utils/__tests__/clientConfigUtils.test.ts create mode 100644 packages/twenty-front/src/modules/client-config/utils/clientConfigUtils.ts create mode 100644 packages/twenty-server/src/engine/core-modules/client-config/client-config.controller.spec.ts create mode 100644 packages/twenty-server/src/engine/core-modules/client-config/client-config.controller.ts create mode 100644 packages/twenty-server/src/engine/core-modules/client-config/services/client-config.service.spec.ts create mode 100644 packages/twenty-server/src/engine/core-modules/client-config/services/client-config.service.ts diff --git a/packages/twenty-front/src/modules/activities/files/utils/__tests__/downloadFile.test.ts b/packages/twenty-front/src/modules/activities/files/utils/__tests__/downloadFile.test.ts index 2261b584e..ecc8c9aa1 100644 --- a/packages/twenty-front/src/modules/activities/files/utils/__tests__/downloadFile.test.ts +++ b/packages/twenty-front/src/modules/activities/files/utils/__tests__/downloadFile.test.ts @@ -1,6 +1,5 @@ import { downloadFile } from '../downloadFile'; -// Mock fetch global.fetch = jest.fn(() => Promise.resolve({ status: 200, @@ -16,13 +15,10 @@ window.URL.revokeObjectURL = jest.fn(); // `global.fetch` and `window.fetch` are also undefined describe.skip('downloadFile', () => { it('should download a file', () => { - // Call downloadFile downloadFile('url/to/file.pdf', 'file.pdf'); - // Assert on fetch expect(fetch).toHaveBeenCalledWith('url/to/file.pdf'); - // Assert on element creation const link = document.querySelector( 'a[href="mock-url"][download="file.pdf"]', ); @@ -32,10 +28,8 @@ describe.skip('downloadFile', () => { // @ts-ignore expect(link?.style?.display).toBe('none'); - // Assert on element click expect(link).toHaveBeenCalledTimes(1); - // Clean up mocks jest.clearAllMocks(); }); }); diff --git a/packages/twenty-front/src/modules/client-config/components/ClientConfigProvider.tsx b/packages/twenty-front/src/modules/client-config/components/ClientConfigProvider.tsx index b0a7346cf..774bb2361 100644 --- a/packages/twenty-front/src/modules/client-config/components/ClientConfigProvider.tsx +++ b/packages/twenty-front/src/modules/client-config/components/ClientConfigProvider.tsx @@ -2,26 +2,11 @@ import { useRecoilValue } from 'recoil'; import { clientConfigApiStatusState } from '@/client-config/states/clientConfigApiStatusState'; import { AppFullScreenErrorFallback } from '@/error-handler/components/AppFullScreenErrorFallback'; -import { AppPath } from '@/types/AppPath'; -import { useLocation } from 'react-router-dom'; -import { isMatchingLocation } from '~/utils/isMatchingLocation'; export const ClientConfigProvider: React.FC = ({ children, }) => { - const { isLoaded, isErrored, error } = useRecoilValue( - clientConfigApiStatusState, - ); - - const location = useLocation(); - - // TODO: Implement a better loading strategy - if ( - !isLoaded && - !isMatchingLocation(location, AppPath.Verify) && - !isMatchingLocation(location, AppPath.VerifyEmail) - ) - return null; + const { isErrored, error } = useRecoilValue(clientConfigApiStatusState); return isErrored && error instanceof Error ? ( { const setIsDebugMode = useSetRecoilState(isDebugModeState); @@ -87,9 +87,13 @@ export const ClientConfigProviderEffect = () => { isConfigVariablesInDbEnabledState, ); - const { data, loading, error } = useGetClientConfigQuery({ - skip: clientConfigApiStatus.isLoaded, - }); + const { data, loading, error, fetchClientConfig } = useClientConfig(); + + useEffect(() => { + if (!clientConfigApiStatus.isLoaded) { + fetchClientConfig(); + } + }, [clientConfigApiStatus.isLoaded, fetchClientConfig]); useEffect(() => { if (loading) return; diff --git a/packages/twenty-front/src/modules/client-config/hooks/useClientConfig.ts b/packages/twenty-front/src/modules/client-config/hooks/useClientConfig.ts new file mode 100644 index 000000000..344220c1b --- /dev/null +++ b/packages/twenty-front/src/modules/client-config/hooks/useClientConfig.ts @@ -0,0 +1,43 @@ +import { useCallback, useState } from 'react'; +import { ClientConfig } from '~/generated/graphql'; +import { getClientConfig } from '../utils/clientConfigUtils'; + +interface UseClientConfigResult { + data: { clientConfig: ClientConfig } | undefined; + loading: boolean; + error: Error | undefined; + fetchClientConfig: () => Promise; + refetchClientConfig: () => Promise; +} + +export const useClientConfig = (): UseClientConfigResult => { + const [data, setData] = useState<{ clientConfig: ClientConfig } | undefined>( + undefined, + ); + const [loading, setLoading] = useState(false); + const [error, setError] = useState(undefined); + + const fetchClientConfig = useCallback(async () => { + setLoading(true); + setError(undefined); + + try { + const clientConfig = await getClientConfig(); + setData({ clientConfig }); + } catch (err) { + setError( + err instanceof Error ? err : new Error('Failed to fetch client config'), + ); + } finally { + setLoading(false); + } + }, []); + + return { + data, + loading, + error, + fetchClientConfig, + refetchClientConfig: fetchClientConfig, + }; +}; diff --git a/packages/twenty-front/src/modules/client-config/utils/__tests__/clientConfigUtils.test.ts b/packages/twenty-front/src/modules/client-config/utils/__tests__/clientConfigUtils.test.ts new file mode 100644 index 000000000..46fd5e121 --- /dev/null +++ b/packages/twenty-front/src/modules/client-config/utils/__tests__/clientConfigUtils.test.ts @@ -0,0 +1,163 @@ +import { + clearClientConfigCache, + getClientConfig, + refreshClientConfig, +} from '../clientConfigUtils'; + +import { REACT_APP_SERVER_BASE_URL } from '~/config'; + +global.fetch = jest.fn(); + +const mockClientConfig = { + billing: { + isBillingEnabled: true, + billingUrl: 'https://billing.example.com', + trialPeriods: [], + }, + authProviders: { + google: true, + magicLink: false, + password: true, + microsoft: false, + sso: [], + }, + signInPrefilled: false, + isMultiWorkspaceEnabled: true, + isEmailVerificationRequired: false, + defaultSubdomain: 'app', + frontDomain: 'localhost', + debugMode: true, + support: { + supportDriver: 'none', + supportFrontChatId: undefined, + }, + sentry: { + environment: 'development', + release: '1.0.0', + dsn: undefined, + }, + captcha: { + provider: undefined, + siteKey: undefined, + }, + chromeExtensionId: undefined, + api: { + mutationMaximumAffectedRecords: 100, + }, + isAttachmentPreviewEnabled: true, + analyticsEnabled: false, + canManageFeatureFlags: true, + publicFeatureFlags: [], + isMicrosoftMessagingEnabled: false, + isMicrosoftCalendarEnabled: false, + isGoogleMessagingEnabled: false, + isGoogleCalendarEnabled: false, + isConfigVariablesInDbEnabled: false, +}; + +describe('clientConfigUtils', () => { + beforeEach(() => { + clearClientConfigCache(); + jest.clearAllMocks(); + }); + + afterEach(() => { + clearClientConfigCache(); + }); + + describe('getClientConfig', () => { + it('should fetch client config from API', async () => { + (fetch as jest.Mock).mockResolvedValueOnce({ + ok: true, + json: async () => mockClientConfig, + }); + + const result = await getClientConfig(); + + expect(fetch).toHaveBeenCalledWith( + REACT_APP_SERVER_BASE_URL + '/client-config', + { + method: 'GET', + headers: { + 'Content-Type': 'application/json', + }, + }, + ); + expect(result).toEqual(mockClientConfig); + }); + + it('should cache the result', async () => { + (fetch as jest.Mock).mockResolvedValueOnce({ + ok: true, + json: async () => mockClientConfig, + }); + + // First call + await getClientConfig(); + + // Second call should use cache + const result = await getClientConfig(); + + expect(fetch).toHaveBeenCalledTimes(1); + expect(result).toEqual(mockClientConfig); + }); + + it('should handle fetch errors', async () => { + (fetch as jest.Mock).mockResolvedValueOnce({ + ok: false, + status: 500, + statusText: 'Internal Server Error', + }); + + await expect(getClientConfig()).rejects.toThrow( + 'Failed to fetch client config: 500 Internal Server Error', + ); + }); + }); + + describe('refreshClientConfig', () => { + it('should clear cache and fetch fresh data', async () => { + // First call to populate cache + (fetch as jest.Mock).mockResolvedValueOnce({ + ok: true, + json: async () => mockClientConfig, + }); + await getClientConfig(); + + // Mock a different response for refresh + const updatedConfig = { ...mockClientConfig, debugMode: false }; + (fetch as jest.Mock).mockResolvedValueOnce({ + ok: true, + json: async () => updatedConfig, + }); + + const result = await refreshClientConfig(); + + expect(fetch).toHaveBeenCalledTimes(2); + expect(result).toEqual(updatedConfig); + }); + }); + + describe('clearClientConfigCache', () => { + it('should clear the cache', async () => { + // Populate cache + (fetch as jest.Mock).mockResolvedValueOnce({ + ok: true, + json: async () => mockClientConfig, + }); + await getClientConfig(); + + // Clear cache + clearClientConfigCache(); + + // Next call should fetch again + (fetch as jest.Mock).mockResolvedValueOnce({ + ok: true, + json: async () => mockClientConfig, + }); + await getClientConfig(); + + expect(fetch).toHaveBeenCalledTimes(2); + }); + }); +}); diff --git a/packages/twenty-front/src/modules/client-config/utils/clientConfigUtils.ts b/packages/twenty-front/src/modules/client-config/utils/clientConfigUtils.ts new file mode 100644 index 000000000..382329c6e --- /dev/null +++ b/packages/twenty-front/src/modules/client-config/utils/clientConfigUtils.ts @@ -0,0 +1,38 @@ +import { ClientConfig } from '~/generated/graphql'; + +import { REACT_APP_SERVER_BASE_URL } from '~/config'; + +let cachedClientConfig: ClientConfig | null = null; + +export const getClientConfig = async (): Promise => { + if (cachedClientConfig !== null) { + return cachedClientConfig; + } + + const response = await fetch(`${REACT_APP_SERVER_BASE_URL}/client-config`, { + method: 'GET', + headers: { + 'Content-Type': 'application/json', + }, + }); + + if (!response.ok) { + throw new Error( + `Failed to fetch client config: ${response.status} ${response.statusText}`, + ); + } + + const clientConfig: ClientConfig = await response.json(); + cachedClientConfig = clientConfig; + + return clientConfig; +}; + +export const refreshClientConfig = async (): Promise => { + cachedClientConfig = null; + return getClientConfig(); +}; + +export const clearClientConfigCache = (): void => { + cachedClientConfig = null; +}; diff --git a/packages/twenty-front/src/modules/settings/admin-panel/config-variables/hooks/useConfigVariableActions.ts b/packages/twenty-front/src/modules/settings/admin-panel/config-variables/hooks/useConfigVariableActions.ts index bf17bd91e..40f987ca4 100644 --- a/packages/twenty-front/src/modules/settings/admin-panel/config-variables/hooks/useConfigVariableActions.ts +++ b/packages/twenty-front/src/modules/settings/admin-panel/config-variables/hooks/useConfigVariableActions.ts @@ -1,6 +1,5 @@ import { useLingui } from '@lingui/react/macro'; -import { GET_CLIENT_CONFIG } from '@/client-config/graphql/queries/getClientConfig'; import { GET_DATABASE_CONFIG_VARIABLE } from '@/settings/admin-panel/config-variables/graphql/queries/getDatabaseConfigVariable'; import { SnackBarVariant } from '@/ui/feedback/snack-bar-manager/components/SnackBar'; import { useSnackBar } from '@/ui/feedback/snack-bar-manager/hooks/useSnackBar'; @@ -48,9 +47,6 @@ export const useConfigVariableActions = (variableName: string) => { query: GET_DATABASE_CONFIG_VARIABLE, variables: { key: variableName }, }, - { - query: GET_CLIENT_CONFIG, - }, ], }); } else { @@ -64,9 +60,6 @@ export const useConfigVariableActions = (variableName: string) => { query: GET_DATABASE_CONFIG_VARIABLE, variables: { key: variableName }, }, - { - query: GET_CLIENT_CONFIG, - }, ], }); } @@ -96,9 +89,6 @@ export const useConfigVariableActions = (variableName: string) => { query: GET_DATABASE_CONFIG_VARIABLE, variables: { key: variableName }, }, - { - query: GET_CLIENT_CONFIG, - }, ], }); } catch (error) { diff --git a/packages/twenty-front/src/testing/graphqlMocks.ts b/packages/twenty-front/src/testing/graphqlMocks.ts index 08af596ee..eb672713f 100644 --- a/packages/twenty-front/src/testing/graphqlMocks.ts +++ b/packages/twenty-front/src/testing/graphqlMocks.ts @@ -124,6 +124,9 @@ export const graphqlMocks = { }, }); }), + http.get(`${REACT_APP_SERVER_BASE_URL}/client-config`, () => { + return HttpResponse.json(mockedClientConfig); + }), metadataGraphql.query( getOperationName(FIND_MANY_OBJECT_METADATA_ITEMS) ?? '', () => { diff --git a/packages/twenty-front/src/testing/mock-data/config.ts b/packages/twenty-front/src/testing/mock-data/config.ts index 585185f13..4df865abd 100644 --- a/packages/twenty-front/src/testing/mock-data/config.ts +++ b/packages/twenty-front/src/testing/mock-data/config.ts @@ -10,7 +10,6 @@ export const mockedClientConfig: ClientConfig = { password: true, microsoft: false, sso: [], - __typename: 'AuthProviders', }, frontDomain: 'localhost', defaultSubdomain: 'app', @@ -20,35 +19,29 @@ export const mockedClientConfig: ClientConfig = { support: { supportDriver: 'front', supportFrontChatId: null, - __typename: 'Support', }, sentry: { dsn: 'MOCKED_DSN', release: 'MOCKED_RELEASE', environment: 'MOCKED_ENVIRONMENT', - __typename: 'Sentry', }, billing: { isBillingEnabled: true, billingUrl: '', trialPeriods: [ { - __typename: 'BillingTrialPeriodDTO', duration: 30, isCreditCardRequired: true, }, { - __typename: 'BillingTrialPeriodDTO', duration: 7, isCreditCardRequired: false, }, ], - __typename: 'Billing', }, captcha: { provider: CaptchaDriverType.GoogleRecaptcha, siteKey: 'MOCKED_SITE_KEY', - __typename: 'Captcha', }, api: { mutationMaximumAffectedRecords: 100 }, canManageFeatureFlags: true, diff --git a/packages/twenty-server/src/engine/core-modules/captcha/interfaces/captcha.interface.ts b/packages/twenty-server/src/engine/core-modules/captcha/interfaces/captcha.interface.ts index f6f61b814..6688c084a 100644 --- a/packages/twenty-server/src/engine/core-modules/captcha/interfaces/captcha.interface.ts +++ b/packages/twenty-server/src/engine/core-modules/captcha/interfaces/captcha.interface.ts @@ -36,5 +36,4 @@ export type CaptchaModuleAsyncOptions = { ) => CaptchaModuleOptions | Promise | undefined; } & Pick & Pick; - export type CaptchaValidateResult = { success: boolean; error?: string }; diff --git a/packages/twenty-server/src/engine/core-modules/client-config/client-config.controller.spec.ts b/packages/twenty-server/src/engine/core-modules/client-config/client-config.controller.spec.ts new file mode 100644 index 000000000..4686adf32 --- /dev/null +++ b/packages/twenty-server/src/engine/core-modules/client-config/client-config.controller.spec.ts @@ -0,0 +1,96 @@ +import { Test, TestingModule } from '@nestjs/testing'; + +import { ClientConfigService } from 'src/engine/core-modules/client-config/services/client-config.service'; + +import { ClientConfigController } from './client-config.controller'; + +describe('ClientConfigController', () => { + let controller: ClientConfigController; + let clientConfigService: ClientConfigService; + + beforeEach(async () => { + const module: TestingModule = await Test.createTestingModule({ + controllers: [ClientConfigController], + providers: [ + { + provide: ClientConfigService, + useValue: { + getClientConfig: jest.fn(), + }, + }, + ], + }).compile(); + + controller = module.get(ClientConfigController); + clientConfigService = module.get(ClientConfigService); + }); + + it('should be defined', () => { + expect(controller).toBeDefined(); + }); + + describe('getClientConfig', () => { + it('should return client config from service', async () => { + const mockClientConfig = { + billing: { + isBillingEnabled: true, + billingUrl: 'https://billing.example.com', + trialPeriods: [ + { + duration: 7, + isCreditCardRequired: false, + }, + ], + }, + authProviders: { + google: true, + magicLink: false, + password: true, + microsoft: false, + sso: [], + }, + signInPrefilled: false, + isMultiWorkspaceEnabled: true, + isEmailVerificationRequired: false, + defaultSubdomain: 'app', + frontDomain: 'localhost', + debugMode: true, + support: { + supportDriver: 'none', + supportFrontChatId: undefined, + }, + sentry: { + environment: 'development', + release: '1.0.0', + dsn: undefined, + }, + captcha: { + provider: undefined, + siteKey: undefined, + }, + chromeExtensionId: undefined, + api: { + mutationMaximumAffectedRecords: 100, + }, + isAttachmentPreviewEnabled: true, + analyticsEnabled: false, + canManageFeatureFlags: true, + publicFeatureFlags: [], + isMicrosoftMessagingEnabled: false, + isMicrosoftCalendarEnabled: false, + isGoogleMessagingEnabled: false, + isGoogleCalendarEnabled: false, + isConfigVariablesInDbEnabled: false, + }; + + jest + .spyOn(clientConfigService, 'getClientConfig') + .mockResolvedValue(mockClientConfig); + + const result = await controller.getClientConfig(); + + expect(clientConfigService.getClientConfig).toHaveBeenCalled(); + expect(result).toEqual(mockClientConfig); + }); + }); +}); diff --git a/packages/twenty-server/src/engine/core-modules/client-config/client-config.controller.ts b/packages/twenty-server/src/engine/core-modules/client-config/client-config.controller.ts new file mode 100644 index 000000000..fa619d512 --- /dev/null +++ b/packages/twenty-server/src/engine/core-modules/client-config/client-config.controller.ts @@ -0,0 +1,14 @@ +import { Controller, Get } from '@nestjs/common'; + +import { ClientConfig } from 'src/engine/core-modules/client-config/client-config.entity'; +import { ClientConfigService } from 'src/engine/core-modules/client-config/services/client-config.service'; + +@Controller('/client-config') +export class ClientConfigController { + constructor(private readonly clientConfigService: ClientConfigService) {} + + @Get() + async getClientConfig(): Promise { + return this.clientConfigService.getClientConfig(); + } +} diff --git a/packages/twenty-server/src/engine/core-modules/client-config/client-config.module.ts b/packages/twenty-server/src/engine/core-modules/client-config/client-config.module.ts index 70f57128f..63f7d0d8b 100644 --- a/packages/twenty-server/src/engine/core-modules/client-config/client-config.module.ts +++ b/packages/twenty-server/src/engine/core-modules/client-config/client-config.module.ts @@ -2,10 +2,14 @@ import { Module } from '@nestjs/common'; import { DomainManagerModule } from 'src/engine/core-modules/domain-manager/domain-manager.module'; +import { ClientConfigController } from './client-config.controller'; import { ClientConfigResolver } from './client-config.resolver'; +import { ClientConfigService } from './services/client-config.service'; + @Module({ imports: [DomainManagerModule], - providers: [ClientConfigResolver], + controllers: [ClientConfigController], + providers: [ClientConfigResolver, ClientConfigService], }) export class ClientConfigModule {} diff --git a/packages/twenty-server/src/engine/core-modules/client-config/client-config.resolver.spec.ts b/packages/twenty-server/src/engine/core-modules/client-config/client-config.resolver.spec.ts index e67b141c0..ae1774cac 100644 --- a/packages/twenty-server/src/engine/core-modules/client-config/client-config.resolver.spec.ts +++ b/packages/twenty-server/src/engine/core-modules/client-config/client-config.resolver.spec.ts @@ -1,7 +1,6 @@ import { Test, TestingModule } from '@nestjs/testing'; -import { DomainManagerService } from 'src/engine/core-modules/domain-manager/services/domain-manager.service'; -import { TwentyConfigService } from 'src/engine/core-modules/twenty-config/twenty-config.service'; +import { ClientConfigService } from 'src/engine/core-modules/client-config/services/client-config.service'; import { ClientConfigResolver } from './client-config.resolver'; @@ -13,12 +12,10 @@ describe('ClientConfigResolver', () => { providers: [ ClientConfigResolver, { - provide: TwentyConfigService, - useValue: {}, - }, - { - provide: DomainManagerService, - useValue: {}, + provide: ClientConfigService, + useValue: { + getClientConfig: jest.fn(), + }, }, ], }).compile(); diff --git a/packages/twenty-server/src/engine/core-modules/client-config/client-config.resolver.ts b/packages/twenty-server/src/engine/core-modules/client-config/client-config.resolver.ts index 91bc864e0..37ab9f7de 100644 --- a/packages/twenty-server/src/engine/core-modules/client-config/client-config.resolver.ts +++ b/packages/twenty-server/src/engine/core-modules/client-config/client-config.resolver.ts @@ -1,107 +1,15 @@ import { Query, Resolver } from '@nestjs/graphql'; -import { NodeEnvironment } from 'src/engine/core-modules/twenty-config/interfaces/node-environment.interface'; - -import { DomainManagerService } from 'src/engine/core-modules/domain-manager/services/domain-manager.service'; -import { PUBLIC_FEATURE_FLAGS } from 'src/engine/core-modules/feature-flag/constants/public-feature-flag.const'; -import { TwentyConfigService } from 'src/engine/core-modules/twenty-config/twenty-config.service'; +import { ClientConfigService } from 'src/engine/core-modules/client-config/services/client-config.service'; import { ClientConfig } from './client-config.entity'; @Resolver() export class ClientConfigResolver { - constructor( - private twentyConfigService: TwentyConfigService, - private domainManagerService: DomainManagerService, - ) {} + constructor(private clientConfigService: ClientConfigService) {} @Query(() => ClientConfig) async clientConfig(): Promise { - const clientConfig: ClientConfig = { - billing: { - isBillingEnabled: this.twentyConfigService.get('IS_BILLING_ENABLED'), - billingUrl: this.twentyConfigService.get('BILLING_PLAN_REQUIRED_LINK'), - trialPeriods: [ - { - duration: this.twentyConfigService.get( - 'BILLING_FREE_TRIAL_WITH_CREDIT_CARD_DURATION_IN_DAYS', - ), - isCreditCardRequired: true, - }, - { - duration: this.twentyConfigService.get( - 'BILLING_FREE_TRIAL_WITHOUT_CREDIT_CARD_DURATION_IN_DAYS', - ), - isCreditCardRequired: false, - }, - ], - }, - authProviders: { - google: this.twentyConfigService.get('AUTH_GOOGLE_ENABLED'), - magicLink: false, - password: this.twentyConfigService.get('AUTH_PASSWORD_ENABLED'), - microsoft: this.twentyConfigService.get('AUTH_MICROSOFT_ENABLED'), - sso: [], - }, - signInPrefilled: this.twentyConfigService.get('SIGN_IN_PREFILLED'), - isMultiWorkspaceEnabled: this.twentyConfigService.get( - 'IS_MULTIWORKSPACE_ENABLED', - ), - isEmailVerificationRequired: this.twentyConfigService.get( - 'IS_EMAIL_VERIFICATION_REQUIRED', - ), - defaultSubdomain: this.twentyConfigService.get('DEFAULT_SUBDOMAIN'), - frontDomain: this.domainManagerService.getFrontUrl().hostname, - debugMode: - this.twentyConfigService.get('NODE_ENV') === - NodeEnvironment.development, - support: { - supportDriver: this.twentyConfigService.get('SUPPORT_DRIVER'), - supportFrontChatId: this.twentyConfigService.get( - 'SUPPORT_FRONT_CHAT_ID', - ), - }, - sentry: { - environment: this.twentyConfigService.get('SENTRY_ENVIRONMENT'), - release: this.twentyConfigService.get('APP_VERSION'), - dsn: this.twentyConfigService.get('SENTRY_FRONT_DSN'), - }, - captcha: { - provider: this.twentyConfigService.get('CAPTCHA_DRIVER'), - siteKey: this.twentyConfigService.get('CAPTCHA_SITE_KEY'), - }, - chromeExtensionId: this.twentyConfigService.get('CHROME_EXTENSION_ID'), - api: { - mutationMaximumAffectedRecords: this.twentyConfigService.get( - 'MUTATION_MAXIMUM_AFFECTED_RECORDS', - ), - }, - isAttachmentPreviewEnabled: this.twentyConfigService.get( - 'IS_ATTACHMENT_PREVIEW_ENABLED', - ), - analyticsEnabled: this.twentyConfigService.get('ANALYTICS_ENABLED'), - canManageFeatureFlags: - this.twentyConfigService.get('NODE_ENV') === - NodeEnvironment.development || - this.twentyConfigService.get('IS_BILLING_ENABLED'), - publicFeatureFlags: PUBLIC_FEATURE_FLAGS, - isMicrosoftMessagingEnabled: this.twentyConfigService.get( - 'MESSAGING_PROVIDER_MICROSOFT_ENABLED', - ), - isMicrosoftCalendarEnabled: this.twentyConfigService.get( - 'CALENDAR_PROVIDER_MICROSOFT_ENABLED', - ), - isGoogleMessagingEnabled: this.twentyConfigService.get( - 'MESSAGING_PROVIDER_GMAIL_ENABLED', - ), - isGoogleCalendarEnabled: this.twentyConfigService.get( - 'CALENDAR_PROVIDER_GOOGLE_ENABLED', - ), - isConfigVariablesInDbEnabled: this.twentyConfigService.get( - 'IS_CONFIG_VARIABLES_IN_DB_ENABLED', - ), - }; - - return Promise.resolve(clientConfig); + return this.clientConfigService.getClientConfig(); } } diff --git a/packages/twenty-server/src/engine/core-modules/client-config/services/client-config.service.spec.ts b/packages/twenty-server/src/engine/core-modules/client-config/services/client-config.service.spec.ts new file mode 100644 index 000000000..becdcadf5 --- /dev/null +++ b/packages/twenty-server/src/engine/core-modules/client-config/services/client-config.service.spec.ts @@ -0,0 +1,252 @@ +import { Test, TestingModule } from '@nestjs/testing'; + +import { NodeEnvironment } from 'src/engine/core-modules/twenty-config/interfaces/node-environment.interface'; +import { SupportDriver } from 'src/engine/core-modules/twenty-config/interfaces/support.interface'; + +import { CaptchaDriverType } from 'src/engine/core-modules/captcha/interfaces'; +import { ClientConfigService } from 'src/engine/core-modules/client-config/services/client-config.service'; +import { DomainManagerService } from 'src/engine/core-modules/domain-manager/services/domain-manager.service'; +import { PUBLIC_FEATURE_FLAGS } from 'src/engine/core-modules/feature-flag/constants/public-feature-flag.const'; +import { TwentyConfigService } from 'src/engine/core-modules/twenty-config/twenty-config.service'; + +describe('ClientConfigService', () => { + let service: ClientConfigService; + let twentyConfigService: TwentyConfigService; + let domainManagerService: DomainManagerService; + + beforeEach(async () => { + const module: TestingModule = await Test.createTestingModule({ + providers: [ + ClientConfigService, + { + provide: TwentyConfigService, + useValue: { + get: jest.fn(), + }, + }, + { + provide: DomainManagerService, + useValue: { + getFrontUrl: jest.fn(), + }, + }, + ], + }).compile(); + + service = module.get(ClientConfigService); + twentyConfigService = module.get(TwentyConfigService); + domainManagerService = + module.get(DomainManagerService); + }); + + it('should be defined', () => { + expect(service).toBeDefined(); + }); + + describe('getClientConfig', () => { + beforeEach(() => { + // Setup default mock values + jest + .spyOn(twentyConfigService, 'get') + .mockImplementation((key: string) => { + const mockValues: Record = { + IS_BILLING_ENABLED: true, + BILLING_PLAN_REQUIRED_LINK: 'https://billing.example.com', + BILLING_FREE_TRIAL_WITH_CREDIT_CARD_DURATION_IN_DAYS: 30, + BILLING_FREE_TRIAL_WITHOUT_CREDIT_CARD_DURATION_IN_DAYS: 7, + AUTH_GOOGLE_ENABLED: true, + AUTH_PASSWORD_ENABLED: true, + AUTH_MICROSOFT_ENABLED: false, + SIGN_IN_PREFILLED: false, + IS_MULTIWORKSPACE_ENABLED: true, + IS_EMAIL_VERIFICATION_REQUIRED: true, + DEFAULT_SUBDOMAIN: 'app', + NODE_ENV: NodeEnvironment.development, + SUPPORT_DRIVER: SupportDriver.Front, + SUPPORT_FRONT_CHAT_ID: 'chat-123', + SENTRY_ENVIRONMENT: 'development', + APP_VERSION: '1.0.0', + SENTRY_FRONT_DSN: 'https://sentry.example.com', + CAPTCHA_DRIVER: CaptchaDriverType.GoogleRecaptcha, + CAPTCHA_SITE_KEY: 'site-key-123', + CHROME_EXTENSION_ID: 'extension-123', + MUTATION_MAXIMUM_AFFECTED_RECORDS: 1000, + IS_ATTACHMENT_PREVIEW_ENABLED: true, + ANALYTICS_ENABLED: true, + MESSAGING_PROVIDER_MICROSOFT_ENABLED: false, + CALENDAR_PROVIDER_MICROSOFT_ENABLED: false, + MESSAGING_PROVIDER_GMAIL_ENABLED: true, + CALENDAR_PROVIDER_GOOGLE_ENABLED: true, + IS_CONFIG_VARIABLES_IN_DB_ENABLED: false, + }; + + return mockValues[key]; + }); + + jest.spyOn(domainManagerService, 'getFrontUrl').mockReturnValue({ + hostname: 'app.twenty.com', + } as URL); + }); + + it('should return complete client config with all properties', async () => { + const result = await service.getClientConfig(); + + expect(result).toEqual({ + billing: { + isBillingEnabled: true, + billingUrl: 'https://billing.example.com', + trialPeriods: [ + { + duration: 30, + isCreditCardRequired: true, + }, + { + duration: 7, + isCreditCardRequired: false, + }, + ], + }, + authProviders: { + google: true, + magicLink: false, + password: true, + microsoft: false, + sso: [], + }, + signInPrefilled: false, + isMultiWorkspaceEnabled: true, + isEmailVerificationRequired: true, + defaultSubdomain: 'app', + frontDomain: 'app.twenty.com', + debugMode: true, + support: { + supportDriver: 'Front', + supportFrontChatId: 'chat-123', + }, + sentry: { + environment: 'development', + release: '1.0.0', + dsn: 'https://sentry.example.com', + }, + captcha: { + provider: 'GoogleRecaptcha', + siteKey: 'site-key-123', + }, + chromeExtensionId: 'extension-123', + api: { + mutationMaximumAffectedRecords: 1000, + }, + isAttachmentPreviewEnabled: true, + analyticsEnabled: true, + canManageFeatureFlags: true, + publicFeatureFlags: PUBLIC_FEATURE_FLAGS, + isMicrosoftMessagingEnabled: false, + isMicrosoftCalendarEnabled: false, + isGoogleMessagingEnabled: true, + isGoogleCalendarEnabled: true, + isConfigVariablesInDbEnabled: false, + }); + }); + + it('should handle production environment correctly', async () => { + jest + .spyOn(twentyConfigService, 'get') + .mockImplementation((key: string) => { + if (key === 'NODE_ENV') return NodeEnvironment.production; + if (key === 'IS_BILLING_ENABLED') return false; + + return undefined; + }); + + const result = await service.getClientConfig(); + + expect(result.debugMode).toBe(false); + expect(result.canManageFeatureFlags).toBe(false); + }); + + it('should handle missing captcha driver', async () => { + jest + .spyOn(twentyConfigService, 'get') + .mockImplementation((key: string) => { + if (key === 'CAPTCHA_DRIVER') return undefined; + if (key === 'CAPTCHA_SITE_KEY') return 'site-key'; + + return undefined; + }); + + const result = await service.getClientConfig(); + + expect(result.captcha.provider).toBeUndefined(); + expect(result.captcha.siteKey).toBe('site-key'); + }); + + it('should handle missing support driver', async () => { + jest + .spyOn(twentyConfigService, 'get') + .mockImplementation((key: string) => { + if (key === 'SUPPORT_DRIVER') return undefined; + + return undefined; + }); + + const result = await service.getClientConfig(); + + expect(result.support.supportDriver).toBe(SupportDriver.None); + }); + + it('should handle billing enabled with feature flags', async () => { + jest + .spyOn(twentyConfigService, 'get') + .mockImplementation((key: string) => { + if (key === 'NODE_ENV') return NodeEnvironment.production; + if (key === 'IS_BILLING_ENABLED') return true; + + return undefined; + }); + + const result = await service.getClientConfig(); + + expect(result.canManageFeatureFlags).toBe(true); + }); + }); + + describe('transformEnum', () => { + it('should transform enum by direct key match', () => { + const result = (service as any).transformEnum( + 'GoogleRecaptcha', + CaptchaDriverType, + ); + + expect(result).toBe(CaptchaDriverType.GoogleRecaptcha); + }); + + it('should transform enum by value match', () => { + const result = (service as any).transformEnum( + 'google-recaptcha', + CaptchaDriverType, + ); + + expect(result).toBe('GoogleRecaptcha'); + }); + + it('should transform SupportDriver enum correctly', () => { + const result = (service as any).transformEnum('front', SupportDriver); + + expect(result).toBe('Front'); + }); + + it('should throw error for unknown enum value', () => { + expect(() => { + (service as any).transformEnum('unknown-value', CaptchaDriverType); + }).toThrow( + 'Unknown enum value: unknown-value. Available keys: GoogleRecaptcha, Turnstile. Available values: google-recaptcha, turnstile', + ); + }); + + it('should handle direct key match for SupportDriver', () => { + const result = (service as any).transformEnum('Front', SupportDriver); + + expect(result).toBe(SupportDriver.Front); + }); + }); +}); diff --git a/packages/twenty-server/src/engine/core-modules/client-config/services/client-config.service.ts b/packages/twenty-server/src/engine/core-modules/client-config/services/client-config.service.ts new file mode 100644 index 000000000..c8e5a3e64 --- /dev/null +++ b/packages/twenty-server/src/engine/core-modules/client-config/services/client-config.service.ts @@ -0,0 +1,144 @@ +import { Injectable } from '@nestjs/common'; + +import { NodeEnvironment } from 'src/engine/core-modules/twenty-config/interfaces/node-environment.interface'; +import { SupportDriver } from 'src/engine/core-modules/twenty-config/interfaces/support.interface'; + +import { CaptchaDriverType } from 'src/engine/core-modules/captcha/interfaces'; +import { ClientConfig } from 'src/engine/core-modules/client-config/client-config.entity'; +import { DomainManagerService } from 'src/engine/core-modules/domain-manager/services/domain-manager.service'; +import { PUBLIC_FEATURE_FLAGS } from 'src/engine/core-modules/feature-flag/constants/public-feature-flag.const'; +import { TwentyConfigService } from 'src/engine/core-modules/twenty-config/twenty-config.service'; + +@Injectable() +export class ClientConfigService { + constructor( + private twentyConfigService: TwentyConfigService, + private domainManagerService: DomainManagerService, + ) {} + + async getClientConfig(): Promise { + const captchaProvider = this.twentyConfigService.get('CAPTCHA_DRIVER'); + const supportDriver = this.twentyConfigService.get('SUPPORT_DRIVER'); + + const clientConfig: ClientConfig = { + billing: { + isBillingEnabled: this.twentyConfigService.get('IS_BILLING_ENABLED'), + billingUrl: this.twentyConfigService.get('BILLING_PLAN_REQUIRED_LINK'), + trialPeriods: [ + { + duration: this.twentyConfigService.get( + 'BILLING_FREE_TRIAL_WITH_CREDIT_CARD_DURATION_IN_DAYS', + ), + isCreditCardRequired: true, + }, + { + duration: this.twentyConfigService.get( + 'BILLING_FREE_TRIAL_WITHOUT_CREDIT_CARD_DURATION_IN_DAYS', + ), + isCreditCardRequired: false, + }, + ], + }, + authProviders: { + google: this.twentyConfigService.get('AUTH_GOOGLE_ENABLED'), + magicLink: false, + password: this.twentyConfigService.get('AUTH_PASSWORD_ENABLED'), + microsoft: this.twentyConfigService.get('AUTH_MICROSOFT_ENABLED'), + sso: [], + }, + signInPrefilled: this.twentyConfigService.get('SIGN_IN_PREFILLED'), + isMultiWorkspaceEnabled: this.twentyConfigService.get( + 'IS_MULTIWORKSPACE_ENABLED', + ), + isEmailVerificationRequired: this.twentyConfigService.get( + 'IS_EMAIL_VERIFICATION_REQUIRED', + ), + defaultSubdomain: this.twentyConfigService.get('DEFAULT_SUBDOMAIN'), + frontDomain: this.domainManagerService.getFrontUrl().hostname, + debugMode: + this.twentyConfigService.get('NODE_ENV') === + NodeEnvironment.development, + support: { + supportDriver: supportDriver + ? this.transformEnum(supportDriver, SupportDriver) + : SupportDriver.None, + supportFrontChatId: this.twentyConfigService.get( + 'SUPPORT_FRONT_CHAT_ID', + ), + }, + sentry: { + environment: this.twentyConfigService.get('SENTRY_ENVIRONMENT'), + release: this.twentyConfigService.get('APP_VERSION'), + dsn: this.twentyConfigService.get('SENTRY_FRONT_DSN'), + }, + captcha: { + provider: captchaProvider + ? this.transformEnum(captchaProvider, CaptchaDriverType) + : undefined, + siteKey: this.twentyConfigService.get('CAPTCHA_SITE_KEY'), + }, + chromeExtensionId: this.twentyConfigService.get('CHROME_EXTENSION_ID'), + api: { + mutationMaximumAffectedRecords: this.twentyConfigService.get( + 'MUTATION_MAXIMUM_AFFECTED_RECORDS', + ), + }, + isAttachmentPreviewEnabled: this.twentyConfigService.get( + 'IS_ATTACHMENT_PREVIEW_ENABLED', + ), + analyticsEnabled: this.twentyConfigService.get('ANALYTICS_ENABLED'), + canManageFeatureFlags: + this.twentyConfigService.get('NODE_ENV') === + NodeEnvironment.development || + this.twentyConfigService.get('IS_BILLING_ENABLED'), + publicFeatureFlags: PUBLIC_FEATURE_FLAGS, + isMicrosoftMessagingEnabled: this.twentyConfigService.get( + 'MESSAGING_PROVIDER_MICROSOFT_ENABLED', + ), + isMicrosoftCalendarEnabled: this.twentyConfigService.get( + 'CALENDAR_PROVIDER_MICROSOFT_ENABLED', + ), + isGoogleMessagingEnabled: this.twentyConfigService.get( + 'MESSAGING_PROVIDER_GMAIL_ENABLED', + ), + isGoogleCalendarEnabled: this.twentyConfigService.get( + 'CALENDAR_PROVIDER_GOOGLE_ENABLED', + ), + isConfigVariablesInDbEnabled: this.twentyConfigService.get( + 'IS_CONFIG_VARIABLES_IN_DB_ENABLED', + ), + }; + + return clientConfig; + } + + // GraphQL enum values are in PascalCase, but the config values are in kebab-case + // This function transforms the config values, the same way GraphQL does + private transformEnum>( + value: string, + enumObject: T, + ): T[keyof T] { + const directMatch = Object.keys(enumObject).find( + (key) => key === value, + ) as keyof T; + + if (directMatch) { + return enumObject[directMatch]; + } + + const valueMatch = Object.entries(enumObject).find( + ([, enumValue]) => enumValue === value, + ); + + if (valueMatch) { + return valueMatch[0] as T[keyof T]; + } + + const availableKeys = Object.keys(enumObject); + const availableValues = Object.values(enumObject); + + throw new Error( + `Unknown enum value: ${value}. Available keys: ${availableKeys.join(', ')}. Available values: ${availableValues.join(', ')}`, + ); + } +} diff --git a/packages/twenty-ui/src/utilities/index.ts b/packages/twenty-ui/src/utilities/index.ts index c2774c4c2..6562033b8 100644 --- a/packages/twenty-ui/src/utilities/index.ts +++ b/packages/twenty-ui/src/utilities/index.ts @@ -24,5 +24,6 @@ export { isModifiedEvent } from './events/isModifiedEvent'; export { useIsMobile } from './responsive/hooks/useIsMobile'; export { useScreenSize } from './screen-size/hooks/useScreenSize'; export { createState } from './state/utils/createState'; +export type { ClickOutsideAttributes } from './types/ClickOutsideAttributes'; export type { Nullable } from './types/Nullable'; export { getDisplayValueByUrlType } from './utils/getDisplayValueByUrlType';