diff --git a/packages/twenty-e2e-testing/tests/workflow-creation.spec.ts b/packages/twenty-e2e-testing/tests/workflow-creation.spec.ts index 026dd5b7b361..049837891b57 100644 --- a/packages/twenty-e2e-testing/tests/workflow-creation.spec.ts +++ b/packages/twenty-e2e-testing/tests/workflow-creation.spec.ts @@ -26,9 +26,6 @@ test('Create workflow', async ({ page }) => { await createWorkflowButton.click(), ]); - const nameInputClosedState = page.getByText('Name').first(); - await nameInputClosedState.click(); - const nameInput = page.getByRole('textbox'); await nameInput.fill(NEW_WORKFLOW_NAME); await nameInput.press('Enter'); @@ -37,23 +34,11 @@ test('Create workflow', async ({ page }) => { const newWorkflowId = body.data.createWorkflow.id; try { - const newWorkflowRowEntryName = page - .getByTestId(`row-id-${newWorkflowId}`) - .locator('div') - .filter({ hasText: NEW_WORKFLOW_NAME }) - .nth(2); - - await Promise.all([ - page.waitForURL( - (url) => url.pathname === `/object/workflow/${newWorkflowId}`, - ), - - newWorkflowRowEntryName.click(), - ]); - const workflowName = page.getByRole('button', { name: NEW_WORKFLOW_NAME }); await expect(workflowName).toBeVisible(); + + await expect(page).toHaveURL(`/object/workflow/${newWorkflowId}`); } finally { await deleteWorkflow({ page, diff --git a/packages/twenty-front/src/generated-metadata/graphql.ts b/packages/twenty-front/src/generated-metadata/graphql.ts index 18a4b164a1c0..2e838d5dbef4 100644 --- a/packages/twenty-front/src/generated-metadata/graphql.ts +++ b/packages/twenty-front/src/generated-metadata/graphql.ts @@ -119,7 +119,7 @@ export type Billing = { __typename?: 'Billing'; billingUrl?: Maybe; isBillingEnabled: Scalars['Boolean']['output']; - trialPeriods: Array; + trialPeriods: Array; }; /** The different billing plans available */ @@ -128,6 +128,72 @@ export enum BillingPlanKey { PRO = 'PRO' } +export type BillingPlanOutput = { + __typename?: 'BillingPlanOutput'; + baseProduct: BillingProductDto; + meteredProducts: Array; + otherLicensedProducts: Array; + planKey: BillingPlanKey; +}; + +export type BillingPriceLicensedDto = { + __typename?: 'BillingPriceLicensedDTO'; + recurringInterval: SubscriptionInterval; + stripePriceId: Scalars['String']['output']; + unitAmount: Scalars['Float']['output']; +}; + +export type BillingPriceMeteredDto = { + __typename?: 'BillingPriceMeteredDTO'; + recurringInterval: SubscriptionInterval; + stripePriceId: Scalars['String']['output']; + tiers?: Maybe>; + tiersMode?: Maybe; +}; + +export type BillingPriceTierDto = { + __typename?: 'BillingPriceTierDTO'; + flatAmount?: Maybe; + unitAmount?: Maybe; + upTo?: Maybe; +}; + +/** The different billing price tiers modes */ +export enum BillingPriceTiersMode { + GRADUATED = 'GRADUATED', + VOLUME = 'VOLUME' +} + +export type BillingPriceUnionDto = BillingPriceLicensedDto | BillingPriceMeteredDto; + +export type BillingProductDto = { + __typename?: 'BillingProductDTO'; + description: Scalars['String']['output']; + images?: Maybe>; + name: Scalars['String']['output']; + prices: Array>; + type: BillingUsageType; +}; + +export type BillingProductPriceDto = { + __typename?: 'BillingProductPriceDTO'; + created: Scalars['Float']['output']; + recurringInterval: SubscriptionInterval; + stripePriceId: Scalars['String']['output']; + unitAmount: Scalars['Float']['output']; +}; + +export type BillingProductPricesOutput = { + __typename?: 'BillingProductPricesOutput'; + productPrices: Array; + totalNumberOfPrices: Scalars['Int']['output']; +}; + +export type BillingSessionOutput = { + __typename?: 'BillingSessionOutput'; + url?: Maybe; +}; + export type BillingSubscription = { __typename?: 'BillingSubscription'; id: Scalars['UUID']['output']; @@ -135,6 +201,23 @@ export type BillingSubscription = { status: SubscriptionStatus; }; +export type BillingTrialPeriodDto = { + __typename?: 'BillingTrialPeriodDTO'; + duration: Scalars['Float']['output']; + isCreditCardRequired: Scalars['Boolean']['output']; +}; + +export type BillingUpdateOutput = { + __typename?: 'BillingUpdateOutput'; + /** Boolean that confirms query was successful */ + success: Scalars['Boolean']['output']; +}; + +export enum BillingUsageType { + LICENSED = 'LICENSED', + METERED = 'METERED' +} + export type BooleanFieldComparison = { is?: InputMaybe; isNot?: InputMaybe; @@ -372,12 +455,11 @@ export enum FeatureFlagKey { IsAdvancedFiltersEnabled = 'IsAdvancedFiltersEnabled', IsAirtableIntegrationEnabled = 'IsAirtableIntegrationEnabled', IsAnalyticsV2Enabled = 'IsAnalyticsV2Enabled', + IsBillingPlansEnabled = 'IsBillingPlansEnabled', IsCommandMenuV2Enabled = 'IsCommandMenuV2Enabled', IsCopilotEnabled = 'IsCopilotEnabled', IsEventObjectEnabled = 'IsEventObjectEnabled', IsFreeAccessEnabled = 'IsFreeAccessEnabled', - IsFunctionSettingsEnabled = 'IsFunctionSettingsEnabled', - IsGmailSendEmailScopeEnabled = 'IsGmailSendEmailScopeEnabled', IsJsonFilterEnabled = 'IsJsonFilterEnabled', IsLocalizationEnabled = 'IsLocalizationEnabled', IsMicrosoftSyncEnabled = 'IsMicrosoftSyncEnabled', @@ -548,7 +630,7 @@ export type Mutation = { activateWorkspace: Workspace; authorizeApp: AuthorizeApp; challenge: LoginToken; - checkoutSession: SessionEntity; + checkoutSession: BillingSessionOutput; computeStepOutputSchema: Scalars['JSON']['output']; createDraftFromWorkflowVersion: WorkflowVersion; createOIDCIdentityProvider: SetupSsoOutput; @@ -593,7 +675,7 @@ export type Mutation = { syncRemoteTableSchemaChanges: RemoteTable; track: Analytics; unsyncRemoteTable: RemoteTable; - updateBillingSubscription: UpdateBillingEntity; + updateBillingSubscription: BillingUpdateOutput; updateLabPublicFeatureFlag: Scalars['Boolean']['output']; updateOneField: Field; updateOneObject: Object; @@ -982,20 +1064,6 @@ export type PostgresCredentials = { workspaceId: Scalars['String']['output']; }; -export type ProductPriceEntity = { - __typename?: 'ProductPriceEntity'; - created: Scalars['Float']['output']; - recurringInterval: SubscriptionInterval; - stripePriceId: Scalars['String']['output']; - unitAmount: Scalars['Float']['output']; -}; - -export type ProductPricesEntity = { - __typename?: 'ProductPricesEntity'; - productPrices: Array; - totalNumberOfPrices: Scalars['Int']['output']; -}; - export type PublicFeatureFlag = { __typename?: 'PublicFeatureFlag'; key: FeatureFlagKey; @@ -1025,7 +1093,7 @@ export type PublishServerlessFunctionInput = { export type Query = { __typename?: 'Query'; - billingPortalSession: SessionEntity; + billingPortalSession: BillingSessionOutput; checkUserExists: UserExistsOutput; checkWorkspaceInviteHashIsValid: WorkspaceInviteHashValid; clientConfig: ClientConfig; @@ -1043,7 +1111,7 @@ export type Query = { findWorkspaceInvitations: Array; getAvailablePackages: Scalars['JSON']['output']; getPostgresCredentials?: Maybe; - getProductPrices: ProductPricesEntity; + getProductPrices: BillingProductPricesOutput; getPublicWorkspaceDataBySubdomain: PublicWorkspaceDataOutput; getServerlessFunctionSourceCode?: Maybe; getTimelineCalendarEventsFromCompanyId: TimelineCalendarEventsWithTotal; @@ -1055,6 +1123,7 @@ export type Query = { listSSOIdentityProvidersByWorkspaceId: Array; object: Object; objects: ObjectConnection; + plans: Array; relation: Relation; relations: RelationConnection; validatePasswordResetToken: ValidatePasswordResetToken; @@ -1368,11 +1437,6 @@ export enum ServerlessFunctionSyncStatus { READY = 'READY' } -export type SessionEntity = { - __typename?: 'SessionEntity'; - url?: Maybe; -}; - export type SetupOidcSsoInput = { clientID: Scalars['String']['input']; clientSecret: Scalars['String']['input']; @@ -1497,12 +1561,6 @@ export type TransientToken = { transientToken: AuthToken; }; -export type TrialPeriodDto = { - __typename?: 'TrialPeriodDTO'; - duration: Scalars['Float']['output']; - isCreditCardRequired: Scalars['Boolean']['output']; -}; - export type UuidFilterComparison = { eq?: InputMaybe; gt?: InputMaybe; @@ -1520,12 +1578,6 @@ export type UuidFilterComparison = { notLike?: InputMaybe; }; -export type UpdateBillingEntity = { - __typename?: 'UpdateBillingEntity'; - /** Boolean that confirms query was successful */ - success: Scalars['Boolean']['output']; -}; - export type UpdateFieldInput = { defaultValue?: InputMaybe; description?: InputMaybe; diff --git a/packages/twenty-front/src/generated/graphql.tsx b/packages/twenty-front/src/generated/graphql.tsx index 7a9b6d25b2d3..61ef992049f7 100644 --- a/packages/twenty-front/src/generated/graphql.tsx +++ b/packages/twenty-front/src/generated/graphql.tsx @@ -112,7 +112,7 @@ export type Billing = { __typename?: 'Billing'; billingUrl?: Maybe; isBillingEnabled: Scalars['Boolean']; - trialPeriods: Array; + trialPeriods: Array; }; /** The different billing plans available */ @@ -121,6 +121,72 @@ export enum BillingPlanKey { PRO = 'PRO' } +export type BillingPlanOutput = { + __typename?: 'BillingPlanOutput'; + baseProduct: BillingProductDto; + meteredProducts: Array; + otherLicensedProducts: Array; + planKey: BillingPlanKey; +}; + +export type BillingPriceLicensedDto = { + __typename?: 'BillingPriceLicensedDTO'; + recurringInterval: SubscriptionInterval; + stripePriceId: Scalars['String']; + unitAmount: Scalars['Float']; +}; + +export type BillingPriceMeteredDto = { + __typename?: 'BillingPriceMeteredDTO'; + recurringInterval: SubscriptionInterval; + stripePriceId: Scalars['String']; + tiers?: Maybe>; + tiersMode?: Maybe; +}; + +export type BillingPriceTierDto = { + __typename?: 'BillingPriceTierDTO'; + flatAmount?: Maybe; + unitAmount?: Maybe; + upTo?: Maybe; +}; + +/** The different billing price tiers modes */ +export enum BillingPriceTiersMode { + GRADUATED = 'GRADUATED', + VOLUME = 'VOLUME' +} + +export type BillingPriceUnionDto = BillingPriceLicensedDto | BillingPriceMeteredDto; + +export type BillingProductDto = { + __typename?: 'BillingProductDTO'; + description: Scalars['String']; + images?: Maybe>; + name: Scalars['String']; + prices: Array>; + type: BillingUsageType; +}; + +export type BillingProductPriceDto = { + __typename?: 'BillingProductPriceDTO'; + created: Scalars['Float']; + recurringInterval: SubscriptionInterval; + stripePriceId: Scalars['String']; + unitAmount: Scalars['Float']; +}; + +export type BillingProductPricesOutput = { + __typename?: 'BillingProductPricesOutput'; + productPrices: Array; + totalNumberOfPrices: Scalars['Int']; +}; + +export type BillingSessionOutput = { + __typename?: 'BillingSessionOutput'; + url?: Maybe; +}; + export type BillingSubscription = { __typename?: 'BillingSubscription'; id: Scalars['UUID']; @@ -128,6 +194,23 @@ export type BillingSubscription = { status: SubscriptionStatus; }; +export type BillingTrialPeriodDto = { + __typename?: 'BillingTrialPeriodDTO'; + duration: Scalars['Float']; + isCreditCardRequired: Scalars['Boolean']; +}; + +export type BillingUpdateOutput = { + __typename?: 'BillingUpdateOutput'; + /** Boolean that confirms query was successful */ + success: Scalars['Boolean']; +}; + +export enum BillingUsageType { + LICENSED = 'LICENSED', + METERED = 'METERED' +} + export type BooleanFieldComparison = { is?: InputMaybe; isNot?: InputMaybe; @@ -304,12 +387,11 @@ export enum FeatureFlagKey { IsAdvancedFiltersEnabled = 'IsAdvancedFiltersEnabled', IsAirtableIntegrationEnabled = 'IsAirtableIntegrationEnabled', IsAnalyticsV2Enabled = 'IsAnalyticsV2Enabled', + IsBillingPlansEnabled = 'IsBillingPlansEnabled', IsCommandMenuV2Enabled = 'IsCommandMenuV2Enabled', IsCopilotEnabled = 'IsCopilotEnabled', IsEventObjectEnabled = 'IsEventObjectEnabled', IsFreeAccessEnabled = 'IsFreeAccessEnabled', - IsFunctionSettingsEnabled = 'IsFunctionSettingsEnabled', - IsGmailSendEmailScopeEnabled = 'IsGmailSendEmailScopeEnabled', IsJsonFilterEnabled = 'IsJsonFilterEnabled', IsLocalizationEnabled = 'IsLocalizationEnabled', IsMicrosoftSyncEnabled = 'IsMicrosoftSyncEnabled', @@ -473,7 +555,7 @@ export type Mutation = { activateWorkspace: Workspace; authorizeApp: AuthorizeApp; challenge: LoginToken; - checkoutSession: SessionEntity; + checkoutSession: BillingSessionOutput; computeStepOutputSchema: Scalars['JSON']; createDraftFromWorkflowVersion: WorkflowVersion; createOIDCIdentityProvider: SetupSsoOutput; @@ -511,7 +593,7 @@ export type Mutation = { signUp: SignUpOutput; skipSyncEmailOnboardingStep: OnboardingStepSuccess; track: Analytics; - updateBillingSubscription: UpdateBillingEntity; + updateBillingSubscription: BillingUpdateOutput; updateLabPublicFeatureFlag: Scalars['Boolean']; updateOneField: Field; updateOneObject: Object; @@ -849,20 +931,6 @@ export type PostgresCredentials = { workspaceId: Scalars['String']; }; -export type ProductPriceEntity = { - __typename?: 'ProductPriceEntity'; - created: Scalars['Float']; - recurringInterval: SubscriptionInterval; - stripePriceId: Scalars['String']; - unitAmount: Scalars['Float']; -}; - -export type ProductPricesEntity = { - __typename?: 'ProductPricesEntity'; - productPrices: Array; - totalNumberOfPrices: Scalars['Int']; -}; - export type PublicFeatureFlag = { __typename?: 'PublicFeatureFlag'; key: FeatureFlagKey; @@ -892,7 +960,7 @@ export type PublishServerlessFunctionInput = { export type Query = { __typename?: 'Query'; - billingPortalSession: SessionEntity; + billingPortalSession: BillingSessionOutput; checkUserExists: UserExistsOutput; checkWorkspaceInviteHashIsValid: WorkspaceInviteHashValid; clientConfig: ClientConfig; @@ -907,7 +975,7 @@ export type Query = { findWorkspaceInvitations: Array; getAvailablePackages: Scalars['JSON']; getPostgresCredentials?: Maybe; - getProductPrices: ProductPricesEntity; + getProductPrices: BillingProductPricesOutput; getPublicWorkspaceDataBySubdomain: PublicWorkspaceDataOutput; getServerlessFunctionSourceCode?: Maybe; getTimelineCalendarEventsFromCompanyId: TimelineCalendarEventsWithTotal; @@ -919,6 +987,7 @@ export type Query = { listSSOIdentityProvidersByWorkspaceId: Array; object: Object; objects: ObjectConnection; + plans: Array; validatePasswordResetToken: ValidatePasswordResetToken; }; @@ -1158,11 +1227,6 @@ export enum ServerlessFunctionSyncStatus { READY = 'READY' } -export type SessionEntity = { - __typename?: 'SessionEntity'; - url?: Maybe; -}; - export type SetupOidcSsoInput = { clientID: Scalars['String']; clientSecret: Scalars['String']; @@ -1287,12 +1351,6 @@ export type TransientToken = { transientToken: AuthToken; }; -export type TrialPeriodDto = { - __typename?: 'TrialPeriodDTO'; - duration: Scalars['Float']; - isCreditCardRequired: Scalars['Boolean']; -}; - export type UuidFilterComparison = { eq?: InputMaybe; gt?: InputMaybe; @@ -1310,12 +1368,6 @@ export type UuidFilterComparison = { notLike?: InputMaybe; }; -export type UpdateBillingEntity = { - __typename?: 'UpdateBillingEntity'; - /** Boolean that confirms query was successful */ - success: Scalars['Boolean']; -}; - export type UpdateFieldInput = { defaultValue?: InputMaybe; description?: InputMaybe; @@ -1988,7 +2040,7 @@ export type BillingPortalSessionQueryVariables = Exact<{ }>; -export type BillingPortalSessionQuery = { __typename?: 'Query', billingPortalSession: { __typename?: 'SessionEntity', url?: string | null } }; +export type BillingPortalSessionQuery = { __typename?: 'Query', billingPortalSession: { __typename?: 'BillingSessionOutput', url?: string | null } }; export type CheckoutSessionMutationVariables = Exact<{ recurringInterval: SubscriptionInterval; @@ -1998,24 +2050,24 @@ export type CheckoutSessionMutationVariables = Exact<{ }>; -export type CheckoutSessionMutation = { __typename?: 'Mutation', checkoutSession: { __typename?: 'SessionEntity', url?: string | null } }; +export type CheckoutSessionMutation = { __typename?: 'Mutation', checkoutSession: { __typename?: 'BillingSessionOutput', url?: string | null } }; export type GetProductPricesQueryVariables = Exact<{ product: Scalars['String']; }>; -export type GetProductPricesQuery = { __typename?: 'Query', getProductPrices: { __typename?: 'ProductPricesEntity', productPrices: Array<{ __typename?: 'ProductPriceEntity', created: number, recurringInterval: SubscriptionInterval, stripePriceId: string, unitAmount: number }> } }; +export type GetProductPricesQuery = { __typename?: 'Query', getProductPrices: { __typename?: 'BillingProductPricesOutput', productPrices: Array<{ __typename?: 'BillingProductPriceDTO', created: number, recurringInterval: SubscriptionInterval, stripePriceId: string, unitAmount: number }> } }; export type UpdateBillingSubscriptionMutationVariables = Exact<{ [key: string]: never; }>; -export type UpdateBillingSubscriptionMutation = { __typename?: 'Mutation', updateBillingSubscription: { __typename?: 'UpdateBillingEntity', success: boolean } }; +export type UpdateBillingSubscriptionMutation = { __typename?: 'Mutation', updateBillingSubscription: { __typename?: 'BillingUpdateOutput', success: boolean } }; export type GetClientConfigQueryVariables = Exact<{ [key: string]: never; }>; -export type GetClientConfigQuery = { __typename?: 'Query', clientConfig: { __typename?: 'ClientConfig', signInPrefilled: boolean, isMultiWorkspaceEnabled: boolean, isEmailVerificationRequired: boolean, defaultSubdomain?: string | null, frontDomain: string, debugMode: boolean, analyticsEnabled: boolean, chromeExtensionId?: string | null, canManageFeatureFlags: boolean, billing: { __typename?: 'Billing', isBillingEnabled: boolean, billingUrl?: string | null, trialPeriods: Array<{ __typename?: 'TrialPeriodDTO', duration: number, isCreditCardRequired: boolean }> }, authProviders: { __typename?: 'AuthProviders', google: boolean, password: boolean, microsoft: boolean, sso: Array<{ __typename?: 'SSOIdentityProvider', id: string, name: string, type: IdentityProviderType, status: SsoIdentityProviderStatus, issuer: string }> }, support: { __typename?: 'Support', supportDriver: string, supportFrontChatId?: string | null }, sentry: { __typename?: 'Sentry', dsn?: string | null, environment?: string | null, release?: string | null }, captcha: { __typename?: 'Captcha', provider?: CaptchaDriverType | null, siteKey?: string | null }, api: { __typename?: 'ApiConfig', mutationMaximumAffectedRecords: number }, publicFeatureFlags: Array<{ __typename?: 'PublicFeatureFlag', key: FeatureFlagKey, metadata: { __typename?: 'PublicFeatureFlagMetadata', label: string, description: string, imagePath: string } }> } }; +export type GetClientConfigQuery = { __typename?: 'Query', clientConfig: { __typename?: 'ClientConfig', signInPrefilled: boolean, isMultiWorkspaceEnabled: boolean, isEmailVerificationRequired: boolean, defaultSubdomain?: string | null, frontDomain: string, debugMode: boolean, analyticsEnabled: boolean, chromeExtensionId?: string | null, canManageFeatureFlags: boolean, billing: { __typename?: 'Billing', isBillingEnabled: boolean, billingUrl?: string | null, trialPeriods: Array<{ __typename?: 'BillingTrialPeriodDTO', duration: number, isCreditCardRequired: boolean }> }, authProviders: { __typename?: 'AuthProviders', google: boolean, password: boolean, microsoft: boolean, sso: Array<{ __typename?: 'SSOIdentityProvider', id: string, name: string, type: IdentityProviderType, status: SsoIdentityProviderStatus, issuer: string }> }, support: { __typename?: 'Support', supportDriver: string, supportFrontChatId?: string | null }, sentry: { __typename?: 'Sentry', dsn?: string | null, environment?: string | null, release?: string | null }, captcha: { __typename?: 'Captcha', provider?: CaptchaDriverType | null, siteKey?: string | null }, api: { __typename?: 'ApiConfig', mutationMaximumAffectedRecords: number }, publicFeatureFlags: Array<{ __typename?: 'PublicFeatureFlag', key: FeatureFlagKey, metadata: { __typename?: 'PublicFeatureFlagMetadata', label: string, description: string, imagePath: string } }> } }; export type SkipSyncEmailOnboardingStepMutationVariables = Exact<{ [key: string]: never; }>; diff --git a/packages/twenty-front/src/modules/action-menu/components/__stories__/RecordIndexActionMenuBar.stories.tsx b/packages/twenty-front/src/modules/action-menu/components/__stories__/RecordIndexActionMenuBar.stories.tsx index ed53303db6f9..4c7b2b538d59 100644 --- a/packages/twenty-front/src/modules/action-menu/components/__stories__/RecordIndexActionMenuBar.stories.tsx +++ b/packages/twenty-front/src/modules/action-menu/components/__stories__/RecordIndexActionMenuBar.stories.tsx @@ -10,6 +10,7 @@ import { getActionBarIdFromActionMenuId } from '@/action-menu/utils/getActionBar import { contextStoreNumberOfSelectedRecordsComponentState } from '@/context-store/states/contextStoreNumberOfSelectedRecordsComponentState'; import { contextStoreTargetedRecordsRuleComponentState } from '@/context-store/states/contextStoreTargetedRecordsRuleComponentState'; import { ContextStoreComponentInstanceContext } from '@/context-store/states/contexts/ContextStoreComponentInstanceContext'; +import { RecordFiltersComponentInstanceContext } from '@/object-record/record-filter/states/context/RecordFiltersComponentInstanceContext'; import { isBottomBarOpenedComponentState } from '@/ui/layout/bottom-bar/states/isBottomBarOpenedComponentState'; import { expect, jest } from '@storybook/jest'; import { Meta, StoryObj } from '@storybook/react'; @@ -25,58 +26,63 @@ const meta: Meta = { decorators: [ RouterDecorator, (Story) => ( - - { - set( - contextStoreTargetedRecordsRuleComponentState.atomFamily({ - instanceId: 'story-action-menu', - }), - { - mode: 'selection', - selectedRecordIds: ['1', '2', '3'], - }, - ); - set( - contextStoreNumberOfSelectedRecordsComponentState.atomFamily({ - instanceId: 'story-action-menu', - }), - 3, - ); - const map = new Map(); - map.set('delete', { - isPinned: true, - scope: ActionMenuEntryScope.RecordSelection, - type: ActionMenuEntryType.Standard, - key: 'delete', - label: 'Delete', - position: 0, - Icon: IconTrash, - onClick: deleteMock, - }); - set( - actionMenuEntriesComponentState.atomFamily({ - instanceId: 'story-action-menu', - }), - map, - ); - set( - isBottomBarOpenedComponentState.atomFamily({ - instanceId: getActionBarIdFromActionMenuId('story-action-menu'), - }), - true, - ); - }} + - { + set( + contextStoreTargetedRecordsRuleComponentState.atomFamily({ + instanceId: 'story-action-menu', + }), + { + mode: 'selection', + selectedRecordIds: ['1', '2', '3'], + }, + ); + set( + contextStoreNumberOfSelectedRecordsComponentState.atomFamily({ + instanceId: 'story-action-menu', + }), + 3, + ); + const map = new Map(); + map.set('delete', { + isPinned: true, + scope: ActionMenuEntryScope.RecordSelection, + type: ActionMenuEntryType.Standard, + key: 'delete', + label: 'Delete', + position: 0, + Icon: IconTrash, + onClick: deleteMock, + }); + set( + actionMenuEntriesComponentState.atomFamily({ + instanceId: 'story-action-menu', + }), + map, + ); + set( + isBottomBarOpenedComponentState.atomFamily({ + instanceId: + getActionBarIdFromActionMenuId('story-action-menu'), + }), + true, + ); + }} > - - - - + + + + + + ), ], args: { diff --git a/packages/twenty-front/src/modules/activities/calendar/right-drawer/hooks/useOpenCalendarEventRightDrawer.ts b/packages/twenty-front/src/modules/activities/calendar/right-drawer/hooks/useOpenCalendarEventRightDrawer.ts index b10743f3533c..4575eb87511a 100644 --- a/packages/twenty-front/src/modules/activities/calendar/right-drawer/hooks/useOpenCalendarEventRightDrawer.ts +++ b/packages/twenty-front/src/modules/activities/calendar/right-drawer/hooks/useOpenCalendarEventRightDrawer.ts @@ -5,6 +5,7 @@ import { useRightDrawer } from '@/ui/layout/right-drawer/hooks/useRightDrawer'; import { RightDrawerHotkeyScope } from '@/ui/layout/right-drawer/types/RightDrawerHotkeyScope'; import { RightDrawerPages } from '@/ui/layout/right-drawer/types/RightDrawerPages'; import { useSetHotkeyScope } from '@/ui/utilities/hotkey/hooks/useSetHotkeyScope'; +import { IconCalendarEvent } from 'twenty-ui'; export const useOpenCalendarEventRightDrawer = () => { const { openRightDrawer } = useRightDrawer(); @@ -13,7 +14,10 @@ export const useOpenCalendarEventRightDrawer = () => { const openCalendarEventRightDrawer = (calendarEventId: string) => { setHotkeyScope(RightDrawerHotkeyScope.RightDrawer, { goto: false }); - openRightDrawer(RightDrawerPages.ViewCalendarEvent); + openRightDrawer(RightDrawerPages.ViewCalendarEvent, { + title: 'Calendar Event', + Icon: IconCalendarEvent, + }); setViewableRecordId(calendarEventId); }; diff --git a/packages/twenty-front/src/modules/activities/copilot/right-drawer/hooks/useOpenCopilotRightDrawer.ts b/packages/twenty-front/src/modules/activities/copilot/right-drawer/hooks/useOpenCopilotRightDrawer.ts index 5369451624e1..8b3e8b4cb36b 100644 --- a/packages/twenty-front/src/modules/activities/copilot/right-drawer/hooks/useOpenCopilotRightDrawer.ts +++ b/packages/twenty-front/src/modules/activities/copilot/right-drawer/hooks/useOpenCopilotRightDrawer.ts @@ -2,6 +2,7 @@ import { useRightDrawer } from '@/ui/layout/right-drawer/hooks/useRightDrawer'; import { RightDrawerHotkeyScope } from '@/ui/layout/right-drawer/types/RightDrawerHotkeyScope'; import { RightDrawerPages } from '@/ui/layout/right-drawer/types/RightDrawerPages'; import { useSetHotkeyScope } from '@/ui/utilities/hotkey/hooks/useSetHotkeyScope'; +import { IconSparkles } from 'twenty-ui'; export const useOpenCopilotRightDrawer = () => { const { openRightDrawer } = useRightDrawer(); @@ -9,6 +10,9 @@ export const useOpenCopilotRightDrawer = () => { return () => { setHotkeyScope(RightDrawerHotkeyScope.RightDrawer, { goto: false }); - openRightDrawer(RightDrawerPages.Copilot); + openRightDrawer(RightDrawerPages.Copilot, { + title: 'Copilot', + Icon: IconSparkles, + }); }; }; diff --git a/packages/twenty-front/src/modules/activities/emails/right-drawer/hooks/__tests__/useOpenEmailThreadRightDrawer.test.ts b/packages/twenty-front/src/modules/activities/emails/right-drawer/hooks/__tests__/useOpenEmailThreadRightDrawer.test.ts index 8660d7617de1..25c21ee10fef 100644 --- a/packages/twenty-front/src/modules/activities/emails/right-drawer/hooks/__tests__/useOpenEmailThreadRightDrawer.test.ts +++ b/packages/twenty-front/src/modules/activities/emails/right-drawer/hooks/__tests__/useOpenEmailThreadRightDrawer.test.ts @@ -4,6 +4,7 @@ import { act } from 'react-dom/test-utils'; import { useOpenEmailThreadRightDrawer } from '@/activities/emails/right-drawer/hooks/useOpenEmailThreadRightDrawer'; import { RightDrawerHotkeyScope } from '@/ui/layout/right-drawer/types/RightDrawerHotkeyScope'; import { RightDrawerPages } from '@/ui/layout/right-drawer/types/RightDrawerPages'; +import { IconMail } from 'twenty-ui'; const mockOpenRightDrawer = jest.fn(); const mockSetHotkeyScope = jest.fn(); @@ -31,5 +32,9 @@ test('useOpenEmailThreadRightDrawer opens the email thread right drawer', () => ); expect(mockOpenRightDrawer).toHaveBeenCalledWith( RightDrawerPages.ViewEmailThread, + { + title: 'Email Thread', + Icon: IconMail, + }, ); }); diff --git a/packages/twenty-front/src/modules/activities/emails/right-drawer/hooks/useOpenEmailThreadRightDrawer.ts b/packages/twenty-front/src/modules/activities/emails/right-drawer/hooks/useOpenEmailThreadRightDrawer.ts index e718d97d2859..1e18a20d4fbe 100644 --- a/packages/twenty-front/src/modules/activities/emails/right-drawer/hooks/useOpenEmailThreadRightDrawer.ts +++ b/packages/twenty-front/src/modules/activities/emails/right-drawer/hooks/useOpenEmailThreadRightDrawer.ts @@ -2,6 +2,7 @@ import { useRightDrawer } from '@/ui/layout/right-drawer/hooks/useRightDrawer'; import { RightDrawerHotkeyScope } from '@/ui/layout/right-drawer/types/RightDrawerHotkeyScope'; import { RightDrawerPages } from '@/ui/layout/right-drawer/types/RightDrawerPages'; import { useSetHotkeyScope } from '@/ui/utilities/hotkey/hooks/useSetHotkeyScope'; +import { IconMail } from 'twenty-ui'; export const useOpenEmailThreadRightDrawer = () => { const { openRightDrawer } = useRightDrawer(); @@ -9,6 +10,9 @@ export const useOpenEmailThreadRightDrawer = () => { return () => { setHotkeyScope(RightDrawerHotkeyScope.RightDrawer, { goto: false }); - openRightDrawer(RightDrawerPages.ViewEmailThread); + openRightDrawer(RightDrawerPages.ViewEmailThread, { + title: 'Email Thread', + Icon: IconMail, + }); }; }; diff --git a/packages/twenty-front/src/modules/app/components/AppRouter.tsx b/packages/twenty-front/src/modules/app/components/AppRouter.tsx index 391142831e25..f4637d2c42b1 100644 --- a/packages/twenty-front/src/modules/app/components/AppRouter.tsx +++ b/packages/twenty-front/src/modules/app/components/AppRouter.tsx @@ -11,9 +11,9 @@ export const AppRouter = () => { const isFreeAccessEnabled = useIsFeatureEnabled( FeatureFlagKey.IsFreeAccessEnabled, ); - const isServerlessFunctionSettingsEnabled = useIsFeatureEnabled( - FeatureFlagKey.IsFunctionSettingsEnabled, - ); + + // We want to disable serverless function settings but keep the code for now + const isFunctionSettingsEnabled = false; const isBillingPageEnabled = billing?.isBillingEnabled && !isFreeAccessEnabled; @@ -26,7 +26,7 @@ export const AppRouter = () => { diff --git a/packages/twenty-front/src/modules/app/components/SettingsRoutes.tsx b/packages/twenty-front/src/modules/app/components/SettingsRoutes.tsx index 4f8c3959b4a7..2b1a7473b7ad 100644 --- a/packages/twenty-front/src/modules/app/components/SettingsRoutes.tsx +++ b/packages/twenty-front/src/modules/app/components/SettingsRoutes.tsx @@ -261,13 +261,13 @@ const SettingsLab = lazy(() => type SettingsRoutesProps = { isBillingEnabled?: boolean; - isServerlessFunctionSettingsEnabled?: boolean; + isFunctionSettingsEnabled?: boolean; isAdminPageEnabled?: boolean; }; export const SettingsRoutes = ({ isBillingEnabled, - isServerlessFunctionSettingsEnabled, + isFunctionSettingsEnabled, isAdminPageEnabled, }: SettingsRoutesProps) => ( }> @@ -305,7 +305,6 @@ export const SettingsRoutes = ({ /> } /> } /> - } @@ -322,7 +321,7 @@ export const SettingsRoutes = ({ path={SettingsPath.DevelopersNewWebhookDetail} element={} /> - {isServerlessFunctionSettingsEnabled && ( + {isFunctionSettingsEnabled && ( <> createBrowserRouter( @@ -62,9 +62,7 @@ export const useCreateAppRouter = ( element={ } diff --git a/packages/twenty-front/src/modules/auth/hooks/useAuth.ts b/packages/twenty-front/src/modules/auth/hooks/useAuth.ts index dec0be5cb7ad..b25b7cb3ecd9 100644 --- a/packages/twenty-front/src/modules/auth/hooks/useAuth.ts +++ b/packages/twenty-front/src/modules/auth/hooks/useAuth.ts @@ -16,7 +16,6 @@ import { isCurrentUserLoadedState } from '@/auth/states/isCurrentUserLoadingStat import { isVerifyPendingState } from '@/auth/states/isVerifyPendingState'; import { workspacesState } from '@/auth/states/workspaces'; import { billingState } from '@/client-config/states/billingState'; -import { captchaProviderState } from '@/client-config/states/captchaProviderState'; import { clientConfigApiStatusState } from '@/client-config/states/clientConfigApiStatusState'; import { isDebugModeState } from '@/client-config/states/isDebugModeState'; import { supportChatState } from '@/client-config/states/supportChatState'; @@ -51,6 +50,7 @@ import { } from '@/auth/states/signInUpStepState'; import { workspacePublicDataState } from '@/auth/states/workspacePublicDataState'; import { BillingCheckoutSession } from '@/auth/types/billingCheckoutSession.type'; +import { captchaState } from '@/client-config/states/captchaState'; import { isEmailVerificationRequiredState } from '@/client-config/states/isEmailVerificationRequiredState'; import { isMultiWorkspaceEnabledState } from '@/client-config/states/isMultiWorkspaceEnabledState'; import { useIsCurrentLocationOnAWorkspaceSubdomain } from '@/domain-manager/hooks/useIsCurrentLocationOnAWorkspaceSubdomain'; @@ -126,9 +126,7 @@ export const useAuth = () => { .getValue(); const supportChat = snapshot.getLoadable(supportChatState).getValue(); const isDebugMode = snapshot.getLoadable(isDebugModeState).getValue(); - const captchaProvider = snapshot - .getLoadable(captchaProviderState) - .getValue(); + const captcha = snapshot.getLoadable(captchaState).getValue(); const clientConfigApiStatus = snapshot .getLoadable(clientConfigApiStatusState) .getValue(); @@ -151,7 +149,7 @@ export const useAuth = () => { ); set(supportChatState, supportChat); set(isDebugModeState, isDebugMode); - set(captchaProviderState, captchaProvider); + set(captchaState, captcha); set(clientConfigApiStatusState, clientConfigApiStatus); set(isCurrentUserLoadedState, isCurrentUserLoaded); set(isMultiWorkspaceEnabledState, isMultiWorkspaceEnabled); diff --git a/packages/twenty-front/src/modules/auth/sign-in-up/components/SignInUpGlobalScopeForm.tsx b/packages/twenty-front/src/modules/auth/sign-in-up/components/SignInUpGlobalScopeForm.tsx index b78b82320677..0d1fd5d173dd 100644 --- a/packages/twenty-front/src/modules/auth/sign-in-up/components/SignInUpGlobalScopeForm.tsx +++ b/packages/twenty-front/src/modules/auth/sign-in-up/components/SignInUpGlobalScopeForm.tsx @@ -21,12 +21,12 @@ import { import { SignInUpMode } from '@/auth/types/signInUpMode'; import { useReadCaptchaToken } from '@/captcha/hooks/useReadCaptchaToken'; import { useRequestFreshCaptchaToken } from '@/captcha/hooks/useRequestFreshCaptchaToken'; +import { isRequestingCaptchaTokenState } from '@/captcha/states/isRequestingCaptchaTokenState'; import { authProvidersState } from '@/client-config/states/authProvidersState'; import { useRedirectToWorkspaceDomain } from '@/domain-manager/hooks/useRedirectToWorkspaceDomain'; import { SnackBarVariant } from '@/ui/feedback/snack-bar-manager/components/SnackBar'; import { useSnackBar } from '@/ui/feedback/snack-bar-manager/hooks/useSnackBar'; import { isDefined } from '~/utils/isDefined'; -import { isRequestingCaptchaTokenState } from '@/captcha/states/isRequestingCaptchaTokenState'; const StyledContentContainer = styled(motion.div)` margin-bottom: ${({ theme }) => theme.spacing(8)}; diff --git a/packages/twenty-front/src/modules/auth/sign-in-up/components/SignInUpWithCredentials.tsx b/packages/twenty-front/src/modules/auth/sign-in-up/components/SignInUpWithCredentials.tsx index e385edd8cf3a..29691cfa7a8c 100644 --- a/packages/twenty-front/src/modules/auth/sign-in-up/components/SignInUpWithCredentials.tsx +++ b/packages/twenty-front/src/modules/auth/sign-in-up/components/SignInUpWithCredentials.tsx @@ -1,21 +1,21 @@ +import { useSignInUp } from '@/auth/sign-in-up/hooks/useSignInUp'; +import { Form } from '@/auth/sign-in-up/hooks/useSignInUpForm'; import { SignInUpStep, signInUpStepState, } from '@/auth/states/signInUpStepState'; -import { useSignInUp } from '@/auth/sign-in-up/hooks/useSignInUp'; -import { Form } from '@/auth/sign-in-up/hooks/useSignInUpForm'; -import { Loader, MainButton } from 'twenty-ui'; -import { isDefined } from '~/utils/isDefined'; import { SignInUpEmailField } from '@/auth/sign-in-up/components/SignInUpEmailField'; -import { useRecoilValue } from 'recoil'; -import styled from '@emotion/styled'; import { SignInUpPasswordField } from '@/auth/sign-in-up/components/SignInUpPasswordField'; -import { useState, useMemo } from 'react'; -import { captchaProviderState } from '@/client-config/states/captchaProviderState'; +import { SignInUpMode } from '@/auth/types/signInUpMode'; import { isRequestingCaptchaTokenState } from '@/captcha/states/isRequestingCaptchaTokenState'; +import { captchaState } from '@/client-config/states/captchaState'; +import styled from '@emotion/styled'; +import { useMemo, useState } from 'react'; import { useFormContext } from 'react-hook-form'; -import { SignInUpMode } from '@/auth/types/signInUpMode'; +import { useRecoilValue } from 'recoil'; +import { Loader, MainButton } from 'twenty-ui'; +import { isDefined } from '~/utils/isDefined'; const StyledForm = styled.form` align-items: center; @@ -29,7 +29,7 @@ export const SignInUpWithCredentials = () => { const signInUpStep = useRecoilValue(signInUpStepState); const [showErrors, setShowErrors] = useState(false); - const captchaProvider = useRecoilValue(captchaProviderState); + const captcha = useRecoilValue(captchaState); const isRequestingCaptchaToken = useRecoilValue( isRequestingCaptchaTokenState, ); @@ -86,7 +86,7 @@ export const SignInUpWithCredentials = () => { const shouldWaitForCaptchaToken = signInUpStep !== SignInUpStep.Init && - isDefined(captchaProvider?.provider) && + isDefined(captcha?.provider) && isRequestingCaptchaToken; const isEmailStepSubmitButtonDisabledCondition = diff --git a/packages/twenty-front/src/modules/auth/sign-in-up/components/SignInUpWorkspaceScopeFormEffect.tsx b/packages/twenty-front/src/modules/auth/sign-in-up/components/SignInUpWorkspaceScopeFormEffect.tsx index 00a7831f121f..894b4eaccd2c 100644 --- a/packages/twenty-front/src/modules/auth/sign-in-up/components/SignInUpWorkspaceScopeFormEffect.tsx +++ b/packages/twenty-front/src/modules/auth/sign-in-up/components/SignInUpWorkspaceScopeFormEffect.tsx @@ -1,15 +1,22 @@ import { useSignInUp } from '@/auth/sign-in-up/hooks/useSignInUp'; import { useSignInUpForm } from '@/auth/sign-in-up/hooks/useSignInUpForm'; import { SignInUpStep } from '@/auth/states/signInUpStepState'; +import { isRequestingCaptchaTokenState } from '@/captcha/states/isRequestingCaptchaTokenState'; +import { captchaState } from '@/client-config/states/captchaState'; import { workspaceAuthProvidersState } from '@/workspace/states/workspaceAuthProvidersState'; import { useEffect, useState } from 'react'; import { useRecoilValue } from 'recoil'; import { isDefined } from '~/utils/isDefined'; -import { isRequestingCaptchaTokenState } from '@/captcha/states/isRequestingCaptchaTokenState'; const searchParams = new URLSearchParams(window.location.search); const email = searchParams.get('email'); +enum LoadingStatus { + Loading = 'loading', + RequestingCaptchaToken = 'requestingCaptchaToken', + Done = 'done', +} + export const SignInUpWorkspaceScopeFormEffect = () => { const workspaceAuthProviders = useRecoilValue(workspaceAuthProvidersState); @@ -17,7 +24,11 @@ export const SignInUpWorkspaceScopeFormEffect = () => { isRequestingCaptchaTokenState, ); - const [isInitialLoading, setIsInitialLoading] = useState(false); + const captcha = useRecoilValue(captchaState); + + const [loadingStatus, setLoadingStatus] = useState( + LoadingStatus.Loading, + ); const { form } = useSignInUpForm(); @@ -25,10 +36,26 @@ export const SignInUpWorkspaceScopeFormEffect = () => { useSignInUp(form); useEffect(() => { - if (!isRequestingCaptchaToken) { - setIsInitialLoading(true); + if (loadingStatus === LoadingStatus.Done) { + return; + } + + if (!isDefined(captcha?.provider)) { + setLoadingStatus(LoadingStatus.Done); + return; + } + + if (isRequestingCaptchaToken) { + setLoadingStatus(LoadingStatus.RequestingCaptchaToken); + } + + if ( + !isRequestingCaptchaToken && + loadingStatus === LoadingStatus.RequestingCaptchaToken + ) { + setLoadingStatus(LoadingStatus.Done); } - }, [isRequestingCaptchaToken]); + }, [captcha?.provider, isRequestingCaptchaToken, loadingStatus]); useEffect(() => { if ( @@ -44,7 +71,7 @@ export const SignInUpWorkspaceScopeFormEffect = () => { if ( isDefined(email) && workspaceAuthProviders.password && - isInitialLoading + loadingStatus === LoadingStatus.Done ) { continueWithCredentials(); } @@ -56,7 +83,7 @@ export const SignInUpWorkspaceScopeFormEffect = () => { workspaceAuthProviders.password, continueWithEmail, continueWithCredentials, - isInitialLoading, + loadingStatus, ]); return <>; diff --git a/packages/twenty-front/src/modules/auth/sign-in-up/hooks/__tests__/useSignInWithGoogle.test.ts b/packages/twenty-front/src/modules/auth/sign-in-up/hooks/__tests__/useSignInWithGoogle.test.ts index 865472c45332..90dde88b0ce9 100644 --- a/packages/twenty-front/src/modules/auth/sign-in-up/hooks/__tests__/useSignInWithGoogle.test.ts +++ b/packages/twenty-front/src/modules/auth/sign-in-up/hooks/__tests__/useSignInWithGoogle.test.ts @@ -8,6 +8,7 @@ import { getJestMetadataAndApolloMocksWrapper } from '~/testing/jest/getJestMeta jest.mock('react-router-dom', () => ({ useParams: jest.fn(), useSearchParams: jest.fn(), + Link: jest.fn(), })); jest.mock('@/auth/hooks/useAuth', () => ({ diff --git a/packages/twenty-front/src/modules/auth/sign-in-up/hooks/__tests__/useSignInWithMicrosoft.test.ts b/packages/twenty-front/src/modules/auth/sign-in-up/hooks/__tests__/useSignInWithMicrosoft.test.ts index e4a5b1b2c0a0..577ee72026e6 100644 --- a/packages/twenty-front/src/modules/auth/sign-in-up/hooks/__tests__/useSignInWithMicrosoft.test.ts +++ b/packages/twenty-front/src/modules/auth/sign-in-up/hooks/__tests__/useSignInWithMicrosoft.test.ts @@ -7,6 +7,7 @@ import { getJestMetadataAndApolloMocksWrapper } from '~/testing/jest/getJestMeta jest.mock('react-router-dom', () => ({ useParams: jest.fn(), useSearchParams: jest.fn(), + Link: jest.fn(), })); jest.mock('@/auth/hooks/useAuth', () => ({ diff --git a/packages/twenty-front/src/modules/captcha/components/CaptchaProviderScriptLoaderEffect.tsx b/packages/twenty-front/src/modules/captcha/components/CaptchaProviderScriptLoaderEffect.tsx index aae90964f1dd..8611436a8da1 100644 --- a/packages/twenty-front/src/modules/captcha/components/CaptchaProviderScriptLoaderEffect.tsx +++ b/packages/twenty-front/src/modules/captcha/components/CaptchaProviderScriptLoaderEffect.tsx @@ -3,23 +3,23 @@ import { useRecoilValue, useSetRecoilState } from 'recoil'; import { isCaptchaScriptLoadedState } from '@/captcha/states/isCaptchaScriptLoadedState'; import { getCaptchaUrlByProvider } from '@/captcha/utils/getCaptchaUrlByProvider'; -import { captchaProviderState } from '@/client-config/states/captchaProviderState'; +import { captchaState } from '@/client-config/states/captchaState'; import { CaptchaDriverType } from '~/generated/graphql'; export const CaptchaProviderScriptLoaderEffect = () => { - const captchaProvider = useRecoilValue(captchaProviderState); + const captcha = useRecoilValue(captchaState); const setIsCaptchaScriptLoaded = useSetRecoilState( isCaptchaScriptLoadedState, ); useEffect(() => { - if (!captchaProvider?.provider || !captchaProvider.siteKey) { + if (!captcha?.provider || !captcha.siteKey) { return; } const scriptUrl = getCaptchaUrlByProvider( - captchaProvider.provider, - captchaProvider.siteKey, + captcha.provider, + captcha.siteKey, ); if (!scriptUrl) { return; @@ -32,7 +32,7 @@ export const CaptchaProviderScriptLoaderEffect = () => { scriptElement = document.createElement('script'); scriptElement.src = scriptUrl; scriptElement.onload = () => { - if (captchaProvider.provider === CaptchaDriverType.GoogleRecaptcha) { + if (captcha.provider === CaptchaDriverType.GoogleRecaptcha) { window.grecaptcha?.ready(() => { setIsCaptchaScriptLoaded(true); }); @@ -42,11 +42,7 @@ export const CaptchaProviderScriptLoaderEffect = () => { }; document.body.appendChild(scriptElement); } - }, [ - captchaProvider?.provider, - captchaProvider?.siteKey, - setIsCaptchaScriptLoaded, - ]); + }, [captcha?.provider, captcha?.siteKey, setIsCaptchaScriptLoaded]); return <>; }; diff --git a/packages/twenty-front/src/modules/captcha/hooks/useRequestFreshCaptchaToken.ts b/packages/twenty-front/src/modules/captcha/hooks/useRequestFreshCaptchaToken.ts index 63e6ee725480..13d1fe2c1c5d 100644 --- a/packages/twenty-front/src/modules/captcha/hooks/useRequestFreshCaptchaToken.ts +++ b/packages/twenty-front/src/modules/captcha/hooks/useRequestFreshCaptchaToken.ts @@ -2,7 +2,7 @@ import { useRecoilCallback, useSetRecoilState } from 'recoil'; import { captchaTokenState } from '@/captcha/states/captchaTokenState'; import { isRequestingCaptchaTokenState } from '@/captcha/states/isRequestingCaptchaTokenState'; -import { captchaProviderState } from '@/client-config/states/captchaProviderState'; +import { captchaState } from '@/client-config/states/captchaState'; import { CaptchaDriverType } from '~/generated-metadata/graphql'; import { isUndefinedOrNull } from '~/utils/isUndefinedOrNull'; @@ -22,11 +22,9 @@ export const useRequestFreshCaptchaToken = () => { const requestFreshCaptchaToken = useRecoilCallback( ({ snapshot }) => async () => { - const captchaProvider = snapshot - .getLoadable(captchaProviderState) - .getValue(); + const captcha = snapshot.getLoadable(captchaState).getValue(); - if (isUndefinedOrNull(captchaProvider)) { + if (isUndefinedOrNull(captcha?.provider)) { return; } @@ -34,10 +32,10 @@ export const useRequestFreshCaptchaToken = () => { let captchaWidget: any; - switch (captchaProvider.provider) { + switch (captcha.provider) { case CaptchaDriverType.GoogleRecaptcha: window.grecaptcha - .execute(captchaProvider.siteKey, { + .execute(captcha.siteKey, { action: 'submit', }) .then((token: string) => { @@ -49,7 +47,7 @@ export const useRequestFreshCaptchaToken = () => { // TODO: fix workspace-no-hardcoded-colors rule // eslint-disable-next-line @nx/workspace-no-hardcoded-colors captchaWidget = window.turnstile.render('#captcha-widget', { - sitekey: captchaProvider.siteKey, + sitekey: captcha.siteKey, }); window.turnstile.execute(captchaWidget, { callback: (token: string) => { diff --git a/packages/twenty-front/src/modules/client-config/components/ClientConfigProviderEffect.tsx b/packages/twenty-front/src/modules/client-config/components/ClientConfigProviderEffect.tsx index 64171344e239..ac34e0a6f444 100644 --- a/packages/twenty-front/src/modules/client-config/components/ClientConfigProviderEffect.tsx +++ b/packages/twenty-front/src/modules/client-config/components/ClientConfigProviderEffect.tsx @@ -2,7 +2,7 @@ import { apiConfigState } from '@/client-config/states/apiConfigState'; import { authProvidersState } from '@/client-config/states/authProvidersState'; import { billingState } from '@/client-config/states/billingState'; import { canManageFeatureFlagsState } from '@/client-config/states/canManageFeatureFlagsState'; -import { captchaProviderState } from '@/client-config/states/captchaProviderState'; +import { captchaState } from '@/client-config/states/captchaState'; import { chromeExtensionIdState } from '@/client-config/states/chromeExtensionIdState'; import { clientConfigApiStatusState } from '@/client-config/states/clientConfigApiStatusState'; import { isAnalyticsEnabledState } from '@/client-config/states/isAnalyticsEnabledState'; @@ -43,7 +43,7 @@ export const ClientConfigProviderEffect = () => { clientConfigApiStatusState, ); - const setCaptchaProvider = useSetRecoilState(captchaProviderState); + const setCaptcha = useSetRecoilState(captchaState); const setChromeExtensionId = useSetRecoilState(chromeExtensionIdState); @@ -110,7 +110,7 @@ export const ClientConfigProviderEffect = () => { environment: data?.clientConfig?.sentry?.environment, }); - setCaptchaProvider({ + setCaptcha({ provider: data?.clientConfig?.captcha?.provider, siteKey: data?.clientConfig?.captcha?.siteKey, }); @@ -134,7 +134,7 @@ export const ClientConfigProviderEffect = () => { setSentryConfig, loading, setClientConfigApiStatus, - setCaptchaProvider, + setCaptcha, setChromeExtensionId, setApiConfig, setIsAnalyticsEnabled, diff --git a/packages/twenty-front/src/modules/client-config/states/captchaProviderState.ts b/packages/twenty-front/src/modules/client-config/states/captchaState.ts similarity index 59% rename from packages/twenty-front/src/modules/client-config/states/captchaProviderState.ts rename to packages/twenty-front/src/modules/client-config/states/captchaState.ts index ca312acf047c..8d22c88f6e33 100644 --- a/packages/twenty-front/src/modules/client-config/states/captchaProviderState.ts +++ b/packages/twenty-front/src/modules/client-config/states/captchaState.ts @@ -2,7 +2,7 @@ import { createState } from '@ui/utilities/state/utils/createState'; import { Captcha } from '~/generated/graphql'; -export const captchaProviderState = createState({ - key: 'captchaProviderState', +export const captchaState = createState({ + key: 'captchaState', defaultValue: null, }); diff --git a/packages/twenty-front/src/modules/command-menu/components/CommandMenuContainer.tsx b/packages/twenty-front/src/modules/command-menu/components/CommandMenuContainer.tsx index b3124e701795..694637ed7b40 100644 --- a/packages/twenty-front/src/modules/command-menu/components/CommandMenuContainer.tsx +++ b/packages/twenty-front/src/modules/command-menu/components/CommandMenuContainer.tsx @@ -9,6 +9,7 @@ import { useCommandMenuHotKeys } from '@/command-menu/hooks/useCommandMenuHotKey import { isCommandMenuOpenedState } from '@/command-menu/states/isCommandMenuOpenedState'; import { CommandMenuAnimationVariant } from '@/command-menu/types/CommandMenuAnimationVariant'; import { ContextStoreComponentInstanceContext } from '@/context-store/states/contexts/ContextStoreComponentInstanceContext'; +import { RecordFiltersComponentInstanceContext } from '@/object-record/record-filter/states/context/RecordFiltersComponentInstanceContext'; import { AppHotkeyScope } from '@/ui/utilities/hotkey/types/AppHotkeyScope'; import { useListenClickOutside } from '@/ui/utilities/pointer-event/hooks/useListenClickOutside'; import { workflowReactFlowRefState } from '@/workflow/workflow-diagram/states/workflowReactFlowRefState'; @@ -74,37 +75,41 @@ export const CommandMenuContainer = ({ const theme = useTheme(); return ( - - - - - {isWorkflowEnabled && } - - {isCommandMenuOpened && ( - - {children} - - )} - - - + + + {isWorkflowEnabled && } + + {isCommandMenuOpened && ( + + {children} + + )} + + + + ); }; diff --git a/packages/twenty-front/src/modules/command-menu/components/CommandMenuContextChip.tsx b/packages/twenty-front/src/modules/command-menu/components/CommandMenuContextChip.tsx new file mode 100644 index 000000000000..119ed77ec432 --- /dev/null +++ b/packages/twenty-front/src/modules/command-menu/components/CommandMenuContextChip.tsx @@ -0,0 +1,63 @@ +import styled from '@emotion/styled'; + +const StyledChip = styled.div` + align-items: center; + background: ${({ theme }) => theme.background.transparent.light}; + border: 1px solid ${({ theme }) => theme.border.color.medium}; + border-radius: ${({ theme }) => theme.border.radius.md}; + box-sizing: border-box; + display: flex; + gap: ${({ theme }) => theme.spacing(1)}; + height: ${({ theme }) => theme.spacing(8)}; + padding: 0 ${({ theme }) => theme.spacing(2)}; + font-size: ${({ theme }) => theme.font.size.sm}; + font-weight: ${({ theme }) => theme.font.weight.medium}; + line-height: ${({ theme }) => theme.text.lineHeight.lg}; + color: ${({ theme }) => theme.font.color.primary}; +`; + +const StyledIconsContainer = styled.div` + display: flex; +`; + +const StyledIconWrapper = styled.div<{ withIconBackground?: boolean }>` + background: ${({ theme, withIconBackground }) => + withIconBackground ? theme.background.primary : 'unset'}; + border-radius: ${({ theme }) => theme.border.radius.sm}; + padding: ${({ theme }) => theme.spacing(0.5)}; + border: 1px solid + ${({ theme, withIconBackground }) => + withIconBackground ? theme.border.color.medium : 'transparent'}; + &:not(:first-of-type) { + margin-left: -${({ theme }) => theme.spacing(1)}; + } + display: flex; + align-items: center; + justify-content: center; +`; + +export const CommandMenuContextChip = ({ + Icons, + text, + withIconBackground, +}: { + Icons: React.ReactNode[]; + text?: string; + withIconBackground?: boolean; +}) => { + return ( + + + {Icons.map((Icon, index) => ( + + {Icon} + + ))} + + {text} + + ); +}; diff --git a/packages/twenty-front/src/modules/command-menu/components/CommandMenuContextRecordChip.tsx b/packages/twenty-front/src/modules/command-menu/components/CommandMenuContextRecordChip.tsx index 0335346d73c7..75f22319a358 100644 --- a/packages/twenty-front/src/modules/command-menu/components/CommandMenuContextRecordChip.tsx +++ b/packages/twenty-front/src/modules/command-menu/components/CommandMenuContextRecordChip.tsx @@ -1,30 +1,10 @@ +import { CommandMenuContextChip } from '@/command-menu/components/CommandMenuContextChip'; import { CommandMenuContextRecordChipAvatars } from '@/command-menu/components/CommandMenuContextRecordChipAvatars'; import { useFindManyRecordsSelectedInContextStore } from '@/context-store/hooks/useFindManyRecordsSelectedInContextStore'; import { useObjectMetadataItemById } from '@/object-metadata/hooks/useObjectMetadataItemById'; import { getObjectRecordIdentifier } from '@/object-metadata/utils/getObjectRecordIdentifier'; -import styled from '@emotion/styled'; import { capitalize } from 'twenty-shared'; -const StyledChip = styled.div` - align-items: center; - background: ${({ theme }) => theme.background.transparent.light}; - border: 1px solid ${({ theme }) => theme.border.color.medium}; - border-radius: ${({ theme }) => theme.border.radius.md}; - box-sizing: border-box; - display: flex; - gap: ${({ theme }) => theme.spacing(1)}; - height: ${({ theme }) => theme.spacing(8)}; - padding: 0 ${({ theme }) => theme.spacing(2)}; - font-size: ${({ theme }) => theme.font.size.sm}; - font-weight: ${({ theme }) => theme.font.weight.medium}; - line-height: ${({ theme }) => theme.text.lineHeight.lg}; - color: ${({ theme }) => theme.font.color.primary}; -`; - -const StyledAvatarContainer = styled.div` - display: flex; -`; - export const CommandMenuContextRecordChip = ({ objectMetadataItemId, }: { @@ -43,21 +23,25 @@ export const CommandMenuContextRecordChip = ({ return null; } + const Avatars = records.map((record) => ( + + )); + + const text = + totalCount === 1 + ? getObjectRecordIdentifier({ objectMetadataItem, record: records[0] }) + .name + : `${totalCount} ${capitalize(objectMetadataItem.namePlural)}`; + return ( - - - {records.map((record) => ( - - ))} - - {totalCount === 1 - ? getObjectRecordIdentifier({ objectMetadataItem, record: records[0] }) - .name - : `${totalCount} ${capitalize(objectMetadataItem.namePlural)}`} - + ); }; diff --git a/packages/twenty-front/src/modules/command-menu/components/CommandMenuContextRecordChipAvatars.tsx b/packages/twenty-front/src/modules/command-menu/components/CommandMenuContextRecordChipAvatars.tsx index a83ab135a057..51792b4cc41d 100644 --- a/packages/twenty-front/src/modules/command-menu/components/CommandMenuContextRecordChipAvatars.tsx +++ b/packages/twenty-front/src/modules/command-menu/components/CommandMenuContextRecordChipAvatars.tsx @@ -3,22 +3,8 @@ import { ObjectMetadataItem } from '@/object-metadata/types/ObjectMetadataItem'; import { useRecordChipData } from '@/object-record/hooks/useRecordChipData'; import { ObjectRecord } from '@/object-record/types/ObjectRecord'; import { useTheme } from '@emotion/react'; -import styled from '@emotion/styled'; import { Avatar } from 'twenty-ui'; -const StyledAvatarWrapper = styled.div` - background-color: ${({ theme }) => theme.background.primary}; - border-radius: ${({ theme }) => theme.border.radius.sm}; - padding: ${({ theme }) => theme.spacing(0.5)}; - border: 1px solid ${({ theme }) => theme.border.color.medium}; - &:not(:first-of-type) { - margin-left: -${({ theme }) => theme.spacing(1)}; - } - display: flex; - align-items: center; - justify-content: center; -`; - export const CommandMenuContextRecordChipAvatars = ({ objectMetadataItem, record, @@ -38,7 +24,7 @@ export const CommandMenuContextRecordChipAvatars = ({ const theme = useTheme(); return ( - + <> {Icon ? ( ) : ( @@ -50,6 +36,6 @@ export const CommandMenuContextRecordChipAvatars = ({ size="sm" /> )} - + ); }; diff --git a/packages/twenty-front/src/modules/command-menu/components/CommandMenuTopBar.tsx b/packages/twenty-front/src/modules/command-menu/components/CommandMenuTopBar.tsx index f7052151f289..2d61d13a3366 100644 --- a/packages/twenty-front/src/modules/command-menu/components/CommandMenuTopBar.tsx +++ b/packages/twenty-front/src/modules/command-menu/components/CommandMenuTopBar.tsx @@ -1,12 +1,15 @@ +import { CommandMenuContextChip } from '@/command-menu/components/CommandMenuContextChip'; import { CommandMenuContextRecordChip } from '@/command-menu/components/CommandMenuContextRecordChip'; import { CommandMenuPages } from '@/command-menu/components/CommandMenuPages'; import { COMMAND_MENU_SEARCH_BAR_HEIGHT } from '@/command-menu/constants/CommandMenuSearchBarHeight'; import { COMMAND_MENU_SEARCH_BAR_PADDING } from '@/command-menu/constants/CommandMenuSearchBarPadding'; import { useCommandMenu } from '@/command-menu/hooks/useCommandMenu'; import { commandMenuPageState } from '@/command-menu/states/commandMenuPageState'; +import { commandMenuPageInfoState } from '@/command-menu/states/commandMenuPageTitle'; import { commandMenuSearchState } from '@/command-menu/states/commandMenuSearchState'; import { contextStoreCurrentObjectMetadataIdComponentState } from '@/context-store/states/contextStoreCurrentObjectMetadataIdComponentState'; import { useRecoilComponentValueV2 } from '@/ui/utilities/state/component-state/hooks/useRecoilComponentValueV2'; +import { useTheme } from '@emotion/react'; import styled from '@emotion/styled'; import { useRecoilState, useRecoilValue } from 'recoil'; import { IconX, LightIconButton, isDefined, useIsMobile } from 'twenty-ui'; @@ -82,6 +85,10 @@ export const CommandMenuTopBar = () => { const commandMenuPage = useRecoilValue(commandMenuPageState); + const { title, Icon } = useRecoilValue(commandMenuPageInfoState); + + const theme = useTheme(); + return ( @@ -90,6 +97,13 @@ export const CommandMenuTopBar = () => { objectMetadataItemId={contextStoreCurrentObjectMetadataId} /> )} + {isDefined(Icon) && ( + ]} + text={title} + /> + )} + {commandMenuPage === CommandMenuPages.Root && ( { return ( - - - - - - - + + + + + + + ); }; diff --git a/packages/twenty-front/src/modules/command-menu/components/__stories__/CommandMenuContextChip.stories.tsx b/packages/twenty-front/src/modules/command-menu/components/__stories__/CommandMenuContextChip.stories.tsx new file mode 100644 index 000000000000..efcee93e3d42 --- /dev/null +++ b/packages/twenty-front/src/modules/command-menu/components/__stories__/CommandMenuContextChip.stories.tsx @@ -0,0 +1,53 @@ +import { Meta, StoryObj } from '@storybook/react'; + +import { + ComponentDecorator, + IconBuildingSkyscraper, + IconUser, +} from 'twenty-ui'; +import { CommandMenuContextChip } from '../CommandMenuContextChip'; + +const meta: Meta = { + title: 'Modules/CommandMenu/CommandMenuContextChip', + component: CommandMenuContextChip, + decorators: [ComponentDecorator], +}; + +export default meta; +type Story = StoryObj; + +export const SingleIcon: Story = { + args: { + Icons: [], + text: 'Person', + }, +}; + +export const MultipleIcons: Story = { + args: { + Icons: [, ], + text: 'Person & Company', + }, +}; + +export const WithIconBackground: Story = { + args: { + Icons: [], + text: 'Person', + withIconBackground: true, + }, +}; + +export const MultipleIconsWithIconBackground: Story = { + args: { + Icons: [, ], + text: 'Person & Company', + withIconBackground: true, + }, +}; + +export const IconsOnly: Story = { + args: { + Icons: [, ], + }, +}; diff --git a/packages/twenty-front/src/modules/command-menu/components/__stories__/CommandMenuContextRecordChip.stories.tsx b/packages/twenty-front/src/modules/command-menu/components/__stories__/CommandMenuContextRecordChip.stories.tsx new file mode 100644 index 000000000000..2a02b9190d2b --- /dev/null +++ b/packages/twenty-front/src/modules/command-menu/components/__stories__/CommandMenuContextRecordChip.stories.tsx @@ -0,0 +1,261 @@ +import { gql } from '@apollo/client'; +import { Decorator, Meta, StoryObj } from '@storybook/react'; + +import { CommandMenuContextRecordChip } from '@/command-menu/components/CommandMenuContextRecordChip'; +import { PreComputedChipGeneratorsContext } from '@/object-metadata/contexts/PreComputedChipGeneratorsContext'; +import { RecordChipData } from '@/object-record/record-field/types/RecordChipData'; +import { recordStoreFamilyState } from '@/object-record/record-store/states/recordStoreFamilyState'; +import { ObjectRecord } from '@/object-record/types/ObjectRecord'; +import { ComponentDecorator } from 'twenty-ui'; +import { getJestMetadataAndApolloMocksAndActionMenuWrapper } from '~/testing/jest/getJestMetadataAndApolloMocksAndContextStoreWrapper'; +import { getCompaniesMock } from '~/testing/mock-data/companies'; +import { generatedMockObjectMetadataItems } from '~/testing/mock-data/generatedMockObjectMetadataItems'; + +const FIND_MANY_COMPANIES = gql` + query FindManyCompanies( + $filter: CompanyFilterInput + $orderBy: [CompanyOrderByInput] + $lastCursor: String + $limit: Int + ) { + companies( + filter: $filter + orderBy: $orderBy + first: $limit + after: $lastCursor + ) { + edges { + node { + __typename + accountOwnerId + address { + addressStreet1 + addressStreet2 + addressCity + addressState + addressCountry + addressPostcode + addressLat + addressLng + } + annualRecurringRevenue { + amountMicros + currencyCode + } + createdAt + createdBy { + source + workspaceMemberId + name + } + deletedAt + domainName { + primaryLinkUrl + primaryLinkLabel + secondaryLinks + } + employees + id + idealCustomerProfile + introVideo { + primaryLinkUrl + primaryLinkLabel + secondaryLinks + } + linkedinLink { + primaryLinkUrl + primaryLinkLabel + secondaryLinks + } + name + position + tagline + updatedAt + visaSponsorship + workPolicy + xLink { + primaryLinkUrl + primaryLinkLabel + secondaryLinks + } + } + cursor + } + pageInfo { + hasNextPage + hasPreviousPage + startCursor + endCursor + } + totalCount + } + } +`; + +const companyMockObjectMetadataItem = generatedMockObjectMetadataItems.find( + (item) => item.nameSingular === 'company', +); + +const companiesMock = getCompaniesMock(); + +const companyMock = companiesMock[0]; + +const chipGeneratorPerObjectPerField: Record< + string, + Record RecordChipData> +> = { + company: { + name: (record: ObjectRecord): RecordChipData => ({ + recordId: record.id, + name: record.name as string, + avatarUrl: '', + avatarType: 'rounded', + isLabelIdentifier: true, + objectNameSingular: 'company', + }), + }, +}; + +const identifierChipGeneratorPerObject: Record< + string, + (record: ObjectRecord) => RecordChipData +> = { + company: chipGeneratorPerObjectPerField.company.name, +}; + +const ChipGeneratorsDecorator: Decorator = (Story) => ( + + + +); + +const createContextStoreWrapper = ({ + companies, + componentInstanceId, +}: { + companies: typeof companiesMock; + componentInstanceId: string; +}) => { + return getJestMetadataAndApolloMocksAndActionMenuWrapper({ + apolloMocks: [ + { + request: { + query: FIND_MANY_COMPANIES, + variables: { + filter: { + id: { in: companies.map((company) => company.id) }, + deletedAt: { is: 'NOT_NULL' }, + }, + orderBy: [{ position: 'AscNullsFirst' }], + limit: 3, + }, + }, + result: { + data: { + companies: { + edges: companies.slice(0, 3).map((company, index) => ({ + node: company, + cursor: `cursor-${index + 1}`, + })), + pageInfo: { + hasNextPage: companies.length > 3, + hasPreviousPage: false, + startCursor: 'cursor-1', + endCursor: + companies.length > 0 + ? `cursor-${Math.min(companies.length, 3)}` + : null, + }, + totalCount: companies.length, + }, + }, + }, + }, + ], + componentInstanceId, + contextStoreCurrentObjectMetadataNameSingular: + companyMockObjectMetadataItem?.nameSingular, + contextStoreTargetedRecordsRule: { + mode: 'selection', + selectedRecordIds: companies.map((company) => company.id), + }, + contextStoreNumberOfSelectedRecords: companies.length, + onInitializeRecoilSnapshot: (snapshot) => { + for (const company of companies) { + snapshot.set(recordStoreFamilyState(company.id), company); + } + }, + }); +}; + +const ContextStoreDecorator: Decorator = (Story) => { + const ContextStoreWrapper = createContextStoreWrapper({ + companies: [companyMock], + componentInstanceId: '1', + }); + + return ( + + + + ); +}; + +const meta: Meta = { + title: 'Modules/CommandMenu/CommandMenuContextRecordChip', + component: CommandMenuContextRecordChip, + decorators: [ + ContextStoreDecorator, + ChipGeneratorsDecorator, + ComponentDecorator, + ], + args: { + objectMetadataItemId: companyMockObjectMetadataItem?.id, + }, +}; + +export default meta; +type Story = StoryObj; + +export const Default: Story = {}; + +export const WithTwoCompanies: Story = { + decorators: [ + (Story) => { + const twoCompaniesMock = companiesMock.slice(0, 2); + const TwoCompaniesWrapper = createContextStoreWrapper({ + companies: twoCompaniesMock, + componentInstanceId: '2', + }); + + return ( + + + + ); + }, + ], +}; + +export const WithTenCompanies: Story = { + decorators: [ + (Story) => { + const tenCompaniesMock = companiesMock.slice(0, 10); + const TenCompaniesWrapper = createContextStoreWrapper({ + companies: tenCompaniesMock, + componentInstanceId: '3', + }); + + return ( + + + + ); + }, + ], +}; diff --git a/packages/twenty-front/src/modules/command-menu/hooks/useCommandMenu.ts b/packages/twenty-front/src/modules/command-menu/hooks/useCommandMenu.ts index 9afb5d1f76b9..a37a2b66a68c 100644 --- a/packages/twenty-front/src/modules/command-menu/hooks/useCommandMenu.ts +++ b/packages/twenty-front/src/modules/command-menu/hooks/useCommandMenu.ts @@ -9,6 +9,7 @@ import { isDefined } from '~/utils/isDefined'; import { actionMenuEntriesComponentState } from '@/action-menu/states/actionMenuEntriesComponentState'; import { CommandMenuPages } from '@/command-menu/components/CommandMenuPages'; import { commandMenuPageState } from '@/command-menu/states/commandMenuPageState'; +import { commandMenuPageInfoState } from '@/command-menu/states/commandMenuPageTitle'; import { contextStoreCurrentObjectMetadataIdComponentState } from '@/context-store/states/contextStoreCurrentObjectMetadataIdComponentState'; import { contextStoreCurrentViewIdComponentState } from '@/context-store/states/contextStoreCurrentViewIdComponentState'; import { contextStoreCurrentViewTypeComponentState } from '@/context-store/states/contextStoreCurrentViewTypeComponentState'; @@ -213,6 +214,10 @@ export const useCommandMenu = () => { set(viewableRecordIdState, null); set(commandMenuPageState, CommandMenuPages.Root); + set(commandMenuPageInfoState, { + title: undefined, + Icon: undefined, + }); set(isCommandMenuOpenedState, false); resetSelectedItem(); goBackToPreviousHotkeyScope(); @@ -278,6 +283,11 @@ export const useCommandMenu = () => { }), null, ); + + set(commandMenuPageInfoState, { + title: undefined, + Icon: undefined, + }); }; }, []); diff --git a/packages/twenty-front/src/modules/command-menu/states/commandMenuPageTitle.ts b/packages/twenty-front/src/modules/command-menu/states/commandMenuPageTitle.ts new file mode 100644 index 000000000000..986421873a13 --- /dev/null +++ b/packages/twenty-front/src/modules/command-menu/states/commandMenuPageTitle.ts @@ -0,0 +1,10 @@ +import { createState } from '@ui/utilities/state/utils/createState'; +import { IconComponent } from 'twenty-ui'; + +export const commandMenuPageInfoState = createState<{ + title: string | undefined; + Icon: IconComponent | undefined; +}>({ + key: 'command-menu/commandMenuPageInfoState', + defaultValue: { title: undefined, Icon: undefined }, +}); diff --git a/packages/twenty-front/src/modules/favorites/components/FavoritesFolders.tsx b/packages/twenty-front/src/modules/favorites/components/FavoritesFolders.tsx index 3fd41f5ef300..fb7c53603154 100644 --- a/packages/twenty-front/src/modules/favorites/components/FavoritesFolders.tsx +++ b/packages/twenty-front/src/modules/favorites/components/FavoritesFolders.tsx @@ -3,6 +3,7 @@ import { FavoriteFolderHotkeyScope } from '@/favorites/constants/FavoriteFolderR import { useCreateFavoriteFolder } from '@/favorites/hooks/useCreateFavoriteFolder'; import { useFavoritesByFolder } from '@/favorites/hooks/useFavoritesByFolder'; import { isFavoriteFolderCreatingState } from '@/favorites/states/isFavoriteFolderCreatingState'; +import { NavigationDrawerAnimatedCollapseWrapper } from '@/ui/navigation/navigation-drawer/components/NavigationDrawerAnimatedCollapseWrapper'; import { NavigationDrawerInput } from '@/ui/navigation/navigation-drawer/components/NavigationDrawerInput'; import { useState } from 'react'; import { useRecoilState } from 'recoil'; @@ -62,15 +63,19 @@ export const FavoriteFolders = ({ return ( <> {isFavoriteFolderCreating && ( - + + + )} {favoritesByFolder.map((folder) => ( { @@ -12,9 +12,10 @@ export const useDeleteFavoriteFolder = () => { objectNameSingular: CoreObjectNameSingular.FavoriteFolder, }); - const { upsertRecordsInCache } = usePrefetchRunQuery({ - prefetchKey: PrefetchKey.AllFavorites, - }); + const { upsertRecordsInCache } = + useUpsertRecordsInCacheForPrefetchKey({ + prefetchKey: PrefetchKey.AllFavorites, + }); const { objectMetadataItem } = useObjectMetadataItem({ objectNameSingular: diff --git a/packages/twenty-front/src/modules/favorites/hooks/usePrefetchedFavoritesData.ts b/packages/twenty-front/src/modules/favorites/hooks/usePrefetchedFavoritesData.ts index 7eae9cf3a95c..ece3d2bdf3f3 100644 --- a/packages/twenty-front/src/modules/favorites/hooks/usePrefetchedFavoritesData.ts +++ b/packages/twenty-front/src/modules/favorites/hooks/usePrefetchedFavoritesData.ts @@ -1,6 +1,6 @@ import { currentWorkspaceMemberState } from '@/auth/states/currentWorkspaceMemberState'; import { Favorite } from '@/favorites/types/Favorite'; -import { usePrefetchRunQuery } from '@/prefetch/hooks/internal/usePrefetchRunQuery'; +import { useUpsertRecordsInCacheForPrefetchKey } from '@/prefetch/hooks/internal/useUpsertRecordsInCacheForPrefetchKey'; import { usePrefetchedData } from '@/prefetch/hooks/usePrefetchedData'; import { PrefetchKey } from '@/prefetch/types/PrefetchKey'; import { useRecoilValue } from 'recoil'; @@ -33,7 +33,7 @@ export const usePrefetchedFavoritesData = (): PrefetchedFavoritesData => { ); const { upsertRecordsInCache: upsertFavorites } = - usePrefetchRunQuery({ + useUpsertRecordsInCacheForPrefetchKey({ prefetchKey: PrefetchKey.AllFavorites, }); diff --git a/packages/twenty-front/src/modules/favorites/hooks/usePrefetchedFavoritesFoldersData.ts b/packages/twenty-front/src/modules/favorites/hooks/usePrefetchedFavoritesFoldersData.ts index b794dc2ec494..32972ed05697 100644 --- a/packages/twenty-front/src/modules/favorites/hooks/usePrefetchedFavoritesFoldersData.ts +++ b/packages/twenty-front/src/modules/favorites/hooks/usePrefetchedFavoritesFoldersData.ts @@ -1,6 +1,6 @@ import { currentWorkspaceMemberState } from '@/auth/states/currentWorkspaceMemberState'; import { FavoriteFolder } from '@/favorites/types/FavoriteFolder'; -import { usePrefetchRunQuery } from '@/prefetch/hooks/internal/usePrefetchRunQuery'; +import { useUpsertRecordsInCacheForPrefetchKey } from '@/prefetch/hooks/internal/useUpsertRecordsInCacheForPrefetchKey'; import { usePrefetchedData } from '@/prefetch/hooks/usePrefetchedData'; import { PrefetchKey } from '@/prefetch/types/PrefetchKey'; import { useRecoilValue } from 'recoil'; @@ -26,7 +26,7 @@ export const usePrefetchedFavoritesFoldersData = ); const { upsertRecordsInCache: upsertFavoriteFolders } = - usePrefetchRunQuery({ + useUpsertRecordsInCacheForPrefetchKey({ prefetchKey: PrefetchKey.AllFavoritesFolders, }); diff --git a/packages/twenty-front/src/modules/object-metadata/hooks/__mocks__/useCreateOneObjectMetadataItem.ts b/packages/twenty-front/src/modules/object-metadata/hooks/__mocks__/useCreateOneObjectMetadataItem.ts index ce4d5cfbe41b..170d396b969a 100644 --- a/packages/twenty-front/src/modules/object-metadata/hooks/__mocks__/useCreateOneObjectMetadataItem.ts +++ b/packages/twenty-front/src/modules/object-metadata/hooks/__mocks__/useCreateOneObjectMetadataItem.ts @@ -81,6 +81,6 @@ export const responseData = { isActive: true, createdAt: '', updatedAt: '', - labelIdentifierFieldMetadataId: '', + labelIdentifierFieldMetadataId: '20202020-72ba-4e11-a36d-e17b544541e1', imageIdentifierFieldMetadataId: '', }; diff --git a/packages/twenty-front/src/modules/object-metadata/hooks/__mocks__/useDeleteOneObjectMetadataItem.ts b/packages/twenty-front/src/modules/object-metadata/hooks/__mocks__/useDeleteOneObjectMetadataItem.ts index e7be9105bf9f..a580e6e4f6de 100644 --- a/packages/twenty-front/src/modules/object-metadata/hooks/__mocks__/useDeleteOneObjectMetadataItem.ts +++ b/packages/twenty-front/src/modules/object-metadata/hooks/__mocks__/useDeleteOneObjectMetadataItem.ts @@ -36,6 +36,6 @@ export const responseData = { isActive: true, createdAt: '', updatedAt: '', - labelIdentifierFieldMetadataId: '', + labelIdentifierFieldMetadataId: '20202020-72ba-4e11-a36d-e17b544541e1', imageIdentifierFieldMetadataId: '', }; diff --git a/packages/twenty-front/src/modules/object-metadata/hooks/__mocks__/useFilteredObjectMetadataItems.ts b/packages/twenty-front/src/modules/object-metadata/hooks/__mocks__/useFilteredObjectMetadataItems.ts index a2419898a788..8e5ba1ea35fa 100644 --- a/packages/twenty-front/src/modules/object-metadata/hooks/__mocks__/useFilteredObjectMetadataItems.ts +++ b/packages/twenty-front/src/modules/object-metadata/hooks/__mocks__/useFilteredObjectMetadataItems.ts @@ -50,6 +50,6 @@ export const responseData = { isActive: true, createdAt: '', updatedAt: '', - labelIdentifierFieldMetadataId: '', + labelIdentifierFieldMetadataId: '20202020-72ba-4e11-a36d-e17b544541e1', imageIdentifierFieldMetadataId: '', }; diff --git a/packages/twenty-front/src/modules/object-metadata/types/ObjectMetadataItem.ts b/packages/twenty-front/src/modules/object-metadata/types/ObjectMetadataItem.ts index 61c0fc495fea..09c23eeb8aa2 100644 --- a/packages/twenty-front/src/modules/object-metadata/types/ObjectMetadataItem.ts +++ b/packages/twenty-front/src/modules/object-metadata/types/ObjectMetadataItem.ts @@ -5,9 +5,14 @@ import { FieldMetadataItem } from './FieldMetadataItem'; export type ObjectMetadataItem = Omit< GeneratedObject, - '__typename' | 'fields' | 'dataSourceId' | 'indexMetadatas' + | '__typename' + | 'fields' + | 'dataSourceId' + | 'indexMetadatas' + | 'labelIdentifierFieldMetadataId' > & { __typename?: string; fields: FieldMetadataItem[]; + labelIdentifierFieldMetadataId: string; indexMetadatas: IndexMetadataItem[]; }; diff --git a/packages/twenty-front/src/modules/object-metadata/utils/__tests__/isLabelIdentifierField.test.ts b/packages/twenty-front/src/modules/object-metadata/utils/__tests__/isLabelIdentifierField.test.ts index 85ad1f0eb429..6cd5dd8c1119 100644 --- a/packages/twenty-front/src/modules/object-metadata/utils/__tests__/isLabelIdentifierField.test.ts +++ b/packages/twenty-front/src/modules/object-metadata/utils/__tests__/isLabelIdentifierField.test.ts @@ -1,11 +1,23 @@ import { isLabelIdentifierField } from '@/object-metadata/utils/isLabelIdentifierField'; describe('isLabelIdentifierField', () => { - it('should work as expected', () => { + it('should not find unknown labelIdentifier', () => { const res = isLabelIdentifierField({ fieldMetadataItem: { id: 'fieldId', name: 'fieldName' }, - objectMetadataItem: {}, + objectMetadataItem: { + labelIdentifierFieldMetadataId: 'unknown', + }, }); expect(res).toBe(false); }); + + it('should find known labelIdentifier', () => { + const res = isLabelIdentifierField({ + fieldMetadataItem: { id: 'fieldId', name: 'fieldName' }, + objectMetadataItem: { + labelIdentifierFieldMetadataId: 'fieldId', + }, + }); + expect(res).toBe(true); + }); }); diff --git a/packages/twenty-front/src/modules/object-metadata/utils/mapPaginatedObjectMetadataItemsToObjectMetadataItems.ts b/packages/twenty-front/src/modules/object-metadata/utils/mapPaginatedObjectMetadataItemsToObjectMetadataItems.ts index 0ab2a8229854..50c6d003131e 100644 --- a/packages/twenty-front/src/modules/object-metadata/utils/mapPaginatedObjectMetadataItemsToObjectMetadataItems.ts +++ b/packages/twenty-front/src/modules/object-metadata/utils/mapPaginatedObjectMetadataItemsToObjectMetadataItems.ts @@ -1,5 +1,5 @@ +import { objectMetadataItemSchema } from '@/object-metadata/validation-schemas/objectMetadataItemSchema'; import { ObjectMetadataItemsQuery } from '~/generated-metadata/graphql'; - import { ObjectMetadataItem } from '../types/ObjectMetadataItem'; export const mapPaginatedObjectMetadataItemsToObjectMetadataItems = ({ @@ -8,16 +8,24 @@ export const mapPaginatedObjectMetadataItemsToObjectMetadataItems = ({ pagedObjectMetadataItems: ObjectMetadataItemsQuery | undefined; }) => { const formattedObjects: ObjectMetadataItem[] = - pagedObjectMetadataItems?.objects.edges.map((object) => ({ - ...object.node, - fields: object.node.fields.edges.map((field) => field.node), - indexMetadatas: object.node.indexMetadatas?.edges.map((index) => ({ - ...index.node, - indexFieldMetadatas: index.node.indexFieldMetadatas?.edges.map( - (indexField) => indexField.node, - ), - })), - })) ?? []; + pagedObjectMetadataItems?.objects.edges.map((object) => { + const labelIdentifierFieldMetadataId = + objectMetadataItemSchema.shape.labelIdentifierFieldMetadataId.parse( + object.node.labelIdentifierFieldMetadataId, + ); + + return { + ...object.node, + fields: object.node.fields.edges.map((field) => field.node), + labelIdentifierFieldMetadataId, + indexMetadatas: object.node.indexMetadatas?.edges.map((index) => ({ + ...index.node, + indexFieldMetadatas: index.node.indexFieldMetadatas?.edges.map( + (indexField) => indexField.node, + ), + })), + }; + }) ?? []; return formattedObjects; }; diff --git a/packages/twenty-front/src/modules/object-metadata/validation-schemas/__tests__/objectMetadataItemSchema.test.ts b/packages/twenty-front/src/modules/object-metadata/validation-schemas/__tests__/objectMetadataItemSchema.test.ts index 64e513214f1c..8697d279a995 100644 --- a/packages/twenty-front/src/modules/object-metadata/validation-schemas/__tests__/objectMetadataItemSchema.test.ts +++ b/packages/twenty-front/src/modules/object-metadata/validation-schemas/__tests__/objectMetadataItemSchema.test.ts @@ -1,3 +1,4 @@ +import { ObjectMetadataItem } from '@/object-metadata/types/ObjectMetadataItem'; import { generatedMockObjectMetadataItems } from '~/testing/mock-data/generatedMockObjectMetadataItems'; import { objectMetadataItemSchema } from '../objectMetadataItemSchema'; @@ -15,16 +16,37 @@ describe('objectMetadataItemSchema', () => { expect(result).toEqual(validObjectMetadataItem); }); + it('fails for an invalid object metadata item that has null labelIdentifier', () => { + // Given + const validObjectMetadataItem = generatedMockObjectMetadataItems.find( + (item) => item.nameSingular === 'company', + ); + expect(validObjectMetadataItem).not.toBeUndefined(); + if (validObjectMetadataItem === undefined) + throw new Error('Should never occurs'); + + // When + const result = objectMetadataItemSchema.safeParse({ + ...validObjectMetadataItem, + labelIdentifierFieldMetadataId: null, + }); + + // Then + expect(result.success).toEqual(false); + }); + it('fails for an invalid object metadata item', () => { // Given - const invalidObjectMetadataItem = { + const invalidObjectMetadataItem: Partial< + Record + > = { createdAt: 'invalid date', - dataSourceId: 'invalid uuid', fields: 'not an array', icon: 'invalid icon', isActive: 'not a boolean', isCustom: 'not a boolean', isSystem: 'not a boolean', + labelIdentifierFieldMetadataId: 'not a uuid', labelPlural: 123, labelSingular: 123, namePlural: 'notCamelCase', @@ -41,4 +63,22 @@ describe('objectMetadataItemSchema', () => { // Then expect(result.success).toBe(false); }); + + it('should fail to parse empty string as LabelIdentifier', () => { + const emptyString = ''; + const result = + objectMetadataItemSchema.shape.labelIdentifierFieldMetadataId.safeParse( + emptyString, + ); + expect(result.success).toBe(false); + }); + + it('should succeed to parse valid uuid as LabelIdentifier', () => { + const validUuid = '20202020-ae24-4871-b445-10cc8872cb10'; + const result = + objectMetadataItemSchema.shape.labelIdentifierFieldMetadataId.safeParse( + validUuid, + ); + expect(result.success).toBe(true); + }); }); diff --git a/packages/twenty-front/src/modules/object-metadata/validation-schemas/objectMetadataItemSchema.ts b/packages/twenty-front/src/modules/object-metadata/validation-schemas/objectMetadataItemSchema.ts index 1c40e625864d..84ed6083cc7e 100644 --- a/packages/twenty-front/src/modules/object-metadata/validation-schemas/objectMetadataItemSchema.ts +++ b/packages/twenty-front/src/modules/object-metadata/validation-schemas/objectMetadataItemSchema.ts @@ -20,7 +20,7 @@ export const objectMetadataItemSchema = z.object({ isCustom: z.boolean(), isRemote: z.boolean(), isSystem: z.boolean(), - labelIdentifierFieldMetadataId: z.string().uuid().nullable(), + labelIdentifierFieldMetadataId: z.string().uuid(), labelPlural: metadataLabelSchema(), labelSingular: metadataLabelSchema(), namePlural: camelCaseStringSchema, diff --git a/packages/twenty-front/src/modules/object-record/hooks/__tests__/useAggregateRecordsQuery.test.tsx b/packages/twenty-front/src/modules/object-record/hooks/__tests__/useAggregateRecordsQuery.test.tsx index d2d3faef8f10..7b94218aa0a1 100644 --- a/packages/twenty-front/src/modules/object-record/hooks/__tests__/useAggregateRecordsQuery.test.tsx +++ b/packages/twenty-front/src/modules/object-record/hooks/__tests__/useAggregateRecordsQuery.test.tsx @@ -17,12 +17,13 @@ const mockObjectMetadataItem: ObjectMetadataItem = { labelSingular: 'Company', labelPlural: 'Companies', isCustom: false, + labelIdentifierFieldMetadataId: '20202020-dd4a-4ea4-bb7b-1c7300491b65', isActive: true, createdAt: new Date().toISOString(), updatedAt: new Date().toISOString(), fields: [ { - id: 'field-1', + id: '20202020-fed9-4ce5-9502-02a8efaf46e1', name: 'amount', label: 'Amount', type: FieldMetadataType.NUMBER, @@ -32,7 +33,7 @@ const mockObjectMetadataItem: ObjectMetadataItem = { updatedAt: new Date().toISOString(), } as FieldMetadataItem, { - id: 'field-2', + id: '20202020-dd4a-4ea4-bb7b-1c7300491b65', name: 'name', label: 'Name', type: FieldMetadataType.TEXT, diff --git a/packages/twenty-front/src/modules/object-record/object-filter-dropdown/components/ObjectFilterDropdownSourceSelect.tsx b/packages/twenty-front/src/modules/object-record/object-filter-dropdown/components/ObjectFilterDropdownSourceSelect.tsx index 92b77bd0830b..5639d82ea1f9 100644 --- a/packages/twenty-front/src/modules/object-record/object-filter-dropdown/components/ObjectFilterDropdownSourceSelect.tsx +++ b/packages/twenty-front/src/modules/object-record/object-filter-dropdown/components/ObjectFilterDropdownSourceSelect.tsx @@ -9,6 +9,7 @@ import { selectedFilterComponentState } from '@/object-record/object-filter-drop import { selectedOperandInDropdownComponentState } from '@/object-record/object-filter-dropdown/states/selectedOperandInDropdownComponentState'; import { getActorSourceMultiSelectOptions } from '@/object-record/object-filter-dropdown/utils/getActorSourceMultiSelectOptions'; import { useApplyRecordFilter } from '@/object-record/record-filter/hooks/useApplyRecordFilter'; +import { useRemoveRecordFilter } from '@/object-record/record-filter/hooks/useRemoveRecordFilter'; import { RelationPickerHotkeyScope } from '@/object-record/relation-picker/types/RelationPickerHotkeyScope'; import { MultipleSelectDropdown } from '@/object-record/select/components/MultipleSelectDropdown'; import { SelectableItem } from '@/object-record/select/types/SelectableItem'; @@ -61,6 +62,7 @@ export const ObjectFilterDropdownSourceSelect = ({ const { currentViewWithCombinedFiltersAndSorts } = useGetCurrentView(viewComponentId); + // TODO: this should be removed as it is not consistent across re-renders const [fieldId] = useState(v4()); const sourceTypes = getActorSourceMultiSelectOptions( @@ -73,6 +75,8 @@ export const ObjectFilterDropdownSourceSelect = ({ const { emptyRecordFilter } = useEmptyRecordFilter(); + const { removeRecordFilter } = useRemoveRecordFilter(); + const handleMultipleItemSelectChange = ( itemToSelect: SelectableItem, newSelectedValue: boolean, @@ -83,8 +87,13 @@ export const ObjectFilterDropdownSourceSelect = ({ (id) => id !== itemToSelect.id, ); + if (!filterDefinitionUsedInDropdown) { + throw new Error('Filter definition used in dropdown should be defined'); + } + if (newSelectedItemIds.length === 0) { emptyRecordFilter(); + removeRecordFilter(filterDefinitionUsedInDropdown.fieldMetadataId); deleteCombinedViewFilter(fieldId); return; } diff --git a/packages/twenty-front/src/modules/object-record/object-filter-dropdown/components/__stories__/MultipleFiltersDropdownButton.stories.tsx b/packages/twenty-front/src/modules/object-record/object-filter-dropdown/components/__stories__/MultipleFiltersDropdownButton.stories.tsx index 250b9b364a94..7120a230f3cf 100644 --- a/packages/twenty-front/src/modules/object-record/object-filter-dropdown/components/__stories__/MultipleFiltersDropdownButton.stories.tsx +++ b/packages/twenty-front/src/modules/object-record/object-filter-dropdown/components/__stories__/MultipleFiltersDropdownButton.stories.tsx @@ -5,6 +5,7 @@ import { CoreObjectNamePlural } from '@/object-metadata/types/CoreObjectNamePlur import { CoreObjectNameSingular } from '@/object-metadata/types/CoreObjectNameSingular'; import { MultipleFiltersDropdownButton } from '@/object-record/object-filter-dropdown/components/MultipleFiltersDropdownButton'; import { ObjectFilterDropdownComponentInstanceContext } from '@/object-record/object-filter-dropdown/states/contexts/ObjectFilterDropdownComponentInstanceContext'; +import { RecordFiltersComponentInstanceContext } from '@/object-record/record-filter/states/context/RecordFiltersComponentInstanceContext'; import { RecordIndexContextProvider } from '@/object-record/record-index/contexts/RecordIndexContext'; import { RecordTableComponentInstanceContext } from '@/object-record/record-table/states/context/RecordTableComponentInstanceContext'; import { tableColumnsComponentState } from '@/object-record/record-table/states/tableColumnsComponentState'; @@ -107,17 +108,21 @@ const meta: Meta = { recordIndexId: instanceId, }} > - - {} }} + - - - - - + {} }} + > + + + + + + ); }, diff --git a/packages/twenty-front/src/modules/object-record/object-filter-dropdown/states/availableFilterDefinitionsComponentState.ts b/packages/twenty-front/src/modules/object-record/object-filter-dropdown/states/availableFilterDefinitionsComponentState.ts index 52ae7db07f25..ddc529f0002a 100644 --- a/packages/twenty-front/src/modules/object-record/object-filter-dropdown/states/availableFilterDefinitionsComponentState.ts +++ b/packages/twenty-front/src/modules/object-record/object-filter-dropdown/states/availableFilterDefinitionsComponentState.ts @@ -1,5 +1,6 @@ import { ObjectFilterDropdownComponentInstanceContext } from '@/object-record/object-filter-dropdown/states/contexts/ObjectFilterDropdownComponentInstanceContext'; import { RecordFilterDefinition } from '@/object-record/record-filter/types/RecordFilterDefinition'; + import { createComponentStateV2 } from '@/ui/utilities/state/component-state/utils/createComponentStateV2'; export const availableFilterDefinitionsComponentState = createComponentStateV2< diff --git a/packages/twenty-front/src/modules/object-record/object-options-dropdown/components/__stories__/ObjectOptionsDropdownContent.stories.tsx b/packages/twenty-front/src/modules/object-record/object-options-dropdown/components/__stories__/ObjectOptionsDropdownContent.stories.tsx index ce819f7496e6..bb06fb6617fc 100644 --- a/packages/twenty-front/src/modules/object-record/object-options-dropdown/components/__stories__/ObjectOptionsDropdownContent.stories.tsx +++ b/packages/twenty-front/src/modules/object-record/object-options-dropdown/components/__stories__/ObjectOptionsDropdownContent.stories.tsx @@ -7,6 +7,7 @@ import { ObjectOptionsDropdownContent } from '@/object-record/object-options-dro import { OBJECT_OPTIONS_DROPDOWN_ID } from '@/object-record/object-options-dropdown/constants/ObjectOptionsDropdownId'; import { ObjectOptionsDropdownContext } from '@/object-record/object-options-dropdown/states/contexts/ObjectOptionsDropdownContext'; import { ObjectOptionsContentId } from '@/object-record/object-options-dropdown/types/ObjectOptionsContentId'; +import { RecordFiltersComponentInstanceContext } from '@/object-record/record-filter/states/context/RecordFiltersComponentInstanceContext'; import { RecordIndexContextProvider } from '@/object-record/record-index/contexts/RecordIndexContext'; import { RecordTableComponentInstanceContext } from '@/object-record/record-table/states/context/RecordTableComponentInstanceContext'; import { DropdownMenu } from '@/ui/layout/dropdown/components/DropdownMenu'; @@ -37,22 +38,26 @@ const meta: Meta = { }, [setObjectMetadataItems]); return ( - {} }} + - - - {} }} + > + + - - - - - + + + + + + + ); }, ObjectMetadataItemsDecorator, diff --git a/packages/twenty-front/src/modules/object-record/object-sort-dropdown/utils/__tests__/turnSortsIntoOrderBy.test.ts b/packages/twenty-front/src/modules/object-record/object-sort-dropdown/utils/__tests__/turnSortsIntoOrderBy.test.ts index 6d3d8b92e904..4b0b58c2ee83 100644 --- a/packages/twenty-front/src/modules/object-record/object-sort-dropdown/utils/__tests__/turnSortsIntoOrderBy.test.ts +++ b/packages/twenty-front/src/modules/object-record/object-sort-dropdown/utils/__tests__/turnSortsIntoOrderBy.test.ts @@ -18,6 +18,7 @@ const objectMetadataItem: ObjectMetadataItem = { updatedAt: '2021-01-01', nameSingular: 'object1', namePlural: 'object1s', + labelIdentifierFieldMetadataId: '20202020-72ba-4e11-a36d-e17b544541e1', icon: 'icon', isActive: true, isSystem: false, diff --git a/packages/twenty-front/src/modules/object-record/record-board/record-board-column/utils/__tests__/buildRecordGqlFieldsAggregateForView.test.ts b/packages/twenty-front/src/modules/object-record/record-board/record-board-column/utils/__tests__/buildRecordGqlFieldsAggregateForView.test.ts index b442b22f9d5c..e79eb3a3a96a 100644 --- a/packages/twenty-front/src/modules/object-record/record-board/record-board-column/utils/__tests__/buildRecordGqlFieldsAggregateForView.test.ts +++ b/packages/twenty-front/src/modules/object-record/record-board/record-board-column/utils/__tests__/buildRecordGqlFieldsAggregateForView.test.ts @@ -6,7 +6,6 @@ import { AGGREGATE_OPERATIONS } from '@/object-record/record-table/constants/Agg import { FieldMetadataType } from '~/generated-metadata/graphql'; const MOCK_FIELD_ID = '7d2d7b5e-7b3e-4b4a-8b0a-7b3e4b4a8b0a'; -const MOCK_KANBAN_FIELD = 'stage'; describe('buildRecordGqlFieldsAggregateForView', () => { const mockObjectMetadata: ObjectMetadataItem = { @@ -19,7 +18,7 @@ describe('buildRecordGqlFieldsAggregateForView', () => { isActive: true, isSystem: false, isRemote: false, - labelIdentifierFieldMetadataId: null, + labelIdentifierFieldMetadataId: '06b33746-5293-4d07-9f7f-ebf5ad396064', imageIdentifierFieldMetadataId: null, isLabelSyncedWithName: true, fields: [ @@ -53,7 +52,6 @@ describe('buildRecordGqlFieldsAggregateForView', () => { const result = buildRecordGqlFieldsAggregateForView({ objectMetadataItem: mockObjectMetadata, recordIndexKanbanAggregateOperation: kanbanAggregateOperation, - fieldNameForCount: MOCK_KANBAN_FIELD, }); expect(result).toEqual({ @@ -70,11 +68,10 @@ describe('buildRecordGqlFieldsAggregateForView', () => { const result = buildRecordGqlFieldsAggregateForView({ objectMetadataItem: mockObjectMetadata, recordIndexKanbanAggregateOperation: operation, - fieldNameForCount: MOCK_KANBAN_FIELD, }); expect(result).toEqual({ - [MOCK_KANBAN_FIELD]: [AGGREGATE_OPERATIONS.count], + id: [AGGREGATE_OPERATIONS.count], }); }); @@ -88,7 +85,6 @@ describe('buildRecordGqlFieldsAggregateForView', () => { buildRecordGqlFieldsAggregateForView({ objectMetadataItem: mockObjectMetadata, recordIndexKanbanAggregateOperation: operation, - fieldNameForCount: MOCK_KANBAN_FIELD, }), ).toThrow( `No field found to compute aggregate operation ${AGGREGATE_OPERATIONS.sum} on object ${mockObjectMetadata.nameSingular}`, diff --git a/packages/twenty-front/src/modules/object-record/record-board/record-board-column/utils/__tests__/computeAggregateValueAndLabel.test.ts b/packages/twenty-front/src/modules/object-record/record-board/record-board-column/utils/__tests__/computeAggregateValueAndLabel.test.ts index f04a92574ce7..7a48508d3db3 100644 --- a/packages/twenty-front/src/modules/object-record/record-board/record-board-column/utils/__tests__/computeAggregateValueAndLabel.test.ts +++ b/packages/twenty-front/src/modules/object-record/record-board/record-board-column/utils/__tests__/computeAggregateValueAndLabel.test.ts @@ -9,7 +9,6 @@ import { DATE_AGGREGATE_OPERATIONS } from '@/object-record/record-table/constant import { FieldMetadataType } from '~/generated/graphql'; const MOCK_FIELD_ID = '7d2d7b5e-7b3e-4b4a-8b0a-7b3e4b4a8b0a'; -const MOCK_KANBAN_FIELD_NAME = 'stage'; describe('computeAggregateValueAndLabel', () => { const mockObjectMetadata: ObjectMetadataItem = { @@ -36,7 +35,6 @@ describe('computeAggregateValueAndLabel', () => { objectMetadataItem: mockObjectMetadata, fieldMetadataId: MOCK_FIELD_ID, aggregateOperation: AGGREGATE_OPERATIONS.sum, - fallbackFieldName: MOCK_KANBAN_FIELD_NAME, ...defaultParams, }); @@ -55,7 +53,6 @@ describe('computeAggregateValueAndLabel', () => { objectMetadataItem: mockObjectMetadata, fieldMetadataId: MOCK_FIELD_ID, aggregateOperation: AGGREGATE_OPERATIONS.sum, - fallbackFieldName: MOCK_KANBAN_FIELD_NAME, ...defaultParams, }); @@ -93,7 +90,6 @@ describe('computeAggregateValueAndLabel', () => { objectMetadataItem: mockObjectMetadataWithPercentageField, fieldMetadataId: MOCK_FIELD_ID, aggregateOperation: AGGREGATE_OPERATIONS.avg, - fallbackFieldName: MOCK_KANBAN_FIELD_NAME, ...defaultParams, }); @@ -131,7 +127,6 @@ describe('computeAggregateValueAndLabel', () => { objectMetadataItem: mockObjectMetadataWithDecimalsField, fieldMetadataId: MOCK_FIELD_ID, aggregateOperation: AGGREGATE_OPERATIONS.sum, - fallbackFieldName: MOCK_KANBAN_FIELD_NAME, ...defaultParams, }); @@ -166,7 +161,6 @@ describe('computeAggregateValueAndLabel', () => { objectMetadataItem: mockObjectMetadataWithDatetimeField, fieldMetadataId: MOCK_FIELD_ID, aggregateOperation: DATE_AGGREGATE_OPERATIONS.earliest, - fallbackFieldName: MOCK_KANBAN_FIELD_NAME, ...defaultParams, }); @@ -201,7 +195,6 @@ describe('computeAggregateValueAndLabel', () => { objectMetadataItem: mockObjectMetadataWithDatetimeField, fieldMetadataId: MOCK_FIELD_ID, aggregateOperation: DATE_AGGREGATE_OPERATIONS.latest, - fallbackFieldName: MOCK_KANBAN_FIELD_NAME, ...defaultParams, }); @@ -214,7 +207,7 @@ describe('computeAggregateValueAndLabel', () => { it('should default to count when field not found', () => { const mockData = { - [MOCK_KANBAN_FIELD_NAME]: { + id: { [AGGREGATE_OPERATIONS.count]: 42, }, } as AggregateRecordsData; @@ -222,7 +215,6 @@ describe('computeAggregateValueAndLabel', () => { const result = computeAggregateValueAndLabel({ data: mockData, objectMetadataItem: mockObjectMetadata, - fallbackFieldName: MOCK_KANBAN_FIELD_NAME, ...defaultParams, }); diff --git a/packages/twenty-front/src/modules/object-record/record-board/record-board-column/utils/buildRecordGqlFieldsAggregateForView.ts b/packages/twenty-front/src/modules/object-record/record-board/record-board-column/utils/buildRecordGqlFieldsAggregateForView.ts index 0c0b1b82d489..ad27e0355cab 100644 --- a/packages/twenty-front/src/modules/object-record/record-board/record-board-column/utils/buildRecordGqlFieldsAggregateForView.ts +++ b/packages/twenty-front/src/modules/object-record/record-board/record-board-column/utils/buildRecordGqlFieldsAggregateForView.ts @@ -2,16 +2,15 @@ import { ObjectMetadataItem } from '@/object-metadata/types/ObjectMetadataItem'; import { RecordGqlFieldsAggregate } from '@/object-record/graphql/types/RecordGqlFieldsAggregate'; import { KanbanAggregateOperation } from '@/object-record/record-index/states/recordIndexKanbanAggregateOperationState'; import { AGGREGATE_OPERATIONS } from '@/object-record/record-table/constants/AggregateOperations'; +import { FIELD_FOR_TOTAL_COUNT_AGGREGATE_OPERATION } from 'twenty-shared'; import { isDefined } from '~/utils/isDefined'; export const buildRecordGqlFieldsAggregateForView = ({ objectMetadataItem, recordIndexKanbanAggregateOperation, - fieldNameForCount, }: { objectMetadataItem: ObjectMetadataItem; recordIndexKanbanAggregateOperation: KanbanAggregateOperation; - fieldNameForCount: string; }): RecordGqlFieldsAggregate => { let recordGqlFieldsAggregate = {}; @@ -31,7 +30,9 @@ export const buildRecordGqlFieldsAggregateForView = ({ ); } else { recordGqlFieldsAggregate = { - [fieldNameForCount]: [AGGREGATE_OPERATIONS.count], + [FIELD_FOR_TOTAL_COUNT_AGGREGATE_OPERATION]: [ + AGGREGATE_OPERATIONS.count, + ], }; } } else { diff --git a/packages/twenty-front/src/modules/object-record/record-board/record-board-column/utils/computeAggregateValueAndLabel.ts b/packages/twenty-front/src/modules/object-record/record-board/record-board-column/utils/computeAggregateValueAndLabel.ts index d71a4dc80c61..1fcadf4d8088 100644 --- a/packages/twenty-front/src/modules/object-record/record-board/record-board-column/utils/computeAggregateValueAndLabel.ts +++ b/packages/twenty-front/src/modules/object-record/record-board/record-board-column/utils/computeAggregateValueAndLabel.ts @@ -9,6 +9,7 @@ import { COUNT_AGGREGATE_OPERATION_OPTIONS } from '@/object-record/record-table/ import { PERCENT_AGGREGATE_OPERATION_OPTIONS } from '@/object-record/record-table/record-table-footer/constants/percentAggregateOperationOptions'; import { ExtendedAggregateOperations } from '@/object-record/record-table/types/ExtendedAggregateOperations'; import isEmpty from 'lodash.isempty'; +import { FIELD_FOR_TOTAL_COUNT_AGGREGATE_OPERATION } from 'twenty-shared'; import { FieldMetadataType } from '~/generated-metadata/graphql'; import { formatAmount } from '~/utils/format/formatAmount'; import { formatNumber } from '~/utils/format/number'; @@ -21,7 +22,6 @@ export const computeAggregateValueAndLabel = ({ objectMetadataItem, fieldMetadataId, aggregateOperation, - fallbackFieldName, dateFormat, timeFormat, timeZone, @@ -30,7 +30,6 @@ export const computeAggregateValueAndLabel = ({ objectMetadataItem: ObjectMetadataItem; fieldMetadataId?: string | null; aggregateOperation?: ExtendedAggregateOperations | null; - fallbackFieldName?: string; dateFormat: DateFormat; timeFormat: TimeFormat; timeZone: string; @@ -43,11 +42,11 @@ export const computeAggregateValueAndLabel = ({ ); if (!isDefined(field)) { - if (!fallbackFieldName) { - throw new Error('Missing fallback field name'); - } return { - value: data?.[fallbackFieldName]?.[AGGREGATE_OPERATIONS.count], + value: + data?.[FIELD_FOR_TOTAL_COUNT_AGGREGATE_OPERATION]?.[ + AGGREGATE_OPERATIONS.count + ], label: `${getAggregateOperationLabel(AGGREGATE_OPERATIONS.count)}`, labelWithFieldName: `${getAggregateOperationLabel(AGGREGATE_OPERATIONS.count)}`, }; diff --git a/packages/twenty-front/src/modules/object-record/record-filter/hooks/__tests__/useRemoveRecordFilter.test.tsx b/packages/twenty-front/src/modules/object-record/record-filter/hooks/__tests__/useRemoveRecordFilter.test.tsx new file mode 100644 index 000000000000..9c8d1f0217fc --- /dev/null +++ b/packages/twenty-front/src/modules/object-record/record-filter/hooks/__tests__/useRemoveRecordFilter.test.tsx @@ -0,0 +1,117 @@ +import { renderHook } from '@testing-library/react'; +import { act } from 'react-dom/test-utils'; + +import { currentRecordFiltersComponentState } from '@/object-record/record-filter/states/currentRecordFiltersComponentState'; +import { RecordFilter } from '@/object-record/record-filter/types/RecordFilter'; +import { useRecoilComponentValueV2 } from '@/ui/utilities/state/component-state/hooks/useRecoilComponentValueV2'; +import { ViewFilterOperand } from '@/views/types/ViewFilterOperand'; +import { getJestMetadataAndApolloMocksWrapper } from '~/testing/jest/getJestMetadataAndApolloMocksWrapper'; +import { useRemoveRecordFilter } from '../useRemoveRecordFilter'; +import { useUpsertRecordFilter } from '../useUpsertRecordFilter'; + +const Wrapper = getJestMetadataAndApolloMocksWrapper({ + apolloMocks: [], +}); + +describe('useRemoveRecordFilter', () => { + it('should remove an existing filter', () => { + const { result } = renderHook( + () => { + const currentRecordFilters = useRecoilComponentValueV2( + currentRecordFiltersComponentState, + ); + + const { upsertRecordFilter } = useUpsertRecordFilter(); + const { removeRecordFilter } = useRemoveRecordFilter(); + + return { + upsertRecordFilter, + removeRecordFilter, + currentRecordFilters, + }; + }, + { + wrapper: Wrapper, + }, + ); + + const filter: RecordFilter = { + id: 'filter-1', + fieldMetadataId: 'field-1', + value: 'test-value', + operand: ViewFilterOperand.Contains, + displayValue: 'test-value', + definition: { + type: 'TEXT', + fieldMetadataId: 'field-1', + label: 'Test Field', + iconName: 'IconText', + }, + }; + + // First add a filter + act(() => { + result.current.upsertRecordFilter(filter); + }); + + expect(result.current.currentRecordFilters).toHaveLength(1); + expect(result.current.currentRecordFilters[0]).toEqual(filter); + + // Then remove it + act(() => { + result.current.removeRecordFilter(filter.fieldMetadataId); + }); + + expect(result.current.currentRecordFilters).toHaveLength(0); + }); + + it('should not modify filters when trying to remove a non-existent filter', () => { + const { result } = renderHook( + () => { + const currentRecordFilters = useRecoilComponentValueV2( + currentRecordFiltersComponentState, + ); + const { upsertRecordFilter } = useUpsertRecordFilter(); + const { removeRecordFilter } = useRemoveRecordFilter(); + return { + upsertRecordFilter, + removeRecordFilter, + currentRecordFilters, + }; + }, + { + wrapper: Wrapper, + }, + ); + + const filter: RecordFilter = { + id: 'filter-1', + fieldMetadataId: 'field-1', + value: 'test-value', + operand: ViewFilterOperand.Contains, + displayValue: 'test-value', + definition: { + type: 'TEXT', + fieldMetadataId: 'field-1', + label: 'Test Field', + iconName: 'IconText', + }, + }; + + // Add a filter + act(() => { + result.current.upsertRecordFilter(filter); + }); + + expect(result.current.currentRecordFilters).toHaveLength(1); + + // Try to remove a non-existent filter + act(() => { + result.current.removeRecordFilter('non-existent-field'); + }); + + // Filter list should remain unchanged + expect(result.current.currentRecordFilters).toHaveLength(1); + expect(result.current.currentRecordFilters[0]).toEqual(filter); + }); +}); diff --git a/packages/twenty-front/src/modules/object-record/record-filter/hooks/__tests__/useUpsertRecordFilter.test.tsx b/packages/twenty-front/src/modules/object-record/record-filter/hooks/__tests__/useUpsertRecordFilter.test.tsx new file mode 100644 index 000000000000..3b25ff2f0745 --- /dev/null +++ b/packages/twenty-front/src/modules/object-record/record-filter/hooks/__tests__/useUpsertRecordFilter.test.tsx @@ -0,0 +1,112 @@ +import { renderHook } from '@testing-library/react'; +import { act } from 'react-dom/test-utils'; + +import { currentRecordFiltersComponentState } from '@/object-record/record-filter/states/currentRecordFiltersComponentState'; +import { RecordFilter } from '@/object-record/record-filter/types/RecordFilter'; +import { useRecoilComponentValueV2 } from '@/ui/utilities/state/component-state/hooks/useRecoilComponentValueV2'; +import { ViewFilterOperand } from '@/views/types/ViewFilterOperand'; +import { getJestMetadataAndApolloMocksWrapper } from '~/testing/jest/getJestMetadataAndApolloMocksWrapper'; +import { useUpsertRecordFilter } from '../useUpsertRecordFilter'; + +const Wrapper = getJestMetadataAndApolloMocksWrapper({ + apolloMocks: [], +}); + +describe('useUpsertRecordFilter', () => { + it('should add a new filter when fieldMetadataId does not exist', () => { + const { result } = renderHook( + () => { + const currentRecordFilters = useRecoilComponentValueV2( + currentRecordFiltersComponentState, + ); + + const { upsertRecordFilter } = useUpsertRecordFilter(); + + return { upsertRecordFilter, currentRecordFilters }; + }, + { + wrapper: Wrapper, + }, + ); + + const newFilter: RecordFilter = { + id: 'filter-1', + fieldMetadataId: 'field-1', + value: 'test-value', + operand: ViewFilterOperand.Contains, + displayValue: 'test-value', + definition: { + type: 'TEXT', + fieldMetadataId: 'field-1', + label: 'Test Field', + iconName: 'IconText', + }, + }; + + act(() => { + result.current.upsertRecordFilter(newFilter); + }); + + expect(result.current.currentRecordFilters).toHaveLength(1); + expect(result.current.currentRecordFilters[0]).toEqual(newFilter); + }); + + it('should update an existing filter when fieldMetadataId exists', () => { + const { result } = renderHook( + () => { + const currentRecordFilters = useRecoilComponentValueV2( + currentRecordFiltersComponentState, + ); + + const { upsertRecordFilter } = useUpsertRecordFilter(); + + return { upsertRecordFilter, currentRecordFilters }; + }, + { + wrapper: Wrapper, + }, + ); + + const initialFilter: RecordFilter = { + id: 'filter-1', + fieldMetadataId: 'field-1', + value: 'initial-value', + operand: ViewFilterOperand.Contains, + displayValue: 'initial-value', + definition: { + type: 'TEXT', + fieldMetadataId: 'field-1', + label: 'Test Field', + iconName: 'IconText', + }, + }; + + const updatedFilter: RecordFilter = { + id: 'filter-1', + fieldMetadataId: 'field-1', + value: 'updated-value', + operand: ViewFilterOperand.Contains, + displayValue: 'updated-value', + definition: { + type: 'TEXT', + fieldMetadataId: 'field-1', + label: 'Test Field', + iconName: 'IconText', + }, + }; + + act(() => { + result.current.upsertRecordFilter(initialFilter); + }); + + expect(result.current.currentRecordFilters).toHaveLength(1); + expect(result.current.currentRecordFilters[0]).toEqual(initialFilter); + + act(() => { + result.current.upsertRecordFilter(updatedFilter); + }); + + expect(result.current.currentRecordFilters).toHaveLength(1); + expect(result.current.currentRecordFilters[0]).toEqual(updatedFilter); + }); +}); diff --git a/packages/twenty-front/src/modules/object-record/record-filter/hooks/useApplyRecordFilter.ts b/packages/twenty-front/src/modules/object-record/record-filter/hooks/useApplyRecordFilter.ts index ad0edea7e2cd..ba82b14c57be 100644 --- a/packages/twenty-front/src/modules/object-record/record-filter/hooks/useApplyRecordFilter.ts +++ b/packages/twenty-front/src/modules/object-record/record-filter/hooks/useApplyRecordFilter.ts @@ -1,7 +1,6 @@ -import { onFilterSelectComponentState } from '@/object-record/object-filter-dropdown/states/onFilterSelectComponentState'; import { selectedFilterComponentState } from '@/object-record/object-filter-dropdown/states/selectedFilterComponentState'; +import { useUpsertRecordFilter } from '@/object-record/record-filter/hooks/useUpsertRecordFilter'; import { RecordFilter } from '@/object-record/record-filter/types/RecordFilter'; -import { getSnapshotValue } from '@/ui/utilities/recoil-scope/utils/getSnapshotValue'; import { useRecoilComponentCallbackStateV2 } from '@/ui/utilities/state/component-state/hooks/useRecoilComponentCallbackStateV2'; import { useUpsertCombinedViewFilters } from '@/views/hooks/useUpsertCombinedViewFilters'; import { useRecoilCallback } from 'recoil'; @@ -14,32 +13,19 @@ export const useApplyRecordFilter = (componentInstanceId?: string) => { componentInstanceId, ); - const onFilterSelectCallbackState = useRecoilComponentCallbackStateV2( - onFilterSelectComponentState, - componentInstanceId, - ); + const { upsertRecordFilter } = useUpsertRecordFilter(); const applyRecordFilter = useRecoilCallback( - ({ set, snapshot }) => + ({ set }) => (filter: RecordFilter | null) => { set(selectedFilterCallbackState, filter); - const onFilterSelect = getSnapshotValue( - snapshot, - onFilterSelectCallbackState, - ); - if (isDefined(filter)) { upsertCombinedViewFilter(filter); + upsertRecordFilter(filter); } - - onFilterSelect?.(filter); }, - [ - selectedFilterCallbackState, - onFilterSelectCallbackState, - upsertCombinedViewFilter, - ], + [selectedFilterCallbackState, upsertCombinedViewFilter, upsertRecordFilter], ); return { diff --git a/packages/twenty-front/src/modules/object-record/record-filter/hooks/useRemoveRecordFilter.ts b/packages/twenty-front/src/modules/object-record/record-filter/hooks/useRemoveRecordFilter.ts new file mode 100644 index 000000000000..a41f5b3f87cd --- /dev/null +++ b/packages/twenty-front/src/modules/object-record/record-filter/hooks/useRemoveRecordFilter.ts @@ -0,0 +1,46 @@ +import { currentRecordFiltersComponentState } from '@/object-record/record-filter/states/currentRecordFiltersComponentState'; +import { useRecoilComponentCallbackStateV2 } from '@/ui/utilities/state/component-state/hooks/useRecoilComponentCallbackStateV2'; +import { getSnapshotValue } from '@/ui/utilities/state/utils/getSnapshotValue'; +import { useRecoilCallback } from 'recoil'; + +export const useRemoveRecordFilter = () => { + const currentRecordFiltersCallbackState = useRecoilComponentCallbackStateV2( + currentRecordFiltersComponentState, + ); + + const removeRecordFilter = useRecoilCallback( + ({ set, snapshot }) => + (fieldMetadataId: string) => { + const currentRecordFilters = getSnapshotValue( + snapshot, + currentRecordFiltersCallbackState, + ); + + const foundRecordFilterInCurrentRecordFilters = + currentRecordFilters.some( + (existingFilter) => + existingFilter.fieldMetadataId === fieldMetadataId, + ); + + if (foundRecordFilterInCurrentRecordFilters) { + set(currentRecordFiltersCallbackState, (currentRecordFilters) => { + const newCurrentRecordFilters = [...currentRecordFilters]; + + const indexOfFilterToRemove = newCurrentRecordFilters.findIndex( + (existingFilter) => + existingFilter.fieldMetadataId === fieldMetadataId, + ); + + newCurrentRecordFilters.splice(indexOfFilterToRemove, 1); + + return newCurrentRecordFilters; + }); + } + }, + [currentRecordFiltersCallbackState], + ); + + return { + removeRecordFilter, + }; +}; diff --git a/packages/twenty-front/src/modules/object-record/record-filter/hooks/useUpsertRecordFilter.ts b/packages/twenty-front/src/modules/object-record/record-filter/hooks/useUpsertRecordFilter.ts new file mode 100644 index 000000000000..3e7291f577c5 --- /dev/null +++ b/packages/twenty-front/src/modules/object-record/record-filter/hooks/useUpsertRecordFilter.ts @@ -0,0 +1,54 @@ +import { currentRecordFiltersComponentState } from '@/object-record/record-filter/states/currentRecordFiltersComponentState'; +import { RecordFilter } from '@/object-record/record-filter/types/RecordFilter'; +import { useRecoilComponentCallbackStateV2 } from '@/ui/utilities/state/component-state/hooks/useRecoilComponentCallbackStateV2'; +import { getSnapshotValue } from '@/ui/utilities/state/utils/getSnapshotValue'; +import { useRecoilCallback } from 'recoil'; + +export const useUpsertRecordFilter = () => { + const currentRecordFiltersCallbackState = useRecoilComponentCallbackStateV2( + currentRecordFiltersComponentState, + ); + + const upsertRecordFilter = useRecoilCallback( + ({ set, snapshot }) => + (filter: RecordFilter) => { + const currentRecordFilters = getSnapshotValue( + snapshot, + currentRecordFiltersCallbackState, + ); + + const foundRecordFilterInCurrentRecordFilters = + currentRecordFilters.some( + (existingFilter) => + existingFilter.fieldMetadataId === filter.fieldMetadataId, + ); + + if (!foundRecordFilterInCurrentRecordFilters) { + set(currentRecordFiltersCallbackState, [ + ...currentRecordFilters, + filter, + ]); + } else { + set(currentRecordFiltersCallbackState, (currentRecordFilters) => { + const newCurrentRecordFilters = [...currentRecordFilters]; + + const indexOfFilterToUpdate = newCurrentRecordFilters.findIndex( + (existingFilter) => + existingFilter.fieldMetadataId === filter.fieldMetadataId, + ); + + newCurrentRecordFilters[indexOfFilterToUpdate] = { + ...filter, + }; + + return newCurrentRecordFilters; + }); + } + }, + [currentRecordFiltersCallbackState], + ); + + return { + upsertRecordFilter, + }; +}; diff --git a/packages/twenty-front/src/modules/object-record/record-filter/states/context/RecordFiltersComponentInstanceContext.ts b/packages/twenty-front/src/modules/object-record/record-filter/states/context/RecordFiltersComponentInstanceContext.ts new file mode 100644 index 000000000000..e8a6200d6a14 --- /dev/null +++ b/packages/twenty-front/src/modules/object-record/record-filter/states/context/RecordFiltersComponentInstanceContext.ts @@ -0,0 +1,4 @@ +import { createComponentInstanceContext } from '@/ui/utilities/state/component-state/utils/createComponentInstanceContext'; + +export const RecordFiltersComponentInstanceContext = + createComponentInstanceContext(); diff --git a/packages/twenty-front/src/modules/object-record/record-filter/states/currentRecordFiltersComponentState.ts b/packages/twenty-front/src/modules/object-record/record-filter/states/currentRecordFiltersComponentState.ts new file mode 100644 index 000000000000..a2b6d80595b3 --- /dev/null +++ b/packages/twenty-front/src/modules/object-record/record-filter/states/currentRecordFiltersComponentState.ts @@ -0,0 +1,11 @@ +import { RecordFiltersComponentInstanceContext } from '@/object-record/record-filter/states/context/RecordFiltersComponentInstanceContext'; +import { createComponentStateV2 } from '@/ui/utilities/state/component-state/utils/createComponentStateV2'; +import { RecordFilter } from '../../record-filter/types/RecordFilter'; + +export const currentRecordFiltersComponentState = createComponentStateV2< + RecordFilter[] +>({ + key: 'currentRecordFiltersComponentState', + defaultValue: [], + componentInstanceContext: RecordFiltersComponentInstanceContext, +}); diff --git a/packages/twenty-front/src/modules/object-record/record-filter/utils/__tests__/isMatchingArrayFilter.test.ts b/packages/twenty-front/src/modules/object-record/record-filter/utils/__tests__/isMatchingArrayFilter.test.ts new file mode 100644 index 000000000000..6dde97f7bc59 --- /dev/null +++ b/packages/twenty-front/src/modules/object-record/record-filter/utils/__tests__/isMatchingArrayFilter.test.ts @@ -0,0 +1,128 @@ +import { isMatchingArrayFilter } from '../isMatchingArrayFilter'; + +describe('isMatchingArrayFilter', () => { + describe('is filter', () => { + it('should return true when checking for NULL and value is null', () => { + expect( + isMatchingArrayFilter({ + arrayFilter: { is: 'NULL' }, + value: null, + }), + ).toBe(true); + }); + + it('should return false when checking for NULL and value is not null', () => { + expect( + isMatchingArrayFilter({ + arrayFilter: { is: 'NULL' }, + value: ['test'], + }), + ).toBe(false); + }); + + it('should return true when checking for NOT_NULL and value is not null', () => { + expect( + isMatchingArrayFilter({ + arrayFilter: { is: 'NOT_NULL' }, + value: ['test'], + }), + ).toBe(true); + }); + + it('should return false when checking for NOT_NULL and value is null', () => { + expect( + isMatchingArrayFilter({ + arrayFilter: { is: 'NOT_NULL' }, + value: null, + }), + ).toBe(false); + }); + }); + + describe('isEmptyArray filter', () => { + it('should return true when array is empty and checking for empty array', () => { + expect( + isMatchingArrayFilter({ + arrayFilter: { isEmptyArray: true }, + value: [], + }), + ).toBe(true); + }); + + it('should return false when array is not empty and checking for empty array', () => { + expect( + isMatchingArrayFilter({ + arrayFilter: { isEmptyArray: true }, + value: ['test'], + }), + ).toBe(false); + }); + + it('should return false when value is null and checking for empty array', () => { + expect( + isMatchingArrayFilter({ + arrayFilter: { isEmptyArray: true }, + value: null, + }), + ).toBe(false); + }); + }); + + describe('containsIlike filter', () => { + it('should return true when array contains item matching case-insensitive search', () => { + expect( + isMatchingArrayFilter({ + arrayFilter: { containsIlike: 'TEST' }, + value: ['test item'], + }), + ).toBe(true); + }); + + it('should return false when array does not contain item matching search', () => { + expect( + isMatchingArrayFilter({ + arrayFilter: { containsIlike: 'missing' }, + value: ['test item'], + }), + ).toBe(false); + }); + + it('should return false when value is null and using containsIlike', () => { + expect( + isMatchingArrayFilter({ + arrayFilter: { containsIlike: 'test' }, + value: null, + }), + ).toBe(false); + }); + + it('should match partial strings case-insensitively', () => { + expect( + isMatchingArrayFilter({ + arrayFilter: { containsIlike: 'TE' }, + value: ['Test Item', 'Another Item'], + }), + ).toBe(true); + }); + }); + + describe('error handling', () => { + it('should throw error for invalid filter', () => { + expect(() => + isMatchingArrayFilter({ + arrayFilter: {}, + value: [], + }), + ).toThrow('Unexpected value for array filter'); + }); + + it('should throw error for unknown filter type', () => { + expect(() => + isMatchingArrayFilter({ + arrayFilter: { unknownFilter: 'test' } as any, + value: [], + }), + ).toThrow('Unexpected value for array filter'); + }); + }); +}); diff --git a/packages/twenty-front/src/modules/object-record/record-index/hooks/useFindManyRecordIndexTableParams.ts b/packages/twenty-front/src/modules/object-record/record-index/hooks/useFindManyRecordIndexTableParams.ts index df15ba3715c5..4e69c157aba1 100644 --- a/packages/twenty-front/src/modules/object-record/record-index/hooks/useFindManyRecordIndexTableParams.ts +++ b/packages/twenty-front/src/modules/object-record/record-index/hooks/useFindManyRecordIndexTableParams.ts @@ -1,10 +1,10 @@ import { useObjectMetadataItem } from '@/object-metadata/hooks/useObjectMetadataItem'; import { turnSortsIntoOrderBy } from '@/object-record/object-sort-dropdown/utils/turnSortsIntoOrderBy'; import { useFilterValueDependencies } from '@/object-record/record-filter/hooks/useFilterValueDependencies'; +import { currentRecordFiltersComponentState } from '@/object-record/record-filter/states/currentRecordFiltersComponentState'; import { computeViewRecordGqlOperationFilter } from '@/object-record/record-filter/utils/computeViewRecordGqlOperationFilter'; import { useCurrentRecordGroupDefinition } from '@/object-record/record-group/hooks/useCurrentRecordGroupDefinition'; import { useRecordGroupFilter } from '@/object-record/record-group/hooks/useRecordGroupFilter'; -import { tableFiltersComponentState } from '@/object-record/record-table/states/tableFiltersComponentState'; import { tableSortsComponentState } from '@/object-record/record-table/states/tableSortsComponentState'; import { tableViewFilterGroupsComponentState } from '@/object-record/record-table/states/tableViewFilterGroupsComponentState'; import { useRecoilComponentValueV2 } from '@/ui/utilities/state/component-state/hooks/useRecoilComponentValueV2'; @@ -27,20 +27,21 @@ export const useFindManyRecordIndexTableParams = ( tableViewFilterGroupsComponentState, recordTableId, ); - const tableFilters = useRecoilComponentValueV2( - tableFiltersComponentState, - recordTableId, - ); + const tableSorts = useRecoilComponentValueV2( tableSortsComponentState, recordTableId, ); + const currentRecordFilters = useRecoilComponentValueV2( + currentRecordFiltersComponentState, + ); + const { filterValueDependencies } = useFilterValueDependencies(); const stateFilter = computeViewRecordGqlOperationFilter( filterValueDependencies, - tableFilters, + currentRecordFilters, objectMetadataItem?.fields ?? [], tableViewFilterGroups, ); diff --git a/packages/twenty-front/src/modules/object-record/record-index/hooks/useHandleToggleColumnFilter.ts b/packages/twenty-front/src/modules/object-record/record-index/hooks/useHandleToggleColumnFilter.ts index e573c4cfd7cc..daa0bb001a61 100644 --- a/packages/twenty-front/src/modules/object-record/record-index/hooks/useHandleToggleColumnFilter.ts +++ b/packages/twenty-front/src/modules/object-record/record-index/hooks/useHandleToggleColumnFilter.ts @@ -5,6 +5,7 @@ import { useColumnDefinitionsFromFieldMetadata } from '@/object-metadata/hooks/u import { useObjectMetadataItem } from '@/object-metadata/hooks/useObjectMetadataItem'; import { useSelectFilterDefinitionUsedInDropdown } from '@/object-record/object-filter-dropdown/hooks/useSelectFilterDefinitionUsedInDropdown'; +import { useUpsertRecordFilter } from '@/object-record/record-filter/hooks/useUpsertRecordFilter'; import { RecordFilter } from '@/object-record/record-filter/types/RecordFilter'; import { getRecordFilterOperandsForRecordFilterDefinition } from '@/object-record/record-filter/utils/getRecordFilterOperandsForRecordFilterDefinition'; import { isDropdownOpenComponentState } from '@/ui/layout/dropdown/states/isDropdownOpenComponentState'; @@ -33,6 +34,7 @@ export const useHandleToggleColumnFilter = ({ useColumnDefinitionsFromFieldMetadata(objectMetadataItem); const { upsertCombinedViewFilter } = useUpsertCombinedViewFilters(viewBarId); + const { upsertRecordFilter } = useUpsertRecordFilter(); const openDropdown = useRecoilCallback(({ set }) => { return (dropdownId: string) => { @@ -93,6 +95,8 @@ export const useHandleToggleColumnFilter = ({ value: '', }; + upsertRecordFilter(newFilter); + await upsertCombinedViewFilter(newFilter); selectFilterDefinitionUsedInDropdown({ filterDefinition }); @@ -107,6 +111,7 @@ export const useHandleToggleColumnFilter = ({ selectFilterDefinitionUsedInDropdown, currentViewWithCombinedFiltersAndSorts, availableFilterDefinitions, + upsertRecordFilter, ], ); diff --git a/packages/twenty-front/src/modules/object-record/record-index/hooks/useHandleToggleTrashColumnFilter.ts b/packages/twenty-front/src/modules/object-record/record-index/hooks/useHandleToggleTrashColumnFilter.ts index 4964a26e4ae0..f3471ea3ae1a 100644 --- a/packages/twenty-front/src/modules/object-record/record-index/hooks/useHandleToggleTrashColumnFilter.ts +++ b/packages/twenty-front/src/modules/object-record/record-index/hooks/useHandleToggleTrashColumnFilter.ts @@ -4,6 +4,7 @@ import { v4 } from 'uuid'; import { useColumnDefinitionsFromFieldMetadata } from '@/object-metadata/hooks/useColumnDefinitionsFromFieldMetadata'; import { useObjectMetadataItem } from '@/object-metadata/hooks/useObjectMetadataItem'; import { getFilterTypeFromFieldType } from '@/object-metadata/utils/formatFieldMetadataItemsAsFilterDefinitions'; +import { useUpsertRecordFilter } from '@/object-record/record-filter/hooks/useUpsertRecordFilter'; import { RecordFilter } from '@/object-record/record-filter/types/RecordFilter'; import { isSoftDeleteFilterActiveComponentState } from '@/object-record/record-table/states/isSoftDeleteFilterActiveComponentState'; import { useRecoilComponentCallbackStateV2 } from '@/ui/utilities/state/component-state/hooks/useRecoilComponentCallbackStateV2'; @@ -36,6 +37,8 @@ export const useHandleToggleTrashColumnFilter = ({ viewBarId, ); + const { upsertRecordFilter } = useUpsertRecordFilter(); + const handleToggleTrashColumnFilter = useCallback(() => { const trashFieldMetadata = objectMetadataItem.fields.find( (field: { name: string }) => field.name === 'deletedAt', @@ -69,8 +72,14 @@ export const useHandleToggleTrashColumnFilter = ({ value: '', }; + upsertRecordFilter(newFilter); upsertCombinedViewFilter(newFilter); - }, [columnDefinitions, objectMetadataItem, upsertCombinedViewFilter]); + }, [ + columnDefinitions, + objectMetadataItem, + upsertCombinedViewFilter, + upsertRecordFilter, + ]); const toggleSoftDeleteFilterState = useRecoilCallback( ({ set }) => diff --git a/packages/twenty-front/src/modules/object-record/record-right-drawer/components/RightDrawerRecord.tsx b/packages/twenty-front/src/modules/object-record/record-right-drawer/components/RightDrawerRecord.tsx index 4cd7a5507892..0a8757ce6558 100644 --- a/packages/twenty-front/src/modules/object-record/record-right-drawer/components/RightDrawerRecord.tsx +++ b/packages/twenty-front/src/modules/object-record/record-right-drawer/components/RightDrawerRecord.tsx @@ -2,6 +2,7 @@ import { useRecoilValue } from 'recoil'; import { ActionMenuComponentInstanceContext } from '@/action-menu/states/contexts/ActionMenuComponentInstanceContext'; import { ContextStoreComponentInstanceContext } from '@/context-store/states/contexts/ContextStoreComponentInstanceContext'; +import { RecordFiltersComponentInstanceContext } from '@/object-record/record-filter/states/context/RecordFiltersComponentInstanceContext'; import { isNewViewableRecordLoadingState } from '@/object-record/record-right-drawer/states/isNewViewableRecordLoading'; import { viewableRecordIdState } from '@/object-record/record-right-drawer/states/viewableRecordIdState'; import { viewableRecordNameSingularState } from '@/object-record/record-right-drawer/states/viewableRecordNameSingularState'; @@ -42,29 +43,33 @@ export const RightDrawerRecord = () => { ); return ( - - - - - {!isNewViewableRecordLoading && ( - - )} - - - - - + + + + {!isNewViewableRecordLoading && ( + + )} + + + + + + ); }; diff --git a/packages/twenty-front/src/modules/object-record/components/RecordEditableName.tsx b/packages/twenty-front/src/modules/object-record/record-show/components/ObjectRecordShowPageBreadcrumb.tsx similarity index 53% rename from packages/twenty-front/src/modules/object-record/components/RecordEditableName.tsx rename to packages/twenty-front/src/modules/object-record/record-show/components/ObjectRecordShowPageBreadcrumb.tsx index e73521422a58..7813a097af48 100644 --- a/packages/twenty-front/src/modules/object-record/components/RecordEditableName.tsx +++ b/packages/twenty-front/src/modules/object-record/record-show/components/ObjectRecordShowPageBreadcrumb.tsx @@ -1,50 +1,49 @@ +import { FieldMetadataItem } from '@/object-metadata/types/FieldMetadataItem'; import { useFindOneRecord } from '@/object-record/hooks/useFindOneRecord'; import { useUpdateOneRecord } from '@/object-record/hooks/useUpdateOneRecord'; -import { NavigationDrawerInput } from '@/ui/navigation/navigation-drawer/components/NavigationDrawerInput'; -import { NavigationDrawerItem } from '@/ui/navigation/navigation-drawer/components/NavigationDrawerItem'; +import { EditableBreadcrumbItem } from '@/ui/navigation/bread-crumb/components/EditableBreadcrumbItem'; import styled from '@emotion/styled'; -import { useEffect, useState } from 'react'; import { capitalize } from 'twenty-shared'; const StyledEditableTitleContainer = styled.div` - align-items: flex-start; + align-items: center; display: flex; flex-direction: row; + overflow-x: hidden; `; const StyledEditableTitlePrefix = styled.div` color: ${({ theme }) => theme.font.color.tertiary}; - line-height: 24px; display: flex; + flex: 1 0 auto; flex-direction: row; - padding: ${({ theme }) => theme.spacing(0.75)}; gap: ${({ theme }) => theme.spacing(1)}; + padding: ${({ theme }) => theme.spacing(0.75)}; `; -export const RecordEditableName = ({ +export const ObjectRecordShowPageBreadcrumb = ({ objectNameSingular, objectRecordId, objectLabelPlural, + labelIdentifierFieldMetadataItem, }: { objectNameSingular: string; objectRecordId: string; objectLabelPlural: string; + labelIdentifierFieldMetadataItem?: FieldMetadataItem; }) => { - const [isRenaming, setIsRenaming] = useState(false); const { record, loading } = useFindOneRecord({ objectNameSingular, objectRecordId, recordGqlFields: { - name: true, + [labelIdentifierFieldMetadataItem?.name ?? 'name']: true, }, }); - const [recordName, setRecordName] = useState(record?.name); - const { updateOneRecord } = useUpdateOneRecord({ objectNameSingular, recordGqlFields: { - name: true, + [labelIdentifierFieldMetadataItem?.name ?? 'name']: true, }, }); @@ -55,18 +54,8 @@ export const RecordEditableName = ({ name: value, }, }); - setIsRenaming(false); }; - const handleCancel = () => { - setRecordName(record?.name); - setIsRenaming(false); - }; - - useEffect(() => { - setRecordName(record?.name); - }, [record?.name]); - if (loading) { return null; } @@ -77,24 +66,13 @@ export const RecordEditableName = ({ {capitalize(objectLabelPlural)} {' / '} - {isRenaming ? ( - - ) : ( - setIsRenaming(true)} - rightOptions={undefined} - className="navigation-drawer-item" - active - /> - )} + ); }; diff --git a/packages/twenty-front/src/modules/object-record/record-table/empty-state/components/RecordTableEmptyStateSoftDelete.tsx b/packages/twenty-front/src/modules/object-record/record-table/empty-state/components/RecordTableEmptyStateSoftDelete.tsx index fd05f3a28b1f..055c9a129ab7 100644 --- a/packages/twenty-front/src/modules/object-record/record-table/empty-state/components/RecordTableEmptyStateSoftDelete.tsx +++ b/packages/twenty-front/src/modules/object-record/record-table/empty-state/components/RecordTableEmptyStateSoftDelete.tsx @@ -1,6 +1,7 @@ import { IconFilterOff } from 'twenty-ui'; import { useObjectLabel } from '@/object-metadata/hooks/useObjectLabel'; +import { useRemoveRecordFilter } from '@/object-record/record-filter/hooks/useRemoveRecordFilter'; import { useHandleToggleTrashColumnFilter } from '@/object-record/record-index/hooks/useHandleToggleTrashColumnFilter'; import { useRecordTableContextOrThrow } from '@/object-record/record-table/contexts/RecordTableContext'; import { RecordTableEmptyStateDisplay } from '@/object-record/record-table/empty-state/components/RecordTableEmptyStateDisplay'; @@ -25,14 +26,22 @@ export const RecordTableEmptyStateSoftDelete = () => { viewBarId: recordTableId, }); + const { removeRecordFilter } = useRemoveRecordFilter(); + const handleButtonClick = async () => { - deleteCombinedViewFilter( - tableFilters.find( - (filter) => - filter.definition.label === 'Deleted' && - filter.operand === 'isNotEmpty', - )?.id ?? '', + const deletedFilter = tableFilters.find( + (filter) => + filter.definition.label === 'Deleted' && + filter.operand === 'isNotEmpty', ); + + if (!deletedFilter) { + throw new Error('Deleted filter not found'); + } + + removeRecordFilter(deletedFilter.fieldMetadataId); + deleteCombinedViewFilter(deletedFilter.id); + toggleSoftDeleteFilterState(false); }; diff --git a/packages/twenty-front/src/modules/object-record/record-table/hooks/useAggregateRecordsForHeader.ts b/packages/twenty-front/src/modules/object-record/record-table/hooks/useAggregateRecordsForHeader.ts index 42d5cff5d06d..60a92cc4c7f2 100644 --- a/packages/twenty-front/src/modules/object-record/record-table/hooks/useAggregateRecordsForHeader.ts +++ b/packages/twenty-front/src/modules/object-record/record-table/hooks/useAggregateRecordsForHeader.ts @@ -21,7 +21,6 @@ type UseAggregateRecordsProps = { export const useAggregateRecordsForHeader = ({ objectMetadataItem, additionalFilters = {}, - fallbackFieldName, }: UseAggregateRecordsProps) => { const recordIndexViewFilterGroups = useRecoilValue( recordIndexViewFilterGroupsState, @@ -47,7 +46,6 @@ export const useAggregateRecordsForHeader = ({ const recordGqlFieldsAggregate = buildRecordGqlFieldsAggregateForView({ objectMetadataItem, recordIndexKanbanAggregateOperation, - fieldNameForCount: fallbackFieldName, }); const { data } = useAggregateRecords({ @@ -61,7 +59,6 @@ export const useAggregateRecordsForHeader = ({ objectMetadataItem, fieldMetadataId: recordIndexKanbanAggregateOperation?.fieldMetadataId, aggregateOperation: recordIndexKanbanAggregateOperation?.operation, - fallbackFieldName, dateFormat, timeFormat, timeZone, diff --git a/packages/twenty-front/src/modules/object-record/record-table/hooks/useCreateNewTableRecords.ts b/packages/twenty-front/src/modules/object-record/record-table/hooks/useCreateNewTableRecords.ts index a5718cb27014..189385d736a9 100644 --- a/packages/twenty-front/src/modules/object-record/record-table/hooks/useCreateNewTableRecords.ts +++ b/packages/twenty-front/src/modules/object-record/record-table/hooks/useCreateNewTableRecords.ts @@ -5,7 +5,10 @@ import { DEFAULT_CELL_SCOPE } from '@/object-record/record-table/record-table-ce import { useSelectedTableCellEditMode } from '@/object-record/record-table/record-table-cell/hooks/useSelectedTableCellEditMode'; import { recordTablePendingRecordIdByGroupComponentFamilyState } from '@/object-record/record-table/states/recordTablePendingRecordIdByGroupComponentFamilyState'; import { recordTablePendingRecordIdComponentState } from '@/object-record/record-table/states/recordTablePendingRecordIdComponentState'; +import { isUpdatingRecordEditableNameState } from '@/object-record/states/isUpdatingRecordEditableName'; import { getDropdownFocusIdForRecordField } from '@/object-record/utils/getDropdownFocusIdForRecordField'; +import { shouldRedirectToShowPageOnCreation } from '@/object-record/utils/shouldRedirectToShowPageOnCreation'; +import { AppPath } from '@/types/AppPath'; import { useSetActiveDropdownFocusIdAndMemorizePrevious } from '@/ui/layout/dropdown/hooks/useSetFocusedDropdownIdAndMemorizePrevious'; import { useSetHotkeyScope } from '@/ui/utilities/hotkey/hooks/useSetHotkeyScope'; import { useRecoilComponentCallbackStateV2 } from '@/ui/utilities/state/component-state/hooks/useRecoilComponentCallbackStateV2'; @@ -14,6 +17,7 @@ import { useIsFeatureEnabled } from '@/workspace/hooks/useIsFeatureEnabled'; import { useRecoilCallback } from 'recoil'; import { v4 } from 'uuid'; import { FeatureFlagKey } from '~/generated/graphql'; +import { useNavigateApp } from '~/hooks/useNavigateApp'; import { isDefined } from '~/utils/isDefined'; export const useCreateNewTableRecord = ({ @@ -54,30 +58,69 @@ export const useCreateNewTableRecord = ({ shouldMatchRootQueryFilter: true, }); - const createNewTableRecord = async () => { - const recordId = v4(); + const navigate = useNavigateApp(); - if (isCommandMenuV2Enabled) { - await createOneRecord({ id: recordId }); + const createNewTableRecord = useRecoilCallback( + ({ set }) => + async () => { + const recordId = v4(); - openRecordInCommandMenu(recordId, objectMetadataItem.nameSingular); - return; - } + if (isCommandMenuV2Enabled) { + // TODO: Generalize this behaviour, there will be a view setting to specify + // if the new record should be displayed in the side panel or on the record page + if ( + shouldRedirectToShowPageOnCreation(objectMetadataItem.nameSingular) + ) { + await createOneRecord({ + id: recordId, + name: 'Untitled', + }); + + navigate(AppPath.RecordShowPage, { + objectNameSingular: objectMetadataItem.nameSingular, + objectRecordId: recordId, + }); + + set(isUpdatingRecordEditableNameState, true); + return; + } + + await createOneRecord({ id: recordId }); + openRecordInCommandMenu(recordId, objectMetadataItem.nameSingular); + + return; + } - setPendingRecordId(recordId); - setSelectedTableCellEditMode(-1, 0); - setHotkeyScope(DEFAULT_CELL_SCOPE.scope, DEFAULT_CELL_SCOPE.customScopes); + setPendingRecordId(recordId); + setSelectedTableCellEditMode(-1, 0); + setHotkeyScope( + DEFAULT_CELL_SCOPE.scope, + DEFAULT_CELL_SCOPE.customScopes, + ); - if (isDefined(objectMetadataItem.labelIdentifierFieldMetadataId)) { - setActiveDropdownFocusIdAndMemorizePrevious( - getDropdownFocusIdForRecordField( - recordId, - objectMetadataItem.labelIdentifierFieldMetadataId, - 'table-cell', - ), - ); - } - }; + if (isDefined(objectMetadataItem.labelIdentifierFieldMetadataId)) { + setActiveDropdownFocusIdAndMemorizePrevious( + getDropdownFocusIdForRecordField( + recordId, + objectMetadataItem.labelIdentifierFieldMetadataId, + 'table-cell', + ), + ); + } + }, + [ + createOneRecord, + isCommandMenuV2Enabled, + navigate, + objectMetadataItem.labelIdentifierFieldMetadataId, + objectMetadataItem.nameSingular, + openRecordInCommandMenu, + setActiveDropdownFocusIdAndMemorizePrevious, + setHotkeyScope, + setPendingRecordId, + setSelectedTableCellEditMode, + ], + ); const createNewTableRecordInGroup = useRecoilCallback( ({ set }) => diff --git a/packages/twenty-front/src/modules/object-record/relation-picker/hooks/__tests__/useLimitPerMetadataItem.test.tsx b/packages/twenty-front/src/modules/object-record/relation-picker/hooks/__tests__/useLimitPerMetadataItem.test.tsx index 8596a3343b00..b28747d6cdbd 100644 --- a/packages/twenty-front/src/modules/object-record/relation-picker/hooks/__tests__/useLimitPerMetadataItem.test.tsx +++ b/packages/twenty-front/src/modules/object-record/relation-picker/hooks/__tests__/useLimitPerMetadataItem.test.tsx @@ -25,6 +25,7 @@ describe('useLimitPerMetadataItem', () => { labelSingular: 'labelSingular', namePlural: 'namePlural', nameSingular: 'nameSingular', + labelIdentifierFieldMetadataId: '20202020-72ba-4e11-a36d-e17b544541e1', updatedAt: 'updatedAt', isLabelSyncedWithName: false, fields: [], diff --git a/packages/twenty-front/src/modules/object-record/states/isUpdatingRecordEditableName.ts b/packages/twenty-front/src/modules/object-record/states/isUpdatingRecordEditableName.ts new file mode 100644 index 000000000000..5829911a53c7 --- /dev/null +++ b/packages/twenty-front/src/modules/object-record/states/isUpdatingRecordEditableName.ts @@ -0,0 +1,6 @@ +import { createState } from 'twenty-ui'; + +export const isUpdatingRecordEditableNameState = createState({ + key: 'isUpdatingRecordEditableNameState', + defaultValue: false, +}); diff --git a/packages/twenty-front/src/modules/object-record/utils/__tests__/generateAggregateQuery.test.ts b/packages/twenty-front/src/modules/object-record/utils/__tests__/generateAggregateQuery.test.ts index 4ce449957125..7cf2c38f8f3f 100644 --- a/packages/twenty-front/src/modules/object-record/utils/__tests__/generateAggregateQuery.test.ts +++ b/packages/twenty-front/src/modules/object-record/utils/__tests__/generateAggregateQuery.test.ts @@ -9,6 +9,7 @@ describe('generateAggregateQuery', () => { id: 'test-id', labelSingular: 'Company', labelPlural: 'Companies', + labelIdentifierFieldMetadataId: '20202020-72ba-4e11-a36d-e17b544541e1', isCustom: false, isActive: true, createdAt: new Date().toISOString(), @@ -46,6 +47,7 @@ describe('generateAggregateQuery', () => { id: 'test-id', labelSingular: 'Person', labelPlural: 'People', + labelIdentifierFieldMetadataId: '20202020-72ba-4e11-a36d-e17b544541e1', isCustom: false, isActive: true, createdAt: new Date().toISOString(), diff --git a/packages/twenty-front/src/modules/object-record/utils/getAvailableAggregationsFromObjectFields.ts b/packages/twenty-front/src/modules/object-record/utils/getAvailableAggregationsFromObjectFields.ts index 5ace360808ca..ee50c8eb82f1 100644 --- a/packages/twenty-front/src/modules/object-record/utils/getAvailableAggregationsFromObjectFields.ts +++ b/packages/twenty-front/src/modules/object-record/utils/getAvailableAggregationsFromObjectFields.ts @@ -2,7 +2,11 @@ import { FieldMetadataItem } from '@/object-metadata/types/FieldMetadataItem'; import { AGGREGATE_OPERATIONS } from '@/object-record/record-table/constants/AggregateOperations'; import { DATE_AGGREGATE_OPERATIONS } from '@/object-record/record-table/constants/DateAggregateOperations'; import { ExtendedAggregateOperations } from '@/object-record/record-table/types/ExtendedAggregateOperations'; -import { capitalize, isFieldMetadataDateKind } from 'twenty-shared'; +import { + capitalize, + FIELD_FOR_TOTAL_COUNT_AGGREGATE_OPERATION, + isFieldMetadataDateKind, +} from 'twenty-shared'; import { FieldMetadataType } from '~/generated-metadata/graphql'; type NameForAggregation = { @@ -16,59 +20,66 @@ type Aggregations = { export const getAvailableAggregationsFromObjectFields = ( fields: FieldMetadataItem[], ): Aggregations => { - return fields.reduce>((acc, field) => { - if (field.isSystem === true) { - return acc; - } + return fields.reduce>( + (acc, field) => { + if (field.isSystem === true) { + return acc; + } + + if (field.type === FieldMetadataType.RELATION) { + acc[field.name] = { + [AGGREGATE_OPERATIONS.count]: 'totalCount', + }; + return acc; + } - if (field.type === FieldMetadataType.RELATION) { acc[field.name] = { + [AGGREGATE_OPERATIONS.countUniqueValues]: `countUniqueValues${capitalize(field.name)}`, + [AGGREGATE_OPERATIONS.countEmpty]: `countEmpty${capitalize(field.name)}`, + [AGGREGATE_OPERATIONS.countNotEmpty]: `countNotEmpty${capitalize(field.name)}`, + [AGGREGATE_OPERATIONS.percentageEmpty]: `percentageEmpty${capitalize(field.name)}`, + [AGGREGATE_OPERATIONS.percentageNotEmpty]: `percentageNotEmpty${capitalize(field.name)}`, [AGGREGATE_OPERATIONS.count]: 'totalCount', }; - return acc; - } - acc[field.name] = { - [AGGREGATE_OPERATIONS.countUniqueValues]: `countUniqueValues${capitalize(field.name)}`, - [AGGREGATE_OPERATIONS.countEmpty]: `countEmpty${capitalize(field.name)}`, - [AGGREGATE_OPERATIONS.countNotEmpty]: `countNotEmpty${capitalize(field.name)}`, - [AGGREGATE_OPERATIONS.percentageEmpty]: `percentageEmpty${capitalize(field.name)}`, - [AGGREGATE_OPERATIONS.percentageNotEmpty]: `percentageNotEmpty${capitalize(field.name)}`, - [AGGREGATE_OPERATIONS.count]: 'totalCount', - }; - - if (field.type === FieldMetadataType.NUMBER) { - acc[field.name] = { - ...acc[field.name], - [AGGREGATE_OPERATIONS.min]: `min${capitalize(field.name)}`, - [AGGREGATE_OPERATIONS.max]: `max${capitalize(field.name)}`, - [AGGREGATE_OPERATIONS.avg]: `avg${capitalize(field.name)}`, - [AGGREGATE_OPERATIONS.sum]: `sum${capitalize(field.name)}`, - }; - } + if (field.type === FieldMetadataType.NUMBER) { + acc[field.name] = { + ...acc[field.name], + [AGGREGATE_OPERATIONS.min]: `min${capitalize(field.name)}`, + [AGGREGATE_OPERATIONS.max]: `max${capitalize(field.name)}`, + [AGGREGATE_OPERATIONS.avg]: `avg${capitalize(field.name)}`, + [AGGREGATE_OPERATIONS.sum]: `sum${capitalize(field.name)}`, + }; + } - if (field.type === FieldMetadataType.CURRENCY) { - acc[field.name] = { - ...acc[field.name], - [AGGREGATE_OPERATIONS.min]: `min${capitalize(field.name)}AmountMicros`, - [AGGREGATE_OPERATIONS.max]: `max${capitalize(field.name)}AmountMicros`, - [AGGREGATE_OPERATIONS.avg]: `avg${capitalize(field.name)}AmountMicros`, - [AGGREGATE_OPERATIONS.sum]: `sum${capitalize(field.name)}AmountMicros`, - }; - } + if (field.type === FieldMetadataType.CURRENCY) { + acc[field.name] = { + ...acc[field.name], + [AGGREGATE_OPERATIONS.min]: `min${capitalize(field.name)}AmountMicros`, + [AGGREGATE_OPERATIONS.max]: `max${capitalize(field.name)}AmountMicros`, + [AGGREGATE_OPERATIONS.avg]: `avg${capitalize(field.name)}AmountMicros`, + [AGGREGATE_OPERATIONS.sum]: `sum${capitalize(field.name)}AmountMicros`, + }; + } - if (isFieldMetadataDateKind(field.type) === true) { - acc[field.name] = { - ...acc[field.name], - [DATE_AGGREGATE_OPERATIONS.earliest]: `min${capitalize(field.name)}`, - [DATE_AGGREGATE_OPERATIONS.latest]: `max${capitalize(field.name)}`, - }; - } + if (isFieldMetadataDateKind(field.type) === true) { + acc[field.name] = { + ...acc[field.name], + [DATE_AGGREGATE_OPERATIONS.earliest]: `min${capitalize(field.name)}`, + [DATE_AGGREGATE_OPERATIONS.latest]: `max${capitalize(field.name)}`, + }; + } - if (acc[field.name] === undefined) { - acc[field.name] = {}; - } + if (acc[field.name] === undefined) { + acc[field.name] = {}; + } - return acc; - }, {}); + return acc; + }, + { + [FIELD_FOR_TOTAL_COUNT_AGGREGATE_OPERATION]: { + [AGGREGATE_OPERATIONS.count]: 'totalCount', + }, + }, + ); }; diff --git a/packages/twenty-front/src/modules/object-record/utils/shouldRedirectToShowPageOnCreation.ts b/packages/twenty-front/src/modules/object-record/utils/shouldRedirectToShowPageOnCreation.ts new file mode 100644 index 000000000000..d9a98b5ff883 --- /dev/null +++ b/packages/twenty-front/src/modules/object-record/utils/shouldRedirectToShowPageOnCreation.ts @@ -0,0 +1,11 @@ +import { CoreObjectNameSingular } from '@/object-metadata/types/CoreObjectNameSingular'; + +export const shouldRedirectToShowPageOnCreation = ( + objectNameSingular: string, +) => { + if (objectNameSingular === CoreObjectNameSingular.Workflow) { + return true; + } + + return false; +}; diff --git a/packages/twenty-front/src/modules/prefetch/components/PrefetchRunQueriesEffect.tsx b/packages/twenty-front/src/modules/prefetch/components/PrefetchRunQueriesEffect.tsx index 1808b4c3dc65..2a6481d292c6 100644 --- a/packages/twenty-front/src/modules/prefetch/components/PrefetchRunQueriesEffect.tsx +++ b/packages/twenty-front/src/modules/prefetch/components/PrefetchRunQueriesEffect.tsx @@ -7,7 +7,7 @@ import { FavoriteFolder } from '@/favorites/types/FavoriteFolder'; import { useObjectMetadataItems } from '@/object-metadata/hooks/useObjectMetadataItems'; import { useCombinedFindManyRecords } from '@/object-record/multiple-objects/hooks/useCombinedFindManyRecords'; import { PREFETCH_CONFIG } from '@/prefetch/constants/PrefetchConfig'; -import { usePrefetchRunQuery } from '@/prefetch/hooks/internal/usePrefetchRunQuery'; +import { useUpsertRecordsInCacheForPrefetchKey } from '@/prefetch/hooks/internal/useUpsertRecordsInCacheForPrefetchKey'; import { PrefetchKey } from '@/prefetch/types/PrefetchKey'; import { View } from '@/views/types/View'; import { useIsWorkspaceActivationStatusSuspended } from '@/workspace/hooks/useIsWorkspaceActivationStatusSuspended'; @@ -19,16 +19,16 @@ export const PrefetchRunQueriesEffect = () => { const isWorkspaceSuspended = useIsWorkspaceActivationStatusSuspended(); const { upsertRecordsInCache: upsertViewsInCache } = - usePrefetchRunQuery({ + useUpsertRecordsInCacheForPrefetchKey({ prefetchKey: PrefetchKey.AllViews, }); const { upsertRecordsInCache: upsertFavoritesInCache } = - usePrefetchRunQuery({ + useUpsertRecordsInCacheForPrefetchKey({ prefetchKey: PrefetchKey.AllFavorites, }); const { upsertRecordsInCache: upsertFavoritesFoldersInCache } = - usePrefetchRunQuery({ + useUpsertRecordsInCacheForPrefetchKey({ prefetchKey: PrefetchKey.AllFavoritesFolders, }); const { objectMetadataItems } = useObjectMetadataItems(); diff --git a/packages/twenty-front/src/modules/prefetch/hooks/internal/usePrefetchRunQuery.ts b/packages/twenty-front/src/modules/prefetch/hooks/internal/useUpsertRecordsInCacheForPrefetchKey.ts similarity index 94% rename from packages/twenty-front/src/modules/prefetch/hooks/internal/usePrefetchRunQuery.ts rename to packages/twenty-front/src/modules/prefetch/hooks/internal/useUpsertRecordsInCacheForPrefetchKey.ts index a2b18f3ca139..2e1da0cb8967 100644 --- a/packages/twenty-front/src/modules/prefetch/hooks/internal/usePrefetchRunQuery.ts +++ b/packages/twenty-front/src/modules/prefetch/hooks/internal/useUpsertRecordsInCacheForPrefetchKey.ts @@ -11,7 +11,7 @@ export type UsePrefetchRunQuery = { prefetchKey: PrefetchKey; }; -export const usePrefetchRunQuery = ({ +export const useUpsertRecordsInCacheForPrefetchKey = ({ prefetchKey, }: UsePrefetchRunQuery) => { const setPrefetchDataIsLoaded = useSetRecoilState( @@ -45,7 +45,6 @@ export const usePrefetchRunQuery = ({ return { objectMetadataItem, - setPrefetchDataIsLoaded, upsertRecordsInCache, }; }; diff --git a/packages/twenty-front/src/modules/settings/components/SettingsNavigationDrawerItems.tsx b/packages/twenty-front/src/modules/settings/components/SettingsNavigationDrawerItems.tsx index 32113d19c150..5fd9d1a8bbf4 100644 --- a/packages/twenty-front/src/modules/settings/components/SettingsNavigationDrawerItems.tsx +++ b/packages/twenty-front/src/modules/settings/components/SettingsNavigationDrawerItems.tsx @@ -55,9 +55,11 @@ export const SettingsNavigationDrawerItems = () => { const { t } = useLingui(); const billing = useRecoilValue(billingState); - const isFunctionSettingsEnabled = useIsFeatureEnabled( - FeatureFlagKey.IsFunctionSettingsEnabled, - ); + + // We want to disable this serverless function setting menu but keep the code + // for now + const isFunctionSettingsEnabled = false; + const isFreeAccessEnabled = useIsFeatureEnabled( FeatureFlagKey.IsFreeAccessEnabled, ); diff --git a/packages/twenty-front/src/modules/settings/data-model/fields/preview/hooks/useFieldPreviewValue.ts b/packages/twenty-front/src/modules/settings/data-model/fields/preview/hooks/useFieldPreviewValue.ts index d24e20f26c10..a2f1d9358545 100644 --- a/packages/twenty-front/src/modules/settings/data-model/fields/preview/hooks/useFieldPreviewValue.ts +++ b/packages/twenty-front/src/modules/settings/data-model/fields/preview/hooks/useFieldPreviewValue.ts @@ -28,6 +28,7 @@ export const useFieldPreviewValue = ({ relationObjectMetadataItem: relationObjectMetadataItem ?? { fields: [], labelSingular: '', + labelIdentifierFieldMetadataId: '20202020-1000-4629-87e5-9a1fae1cc2fd', nameSingular: CoreObjectNameSingular.Company, }, skip: diff --git a/packages/twenty-front/src/modules/settings/data-model/object-details/components/tabs/ObjectSettings.tsx b/packages/twenty-front/src/modules/settings/data-model/object-details/components/tabs/ObjectSettings.tsx index 6fcf55ad30d4..0d919f23296c 100644 --- a/packages/twenty-front/src/modules/settings/data-model/object-details/components/tabs/ObjectSettings.tsx +++ b/packages/twenty-front/src/modules/settings/data-model/object-details/components/tabs/ObjectSettings.tsx @@ -23,7 +23,6 @@ import { SnackBarVariant } from '@/ui/feedback/snack-bar-manager/components/Snac import { useSnackBar } from '@/ui/feedback/snack-bar-manager/hooks/useSnackBar'; import { navigationMemorizedUrlState } from '@/ui/navigation/states/navigationMemorizedUrlState'; import styled from '@emotion/styled'; -import isEmpty from 'lodash.isempty'; import pick from 'lodash.pick'; import { useSetRecoilState } from 'recoil'; import { useNavigateSettings } from '~/hooks/useNavigateSettings'; @@ -70,6 +69,7 @@ export const ObjectSettings = ({ objectMetadataItem }: ObjectSettingsProps) => { mode: 'onTouched', resolver: zodResolver(objectEditFormSchema), }); + const { isDirty } = formConfig.formState; const setNavigationMemorizedUrl = useSetRecoilState( navigationMemorizedUrlState, @@ -124,7 +124,7 @@ export const ObjectSettings = ({ objectMetadataItem }: ObjectSettingsProps) => { const handleSave = async ( formValues: SettingsDataModelObjectEditFormValues, ) => { - if (isEmpty(formConfig.formState.dirtyFields) === true) { + if (!isDirty) { return; } try { @@ -202,6 +202,7 @@ export const ObjectSettings = ({ objectMetadataItem }: ObjectSettingsProps) => { description="Choose the fields that will identify your records" /> formConfig.handleSubmit(handleSave)()} objectMetadataItem={objectMetadataItem} /> diff --git a/packages/twenty-front/src/modules/settings/data-model/objects/forms/components/SettingsDataModelObjectAboutForm.tsx b/packages/twenty-front/src/modules/settings/data-model/objects/forms/components/SettingsDataModelObjectAboutForm.tsx index ce881e136a70..54233dc35224 100644 --- a/packages/twenty-front/src/modules/settings/data-model/objects/forms/components/SettingsDataModelObjectAboutForm.tsx +++ b/packages/twenty-front/src/modules/settings/data-model/objects/forms/components/SettingsDataModelObjectAboutForm.tsx @@ -219,6 +219,7 @@ export const SettingsDataModelObjectAboutForm = ({ value={value ?? undefined} onChange={(nextValue) => onChange(nextValue ?? null)} disabled={disableEdition} + onBlur={onBlur} /> )} /> diff --git a/packages/twenty-front/src/modules/settings/data-model/objects/forms/components/SettingsDataModelObjectIdentifiersForm.tsx b/packages/twenty-front/src/modules/settings/data-model/objects/forms/components/SettingsDataModelObjectIdentifiersForm.tsx index 29f5512e5b0c..bafa6d387186 100644 --- a/packages/twenty-front/src/modules/settings/data-model/objects/forms/components/SettingsDataModelObjectIdentifiersForm.tsx +++ b/packages/twenty-front/src/modules/settings/data-model/objects/forms/components/SettingsDataModelObjectIdentifiersForm.tsx @@ -1,7 +1,7 @@ import styled from '@emotion/styled'; import { useMemo } from 'react'; import { Controller, useFormContext } from 'react-hook-form'; -import { IconCircleOff, isDefined, useIcons } from 'twenty-ui'; +import { IconCircleOff, useIcons } from 'twenty-ui'; import { z } from 'zod'; import { LABEL_IDENTIFIER_FIELD_METADATA_TYPES } from '@/object-metadata/constants/LabelIdentifierFieldMetadataTypes'; @@ -19,11 +19,16 @@ export const settingsDataModelObjectIdentifiersFormSchema = export type SettingsDataModelObjectIdentifiersFormValues = z.infer< typeof settingsDataModelObjectIdentifiersFormSchema >; - +export type SettingsDataModelObjectIdentifiers = + keyof SettingsDataModelObjectIdentifiersFormValues; type SettingsDataModelObjectIdentifiersFormProps = { objectMetadataItem: ObjectMetadataItem; - defaultLabelIdentifierFieldMetadataId: string; + onBlur: () => void; }; +const LABEL_IDENTIFIER_FIELD_METADATA_ID: SettingsDataModelObjectIdentifiers = + 'labelIdentifierFieldMetadataId'; +const IMAGE_IDENTIFIER_FIELD_METADATA_ID: SettingsDataModelObjectIdentifiers = + 'imageIdentifierFieldMetadataId'; const StyledContainer = styled.div` display: flex; @@ -32,12 +37,11 @@ const StyledContainer = styled.div` export const SettingsDataModelObjectIdentifiersForm = ({ objectMetadataItem, - defaultLabelIdentifierFieldMetadataId, + onBlur, }: SettingsDataModelObjectIdentifiersFormProps) => { const { control } = useFormContext(); const { getIcon } = useIcons(); - const labelIdentifierFieldOptions = useMemo( () => getActiveFieldMetadataItems(objectMetadataItem) @@ -65,41 +69,37 @@ export const SettingsDataModelObjectIdentifiersForm = ({ {[ { label: 'Record label', - fieldName: 'labelIdentifierFieldMetadataId' as const, + fieldName: LABEL_IDENTIFIER_FIELD_METADATA_ID, options: labelIdentifierFieldOptions, + defaultValue: objectMetadataItem.labelIdentifierFieldMetadataId, }, { label: 'Record image', - fieldName: 'imageIdentifierFieldMetadataId' as const, + fieldName: IMAGE_IDENTIFIER_FIELD_METADATA_ID, options: imageIdentifierFieldOptions, + defaultValue: null, }, - ].map(({ fieldName, label, options }) => ( + ].map(({ fieldName, label, options, defaultValue }) => ( { - return ( - { + onChange(value); + onBlur(); + }} + /> + )} /> ))} diff --git a/packages/twenty-front/src/modules/settings/data-model/objects/forms/components/SettingsDataModelObjectSettingsFormCard.tsx b/packages/twenty-front/src/modules/settings/data-model/objects/forms/components/SettingsDataModelObjectSettingsFormCard.tsx index 237196206246..822e257e7289 100644 --- a/packages/twenty-front/src/modules/settings/data-model/objects/forms/components/SettingsDataModelObjectSettingsFormCard.tsx +++ b/packages/twenty-front/src/modules/settings/data-model/objects/forms/components/SettingsDataModelObjectSettingsFormCard.tsx @@ -1,21 +1,18 @@ import styled from '@emotion/styled'; import { useMemo } from 'react'; -import { useFormContext } from 'react-hook-form'; import { ObjectMetadataItem } from '@/object-metadata/types/ObjectMetadataItem'; import { getLabelIdentifierFieldMetadataItem } from '@/object-metadata/utils/getLabelIdentifierFieldMetadataItem'; import { SettingsDataModelCardTitle } from '@/settings/data-model/components/SettingsDataModelCardTitle'; import { SettingsDataModelFieldPreviewCard } from '@/settings/data-model/fields/preview/components/SettingsDataModelFieldPreviewCard'; import { SettingsDataModelObjectSummary } from '@/settings/data-model/objects/components/SettingsDataModelObjectSummary'; -import { - SettingsDataModelObjectIdentifiersForm, - SettingsDataModelObjectIdentifiersFormValues, -} from '@/settings/data-model/objects/forms/components/SettingsDataModelObjectIdentifiersForm'; +import { SettingsDataModelObjectIdentifiersForm } from '@/settings/data-model/objects/forms/components/SettingsDataModelObjectIdentifiersForm'; import { Trans } from '@lingui/react/macro'; import { Card, CardContent } from 'twenty-ui'; type SettingsDataModelObjectSettingsFormCardProps = { objectMetadataItem: ObjectMetadataItem; + onBlur: () => void; }; const StyledFieldPreviewCard = styled(SettingsDataModelFieldPreviewCard)` @@ -38,22 +35,15 @@ const StyledObjectSummaryCardContent = styled(CardContent)` export const SettingsDataModelObjectSettingsFormCard = ({ objectMetadataItem, + onBlur, }: SettingsDataModelObjectSettingsFormCardProps) => { - const { watch: watchFormValue } = - useFormContext(); - - const labelIdentifierFieldMetadataIdFormValue = watchFormValue( - 'labelIdentifierFieldMetadataId', - ); - - const labelIdentifierFieldMetadataItem = useMemo( - () => - getLabelIdentifierFieldMetadataItem({ - fields: objectMetadataItem.fields, - labelIdentifierFieldMetadataId: labelIdentifierFieldMetadataIdFormValue, - }), - [labelIdentifierFieldMetadataIdFormValue, objectMetadataItem], - ); + const labelIdentifierFieldMetadataItem = useMemo(() => { + return getLabelIdentifierFieldMetadataItem({ + fields: objectMetadataItem.fields, + labelIdentifierFieldMetadataId: + objectMetadataItem.labelIdentifierFieldMetadataId, + }); + }, [objectMetadataItem]); return ( @@ -80,9 +70,7 @@ export const SettingsDataModelObjectSettingsFormCard = ({ diff --git a/packages/twenty-front/src/modules/sign-in-background-mock/components/SignInBackgroundMockContainer.tsx b/packages/twenty-front/src/modules/sign-in-background-mock/components/SignInBackgroundMockContainer.tsx index 7b379dcc2916..4c2a25958bab 100644 --- a/packages/twenty-front/src/modules/sign-in-background-mock/components/SignInBackgroundMockContainer.tsx +++ b/packages/twenty-front/src/modules/sign-in-background-mock/components/SignInBackgroundMockContainer.tsx @@ -3,6 +3,7 @@ import styled from '@emotion/styled'; import { ActionMenuComponentInstanceContext } from '@/action-menu/states/contexts/ActionMenuComponentInstanceContext'; import { ContextStoreComponentInstanceContext } from '@/context-store/states/contexts/ContextStoreComponentInstanceContext'; import { useObjectMetadataItem } from '@/object-metadata/hooks/useObjectMetadataItem'; +import { RecordFiltersComponentInstanceContext } from '@/object-record/record-filter/states/context/RecordFiltersComponentInstanceContext'; import { RecordIndexContextProvider } from '@/object-record/record-index/contexts/RecordIndexContext'; import { RecordTableWithWrappers } from '@/object-record/record-table/components/RecordTableWithWrappers'; import { SignInBackgroundMockContainerEffect } from '@/sign-in-background-mock/components/SignInBackgroundMockContainerEffect'; @@ -41,30 +42,36 @@ export const SignInBackgroundMockContainer = () => { - - - {}} - optionsDropdownButton={<>} - /> - - {}} - /> - - + + {}} + optionsDropdownButton={<>} + /> + + {}} + /> + + + diff --git a/packages/twenty-front/src/modules/ui/input/components/TextInputV2.tsx b/packages/twenty-front/src/modules/ui/input/components/TextInputV2.tsx index d770d8a1b004..f3a371084947 100644 --- a/packages/twenty-front/src/modules/ui/input/components/TextInputV2.tsx +++ b/packages/twenty-front/src/modules/ui/input/components/TextInputV2.tsx @@ -1,3 +1,4 @@ +import { InputLabel } from '@/ui/input/components/InputLabel'; import { useTheme } from '@emotion/react'; import styled from '@emotion/styled'; import { @@ -10,28 +11,37 @@ import { useRef, useState, } from 'react'; -import { IconComponent, IconEye, IconEyeOff, RGBA } from 'twenty-ui'; +import { + ComputeNodeDimensions, + IconComponent, + IconEye, + IconEyeOff, + RGBA, +} from 'twenty-ui'; import { useCombinedRefs } from '~/hooks/useCombinedRefs'; import { turnIntoEmptyStringIfWhitespacesOnly } from '~/utils/string/turnIntoEmptyStringIfWhitespacesOnly'; -import { InputLabel } from './InputLabel'; const StyledContainer = styled.div< Pick >` + box-sizing: border-box; display: inline-flex; flex-direction: column; width: ${({ fullWidth }) => (fullWidth ? `100%` : 'auto')}; `; const StyledInputContainer = styled.div` + background-color: inherit; display: flex; flex-direction: row; - width: 100%; position: relative; `; const StyledInput = styled.input< - Pick + Pick< + TextInputV2ComponentProps, + 'LeftIcon' | 'error' | 'sizeVariant' | 'width' + > >` background-color: ${({ theme }) => theme.background.transparent.lighter}; border: 1px solid @@ -44,12 +54,14 @@ const StyledInput = styled.input< flex-grow: 1; font-family: ${({ theme }) => theme.font.family}; font-weight: ${({ theme }) => theme.font.weight.regular}; - height: 32px; + height: ${({ sizeVariant }) => (sizeVariant === 'sm' ? '20px' : '32px')}; outline: none; - padding: ${({ theme }) => theme.spacing(2)}; + padding: ${({ theme, sizeVariant }) => + sizeVariant === 'sm' ? `${theme.spacing(2)} 0` : theme.spacing(2)}; padding-left: ${({ theme, LeftIcon }) => - LeftIcon ? `calc(${theme.spacing(4)} + 16px)` : theme.spacing(2)}; - width: 100%; + LeftIcon ? `px` : theme.spacing(2)}; + width: ${({ theme, width }) => + width ? `calc(${width}px + ${theme.spacing(5)})` : '100%'}; &::placeholder, &::-webkit-input-placeholder { @@ -111,6 +123,8 @@ const StyledTrailingIcon = styled.div` const INPUT_TYPE_PASSWORD = 'password'; +export type TextInputV2Size = 'sm' | 'md'; + export type TextInputV2ComponentProps = Omit< InputHTMLAttributes, 'onChange' | 'onKeyDown' @@ -123,11 +137,15 @@ export type TextInputV2ComponentProps = Omit< noErrorHelper?: boolean; RightIcon?: IconComponent; LeftIcon?: IconComponent; + autoGrow?: boolean; onKeyDown?: (event: React.KeyboardEvent) => void; onBlur?: FocusEventHandler; dataTestId?: string; + sizeVariant?: TextInputV2Size; }; +type TextInputV2WithAutoGrowWrapperProps = TextInputV2ComponentProps; + const TextInputV2Component = ( { className, @@ -138,6 +156,7 @@ const TextInputV2Component = ( onBlur, onKeyDown, fullWidth, + width, error, noErrorHelper = false, required, @@ -150,6 +169,7 @@ const TextInputV2Component = ( LeftIcon, autoComplete, maxLength, + sizeVariant = 'md', dataTestId, }: TextInputV2ComponentProps, // eslint-disable-next-line @nx/workspace-component-props-naming @@ -183,8 +203,10 @@ const TextInputV2Component = ( )} + + {!error && type === INPUT_TYPE_PASSWORD && ( ( + <> + {props.autoGrow ? ( + + {(nodeDimensions) => ( + // eslint-disable-next-line + + )} + + ) : ( + // eslint-disable-next-line + + )} + +); + +export const TextInputV2 = forwardRef(TextInputV2WithAutoGrowWrapper); diff --git a/packages/twenty-front/src/modules/ui/input/components/__stories__/TextInputV2.stories.tsx b/packages/twenty-front/src/modules/ui/input/components/__stories__/TextInputV2.stories.tsx index 8cd86abc20a2..e9ffe6d6d609 100644 --- a/packages/twenty-front/src/modules/ui/input/components/__stories__/TextInputV2.stories.tsx +++ b/packages/twenty-front/src/modules/ui/input/components/__stories__/TextInputV2.stories.tsx @@ -1,5 +1,5 @@ -import { useState } from 'react'; import { Meta, StoryObj } from '@storybook/react'; +import { useState } from 'react'; import { ComponentDecorator } from 'twenty-ui'; import { @@ -40,3 +40,19 @@ export const Filled: Story = { export const Disabled: Story = { args: { disabled: true, value: 'Tim' }, }; + +export const AutoGrow: Story = { + args: { autoGrow: true, value: 'Tim' }, +}; + +export const AutoGrowWithPlaceholder: Story = { + args: { autoGrow: true, placeholder: 'Tim' }, +}; + +export const Small: Story = { + args: { sizeVariant: 'sm', value: 'Tim' }, +}; + +export const AutoGrowSmall: Story = { + args: { autoGrow: true, sizeVariant: 'sm', value: 'Tim' }, +}; diff --git a/packages/twenty-front/src/modules/ui/layout/page/components/PageHeader.tsx b/packages/twenty-front/src/modules/ui/layout/page/components/PageHeader.tsx index 5e5da21227f7..d2acad3e8411 100644 --- a/packages/twenty-front/src/modules/ui/layout/page/components/PageHeader.tsx +++ b/packages/twenty-front/src/modules/ui/layout/page/components/PageHeader.tsx @@ -34,9 +34,9 @@ const StyledTopBarContainer = styled.div` padding: ${({ theme }) => theme.spacing(2)}; padding-left: 0; padding-right: ${({ theme }) => theme.spacing(3)}; + gap: ${({ theme }) => theme.spacing(2)}; @media (max-width: ${MOBILE_VIEWPORT}px) { - width: 100%; box-sizing: border-box; padding: ${({ theme }) => theme.spacing(3)}; } @@ -48,7 +48,7 @@ const StyledLeftContainer = styled.div` flex-direction: row; gap: ${({ theme }) => theme.spacing(1)}; padding-left: ${({ theme }) => theme.spacing(1)}; - width: 100%; + overflow-x: hidden; @media (max-width: ${MOBILE_VIEWPORT}px) { padding-left: ${({ theme }) => theme.spacing(1)}; @@ -60,21 +60,19 @@ const StyledTitleContainer = styled.div` font-size: ${({ theme }) => theme.font.size.md}; font-weight: ${({ theme }) => theme.font.weight.medium}; margin-left: ${({ theme }) => theme.spacing(1)}; - width: 100%; `; const StyledTopBarIconStyledTitleContainer = styled.div` align-items: center; display: flex; - flex: 1 0 auto; gap: ${({ theme }) => theme.spacing(1)}; flex-direction: row; - width: 100%; `; const StyledPageActionContainer = styled.div` display: inline-flex; gap: ${({ theme }) => theme.spacing(2)}; + flex: 1 0 1; `; const StyledTopBarButtonContainer = styled.div` @@ -82,6 +80,13 @@ const StyledTopBarButtonContainer = styled.div` margin-right: ${({ theme }) => theme.spacing(1)}; `; +const StyledIconContainer = styled.div` + flex: 1 0 1; + display: flex; + flex-direction: row; + align-items: center; +`; + type PageHeaderProps = { title?: ReactNode; hasClosePageButton?: boolean; @@ -149,7 +154,9 @@ export const PageHeader = ({ /> )} - {Icon && } + + {Icon && } + {title && ( {typeof title === 'string' ? ( diff --git a/packages/twenty-front/src/modules/ui/layout/right-drawer/hooks/useRightDrawer.ts b/packages/twenty-front/src/modules/ui/layout/right-drawer/hooks/useRightDrawer.ts index 52a604e71a2d..ebcc3efe2567 100644 --- a/packages/twenty-front/src/modules/ui/layout/right-drawer/hooks/useRightDrawer.ts +++ b/packages/twenty-front/src/modules/ui/layout/right-drawer/hooks/useRightDrawer.ts @@ -5,9 +5,11 @@ import { rightDrawerCloseEventState } from '@/ui/layout/right-drawer/states/righ import { useCommandMenu } from '@/command-menu/hooks/useCommandMenu'; import { commandMenuPageState } from '@/command-menu/states/commandMenuPageState'; +import { commandMenuPageInfoState } from '@/command-menu/states/commandMenuPageTitle'; import { emitRightDrawerCloseEvent } from '@/ui/layout/right-drawer/utils/emitRightDrawerCloseEvent'; import { mapRightDrawerPageToCommandMenuPage } from '@/ui/layout/right-drawer/utils/mapRightDrawerPageToCommandMenuPage'; import { useIsFeatureEnabled } from '@/workspace/hooks/useIsFeatureEnabled'; +import { IconComponent } from 'twenty-ui'; import { FeatureFlagKey } from '~/generated/graphql'; import { isRightDrawerOpenState } from '../states/isRightDrawerOpenState'; import { rightDrawerPageState } from '../states/rightDrawerPageState'; @@ -27,12 +29,22 @@ export const useRightDrawer = () => { const openRightDrawer = useRecoilCallback( ({ set }) => - (rightDrawerPage: RightDrawerPages) => { + ( + rightDrawerPage: RightDrawerPages, + commandMenuPageInfo?: { + title?: string; + Icon?: IconComponent; + }, + ) => { if (isCommandMenuV2Enabled) { const commandMenuPage = mapRightDrawerPageToCommandMenuPage(rightDrawerPage); set(commandMenuPageState, commandMenuPage); + set(commandMenuPageInfoState, { + title: commandMenuPageInfo?.title, + Icon: commandMenuPageInfo?.Icon, + }); openCommandMenu(); return; } diff --git a/packages/twenty-front/src/modules/ui/navigation/bread-crumb/components/EditableBreadcrumbItem.tsx b/packages/twenty-front/src/modules/ui/navigation/bread-crumb/components/EditableBreadcrumbItem.tsx new file mode 100644 index 000000000000..b3956a044822 --- /dev/null +++ b/packages/twenty-front/src/modules/ui/navigation/bread-crumb/components/EditableBreadcrumbItem.tsx @@ -0,0 +1,120 @@ +import { isUpdatingRecordEditableNameState } from '@/object-record/states/isUpdatingRecordEditableName'; +import { TextInputV2 } from '@/ui/input/components/TextInputV2'; +import { useOpenEditableBreadCrumbItem } from '@/ui/navigation/bread-crumb/hooks/useOpenEditableBreadCrumbItem'; +import { EditableBreadcrumbItemHotkeyScope } from '@/ui/navigation/bread-crumb/types/EditableBreadcrumbItemHotkeyScope'; +import { useScopedHotkeys } from '@/ui/utilities/hotkey/hooks/useScopedHotkeys'; +import { useListenClickOutside } from '@/ui/utilities/pointer-event/hooks/useListenClickOutside'; +import styled from '@emotion/styled'; +import { useRef, useState } from 'react'; +import { useRecoilState } from 'recoil'; +import { Key } from 'ts-key-enum'; +import { isDefined } from 'twenty-ui'; +import { useHotkeyScopeOnMount } from '~/hooks/useHotkeyScopeOnMount'; + +type EditableBreadcrumbItemProps = { + className?: string; + defaultValue: string; + noValuePlaceholder?: string; + placeholder: string; + onSubmit: (value: string) => void; + hotkeyScope: string; +}; + +const StyledButton = styled('button')` + align-items: center; + background: inherit; + border: none; + border-radius: ${({ theme }) => theme.border.radius.sm}; + box-sizing: content-box; + color: ${({ theme }) => theme.font.color.primary}; + cursor: pointer; + display: flex; + font-family: ${({ theme }) => theme.font.family}; + font-size: ${({ theme }) => theme.font.size.md}; + height: 20px; + overflow: hidden; + text-decoration: none; + text-overflow: ellipsis; + white-space: nowrap; + + :hover { + background: ${({ theme }) => theme.background.transparent.light}; + } +`; + +export const EditableBreadcrumbItem = ({ + className, + defaultValue, + noValuePlaceholder, + placeholder, + onSubmit, +}: EditableBreadcrumbItemProps) => { + const inputRef = useRef(null); + const buttonRef = useRef(null); + + const [isUpdatingRecordEditableName, setIsUpdatingRecordEditableName] = + useRecoilState(isUpdatingRecordEditableNameState); + + // TODO: remove this and set the hokey scopes synchronously on page change inside the useNavigateApp hook + useHotkeyScopeOnMount( + EditableBreadcrumbItemHotkeyScope.EditableBreadcrumbItem, + ); + + useScopedHotkeys( + [Key.Escape], + () => { + setIsUpdatingRecordEditableName(false); + }, + EditableBreadcrumbItemHotkeyScope.EditableBreadcrumbItem, + ); + + useScopedHotkeys( + [Key.Enter], + () => { + onSubmit(value); + setIsUpdatingRecordEditableName(false); + }, + EditableBreadcrumbItemHotkeyScope.EditableBreadcrumbItem, + ); + + const clickOutsideRefs: Array> = [ + inputRef, + buttonRef, + ]; + + useListenClickOutside({ + refs: clickOutsideRefs, + callback: () => { + setIsUpdatingRecordEditableName(false); + }, + listenerId: 'editable-breadcrumb-item', + }); + + const handleFocus = (event: React.FocusEvent) => { + if (isDefined(value)) { + event.target.select(); + } + }; + + const [value, setValue] = useState(defaultValue); + + const { openEditableBreadCrumbItem } = useOpenEditableBreadCrumbItem(); + + return isUpdatingRecordEditableName ? ( + + ) : ( + + {value || noValuePlaceholder} + + ); +}; diff --git a/packages/twenty-front/src/modules/ui/navigation/bread-crumb/components/__stories__/EditableBreadcrumbItem.stories.tsx b/packages/twenty-front/src/modules/ui/navigation/bread-crumb/components/__stories__/EditableBreadcrumbItem.stories.tsx new file mode 100644 index 000000000000..2a1ec4f9eea1 --- /dev/null +++ b/packages/twenty-front/src/modules/ui/navigation/bread-crumb/components/__stories__/EditableBreadcrumbItem.stories.tsx @@ -0,0 +1,68 @@ +import { expect, jest } from '@storybook/jest'; +import { Meta, StoryObj } from '@storybook/react'; +import { RecoilRoot } from 'recoil'; + +import { EditableBreadcrumbItemHotkeyScope } from '@/ui/navigation/bread-crumb/types/EditableBreadcrumbItemHotkeyScope'; + +import { findByText, userEvent } from '@storybook/test'; +import { ComponentDecorator } from 'twenty-ui'; +import { EditableBreadcrumbItem } from '../EditableBreadcrumbItem'; + +const onSubmit = jest.fn(); + +const meta: Meta = { + title: 'UI/Navigation/BreadCrumb/EditableBreadcrumbItem', + component: EditableBreadcrumbItem, + decorators: [ + (Story) => ( + + + + ), + ComponentDecorator, + ], + args: { + defaultValue: 'Company Name', + placeholder: 'Enter name', + hotkeyScope: EditableBreadcrumbItemHotkeyScope.EditableBreadcrumbItem, + onSubmit, + }, +}; + +export default meta; +type Story = StoryObj; + +export const Default: Story = { + args: {}, + play: async ({ canvasElement }) => { + const button = await findByText(canvasElement, 'Company Name'); + expect(button).toBeInTheDocument(); + }, +}; + +export const Editing: Story = { + args: {}, + play: async ({ canvasElement }) => { + const button = canvasElement.querySelector('button'); + await userEvent.click(button); + + await new Promise((resolve) => setTimeout(resolve, 100)); + + await userEvent.keyboard('New Name'); + await userEvent.keyboard('{Enter}'); + + expect(onSubmit).toHaveBeenCalledWith('New Name'); + }, +}; + +export const WithNoValue: Story = { + args: { + defaultValue: '', + noValuePlaceholder: 'Untitled', + }, + play: async ({ canvasElement }) => { + const button = await findByText(canvasElement, 'Untitled'); + + expect(button).toBeInTheDocument(); + }, +}; diff --git a/packages/twenty-front/src/modules/ui/navigation/bread-crumb/hooks/useOpenEditableBreadCrumbItem.ts b/packages/twenty-front/src/modules/ui/navigation/bread-crumb/hooks/useOpenEditableBreadCrumbItem.ts new file mode 100644 index 000000000000..e42dca770188 --- /dev/null +++ b/packages/twenty-front/src/modules/ui/navigation/bread-crumb/hooks/useOpenEditableBreadCrumbItem.ts @@ -0,0 +1,19 @@ +import { isUpdatingRecordEditableNameState } from '@/object-record/states/isUpdatingRecordEditableName'; +import { EditableBreadcrumbItemHotkeyScope } from '@/ui/navigation/bread-crumb/types/EditableBreadcrumbItemHotkeyScope'; +import { useSetHotkeyScope } from '@/ui/utilities/hotkey/hooks/useSetHotkeyScope'; +import { useSetRecoilState } from 'recoil'; + +export const useOpenEditableBreadCrumbItem = () => { + const setIsUpdatingRecordEditableName = useSetRecoilState( + isUpdatingRecordEditableNameState, + ); + + const setHotkeyScope = useSetHotkeyScope(); + + const openEditableBreadCrumbItem = () => { + setIsUpdatingRecordEditableName(true); + setHotkeyScope(EditableBreadcrumbItemHotkeyScope.EditableBreadcrumbItem); + }; + + return { openEditableBreadCrumbItem }; +}; diff --git a/packages/twenty-front/src/modules/ui/navigation/bread-crumb/types/EditableBreadcrumbItemHotkeyScope.ts b/packages/twenty-front/src/modules/ui/navigation/bread-crumb/types/EditableBreadcrumbItemHotkeyScope.ts new file mode 100644 index 000000000000..0bc4d86dd9c5 --- /dev/null +++ b/packages/twenty-front/src/modules/ui/navigation/bread-crumb/types/EditableBreadcrumbItemHotkeyScope.ts @@ -0,0 +1,3 @@ +export enum EditableBreadcrumbItemHotkeyScope { + EditableBreadcrumbItem = 'editable-breadcrumb-item', +} diff --git a/packages/twenty-front/src/modules/ui/navigation/navigation-drawer/components/NavigationDrawerInput.tsx b/packages/twenty-front/src/modules/ui/navigation/navigation-drawer/components/NavigationDrawerInput.tsx index f76e5bcbfed8..0266bb39b017 100644 --- a/packages/twenty-front/src/modules/ui/navigation/navigation-drawer/components/NavigationDrawerInput.tsx +++ b/packages/twenty-front/src/modules/ui/navigation/navigation-drawer/components/NavigationDrawerInput.tsx @@ -1,23 +1,16 @@ -import { NavigationDrawerAnimatedCollapseWrapper } from '@/ui/navigation/navigation-drawer/components/NavigationDrawerAnimatedCollapseWrapper'; -import { isNavigationDrawerExpandedState } from '@/ui/navigation/states/isNavigationDrawerExpanded'; +import { TextInputV2 } from '@/ui/input/components/TextInputV2'; import { useScopedHotkeys } from '@/ui/utilities/hotkey/hooks/useScopedHotkeys'; import { useListenClickOutside } from '@/ui/utilities/pointer-event/hooks/useListenClickOutside'; -import { useTheme } from '@emotion/react'; import styled from '@emotion/styled'; -import { ChangeEvent, FocusEvent, useRef } from 'react'; -import { useRecoilState } from 'recoil'; +import { FocusEvent, useRef } from 'react'; import { Key } from 'ts-key-enum'; -import { - IconComponent, - isDefined, - TablerIconsProps, - TEXT_INPUT_STYLE, -} from 'twenty-ui'; +import { IconComponent, TablerIconsProps, isDefined } from 'twenty-ui'; import { useHotkeyScopeOnMount } from '~/hooks/useHotkeyScopeOnMount'; type NavigationDrawerInputProps = { className?: string; Icon?: IconComponent | ((props: TablerIconsProps) => JSX.Element); + placeholder?: string; value: string; onChange: (value: string) => void; onSubmit: (value: string) => void; @@ -26,38 +19,13 @@ type NavigationDrawerInputProps = { hotkeyScope: string; }; -const StyledItem = styled.div<{ isNavigationDrawerExpanded: boolean }>` - align-items: center; - background-color: ${({ theme }) => theme.background.primary}; - border: 1px solid ${({ theme }) => theme.color.blue}; - border-radius: ${({ theme }) => theme.border.radius.sm}; - box-sizing: content-box; - color: ${({ theme }) => theme.font.color.primary}; - display: flex; - font-family: ${({ theme }) => theme.font.family}; - font-size: ${({ theme }) => theme.font.size.md}; - height: calc(${({ theme }) => theme.spacing(5)} - 2px); - padding: ${({ theme }) => theme.spacing(1)}; - text-decoration: none; - user-select: none; -`; - -const StyledItemElementsContainer = styled.span` - align-items: center; - gap: ${({ theme }) => theme.spacing(2)}; - display: flex; - width: 100%; -`; - -const StyledTextInput = styled.input` - ${TEXT_INPUT_STYLE} - margin: 0; - width: 100%; - padding: 0; +const StyledInput = styled(TextInputV2)` + background-color: white; `; export const NavigationDrawerInput = ({ className, + placeholder, Icon, value, onChange, @@ -66,10 +34,6 @@ export const NavigationDrawerInput = ({ onClickOutside, hotkeyScope, }: NavigationDrawerInputProps) => { - const theme = useTheme(); - const [isNavigationDrawerExpanded] = useRecoilState( - isNavigationDrawerExpandedState, - ); const inputRef = useRef(null); useHotkeyScopeOnMount(hotkeyScope); @@ -99,10 +63,6 @@ export const NavigationDrawerInput = ({ listenerId: 'navigation-drawer-input', }); - const handleChange = (event: ChangeEvent) => { - onChange(event.target.value); - }; - const handleFocus = (event: FocusEvent) => { if (isDefined(value)) { event.target.select(); @@ -110,29 +70,16 @@ export const NavigationDrawerInput = ({ }; return ( - - - {Icon && ( - - )} - - - - - + LeftIcon={Icon} + ref={inputRef} + value={value} + onChange={onChange} + placeholder={placeholder} + onFocus={handleFocus} + fullWidth + autoFocus + /> ); }; diff --git a/packages/twenty-front/src/modules/ui/navigation/navigation-drawer/components/NavigationDrawerItemInput.tsx b/packages/twenty-front/src/modules/ui/navigation/navigation-drawer/components/NavigationDrawerItemInput.tsx deleted file mode 100644 index 3e920f251692..000000000000 --- a/packages/twenty-front/src/modules/ui/navigation/navigation-drawer/components/NavigationDrawerItemInput.tsx +++ /dev/null @@ -1,5 +0,0 @@ -import { TextInput } from '@/ui/input/components/TextInput'; - -export const NavigationDrawerItemInput = () => { - return ; -}; diff --git a/packages/twenty-front/src/modules/views/components/EditableFilterDropdownButton.tsx b/packages/twenty-front/src/modules/views/components/EditableFilterDropdownButton.tsx index 7f6769cb26f9..de1211f3e006 100644 --- a/packages/twenty-front/src/modules/views/components/EditableFilterDropdownButton.tsx +++ b/packages/twenty-front/src/modules/views/components/EditableFilterDropdownButton.tsx @@ -11,6 +11,7 @@ import { ObjectFilterOperandSelectAndInput } from '@/object-record/object-filter import { filterDefinitionUsedInDropdownComponentState } from '@/object-record/object-filter-dropdown/states/filterDefinitionUsedInDropdownComponentState'; import { selectedFilterComponentState } from '@/object-record/object-filter-dropdown/states/selectedFilterComponentState'; import { selectedOperandInDropdownComponentState } from '@/object-record/object-filter-dropdown/states/selectedOperandInDropdownComponentState'; +import { useRemoveRecordFilter } from '@/object-record/record-filter/hooks/useRemoveRecordFilter'; import { RecordFilterOperand } from '@/object-record/record-filter/types/RecordFilterOperand'; import { useSetRecoilComponentStateV2 } from '@/ui/utilities/state/component-state/hooks/useSetRecoilComponentStateV2'; import { useDeleteCombinedViewFilters } from '@/views/hooks/useDeleteCombinedViewFilters'; @@ -73,14 +74,17 @@ export const EditableFilterDropdownButton = ({ viewFilterDropdownId, ]); + const { removeRecordFilter } = useRemoveRecordFilter(); + const handleRemove = () => { closeDropdown(); deleteCombinedViewFilter(viewFilter.id); + removeRecordFilter(viewFilter.fieldMetadataId); }; const handleDropdownClickOutside = useCallback(() => { - const { id: fieldId, value, operand } = viewFilter; + const { id: fieldId, value, operand, fieldMetadataId } = viewFilter; if ( !value && ![ @@ -91,9 +95,10 @@ export const EditableFilterDropdownButton = ({ RecordFilterOperand.IsToday, ].includes(operand) ) { + removeRecordFilter(fieldMetadataId); deleteCombinedViewFilter(fieldId); } - }, [viewFilter, deleteCombinedViewFilter]); + }, [viewFilter, deleteCombinedViewFilter, removeRecordFilter]); return ( { const { resetUnsavedViewStates } = useResetUnsavedViewStates(); + const { applyViewFiltersToCurrentRecordFilters } = + useApplyViewFiltersToCurrentRecordFilters(); + useEffect(() => { if (!hasFiltersQueryParams) { return; @@ -27,10 +31,12 @@ export const QueryParamsFiltersEffect = () => { getFiltersFromQueryParams().then((filtersFromParams) => { if (Array.isArray(filtersFromParams)) { + applyViewFiltersToCurrentRecordFilters(filtersFromParams); setUnsavedViewFilter(filtersFromParams); } }); }, [ + applyViewFiltersToCurrentRecordFilters, getFiltersFromQueryParams, hasFiltersQueryParams, resetUnsavedViewStates, diff --git a/packages/twenty-front/src/modules/views/components/VariantFilterChip.tsx b/packages/twenty-front/src/modules/views/components/VariantFilterChip.tsx index feaa0a460d67..71f4fe02dbd3 100644 --- a/packages/twenty-front/src/modules/views/components/VariantFilterChip.tsx +++ b/packages/twenty-front/src/modules/views/components/VariantFilterChip.tsx @@ -1,6 +1,7 @@ import { useIcons } from 'twenty-ui'; import { useObjectNameSingularFromPlural } from '@/object-metadata/hooks/useObjectNameSingularFromPlural'; +import { useRemoveRecordFilter } from '@/object-record/record-filter/hooks/useRemoveRecordFilter'; import { RecordFilter } from '@/object-record/record-filter/types/RecordFilter'; import { useHandleToggleTrashColumnFilter } from '@/object-record/record-index/hooks/useHandleToggleTrashColumnFilter'; import { SortOrFilterChip } from '@/views/components/SortOrFilterChip'; @@ -29,10 +30,14 @@ export const VariantFilterChip = ({ viewBarId, }); + const { removeRecordFilter } = useRemoveRecordFilter(); + const { getIcon } = useIcons(); const handleRemoveClick = () => { deleteCombinedViewFilter(viewFilter.id); + removeRecordFilter(viewFilter.fieldMetadataId); + if ( viewFilter.definition.label === 'Deleted' && viewFilter.operand === 'isNotEmpty' diff --git a/packages/twenty-front/src/modules/views/components/ViewBar.tsx b/packages/twenty-front/src/modules/views/components/ViewBar.tsx index 00a80b9183ed..73f84fdb77df 100644 --- a/packages/twenty-front/src/modules/views/components/ViewBar.tsx +++ b/packages/twenty-front/src/modules/views/components/ViewBar.tsx @@ -21,6 +21,7 @@ import { ViewsHotkeyScope } from '../types/ViewsHotkeyScope'; import { FiltersHotkeyScope } from '@/object-record/object-filter-dropdown/types/FiltersHotkeyScope'; import { VIEW_SORT_DROPDOWN_ID } from '@/object-record/object-sort-dropdown/constants/ViewSortDropdownId'; import { ObjectSortDropdownComponentInstanceContext } from '@/object-record/object-sort-dropdown/states/context/ObjectSortDropdownComponentInstanceContext'; +import { ViewBarRecordFilterEffect } from '@/views/components/ViewBarRecordFilterEffect'; import { ViewEventContext } from '@/views/events/contexts/ViewEventContext'; import { UpdateViewButtonGroup } from './UpdateViewButtonGroup'; import { ViewBarDetails } from './ViewBarDetails'; @@ -53,6 +54,7 @@ export const ViewBar = ({ value={{ instanceId: VIEW_SORT_DROPDOWN_ID }} > + diff --git a/packages/twenty-front/src/modules/views/components/ViewBarDetails.tsx b/packages/twenty-front/src/modules/views/components/ViewBarDetails.tsx index 3f9cfc17fece..d81bacc00d20 100644 --- a/packages/twenty-front/src/modules/views/components/ViewBarDetails.tsx +++ b/packages/twenty-front/src/modules/views/components/ViewBarDetails.tsx @@ -14,6 +14,8 @@ import { EditableFilterDropdownButton } from '@/views/components/EditableFilterD import { EditableSortChip } from '@/views/components/EditableSortChip'; import { ViewBarFilterEffect } from '@/views/components/ViewBarFilterEffect'; import { useViewFromQueryParams } from '@/views/hooks/internal/useViewFromQueryParams'; + +import { useApplyCurrentViewFiltersToCurrentRecordFilters } from '@/views/hooks/useApplyCurrentViewFiltersToCurrentRecordFilters'; import { useGetCurrentView } from '@/views/hooks/useGetCurrentView'; import { useResetUnsavedViewStates } from '@/views/hooks/useResetUnsavedViewStates'; import { availableFilterDefinitionsComponentState } from '@/views/states/availableFilterDefinitionsComponentState'; @@ -167,9 +169,13 @@ export const ViewBarDetails = ({ }; }, [currentViewWithCombinedFiltersAndSorts]); + const { applyCurrentViewFiltersToCurrentRecordFilters } = + useApplyCurrentViewFiltersToCurrentRecordFilters(); + const handleCancelClick = () => { if (isDefined(viewId)) { resetUnsavedViewStates(viewId); + applyCurrentViewFiltersToCurrentRecordFilters(); toggleSoftDeleteFilterState(false); } }; diff --git a/packages/twenty-front/src/modules/views/components/ViewBarFilterEffect.tsx b/packages/twenty-front/src/modules/views/components/ViewBarFilterEffect.tsx index d30be6932dc4..61f69af4961c 100644 --- a/packages/twenty-front/src/modules/views/components/ViewBarFilterEffect.tsx +++ b/packages/twenty-front/src/modules/views/components/ViewBarFilterEffect.tsx @@ -1,16 +1,13 @@ import { isNonEmptyString } from '@sniptt/guards'; import { useEffect } from 'react'; -import { RecordFilter } from '@/object-record/record-filter/types/RecordFilter'; import { useRecoilComponentValueV2 } from '@/ui/utilities/state/component-state/hooks/useRecoilComponentValueV2'; import { useSetRecoilComponentStateV2 } from '@/ui/utilities/state/component-state/hooks/useSetRecoilComponentStateV2'; import { filterDefinitionUsedInDropdownComponentState } from '@/object-record/object-filter-dropdown/states/filterDefinitionUsedInDropdownComponentState'; import { objectFilterDropdownSelectedOptionValuesComponentState } from '@/object-record/object-filter-dropdown/states/objectFilterDropdownSelectedOptionValuesComponentState'; import { objectFilterDropdownSelectedRecordIdsComponentState } from '@/object-record/object-filter-dropdown/states/objectFilterDropdownSelectedRecordIdsComponentState'; -import { onFilterSelectComponentState } from '@/object-record/object-filter-dropdown/states/onFilterSelectComponentState'; import { useGetCurrentView } from '@/views/hooks/useGetCurrentView'; -import { useUpsertCombinedViewFilters } from '@/views/hooks/useUpsertCombinedViewFilters'; import { availableFilterDefinitionsComponentState } from '@/views/states/availableFilterDefinitionsComponentState'; import { jsonRelationFilterValueSchema } from '@/views/view-filter-value/validation-schemas/jsonRelationFilterValueSchema'; import { simpleRelationFilterValueSchema } from '@/views/view-filter-value/validation-schemas/simpleRelationFilterValueSchema'; @@ -23,19 +20,12 @@ type ViewBarFilterEffectProps = { export const ViewBarFilterEffect = ({ filterDropdownId, }: ViewBarFilterEffectProps) => { - const { upsertCombinedViewFilter } = useUpsertCombinedViewFilters(); - const { currentViewWithCombinedFiltersAndSorts } = useGetCurrentView(); const availableFilterDefinitions = useRecoilComponentValueV2( availableFilterDefinitionsComponentState, ); - const setOnFilterSelect = useSetRecoilComponentStateV2( - onFilterSelectComponentState, - filterDropdownId, - ); - const filterDefinitionUsedInDropdown = useRecoilComponentValueV2( filterDefinitionUsedInDropdownComponentState, filterDropdownId, @@ -62,17 +52,7 @@ export const ViewBarFilterEffect = ({ if (isDefined(availableFilterDefinitions)) { setAvailableFilterDefinitions(availableFilterDefinitions); } - setOnFilterSelect(() => (filter: RecordFilter | null) => { - if (isDefined(filter)) { - upsertCombinedViewFilter(filter); - } - }); - }, [ - availableFilterDefinitions, - setAvailableFilterDefinitions, - setOnFilterSelect, - upsertCombinedViewFilter, - ]); + }, [availableFilterDefinitions, setAvailableFilterDefinitions]); useEffect(() => { if (filterDefinitionUsedInDropdown?.type === 'RELATION') { diff --git a/packages/twenty-front/src/modules/views/components/ViewBarRecordFilterEffect.tsx b/packages/twenty-front/src/modules/views/components/ViewBarRecordFilterEffect.tsx new file mode 100644 index 000000000000..e5b781432075 --- /dev/null +++ b/packages/twenty-front/src/modules/views/components/ViewBarRecordFilterEffect.tsx @@ -0,0 +1,50 @@ +import { currentRecordFiltersComponentState } from '@/object-record/record-filter/states/currentRecordFiltersComponentState'; +import { usePrefetchedData } from '@/prefetch/hooks/usePrefetchedData'; +import { PrefetchKey } from '@/prefetch/types/PrefetchKey'; +import { useRecoilComponentValueV2 } from '@/ui/utilities/state/component-state/hooks/useRecoilComponentValueV2'; +import { useSetRecoilComponentStateV2 } from '@/ui/utilities/state/component-state/hooks/useSetRecoilComponentStateV2'; +import { availableFilterDefinitionsComponentState } from '@/views/states/availableFilterDefinitionsComponentState'; +import { currentViewIdComponentState } from '@/views/states/currentViewIdComponentState'; +import { View } from '@/views/types/View'; +import { mapViewFiltersToFilters } from '@/views/utils/mapViewFiltersToFilters'; +import { useEffect } from 'react'; +import { isDefined } from 'twenty-ui'; + +export const ViewBarRecordFilterEffect = () => { + const { records: views, isDataPrefetched } = usePrefetchedData( + PrefetchKey.AllViews, + ); + + const currentViewId = useRecoilComponentValueV2(currentViewIdComponentState); + + const setCurrentRecordFilters = useSetRecoilComponentStateV2( + currentRecordFiltersComponentState, + ); + + const availableFilterDefinitions = useRecoilComponentValueV2( + availableFilterDefinitionsComponentState, + ); + + useEffect(() => { + if (isDataPrefetched) { + const currentView = views.find((view) => view.id === currentViewId); + + if (isDefined(currentView)) { + setCurrentRecordFilters( + mapViewFiltersToFilters( + currentView.viewFilters, + availableFilterDefinitions, + ), + ); + } + } + }, [ + isDataPrefetched, + views, + availableFilterDefinitions, + currentViewId, + setCurrentRecordFilters, + ]); + + return null; +}; diff --git a/packages/twenty-front/src/modules/views/hooks/__tests__/useApplyCurrentViewFiltersToCurrentRecordFilters.test.tsx b/packages/twenty-front/src/modules/views/hooks/__tests__/useApplyCurrentViewFiltersToCurrentRecordFilters.test.tsx new file mode 100644 index 000000000000..07f8074cc17e --- /dev/null +++ b/packages/twenty-front/src/modules/views/hooks/__tests__/useApplyCurrentViewFiltersToCurrentRecordFilters.test.tsx @@ -0,0 +1,198 @@ +import { act, renderHook } from '@testing-library/react'; + +import { currentRecordFiltersComponentState } from '@/object-record/record-filter/states/currentRecordFiltersComponentState'; +import { RecordFilterDefinition } from '@/object-record/record-filter/types/RecordFilterDefinition'; +import { usePrefetchedData } from '@/prefetch/hooks/usePrefetchedData'; +import { useRecoilComponentValueV2 } from '@/ui/utilities/state/component-state/hooks/useRecoilComponentValueV2'; +import { availableFilterDefinitionsComponentState } from '@/views/states/availableFilterDefinitionsComponentState'; +import { currentViewIdComponentState } from '@/views/states/currentViewIdComponentState'; +import { ViewFilter } from '@/views/types/ViewFilter'; +import { ViewFilterOperand } from '@/views/types/ViewFilterOperand'; +import { getJestMetadataAndApolloMocksWrapper } from '~/testing/jest/getJestMetadataAndApolloMocksWrapper'; +import { useApplyCurrentViewFiltersToCurrentRecordFilters } from '../useApplyCurrentViewFiltersToCurrentRecordFilters'; + +jest.mock('@/prefetch/hooks/usePrefetchedData'); + +describe('useApplyCurrentViewFiltersToCurrentRecordFilters', () => { + const mockFilterDefinition: RecordFilterDefinition = { + fieldMetadataId: 'field-1', + label: 'Test Field', + type: 'TEXT', + iconName: 'IconText', + }; + + const mockViewFilter: ViewFilter = { + __typename: 'ViewFilter', + id: 'filter-1', + fieldMetadataId: 'field-1', + operand: ViewFilterOperand.Contains, + value: 'test', + displayValue: 'test', + viewFilterGroupId: 'group-1', + positionInViewFilterGroup: 0, + definition: mockFilterDefinition, + }; + + const mockView = { + id: 'view-1', + name: 'Test View', + objectMetadataId: 'object-1', + viewFilters: [mockViewFilter], + }; + + beforeEach(() => { + (usePrefetchedData as jest.Mock).mockReturnValue({ + records: [mockView], + }); + }); + + it('should apply filters from current view', () => { + const { result } = renderHook( + () => { + const { applyCurrentViewFiltersToCurrentRecordFilters } = + useApplyCurrentViewFiltersToCurrentRecordFilters(); + + const currentFilters = useRecoilComponentValueV2( + currentRecordFiltersComponentState, + ); + + return { + applyCurrentViewFiltersToCurrentRecordFilters, + currentFilters, + }; + }, + { + wrapper: getJestMetadataAndApolloMocksWrapper({ + onInitializeRecoilSnapshot: (snapshot) => { + snapshot.set( + currentViewIdComponentState.atomFamily({ + instanceId: 'instanceId', + }), + mockView.id, + ); + snapshot.set( + availableFilterDefinitionsComponentState.atomFamily({ + instanceId: 'instanceId', + }), + [mockFilterDefinition], + ); + }, + }), + }, + ); + + act(() => { + result.current.applyCurrentViewFiltersToCurrentRecordFilters(); + }); + + expect(result.current.currentFilters).toEqual([ + { + id: mockViewFilter.id, + fieldMetadataId: mockViewFilter.fieldMetadataId, + value: mockViewFilter.value, + displayValue: mockViewFilter.displayValue, + operand: mockViewFilter.operand, + viewFilterGroupId: mockViewFilter.viewFilterGroupId, + positionInViewFilterGroup: mockViewFilter.positionInViewFilterGroup, + definition: mockFilterDefinition, + }, + ]); + }); + + it('should not apply filters when current view is not found', () => { + (usePrefetchedData as jest.Mock).mockReturnValue({ + records: [], + }); + + const { result } = renderHook( + () => { + const { applyCurrentViewFiltersToCurrentRecordFilters } = + useApplyCurrentViewFiltersToCurrentRecordFilters(); + + const currentFilters = useRecoilComponentValueV2( + currentRecordFiltersComponentState, + ); + + return { + applyCurrentViewFiltersToCurrentRecordFilters, + currentFilters, + }; + }, + { + wrapper: getJestMetadataAndApolloMocksWrapper({ + onInitializeRecoilSnapshot: (snapshot) => { + snapshot.set( + currentViewIdComponentState.atomFamily({ + instanceId: 'instanceId', + }), + mockView.id, + ); + snapshot.set( + availableFilterDefinitionsComponentState.atomFamily({ + instanceId: 'instanceId', + }), + [mockFilterDefinition], + ); + }, + }), + }, + ); + + act(() => { + result.current.applyCurrentViewFiltersToCurrentRecordFilters(); + }); + + expect(result.current.currentFilters).toEqual([]); + }); + + it('should handle view with empty filters', () => { + const viewWithNoFilters = { + ...mockView, + viewFilters: [], + }; + + (usePrefetchedData as jest.Mock).mockReturnValue({ + records: [viewWithNoFilters], + }); + + const { result } = renderHook( + () => { + const { applyCurrentViewFiltersToCurrentRecordFilters } = + useApplyCurrentViewFiltersToCurrentRecordFilters(); + + const currentFilters = useRecoilComponentValueV2( + currentRecordFiltersComponentState, + ); + + return { + applyCurrentViewFiltersToCurrentRecordFilters, + currentFilters, + }; + }, + { + wrapper: getJestMetadataAndApolloMocksWrapper({ + onInitializeRecoilSnapshot: (snapshot) => { + snapshot.set( + currentViewIdComponentState.atomFamily({ + instanceId: 'instanceId', + }), + mockView.id, + ); + snapshot.set( + availableFilterDefinitionsComponentState.atomFamily({ + instanceId: 'instanceId', + }), + [mockFilterDefinition], + ); + }, + }), + }, + ); + + act(() => { + result.current.applyCurrentViewFiltersToCurrentRecordFilters(); + }); + + expect(result.current.currentFilters).toEqual([]); + }); +}); diff --git a/packages/twenty-front/src/modules/views/hooks/__tests__/useApplyViewFiltersToCurrentRecordFilters.test.tsx b/packages/twenty-front/src/modules/views/hooks/__tests__/useApplyViewFiltersToCurrentRecordFilters.test.tsx new file mode 100644 index 000000000000..d4b74ac83e50 --- /dev/null +++ b/packages/twenty-front/src/modules/views/hooks/__tests__/useApplyViewFiltersToCurrentRecordFilters.test.tsx @@ -0,0 +1,108 @@ +import { act, renderHook } from '@testing-library/react'; + +import { currentRecordFiltersComponentState } from '@/object-record/record-filter/states/currentRecordFiltersComponentState'; +import { RecordFilterDefinition } from '@/object-record/record-filter/types/RecordFilterDefinition'; +import { useRecoilComponentValueV2 } from '@/ui/utilities/state/component-state/hooks/useRecoilComponentValueV2'; +import { availableFilterDefinitionsComponentState } from '@/views/states/availableFilterDefinitionsComponentState'; +import { ViewFilter } from '@/views/types/ViewFilter'; +import { ViewFilterOperand } from '@/views/types/ViewFilterOperand'; +import { getJestMetadataAndApolloMocksWrapper } from '~/testing/jest/getJestMetadataAndApolloMocksWrapper'; +import { useApplyViewFiltersToCurrentRecordFilters } from '../useApplyViewFiltersToCurrentRecordFilters'; + +describe('useApplyViewFiltersToCurrentRecordFilters', () => { + const mockAvailableFilterDefinition: RecordFilterDefinition = { + fieldMetadataId: 'field-1', + label: 'Test Field', + type: 'TEXT', + iconName: 'IconText', + }; + + const mockViewFilter: ViewFilter = { + __typename: 'ViewFilter', + id: 'filter-1', + fieldMetadataId: 'field-1', + operand: ViewFilterOperand.Contains, + value: 'test', + displayValue: 'test', + viewFilterGroupId: 'group-1', + positionInViewFilterGroup: 0, + definition: mockAvailableFilterDefinition, + }; + + it('should apply view filters to current record filters', () => { + const { result } = renderHook( + () => { + const { applyViewFiltersToCurrentRecordFilters } = + useApplyViewFiltersToCurrentRecordFilters(); + + const currentFilters = useRecoilComponentValueV2( + currentRecordFiltersComponentState, + ); + + return { applyViewFiltersToCurrentRecordFilters, currentFilters }; + }, + { + wrapper: getJestMetadataAndApolloMocksWrapper({ + onInitializeRecoilSnapshot: (snapshot) => { + snapshot.set( + availableFilterDefinitionsComponentState.atomFamily({ + instanceId: 'instanceId', + }), + [mockAvailableFilterDefinition], + ); + }, + }), + }, + ); + + act(() => { + result.current.applyViewFiltersToCurrentRecordFilters([mockViewFilter]); + }); + + expect(result.current.currentFilters).toEqual([ + { + id: mockViewFilter.id, + fieldMetadataId: mockViewFilter.fieldMetadataId, + value: mockViewFilter.value, + displayValue: mockViewFilter.displayValue, + operand: mockViewFilter.operand, + viewFilterGroupId: mockViewFilter.viewFilterGroupId, + positionInViewFilterGroup: mockViewFilter.positionInViewFilterGroup, + definition: mockAvailableFilterDefinition, + }, + ]); + }); + + it('should handle empty view filters array', () => { + const { result } = renderHook( + () => { + const { applyViewFiltersToCurrentRecordFilters } = + useApplyViewFiltersToCurrentRecordFilters(); + + const currentFilters = useRecoilComponentValueV2( + currentRecordFiltersComponentState, + ); + + return { applyViewFiltersToCurrentRecordFilters, currentFilters }; + }, + { + wrapper: getJestMetadataAndApolloMocksWrapper({ + onInitializeRecoilSnapshot: (snapshot) => { + snapshot.set( + availableFilterDefinitionsComponentState.atomFamily({ + instanceId: 'instanceId', + }), + [mockAvailableFilterDefinition], + ); + }, + }), + }, + ); + + act(() => { + result.current.applyViewFiltersToCurrentRecordFilters([]); + }); + + expect(result.current.currentFilters).toEqual([]); + }); +}); diff --git a/packages/twenty-front/src/modules/views/hooks/useApplyCurrentViewFiltersToCurrentRecordFilters.ts b/packages/twenty-front/src/modules/views/hooks/useApplyCurrentViewFiltersToCurrentRecordFilters.ts new file mode 100644 index 000000000000..1bbb5e3660e9 --- /dev/null +++ b/packages/twenty-front/src/modules/views/hooks/useApplyCurrentViewFiltersToCurrentRecordFilters.ts @@ -0,0 +1,42 @@ +import { currentRecordFiltersComponentState } from '@/object-record/record-filter/states/currentRecordFiltersComponentState'; +import { usePrefetchedData } from '@/prefetch/hooks/usePrefetchedData'; +import { PrefetchKey } from '@/prefetch/types/PrefetchKey'; +import { useRecoilComponentValueV2 } from '@/ui/utilities/state/component-state/hooks/useRecoilComponentValueV2'; +import { useSetRecoilComponentStateV2 } from '@/ui/utilities/state/component-state/hooks/useSetRecoilComponentStateV2'; +import { availableFilterDefinitionsComponentState } from '@/views/states/availableFilterDefinitionsComponentState'; +import { currentViewIdComponentState } from '@/views/states/currentViewIdComponentState'; +import { View } from '@/views/types/View'; +import { mapViewFiltersToFilters } from '@/views/utils/mapViewFiltersToFilters'; + +import { isDefined } from 'twenty-ui'; + +export const useApplyCurrentViewFiltersToCurrentRecordFilters = () => { + const { records: views } = usePrefetchedData(PrefetchKey.AllViews); + + const currentViewId = useRecoilComponentValueV2(currentViewIdComponentState); + + const setCurrentRecordFilters = useSetRecoilComponentStateV2( + currentRecordFiltersComponentState, + ); + + const availableFilterDefinitions = useRecoilComponentValueV2( + availableFilterDefinitionsComponentState, + ); + + const applyCurrentViewFiltersToCurrentRecordFilters = () => { + const currentView = views.find((view) => view.id === currentViewId); + + if (isDefined(currentView)) { + setCurrentRecordFilters( + mapViewFiltersToFilters( + currentView.viewFilters, + availableFilterDefinitions, + ), + ); + } + }; + + return { + applyCurrentViewFiltersToCurrentRecordFilters, + }; +}; diff --git a/packages/twenty-front/src/modules/views/hooks/useApplyViewFiltersToCurrentRecordFilters.ts b/packages/twenty-front/src/modules/views/hooks/useApplyViewFiltersToCurrentRecordFilters.ts new file mode 100644 index 000000000000..ceba489a8361 --- /dev/null +++ b/packages/twenty-front/src/modules/views/hooks/useApplyViewFiltersToCurrentRecordFilters.ts @@ -0,0 +1,31 @@ +import { currentRecordFiltersComponentState } from '@/object-record/record-filter/states/currentRecordFiltersComponentState'; +import { useRecoilComponentValueV2 } from '@/ui/utilities/state/component-state/hooks/useRecoilComponentValueV2'; +import { useSetRecoilComponentStateV2 } from '@/ui/utilities/state/component-state/hooks/useSetRecoilComponentStateV2'; +import { availableFilterDefinitionsComponentState } from '@/views/states/availableFilterDefinitionsComponentState'; +import { ViewFilter } from '@/views/types/ViewFilter'; +import { mapViewFiltersToFilters } from '@/views/utils/mapViewFiltersToFilters'; + +export const useApplyViewFiltersToCurrentRecordFilters = () => { + const setCurrentRecordFilters = useSetRecoilComponentStateV2( + currentRecordFiltersComponentState, + ); + + const availableFilterDefinitions = useRecoilComponentValueV2( + availableFilterDefinitionsComponentState, + ); + + const applyViewFiltersToCurrentRecordFilters = ( + viewFilters: ViewFilter[], + ) => { + const recordFiltersToApply = mapViewFiltersToFilters( + viewFilters, + availableFilterDefinitions, + ); + + setCurrentRecordFilters(recordFiltersToApply); + }; + + return { + applyViewFiltersToCurrentRecordFilters, + }; +}; diff --git a/packages/twenty-front/src/modules/views/hooks/useSetViewInUrl.ts b/packages/twenty-front/src/modules/views/hooks/useSetViewInUrl.ts index 01e0397ffd6d..a9bd6d9a251b 100644 --- a/packages/twenty-front/src/modules/views/hooks/useSetViewInUrl.ts +++ b/packages/twenty-front/src/modules/views/hooks/useSetViewInUrl.ts @@ -6,7 +6,7 @@ export const useSetViewInUrl = () => { const setViewInUrl = (viewId: string) => { setSearchParams(() => { const searchParams = new URLSearchParams(); - searchParams.set('view', viewId); + searchParams.set('viewId', viewId); return searchParams; }); }; diff --git a/packages/twenty-front/src/modules/views/view-picker/components/ViewPickerListContent.tsx b/packages/twenty-front/src/modules/views/view-picker/components/ViewPickerListContent.tsx index aae3dc2d7113..0d6155d2e3c8 100644 --- a/packages/twenty-front/src/modules/views/view-picker/components/ViewPickerListContent.tsx +++ b/packages/twenty-front/src/modules/views/view-picker/components/ViewPickerListContent.tsx @@ -11,7 +11,6 @@ import { useSetRecoilComponentStateV2 } from '@/ui/utilities/state/component-sta import { useChangeView } from '@/views/hooks/useChangeView'; import { useGetCurrentView } from '@/views/hooks/useGetCurrentView'; import { useUpdateView } from '@/views/hooks/useUpdateView'; -import { View } from '@/views/types/View'; import { ViewPickerOptionDropdown } from '@/views/view-picker/components/ViewPickerOptionDropdown'; import { useViewPickerMode } from '@/views/view-picker/hooks/useViewPickerMode'; import { viewPickerReferenceViewIdComponentState } from '@/views/view-picker/states/viewPickerReferenceViewIdComponentState'; @@ -85,7 +84,7 @@ export const ViewPickerListContent = () => { isDragDisabled={viewsOnCurrentObject.length === 1} itemComponent={ ; onEdit: (event: React.MouseEvent, viewId: string) => void; handleViewSelect: (viewId: string) => void; }; diff --git a/packages/twenty-front/src/modules/workflow/workflow-diagram/components/WorkflowDiagramBaseStepNode.tsx b/packages/twenty-front/src/modules/workflow/workflow-diagram/components/WorkflowDiagramBaseStepNode.tsx index 852aef9a2e21..e421d3ccd796 100644 --- a/packages/twenty-front/src/modules/workflow/workflow-diagram/components/WorkflowDiagramBaseStepNode.tsx +++ b/packages/twenty-front/src/modules/workflow/workflow-diagram/components/WorkflowDiagramBaseStepNode.tsx @@ -76,9 +76,11 @@ const StyledSourceHandle = styled(Handle)` border: none; width: 4px; height: 4px; + left: ${({ theme }) => theme.spacing(10)}; `; export const StyledTargetHandle = styled(Handle)` + left: ${({ theme }) => theme.spacing(10)}; visibility: hidden; `; diff --git a/packages/twenty-front/src/modules/workflow/workflow-diagram/components/WorkflowDiagramCanvasEditableEffect.tsx b/packages/twenty-front/src/modules/workflow/workflow-diagram/components/WorkflowDiagramCanvasEditableEffect.tsx index f1844f78d6c4..ebb581ad5834 100644 --- a/packages/twenty-front/src/modules/workflow/workflow-diagram/components/WorkflowDiagramCanvasEditableEffect.tsx +++ b/packages/twenty-front/src/modules/workflow/workflow-diagram/components/WorkflowDiagramCanvasEditableEffect.tsx @@ -8,11 +8,15 @@ import { EMPTY_TRIGGER_STEP_ID } from '@/workflow/workflow-diagram/constants/Emp import { useStartNodeCreation } from '@/workflow/workflow-diagram/hooks/useStartNodeCreation'; import { useTriggerNodeSelection } from '@/workflow/workflow-diagram/hooks/useTriggerNodeSelection'; import { workflowSelectedNodeState } from '@/workflow/workflow-diagram/states/workflowSelectedNodeState'; -import { WorkflowDiagramNode } from '@/workflow/workflow-diagram/types/WorkflowDiagram'; +import { + WorkflowDiagramNode, + WorkflowDiagramStepNodeData, +} from '@/workflow/workflow-diagram/types/WorkflowDiagram'; +import { getWorkflowNodeIcon } from '@/workflow/workflow-diagram/utils/getWorkflowNodeIcon'; import { OnSelectionChangeParams, useOnSelectionChange } from '@xyflow/react'; import { useCallback } from 'react'; import { useSetRecoilState } from 'recoil'; -import { isDefined } from 'twenty-ui'; +import { IconBolt, isDefined } from 'twenty-ui'; export const WorkflowDiagramCanvasEditableEffect = () => { const { startNodeCreation } = useStartNodeCreation(); @@ -37,7 +41,10 @@ export const WorkflowDiagramCanvasEditableEffect = () => { const isEmptyTriggerNode = selectedNode.type === EMPTY_TRIGGER_STEP_ID; if (isEmptyTriggerNode) { - openRightDrawer(RightDrawerPages.WorkflowStepSelectTriggerType); + openRightDrawer(RightDrawerPages.WorkflowStepSelectTriggerType, { + title: 'Trigger Type', + Icon: IconBolt, + }); return; } @@ -53,9 +60,14 @@ export const WorkflowDiagramCanvasEditableEffect = () => { return; } + const selectedNodeData = selectedNode.data as WorkflowDiagramStepNodeData; + setWorkflowSelectedNode(selectedNode.id); setHotkeyScope(RightDrawerHotkeyScope.RightDrawer, { goto: false }); - openRightDrawer(RightDrawerPages.WorkflowStepEdit); + openRightDrawer(RightDrawerPages.WorkflowStepEdit, { + title: selectedNodeData.name, + Icon: getWorkflowNodeIcon(selectedNodeData), + }); }, [ setWorkflowSelectedNode, diff --git a/packages/twenty-front/src/modules/workflow/workflow-diagram/components/WorkflowDiagramCanvasReadonlyEffect.tsx b/packages/twenty-front/src/modules/workflow/workflow-diagram/components/WorkflowDiagramCanvasReadonlyEffect.tsx index 71cdd8e33ebf..0f2a11c30d00 100644 --- a/packages/twenty-front/src/modules/workflow/workflow-diagram/components/WorkflowDiagramCanvasReadonlyEffect.tsx +++ b/packages/twenty-front/src/modules/workflow/workflow-diagram/components/WorkflowDiagramCanvasReadonlyEffect.tsx @@ -5,7 +5,11 @@ import { RightDrawerPages } from '@/ui/layout/right-drawer/types/RightDrawerPage import { useSetHotkeyScope } from '@/ui/utilities/hotkey/hooks/useSetHotkeyScope'; import { useTriggerNodeSelection } from '@/workflow/workflow-diagram/hooks/useTriggerNodeSelection'; import { workflowSelectedNodeState } from '@/workflow/workflow-diagram/states/workflowSelectedNodeState'; -import { WorkflowDiagramNode } from '@/workflow/workflow-diagram/types/WorkflowDiagram'; +import { + WorkflowDiagramNode, + WorkflowDiagramStepNodeData, +} from '@/workflow/workflow-diagram/types/WorkflowDiagram'; +import { getWorkflowNodeIcon } from '@/workflow/workflow-diagram/utils/getWorkflowNodeIcon'; import { OnSelectionChangeParams, useOnSelectionChange } from '@xyflow/react'; import { useCallback } from 'react'; import { useSetRecoilState } from 'recoil'; @@ -30,7 +34,12 @@ export const WorkflowDiagramCanvasReadonlyEffect = () => { setWorkflowSelectedNode(selectedNode.id); setHotkeyScope(RightDrawerHotkeyScope.RightDrawer, { goto: false }); - openRightDrawer(RightDrawerPages.WorkflowStepView); + + const selectedNodeData = selectedNode.data as WorkflowDiagramStepNodeData; + openRightDrawer(RightDrawerPages.WorkflowStepView, { + title: selectedNodeData.name, + Icon: getWorkflowNodeIcon(selectedNodeData), + }); }, [ setWorkflowSelectedNode, diff --git a/packages/twenty-front/src/modules/workflow/workflow-diagram/components/WorkflowDiagramCreateStepNode.tsx b/packages/twenty-front/src/modules/workflow/workflow-diagram/components/WorkflowDiagramCreateStepNode.tsx index ceaba1c9f91c..493b00d3beb1 100644 --- a/packages/twenty-front/src/modules/workflow/workflow-diagram/components/WorkflowDiagramCreateStepNode.tsx +++ b/packages/twenty-front/src/modules/workflow/workflow-diagram/components/WorkflowDiagramCreateStepNode.tsx @@ -3,10 +3,13 @@ import { Handle, Position } from '@xyflow/react'; import { IconButton, IconPlus } from 'twenty-ui'; const StyledContainer = styled.div` + padding-left: ${({ theme }) => theme.spacing(6)}; padding-top: ${({ theme }) => theme.spacing(1)}; + position: relative; `; export const StyledTargetHandle = styled(Handle)` + left: ${({ theme }) => theme.spacing(10)}; visibility: hidden; `; diff --git a/packages/twenty-front/src/modules/workflow/workflow-diagram/components/WorkflowDiagramStepNodeBase.tsx b/packages/twenty-front/src/modules/workflow/workflow-diagram/components/WorkflowDiagramStepNodeBase.tsx index 50f9e65eb927..b754defb74ac 100644 --- a/packages/twenty-front/src/modules/workflow/workflow-diagram/components/WorkflowDiagramStepNodeBase.tsx +++ b/packages/twenty-front/src/modules/workflow/workflow-diagram/components/WorkflowDiagramStepNodeBase.tsx @@ -1,15 +1,9 @@ import { assertUnreachable } from '@/workflow/utils/assertUnreachable'; import { WorkflowDiagramBaseStepNode } from '@/workflow/workflow-diagram/components/WorkflowDiagramBaseStepNode'; import { WorkflowDiagramStepNodeData } from '@/workflow/workflow-diagram/types/WorkflowDiagram'; +import { getWorkflowNodeIcon } from '@/workflow/workflow-diagram/utils/getWorkflowNodeIcon'; import { useTheme } from '@emotion/react'; import styled from '@emotion/styled'; -import { - IconAddressBook, - IconCode, - IconHandMove, - IconMail, - IconPlaylistAdd, -} from 'twenty-ui'; const StyledStepNodeLabelIconContainer = styled.div` align-items: center; @@ -29,6 +23,8 @@ export const WorkflowDiagramStepNodeBase = ({ }) => { const theme = useTheme(); + const Icon = getWorkflowNodeIcon(data); + const renderStepIcon = () => { switch (data.nodeType) { case 'trigger': { @@ -36,7 +32,7 @@ export const WorkflowDiagramStepNodeBase = ({ case 'DATABASE_EVENT': { return ( - @@ -46,7 +42,7 @@ export const WorkflowDiagramStepNodeBase = ({ case 'MANUAL': { return ( - @@ -62,17 +58,14 @@ export const WorkflowDiagramStepNodeBase = ({ case 'CODE': { return ( - + ); } case 'SEND_EMAIL': { return ( - + ); } @@ -81,7 +74,7 @@ export const WorkflowDiagramStepNodeBase = ({ case 'DELETE_RECORD': { return ( - { const { openRightDrawer } = useRightDrawer(); @@ -22,7 +23,10 @@ export const useStartNodeCreation = () => { setWorkflowCreateStepFromParentStepId(parentNodeId); setHotkeyScope(RightDrawerHotkeyScope.RightDrawer, { goto: false }); - openRightDrawer(RightDrawerPages.WorkflowStepSelectAction); + openRightDrawer(RightDrawerPages.WorkflowStepSelectAction, { + title: 'Select Action', + Icon: IconSettingsAutomation, + }); }, [openRightDrawer, setWorkflowCreateStepFromParentStepId, setHotkeyScope], ); diff --git a/packages/twenty-front/src/modules/workflow/workflow-diagram/utils/getOrganizedDiagram.ts b/packages/twenty-front/src/modules/workflow/workflow-diagram/utils/getOrganizedDiagram.ts index 81bb621335f2..1813bdaea917 100644 --- a/packages/twenty-front/src/modules/workflow/workflow-diagram/utils/getOrganizedDiagram.ts +++ b/packages/twenty-front/src/modules/workflow/workflow-diagram/utils/getOrganizedDiagram.ts @@ -7,11 +7,15 @@ export const getOrganizedDiagram = ( const graph = new Dagre.graphlib.Graph().setDefaultEdgeLabel(() => ({})); graph.setGraph({ rankdir: 'TB' }); + const biggestNodeWidth = diagram.nodes.reduce( + (acc, node) => Math.max(acc, node.measured?.width ?? 0), + 0, + ); + diagram.edges.forEach((edge) => graph.setEdge(edge.source, edge.target)); diagram.nodes.forEach((node) => graph.setNode(node.id, { - ...node, - width: node.measured?.width ?? 0, + width: biggestNodeWidth, height: node.measured?.height ?? 0, }), ); @@ -21,10 +25,11 @@ export const getOrganizedDiagram = ( return { nodes: diagram.nodes.map((node) => { const position = graph.node(node.id); + // We are shifting the dagre node position (anchor=center center) to the top left // so it matches the React Flow node anchor point (top left). - const x = position.x - (node.measured?.width ?? 0) / 2; - const y = position.y - (node.measured?.height ?? 0) / 2; + const x = position.x - position.width / 2; + const y = position.y - position.height / 2; return { ...node, position: { x, y } }; }), diff --git a/packages/twenty-front/src/modules/workflow/workflow-diagram/utils/getWorkflowNodeIcon.ts b/packages/twenty-front/src/modules/workflow/workflow-diagram/utils/getWorkflowNodeIcon.ts new file mode 100644 index 000000000000..feefe37ae514 --- /dev/null +++ b/packages/twenty-front/src/modules/workflow/workflow-diagram/utils/getWorkflowNodeIcon.ts @@ -0,0 +1,56 @@ +import { + WorkflowActionType, + WorkflowTriggerType, +} from '@/workflow/types/Workflow'; +import { assertUnreachable } from '@/workflow/utils/assertUnreachable'; +import { + IconAddressBook, + IconCode, + IconHandMove, + IconMail, + IconPlaylistAdd, +} from 'twenty-ui'; + +export const getWorkflowNodeIcon = ( + data: + | { + nodeType: 'trigger'; + triggerType: WorkflowTriggerType; + } + | { + nodeType: 'action'; + actionType: WorkflowActionType; + }, +) => { + switch (data.nodeType) { + case 'trigger': { + switch (data.triggerType) { + case 'DATABASE_EVENT': { + return IconPlaylistAdd; + } + case 'MANUAL': { + return IconHandMove; + } + } + + return assertUnreachable(data.triggerType); + } + case 'action': { + switch (data.actionType) { + case 'CODE': { + return IconCode; + } + case 'SEND_EMAIL': { + return IconMail; + } + case 'CREATE_RECORD': + case 'UPDATE_RECORD': + case 'DELETE_RECORD': { + return IconAddressBook; + } + } + + return assertUnreachable(data.actionType); + } + } +}; diff --git a/packages/twenty-front/src/modules/workflow/workflow-steps/hooks/__tests__/useCreateStep.test.ts b/packages/twenty-front/src/modules/workflow/workflow-steps/hooks/__tests__/useCreateStep.test.ts index 2430830145b4..751f657998df 100644 --- a/packages/twenty-front/src/modules/workflow/workflow-steps/hooks/__tests__/useCreateStep.test.ts +++ b/packages/twenty-front/src/modules/workflow/workflow-steps/hooks/__tests__/useCreateStep.test.ts @@ -5,7 +5,7 @@ import { useCreateStep } from '../useCreateStep'; const mockOpenRightDrawer = jest.fn(); const mockCreateDraftFromWorkflowVersion = jest.fn().mockResolvedValue('457'); const mockCreateWorkflowVersionStep = jest.fn().mockResolvedValue({ - data: { createWorkflowVersionStep: { id: '1' } }, + data: { createWorkflowVersionStep: { id: '1', type: 'CODE' } }, }); jest.mock('recoil', () => ({ diff --git a/packages/twenty-front/src/modules/workflow/workflow-steps/hooks/useCreateStep.ts b/packages/twenty-front/src/modules/workflow/workflow-steps/hooks/useCreateStep.ts index 89a4f76a1033..8d782dff8936 100644 --- a/packages/twenty-front/src/modules/workflow/workflow-steps/hooks/useCreateStep.ts +++ b/packages/twenty-front/src/modules/workflow/workflow-steps/hooks/useCreateStep.ts @@ -7,6 +7,7 @@ import { WorkflowWithCurrentVersion, } from '@/workflow/types/Workflow'; import { workflowSelectedNodeState } from '@/workflow/workflow-diagram/states/workflowSelectedNodeState'; +import { getWorkflowNodeIcon } from '@/workflow/workflow-diagram/utils/getWorkflowNodeIcon'; import { useCreateWorkflowVersionStep } from '@/workflow/workflow-steps/hooks/useCreateWorkflowVersionStep'; import { workflowCreateStepFromParentStepIdState } from '@/workflow/workflow-steps/states/workflowCreateStepFromParentStepIdState'; import { useRecoilValue, useSetRecoilState } from 'recoil'; @@ -17,7 +18,6 @@ export const useCreateStep = ({ }: { workflow: WorkflowWithCurrentVersion; }) => { - const { openRightDrawer } = useRightDrawer(); const { createWorkflowVersionStep } = useCreateWorkflowVersionStep(); const setWorkflowSelectedNode = useSetRecoilState(workflowSelectedNodeState); const setWorkflowLastCreatedStepId = useSetRecoilState( @@ -30,6 +30,8 @@ export const useCreateStep = ({ const { getUpdatableWorkflowVersion } = useGetUpdatableWorkflowVersion(); + const { openRightDrawer } = useRightDrawer(); + const createStep = async (newStepType: WorkflowStepType) => { if (!isDefined(workflowCreateStepFromParentStepId)) { throw new Error('Select a step to create a new step from first.'); @@ -50,7 +52,14 @@ export const useCreateStep = ({ setWorkflowSelectedNode(createdStep.id); setWorkflowLastCreatedStepId(createdStep.id); - openRightDrawer(RightDrawerPages.WorkflowStepEdit); + + openRightDrawer(RightDrawerPages.WorkflowStepEdit, { + title: createdStep.name, + Icon: getWorkflowNodeIcon({ + nodeType: 'action', + actionType: createdStep.type as WorkflowStepType, + }), + }); }; return { diff --git a/packages/twenty-front/src/modules/workflow/workflow-trigger/components/RightDrawerWorkflowSelectTriggerTypeContent.tsx b/packages/twenty-front/src/modules/workflow/workflow-trigger/components/RightDrawerWorkflowSelectTriggerTypeContent.tsx index e5819a7bc7c5..66cf209072cc 100644 --- a/packages/twenty-front/src/modules/workflow/workflow-trigger/components/RightDrawerWorkflowSelectTriggerTypeContent.tsx +++ b/packages/twenty-front/src/modules/workflow/workflow-trigger/components/RightDrawerWorkflowSelectTriggerTypeContent.tsx @@ -63,7 +63,10 @@ export const RightDrawerWorkflowSelectTriggerTypeContent = ({ setWorkflowSelectedNode(TRIGGER_STEP_ID); - openRightDrawer(RightDrawerPages.WorkflowStepEdit); + openRightDrawer(RightDrawerPages.WorkflowStepEdit, { + title: action.name, + Icon: action.icon, + }); }} /> ))} @@ -84,7 +87,10 @@ export const RightDrawerWorkflowSelectTriggerTypeContent = ({ setWorkflowSelectedNode(TRIGGER_STEP_ID); - openRightDrawer(RightDrawerPages.WorkflowStepEdit); + openRightDrawer(RightDrawerPages.WorkflowStepEdit, { + title: action.name, + Icon: action.icon, + }); }} /> ))} diff --git a/packages/twenty-front/src/pages/object-record/RecordIndexPage.tsx b/packages/twenty-front/src/pages/object-record/RecordIndexPage.tsx index 67e818ec442b..36bb2df80f15 100644 --- a/packages/twenty-front/src/pages/object-record/RecordIndexPage.tsx +++ b/packages/twenty-front/src/pages/object-record/RecordIndexPage.tsx @@ -9,6 +9,7 @@ import { ContextStoreComponentInstanceContext } from '@/context-store/states/con import { useObjectMetadataItem } from '@/object-metadata/hooks/useObjectMetadataItem'; import { useObjectNameSingularFromPlural } from '@/object-metadata/hooks/useObjectNameSingularFromPlural'; import { lastShowPageRecordIdState } from '@/object-record/record-field/states/lastShowPageRecordId'; +import { RecordFiltersComponentInstanceContext } from '@/object-record/record-filter/states/context/RecordFiltersComponentInstanceContext'; import { RecordIndexContainer } from '@/object-record/record-index/components/RecordIndexContainer'; import { RecordIndexContainerContextStoreNumberOfSelectedRecordsEffect } from '@/object-record/record-index/components/RecordIndexContainerContextStoreNumberOfSelectedRecordsEffect'; import { RecordIndexContainerContextStoreObjectMetadataEffect } from '@/object-record/record-index/components/RecordIndexContainerContextStoreObjectMetadataEffect'; @@ -81,28 +82,32 @@ export const RecordIndexPage = () => { - - - - - - - - - - - - - - + + + + + + + + + + + + + + diff --git a/packages/twenty-front/src/pages/object-record/RecordShowPage.tsx b/packages/twenty-front/src/pages/object-record/RecordShowPage.tsx index 3b9b8b9615b6..e0f62e4bb8c4 100644 --- a/packages/twenty-front/src/pages/object-record/RecordShowPage.tsx +++ b/packages/twenty-front/src/pages/object-record/RecordShowPage.tsx @@ -5,6 +5,7 @@ import { ActionMenuComponentInstanceContext } from '@/action-menu/states/context import { TimelineActivityContext } from '@/activities/timeline-activities/contexts/TimelineActivityContext'; import { ContextStoreComponentInstanceContext } from '@/context-store/states/contexts/ContextStoreComponentInstanceContext'; import { CoreObjectNameSingular } from '@/object-metadata/types/CoreObjectNameSingular'; +import { RecordFiltersComponentInstanceContext } from '@/object-record/record-filter/states/context/RecordFiltersComponentInstanceContext'; import { RecordShowContainer } from '@/object-record/record-show/components/RecordShowContainer'; import { useRecordShowPage } from '@/object-record/record-show/hooks/useRecordShowPage'; import { RecordValueSetterEffect } from '@/object-record/record-store/components/RecordValueSetterEffect'; @@ -46,62 +47,68 @@ export const RecordShowPage = () => { return ( - - - - - - - <> - {!isCommandMenuV2Enabled && - objectNameSingular === CoreObjectNameSingular.Workflow && ( - - )} - {!isCommandMenuV2Enabled && - objectNameSingular === - CoreObjectNameSingular.WorkflowVersion && ( - + + + + + <> + {!isCommandMenuV2Enabled && + objectNameSingular === CoreObjectNameSingular.Workflow && ( + + )} + {!isCommandMenuV2Enabled && + objectNameSingular === + CoreObjectNameSingular.WorkflowVersion && ( + + )} + {(isCommandMenuV2Enabled || + (objectNameSingular !== CoreObjectNameSingular.Workflow && + objectNameSingular !== + CoreObjectNameSingular.WorkflowVersion)) && ( + )} - {(isCommandMenuV2Enabled || - (objectNameSingular !== CoreObjectNameSingular.Workflow && - objectNameSingular !== - CoreObjectNameSingular.WorkflowVersion)) && ( - + + + + - )} - - - - - - - - - - + + + + + + ); }; diff --git a/packages/twenty-front/src/pages/object-record/RecordShowPageHeader.tsx b/packages/twenty-front/src/pages/object-record/RecordShowPageHeader.tsx index ecaf938f9c37..37549ebc4013 100644 --- a/packages/twenty-front/src/pages/object-record/RecordShowPageHeader.tsx +++ b/packages/twenty-front/src/pages/object-record/RecordShowPageHeader.tsx @@ -1,5 +1,6 @@ import { CoreObjectNameSingular } from '@/object-metadata/types/CoreObjectNameSingular'; -import { RecordEditableName } from '@/object-record/components/RecordEditableName'; +import { getObjectMetadataIdentifierFields } from '@/object-metadata/utils/getObjectMetadataIdentifierFields'; +import { ObjectRecordShowPageBreadcrumb } from '@/object-record/record-show/components/ObjectRecordShowPageBreadcrumb'; import { useRecordShowContainerTabs } from '@/object-record/record-show/hooks/useRecordShowContainerTabs'; import { useRecordShowPage } from '@/object-record/record-show/hooks/useRecordShowPage'; import { useRecordShowPagePagination } from '@/object-record/record-show/hooks/useRecordShowPagePagination'; @@ -34,14 +35,18 @@ export const RecordShowPageHeader = ({ const hasEditableName = layout.hideSummaryAndFields === true; + const { labelIdentifierFieldMetadataItem } = + getObjectMetadataIdentifierFields({ objectMetadataItem }); + return ( ) : ( viewName diff --git a/packages/twenty-front/src/testing/decorators/PageDecorator.tsx b/packages/twenty-front/src/testing/decorators/PageDecorator.tsx index 0d7e8ab2f415..35fb494587e7 100644 --- a/packages/twenty-front/src/testing/decorators/PageDecorator.tsx +++ b/packages/twenty-front/src/testing/decorators/PageDecorator.tsx @@ -22,6 +22,7 @@ import { mockedApolloClient } from '~/testing/mockedApolloClient'; import { RecoilDebugObserverEffect } from '@/debug/components/RecoilDebugObserver'; import { ObjectMetadataItemsProvider } from '@/object-metadata/components/ObjectMetadataItemsProvider'; +import { RecordFiltersComponentInstanceContext } from '@/object-record/record-filter/states/context/RecordFiltersComponentInstanceContext'; import { PrefetchDataProvider } from '@/prefetch/components/PrefetchDataProvider'; import { WorkspaceProviderEffect } from '@/workspace/components/WorkspaceProviderEffect'; import { i18n } from '@lingui/core'; @@ -88,7 +89,13 @@ const Providers = () => { - + + + diff --git a/packages/twenty-front/src/testing/jest/getJestMetadataAndApolloMocksAndContextStoreWrapper.tsx b/packages/twenty-front/src/testing/jest/getJestMetadataAndApolloMocksAndContextStoreWrapper.tsx index adb6799e8e4b..894cd7795e59 100644 --- a/packages/twenty-front/src/testing/jest/getJestMetadataAndApolloMocksAndContextStoreWrapper.tsx +++ b/packages/twenty-front/src/testing/jest/getJestMetadataAndApolloMocksAndContextStoreWrapper.tsx @@ -1,6 +1,7 @@ import { ActionMenuComponentInstanceContext } from '@/action-menu/states/contexts/ActionMenuComponentInstanceContext'; import { ContextStoreComponentInstanceContext } from '@/context-store/states/contexts/ContextStoreComponentInstanceContext'; import { ContextStoreTargetedRecordsRule } from '@/context-store/states/contextStoreTargetedRecordsRuleComponentState'; +import { RecordFiltersComponentInstanceContext } from '@/object-record/record-filter/states/context/RecordFiltersComponentInstanceContext'; import { MockedResponse } from '@apollo/client/testing'; import { ReactNode } from 'react'; import { MutableSnapshot } from 'recoil'; @@ -33,25 +34,33 @@ export const getJestMetadataAndApolloMocksAndActionMenuWrapper = ({ return ({ children }: { children: ReactNode }) => ( - - - - {children} - - - + + {children} + + + + ); }; diff --git a/packages/twenty-front/src/testing/jest/getJestMetadataAndApolloMocksWrapper.tsx b/packages/twenty-front/src/testing/jest/getJestMetadataAndApolloMocksWrapper.tsx index ea0e6528f0bc..7665d4d056a9 100644 --- a/packages/twenty-front/src/testing/jest/getJestMetadataAndApolloMocksWrapper.tsx +++ b/packages/twenty-front/src/testing/jest/getJestMetadataAndApolloMocksWrapper.tsx @@ -2,14 +2,16 @@ import { MockedProvider, MockedResponse } from '@apollo/client/testing'; import { ReactNode } from 'react'; import { MutableSnapshot, RecoilRoot } from 'recoil'; +import { RecordFiltersComponentInstanceContext } from '@/object-record/record-filter/states/context/RecordFiltersComponentInstanceContext'; import { SnackBarProviderScope } from '@/ui/feedback/snack-bar-manager/scopes/SnackBarProviderScope'; +import { ViewComponentInstanceContext } from '@/views/states/contexts/ViewComponentInstanceContext'; import { JestObjectMetadataItemSetter } from '~/testing/jest/JestObjectMetadataItemSetter'; export const getJestMetadataAndApolloMocksWrapper = ({ apolloMocks, onInitializeRecoilSnapshot, }: { - apolloMocks: + apolloMocks?: | readonly MockedResponse, Record>[] | undefined; onInitializeRecoilSnapshot?: (snapshot: MutableSnapshot) => void; @@ -18,9 +20,17 @@ export const getJestMetadataAndApolloMocksWrapper = ({ - - {children} - + + + + {children} + + + diff --git a/packages/twenty-front/src/testing/mock-data/config.ts b/packages/twenty-front/src/testing/mock-data/config.ts index fdd091d7c4e0..6c91c6caf803 100644 --- a/packages/twenty-front/src/testing/mock-data/config.ts +++ b/packages/twenty-front/src/testing/mock-data/config.ts @@ -33,12 +33,12 @@ export const mockedClientConfig: ClientConfig = { billingUrl: '', trialPeriods: [ { - __typename: 'TrialPeriodDTO', + __typename: 'BillingTrialPeriodDTO', duration: 30, isCreditCardRequired: true, }, { - __typename: 'TrialPeriodDTO', + __typename: 'BillingTrialPeriodDTO', duration: 7, isCreditCardRequired: false, }, diff --git a/packages/twenty-front/src/testing/mock-data/generated/mock-metadata-query-result.ts b/packages/twenty-front/src/testing/mock-data/generated/mock-metadata-query-result.ts index 31e08110ea54..18375a710d5b 100644 --- a/packages/twenty-front/src/testing/mock-data/generated/mock-metadata-query-result.ts +++ b/packages/twenty-front/src/testing/mock-data/generated/mock-metadata-query-result.ts @@ -2868,7 +2868,7 @@ export const mockedStandardObjectMetadataQueryResult: ObjectMetadataItemsQuery = "isSystem": false, "createdAt": "2024-11-06T08:55:38.993Z", "updatedAt": "2024-11-06T08:55:38.993Z", - "labelIdentifierFieldMetadataId": null, + "labelIdentifierFieldMetadataId": "7896a006-eb14-481e-8197-661b7009a22e", "imageIdentifierFieldMetadataId": null, "shortcut": null, "isLabelSyncedWithName": false, diff --git a/packages/twenty-front/src/testing/mock-data/generatedMockObjectMetadataItems.ts b/packages/twenty-front/src/testing/mock-data/generatedMockObjectMetadataItems.ts index ea4a8fae3850..665bc9c896ab 100644 --- a/packages/twenty-front/src/testing/mock-data/generatedMockObjectMetadataItems.ts +++ b/packages/twenty-front/src/testing/mock-data/generatedMockObjectMetadataItems.ts @@ -1,14 +1,23 @@ import { ObjectMetadataItem } from '@/object-metadata/types/ObjectMetadataItem'; +import { objectMetadataItemSchema } from '@/object-metadata/validation-schemas/objectMetadataItemSchema'; import { mockedStandardObjectMetadataQueryResult } from '~/testing/mock-data/generated/mock-metadata-query-result'; export const generatedMockObjectMetadataItems: ObjectMetadataItem[] = - mockedStandardObjectMetadataQueryResult.objects.edges.map((edge) => ({ - ...edge.node, - fields: edge.node.fields.edges.map((edge) => edge.node), - indexMetadatas: edge.node.indexMetadatas.edges.map((index) => ({ - ...index.node, - indexFieldMetadatas: index.node.indexFieldMetadatas?.edges.map( - (indexField) => indexField.node, - ), - })), - })); + mockedStandardObjectMetadataQueryResult.objects.edges.map((edge) => { + const labelIdentifierFieldMetadataId = + objectMetadataItemSchema.shape.labelIdentifierFieldMetadataId.parse( + edge.node.labelIdentifierFieldMetadataId, + ); + + return { + ...edge.node, + fields: edge.node.fields.edges.map((edge) => edge.node), + labelIdentifierFieldMetadataId, + indexMetadatas: edge.node.indexMetadatas.edges.map((index) => ({ + ...index.node, + indexFieldMetadatas: index.node.indexFieldMetadatas?.edges.map( + (indexField) => indexField.node, + ), + })), + }; + }); diff --git a/packages/twenty-server/.env.example b/packages/twenty-server/.env.example index 038b42d8b1c6..76c24492da69 100644 --- a/packages/twenty-server/.env.example +++ b/packages/twenty-server/.env.example @@ -27,7 +27,6 @@ FRONT_PORT=3001 # IS_MULTIWORKSPACE_ENABLED=false # AUTH_MICROSOFT_ENABLED=false # AUTH_MICROSOFT_CLIENT_ID=replace_me_with_azure_client_id -# AUTH_MICROSOFT_TENANT_ID=replace_me_with_azure_tenant_id # AUTH_MICROSOFT_CLIENT_SECRET=replace_me_with_azure_client_secret # AUTH_MICROSOFT_CALLBACK_URL=http://localhost:3000/auth/microsoft/redirect # AUTH_MICROSOFT_APIS_CALLBACK_URL=http://localhost:3000/auth/microsoft-apis/get-access-token diff --git a/packages/twenty-server/src/database/commands/database-command.module.ts b/packages/twenty-server/src/database/commands/database-command.module.ts index 8422f1e9cfd7..f6f95ce43620 100644 --- a/packages/twenty-server/src/database/commands/database-command.module.ts +++ b/packages/twenty-server/src/database/commands/database-command.module.ts @@ -8,6 +8,7 @@ import { DataSeedDemoWorkspaceModule } from 'src/database/commands/data-seed-dem import { DataSeedWorkspaceCommand } from 'src/database/commands/data-seed-dev-workspace.command'; import { ConfirmationQuestion } from 'src/database/commands/questions/confirmation.question'; import { UpgradeTo0_40CommandModule } from 'src/database/commands/upgrade-version/0-40/0-40-upgrade-version.module'; +import { UpgradeTo0_41CommandModule } from 'src/database/commands/upgrade-version/0-41/0-41-upgrade-version.module'; import { TypeORMModule } from 'src/database/typeorm/typeorm.module'; import { BillingSubscription } from 'src/engine/core-modules/billing/entities/billing-subscription.entity'; import { FeatureFlagEntity } from 'src/engine/core-modules/feature-flag/feature-flag.entity'; @@ -49,6 +50,7 @@ import { WorkspaceSyncMetadataModule } from 'src/engine/workspace-manager/worksp WorkspaceCacheStorageModule, WorkspaceMetadataVersionModule, UpgradeTo0_40CommandModule, + UpgradeTo0_41CommandModule, FeatureFlagModule, ], providers: [ diff --git a/packages/twenty-server/src/database/commands/upgrade-version/0-41/0-41-migrate-relations-to-field-metadata.command.ts b/packages/twenty-server/src/database/commands/upgrade-version/0-41/0-41-migrate-relations-to-field-metadata.command.ts new file mode 100644 index 000000000000..93f1ba9ed9fd --- /dev/null +++ b/packages/twenty-server/src/database/commands/upgrade-version/0-41/0-41-migrate-relations-to-field-metadata.command.ts @@ -0,0 +1,140 @@ +import { InjectRepository } from '@nestjs/typeorm'; + +import chalk from 'chalk'; +import { Command } from 'nest-commander'; +import { FieldMetadataType } from 'twenty-shared'; +import { Repository } from 'typeorm'; + +import { RelationType } from 'src/engine/metadata-modules/field-metadata/interfaces/relation-type.interface'; + +import { + ActiveWorkspacesCommandOptions, + ActiveWorkspacesCommandRunner, +} from 'src/database/commands/active-workspaces.command'; +import { isCommandLogger } from 'src/database/commands/logger'; +import { Workspace } from 'src/engine/core-modules/workspace/workspace.entity'; +import { FieldMetadataEntity } from 'src/engine/metadata-modules/field-metadata/field-metadata.entity'; +import { + deduceRelationDirection, + RelationDirection, +} from 'src/engine/utils/deduce-relation-direction.util'; + +@Command({ + name: 'upgrade-0.41:migrate-relations-to-field-metadata', + description: 'Migrate relations to field metadata', +}) +export class MigrateRelationsToFieldMetadataCommand extends ActiveWorkspacesCommandRunner { + constructor( + @InjectRepository(Workspace, 'core') + protected readonly workspaceRepository: Repository, + @InjectRepository(FieldMetadataEntity, 'metadata') + private readonly fieldMetadataRepository: Repository, + ) { + super(workspaceRepository); + } + + async executeActiveWorkspacesCommand( + _passedParam: string[], + options: ActiveWorkspacesCommandOptions, + workspaceIds: string[], + ): Promise { + this.logger.log('Running command to create many to one relations'); + + if (isCommandLogger(this.logger)) { + this.logger.setVerbose(options.verbose ?? false); + } + + try { + for (const [index, workspaceId] of workspaceIds.entries()) { + await this.processWorkspace(workspaceId, index, workspaceIds.length); + } + + this.logger.log(chalk.green('Command completed!')); + } catch (error) { + this.logger.log(chalk.red('Error in workspace')); + } + } + + private async processWorkspace( + workspaceId: string, + index: number, + total: number, + ): Promise { + try { + this.logger.log( + `Running command for workspace ${workspaceId} ${index + 1}/${total}`, + ); + + const fieldMetadataCollection = (await this.fieldMetadataRepository.find({ + where: { workspaceId, type: FieldMetadataType.RELATION }, + relations: ['fromRelationMetadata', 'toRelationMetadata'], + })) as unknown as FieldMetadataEntity[]; + + if (!fieldMetadataCollection.length) { + this.logger.log( + chalk.yellow( + `No relation field metadata found for workspace ${workspaceId}.`, + ), + ); + + return; + } + + const fieldMetadataToUpdateCollection = fieldMetadataCollection.map( + (fieldMetadata) => this.mapFieldMetadata(fieldMetadata), + ); + + if (fieldMetadataToUpdateCollection.length > 0) { + await this.fieldMetadataRepository.save( + fieldMetadataToUpdateCollection, + ); + } + + this.logger.log( + chalk.green(`Command completed for workspace ${workspaceId}.`), + ); + } catch { + this.logger.log(chalk.red(`Error in workspace ${workspaceId}.`)); + } + } + + private mapFieldMetadata( + fieldMetadata: FieldMetadataEntity, + ): FieldMetadataEntity { + const relationMetadata = + fieldMetadata.fromRelationMetadata ?? fieldMetadata.toRelationMetadata; + + const relationDirection = deduceRelationDirection( + fieldMetadata, + relationMetadata, + ); + let relationType = relationMetadata.relationType as unknown as RelationType; + + if ( + relationDirection === RelationDirection.TO && + relationType === RelationType.ONE_TO_MANY + ) { + relationType = RelationType.MANY_TO_ONE; + } + + const relationTargetFieldMetadataId = + relationDirection === RelationDirection.FROM + ? relationMetadata.toFieldMetadataId + : relationMetadata.fromFieldMetadataId; + + const relationTargetObjectMetadataId = + relationDirection === RelationDirection.FROM + ? relationMetadata.toObjectMetadataId + : relationMetadata.fromObjectMetadataId; + + return { + ...fieldMetadata, + settings: { + relationType, + onDelete: relationMetadata.onDeleteAction, + }, + relationTargetFieldMetadataId, + relationTargetObjectMetadataId, + }; + } +} diff --git a/packages/twenty-server/src/database/commands/upgrade-version/0-41/0-41-seed-workflow-views.command.ts b/packages/twenty-server/src/database/commands/upgrade-version/0-41/0-41-seed-workflow-views.command.ts new file mode 100644 index 000000000000..4bce88ee7be3 --- /dev/null +++ b/packages/twenty-server/src/database/commands/upgrade-version/0-41/0-41-seed-workflow-views.command.ts @@ -0,0 +1,203 @@ +import { Logger } from '@nestjs/common'; +import { InjectRepository } from '@nestjs/typeorm'; + +import { Command } from 'nest-commander'; +import { EntityManager, IsNull, Not, Repository } from 'typeorm'; + +import { + ActiveWorkspacesCommandOptions, + ActiveWorkspacesCommandRunner, +} from 'src/database/commands/active-workspaces.command'; +import { TypeORMService } from 'src/database/typeorm/typeorm.service'; +import { Workspace } from 'src/engine/core-modules/workspace/workspace.entity'; +import { DataSourceService } from 'src/engine/metadata-modules/data-source/data-source.service'; +import { ObjectMetadataService } from 'src/engine/metadata-modules/object-metadata/object-metadata.service'; +import { TwentyORMGlobalManager } from 'src/engine/twenty-orm/twenty-orm-global.manager'; +import { createWorkspaceViews } from 'src/engine/workspace-manager/standard-objects-prefill-data/create-workspace-views'; +import { workflowRunsAllView } from 'src/engine/workspace-manager/standard-objects-prefill-data/views/workflow-runs-all.view'; +import { workflowVersionsAllView } from 'src/engine/workspace-manager/standard-objects-prefill-data/views/workflow-versions-all.view'; +import { workflowsAllView } from 'src/engine/workspace-manager/standard-objects-prefill-data/views/workflows-all.view'; +import { STANDARD_OBJECT_IDS } from 'src/engine/workspace-manager/workspace-sync-metadata/constants/standard-object-ids'; + +@Command({ + name: 'upgrade-0.41:workflow-seed-views', + description: 'Seed workflow views for workspace.', +}) +export class SeedWorkflowViewsCommand extends ActiveWorkspacesCommandRunner { + protected readonly logger: Logger; + + constructor( + @InjectRepository(Workspace, 'core') + protected readonly workspaceRepository: Repository, + private readonly twentyORMGlobalManager: TwentyORMGlobalManager, + private readonly dataSourceService: DataSourceService, + private readonly typeORMService: TypeORMService, + private readonly objectMetadataService: ObjectMetadataService, + ) { + super(workspaceRepository); + this.logger = new Logger(this.constructor.name); + } + + async executeActiveWorkspacesCommand( + _passedParam: string[], + _options: ActiveWorkspacesCommandOptions, + _workspaceIds: string[], + ): Promise { + const { dryRun } = _options; + + for (const workspaceId of _workspaceIds) { + await this.execute(workspaceId, dryRun); + } + } + + private async execute(workspaceId: string, dryRun = false): Promise { + this.logger.log(`Seeding workflow views for workspace: ${workspaceId}`); + + const workflowObjectMetadata = + await this.objectMetadataService.findOneWithinWorkspace(workspaceId, { + where: { + standardId: STANDARD_OBJECT_IDS.workflow, + }, + }); + + if (!workflowObjectMetadata) { + this.logger.error('Workflow object metadata not found'); + + return; + } + + await this.seedWorkflowViews( + workspaceId, + workflowObjectMetadata.id, + dryRun, + ); + + await this.seedWorkspaceFavorite( + workspaceId, + workflowObjectMetadata.id, + dryRun, + ); + } + + private async seedWorkflowViews( + workspaceId: string, + workflowObjectMetadataId: string, + dryRun = false, + ) { + const viewRepository = + await this.twentyORMGlobalManager.getRepositoryForWorkspace( + workspaceId, + 'view', + ); + + const existingWorkflowView = await viewRepository.findOne({ + where: { + objectMetadataId: workflowObjectMetadataId, + }, + }); + + if (existingWorkflowView) { + this.logger.log(`View already exists: ${existingWorkflowView.id}`); + + return; + } + + if (dryRun) { + this.logger.log(`Dry run: not creating view`); + + return; + } + + const { objectMetadataStandardIdToIdMap } = + await this.objectMetadataService.getObjectMetadataStandardIdToIdMap( + workspaceId, + ); + + const dataSourceMetadata = + await this.dataSourceService.getLastDataSourceMetadataFromWorkspaceIdOrFail( + workspaceId, + ); + + const workspaceDataSource = + await this.typeORMService.connectToDataSource(dataSourceMetadata); + + if (!workspaceDataSource) { + this.logger.error('Could not connect to workspace data source'); + + return; + } + + const viewDefinitions = [ + workflowsAllView(objectMetadataStandardIdToIdMap), + workflowVersionsAllView(objectMetadataStandardIdToIdMap), + workflowRunsAllView(objectMetadataStandardIdToIdMap), + ]; + + await workspaceDataSource.transaction( + async (entityManager: EntityManager) => { + return createWorkspaceViews( + entityManager, + dataSourceMetadata.schema, + viewDefinitions, + ); + }, + ); + } + + private async seedWorkspaceFavorite( + workspaceId: string, + workflowObjectMetadataId: string, + dryRun = false, + ) { + const viewRepository = + await this.twentyORMGlobalManager.getRepositoryForWorkspace( + workspaceId, + 'view', + ); + + const workflowView = await viewRepository.findOne({ + where: { + objectMetadataId: workflowObjectMetadataId, + }, + }); + + if (!workflowView) { + this.logger.error('Workflow view not found'); + + return; + } + + if (dryRun) { + this.logger.log(`Dry run: not creating favorite`); + + return; + } + + const favoriteRepository = + await this.twentyORMGlobalManager.getRepositoryForWorkspace( + workspaceId, + 'favorite', + ); + + const existingFavorites = await favoriteRepository.find({ + where: { + viewId: Not(IsNull()), + }, + }); + + const workflowFavorite = existingFavorites.find( + (favorite) => favorite.viewId === workflowView.id, + ); + + if (workflowFavorite) { + this.logger.log(`Favorite already exists: ${workflowFavorite.id}`); + + return; + } + + await favoriteRepository.insert({ + viewId: workflowView.id, + position: existingFavorites.length, + }); + } +} diff --git a/packages/twenty-server/src/database/commands/upgrade-version/0-41/0-41-upgrade-version.command.ts b/packages/twenty-server/src/database/commands/upgrade-version/0-41/0-41-upgrade-version.command.ts new file mode 100644 index 000000000000..5a8d6216e07b --- /dev/null +++ b/packages/twenty-server/src/database/commands/upgrade-version/0-41/0-41-upgrade-version.command.ts @@ -0,0 +1,56 @@ +import { InjectRepository } from '@nestjs/typeorm'; + +import { Command } from 'nest-commander'; +import { Repository } from 'typeorm'; + +import { ActiveWorkspacesCommandRunner } from 'src/database/commands/active-workspaces.command'; +import { BaseCommandOptions } from 'src/database/commands/base.command'; +import { MigrateRelationsToFieldMetadataCommand } from 'src/database/commands/upgrade-version/0-41/0-41-migrate-relations-to-field-metadata.command'; +import { SeedWorkflowViewsCommand } from 'src/database/commands/upgrade-version/0-41/0-41-seed-workflow-views.command'; +import { Workspace } from 'src/engine/core-modules/workspace/workspace.entity'; +import { SyncWorkspaceMetadataCommand } from 'src/engine/workspace-manager/workspace-sync-metadata/commands/sync-workspace-metadata.command'; + +@Command({ + name: 'upgrade-0.41', + description: 'Upgrade to 0.41', +}) +export class UpgradeTo0_41Command extends ActiveWorkspacesCommandRunner { + constructor( + @InjectRepository(Workspace, 'core') + protected readonly workspaceRepository: Repository, + private readonly seedWorkflowViewsCommand: SeedWorkflowViewsCommand, + private readonly syncWorkspaceMetadataCommand: SyncWorkspaceMetadataCommand, + private readonly migrateRelationsToFieldMetadata: MigrateRelationsToFieldMetadataCommand, + ) { + super(workspaceRepository); + } + + async executeActiveWorkspacesCommand( + passedParam: string[], + options: BaseCommandOptions, + workspaceIds: string[], + ): Promise { + this.logger.log('Running command to upgrade to 0.41'); + + await this.syncWorkspaceMetadataCommand.executeActiveWorkspacesCommand( + passedParam, + { + ...options, + force: true, + }, + workspaceIds, + ); + + await this.seedWorkflowViewsCommand.executeActiveWorkspacesCommand( + passedParam, + options, + workspaceIds, + ); + + await this.migrateRelationsToFieldMetadata.executeActiveWorkspacesCommand( + passedParam, + options, + workspaceIds, + ); + } +} diff --git a/packages/twenty-server/src/database/commands/upgrade-version/0-41/0-41-upgrade-version.module.ts b/packages/twenty-server/src/database/commands/upgrade-version/0-41/0-41-upgrade-version.module.ts new file mode 100644 index 000000000000..fe417eaa2b7a --- /dev/null +++ b/packages/twenty-server/src/database/commands/upgrade-version/0-41/0-41-upgrade-version.module.ts @@ -0,0 +1,37 @@ +import { Module } from '@nestjs/common'; +import { TypeOrmModule } from '@nestjs/typeorm'; + +import { MigrateRelationsToFieldMetadataCommand } from 'src/database/commands/upgrade-version/0-41/0-41-migrate-relations-to-field-metadata.command'; +import { SeedWorkflowViewsCommand } from 'src/database/commands/upgrade-version/0-41/0-41-seed-workflow-views.command'; +import { UpgradeTo0_41Command } from 'src/database/commands/upgrade-version/0-41/0-41-upgrade-version.command'; +import { TypeORMModule } from 'src/database/typeorm/typeorm.module'; +import { Workspace } from 'src/engine/core-modules/workspace/workspace.entity'; +import { DataSourceModule } from 'src/engine/metadata-modules/data-source/data-source.module'; +import { FieldMetadataEntity } from 'src/engine/metadata-modules/field-metadata/field-metadata.entity'; +import { ObjectMetadataModule } from 'src/engine/metadata-modules/object-metadata/object-metadata.module'; +import { WorkspaceHealthModule } from 'src/engine/workspace-manager/workspace-health/workspace-health.module'; +import { SyncWorkspaceLoggerService } from 'src/engine/workspace-manager/workspace-sync-metadata/commands/services/sync-workspace-logger.service'; +import { SyncWorkspaceMetadataCommand } from 'src/engine/workspace-manager/workspace-sync-metadata/commands/sync-workspace-metadata.command'; +import { WorkspaceSyncMetadataCommandsModule } from 'src/engine/workspace-manager/workspace-sync-metadata/commands/workspace-sync-metadata-commands.module'; +import { WorkspaceSyncMetadataModule } from 'src/engine/workspace-manager/workspace-sync-metadata/workspace-sync-metadata.module'; + +@Module({ + imports: [ + TypeOrmModule.forFeature([Workspace], 'core'), + TypeOrmModule.forFeature([FieldMetadataEntity], 'metadata'), + TypeORMModule, + DataSourceModule, + ObjectMetadataModule, + WorkspaceSyncMetadataCommandsModule, + WorkspaceSyncMetadataModule, + WorkspaceHealthModule, + ], + providers: [ + SyncWorkspaceLoggerService, + SyncWorkspaceMetadataCommand, + SeedWorkflowViewsCommand, + UpgradeTo0_41Command, + MigrateRelationsToFieldMetadataCommand, + ], +}) +export class UpgradeTo0_41CommandModule {} diff --git a/packages/twenty-server/src/database/typeorm-seeds/core/feature-flags.ts b/packages/twenty-server/src/database/typeorm-seeds/core/feature-flags.ts index 03ae59dca282..e567c7e0888d 100644 --- a/packages/twenty-server/src/database/typeorm-seeds/core/feature-flags.ts +++ b/packages/twenty-server/src/database/typeorm-seeds/core/feature-flags.ts @@ -35,11 +35,6 @@ export const seedFeatureFlags = async ( workspaceId: workspaceId, value: true, }, - { - key: FeatureFlagKey.IsFunctionSettingsEnabled, - workspaceId: workspaceId, - value: false, - }, { key: FeatureFlagKey.IsWorkflowEnabled, workspaceId: workspaceId, @@ -51,7 +46,7 @@ export const seedFeatureFlags = async ( value: true, }, { - key: FeatureFlagKey.IsGmailSendEmailScopeEnabled, + key: FeatureFlagKey.IsBillingPlansEnabled, workspaceId: workspaceId, value: true, }, diff --git a/packages/twenty-server/src/database/typeorm/core/migrations/billing/1737127856478-addNonNullableProductDescription.ts b/packages/twenty-server/src/database/typeorm/core/migrations/billing/1737127856478-addNonNullableProductDescription.ts new file mode 100644 index 000000000000..0888faf69629 --- /dev/null +++ b/packages/twenty-server/src/database/typeorm/core/migrations/billing/1737127856478-addNonNullableProductDescription.ts @@ -0,0 +1,25 @@ +import { MigrationInterface, QueryRunner } from 'typeorm'; + +export class AddNonNullableProductDescription1737127856478 + implements MigrationInterface +{ + name = 'AddNonNullableProductDescription1737127856478'; + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query( + `ALTER TABLE "core"."billingProduct" ALTER COLUMN "description" SET DEFAULT ''`, + ); + await queryRunner.query( + `ALTER TABLE "core"."billingProduct" ALTER COLUMN "description" SET NOT NULL`, + ); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query( + `ALTER TABLE "core"."billingProduct" ALTER COLUMN "description" DROP DEFAULT`, + ); + await queryRunner.query( + `ALTER TABLE "core"."billingProduct" ALTER COLUMN "description" DROP NOT NULL`, + ); + } +} diff --git a/packages/twenty-server/src/database/typeorm/core/migrations/common/1737630672873-workspace-entity-default-microsoft-auth-enabled.ts b/packages/twenty-server/src/database/typeorm/core/migrations/common/1737630672873-workspace-entity-default-microsoft-auth-enabled.ts new file mode 100644 index 000000000000..84ef81e60fa4 --- /dev/null +++ b/packages/twenty-server/src/database/typeorm/core/migrations/common/1737630672873-workspace-entity-default-microsoft-auth-enabled.ts @@ -0,0 +1,19 @@ +import { MigrationInterface, QueryRunner } from 'typeorm'; + +export class WorkspaceEntityDefaultMicrosoftAuthEnabled1737630672873 + implements MigrationInterface +{ + name = 'WorkspaceEntityDefaultMicrosoftAuthEnabled1737630672873'; + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query( + `ALTER TABLE "core"."workspace" ALTER COLUMN "isMicrosoftAuthEnabled" SET DEFAULT true`, + ); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query( + `ALTER TABLE "core"."workspace" ALTER COLUMN "isMicrosoftAuthEnabled" SET DEFAULT false`, + ); + } +} diff --git a/packages/twenty-server/src/database/typeorm/metadata/migrations/1737561084251-addRelationTargetFieldAndObjectToFieldMetadata.ts b/packages/twenty-server/src/database/typeorm/metadata/migrations/1737561084251-addRelationTargetFieldAndObjectToFieldMetadata.ts new file mode 100644 index 000000000000..e8932a7c4296 --- /dev/null +++ b/packages/twenty-server/src/database/typeorm/metadata/migrations/1737561084251-addRelationTargetFieldAndObjectToFieldMetadata.ts @@ -0,0 +1,55 @@ +import { MigrationInterface, QueryRunner } from 'typeorm'; + +export class AddRelationTargetFieldAndObjectToFieldMetadata1737561084251 + implements MigrationInterface +{ + name = 'AddRelationTargetFieldAndObjectToFieldMetadata1737561084251'; + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query( + `ALTER TABLE "metadata"."fieldMetadata" ADD "relationTargetFieldMetadataId" uuid`, + ); + await queryRunner.query( + `ALTER TABLE "metadata"."fieldMetadata" ADD CONSTRAINT "UQ_47a6c57e1652b6475f8248cff78" UNIQUE ("relationTargetFieldMetadataId")`, + ); + await queryRunner.query( + `ALTER TABLE "metadata"."fieldMetadata" ADD "relationTargetObjectMetadataId" uuid`, + ); + await queryRunner.query( + `CREATE INDEX "IndexOnRelationTargetObjectMetadataId" ON "metadata"."fieldMetadata" ("relationTargetObjectMetadataId") `, + ); + await queryRunner.query( + `CREATE INDEX "IndexOnRelationTargetFieldMetadataId" ON "metadata"."fieldMetadata" ("relationTargetFieldMetadataId") `, + ); + await queryRunner.query( + `ALTER TABLE "metadata"."fieldMetadata" ADD CONSTRAINT "FK_47a6c57e1652b6475f8248cff78" FOREIGN KEY ("relationTargetFieldMetadataId") REFERENCES "metadata"."fieldMetadata"("id") ON DELETE NO ACTION ON UPDATE NO ACTION`, + ); + await queryRunner.query( + `ALTER TABLE "metadata"."fieldMetadata" ADD CONSTRAINT "FK_6f6c87ec32cca956d8be321071c" FOREIGN KEY ("relationTargetObjectMetadataId") REFERENCES "metadata"."objectMetadata"("id") ON DELETE NO ACTION ON UPDATE NO ACTION`, + ); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query( + `ALTER TABLE "metadata"."fieldMetadata" DROP CONSTRAINT "FK_6f6c87ec32cca956d8be321071c"`, + ); + await queryRunner.query( + `ALTER TABLE "metadata"."fieldMetadata" DROP CONSTRAINT "FK_47a6c57e1652b6475f8248cff78"`, + ); + await queryRunner.query( + `DROP INDEX "metadata"."IndexOnRelationTargetFieldMetadataId"`, + ); + await queryRunner.query( + `DROP INDEX "metadata"."IndexOnRelationTargetObjectMetadataId"`, + ); + await queryRunner.query( + `ALTER TABLE "metadata"."fieldMetadata" DROP COLUMN "relationTargetObjectMetadataId"`, + ); + await queryRunner.query( + `ALTER TABLE "metadata"."fieldMetadata" DROP CONSTRAINT "UQ_47a6c57e1652b6475f8248cff78"`, + ); + await queryRunner.query( + `ALTER TABLE "metadata"."fieldMetadata" DROP COLUMN "relationTargetFieldMetadataId"`, + ); + } +} diff --git a/packages/twenty-server/src/engine/api/graphql/workspace-schema-builder/utils/get-available-aggregations-from-object-fields.util.ts b/packages/twenty-server/src/engine/api/graphql/workspace-schema-builder/utils/get-available-aggregations-from-object-fields.util.ts index 5f80f9266fb7..f659720bf381 100644 --- a/packages/twenty-server/src/engine/api/graphql/workspace-schema-builder/utils/get-available-aggregations-from-object-fields.util.ts +++ b/packages/twenty-server/src/engine/api/graphql/workspace-schema-builder/utils/get-available-aggregations-from-object-fields.util.ts @@ -3,6 +3,7 @@ import { GraphQLISODateTime } from '@nestjs/graphql'; import { GraphQLFloat, GraphQLInt, GraphQLScalarType } from 'graphql'; import { capitalize, + FIELD_FOR_TOTAL_COUNT_AGGREGATE_OPERATION, FieldMetadataType, isFieldMetadataDateKind, } from 'twenty-shared'; @@ -176,7 +177,7 @@ export const getAvailableAggregationsFromObjectFields = ( totalCount: { type: GraphQLInt, description: `Total number of records in the connection`, - fromField: 'id', + fromField: FIELD_FOR_TOTAL_COUNT_AGGREGATE_OPERATION, fromFieldType: FieldMetadataType.UUID, aggregateOperation: AGGREGATE_OPERATIONS.count, }, diff --git a/packages/twenty-server/src/engine/core-modules/auth/auth.exception.ts b/packages/twenty-server/src/engine/core-modules/auth/auth.exception.ts index 0a7b7a45166e..b658f25e28d1 100644 --- a/packages/twenty-server/src/engine/core-modules/auth/auth.exception.ts +++ b/packages/twenty-server/src/engine/core-modules/auth/auth.exception.ts @@ -14,6 +14,7 @@ export enum AuthExceptionCode { WORKSPACE_NOT_FOUND = 'WORKSPACE_NOT_FOUND', INVALID_INPUT = 'INVALID_INPUT', FORBIDDEN_EXCEPTION = 'FORBIDDEN_EXCEPTION', + INSUFFICIENT_SCOPES = 'INSUFFICIENT_SCOPES', UNAUTHENTICATED = 'UNAUTHENTICATED', INVALID_DATA = 'INVALID_DATA', INTERNAL_SERVER_ERROR = 'INTERNAL_SERVER_ERROR', diff --git a/packages/twenty-server/src/engine/core-modules/auth/guards/google-apis-oauth-exchange-code-for-token.guard.ts b/packages/twenty-server/src/engine/core-modules/auth/guards/google-apis-oauth-exchange-code-for-token.guard.ts index 7d3e1c324568..ea984e8eb947 100644 --- a/packages/twenty-server/src/engine/core-modules/auth/guards/google-apis-oauth-exchange-code-for-token.guard.ts +++ b/packages/twenty-server/src/engine/core-modules/auth/guards/google-apis-oauth-exchange-code-for-token.guard.ts @@ -6,36 +6,20 @@ import { AuthExceptionCode, } from 'src/engine/core-modules/auth/auth.exception'; import { GoogleAPIsOauthExchangeCodeForTokenStrategy } from 'src/engine/core-modules/auth/strategies/google-apis-oauth-exchange-code-for-token.auth.strategy'; -import { TransientTokenService } from 'src/engine/core-modules/auth/token/services/transient-token.service'; import { setRequestExtraParams } from 'src/engine/core-modules/auth/utils/google-apis-set-request-extra-params.util'; import { EnvironmentService } from 'src/engine/core-modules/environment/environment.service'; -import { FeatureFlagKey } from 'src/engine/core-modules/feature-flag/enums/feature-flag-key.enum'; -import { FeatureFlagService } from 'src/engine/core-modules/feature-flag/services/feature-flag.service'; @Injectable() export class GoogleAPIsOauthExchangeCodeForTokenGuard extends AuthGuard( 'google-apis', ) { - constructor( - private readonly environmentService: EnvironmentService, - private readonly featureFlagService: FeatureFlagService, - private readonly transientTokenService: TransientTokenService, - ) { + constructor(private readonly environmentService: EnvironmentService) { super(); } async canActivate(context: ExecutionContext) { const request = context.switchToHttp().getRequest(); const state = JSON.parse(request.query.state); - const { workspaceId } = - await this.transientTokenService.verifyTransientToken( - state.transientToken, - ); - const isGmailSendEmailScopeEnabled = - await this.featureFlagService.isFeatureEnabled( - FeatureFlagKey.IsGmailSendEmailScopeEnabled, - workspaceId, - ); if ( !this.environmentService.get('MESSAGING_PROVIDER_GMAIL_ENABLED') && @@ -50,7 +34,6 @@ export class GoogleAPIsOauthExchangeCodeForTokenGuard extends AuthGuard( new GoogleAPIsOauthExchangeCodeForTokenStrategy( this.environmentService, {}, - isGmailSendEmailScopeEnabled, ); setRequestExtraParams(request, { diff --git a/packages/twenty-server/src/engine/core-modules/auth/guards/google-apis-oauth-request-code.guard.ts b/packages/twenty-server/src/engine/core-modules/auth/guards/google-apis-oauth-request-code.guard.ts index 470c0ddf01fa..3a52e0037215 100644 --- a/packages/twenty-server/src/engine/core-modules/auth/guards/google-apis-oauth-request-code.guard.ts +++ b/packages/twenty-server/src/engine/core-modules/auth/guards/google-apis-oauth-request-code.guard.ts @@ -9,7 +9,6 @@ import { GoogleAPIsOauthRequestCodeStrategy } from 'src/engine/core-modules/auth import { TransientTokenService } from 'src/engine/core-modules/auth/token/services/transient-token.service'; import { setRequestExtraParams } from 'src/engine/core-modules/auth/utils/google-apis-set-request-extra-params.util'; import { EnvironmentService } from 'src/engine/core-modules/environment/environment.service'; -import { FeatureFlagKey } from 'src/engine/core-modules/feature-flag/enums/feature-flag-key.enum'; import { FeatureFlagService } from 'src/engine/core-modules/feature-flag/services/feature-flag.service'; @Injectable() @@ -31,11 +30,6 @@ export class GoogleAPIsOauthRequestCodeGuard extends AuthGuard('google-apis') { await this.transientTokenService.verifyTransientToken( request.query.transientToken, ); - const isGmailSendEmailScopeEnabled = - await this.featureFlagService.isFeatureEnabled( - FeatureFlagKey.IsGmailSendEmailScopeEnabled, - workspaceId, - ); setRequestExtraParams(request, { transientToken: request.query.transientToken, @@ -57,11 +51,7 @@ export class GoogleAPIsOauthRequestCodeGuard extends AuthGuard('google-apis') { ); } - new GoogleAPIsOauthRequestCodeStrategy( - this.environmentService, - {}, - isGmailSendEmailScopeEnabled, - ); + new GoogleAPIsOauthRequestCodeStrategy(this.environmentService, {}); const activate = (await super.canActivate(context)) as boolean; diff --git a/packages/twenty-server/src/engine/core-modules/auth/guards/microsoft-apis-oauth-exchange-code-for-token.guard.ts b/packages/twenty-server/src/engine/core-modules/auth/guards/microsoft-apis-oauth-exchange-code-for-token.guard.ts index db94e5d1fec2..de9bce16fe56 100644 --- a/packages/twenty-server/src/engine/core-modules/auth/guards/microsoft-apis-oauth-exchange-code-for-token.guard.ts +++ b/packages/twenty-server/src/engine/core-modules/auth/guards/microsoft-apis-oauth-exchange-code-for-token.guard.ts @@ -1,6 +1,10 @@ import { ExecutionContext, Injectable } from '@nestjs/common'; import { AuthGuard } from '@nestjs/passport'; +import { + AuthException, + AuthExceptionCode, +} from 'src/engine/core-modules/auth/auth.exception'; import { MicrosoftAPIsOauthExchangeCodeForTokenStrategy } from 'src/engine/core-modules/auth/strategies/microsoft-apis-oauth-exchange-code-for-token.auth.strategy'; import { setRequestExtraParams } from 'src/engine/core-modules/auth/utils/google-apis-set-request-extra-params.util'; import { EnvironmentService } from 'src/engine/core-modules/environment/environment.service'; @@ -14,18 +18,31 @@ export class MicrosoftAPIsOauthExchangeCodeForTokenGuard extends AuthGuard( } async canActivate(context: ExecutionContext) { - const request = context.switchToHttp().getRequest(); - const state = JSON.parse(request.query.state); + try { + const request = context.switchToHttp().getRequest(); + const state = JSON.parse(request.query.state); - new MicrosoftAPIsOauthExchangeCodeForTokenStrategy(this.environmentService); + new MicrosoftAPIsOauthExchangeCodeForTokenStrategy( + this.environmentService, + ); - setRequestExtraParams(request, { - transientToken: state.transientToken, - redirectLocation: state.redirectLocation, - calendarVisibility: state.calendarVisibility, - messageVisibility: state.messageVisibility, - }); + setRequestExtraParams(request, { + transientToken: state.transientToken, + redirectLocation: state.redirectLocation, + calendarVisibility: state.calendarVisibility, + messageVisibility: state.messageVisibility, + }); - return (await super.canActivate(context)) as boolean; + return (await super.canActivate(context)) as boolean; + } catch (error) { + if (error?.oauthError?.statusCode === 403) { + throw new AuthException( + `Insufficient privileges to access this microsoft resource. Make sure you have the correct scopes or ask your admin to update your scopes. ${error?.message}`, + AuthExceptionCode.INSUFFICIENT_SCOPES, + ); + } + + return false; + } } } diff --git a/packages/twenty-server/src/engine/core-modules/auth/services/google-apis.service.ts b/packages/twenty-server/src/engine/core-modules/auth/services/google-apis.service.ts index ad60b010d258..2512918f7545 100644 --- a/packages/twenty-server/src/engine/core-modules/auth/services/google-apis.service.ts +++ b/packages/twenty-server/src/engine/core-modules/auth/services/google-apis.service.ts @@ -5,8 +5,6 @@ import { v4 } from 'uuid'; import { getGoogleApisOauthScopes } from 'src/engine/core-modules/auth/utils/get-google-apis-oauth-scopes'; import { EnvironmentService } from 'src/engine/core-modules/environment/environment.service'; -import { FeatureFlagKey } from 'src/engine/core-modules/feature-flag/enums/feature-flag-key.enum'; -import { FeatureFlagService } from 'src/engine/core-modules/feature-flag/services/feature-flag.service'; import { InjectMessageQueue } from 'src/engine/core-modules/message-queue/decorators/message-queue.decorator'; import { MessageQueue } from 'src/engine/core-modules/message-queue/message-queue.constants'; import { MessageQueueService } from 'src/engine/core-modules/message-queue/services/message-queue.service'; @@ -47,7 +45,6 @@ export class GoogleAPIsService { private readonly calendarQueueService: MessageQueueService, private readonly environmentService: EnvironmentService, private readonly accountsToReconnectService: AccountsToReconnectService, - private readonly featureFlagService: FeatureFlagService, ) {} async refreshGoogleRefreshToken(input: { @@ -99,12 +96,7 @@ export class GoogleAPIsService { const workspaceDataSource = await this.twentyORMGlobalManager.getDataSourceForWorkspace(workspaceId); - const isGmailSendEmailScopeEnabled = - await this.featureFlagService.isFeatureEnabled( - FeatureFlagKey.IsGmailSendEmailScopeEnabled, - workspaceId, - ); - const scopes = getGoogleApisOauthScopes(isGmailSendEmailScopeEnabled); + const scopes = getGoogleApisOauthScopes(); await workspaceDataSource.transaction(async (manager: EntityManager) => { if (!existingAccountId) { diff --git a/packages/twenty-server/src/engine/core-modules/auth/strategies/google-apis-oauth-common.auth.strategy.ts b/packages/twenty-server/src/engine/core-modules/auth/strategies/google-apis-oauth-common.auth.strategy.ts index addf4b6e78cd..741a05ef6c89 100644 --- a/packages/twenty-server/src/engine/core-modules/auth/strategies/google-apis-oauth-common.auth.strategy.ts +++ b/packages/twenty-server/src/engine/core-modules/auth/strategies/google-apis-oauth-common.auth.strategy.ts @@ -19,9 +19,8 @@ export class GoogleAPIsOauthCommonStrategy extends PassportStrategy( constructor( environmentService: EnvironmentService, scopeConfig: GoogleAPIScopeConfig, - isGmailSendEmailScopeEnabled = false, ) { - const scopes = getGoogleApisOauthScopes(isGmailSendEmailScopeEnabled); + const scopes = getGoogleApisOauthScopes(); super({ clientID: environmentService.get('AUTH_GOOGLE_CLIENT_ID'), diff --git a/packages/twenty-server/src/engine/core-modules/auth/strategies/google-apis-oauth-exchange-code-for-token.auth.strategy.ts b/packages/twenty-server/src/engine/core-modules/auth/strategies/google-apis-oauth-exchange-code-for-token.auth.strategy.ts index c8559bd141f2..244b1066d846 100644 --- a/packages/twenty-server/src/engine/core-modules/auth/strategies/google-apis-oauth-exchange-code-for-token.auth.strategy.ts +++ b/packages/twenty-server/src/engine/core-modules/auth/strategies/google-apis-oauth-exchange-code-for-token.auth.strategy.ts @@ -15,9 +15,8 @@ export class GoogleAPIsOauthExchangeCodeForTokenStrategy extends GoogleAPIsOauth constructor( environmentService: EnvironmentService, scopeConfig: GoogleAPIScopeConfig, - isGmailSendEmailScopeEnabled = false, ) { - super(environmentService, scopeConfig, isGmailSendEmailScopeEnabled); + super(environmentService, scopeConfig); } async validate( diff --git a/packages/twenty-server/src/engine/core-modules/auth/strategies/google-apis-oauth-request-code.auth.strategy.ts b/packages/twenty-server/src/engine/core-modules/auth/strategies/google-apis-oauth-request-code.auth.strategy.ts index ee0782b9cd8b..6ce3c33c5026 100644 --- a/packages/twenty-server/src/engine/core-modules/auth/strategies/google-apis-oauth-request-code.auth.strategy.ts +++ b/packages/twenty-server/src/engine/core-modules/auth/strategies/google-apis-oauth-request-code.auth.strategy.ts @@ -13,9 +13,8 @@ export class GoogleAPIsOauthRequestCodeStrategy extends GoogleAPIsOauthCommonStr constructor( environmentService: EnvironmentService, scopeConfig: GoogleAPIScopeConfig, - isGmailSendEmailScopeEnabled = false, ) { - super(environmentService, scopeConfig, isGmailSendEmailScopeEnabled); + super(environmentService, scopeConfig); } authenticate(req: any, options: any) { diff --git a/packages/twenty-server/src/engine/core-modules/auth/strategies/microsoft-apis-oauth-common.auth.strategy.ts b/packages/twenty-server/src/engine/core-modules/auth/strategies/microsoft-apis-oauth-common.auth.strategy.ts index 8b506a98045c..e0be81b2e0b9 100644 --- a/packages/twenty-server/src/engine/core-modules/auth/strategies/microsoft-apis-oauth-common.auth.strategy.ts +++ b/packages/twenty-server/src/engine/core-modules/auth/strategies/microsoft-apis-oauth-common.auth.strategy.ts @@ -22,7 +22,7 @@ export class MicrosoftAPIsOauthCommonStrategy extends PassportStrategy( super({ clientID: environmentService.get('AUTH_MICROSOFT_CLIENT_ID'), clientSecret: environmentService.get('AUTH_MICROSOFT_CLIENT_SECRET'), - tenant: environmentService.get('AUTH_MICROSOFT_TENANT_ID'), + tenant: 'common', callbackURL: environmentService.get('AUTH_MICROSOFT_APIS_CALLBACK_URL'), scope: scopes, passReqToCallback: true, diff --git a/packages/twenty-server/src/engine/core-modules/auth/strategies/microsoft.auth.strategy.ts b/packages/twenty-server/src/engine/core-modules/auth/strategies/microsoft.auth.strategy.ts index 4460d906b327..cbe7231d9651 100644 --- a/packages/twenty-server/src/engine/core-modules/auth/strategies/microsoft.auth.strategy.ts +++ b/packages/twenty-server/src/engine/core-modules/auth/strategies/microsoft.auth.strategy.ts @@ -32,7 +32,7 @@ export class MicrosoftStrategy extends PassportStrategy(Strategy, 'microsoft') { clientID: environmentService.get('AUTH_MICROSOFT_CLIENT_ID'), clientSecret: environmentService.get('AUTH_MICROSOFT_CLIENT_SECRET'), callbackURL: environmentService.get('AUTH_MICROSOFT_CALLBACK_URL'), - tenant: environmentService.get('AUTH_MICROSOFT_TENANT_ID'), + tenant: 'common', scope: ['user.read'], passReqToCallback: true, }); diff --git a/packages/twenty-server/src/engine/core-modules/auth/utils/get-google-apis-oauth-scopes.ts b/packages/twenty-server/src/engine/core-modules/auth/utils/get-google-apis-oauth-scopes.ts index e532c3cdf405..aa94f12cf3ae 100644 --- a/packages/twenty-server/src/engine/core-modules/auth/utils/get-google-apis-oauth-scopes.ts +++ b/packages/twenty-server/src/engine/core-modules/auth/utils/get-google-apis-oauth-scopes.ts @@ -1,17 +1,10 @@ -export const getGoogleApisOauthScopes = ( - isGmailSendEmailScopeEnabled = false, -) => { - const scopes = [ +export const getGoogleApisOauthScopes = () => { + return [ 'email', 'profile', 'https://www.googleapis.com/auth/gmail.readonly', 'https://www.googleapis.com/auth/calendar.events', 'https://www.googleapis.com/auth/profile.emails.read', + 'https://www.googleapis.com/auth/gmail.send', ]; - - if (isGmailSendEmailScopeEnabled) { - scopes.push('https://www.googleapis.com/auth/gmail.send'); - } - - return scopes; }; diff --git a/packages/twenty-server/src/engine/core-modules/auth/utils/get-microsoft-apis-oauth-scopes.ts b/packages/twenty-server/src/engine/core-modules/auth/utils/get-microsoft-apis-oauth-scopes.ts index e8353e88b5e5..0daa2c3b7ef8 100644 --- a/packages/twenty-server/src/engine/core-modules/auth/utils/get-microsoft-apis-oauth-scopes.ts +++ b/packages/twenty-server/src/engine/core-modules/auth/utils/get-microsoft-apis-oauth-scopes.ts @@ -6,6 +6,7 @@ export const getMicrosoftApisOauthScopes = () => { 'offline_access', 'Mail.Read', 'Calendars.Read', + 'User.Read', ]; return scopes; diff --git a/packages/twenty-server/src/engine/core-modules/billing/billing.controller.ts b/packages/twenty-server/src/engine/core-modules/billing/billing.controller.ts index 19c95edefeb8..e35b8743b1d6 100644 --- a/packages/twenty-server/src/engine/core-modules/billing/billing.controller.ts +++ b/packages/twenty-server/src/engine/core-modules/billing/billing.controller.ts @@ -19,11 +19,11 @@ import { import { BillingWebhookEvent } from 'src/engine/core-modules/billing/enums/billing-webhook-events.enum'; import { BillingRestApiExceptionFilter } from 'src/engine/core-modules/billing/filters/billing-api-exception.filter'; import { BillingSubscriptionService } from 'src/engine/core-modules/billing/services/billing-subscription.service'; -import { BillingWebhookEntitlementService } from 'src/engine/core-modules/billing/services/billing-webhook-entitlement.service'; -import { BillingWebhookPriceService } from 'src/engine/core-modules/billing/services/billing-webhook-price.service'; -import { BillingWebhookProductService } from 'src/engine/core-modules/billing/services/billing-webhook-product.service'; -import { BillingWebhookSubscriptionService } from 'src/engine/core-modules/billing/services/billing-webhook-subscription.service'; import { StripeWebhookService } from 'src/engine/core-modules/billing/stripe/services/stripe-webhook.service'; +import { BillingWebhookEntitlementService } from 'src/engine/core-modules/billing/webhooks/services/billing-webhook-entitlement.service'; +import { BillingWebhookPriceService } from 'src/engine/core-modules/billing/webhooks/services/billing-webhook-price.service'; +import { BillingWebhookProductService } from 'src/engine/core-modules/billing/webhooks/services/billing-webhook-product.service'; +import { BillingWebhookSubscriptionService } from 'src/engine/core-modules/billing/webhooks/services/billing-webhook-subscription.service'; @Controller('billing') @UseFilters(BillingRestApiExceptionFilter) export class BillingController { diff --git a/packages/twenty-server/src/engine/core-modules/billing/billing.exception.ts b/packages/twenty-server/src/engine/core-modules/billing/billing.exception.ts index 093b0c481028..e5d6230853e4 100644 --- a/packages/twenty-server/src/engine/core-modules/billing/billing.exception.ts +++ b/packages/twenty-server/src/engine/core-modules/billing/billing.exception.ts @@ -11,6 +11,8 @@ export class BillingException extends CustomException { export enum BillingExceptionCode { BILLING_CUSTOMER_NOT_FOUND = 'BILLING_CUSTOMER_NOT_FOUND', + BILLING_PLAN_NOT_FOUND = 'BILLING_PLAN_NOT_FOUND', BILLING_PRODUCT_NOT_FOUND = 'BILLING_PRODUCT_NOT_FOUND', + BILLING_PRICE_NOT_FOUND = 'BILLING_PRICE_NOT_FOUND', BILLING_SUBSCRIPTION_EVENT_WORKSPACE_NOT_FOUND = 'BILLING_SUBSCRIPTION_EVENT_WORKSPACE_NOT_FOUND', } diff --git a/packages/twenty-server/src/engine/core-modules/billing/billing.module.ts b/packages/twenty-server/src/engine/core-modules/billing/billing.module.ts index 3dddad725acb..ebab7647ee61 100644 --- a/packages/twenty-server/src/engine/core-modules/billing/billing.module.ts +++ b/packages/twenty-server/src/engine/core-modules/billing/billing.module.ts @@ -14,14 +14,15 @@ import { BillingSubscriptionItem } from 'src/engine/core-modules/billing/entitie import { BillingSubscription } from 'src/engine/core-modules/billing/entities/billing-subscription.entity'; import { BillingRestApiExceptionFilter } from 'src/engine/core-modules/billing/filters/billing-api-exception.filter'; import { BillingWorkspaceMemberListener } from 'src/engine/core-modules/billing/listeners/billing-workspace-member.listener'; +import { BillingPlanService } from 'src/engine/core-modules/billing/services/billing-plan.service'; import { BillingPortalWorkspaceService } from 'src/engine/core-modules/billing/services/billing-portal.workspace-service'; import { BillingSubscriptionService } from 'src/engine/core-modules/billing/services/billing-subscription.service'; -import { BillingWebhookEntitlementService } from 'src/engine/core-modules/billing/services/billing-webhook-entitlement.service'; -import { BillingWebhookPriceService } from 'src/engine/core-modules/billing/services/billing-webhook-price.service'; -import { BillingWebhookProductService } from 'src/engine/core-modules/billing/services/billing-webhook-product.service'; -import { BillingWebhookSubscriptionService } from 'src/engine/core-modules/billing/services/billing-webhook-subscription.service'; import { BillingService } from 'src/engine/core-modules/billing/services/billing.service'; import { StripeModule } from 'src/engine/core-modules/billing/stripe/stripe.module'; +import { BillingWebhookEntitlementService } from 'src/engine/core-modules/billing/webhooks/services/billing-webhook-entitlement.service'; +import { BillingWebhookPriceService } from 'src/engine/core-modules/billing/webhooks/services/billing-webhook-price.service'; +import { BillingWebhookProductService } from 'src/engine/core-modules/billing/webhooks/services/billing-webhook-product.service'; +import { BillingWebhookSubscriptionService } from 'src/engine/core-modules/billing/webhooks/services/billing-webhook-subscription.service'; import { DomainManagerModule } from 'src/engine/core-modules/domain-manager/domain-manager.module'; import { FeatureFlagEntity } from 'src/engine/core-modules/feature-flag/feature-flag.entity'; import { FeatureFlagModule } from 'src/engine/core-modules/feature-flag/feature-flag.module'; @@ -56,6 +57,7 @@ import { Workspace } from 'src/engine/core-modules/workspace/workspace.entity'; BillingWebhookEntitlementService, BillingPortalWorkspaceService, BillingResolver, + BillingPlanService, BillingWorkspaceMemberListener, BillingService, BillingWebhookProductService, diff --git a/packages/twenty-server/src/engine/core-modules/billing/billing.resolver.ts b/packages/twenty-server/src/engine/core-modules/billing/billing.resolver.ts index 34d55e27769b..99e5540dc7ac 100644 --- a/packages/twenty-server/src/engine/core-modules/billing/billing.resolver.ts +++ b/packages/twenty-server/src/engine/core-modules/billing/billing.resolver.ts @@ -1,16 +1,25 @@ import { UseGuards } from '@nestjs/common'; import { Args, Mutation, Query, Resolver } from '@nestjs/graphql'; -import { BillingSessionInput } from 'src/engine/core-modules/billing/dto/billing-session.input'; -import { CheckoutSessionInput } from 'src/engine/core-modules/billing/dto/checkout-session.input'; -import { ProductPricesEntity } from 'src/engine/core-modules/billing/dto/product-prices.entity'; -import { ProductInput } from 'src/engine/core-modules/billing/dto/product.input'; -import { SessionEntity } from 'src/engine/core-modules/billing/dto/session.entity'; -import { UpdateBillingEntity } from 'src/engine/core-modules/billing/dto/update-billing.entity'; +import { GraphQLError } from 'graphql'; + +import { BillingCheckoutSessionInput } from 'src/engine/core-modules/billing/dtos/inputs/billing-checkout-session.input'; +import { BillingProductInput } from 'src/engine/core-modules/billing/dtos/inputs/billing-product.input'; +import { BillingSessionInput } from 'src/engine/core-modules/billing/dtos/inputs/billing-session.input'; +import { BillingPlanOutput } from 'src/engine/core-modules/billing/dtos/outputs/billing-plan.output'; +import { BillingProductPricesOutput } from 'src/engine/core-modules/billing/dtos/outputs/billing-product-prices.output'; +import { BillingSessionOutput } from 'src/engine/core-modules/billing/dtos/outputs/billing-session.output'; +import { BillingUpdateOutput } from 'src/engine/core-modules/billing/dtos/outputs/billing-update.output'; import { AvailableProduct } from 'src/engine/core-modules/billing/enums/billing-available-product.enum'; +import { BillingPlanKey } from 'src/engine/core-modules/billing/enums/billing-plan-key.enum'; +import { BillingPlanService } from 'src/engine/core-modules/billing/services/billing-plan.service'; import { BillingPortalWorkspaceService } from 'src/engine/core-modules/billing/services/billing-portal.workspace-service'; import { BillingSubscriptionService } from 'src/engine/core-modules/billing/services/billing-subscription.service'; import { StripePriceService } from 'src/engine/core-modules/billing/stripe/services/stripe-price.service'; +import { BillingPortalCheckoutSessionParameters } from 'src/engine/core-modules/billing/types/billing-portal-checkout-session-parameters.type'; +import { formatBillingDatabaseProductToGraphqlDTO } from 'src/engine/core-modules/billing/utils/format-database-product-to-graphql-dto.util'; +import { FeatureFlagKey } from 'src/engine/core-modules/feature-flag/enums/feature-flag-key.enum'; +import { FeatureFlagService } from 'src/engine/core-modules/feature-flag/services/feature-flag.service'; import { User } from 'src/engine/core-modules/user/user.entity'; import { Workspace } from 'src/engine/core-modules/workspace/workspace.entity'; import { AuthUser } from 'src/engine/decorators/auth/auth-user.decorator'; @@ -24,20 +33,26 @@ export class BillingResolver { private readonly billingSubscriptionService: BillingSubscriptionService, private readonly billingPortalWorkspaceService: BillingPortalWorkspaceService, private readonly stripePriceService: StripePriceService, + private readonly billingPlanService: BillingPlanService, + private readonly featureFlagService: FeatureFlagService, ) {} - @Query(() => ProductPricesEntity) - async getProductPrices(@Args() { product }: ProductInput) { + @Query(() => BillingProductPricesOutput) + @UseGuards(WorkspaceAuthGuard) + async getProductPrices( + @AuthWorkspace() workspace: Workspace, + @Args() { product }: BillingProductInput, + ) { const productPrices = await this.stripePriceService.getStripePrices(product); return { totalNumberOfPrices: productPrices.length, - productPrices: productPrices, + productPrices, }; } - @Query(() => SessionEntity) + @Query(() => BillingSessionOutput) @UseGuards(WorkspaceAuthGuard) async billingPortalSession( @AuthWorkspace() workspace: Workspace, @@ -51,7 +66,7 @@ export class BillingResolver { }; } - @Mutation(() => SessionEntity) + @Mutation(() => BillingSessionOutput) @UseGuards(WorkspaceAuthGuard, UserAuthGuard) async checkoutSession( @AuthWorkspace() workspace: Workspace, @@ -62,36 +77,73 @@ export class BillingResolver { successUrlPath, plan, requirePaymentMethod, - }: CheckoutSessionInput, + }: BillingCheckoutSessionInput, ) { + const isBillingPlansEnabled = + await this.featureFlagService.isFeatureEnabled( + FeatureFlagKey.IsBillingPlansEnabled, + workspace.id, + ); + + const checkoutSessionParams: BillingPortalCheckoutSessionParameters = { + user, + workspace, + successUrlPath, + plan: plan ?? BillingPlanKey.PRO, + requirePaymentMethod, + }; + + if (isBillingPlansEnabled) { + const billingPricesPerPlan = + await this.billingPlanService.getPricesPerPlan({ + planKey: checkoutSessionParams.plan, + interval: recurringInterval, + }); + const checkoutSessionURL = + await this.billingPortalWorkspaceService.computeCheckoutSessionURL({ + ...checkoutSessionParams, + billingPricesPerPlan, + }); + + return { + url: checkoutSessionURL, + }; + } + const productPrice = await this.stripePriceService.getStripePrice( AvailableProduct.BasePlan, recurringInterval, ); if (!productPrice) { - throw new Error( + throw new GraphQLError( 'Product price not found for the given recurring interval', ); } + const checkoutSessionURL = + await this.billingPortalWorkspaceService.computeCheckoutSessionURL({ + ...checkoutSessionParams, + priceId: productPrice.stripePriceId, + }); return { - url: await this.billingPortalWorkspaceService.computeCheckoutSessionURL( - user, - workspace, - productPrice.stripePriceId, - successUrlPath, - plan, - requirePaymentMethod, - ), + url: checkoutSessionURL, }; } - @Mutation(() => UpdateBillingEntity) + @Mutation(() => BillingUpdateOutput) @UseGuards(WorkspaceAuthGuard) async updateBillingSubscription(@AuthWorkspace() workspace: Workspace) { await this.billingSubscriptionService.applyBillingSubscription(workspace); return { success: true }; } + + @Query(() => [BillingPlanOutput]) + @UseGuards(WorkspaceAuthGuard) + async plans(): Promise { + const plans = await this.billingPlanService.getPlans(); + + return plans.map(formatBillingDatabaseProductToGraphqlDTO); + } } diff --git a/packages/twenty-server/src/engine/core-modules/billing/commands/billing-sync-plans-data.command.ts b/packages/twenty-server/src/engine/core-modules/billing/commands/billing-sync-plans-data.command.ts index a220c6a5954b..73f0a63cc4cf 100644 --- a/packages/twenty-server/src/engine/core-modules/billing/commands/billing-sync-plans-data.command.ts +++ b/packages/twenty-server/src/engine/core-modules/billing/commands/billing-sync-plans-data.command.ts @@ -15,9 +15,9 @@ import { StripeBillingMeterService } from 'src/engine/core-modules/billing/strip import { StripePriceService } from 'src/engine/core-modules/billing/stripe/services/stripe-price.service'; import { StripeProductService } from 'src/engine/core-modules/billing/stripe/services/stripe-product.service'; import { isStripeValidProductMetadata } from 'src/engine/core-modules/billing/utils/is-stripe-valid-product-metadata.util'; -import { transformStripeMeterDataToMeterRepositoryData } from 'src/engine/core-modules/billing/utils/transform-stripe-meter-data-to-meter-repository-data.util'; -import { transformStripePriceDataToPriceRepositoryData } from 'src/engine/core-modules/billing/utils/transform-stripe-price-data-to-price-repository-data.util'; -import { transformStripeProductDataToProductRepositoryData } from 'src/engine/core-modules/billing/utils/transform-stripe-product-data-to-product-repository-data.util'; +import { transformStripeMeterToDatabaseMeter } from 'src/engine/core-modules/billing/utils/transform-stripe-meter-to-database-meter.util'; +import { transformStripePriceToDatabasePrice } from 'src/engine/core-modules/billing/utils/transform-stripe-price-to-database-price.util'; +import { transformStripeProductToDatabaseProduct } from 'src/engine/core-modules/billing/utils/transform-stripe-product-to-database-product.util'; @Command({ name: 'billing:sync-plans-data', description: @@ -47,7 +47,7 @@ export class BillingSyncPlansDataCommand extends BaseCommandRunner { try { if (!options.dryRun) { await this.billingMeterRepository.upsert( - transformStripeMeterDataToMeterRepositoryData(meter), + transformStripeMeterToDatabaseMeter(meter), { conflictPaths: ['stripeMeterId'], }, @@ -67,7 +67,7 @@ export class BillingSyncPlansDataCommand extends BaseCommandRunner { try { if (!options.dryRun) { await this.billingProductRepository.upsert( - transformStripeProductDataToProductRepositoryData(product), + transformStripeProductToDatabaseProduct(product), { conflictPaths: ['stripeProductId'], }, @@ -148,9 +148,7 @@ export class BillingSyncPlansDataCommand extends BaseCommandRunner { options, ); const transformedPrices = billingPrices.flatMap((prices) => - prices.map((price) => - transformStripePriceDataToPriceRepositoryData(price), - ), + prices.map((price) => transformStripePriceToDatabasePrice(price)), ); this.logger.log(`Upserting ${transformedPrices.length} transformed prices`); diff --git a/packages/twenty-server/src/engine/core-modules/billing/dto/product-prices.entity.ts b/packages/twenty-server/src/engine/core-modules/billing/dto/product-prices.entity.ts deleted file mode 100644 index 02efc4174929..000000000000 --- a/packages/twenty-server/src/engine/core-modules/billing/dto/product-prices.entity.ts +++ /dev/null @@ -1,12 +0,0 @@ -import { Field, Int, ObjectType } from '@nestjs/graphql'; - -import { ProductPriceEntity } from 'src/engine/core-modules/billing/dto/product-price.entity'; - -@ObjectType() -export class ProductPricesEntity { - @Field(() => Int) - totalNumberOfPrices: number; - - @Field(() => [ProductPriceEntity]) - productPrices: ProductPriceEntity[]; -} diff --git a/packages/twenty-server/src/engine/core-modules/billing/dtos/billing-price-licensed.dto.ts b/packages/twenty-server/src/engine/core-modules/billing/dtos/billing-price-licensed.dto.ts new file mode 100644 index 000000000000..b6fc10d97728 --- /dev/null +++ b/packages/twenty-server/src/engine/core-modules/billing/dtos/billing-price-licensed.dto.ts @@ -0,0 +1,15 @@ +import { Field, ObjectType } from '@nestjs/graphql'; + +import { SubscriptionInterval } from 'src/engine/core-modules/billing/enums/billing-subscription-interval.enum'; + +@ObjectType() +export class BillingPriceLicensedDTO { + @Field(() => SubscriptionInterval) + recurringInterval: SubscriptionInterval; + + @Field(() => Number) + unitAmount: number; + + @Field(() => String) + stripePriceId: string; +} diff --git a/packages/twenty-server/src/engine/core-modules/billing/dtos/billing-price-metered.dto.ts b/packages/twenty-server/src/engine/core-modules/billing/dtos/billing-price-metered.dto.ts new file mode 100644 index 000000000000..84d7398c869f --- /dev/null +++ b/packages/twenty-server/src/engine/core-modules/billing/dtos/billing-price-metered.dto.ts @@ -0,0 +1,20 @@ +import { Field, ObjectType } from '@nestjs/graphql'; + +import { BillingPriceTierDTO } from 'src/engine/core-modules/billing/dtos/billing-price-tier.dto'; +import { BillingPriceTiersMode } from 'src/engine/core-modules/billing/enums/billing-price-tiers-mode.enum'; +import { SubscriptionInterval } from 'src/engine/core-modules/billing/enums/billing-subscription-interval.enum'; + +@ObjectType() +export class BillingPriceMeteredDTO { + @Field(() => BillingPriceTiersMode, { nullable: true }) + tiersMode: BillingPriceTiersMode.GRADUATED | null; + + @Field(() => [BillingPriceTierDTO], { nullable: true }) + tiers: BillingPriceTierDTO[]; + + @Field(() => SubscriptionInterval) + recurringInterval: SubscriptionInterval; + + @Field(() => String) + stripePriceId: string; +} diff --git a/packages/twenty-server/src/engine/core-modules/billing/dtos/billing-price-tier.dto.ts b/packages/twenty-server/src/engine/core-modules/billing/dtos/billing-price-tier.dto.ts new file mode 100644 index 000000000000..6765dd1c189c --- /dev/null +++ b/packages/twenty-server/src/engine/core-modules/billing/dtos/billing-price-tier.dto.ts @@ -0,0 +1,13 @@ +import { Field, ObjectType } from '@nestjs/graphql'; + +@ObjectType() +export class BillingPriceTierDTO { + @Field(() => Number, { nullable: true }) + upTo: number | null; + + @Field(() => Number, { nullable: true }) + flatAmount: number | null; + + @Field(() => Number, { nullable: true }) + unitAmount: number | null; +} diff --git a/packages/twenty-server/src/engine/core-modules/billing/dtos/billing-price-union.dto.ts b/packages/twenty-server/src/engine/core-modules/billing/dtos/billing-price-union.dto.ts new file mode 100644 index 000000000000..9767818770fc --- /dev/null +++ b/packages/twenty-server/src/engine/core-modules/billing/dtos/billing-price-union.dto.ts @@ -0,0 +1,16 @@ +import { createUnionType } from '@nestjs/graphql'; + +import { BillingPriceLicensedDTO } from 'src/engine/core-modules/billing/dtos/billing-price-licensed.dto'; +import { BillingPriceMeteredDTO } from 'src/engine/core-modules/billing/dtos/billing-price-metered.dto'; + +export const BillingPriceUnionDTO = createUnionType({ + name: 'BillingPriceUnionDTO', + types: () => [BillingPriceLicensedDTO, BillingPriceMeteredDTO], + resolveType(value) { + if ('unitAmount' in value) { + return BillingPriceLicensedDTO; + } + + return BillingPriceMeteredDTO; + }, +}); diff --git a/packages/twenty-server/src/engine/core-modules/billing/dto/product-price.entity.ts b/packages/twenty-server/src/engine/core-modules/billing/dtos/billing-product-price.dto.ts similarity index 92% rename from packages/twenty-server/src/engine/core-modules/billing/dto/product-price.entity.ts rename to packages/twenty-server/src/engine/core-modules/billing/dtos/billing-product-price.dto.ts index 011d880b2af9..6403d233d957 100644 --- a/packages/twenty-server/src/engine/core-modules/billing/dto/product-price.entity.ts +++ b/packages/twenty-server/src/engine/core-modules/billing/dtos/billing-product-price.dto.ts @@ -4,7 +4,7 @@ import Stripe from 'stripe'; import { SubscriptionInterval } from 'src/engine/core-modules/billing/enums/billing-subscription-interval.enum'; @ObjectType() -export class ProductPriceEntity { +export class BillingProductPriceDTO { @Field(() => SubscriptionInterval) recurringInterval: Stripe.Price.Recurring.Interval; diff --git a/packages/twenty-server/src/engine/core-modules/billing/dtos/billing-product.dto.ts b/packages/twenty-server/src/engine/core-modules/billing/dtos/billing-product.dto.ts new file mode 100644 index 000000000000..391a1ed27ce1 --- /dev/null +++ b/packages/twenty-server/src/engine/core-modules/billing/dtos/billing-product.dto.ts @@ -0,0 +1,24 @@ +import { Field, ObjectType } from '@nestjs/graphql'; + +import { BillingPriceLicensedDTO } from 'src/engine/core-modules/billing/dtos/billing-price-licensed.dto'; +import { BillingPriceMeteredDTO } from 'src/engine/core-modules/billing/dtos/billing-price-metered.dto'; +import { BillingPriceUnionDTO } from 'src/engine/core-modules/billing/dtos/billing-price-union.dto'; +import { BillingUsageType } from 'src/engine/core-modules/billing/enums/billing-usage-type.enum'; + +@ObjectType() +export class BillingProductDTO { + @Field(() => String) + name: string; + + @Field(() => String) + description: string; + + @Field(() => [String], { nullable: true }) + images: string[]; + + @Field(() => BillingUsageType) + type: BillingUsageType; + + @Field(() => [BillingPriceUnionDTO], { nullable: 'items' }) + prices: Array; +} diff --git a/packages/twenty-server/src/engine/core-modules/billing/dto/trial-period.dto.ts b/packages/twenty-server/src/engine/core-modules/billing/dtos/billing-trial-period.dto.ts similarity index 85% rename from packages/twenty-server/src/engine/core-modules/billing/dto/trial-period.dto.ts rename to packages/twenty-server/src/engine/core-modules/billing/dtos/billing-trial-period.dto.ts index f8f9b8a5a53e..e78cadd197b7 100644 --- a/packages/twenty-server/src/engine/core-modules/billing/dto/trial-period.dto.ts +++ b/packages/twenty-server/src/engine/core-modules/billing/dtos/billing-trial-period.dto.ts @@ -3,7 +3,7 @@ import { Field, ObjectType } from '@nestjs/graphql'; import { Min } from 'class-validator'; @ObjectType() -export class TrialPeriodDTO { +export class BillingTrialPeriodDTO { @Field(() => Number) @Min(0) duration: number; diff --git a/packages/twenty-server/src/engine/core-modules/billing/dto/checkout-session.input.ts b/packages/twenty-server/src/engine/core-modules/billing/dtos/inputs/billing-checkout-session.input.ts similarity index 95% rename from packages/twenty-server/src/engine/core-modules/billing/dto/checkout-session.input.ts rename to packages/twenty-server/src/engine/core-modules/billing/dtos/inputs/billing-checkout-session.input.ts index b5c882be2b66..6efa6ca03257 100644 --- a/packages/twenty-server/src/engine/core-modules/billing/dto/checkout-session.input.ts +++ b/packages/twenty-server/src/engine/core-modules/billing/dtos/inputs/billing-checkout-session.input.ts @@ -12,7 +12,7 @@ import { BillingPlanKey } from 'src/engine/core-modules/billing/enums/billing-pl import { SubscriptionInterval } from 'src/engine/core-modules/billing/enums/billing-subscription-interval.enum'; @ArgsType() -export class CheckoutSessionInput { +export class BillingCheckoutSessionInput { @Field(() => SubscriptionInterval) @IsEnum(SubscriptionInterval) @IsNotEmpty() diff --git a/packages/twenty-server/src/engine/core-modules/billing/dto/product.input.ts b/packages/twenty-server/src/engine/core-modules/billing/dtos/inputs/billing-product.input.ts similarity index 89% rename from packages/twenty-server/src/engine/core-modules/billing/dto/product.input.ts rename to packages/twenty-server/src/engine/core-modules/billing/dtos/inputs/billing-product.input.ts index 126e1351d0ec..e3eade45ade8 100644 --- a/packages/twenty-server/src/engine/core-modules/billing/dto/product.input.ts +++ b/packages/twenty-server/src/engine/core-modules/billing/dtos/inputs/billing-product.input.ts @@ -5,7 +5,7 @@ import { IsNotEmpty, IsString } from 'class-validator'; import { AvailableProduct } from 'src/engine/core-modules/billing/enums/billing-available-product.enum'; @ArgsType() -export class ProductInput { +export class BillingProductInput { @Field(() => String) @IsString() @IsNotEmpty() diff --git a/packages/twenty-server/src/engine/core-modules/billing/dto/billing-session.input.ts b/packages/twenty-server/src/engine/core-modules/billing/dtos/inputs/billing-session.input.ts similarity index 100% rename from packages/twenty-server/src/engine/core-modules/billing/dto/billing-session.input.ts rename to packages/twenty-server/src/engine/core-modules/billing/dtos/inputs/billing-session.input.ts diff --git a/packages/twenty-server/src/engine/core-modules/billing/dtos/outputs/billing-plan.output.ts b/packages/twenty-server/src/engine/core-modules/billing/dtos/outputs/billing-plan.output.ts new file mode 100644 index 000000000000..765d38cbe399 --- /dev/null +++ b/packages/twenty-server/src/engine/core-modules/billing/dtos/outputs/billing-plan.output.ts @@ -0,0 +1,19 @@ +import { Field, ObjectType } from '@nestjs/graphql'; + +import { BillingProductDTO } from 'src/engine/core-modules/billing/dtos/billing-product.dto'; +import { BillingPlanKey } from 'src/engine/core-modules/billing/enums/billing-plan-key.enum'; + +@ObjectType() +export class BillingPlanOutput { + @Field(() => BillingPlanKey) + planKey: BillingPlanKey; + + @Field(() => BillingProductDTO) + baseProduct: BillingProductDTO; + + @Field(() => [BillingProductDTO]) + otherLicensedProducts: BillingProductDTO[]; + + @Field(() => [BillingProductDTO]) + meteredProducts: BillingProductDTO[]; +} diff --git a/packages/twenty-server/src/engine/core-modules/billing/dtos/outputs/billing-product-prices.output.ts b/packages/twenty-server/src/engine/core-modules/billing/dtos/outputs/billing-product-prices.output.ts new file mode 100644 index 000000000000..c80bc23d9ac0 --- /dev/null +++ b/packages/twenty-server/src/engine/core-modules/billing/dtos/outputs/billing-product-prices.output.ts @@ -0,0 +1,12 @@ +import { Field, Int, ObjectType } from '@nestjs/graphql'; + +import { BillingProductPriceDTO } from 'src/engine/core-modules/billing/dtos/billing-product-price.dto'; + +@ObjectType() +export class BillingProductPricesOutput { + @Field(() => Int) + totalNumberOfPrices: number; + + @Field(() => [BillingProductPriceDTO]) + productPrices: BillingProductPriceDTO[]; +} diff --git a/packages/twenty-server/src/engine/core-modules/billing/dto/session.entity.ts b/packages/twenty-server/src/engine/core-modules/billing/dtos/outputs/billing-session.output.ts similarity index 78% rename from packages/twenty-server/src/engine/core-modules/billing/dto/session.entity.ts rename to packages/twenty-server/src/engine/core-modules/billing/dtos/outputs/billing-session.output.ts index 745a7364b749..a07ebf7a9c55 100644 --- a/packages/twenty-server/src/engine/core-modules/billing/dto/session.entity.ts +++ b/packages/twenty-server/src/engine/core-modules/billing/dtos/outputs/billing-session.output.ts @@ -1,7 +1,7 @@ import { Field, ObjectType } from '@nestjs/graphql'; @ObjectType() -export class SessionEntity { +export class BillingSessionOutput { @Field(() => String, { nullable: true }) url: string; } diff --git a/packages/twenty-server/src/engine/core-modules/billing/dto/update-billing.entity.ts b/packages/twenty-server/src/engine/core-modules/billing/dtos/outputs/billing-update.output.ts similarity index 84% rename from packages/twenty-server/src/engine/core-modules/billing/dto/update-billing.entity.ts rename to packages/twenty-server/src/engine/core-modules/billing/dtos/outputs/billing-update.output.ts index ae8f8660d0f0..fd57062c7410 100644 --- a/packages/twenty-server/src/engine/core-modules/billing/dto/update-billing.entity.ts +++ b/packages/twenty-server/src/engine/core-modules/billing/dtos/outputs/billing-update.output.ts @@ -1,7 +1,7 @@ import { Field, ObjectType } from '@nestjs/graphql'; @ObjectType() -export class UpdateBillingEntity { +export class BillingUpdateOutput { @Field(() => Boolean, { description: 'Boolean that confirms query was successful', }) diff --git a/packages/twenty-server/src/engine/core-modules/billing/entities/billing-product.entity.ts b/packages/twenty-server/src/engine/core-modules/billing/entities/billing-product.entity.ts index 7019d38ec159..a226c8fdaffa 100644 --- a/packages/twenty-server/src/engine/core-modules/billing/entities/billing-product.entity.ts +++ b/packages/twenty-server/src/engine/core-modules/billing/entities/billing-product.entity.ts @@ -32,8 +32,8 @@ export class BillingProduct { @Column({ nullable: false }) active: boolean; - @Column({ nullable: true, type: 'text' }) - description: string | null; + @Column({ nullable: false, type: 'text', default: '' }) + description: string; @Column({ nullable: false }) name: string; diff --git a/packages/twenty-server/src/engine/core-modules/billing/enums/billing-price-tiers-mode.enum.ts b/packages/twenty-server/src/engine/core-modules/billing/enums/billing-price-tiers-mode.enum.ts index 2d54ac874063..f7372e1faeec 100644 --- a/packages/twenty-server/src/engine/core-modules/billing/enums/billing-price-tiers-mode.enum.ts +++ b/packages/twenty-server/src/engine/core-modules/billing/enums/billing-price-tiers-mode.enum.ts @@ -1,4 +1,10 @@ +import { registerEnumType } from '@nestjs/graphql'; + export enum BillingPriceTiersMode { GRADUATED = 'GRADUATED', VOLUME = 'VOLUME', } +registerEnumType(BillingPriceTiersMode, { + name: 'BillingPriceTiersMode', + description: 'The different billing price tiers modes', +}); diff --git a/packages/twenty-server/src/engine/core-modules/billing/services/billing-plan.service.ts b/packages/twenty-server/src/engine/core-modules/billing/services/billing-plan.service.ts new file mode 100644 index 000000000000..cecf60a80630 --- /dev/null +++ b/packages/twenty-server/src/engine/core-modules/billing/services/billing-plan.service.ts @@ -0,0 +1,153 @@ +import { Injectable, Logger } from '@nestjs/common'; +import { InjectRepository } from '@nestjs/typeorm'; + +import { Repository } from 'typeorm'; + +import { + BillingException, + BillingExceptionCode, +} from 'src/engine/core-modules/billing/billing.exception'; +import { BillingProduct } from 'src/engine/core-modules/billing/entities/billing-product.entity'; +import { BillingPlanKey } from 'src/engine/core-modules/billing/enums/billing-plan-key.enum'; +import { SubscriptionInterval } from 'src/engine/core-modules/billing/enums/billing-subscription-interval.enum'; +import { BillingUsageType } from 'src/engine/core-modules/billing/enums/billing-usage-type.enum'; +import { BillingGetPlanResult } from 'src/engine/core-modules/billing/types/billing-get-plan-result.type'; +import { BillingGetPricesPerPlanResult } from 'src/engine/core-modules/billing/types/billing-get-prices-per-plan-result.type'; + +@Injectable() +export class BillingPlanService { + protected readonly logger = new Logger(BillingPlanService.name); + constructor( + @InjectRepository(BillingProduct, 'core') + private readonly billingProductRepository: Repository, + ) {} + + async getProductsByProductMetadata({ + planKey, + priceUsageBased, + isBaseProduct, + }: { + planKey: BillingPlanKey; + priceUsageBased: BillingUsageType; + isBaseProduct: 'true' | 'false'; + }): Promise { + const products = await this.billingProductRepository.find({ + where: { + metadata: { + planKey, + priceUsageBased, + isBaseProduct, + }, + active: true, + }, + relations: ['billingPrices'], + }); + + return products; + } + + async getPlanBaseProduct(planKey: BillingPlanKey): Promise { + const [baseProduct] = await this.getProductsByProductMetadata({ + planKey, + priceUsageBased: BillingUsageType.LICENSED, + isBaseProduct: 'true', + }); + + return baseProduct; + } + + async getPlans(): Promise { + const planKeys = Object.values(BillingPlanKey); + + const products = await this.billingProductRepository.find({ + where: { + active: true, + }, + relations: ['billingPrices'], + }); + + return planKeys.map((planKey) => { + const planProducts = products + .filter((product) => product.metadata.planKey === planKey) + .map((product) => { + return { + ...product, + billingPrices: product.billingPrices.filter( + (price) => price.active, + ), + }; + }); + const baseProduct = planProducts.find( + (product) => product.metadata.isBaseProduct === 'true', + ); + + if (!baseProduct) { + throw new BillingException( + 'Base product not found', + BillingExceptionCode.BILLING_PRODUCT_NOT_FOUND, + ); + } + + const meteredProducts = planProducts.filter( + (product) => + product.metadata.priceUsageBased === BillingUsageType.METERED, + ); + const otherLicensedProducts = planProducts.filter( + (product) => + product.metadata.priceUsageBased === BillingUsageType.LICENSED && + product.metadata.isBaseProduct === 'false', + ); + + return { + planKey, + baseProduct, + meteredProducts, + otherLicensedProducts, + }; + }); + } + + async getPricesPerPlan({ + planKey, + interval, + }: { + planKey: BillingPlanKey; + interval: SubscriptionInterval; + }): Promise { + const plans = await this.getPlans(); + const plan = plans.find((plan) => plan.planKey === planKey); + + if (!plan) { + throw new BillingException( + 'Billing plan not found', + BillingExceptionCode.BILLING_PLAN_NOT_FOUND, + ); + } + const { baseProduct, meteredProducts, otherLicensedProducts } = plan; + const baseProductPrice = baseProduct.billingPrices.find( + (price) => price.interval === interval, + ); + + if (!baseProductPrice) { + throw new BillingException( + 'Base product price not found for given interval', + BillingExceptionCode.BILLING_PRICE_NOT_FOUND, + ); + } + const filterPricesByInterval = (product: BillingProduct) => + product.billingPrices.filter((price) => price.interval === interval); + + const meteredProductsPrices = meteredProducts.flatMap( + filterPricesByInterval, + ); + const otherLicensedProductsPrices = otherLicensedProducts.flatMap( + filterPricesByInterval, + ); + + return { + baseProductPrice, + meteredProductsPrices, + otherLicensedProductsPrices, + }; + } +} diff --git a/packages/twenty-server/src/engine/core-modules/billing/services/billing-portal.workspace-service.ts b/packages/twenty-server/src/engine/core-modules/billing/services/billing-portal.workspace-service.ts index 850953825c3d..f95789a32c79 100644 --- a/packages/twenty-server/src/engine/core-modules/billing/services/billing-portal.workspace-service.ts +++ b/packages/twenty-server/src/engine/core-modules/billing/services/billing-portal.workspace-service.ts @@ -2,16 +2,22 @@ import { Injectable, Logger } from '@nestjs/common'; import { InjectRepository } from '@nestjs/typeorm'; import { isDefined } from 'class-validator'; +import Stripe from 'stripe'; import { Repository } from 'typeorm'; +import { + BillingException, + BillingExceptionCode, +} from 'src/engine/core-modules/billing/billing.exception'; import { BillingSubscription } from 'src/engine/core-modules/billing/entities/billing-subscription.entity'; -import { BillingPlanKey } from 'src/engine/core-modules/billing/enums/billing-plan-key.enum'; -import { BillingSubscriptionService } from 'src/engine/core-modules/billing/services/billing-subscription.service'; import { StripeBillingPortalService } from 'src/engine/core-modules/billing/stripe/services/stripe-billing-portal.service'; import { StripeCheckoutService } from 'src/engine/core-modules/billing/stripe/services/stripe-checkout.service'; +import { BillingGetPricesPerPlanResult } from 'src/engine/core-modules/billing/types/billing-get-prices-per-plan-result.type'; +import { BillingPortalCheckoutSessionParameters } from 'src/engine/core-modules/billing/types/billing-portal-checkout-session-parameters.type'; import { DomainManagerService } from 'src/engine/core-modules/domain-manager/service/domain-manager.service'; +import { FeatureFlagKey } from 'src/engine/core-modules/feature-flag/enums/feature-flag-key.enum'; +import { FeatureFlagService } from 'src/engine/core-modules/feature-flag/services/feature-flag.service'; import { UserWorkspace } from 'src/engine/core-modules/user-workspace/user-workspace.entity'; -import { User } from 'src/engine/core-modules/user/user.entity'; import { Workspace } from 'src/engine/core-modules/workspace/workspace.entity'; import { assert } from 'src/utils/assert'; @@ -22,21 +28,22 @@ export class BillingPortalWorkspaceService { private readonly stripeCheckoutService: StripeCheckoutService, private readonly stripeBillingPortalService: StripeBillingPortalService, private readonly domainManagerService: DomainManagerService, + private readonly featureFlagService: FeatureFlagService, @InjectRepository(BillingSubscription, 'core') private readonly billingSubscriptionRepository: Repository, @InjectRepository(UserWorkspace, 'core') private readonly userWorkspaceRepository: Repository, - private readonly billingSubscriptionService: BillingSubscriptionService, ) {} - async computeCheckoutSessionURL( - user: User, - workspace: Workspace, - priceId: string, - successUrlPath?: string, - plan?: BillingPlanKey, - requirePaymentMethod?: boolean, - ): Promise { + async computeCheckoutSessionURL({ + user, + workspace, + billingPricesPerPlan, + successUrlPath, + plan, + priceId, + requirePaymentMethod, + }: BillingPortalCheckoutSessionParameters): Promise { const frontBaseUrl = this.domainManagerService.buildWorkspaceURL({ subdomain: workspace.subdomain, }); @@ -56,23 +63,37 @@ export class BillingPortalWorkspaceService { }); const stripeCustomerId = subscription?.stripeCustomerId; + const isBillingPlansEnabled = + await this.featureFlagService.isFeatureEnabled( + FeatureFlagKey.IsBillingPlansEnabled, + workspace.id, + ); - const session = await this.stripeCheckoutService.createCheckoutSession({ - user, - workspaceId: workspace.id, - priceId, - quantity, - successUrl, - cancelUrl, - stripeCustomerId, - plan, - requirePaymentMethod, - withTrialPeriod: !isDefined(subscription), - }); + const stripeSubscriptionLineItems = + await this.getStripeSubscriptionLineItems({ + quantity, + isBillingPlansEnabled, + billingPricesPerPlan, + priceId, + }); + + const checkoutSession = + await this.stripeCheckoutService.createCheckoutSession({ + user, + workspaceId: workspace.id, + stripeSubscriptionLineItems, + successUrl, + cancelUrl, + stripeCustomerId, + plan, + requirePaymentMethod, + withTrialPeriod: !isDefined(subscription), + isBillingPlansEnabled, + }); - assert(session.url, 'Error: missing checkout.session.url'); + assert(checkoutSession.url, 'Error: missing checkout.session.url'); - return session.url; + return checkoutSession.url; } async computeBillingPortalSessionURLOrThrow( @@ -113,4 +134,39 @@ export class BillingPortalWorkspaceService { return session.url; } + + private getStripeSubscriptionLineItems({ + quantity, + isBillingPlansEnabled, + billingPricesPerPlan, + priceId, + }: { + quantity: number; + isBillingPlansEnabled: boolean; + billingPricesPerPlan?: BillingGetPricesPerPlanResult; + priceId?: string; + }): Stripe.Checkout.SessionCreateParams.LineItem[] { + if (isBillingPlansEnabled && billingPricesPerPlan) { + return [ + { + price: billingPricesPerPlan.baseProductPrice.stripePriceId, + quantity, + }, + ...billingPricesPerPlan.meteredProductsPrices.map((price) => ({ + price: price.stripePriceId, + })), + ]; + } + + if (priceId && !isBillingPlansEnabled) { + return [{ price: priceId, quantity }]; + } + + throw new BillingException( + isBillingPlansEnabled + ? 'Missing Billing prices per plan' + : 'Missing price id', + BillingExceptionCode.BILLING_PRICE_NOT_FOUND, + ); + } } diff --git a/packages/twenty-server/src/engine/core-modules/billing/services/billing-subscription.service.ts b/packages/twenty-server/src/engine/core-modules/billing/services/billing-subscription.service.ts index a43125e5bd2a..7378f3f8b22e 100644 --- a/packages/twenty-server/src/engine/core-modules/billing/services/billing-subscription.service.ts +++ b/packages/twenty-server/src/engine/core-modules/billing/services/billing-subscription.service.ts @@ -6,18 +6,25 @@ import assert from 'assert'; import Stripe from 'stripe'; import { Not, Repository } from 'typeorm'; +import { + BillingException, + BillingExceptionCode, +} from 'src/engine/core-modules/billing/billing.exception'; import { BillingEntitlement } from 'src/engine/core-modules/billing/entities/billing-entitlement.entity'; import { BillingSubscription } from 'src/engine/core-modules/billing/entities/billing-subscription.entity'; import { AvailableProduct } from 'src/engine/core-modules/billing/enums/billing-available-product.enum'; import { BillingEntitlementKey } from 'src/engine/core-modules/billing/enums/billing-entitlement-key.enum'; +import { BillingPlanKey } from 'src/engine/core-modules/billing/enums/billing-plan-key.enum'; import { SubscriptionInterval } from 'src/engine/core-modules/billing/enums/billing-subscription-interval.enum'; import { SubscriptionStatus } from 'src/engine/core-modules/billing/enums/billing-subscription-status.enum'; +import { BillingPlanService } from 'src/engine/core-modules/billing/services/billing-plan.service'; import { StripePriceService } from 'src/engine/core-modules/billing/stripe/services/stripe-price.service'; import { StripeSubscriptionItemService } from 'src/engine/core-modules/billing/stripe/services/stripe-subscription-item.service'; import { StripeSubscriptionService } from 'src/engine/core-modules/billing/stripe/services/stripe-subscription.service'; import { EnvironmentService } from 'src/engine/core-modules/environment/environment.service'; +import { FeatureFlagKey } from 'src/engine/core-modules/feature-flag/enums/feature-flag-key.enum'; +import { FeatureFlagService } from 'src/engine/core-modules/feature-flag/services/feature-flag.service'; import { Workspace } from 'src/engine/core-modules/workspace/workspace.entity'; - @Injectable() export class BillingSubscriptionService { protected readonly logger = new Logger(BillingSubscriptionService.name); @@ -25,7 +32,9 @@ export class BillingSubscriptionService { private readonly stripeSubscriptionService: StripeSubscriptionService, private readonly stripePriceService: StripePriceService, private readonly stripeSubscriptionItemService: StripeSubscriptionItemService, + private readonly billingPlanService: BillingPlanService, private readonly environmentService: EnvironmentService, + private readonly featureFlagService: FeatureFlagService, @InjectRepository(BillingEntitlement, 'core') private readonly billingEntitlementRepository: Repository, @InjectRepository(BillingSubscription, 'core') @@ -56,19 +65,37 @@ export class BillingSubscriptionService { 'BILLING_STRIPE_BASE_PLAN_PRODUCT_ID', ), ) { + const isBillingPlansEnabled = + await this.featureFlagService.isFeatureEnabled( + FeatureFlagKey.IsBillingPlansEnabled, + workspaceId, + ); + const billingSubscription = await this.getCurrentBillingSubscriptionOrThrow( { workspaceId }, ); + const getStripeProductId = isBillingPlansEnabled + ? (await this.billingPlanService.getPlanBaseProduct(BillingPlanKey.PRO)) + ?.stripeProductId + : stripeProductId; + + if (!getStripeProductId) { + throw new BillingException( + 'Base product not found', + BillingExceptionCode.BILLING_PRODUCT_NOT_FOUND, + ); + } + const billingSubscriptionItem = billingSubscription.billingSubscriptionItems.filter( (billingSubscriptionItem) => - billingSubscriptionItem.stripeProductId === stripeProductId, + billingSubscriptionItem.stripeProductId === getStripeProductId, )?.[0]; if (!billingSubscriptionItem) { throw new Error( - `Cannot find billingSubscriptionItem for product ${stripeProductId} for workspace ${workspaceId}`, + `Cannot find billingSubscriptionItem for product ${getStripeProductId} for workspace ${workspaceId}`, ); } @@ -127,7 +154,11 @@ export class BillingSubscriptionService { const billingSubscription = await this.getCurrentBillingSubscriptionOrThrow( { workspaceId: workspace.id }, ); - + const isBillingPlansEnabled = + await this.featureFlagService.isFeatureEnabled( + FeatureFlagKey.IsBillingPlansEnabled, + workspace.id, + ); const newInterval = billingSubscription?.interval === SubscriptionInterval.Year ? SubscriptionInterval.Month @@ -136,10 +167,29 @@ export class BillingSubscriptionService { const billingSubscriptionItem = await this.getCurrentBillingSubscriptionItemOrThrow(workspace.id); - const productPrice = await this.stripePriceService.getStripePrice( - AvailableProduct.BasePlan, - newInterval, - ); + let productPrice; + + if (isBillingPlansEnabled) { + const baseProduct = await this.billingPlanService.getPlanBaseProduct( + BillingPlanKey.PRO, + ); + + if (!baseProduct) { + throw new BillingException( + 'Base product not found', + BillingExceptionCode.BILLING_PRODUCT_NOT_FOUND, + ); + } + + productPrice = baseProduct.billingPrices.find( + (price) => price.interval === newInterval, + ); + } else { + productPrice = await this.stripePriceService.getStripePrice( + AvailableProduct.BasePlan, + newInterval, + ); + } if (!productPrice) { throw new Error( diff --git a/packages/twenty-server/src/engine/core-modules/billing/stripe/services/stripe-checkout.service.ts b/packages/twenty-server/src/engine/core-modules/billing/stripe/services/stripe-checkout.service.ts index bab412a8db94..82e23fab55bc 100644 --- a/packages/twenty-server/src/engine/core-modules/billing/stripe/services/stripe-checkout.service.ts +++ b/packages/twenty-server/src/engine/core-modules/billing/stripe/services/stripe-checkout.service.ts @@ -27,33 +27,28 @@ export class StripeCheckoutService { async createCheckoutSession({ user, workspaceId, - priceId, - quantity, + stripeSubscriptionLineItems, successUrl, cancelUrl, stripeCustomerId, plan = BillingPlanKey.PRO, requirePaymentMethod = true, withTrialPeriod, + isBillingPlansEnabled = false, }: { user: User; workspaceId: string; - priceId: string; - quantity: number; + stripeSubscriptionLineItems: Stripe.Checkout.SessionCreateParams.LineItem[]; successUrl?: string; cancelUrl?: string; stripeCustomerId?: string; plan?: BillingPlanKey; requirePaymentMethod?: boolean; withTrialPeriod: boolean; + isBillingPlansEnabled: boolean; }): Promise { return await this.stripe.checkout.sessions.create({ - line_items: [ - { - price: priceId, - quantity, - }, - ], + line_items: stripeSubscriptionLineItems, mode: 'subscription', subscription_data: { metadata: { @@ -68,7 +63,11 @@ export class StripeCheckoutService { : 'BILLING_FREE_TRIAL_WITHOUT_CREDIT_CARD_DURATION_IN_DAYS', ), trial_settings: { - end_behavior: { missing_payment_method: 'pause' }, + end_behavior: { + missing_payment_method: isBillingPlansEnabled + ? 'create_invoice' + : 'pause', + }, }, } : {}), diff --git a/packages/twenty-server/src/engine/core-modules/billing/stripe/services/stripe-price.service.ts b/packages/twenty-server/src/engine/core-modules/billing/stripe/services/stripe-price.service.ts index 83e4058f41e1..01a3c8d12d8c 100644 --- a/packages/twenty-server/src/engine/core-modules/billing/stripe/services/stripe-price.service.ts +++ b/packages/twenty-server/src/engine/core-modules/billing/stripe/services/stripe-price.service.ts @@ -2,7 +2,7 @@ import { Injectable, Logger } from '@nestjs/common'; import Stripe from 'stripe'; -import { ProductPriceEntity } from 'src/engine/core-modules/billing/dto/product-price.entity'; +import { BillingProductPriceDTO } from 'src/engine/core-modules/billing/dtos/billing-product-price.dto'; import { AvailableProduct } from 'src/engine/core-modules/billing/enums/billing-available-product.enum'; import { StripeSDKService } from 'src/engine/core-modules/billing/stripe/stripe-sdk/services/stripe-sdk.service'; import { EnvironmentService } from 'src/engine/core-modules/environment/environment.service'; @@ -46,10 +46,10 @@ export class StripePriceService { if (product === AvailableProduct.BasePlan) { return this.environmentService.get('BILLING_STRIPE_BASE_PLAN_PRODUCT_ID'); } - } // PD:,will be eliminated after refactoring + } - formatProductPrices(prices: Stripe.Price[]): ProductPriceEntity[] { - const productPrices: ProductPriceEntity[] = Object.values( + formatProductPrices(prices: Stripe.Price[]): BillingProductPriceDTO[] { + const productPrices: BillingProductPriceDTO[] = Object.values( prices .filter((item) => item.recurring?.interval && item.unit_amount) .reduce((acc, item: Stripe.Price) => { @@ -68,7 +68,7 @@ export class StripePriceService { }; } - return acc satisfies Record; + return acc satisfies Record; }, {}), ); @@ -76,8 +76,10 @@ export class StripePriceService { } async getPricesByProductId(productId: string) { - const prices = await this.stripe.prices.search({ - query: `product:'${productId}'`, + const prices = await this.stripe.prices.list({ + product: productId, + type: 'recurring', + expand: ['data.currency_options', 'data.tiers'], }); return prices.data; diff --git a/packages/twenty-server/src/engine/core-modules/billing/stripe/services/stripe-product.service.ts b/packages/twenty-server/src/engine/core-modules/billing/stripe/services/stripe-product.service.ts index 51aa3f9c47dd..c9fd5526c5a4 100644 --- a/packages/twenty-server/src/engine/core-modules/billing/stripe/services/stripe-product.service.ts +++ b/packages/twenty-server/src/engine/core-modules/billing/stripe/services/stripe-product.service.ts @@ -23,7 +23,10 @@ export class StripeProductService { } async getAllProducts() { - const products = await this.stripe.products.list(); + const products = await this.stripe.products.list({ + active: true, + limit: 100, + }); return products.data; } diff --git a/packages/twenty-server/src/engine/core-modules/billing/types/billing-get-plan-result.type.ts b/packages/twenty-server/src/engine/core-modules/billing/types/billing-get-plan-result.type.ts new file mode 100644 index 000000000000..3505e094d5f9 --- /dev/null +++ b/packages/twenty-server/src/engine/core-modules/billing/types/billing-get-plan-result.type.ts @@ -0,0 +1,9 @@ +import { BillingProduct } from 'src/engine/core-modules/billing/entities/billing-product.entity'; +import { BillingPlanKey } from 'src/engine/core-modules/billing/enums/billing-plan-key.enum'; + +export type BillingGetPlanResult = { + planKey: BillingPlanKey; + baseProduct: BillingProduct; + meteredProducts: BillingProduct[]; + otherLicensedProducts: BillingProduct[]; +}; diff --git a/packages/twenty-server/src/engine/core-modules/billing/types/billing-get-prices-per-plan-result.type.ts b/packages/twenty-server/src/engine/core-modules/billing/types/billing-get-prices-per-plan-result.type.ts new file mode 100644 index 000000000000..cf3b613a14fa --- /dev/null +++ b/packages/twenty-server/src/engine/core-modules/billing/types/billing-get-prices-per-plan-result.type.ts @@ -0,0 +1,7 @@ +import { BillingPrice } from 'src/engine/core-modules/billing/entities/billing-price.entity'; + +export type BillingGetPricesPerPlanResult = { + baseProductPrice: BillingPrice; + meteredProductsPrices: BillingPrice[]; + otherLicensedProductsPrices: BillingPrice[]; +}; diff --git a/packages/twenty-server/src/engine/core-modules/billing/types/billing-portal-checkout-session-parameters.type.ts b/packages/twenty-server/src/engine/core-modules/billing/types/billing-portal-checkout-session-parameters.type.ts new file mode 100644 index 000000000000..cafcbc7554dc --- /dev/null +++ b/packages/twenty-server/src/engine/core-modules/billing/types/billing-portal-checkout-session-parameters.type.ts @@ -0,0 +1,14 @@ +import { BillingPlanKey } from 'src/engine/core-modules/billing/enums/billing-plan-key.enum'; +import { BillingGetPricesPerPlanResult } from 'src/engine/core-modules/billing/types/billing-get-prices-per-plan-result.type'; +import { User } from 'src/engine/core-modules/user/user.entity'; +import { Workspace } from 'src/engine/core-modules/workspace/workspace.entity'; + +export type BillingPortalCheckoutSessionParameters = { + user: User; + workspace: Workspace; + billingPricesPerPlan?: BillingGetPricesPerPlanResult; + successUrlPath?: string; + plan: BillingPlanKey; + priceId?: string; + requirePaymentMethod?: boolean; +}; diff --git a/packages/twenty-server/src/engine/core-modules/billing/types/billing-product-metadata.type.ts b/packages/twenty-server/src/engine/core-modules/billing/types/billing-product-metadata.type.ts index 39245f0aeee0..382e22fa69fd 100644 --- a/packages/twenty-server/src/engine/core-modules/billing/types/billing-product-metadata.type.ts +++ b/packages/twenty-server/src/engine/core-modules/billing/types/billing-product-metadata.type.ts @@ -5,6 +5,7 @@ export type BillingProductMetadata = | { planKey: BillingPlanKey; priceUsageBased: BillingUsageType; + isBaseProduct: 'true' | 'false'; [key: string]: string; } | Record; diff --git a/packages/twenty-server/src/engine/core-modules/billing/utils/__tests__/format-database-product-to-graphql-dto.util.spec.ts b/packages/twenty-server/src/engine/core-modules/billing/utils/__tests__/format-database-product-to-graphql-dto.util.spec.ts new file mode 100644 index 000000000000..75a049a5fffd --- /dev/null +++ b/packages/twenty-server/src/engine/core-modules/billing/utils/__tests__/format-database-product-to-graphql-dto.util.spec.ts @@ -0,0 +1,224 @@ +import { BillingPlanKey } from 'src/engine/core-modules/billing/enums/billing-plan-key.enum'; +import { BillingPriceTiersMode } from 'src/engine/core-modules/billing/enums/billing-price-tiers-mode.enum'; +import { SubscriptionInterval } from 'src/engine/core-modules/billing/enums/billing-subscription-interval.enum'; +import { BillingUsageType } from 'src/engine/core-modules/billing/enums/billing-usage-type.enum'; +import { BillingGetPlanResult } from 'src/engine/core-modules/billing/types/billing-get-plan-result.type'; +import { formatBillingDatabaseProductToGraphqlDTO } from 'src/engine/core-modules/billing/utils/format-database-product-to-graphql-dto.util'; + +describe('formatBillingDatabaseProductToGraphqlDTO', () => { + it('should format a complete billing plan correctly', () => { + const mockPlan = { + planKey: BillingPlanKey.PRO, + baseProduct: { + id: 'base-1', + name: 'Base Product', + billingPrices: [ + { + interval: SubscriptionInterval.Month, + unitAmount: 1000, + stripePriceId: 'price_base1', + }, + ], + }, + otherLicensedProducts: [ + { + id: 'licensed-1', + name: 'Licensed Product', + billingPrices: [ + { + interval: SubscriptionInterval.Year, + unitAmount: 2000, + stripePriceId: 'price_licensed1', + }, + ], + }, + ], + meteredProducts: [ + { + id: 'metered-1', + name: 'Metered Product', + billingPrices: [ + { + interval: SubscriptionInterval.Month, + tiersMode: BillingPriceTiersMode.GRADUATED, + tiers: [ + { + up_to: 10, + flat_amount: 1000, + unit_amount: 100, + }, + ], + stripePriceId: 'price_metered1', + }, + ], + }, + ], + }; + + const result = formatBillingDatabaseProductToGraphqlDTO( + mockPlan as unknown as BillingGetPlanResult, + ); + + expect(result).toEqual({ + planKey: BillingPlanKey.PRO, + baseProduct: { + id: 'base-1', + name: 'Base Product', + billingPrices: [ + { + interval: SubscriptionInterval.Month, + unitAmount: 1000, + stripePriceId: 'price_base1', + }, + ], + type: BillingUsageType.LICENSED, + prices: [ + { + recurringInterval: SubscriptionInterval.Month, + unitAmount: 1000, + stripePriceId: 'price_base1', + }, + ], + }, + otherLicensedProducts: [ + { + id: 'licensed-1', + name: 'Licensed Product', + billingPrices: [ + { + interval: SubscriptionInterval.Year, + unitAmount: 2000, + stripePriceId: 'price_licensed1', + }, + ], + type: BillingUsageType.LICENSED, + prices: [ + { + recurringInterval: SubscriptionInterval.Year, + unitAmount: 2000, + stripePriceId: 'price_licensed1', + }, + ], + }, + ], + meteredProducts: [ + { + id: 'metered-1', + name: 'Metered Product', + billingPrices: [ + { + interval: SubscriptionInterval.Month, + tiersMode: BillingPriceTiersMode.GRADUATED, + tiers: [ + { + up_to: 10, + flat_amount: 1000, + unit_amount: 100, + }, + ], + stripePriceId: 'price_metered1', + }, + ], + type: BillingUsageType.METERED, + prices: [ + { + tiersMode: BillingPriceTiersMode.GRADUATED, + tiers: [ + { + upTo: 10, + flatAmount: 1000, + unitAmount: 100, + }, + ], + recurringInterval: SubscriptionInterval.Month, + stripePriceId: 'price_metered1', + }, + ], + }, + ], + }); + }); + + it('should handle empty products and null values', () => { + const mockPlan = { + planKey: 'empty-plan', + baseProduct: { + id: 'base-1', + name: 'Base Product', + billingPrices: [ + { + interval: null, + unitAmount: null, + stripePriceId: null, + }, + ], + }, + otherLicensedProducts: [], + meteredProducts: [ + { + id: 'metered-1', + name: 'Metered Product', + billingPrices: [ + { + interval: null, + tiersMode: null, + tiers: null, + stripePriceId: null, + }, + ], + }, + ], + }; + + const result = formatBillingDatabaseProductToGraphqlDTO( + mockPlan as unknown as BillingGetPlanResult, + ); + + expect(result).toEqual({ + planKey: 'empty-plan', + baseProduct: { + id: 'base-1', + name: 'Base Product', + billingPrices: [ + { + interval: null, + unitAmount: null, + stripePriceId: null, + }, + ], + type: BillingUsageType.LICENSED, + prices: [ + { + recurringInterval: SubscriptionInterval.Month, + unitAmount: 0, + stripePriceId: null, + }, + ], + }, + otherLicensedProducts: [], + meteredProducts: [ + { + id: 'metered-1', + name: 'Metered Product', + billingPrices: [ + { + interval: null, + tiersMode: null, + tiers: null, + stripePriceId: null, + }, + ], + type: BillingUsageType.METERED, + prices: [ + { + tiersMode: null, + tiers: [], + recurringInterval: SubscriptionInterval.Month, + stripePriceId: null, + }, + ], + }, + ], + }); + }); +}); diff --git a/packages/twenty-server/src/engine/core-modules/billing/utils/__tests__/is-stripe-valid-product-metadata.util.spec.ts b/packages/twenty-server/src/engine/core-modules/billing/utils/__tests__/is-stripe-valid-product-metadata.util.spec.ts index 0cbc5f5d87a4..b2cf6741517b 100644 --- a/packages/twenty-server/src/engine/core-modules/billing/utils/__tests__/is-stripe-valid-product-metadata.util.spec.ts +++ b/packages/twenty-server/src/engine/core-modules/billing/utils/__tests__/is-stripe-valid-product-metadata.util.spec.ts @@ -13,6 +13,7 @@ describe('isStripeValidProductMetadata', () => { const metadata: Stripe.Metadata = { planKey: BillingPlanKey.PRO, priceUsageBased: BillingUsageType.METERED, + isBaseProduct: 'true', }; expect(isStripeValidProductMetadata(metadata)).toBe(true); @@ -22,6 +23,7 @@ describe('isStripeValidProductMetadata', () => { const metadata: Stripe.Metadata = { planKey: BillingPlanKey.ENTERPRISE, priceUsageBased: BillingUsageType.METERED, + isBaseProduct: 'false', randomKey: 'randomValue', }; @@ -32,6 +34,7 @@ describe('isStripeValidProductMetadata', () => { const metadata: Stripe.Metadata = { planKey: 'invalid', priceUsageBased: BillingUsageType.METERED, + isBaseProduct: 'invalid', }; expect(isStripeValidProductMetadata(metadata)).toBe(false); @@ -41,6 +44,7 @@ describe('isStripeValidProductMetadata', () => { const metadata: Stripe.Metadata = { planKey: BillingPlanKey.PRO, priceUsageBased: 'invalid', + isBaseProduct: 'true', }; expect(isStripeValidProductMetadata(metadata)).toBe(false); diff --git a/packages/twenty-server/src/engine/core-modules/billing/utils/__tests__/transform-stripe-meter-data-to-meter-repository-data.util.spec.ts b/packages/twenty-server/src/engine/core-modules/billing/utils/__tests__/transform-stripe-meter-to-database-meter.util.spec.ts similarity index 89% rename from packages/twenty-server/src/engine/core-modules/billing/utils/__tests__/transform-stripe-meter-data-to-meter-repository-data.util.spec.ts rename to packages/twenty-server/src/engine/core-modules/billing/utils/__tests__/transform-stripe-meter-to-database-meter.util.spec.ts index 912bc557c511..30d84cc9102b 100644 --- a/packages/twenty-server/src/engine/core-modules/billing/utils/__tests__/transform-stripe-meter-data-to-meter-repository-data.util.spec.ts +++ b/packages/twenty-server/src/engine/core-modules/billing/utils/__tests__/transform-stripe-meter-to-database-meter.util.spec.ts @@ -2,7 +2,7 @@ import Stripe from 'stripe'; import { BillingMeterEventTimeWindow } from 'src/engine/core-modules/billing/enums/billing-meter-event-time-window.enum'; import { BillingMeterStatus } from 'src/engine/core-modules/billing/enums/billing-meter-status.enum'; -import { transformStripeMeterDataToMeterRepositoryData } from 'src/engine/core-modules/billing/utils/transform-stripe-meter-data-to-meter-repository-data.util'; +import { transformStripeMeterToDatabaseMeter } from 'src/engine/core-modules/billing/utils/transform-stripe-meter-to-database-meter.util'; describe('transformStripeMeterDataToMeterRepositoryData', () => { it('should return the correct data with customer mapping', () => { @@ -31,7 +31,7 @@ describe('transformStripeMeterDataToMeterRepositoryData', () => { }, }; - const result = transformStripeMeterDataToMeterRepositoryData(data); + const result = transformStripeMeterToDatabaseMeter(data); expect(result).toEqual({ stripeMeterId: 'met_123', @@ -74,7 +74,7 @@ describe('transformStripeMeterDataToMeterRepositoryData', () => { }, }; - const result = transformStripeMeterDataToMeterRepositoryData(data); + const result = transformStripeMeterToDatabaseMeter(data); expect(result).toEqual({ stripeMeterId: 'met_1234', diff --git a/packages/twenty-server/src/engine/core-modules/billing/utils/__tests__/transform-stripe-price-data-to-price-repository-data.util.spec.ts b/packages/twenty-server/src/engine/core-modules/billing/utils/__tests__/transform-stripe-price-to-database-price.util.spec.ts similarity index 86% rename from packages/twenty-server/src/engine/core-modules/billing/utils/__tests__/transform-stripe-price-data-to-price-repository-data.util.spec.ts rename to packages/twenty-server/src/engine/core-modules/billing/utils/__tests__/transform-stripe-price-to-database-price.util.spec.ts index 81e8e24df4fd..22893641e859 100644 --- a/packages/twenty-server/src/engine/core-modules/billing/utils/__tests__/transform-stripe-price-data-to-price-repository-data.util.spec.ts +++ b/packages/twenty-server/src/engine/core-modules/billing/utils/__tests__/transform-stripe-price-to-database-price.util.spec.ts @@ -6,8 +6,8 @@ import { BillingPriceTiersMode } from 'src/engine/core-modules/billing/enums/bil import { BillingPriceType } from 'src/engine/core-modules/billing/enums/billing-price-type.enum'; import { SubscriptionInterval } from 'src/engine/core-modules/billing/enums/billing-subscription-interval.enum'; import { BillingUsageType } from 'src/engine/core-modules/billing/enums/billing-usage-type.enum'; -import { transformStripePriceDataToPriceRepositoryData } from 'src/engine/core-modules/billing/utils/transform-stripe-price-data-to-price-repository-data.util'; -describe('transformStripePriceDataToPriceRepositoryData', () => { +import { transformStripePriceToDatabasePrice } from 'src/engine/core-modules/billing/utils/transform-stripe-price-to-database-price.util'; +describe('transformStripePriceToDatabasePrice', () => { const createMockPrice = (overrides = {}): Stripe.Price => ({ id: 'price_123', @@ -34,7 +34,7 @@ describe('transformStripePriceDataToPriceRepositoryData', () => { it('should transform basic price data correctly', () => { const mockPrice = createMockPrice(); - const result = transformStripePriceDataToPriceRepositoryData(mockPrice); + const result = transformStripePriceToDatabasePrice(mockPrice); expect(result).toEqual({ stripePriceId: 'price_123', @@ -73,7 +73,7 @@ describe('transformStripePriceDataToPriceRepositoryData', () => { const mockPrice = createMockPrice({ tax_behavior: stripeTaxBehavior as Stripe.Price.TaxBehavior, }); - const result = transformStripePriceDataToPriceRepositoryData(mockPrice); + const result = transformStripePriceToDatabasePrice(mockPrice); expect(result.taxBehavior).toBe(expected); }, @@ -88,7 +88,7 @@ describe('transformStripePriceDataToPriceRepositoryData', () => { const mockPrice = createMockPrice({ type: stripeType as Stripe.Price.Type, }); - const result = transformStripePriceDataToPriceRepositoryData(mockPrice); + const result = transformStripePriceToDatabasePrice(mockPrice); expect(result.type).toBe(expected); }); @@ -104,7 +104,7 @@ describe('transformStripePriceDataToPriceRepositoryData', () => { const mockPrice = createMockPrice({ billing_scheme: stripeScheme as Stripe.Price.BillingScheme, }); - const result = transformStripePriceDataToPriceRepositoryData(mockPrice); + const result = transformStripePriceToDatabasePrice(mockPrice); expect(result.billingScheme).toBe(expected); }, @@ -120,7 +120,7 @@ describe('transformStripePriceDataToPriceRepositoryData', () => { meter: 'meter_123', }, }); - const result = transformStripePriceDataToPriceRepositoryData(mockPrice); + const result = transformStripePriceToDatabasePrice(mockPrice); expect(result.stripeMeterId).toBe('meter_123'); expect(result.usageType).toBe(BillingUsageType.METERED); @@ -139,7 +139,7 @@ describe('transformStripePriceDataToPriceRepositoryData', () => { meter: null, }, }); - const result = transformStripePriceDataToPriceRepositoryData(mockPrice); + const result = transformStripePriceToDatabasePrice(mockPrice); expect(result.interval).toBe(expected); }); @@ -162,7 +162,7 @@ describe('transformStripePriceDataToPriceRepositoryData', () => { tiers: mockTiers, tiers_mode: stripeTiersMode as Stripe.Price.TiersMode, }); - const result = transformStripePriceDataToPriceRepositoryData(mockPrice); + const result = transformStripePriceToDatabasePrice(mockPrice); expect(result.tiersMode).toBe(expected); expect(result.tiers).toEqual(mockTiers); @@ -179,7 +179,7 @@ describe('transformStripePriceDataToPriceRepositoryData', () => { const mockPrice = createMockPrice({ transform_quantity: transformQuantity, }); - const result = transformStripePriceDataToPriceRepositoryData(mockPrice); + const result = transformStripePriceToDatabasePrice(mockPrice); expect(result.transformQuantity).toEqual(transformQuantity); }); @@ -192,7 +192,7 @@ describe('transformStripePriceDataToPriceRepositoryData', () => { }, }; const mockPrice = createMockPrice({ currency_options: currencyOptions }); - const result = transformStripePriceDataToPriceRepositoryData(mockPrice); + const result = transformStripePriceToDatabasePrice(mockPrice); expect(result.currencyOptions).toEqual(currencyOptions); }); @@ -206,7 +206,7 @@ describe('transformStripePriceDataToPriceRepositoryData', () => { tiers: null, currency_options: null, }); - const result = transformStripePriceDataToPriceRepositoryData(mockPrice); + const result = transformStripePriceToDatabasePrice(mockPrice); expect(result.nickname).toBeUndefined(); expect(result.unitAmount).toBeUndefined(); diff --git a/packages/twenty-server/src/engine/core-modules/billing/utils/__tests__/transform-stripe-product-data-to-product-repository-data.util.spec.ts b/packages/twenty-server/src/engine/core-modules/billing/utils/__tests__/transform-stripe-product-to-database-product.util.spec.ts similarity index 83% rename from packages/twenty-server/src/engine/core-modules/billing/utils/__tests__/transform-stripe-product-data-to-product-repository-data.util.spec.ts rename to packages/twenty-server/src/engine/core-modules/billing/utils/__tests__/transform-stripe-product-to-database-product.util.spec.ts index 875a19de032e..e14ebe833e22 100644 --- a/packages/twenty-server/src/engine/core-modules/billing/utils/__tests__/transform-stripe-product-data-to-product-repository-data.util.spec.ts +++ b/packages/twenty-server/src/engine/core-modules/billing/utils/__tests__/transform-stripe-product-to-database-product.util.spec.ts @@ -1,7 +1,7 @@ import Stripe from 'stripe'; -import { transformStripeProductDataToProductRepositoryData } from 'src/engine/core-modules/billing/utils/transform-stripe-product-data-to-product-repository-data.util'; -describe('transformStripeProductDataToProductRepositoryData', () => { +import { transformStripeProductToDatabaseProduct } from 'src/engine/core-modules/billing/utils/transform-stripe-product-to-database-product.util'; +describe('transformStripeProductToDatabaseProduct', () => { it('should return the correct data', () => { const data: Stripe.Product = { id: 'prod_123', @@ -28,7 +28,7 @@ describe('transformStripeProductDataToProductRepositoryData', () => { metadata: { key: 'value' }, }; - const result = transformStripeProductDataToProductRepositoryData(data); + const result = transformStripeProductToDatabaseProduct(data); expect(result).toEqual({ stripeProductId: 'prod_123', @@ -67,7 +67,7 @@ describe('transformStripeProductDataToProductRepositoryData', () => { metadata: {}, }; - const result = transformStripeProductDataToProductRepositoryData(data); + const result = transformStripeProductToDatabaseProduct(data); expect(result).toEqual({ stripeProductId: 'prod_456', diff --git a/packages/twenty-server/src/engine/core-modules/billing/utils/format-database-product-to-graphql-dto.util.ts b/packages/twenty-server/src/engine/core-modules/billing/utils/format-database-product-to-graphql-dto.util.ts new file mode 100644 index 000000000000..f5979f95c1de --- /dev/null +++ b/packages/twenty-server/src/engine/core-modules/billing/utils/format-database-product-to-graphql-dto.util.ts @@ -0,0 +1,70 @@ +import { BillingPriceLicensedDTO } from 'src/engine/core-modules/billing/dtos/billing-price-licensed.dto'; +import { BillingPriceMeteredDTO } from 'src/engine/core-modules/billing/dtos/billing-price-metered.dto'; +import { BillingPlanOutput } from 'src/engine/core-modules/billing/dtos/outputs/billing-plan.output'; +import { BillingPrice } from 'src/engine/core-modules/billing/entities/billing-price.entity'; +import { BillingPriceTiersMode } from 'src/engine/core-modules/billing/enums/billing-price-tiers-mode.enum'; +import { SubscriptionInterval } from 'src/engine/core-modules/billing/enums/billing-subscription-interval.enum'; +import { BillingUsageType } from 'src/engine/core-modules/billing/enums/billing-usage-type.enum'; +import { BillingGetPlanResult } from 'src/engine/core-modules/billing/types/billing-get-plan-result.type'; + +export const formatBillingDatabaseProductToGraphqlDTO = ( + plan: BillingGetPlanResult, +): BillingPlanOutput => { + return { + planKey: plan.planKey, + baseProduct: { + ...plan.baseProduct, + type: BillingUsageType.LICENSED, + prices: plan.baseProduct.billingPrices.map( + formatBillingDatabasePriceToLicensedPriceDTO, + ), + }, + otherLicensedProducts: plan.otherLicensedProducts.map((product) => { + return { + ...product, + type: BillingUsageType.LICENSED, + prices: product.billingPrices.map( + formatBillingDatabasePriceToLicensedPriceDTO, + ), + }; + }), + meteredProducts: plan.meteredProducts.map((product) => { + return { + ...product, + type: BillingUsageType.METERED, + prices: product.billingPrices.map( + formatBillingDatabasePriceToMeteredPriceDTO, + ), + }; + }), + }; +}; + +const formatBillingDatabasePriceToMeteredPriceDTO = ( + billingPrice: BillingPrice, +): BillingPriceMeteredDTO => { + return { + tiersMode: + billingPrice?.tiersMode === BillingPriceTiersMode.GRADUATED + ? BillingPriceTiersMode.GRADUATED + : null, + tiers: + billingPrice?.tiers?.map((tier) => ({ + upTo: tier.up_to, + flatAmount: tier.flat_amount, + unitAmount: tier.unit_amount, + })) ?? [], + recurringInterval: billingPrice?.interval ?? SubscriptionInterval.Month, + stripePriceId: billingPrice?.stripePriceId, + }; +}; + +const formatBillingDatabasePriceToLicensedPriceDTO = ( + billingPrice: BillingPrice, +): BillingPriceLicensedDTO => { + return { + recurringInterval: billingPrice?.interval ?? SubscriptionInterval.Month, + unitAmount: billingPrice?.unitAmount ?? 0, + stripePriceId: billingPrice?.stripePriceId, + }; +}; diff --git a/packages/twenty-server/src/engine/core-modules/billing/utils/is-stripe-valid-product-metadata.util.ts b/packages/twenty-server/src/engine/core-modules/billing/utils/is-stripe-valid-product-metadata.util.ts index cf2a8c955a99..a33b8e5b8454 100644 --- a/packages/twenty-server/src/engine/core-modules/billing/utils/is-stripe-valid-product-metadata.util.ts +++ b/packages/twenty-server/src/engine/core-modules/billing/utils/is-stripe-valid-product-metadata.util.ts @@ -12,8 +12,10 @@ export function isStripeValidProductMetadata( } const hasBillingPlanKey = isValidBillingPlanKey(metadata.planKey); const hasPriceUsageBased = isValidPriceUsageBased(metadata.priceUsageBased); + const hasIsBaseProduct = + metadata.isBaseProduct === 'true' || metadata.isBaseProduct === 'false'; - return hasBillingPlanKey && hasPriceUsageBased; + return hasBillingPlanKey && hasPriceUsageBased && hasIsBaseProduct; } const isValidBillingPlanKey = (planKey?: string) => { diff --git a/packages/twenty-server/src/engine/core-modules/billing/utils/transform-stripe-entitlement-updated-event-to-entitlement-repository-data.util.ts b/packages/twenty-server/src/engine/core-modules/billing/utils/transform-stripe-entitlement-updated-event-to-entitlement-repository-data.util.ts deleted file mode 100644 index 7577f9a90a21..000000000000 --- a/packages/twenty-server/src/engine/core-modules/billing/utils/transform-stripe-entitlement-updated-event-to-entitlement-repository-data.util.ts +++ /dev/null @@ -1,23 +0,0 @@ -import Stripe from 'stripe'; - -import { BillingEntitlementKey } from 'src/engine/core-modules/billing/enums/billing-entitlement-key.enum'; - -export const transformStripeEntitlementUpdatedEventToEntitlementRepositoryData = - ( - workspaceId: string, - data: Stripe.EntitlementsActiveEntitlementSummaryUpdatedEvent.Data, - ) => { - const stripeCustomerId = data.object.customer; - const activeEntitlementsKeys = data.object.entitlements.data.map( - (entitlement) => entitlement.lookup_key, - ); - - return Object.values(BillingEntitlementKey).map((key) => { - return { - workspaceId, - key, - value: activeEntitlementsKeys.includes(key), - stripeCustomerId, - }; - }); - }; diff --git a/packages/twenty-server/src/engine/core-modules/billing/utils/transform-stripe-meter-data-to-meter-repository-data.util.ts b/packages/twenty-server/src/engine/core-modules/billing/utils/transform-stripe-meter-to-database-meter.util.ts similarity index 94% rename from packages/twenty-server/src/engine/core-modules/billing/utils/transform-stripe-meter-data-to-meter-repository-data.util.ts rename to packages/twenty-server/src/engine/core-modules/billing/utils/transform-stripe-meter-to-database-meter.util.ts index e7ae8423055b..59370cbd39e5 100644 --- a/packages/twenty-server/src/engine/core-modules/billing/utils/transform-stripe-meter-data-to-meter-repository-data.util.ts +++ b/packages/twenty-server/src/engine/core-modules/billing/utils/transform-stripe-meter-to-database-meter.util.ts @@ -3,7 +3,7 @@ import Stripe from 'stripe'; import { BillingMeterEventTimeWindow } from 'src/engine/core-modules/billing/enums/billing-meter-event-time-window.enum'; import { BillingMeterStatus } from 'src/engine/core-modules/billing/enums/billing-meter-status.enum'; -export const transformStripeMeterDataToMeterRepositoryData = ( +export const transformStripeMeterToDatabaseMeter = ( data: Stripe.Billing.Meter, ) => { return { diff --git a/packages/twenty-server/src/engine/core-modules/billing/utils/transform-stripe-price-data-to-price-repository-data.util.ts b/packages/twenty-server/src/engine/core-modules/billing/utils/transform-stripe-price-to-database-price.util.ts similarity index 97% rename from packages/twenty-server/src/engine/core-modules/billing/utils/transform-stripe-price-data-to-price-repository-data.util.ts rename to packages/twenty-server/src/engine/core-modules/billing/utils/transform-stripe-price-to-database-price.util.ts index c5525b176277..fdb0cf81c076 100644 --- a/packages/twenty-server/src/engine/core-modules/billing/utils/transform-stripe-price-data-to-price-repository-data.util.ts +++ b/packages/twenty-server/src/engine/core-modules/billing/utils/transform-stripe-price-to-database-price.util.ts @@ -7,9 +7,7 @@ import { BillingPriceType } from 'src/engine/core-modules/billing/enums/billing- import { SubscriptionInterval } from 'src/engine/core-modules/billing/enums/billing-subscription-interval.enum'; import { BillingUsageType } from 'src/engine/core-modules/billing/enums/billing-usage-type.enum'; -export const transformStripePriceDataToPriceRepositoryData = ( - data: Stripe.Price, -) => { +export const transformStripePriceToDatabasePrice = (data: Stripe.Price) => { return { stripePriceId: data.id, active: data.active, diff --git a/packages/twenty-server/src/engine/core-modules/billing/utils/transform-stripe-product-data-to-product-repository-data.util.ts b/packages/twenty-server/src/engine/core-modules/billing/utils/transform-stripe-product-to-database-product.util.ts similarity index 84% rename from packages/twenty-server/src/engine/core-modules/billing/utils/transform-stripe-product-data-to-product-repository-data.util.ts rename to packages/twenty-server/src/engine/core-modules/billing/utils/transform-stripe-product-to-database-product.util.ts index f1e9413fc727..5f9c11ac11f7 100644 --- a/packages/twenty-server/src/engine/core-modules/billing/utils/transform-stripe-product-data-to-product-repository-data.util.ts +++ b/packages/twenty-server/src/engine/core-modules/billing/utils/transform-stripe-product-to-database-product.util.ts @@ -1,13 +1,13 @@ import Stripe from 'stripe'; -export const transformStripeProductDataToProductRepositoryData = ( +export const transformStripeProductToDatabaseProduct = ( data: Stripe.Product, ) => { return { stripeProductId: data.id, name: data.name, active: data.active, - description: data.description, + description: data.description ?? '', images: data.images, marketingFeatures: data.marketing_features, defaultStripePriceId: data.default_price diff --git a/packages/twenty-server/src/engine/core-modules/billing/utils/transform-stripe-subscription-event-to-subscription-item-repository-data.util.ts b/packages/twenty-server/src/engine/core-modules/billing/utils/transform-stripe-subscription-event-to-subscription-item-repository-data.util.ts deleted file mode 100644 index 9c72971dce72..000000000000 --- a/packages/twenty-server/src/engine/core-modules/billing/utils/transform-stripe-subscription-event-to-subscription-item-repository-data.util.ts +++ /dev/null @@ -1,26 +0,0 @@ -import Stripe from 'stripe'; - -export const transformStripeSubscriptionEventToSubscriptionItemRepositoryData = - ( - billingSubscriptionId: string, - data: - | Stripe.CustomerSubscriptionUpdatedEvent.Data - | Stripe.CustomerSubscriptionCreatedEvent.Data - | Stripe.CustomerSubscriptionDeletedEvent.Data, - ) => { - return data.object.items.data.map((item) => { - return { - billingSubscriptionId, - stripeSubscriptionId: data.object.id, - stripeProductId: String(item.price.product), - stripePriceId: item.price.id, - stripeSubscriptionItemId: item.id, - quantity: item.quantity, - metadata: item.metadata, - billingThresholds: - item.billing_thresholds === null - ? undefined - : item.billing_thresholds, - }; - }); - }; diff --git a/packages/twenty-server/src/engine/core-modules/billing/services/billing-webhook-entitlement.service.ts b/packages/twenty-server/src/engine/core-modules/billing/webhooks/services/billing-webhook-entitlement.service.ts similarity index 86% rename from packages/twenty-server/src/engine/core-modules/billing/services/billing-webhook-entitlement.service.ts rename to packages/twenty-server/src/engine/core-modules/billing/webhooks/services/billing-webhook-entitlement.service.ts index 2c3e285030b9..c73efac37d68 100644 --- a/packages/twenty-server/src/engine/core-modules/billing/services/billing-webhook-entitlement.service.ts +++ b/packages/twenty-server/src/engine/core-modules/billing/webhooks/services/billing-webhook-entitlement.service.ts @@ -10,7 +10,7 @@ import { } from 'src/engine/core-modules/billing/billing.exception'; import { BillingEntitlement } from 'src/engine/core-modules/billing/entities/billing-entitlement.entity'; import { BillingSubscription } from 'src/engine/core-modules/billing/entities/billing-subscription.entity'; -import { transformStripeEntitlementUpdatedEventToEntitlementRepositoryData } from 'src/engine/core-modules/billing/utils/transform-stripe-entitlement-updated-event-to-entitlement-repository-data.util'; +import { transformStripeEntitlementUpdatedEventToDatabaseEntitlement } from 'src/engine/core-modules/billing/webhooks/utils/transform-stripe-entitlement-updated-event-to-database-entitlement.util'; @Injectable() export class BillingWebhookEntitlementService { protected readonly logger = new Logger(BillingWebhookEntitlementService.name); @@ -39,7 +39,7 @@ export class BillingWebhookEntitlementService { const workspaceId = billingSubscription.workspaceId; await this.billingEntitlementRepository.upsert( - transformStripeEntitlementUpdatedEventToEntitlementRepositoryData( + transformStripeEntitlementUpdatedEventToDatabaseEntitlement( workspaceId, data, ), diff --git a/packages/twenty-server/src/engine/core-modules/billing/services/billing-webhook-price.service.ts b/packages/twenty-server/src/engine/core-modules/billing/webhooks/services/billing-webhook-price.service.ts similarity index 83% rename from packages/twenty-server/src/engine/core-modules/billing/services/billing-webhook-price.service.ts rename to packages/twenty-server/src/engine/core-modules/billing/webhooks/services/billing-webhook-price.service.ts index 660b1d95d8e3..eca1edd71a6f 100644 --- a/packages/twenty-server/src/engine/core-modules/billing/services/billing-webhook-price.service.ts +++ b/packages/twenty-server/src/engine/core-modules/billing/webhooks/services/billing-webhook-price.service.ts @@ -12,8 +12,9 @@ import { BillingMeter } from 'src/engine/core-modules/billing/entities/billing-m import { BillingPrice } from 'src/engine/core-modules/billing/entities/billing-price.entity'; import { BillingProduct } from 'src/engine/core-modules/billing/entities/billing-product.entity'; import { StripeBillingMeterService } from 'src/engine/core-modules/billing/stripe/services/stripe-billing-meter.service'; -import { transformStripeMeterDataToMeterRepositoryData } from 'src/engine/core-modules/billing/utils/transform-stripe-meter-data-to-meter-repository-data.util'; -import { transformStripePriceEventToPriceRepositoryData } from 'src/engine/core-modules/billing/utils/transform-stripe-price-event-to-price-repository-data.util'; +import { transformStripeMeterToDatabaseMeter } from 'src/engine/core-modules/billing/utils/transform-stripe-meter-to-database-meter.util'; +import { transformStripePriceEventToDatabasePrice } from 'src/engine/core-modules/billing/webhooks/utils/transform-stripe-price-event-to-database-price.util'; + @Injectable() export class BillingWebhookPriceService { protected readonly logger = new Logger(BillingWebhookPriceService.name); @@ -48,7 +49,7 @@ export class BillingWebhookPriceService { const meterData = await this.stripeBillingMeterService.getMeter(meterId); await this.billingMeterRepository.upsert( - transformStripeMeterDataToMeterRepositoryData(meterData), + transformStripeMeterToDatabaseMeter(meterData), { conflictPaths: ['stripeMeterId'], skipUpdateIfNoValuesChanged: true, @@ -57,7 +58,7 @@ export class BillingWebhookPriceService { } await this.billingPriceRepository.upsert( - transformStripePriceEventToPriceRepositoryData(data), + transformStripePriceEventToDatabasePrice(data), { conflictPaths: ['stripePriceId'], skipUpdateIfNoValuesChanged: true, diff --git a/packages/twenty-server/src/engine/core-modules/billing/services/billing-webhook-product.service.ts b/packages/twenty-server/src/engine/core-modules/billing/webhooks/services/billing-webhook-product.service.ts similarity index 87% rename from packages/twenty-server/src/engine/core-modules/billing/services/billing-webhook-product.service.ts rename to packages/twenty-server/src/engine/core-modules/billing/webhooks/services/billing-webhook-product.service.ts index 21d09abb4e48..0fd12dedb60a 100644 --- a/packages/twenty-server/src/engine/core-modules/billing/services/billing-webhook-product.service.ts +++ b/packages/twenty-server/src/engine/core-modules/billing/webhooks/services/billing-webhook-product.service.ts @@ -9,7 +9,7 @@ import { BillingPlanKey } from 'src/engine/core-modules/billing/enums/billing-pl import { BillingUsageType } from 'src/engine/core-modules/billing/enums/billing-usage-type.enum'; import { BillingProductMetadata } from 'src/engine/core-modules/billing/types/billing-product-metadata.type'; import { isStripeValidProductMetadata } from 'src/engine/core-modules/billing/utils/is-stripe-valid-product-metadata.util'; -import { transformStripeProductEventToProductRepositoryData } from 'src/engine/core-modules/billing/utils/transform-stripe-product-event-to-product-repository-data.util'; +import { transformStripeProductEventToDatabaseProduct } from 'src/engine/core-modules/billing/webhooks/utils/transform-stripe-product-event-to-database-product.util'; @Injectable() export class BillingWebhookProductService { protected readonly logger = new Logger(BillingWebhookProductService.name); @@ -24,10 +24,10 @@ export class BillingWebhookProductService { const metadata = data.object.metadata; const productRepositoryData = isStripeValidProductMetadata(metadata) ? { - ...transformStripeProductEventToProductRepositoryData(data), + ...transformStripeProductEventToDatabaseProduct(data), metadata, } - : transformStripeProductEventToProductRepositoryData(data); + : transformStripeProductEventToDatabaseProduct(data); await this.billingProductRepository.upsert(productRepositoryData, { conflictPaths: ['stripeProductId'], diff --git a/packages/twenty-server/src/engine/core-modules/billing/services/billing-webhook-subscription.service.ts b/packages/twenty-server/src/engine/core-modules/billing/webhooks/services/billing-webhook-subscription.service.ts similarity index 81% rename from packages/twenty-server/src/engine/core-modules/billing/services/billing-webhook-subscription.service.ts rename to packages/twenty-server/src/engine/core-modules/billing/webhooks/services/billing-webhook-subscription.service.ts index fba68f223a91..fcad8e097bf7 100644 --- a/packages/twenty-server/src/engine/core-modules/billing/services/billing-webhook-subscription.service.ts +++ b/packages/twenty-server/src/engine/core-modules/billing/webhooks/services/billing-webhook-subscription.service.ts @@ -10,9 +10,9 @@ import { BillingSubscriptionItem } from 'src/engine/core-modules/billing/entitie import { BillingSubscription } from 'src/engine/core-modules/billing/entities/billing-subscription.entity'; import { SubscriptionStatus } from 'src/engine/core-modules/billing/enums/billing-subscription-status.enum'; import { StripeCustomerService } from 'src/engine/core-modules/billing/stripe/services/stripe-customer.service'; -import { transformStripeSubscriptionEventToCustomerRepositoryData } from 'src/engine/core-modules/billing/utils/transform-stripe-subscription-event-to-customer-repository-data.util'; -import { transformStripeSubscriptionEventToSubscriptionItemRepositoryData } from 'src/engine/core-modules/billing/utils/transform-stripe-subscription-event-to-subscription-item-repository-data.util'; -import { transformStripeSubscriptionEventToSubscriptionRepositoryData } from 'src/engine/core-modules/billing/utils/transform-stripe-subscription-event-to-subscription-repository-data.util'; +import { transformStripeSubscriptionEventToDatabaseCustomer } from 'src/engine/core-modules/billing/webhooks/utils/transform-stripe-subscription-event-to-database-customer.util'; +import { transformStripeSubscriptionEventToDatabaseSubscriptionItem } from 'src/engine/core-modules/billing/webhooks/utils/transform-stripe-subscription-event-to-database-subscription-item.util'; +import { transformStripeSubscriptionEventToDatabaseSubscription } from 'src/engine/core-modules/billing/webhooks/utils/transform-stripe-subscription-event-to-database-subscription.util'; import { Workspace } from 'src/engine/core-modules/workspace/workspace.entity'; @Injectable() export class BillingWebhookSubscriptionService { @@ -47,10 +47,7 @@ export class BillingWebhookSubscriptionService { } await this.billingCustomerRepository.upsert( - transformStripeSubscriptionEventToCustomerRepositoryData( - workspaceId, - data, - ), + transformStripeSubscriptionEventToDatabaseCustomer(workspaceId, data), { conflictPaths: ['workspaceId'], skipUpdateIfNoValuesChanged: true, @@ -58,10 +55,7 @@ export class BillingWebhookSubscriptionService { ); await this.billingSubscriptionRepository.upsert( - transformStripeSubscriptionEventToSubscriptionRepositoryData( - workspaceId, - data, - ), + transformStripeSubscriptionEventToDatabaseSubscription(workspaceId, data), { conflictPaths: ['stripeSubscriptionId'], skipUpdateIfNoValuesChanged: true, @@ -74,7 +68,7 @@ export class BillingWebhookSubscriptionService { }); await this.billingSubscriptionItemRepository.upsert( - transformStripeSubscriptionEventToSubscriptionItemRepositoryData( + transformStripeSubscriptionEventToDatabaseSubscriptionItem( billingSubscription.id, data, ), diff --git a/packages/twenty-server/src/engine/core-modules/billing/utils/__tests__/transform-stripe-entitlement-updated-event-to-entitlement-repository-data.util.spec.ts b/packages/twenty-server/src/engine/core-modules/billing/webhooks/utils/__tests__/transform-stripe-entitlement-updated-event-to-database-entitlement.util.spec.ts similarity index 77% rename from packages/twenty-server/src/engine/core-modules/billing/utils/__tests__/transform-stripe-entitlement-updated-event-to-entitlement-repository-data.util.spec.ts rename to packages/twenty-server/src/engine/core-modules/billing/webhooks/utils/__tests__/transform-stripe-entitlement-updated-event-to-database-entitlement.util.spec.ts index 36b87bdd51ea..6077e8bb4068 100644 --- a/packages/twenty-server/src/engine/core-modules/billing/utils/__tests__/transform-stripe-entitlement-updated-event-to-entitlement-repository-data.util.spec.ts +++ b/packages/twenty-server/src/engine/core-modules/billing/webhooks/utils/__tests__/transform-stripe-entitlement-updated-event-to-database-entitlement.util.spec.ts @@ -1,9 +1,9 @@ import Stripe from 'stripe'; import { BillingEntitlementKey } from 'src/engine/core-modules/billing/enums/billing-entitlement-key.enum'; -import { transformStripeEntitlementUpdatedEventToEntitlementRepositoryData } from 'src/engine/core-modules/billing/utils/transform-stripe-entitlement-updated-event-to-entitlement-repository-data.util'; +import { transformStripeEntitlementUpdatedEventToDatabaseEntitlement } from 'src/engine/core-modules/billing/webhooks/utils/transform-stripe-entitlement-updated-event-to-database-entitlement.util'; -describe('transformStripeEntitlementUpdatedEventToEntitlementRepositoryData', () => { +describe('transformStripeEntitlementUpdatedEventToDatabaseEntitlement', () => { it('should return the SSO key with true value', () => { const data: Stripe.EntitlementsActiveEntitlementSummaryUpdatedEvent.Data = { object: { @@ -27,11 +27,10 @@ describe('transformStripeEntitlementUpdatedEventToEntitlementRepositoryData', () }, }; - const result = - transformStripeEntitlementUpdatedEventToEntitlementRepositoryData( - 'workspaceId', - data, - ); + const result = transformStripeEntitlementUpdatedEventToDatabaseEntitlement( + 'workspaceId', + data, + ); expect(result).toEqual([ { @@ -66,11 +65,10 @@ describe('transformStripeEntitlementUpdatedEventToEntitlementRepositoryData', () }, }; - const result = - transformStripeEntitlementUpdatedEventToEntitlementRepositoryData( - 'workspaceId', - data, - ); + const result = transformStripeEntitlementUpdatedEventToDatabaseEntitlement( + 'workspaceId', + data, + ); expect(result).toEqual([ { diff --git a/packages/twenty-server/src/engine/core-modules/billing/utils/__tests__/transform-stripe-price-event-to-price-repository-data.util.spec.ts b/packages/twenty-server/src/engine/core-modules/billing/webhooks/utils/__tests__/transform-stripe-price-event-to-database-price.util.spec.ts similarity index 83% rename from packages/twenty-server/src/engine/core-modules/billing/utils/__tests__/transform-stripe-price-event-to-price-repository-data.util.spec.ts rename to packages/twenty-server/src/engine/core-modules/billing/webhooks/utils/__tests__/transform-stripe-price-event-to-database-price.util.spec.ts index 5faa1385b36b..3ace9f346054 100644 --- a/packages/twenty-server/src/engine/core-modules/billing/utils/__tests__/transform-stripe-price-event-to-price-repository-data.util.spec.ts +++ b/packages/twenty-server/src/engine/core-modules/billing/webhooks/utils/__tests__/transform-stripe-price-event-to-database-price.util.spec.ts @@ -4,9 +4,9 @@ import { BillingPriceTiersMode } from 'src/engine/core-modules/billing/enums/bil import { BillingPriceType } from 'src/engine/core-modules/billing/enums/billing-price-type.enum'; import { SubscriptionInterval } from 'src/engine/core-modules/billing/enums/billing-subscription-interval.enum'; import { BillingUsageType } from 'src/engine/core-modules/billing/enums/billing-usage-type.enum'; -import { transformStripePriceEventToPriceRepositoryData } from 'src/engine/core-modules/billing/utils/transform-stripe-price-event-to-price-repository-data.util'; +import { transformStripePriceEventToDatabasePrice } from 'src/engine/core-modules/billing/webhooks/utils/transform-stripe-price-event-to-database-price.util'; -describe('transformStripePriceEventToPriceRepositoryData', () => { +describe('transformStripePriceEventToDatabasePrice', () => { const createMockPriceData = (overrides = {}) => ({ object: { id: 'price_123', @@ -34,9 +34,7 @@ describe('transformStripePriceEventToPriceRepositoryData', () => { it('should transform basic price data correctly', () => { const mockData = createMockPriceData(); - const result = transformStripePriceEventToPriceRepositoryData( - mockData as any, - ); + const result = transformStripePriceEventToDatabasePrice(mockData as any); expect(result).toEqual({ stripePriceId: 'price_123', @@ -74,9 +72,7 @@ describe('transformStripePriceEventToPriceRepositoryData', () => { const mockData = createMockPriceData({ tax_behavior: stripeTaxBehavior, }); - const result = transformStripePriceEventToPriceRepositoryData( - mockData as any, - ); + const result = transformStripePriceEventToDatabasePrice(mockData as any); expect(result.taxBehavior).toBe(expectedTaxBehavior); }); @@ -90,9 +86,7 @@ describe('transformStripePriceEventToPriceRepositoryData', () => { priceTypes.forEach(([stripeType, expectedType]) => { const mockData = createMockPriceData({ type: stripeType }); - const result = transformStripePriceEventToPriceRepositoryData( - mockData as any, - ); + const result = transformStripePriceEventToDatabasePrice(mockData as any); expect(result.type).toBe(expectedType); }); @@ -106,9 +100,7 @@ describe('transformStripePriceEventToPriceRepositoryData', () => { billingSchemes.forEach(([stripeScheme, expectedScheme]) => { const mockData = createMockPriceData({ billing_scheme: stripeScheme }); - const result = transformStripePriceEventToPriceRepositoryData( - mockData as any, - ); + const result = transformStripePriceEventToDatabasePrice(mockData as any); expect(result.billingScheme).toBe(expectedScheme); }); @@ -124,9 +116,7 @@ describe('transformStripePriceEventToPriceRepositoryData', () => { const mockData = createMockPriceData({ recurring: { usage_type: stripeUsageType, interval: 'month' }, }); - const result = transformStripePriceEventToPriceRepositoryData( - mockData as any, - ); + const result = transformStripePriceEventToDatabasePrice(mockData as any); expect(result.usageType).toBe(expectedUsageType); }); @@ -140,9 +130,7 @@ describe('transformStripePriceEventToPriceRepositoryData', () => { tiersModes.forEach(([stripeTiersMode, expectedTiersMode]) => { const mockData = createMockPriceData({ tiers_mode: stripeTiersMode }); - const result = transformStripePriceEventToPriceRepositoryData( - mockData as any, - ); + const result = transformStripePriceEventToDatabasePrice(mockData as any); expect(result.tiersMode).toBe(expectedTiersMode); }); @@ -160,9 +148,7 @@ describe('transformStripePriceEventToPriceRepositoryData', () => { const mockData = createMockPriceData({ recurring: { usage_type: 'licensed', interval: stripeInterval }, }); - const result = transformStripePriceEventToPriceRepositoryData( - mockData as any, - ); + const result = transformStripePriceEventToDatabasePrice(mockData as any); expect(result.interval).toBe(expectedInterval); }); @@ -180,9 +166,7 @@ describe('transformStripePriceEventToPriceRepositoryData', () => { tiers_mode: 'graduated', }); - const result = transformStripePriceEventToPriceRepositoryData( - mockData as any, - ); + const result = transformStripePriceEventToDatabasePrice(mockData as any); expect(result.billingScheme).toBe(BillingPriceBillingScheme.TIERED); expect(result.tiers).toEqual(mockTiers); @@ -204,9 +188,7 @@ describe('transformStripePriceEventToPriceRepositoryData', () => { transform_quantity: mockTransformQuantity, }); - const result = transformStripePriceEventToPriceRepositoryData( - mockData as any, - ); + const result = transformStripePriceEventToDatabasePrice(mockData as any); expect(result.stripeMeterId).toBe('meter_123'); expect(result.usageType).toBe(BillingUsageType.METERED); @@ -225,9 +207,7 @@ describe('transformStripePriceEventToPriceRepositoryData', () => { currency_options: mockCurrencyOptions, }); - const result = transformStripePriceEventToPriceRepositoryData( - mockData as any, - ); + const result = transformStripePriceEventToDatabasePrice(mockData as any); expect(result.currencyOptions).toEqual(mockCurrencyOptions); }); diff --git a/packages/twenty-server/src/engine/core-modules/billing/utils/__tests__/transform-stripe-product-event-to-product-repository-data.util.spec.ts b/packages/twenty-server/src/engine/core-modules/billing/webhooks/utils/__tests__/transform-stripe-product-event-to-database-product.util.spec.ts similarity index 84% rename from packages/twenty-server/src/engine/core-modules/billing/utils/__tests__/transform-stripe-product-event-to-product-repository-data.util.spec.ts rename to packages/twenty-server/src/engine/core-modules/billing/webhooks/utils/__tests__/transform-stripe-product-event-to-database-product.util.spec.ts index 2a858a781bdd..b8a496be1528 100644 --- a/packages/twenty-server/src/engine/core-modules/billing/utils/__tests__/transform-stripe-product-event-to-product-repository-data.util.spec.ts +++ b/packages/twenty-server/src/engine/core-modules/billing/webhooks/utils/__tests__/transform-stripe-product-event-to-database-product.util.spec.ts @@ -1,8 +1,8 @@ import Stripe from 'stripe'; -import { transformStripeProductEventToProductRepositoryData } from 'src/engine/core-modules/billing/utils/transform-stripe-product-event-to-product-repository-data.util'; +import { transformStripeProductEventToDatabaseProduct } from 'src/engine/core-modules/billing/webhooks/utils/transform-stripe-product-event-to-database-product.util'; -describe('transformStripeProductEventToProductRepositoryData', () => { +describe('transformStripeProductEventToDatabaseProduct', () => { it('should return the correct data', () => { const data: Stripe.ProductCreatedEvent.Data = { object: { @@ -31,7 +31,7 @@ describe('transformStripeProductEventToProductRepositoryData', () => { }, }; - const result = transformStripeProductEventToProductRepositoryData(data); + const result = transformStripeProductEventToDatabaseProduct(data); expect(result).toEqual({ stripeProductId: 'prod_123', @@ -71,7 +71,7 @@ describe('transformStripeProductEventToProductRepositoryData', () => { }, }; - const result = transformStripeProductEventToProductRepositoryData(data); + const result = transformStripeProductEventToDatabaseProduct(data); expect(result).toEqual({ stripeProductId: 'prod_456', diff --git a/packages/twenty-server/src/engine/core-modules/billing/utils/__tests__/transform-stripe-subscription-event-to-customer-repository-data.util.spec.ts b/packages/twenty-server/src/engine/core-modules/billing/webhooks/utils/__tests__/transform-stripe-subscription-event-to-database-customer.util.spec.ts similarity index 80% rename from packages/twenty-server/src/engine/core-modules/billing/utils/__tests__/transform-stripe-subscription-event-to-customer-repository-data.util.spec.ts rename to packages/twenty-server/src/engine/core-modules/billing/webhooks/utils/__tests__/transform-stripe-subscription-event-to-database-customer.util.spec.ts index be841607b25a..d8de729efba0 100644 --- a/packages/twenty-server/src/engine/core-modules/billing/utils/__tests__/transform-stripe-subscription-event-to-customer-repository-data.util.spec.ts +++ b/packages/twenty-server/src/engine/core-modules/billing/webhooks/utils/__tests__/transform-stripe-subscription-event-to-database-customer.util.spec.ts @@ -1,6 +1,5 @@ -import { transformStripeSubscriptionEventToCustomerRepositoryData } from 'src/engine/core-modules/billing/utils/transform-stripe-subscription-event-to-customer-repository-data.util'; - -describe('transformStripeSubscriptionEventToCustomerRepositoryData', () => { +import { transformStripeSubscriptionEventToDatabaseCustomer } from 'src/engine/core-modules/billing/webhooks/utils/transform-stripe-subscription-event-to-database-customer.util'; +describe('transformStripeSubscriptionEventToDatabaseCustomer', () => { const mockWorkspaceId = 'workspace_123'; const mockTimestamp = 1672531200; // 2023-01-01 00:00:00 UTC @@ -38,7 +37,7 @@ describe('transformStripeSubscriptionEventToCustomerRepositoryData', () => { it('should transform basic customer data correctly', () => { const mockData = createMockSubscriptionData('cus_123'); - const result = transformStripeSubscriptionEventToCustomerRepositoryData( + const result = transformStripeSubscriptionEventToDatabaseCustomer( mockWorkspaceId, mockData as any, ); @@ -54,7 +53,7 @@ describe('transformStripeSubscriptionEventToCustomerRepositoryData', () => { // Test with different event types (they should all transform the same way) ['updated', 'created', 'deleted'].forEach(() => { - const result = transformStripeSubscriptionEventToCustomerRepositoryData( + const result = transformStripeSubscriptionEventToDatabaseCustomer( mockWorkspaceId, mockData as any, ); @@ -71,7 +70,7 @@ describe('transformStripeSubscriptionEventToCustomerRepositoryData', () => { const testWorkspaces = ['workspace_1', 'workspace_2', 'workspace_abc']; testWorkspaces.forEach((testWorkspaceId) => { - const result = transformStripeSubscriptionEventToCustomerRepositoryData( + const result = transformStripeSubscriptionEventToDatabaseCustomer( testWorkspaceId, mockData as any, ); diff --git a/packages/twenty-server/src/engine/core-modules/billing/utils/__tests__/transform-stripe-subscription-event-to-subscription-repository-data.util.spec.ts b/packages/twenty-server/src/engine/core-modules/billing/webhooks/utils/__tests__/transform-stripe-subscription-event-to-database-subscription.util.spec.ts similarity index 84% rename from packages/twenty-server/src/engine/core-modules/billing/utils/__tests__/transform-stripe-subscription-event-to-subscription-repository-data.util.spec.ts rename to packages/twenty-server/src/engine/core-modules/billing/webhooks/utils/__tests__/transform-stripe-subscription-event-to-database-subscription.util.spec.ts index 6c4a6cdb0eb1..40823ffce4f3 100644 --- a/packages/twenty-server/src/engine/core-modules/billing/utils/__tests__/transform-stripe-subscription-event-to-subscription-repository-data.util.spec.ts +++ b/packages/twenty-server/src/engine/core-modules/billing/webhooks/utils/__tests__/transform-stripe-subscription-event-to-database-subscription.util.spec.ts @@ -1,8 +1,8 @@ import { BillingSubscriptionCollectionMethod } from 'src/engine/core-modules/billing/enums/billing-subscription-collection-method.enum'; import { SubscriptionStatus } from 'src/engine/core-modules/billing/enums/billing-subscription-status.enum'; -import { transformStripeSubscriptionEventToSubscriptionRepositoryData } from 'src/engine/core-modules/billing/utils/transform-stripe-subscription-event-to-subscription-repository-data.util'; +import { transformStripeSubscriptionEventToDatabaseSubscription } from 'src/engine/core-modules/billing/webhooks/utils/transform-stripe-subscription-event-to-database-subscription.util'; -describe('transformStripeSubscriptionEventToSubscriptionRepositoryData', () => { +describe('transformStripeSubscriptionEventToDatabaseSubscription', () => { const mockWorkspaceId = 'workspace-123'; const mockTimestamp = 1672531200; // 2023-01-01 00:00:00 UTC @@ -39,7 +39,7 @@ describe('transformStripeSubscriptionEventToSubscriptionRepositoryData', () => { it('should transform basic subscription data correctly', () => { const mockData = createMockSubscriptionData(); - const result = transformStripeSubscriptionEventToSubscriptionRepositoryData( + const result = transformStripeSubscriptionEventToDatabaseSubscription( mockWorkspaceId, mockData as any, ); @@ -83,11 +83,10 @@ describe('transformStripeSubscriptionEventToSubscriptionRepositoryData', () => { const mockData = createMockSubscriptionData({ status: stripeStatus, }); - const result = - transformStripeSubscriptionEventToSubscriptionRepositoryData( - mockWorkspaceId, - mockData as any, - ); + const result = transformStripeSubscriptionEventToDatabaseSubscription( + mockWorkspaceId, + mockData as any, + ); expect(result.status).toBe(expectedStatus); }); @@ -102,7 +101,7 @@ describe('transformStripeSubscriptionEventToSubscriptionRepositoryData', () => { trial_end: trialEnd, }); - const result = transformStripeSubscriptionEventToSubscriptionRepositoryData( + const result = transformStripeSubscriptionEventToDatabaseSubscription( mockWorkspaceId, mockData as any, ); @@ -125,7 +124,7 @@ describe('transformStripeSubscriptionEventToSubscriptionRepositoryData', () => { }, }); - const result = transformStripeSubscriptionEventToSubscriptionRepositoryData( + const result = transformStripeSubscriptionEventToDatabaseSubscription( mockWorkspaceId, mockData as any, ); @@ -148,7 +147,7 @@ describe('transformStripeSubscriptionEventToSubscriptionRepositoryData', () => { }, }); - const result = transformStripeSubscriptionEventToSubscriptionRepositoryData( + const result = transformStripeSubscriptionEventToDatabaseSubscription( mockWorkspaceId, mockData as any, ); @@ -172,11 +171,10 @@ describe('transformStripeSubscriptionEventToSubscriptionRepositoryData', () => { const mockData = createMockSubscriptionData({ collection_method: stripeMethod, }); - const result = - transformStripeSubscriptionEventToSubscriptionRepositoryData( - mockWorkspaceId, - mockData as any, - ); + const result = transformStripeSubscriptionEventToDatabaseSubscription( + mockWorkspaceId, + mockData as any, + ); expect(result.collectionMethod).toBe(expectedMethod); }); @@ -187,7 +185,7 @@ describe('transformStripeSubscriptionEventToSubscriptionRepositoryData', () => { currency: 'eur', }); - const result = transformStripeSubscriptionEventToSubscriptionRepositoryData( + const result = transformStripeSubscriptionEventToDatabaseSubscription( mockWorkspaceId, mockData as any, ); diff --git a/packages/twenty-server/src/engine/core-modules/billing/webhooks/utils/transform-stripe-entitlement-updated-event-to-database-entitlement.util.ts b/packages/twenty-server/src/engine/core-modules/billing/webhooks/utils/transform-stripe-entitlement-updated-event-to-database-entitlement.util.ts new file mode 100644 index 000000000000..02279dca6914 --- /dev/null +++ b/packages/twenty-server/src/engine/core-modules/billing/webhooks/utils/transform-stripe-entitlement-updated-event-to-database-entitlement.util.ts @@ -0,0 +1,22 @@ +import Stripe from 'stripe'; + +import { BillingEntitlementKey } from 'src/engine/core-modules/billing/enums/billing-entitlement-key.enum'; + +export const transformStripeEntitlementUpdatedEventToDatabaseEntitlement = ( + workspaceId: string, + data: Stripe.EntitlementsActiveEntitlementSummaryUpdatedEvent.Data, +) => { + const stripeCustomerId = data.object.customer; + const activeEntitlementsKeys = data.object.entitlements.data.map( + (entitlement) => entitlement.lookup_key, + ); + + return Object.values(BillingEntitlementKey).map((key) => { + return { + workspaceId, + key, + value: activeEntitlementsKeys.includes(key), + stripeCustomerId, + }; + }); +}; diff --git a/packages/twenty-server/src/engine/core-modules/billing/utils/transform-stripe-price-event-to-price-repository-data.util.ts b/packages/twenty-server/src/engine/core-modules/billing/webhooks/utils/transform-stripe-price-event-to-database-price.util.ts similarity index 98% rename from packages/twenty-server/src/engine/core-modules/billing/utils/transform-stripe-price-event-to-price-repository-data.util.ts rename to packages/twenty-server/src/engine/core-modules/billing/webhooks/utils/transform-stripe-price-event-to-database-price.util.ts index 5ce42e1e5b21..66e0bb60da44 100644 --- a/packages/twenty-server/src/engine/core-modules/billing/utils/transform-stripe-price-event-to-price-repository-data.util.ts +++ b/packages/twenty-server/src/engine/core-modules/billing/webhooks/utils/transform-stripe-price-event-to-database-price.util.ts @@ -7,7 +7,7 @@ import { BillingPriceType } from 'src/engine/core-modules/billing/enums/billing- import { SubscriptionInterval } from 'src/engine/core-modules/billing/enums/billing-subscription-interval.enum'; import { BillingUsageType } from 'src/engine/core-modules/billing/enums/billing-usage-type.enum'; -export const transformStripePriceEventToPriceRepositoryData = ( +export const transformStripePriceEventToDatabasePrice = ( data: Stripe.PriceCreatedEvent.Data | Stripe.PriceUpdatedEvent.Data, ) => { return { diff --git a/packages/twenty-server/src/engine/core-modules/billing/utils/transform-stripe-product-event-to-product-repository-data.util.ts b/packages/twenty-server/src/engine/core-modules/billing/webhooks/utils/transform-stripe-product-event-to-database-product.util.ts similarity index 85% rename from packages/twenty-server/src/engine/core-modules/billing/utils/transform-stripe-product-event-to-product-repository-data.util.ts rename to packages/twenty-server/src/engine/core-modules/billing/webhooks/utils/transform-stripe-product-event-to-database-product.util.ts index 2fea8d36c625..de52681f48f6 100644 --- a/packages/twenty-server/src/engine/core-modules/billing/utils/transform-stripe-product-event-to-product-repository-data.util.ts +++ b/packages/twenty-server/src/engine/core-modules/billing/webhooks/utils/transform-stripe-product-event-to-database-product.util.ts @@ -1,13 +1,13 @@ import Stripe from 'stripe'; -export const transformStripeProductEventToProductRepositoryData = ( +export const transformStripeProductEventToDatabaseProduct = ( data: Stripe.ProductUpdatedEvent.Data | Stripe.ProductCreatedEvent.Data, ) => { return { stripeProductId: data.object.id, name: data.object.name, active: data.object.active, - description: data.object.description, + description: data.object.description ?? '', images: data.object.images, marketingFeatures: data.object.marketing_features, defaultStripePriceId: data.object.default_price diff --git a/packages/twenty-server/src/engine/core-modules/billing/utils/transform-stripe-subscription-event-to-customer-repository-data.util.ts b/packages/twenty-server/src/engine/core-modules/billing/webhooks/utils/transform-stripe-subscription-event-to-database-customer.util.ts similarity index 80% rename from packages/twenty-server/src/engine/core-modules/billing/utils/transform-stripe-subscription-event-to-customer-repository-data.util.ts rename to packages/twenty-server/src/engine/core-modules/billing/webhooks/utils/transform-stripe-subscription-event-to-database-customer.util.ts index 3cb313e27c56..5d14b1acc710 100644 --- a/packages/twenty-server/src/engine/core-modules/billing/utils/transform-stripe-subscription-event-to-customer-repository-data.util.ts +++ b/packages/twenty-server/src/engine/core-modules/billing/webhooks/utils/transform-stripe-subscription-event-to-database-customer.util.ts @@ -1,6 +1,6 @@ import Stripe from 'stripe'; -export const transformStripeSubscriptionEventToCustomerRepositoryData = ( +export const transformStripeSubscriptionEventToDatabaseCustomer = ( workspaceId: string, data: | Stripe.CustomerSubscriptionUpdatedEvent.Data diff --git a/packages/twenty-server/src/engine/core-modules/billing/webhooks/utils/transform-stripe-subscription-event-to-database-subscription-item.util.ts b/packages/twenty-server/src/engine/core-modules/billing/webhooks/utils/transform-stripe-subscription-event-to-database-subscription-item.util.ts new file mode 100644 index 000000000000..f6ecd5c65a39 --- /dev/null +++ b/packages/twenty-server/src/engine/core-modules/billing/webhooks/utils/transform-stripe-subscription-event-to-database-subscription-item.util.ts @@ -0,0 +1,23 @@ +import Stripe from 'stripe'; + +export const transformStripeSubscriptionEventToDatabaseSubscriptionItem = ( + billingSubscriptionId: string, + data: + | Stripe.CustomerSubscriptionUpdatedEvent.Data + | Stripe.CustomerSubscriptionCreatedEvent.Data + | Stripe.CustomerSubscriptionDeletedEvent.Data, +) => { + return data.object.items.data.map((item) => { + return { + billingSubscriptionId, + stripeSubscriptionId: data.object.id, + stripeProductId: String(item.price.product), + stripePriceId: item.price.id, + stripeSubscriptionItemId: item.id, + quantity: item.quantity, + metadata: item.metadata, + billingThresholds: + item.billing_thresholds === null ? undefined : item.billing_thresholds, + }; + }); +}; diff --git a/packages/twenty-server/src/engine/core-modules/billing/utils/transform-stripe-subscription-event-to-subscription-repository-data.util.ts b/packages/twenty-server/src/engine/core-modules/billing/webhooks/utils/transform-stripe-subscription-event-to-database-subscription.util.ts similarity index 97% rename from packages/twenty-server/src/engine/core-modules/billing/utils/transform-stripe-subscription-event-to-subscription-repository-data.util.ts rename to packages/twenty-server/src/engine/core-modules/billing/webhooks/utils/transform-stripe-subscription-event-to-database-subscription.util.ts index 53e37d9a1dc7..8d58e3518bc2 100644 --- a/packages/twenty-server/src/engine/core-modules/billing/utils/transform-stripe-subscription-event-to-subscription-repository-data.util.ts +++ b/packages/twenty-server/src/engine/core-modules/billing/webhooks/utils/transform-stripe-subscription-event-to-database-subscription.util.ts @@ -3,7 +3,7 @@ import Stripe from 'stripe'; import { BillingSubscriptionCollectionMethod } from 'src/engine/core-modules/billing/enums/billing-subscription-collection-method.enum'; import { SubscriptionStatus } from 'src/engine/core-modules/billing/enums/billing-subscription-status.enum'; -export const transformStripeSubscriptionEventToSubscriptionRepositoryData = ( +export const transformStripeSubscriptionEventToDatabaseSubscription = ( workspaceId: string, data: | Stripe.CustomerSubscriptionUpdatedEvent.Data diff --git a/packages/twenty-server/src/engine/core-modules/client-config/client-config.entity.ts b/packages/twenty-server/src/engine/core-modules/client-config/client-config.entity.ts index dde6c9a69b91..2fdb7ed8c014 100644 --- a/packages/twenty-server/src/engine/core-modules/client-config/client-config.entity.ts +++ b/packages/twenty-server/src/engine/core-modules/client-config/client-config.entity.ts @@ -1,6 +1,6 @@ import { Field, ObjectType, registerEnumType } from '@nestjs/graphql'; -import { TrialPeriodDTO } from 'src/engine/core-modules/billing/dto/trial-period.dto'; +import { BillingTrialPeriodDTO } from 'src/engine/core-modules/billing/dtos/billing-trial-period.dto'; import { CaptchaDriverType } from 'src/engine/core-modules/captcha/interfaces'; import { FeatureFlagKey } from 'src/engine/core-modules/feature-flag/enums/feature-flag-key.enum'; import { AuthProviders } from 'src/engine/core-modules/workspace/dtos/public-workspace-data-output'; @@ -17,8 +17,8 @@ class Billing { @Field(() => String, { nullable: true }) billingUrl?: string; - @Field(() => [TrialPeriodDTO]) - trialPeriods: TrialPeriodDTO[]; + @Field(() => [BillingTrialPeriodDTO]) + trialPeriods: BillingTrialPeriodDTO[]; } @ObjectType() diff --git a/packages/twenty-server/src/engine/core-modules/environment/environment-variables.ts b/packages/twenty-server/src/engine/core-modules/environment/environment-variables.ts index d7e67b996ff7..04edd54f061c 100644 --- a/packages/twenty-server/src/engine/core-modules/environment/environment-variables.ts +++ b/packages/twenty-server/src/engine/core-modules/environment/environment-variables.ts @@ -204,10 +204,6 @@ export class EnvironmentVariables { @ValidateIf((env) => env.AUTH_MICROSOFT_ENABLED) AUTH_MICROSOFT_CLIENT_ID: string; - @IsString() - @ValidateIf((env) => env.AUTH_MICROSOFT_ENABLED) - AUTH_MICROSOFT_TENANT_ID: string; - @IsString() @ValidateIf((env) => env.AUTH_MICROSOFT_ENABLED) AUTH_MICROSOFT_CLIENT_SECRET: string; diff --git a/packages/twenty-server/src/engine/core-modules/feature-flag/enums/feature-flag-key.enum.ts b/packages/twenty-server/src/engine/core-modules/feature-flag/enums/feature-flag-key.enum.ts index 2cb2752ffec2..c08b90e5eb80 100644 --- a/packages/twenty-server/src/engine/core-modules/feature-flag/enums/feature-flag-key.enum.ts +++ b/packages/twenty-server/src/engine/core-modules/feature-flag/enums/feature-flag-key.enum.ts @@ -5,9 +5,7 @@ export enum FeatureFlagKey { IsStripeIntegrationEnabled = 'IS_STRIPE_INTEGRATION_ENABLED', IsCopilotEnabled = 'IS_COPILOT_ENABLED', IsFreeAccessEnabled = 'IS_FREE_ACCESS_ENABLED', - IsFunctionSettingsEnabled = 'IS_FUNCTION_SETTINGS_ENABLED', IsWorkflowEnabled = 'IS_WORKFLOW_ENABLED', - IsGmailSendEmailScopeEnabled = 'IS_GMAIL_SEND_EMAIL_SCOPE_ENABLED', IsAnalyticsV2Enabled = 'IS_ANALYTICS_V2_ENABLED', IsUniqueIndexesEnabled = 'IS_UNIQUE_INDEXES_ENABLED', IsMicrosoftSyncEnabled = 'IS_MICROSOFT_SYNC_ENABLED', @@ -15,4 +13,5 @@ export enum FeatureFlagKey { IsCommandMenuV2Enabled = 'IS_COMMAND_MENU_V2_ENABLED', IsJsonFilterEnabled = 'IS_JSON_FILTER_ENABLED', IsLocalizationEnabled = 'IS_LOCALIZATION_ENABLED', + IsBillingPlansEnabled = 'IS_BILLING_PLANS_ENABLED', } diff --git a/packages/twenty-server/src/engine/core-modules/workspace/workspace.entity.ts b/packages/twenty-server/src/engine/core-modules/workspace/workspace.entity.ts index ee93babfe848..be5f996bd74f 100644 --- a/packages/twenty-server/src/engine/core-modules/workspace/workspace.entity.ts +++ b/packages/twenty-server/src/engine/core-modules/workspace/workspace.entity.ts @@ -135,6 +135,6 @@ export class Workspace { isPasswordAuthEnabled: boolean; @Field() - @Column({ default: false }) + @Column({ default: true }) isMicrosoftAuthEnabled: boolean; } diff --git a/packages/twenty-server/src/engine/metadata-modules/field-metadata/field-metadata.entity.ts b/packages/twenty-server/src/engine/metadata-modules/field-metadata/field-metadata.entity.ts index 400e6643b551..a59750474433 100644 --- a/packages/twenty-server/src/engine/metadata-modules/field-metadata/field-metadata.entity.ts +++ b/packages/twenty-server/src/engine/metadata-modules/field-metadata/field-metadata.entity.ts @@ -3,6 +3,7 @@ import { Column, CreateDateColumn, Entity, + Index, JoinColumn, ManyToOne, OneToMany, @@ -28,6 +29,12 @@ import { RelationMetadataEntity } from 'src/engine/metadata-modules/relation-met 'objectMetadataId', 'workspaceId', ]) +@Index('IndexOnRelationTargetFieldMetadataId', [ + 'relationTargetFieldMetadataId', +]) +@Index('IndexOnRelationTargetObjectMetadataId', [ + 'relationTargetObjectMetadataId', +]) export class FieldMetadataEntity< T extends FieldMetadataType | 'default' = 'default', > implements FieldMetadataInterface @@ -95,6 +102,26 @@ export class FieldMetadataEntity< @Column({ default: false }) isLabelSyncedWithName: boolean; + @Column({ nullable: true, type: 'uuid' }) + relationTargetFieldMetadataId: string; + @OneToOne( + () => FieldMetadataEntity, + (fieldMetadata: FieldMetadataEntity) => + fieldMetadata.relationTargetFieldMetadataId, + ) + @JoinColumn({ name: 'relationTargetFieldMetadataId' }) + relationTargetFieldMetadata: Relation; + + @Column({ nullable: true, type: 'uuid' }) + relationTargetObjectMetadataId: string; + @ManyToOne( + () => ObjectMetadataEntity, + (objectMetadata: ObjectMetadataEntity) => + objectMetadata.targetRelationFields, + ) + @JoinColumn({ name: 'relationTargetObjectMetadataId' }) + relationTargetObjectMetadata: Relation; + @OneToOne( () => RelationMetadataEntity, (relation: RelationMetadataEntity) => relation.fromFieldMetadata, diff --git a/packages/twenty-server/src/engine/metadata-modules/field-metadata/interfaces/field-metadata-settings.interface.ts b/packages/twenty-server/src/engine/metadata-modules/field-metadata/interfaces/field-metadata-settings.interface.ts index 150c451afede..230cfcdaa37d 100644 --- a/packages/twenty-server/src/engine/metadata-modules/field-metadata/interfaces/field-metadata-settings.interface.ts +++ b/packages/twenty-server/src/engine/metadata-modules/field-metadata/interfaces/field-metadata-settings.interface.ts @@ -1,5 +1,8 @@ import { FieldMetadataType } from 'twenty-shared'; +import { RelationOnDeleteAction } from 'src/engine/metadata-modules/field-metadata/interfaces/relation-on-delete-action.interface'; +import { RelationType } from 'src/engine/metadata-modules/field-metadata/interfaces/relation-type.interface'; + export enum NumberDataType { FLOAT = 'float', INT = 'int', @@ -30,11 +33,17 @@ export type FieldMetadataDateTimeSettings = { displayAsRelativeDate?: boolean; }; +export type FieldMetadataRelationSettings = { + relationType: RelationType; + onDelete?: RelationOnDeleteAction; +}; + type FieldMetadataSettingsMapping = { [FieldMetadataType.NUMBER]: FieldMetadataNumberSettings; [FieldMetadataType.DATE]: FieldMetadataDateSettings; [FieldMetadataType.DATE_TIME]: FieldMetadataDateTimeSettings; [FieldMetadataType.TEXT]: FieldMetadataTextSettings; + [FieldMetadataType.RELATION]: FieldMetadataRelationSettings; }; type SettingsByFieldMetadata = diff --git a/packages/twenty-server/src/engine/metadata-modules/field-metadata/interfaces/relation-on-delete-action.interface.ts b/packages/twenty-server/src/engine/metadata-modules/field-metadata/interfaces/relation-on-delete-action.interface.ts new file mode 100644 index 000000000000..6f42eb3996b2 --- /dev/null +++ b/packages/twenty-server/src/engine/metadata-modules/field-metadata/interfaces/relation-on-delete-action.interface.ts @@ -0,0 +1,6 @@ +export enum RelationOnDeleteAction { + CASCADE = 'CASCADE', + RESTRICT = 'RESTRICT', + SET_NULL = 'SET_NULL', + NO_ACTION = 'NO_ACTION', +} diff --git a/packages/twenty-server/src/engine/metadata-modules/field-metadata/interfaces/relation-type.interface.ts b/packages/twenty-server/src/engine/metadata-modules/field-metadata/interfaces/relation-type.interface.ts new file mode 100644 index 000000000000..300fe72ca25f --- /dev/null +++ b/packages/twenty-server/src/engine/metadata-modules/field-metadata/interfaces/relation-type.interface.ts @@ -0,0 +1,5 @@ +export enum RelationType { + ONE_TO_ONE = 'ONE_TO_ONE', + ONE_TO_MANY = 'ONE_TO_MANY', + MANY_TO_ONE = 'MANY_TO_ONE', +} diff --git a/packages/twenty-server/src/engine/metadata-modules/object-metadata/object-metadata.entity.ts b/packages/twenty-server/src/engine/metadata-modules/object-metadata/object-metadata.entity.ts index b307e1ed8b85..eeea46d11477 100644 --- a/packages/twenty-server/src/engine/metadata-modules/object-metadata/object-metadata.entity.ts +++ b/packages/twenty-server/src/engine/metadata-modules/object-metadata/object-metadata.entity.ts @@ -112,6 +112,12 @@ export class ObjectMetadataEntity implements ObjectMetadataInterface { ) toRelations: Relation; + @OneToMany( + () => FieldMetadataEntity, + (field) => field.relationTargetObjectMetadataId, + ) + targetRelationFields: Relation; + @ManyToOne(() => DataSourceEntity, (dataSource) => dataSource.objects, { onDelete: 'CASCADE', }) diff --git a/packages/twenty-server/src/modules/connected-account/oauth2-client-manager/drivers/microsoft/microsoft-oauth2-client-manager.service.ts b/packages/twenty-server/src/modules/connected-account/oauth2-client-manager/drivers/microsoft/microsoft-oauth2-client-manager.service.ts index b14bb1f82683..7f4d263345c1 100644 --- a/packages/twenty-server/src/modules/connected-account/oauth2-client-manager/drivers/microsoft/microsoft-oauth2-client-manager.service.ts +++ b/packages/twenty-server/src/modules/connected-account/oauth2-client-manager/drivers/microsoft/microsoft-oauth2-client-manager.service.ts @@ -17,10 +17,6 @@ export class MicrosoftOAuth2ClientManagerService { callback: AuthProviderCallback, ) => { try { - const tenantId = this.environmentService.get( - 'AUTH_MICROSOFT_TENANT_ID', - ); - const urlData = new URLSearchParams(); urlData.append( @@ -36,7 +32,7 @@ export class MicrosoftOAuth2ClientManagerService { urlData.append('grant_type', 'refresh_token'); const res = await fetch( - `https://login.microsoftonline.com/${tenantId}/oauth2/v2.0/token`, + `https://login.microsoftonline.com/common/oauth2/v2.0/token`, { method: 'POST', body: urlData, diff --git a/packages/twenty-server/src/modules/contact-creation-manager/utils/filter-out-contacts-from-company-or-workspace.util.ts b/packages/twenty-server/src/modules/contact-creation-manager/utils/filter-out-contacts-from-company-or-workspace.util.ts index 42ecfd59d094..6558f871731d 100644 --- a/packages/twenty-server/src/modules/contact-creation-manager/utils/filter-out-contacts-from-company-or-workspace.util.ts +++ b/packages/twenty-server/src/modules/contact-creation-manager/utils/filter-out-contacts-from-company-or-workspace.util.ts @@ -2,6 +2,7 @@ import { ConnectedAccountWorkspaceEntity } from 'src/modules/connected-account/s import { Contact } from 'src/modules/contact-creation-manager/types/contact.type'; import { getDomainNameFromHandle } from 'src/modules/contact-creation-manager/utils/get-domain-name-from-handle.util'; import { WorkspaceMemberWorkspaceEntity } from 'src/modules/workspace-member/standard-objects/workspace-member.workspace-entity'; +import { isWorkDomain } from 'src/utils/is-work-email'; export function filterOutSelfAndContactsFromCompanyOrWorkspace( contacts: Contact[], @@ -21,9 +22,13 @@ export function filterOutSelfAndContactsFromCompanyOrWorkspace( new Map(), ); + const isDifferentDomain = (contact: Contact, selfDomainName: string) => + getDomainNameFromHandle(contact.handle) !== selfDomainName; + return contacts.filter( (contact) => - getDomainNameFromHandle(contact.handle) !== selfDomainName && + (isDifferentDomain(contact, selfDomainName) || + !isWorkDomain(selfDomainName)) && !workspaceMembersMap[contact.handle] && !handleAliases.includes(contact.handle), ); diff --git a/packages/twenty-server/src/modules/workflow/common/commands/seed-workflow-views.command.ts b/packages/twenty-server/src/modules/workflow/common/commands/seed-workflow-views.command.ts deleted file mode 100644 index 62e969630b25..000000000000 --- a/packages/twenty-server/src/modules/workflow/common/commands/seed-workflow-views.command.ts +++ /dev/null @@ -1,185 +0,0 @@ -import { Logger } from '@nestjs/common'; -import { InjectRepository } from '@nestjs/typeorm'; - -import { Command } from 'nest-commander'; -import { Repository } from 'typeorm'; -import { v4 } from 'uuid'; - -import { - ActiveWorkspacesCommandOptions, - ActiveWorkspacesCommandRunner, -} from 'src/database/commands/active-workspaces.command'; -import { Workspace } from 'src/engine/core-modules/workspace/workspace.entity'; -import { FieldMetadataEntity } from 'src/engine/metadata-modules/field-metadata/field-metadata.entity'; -import { ObjectMetadataEntity } from 'src/engine/metadata-modules/object-metadata/object-metadata.entity'; -import { TwentyORMGlobalManager } from 'src/engine/twenty-orm/twenty-orm-global.manager'; - -@Command({ - name: 'workflow:seed:views', - description: 'Seed workflow views for workspace.', -}) -export class SeedWorkflowViewsCommand extends ActiveWorkspacesCommandRunner { - protected readonly logger: Logger; - - constructor( - @InjectRepository(Workspace, 'core') - protected readonly workspaceRepository: Repository, - @InjectRepository(ObjectMetadataEntity, 'metadata') - private readonly objectMetadataRepository: Repository, - - @InjectRepository(FieldMetadataEntity, 'metadata') - private readonly fieldMetadataRepository: Repository, - private readonly twentyORMGlobalManager: TwentyORMGlobalManager, - ) { - super(workspaceRepository); - this.logger = new Logger(this.constructor.name); - } - - async executeActiveWorkspacesCommand( - _passedParam: string[], - _options: ActiveWorkspacesCommandOptions, - _workspaceIds: string[], - ): Promise { - const { dryRun } = _options; - - for (const workspaceId of _workspaceIds) { - await this.execute(workspaceId, dryRun); - } - } - - private async execute(workspaceId: string, dryRun = false): Promise { - this.logger.log(`Seeding workflow views for workspace: ${workspaceId}`); - - const workflowViewId = await this.seedView( - workspaceId, - 'workflow', - 'All Workflows', - ); - - await this.seedView( - workspaceId, - 'workflowVersion', - 'All Workflow Versions', - ); - - await this.seedView(workspaceId, 'workflowRun', 'All Workflow Runs'); - - const favoriteRepository = - await this.twentyORMGlobalManager.getRepositoryForWorkspace( - workspaceId, - 'favorite', - ); - - const existingFavorites = await favoriteRepository.find({ - where: { - viewId: workflowViewId, - }, - }); - - if (existingFavorites.length > 0) { - this.logger.log( - `Favorite already exists for view: ${existingFavorites[0].id}`, - ); - - return; - } - - if (dryRun) { - this.logger.log(`Dry run: Creating favorite for view: ${workflowViewId}`); - - return; - } - - await favoriteRepository.insert({ - viewId: workflowViewId, - position: 5, - }); - } - - private async seedView( - workspaceId: string, - nameSingular: string, - viewName: string, - dryRun = false, - ): Promise { - const objectMetadata = ( - await this.objectMetadataRepository.find({ - where: { workspaceId, nameSingular }, - }) - )?.[0]; - - if (!objectMetadata) { - throw new Error(`Object metadata not found: ${nameSingular}`); - } - - const fieldMetadataName = ( - await this.fieldMetadataRepository.find({ - where: { - workspaceId, - objectMetadataId: objectMetadata.id, - name: 'name', - }, - }) - )?.[0]; - - if (!fieldMetadataName) { - throw new Error( - `Field metadata not found for ${objectMetadata.id}: name`, - ); - } - - const viewRepository = - await this.twentyORMGlobalManager.getRepositoryForWorkspace( - workspaceId, - 'view', - ); - - const viewFieldRepository = - await this.twentyORMGlobalManager.getRepositoryForWorkspace( - workspaceId, - 'viewField', - ); - - const viewId = v4(); - - const existingViews = await viewRepository.find({ - where: { - objectMetadataId: objectMetadata.id, - name: viewName, - }, - }); - - if (existingViews.length > 0) { - this.logger.log(`View already exists: ${existingViews[0].id}`); - - return existingViews[0].id; - } - - if (dryRun) { - this.logger.log(`Dry run: Creating view: ${viewName}`); - - return viewId; - } - - await viewRepository.insert({ - id: viewId, - name: viewName, - objectMetadataId: objectMetadata.id, - type: 'table', - key: 'INDEX', - position: 0, - icon: 'IconSettingsAutomation', - kanbanFieldMetadataId: '', - }); - - await viewFieldRepository.insert({ - fieldMetadataId: fieldMetadataName.id, - position: 0, - isVisible: true, - size: 210, - viewId: viewId, - }); - - return viewId; - } -} diff --git a/packages/twenty-server/src/modules/workflow/common/commands/workflow-command.module.ts b/packages/twenty-server/src/modules/workflow/common/commands/workflow-command.module.ts deleted file mode 100644 index 08389b824d7f..000000000000 --- a/packages/twenty-server/src/modules/workflow/common/commands/workflow-command.module.ts +++ /dev/null @@ -1,20 +0,0 @@ -import { Module } from '@nestjs/common'; -import { TypeOrmModule } from '@nestjs/typeorm'; - -import { Workspace } from 'src/engine/core-modules/workspace/workspace.entity'; -import { FieldMetadataEntity } from 'src/engine/metadata-modules/field-metadata/field-metadata.entity'; -import { ObjectMetadataEntity } from 'src/engine/metadata-modules/object-metadata/object-metadata.entity'; -import { SeedWorkflowViewsCommand } from 'src/modules/workflow/common/commands/seed-workflow-views.command'; - -@Module({ - imports: [ - TypeOrmModule.forFeature([Workspace], 'core'), - TypeOrmModule.forFeature( - [ObjectMetadataEntity, FieldMetadataEntity], - 'metadata', - ), - ], - providers: [SeedWorkflowViewsCommand], - exports: [SeedWorkflowViewsCommand], -}) -export class WorkflowCommandModule {} diff --git a/packages/twenty-server/src/modules/workflow/common/workflow-common.module.ts b/packages/twenty-server/src/modules/workflow/common/workflow-common.module.ts index 7d0f0c7c623b..26905f9e482c 100644 --- a/packages/twenty-server/src/modules/workflow/common/workflow-common.module.ts +++ b/packages/twenty-server/src/modules/workflow/common/workflow-common.module.ts @@ -2,18 +2,16 @@ import { Module } from '@nestjs/common'; import { NestjsQueryTypeOrmModule } from '@ptc-org/nestjs-query-typeorm'; -import { WorkflowCommandModule } from 'src/modules/workflow/common/commands/workflow-command.module'; +import { ObjectMetadataEntity } from 'src/engine/metadata-modules/object-metadata/object-metadata.entity'; +import { ServerlessFunctionModule } from 'src/engine/metadata-modules/serverless-function/serverless-function.module'; import { WorkflowQueryHookModule } from 'src/modules/workflow/common/query-hooks/workflow-query-hook.module'; import { WorkflowCommonWorkspaceService } from 'src/modules/workflow/common/workspace-services/workflow-common.workspace-service'; import { WorkflowVersionStepWorkspaceService } from 'src/modules/workflow/common/workspace-services/workflow-version-step.workspace-service'; import { WorkflowBuilderModule } from 'src/modules/workflow/workflow-builder/workflow-builder.module'; -import { ObjectMetadataEntity } from 'src/engine/metadata-modules/object-metadata/object-metadata.entity'; -import { ServerlessFunctionModule } from 'src/engine/metadata-modules/serverless-function/serverless-function.module'; @Module({ imports: [ WorkflowQueryHookModule, - WorkflowCommandModule, WorkflowBuilderModule, ServerlessFunctionModule, NestjsQueryTypeOrmModule.forFeature([ObjectMetadataEntity], 'metadata'), diff --git a/packages/twenty-server/src/utils/is-work-email.ts b/packages/twenty-server/src/utils/is-work-email.ts index f5f8c3b75bf8..edc3fade7c4c 100644 --- a/packages/twenty-server/src/utils/is-work-email.ts +++ b/packages/twenty-server/src/utils/is-work-email.ts @@ -8,3 +8,7 @@ export const isWorkEmail = (email: string) => { return false; } }; + +export const isWorkDomain = (domain: string) => { + return !emailProvidersSet.has(domain); +}; diff --git a/packages/twenty-server/tsconfig.json b/packages/twenty-server/tsconfig.json index 5427807328dd..8b1ccda0547d 100644 --- a/packages/twenty-server/tsconfig.json +++ b/packages/twenty-server/tsconfig.json @@ -26,8 +26,7 @@ "types": ["jest", "node"], "paths": { "src/*": ["./src/*"], - "test/*": ["./test/*"], - "twenty-emails": ["../twenty-emails/dist"] + "test/*": ["./test/*"] } }, "ts-node": { diff --git a/packages/twenty-shared/src/constants/FieldForTotalCountAggregateOperation.ts b/packages/twenty-shared/src/constants/FieldForTotalCountAggregateOperation.ts new file mode 100644 index 000000000000..3a10ea479117 --- /dev/null +++ b/packages/twenty-shared/src/constants/FieldForTotalCountAggregateOperation.ts @@ -0,0 +1 @@ +export const FIELD_FOR_TOTAL_COUNT_AGGREGATE_OPERATION = 'id'; diff --git a/packages/twenty-shared/src/index.ts b/packages/twenty-shared/src/index.ts index a568cdebbd09..3d08c22809ca 100644 --- a/packages/twenty-shared/src/index.ts +++ b/packages/twenty-shared/src/index.ts @@ -1,3 +1,4 @@ +export * from './constants/FieldForTotalCountAggregateOperation'; export * from './constants/TwentyCompaniesBaseUrl'; export * from './constants/TwentyIconsBaseUrl'; export * from './types/FieldMetadataType'; @@ -5,3 +6,4 @@ export * from './utils/fieldMetadata/isFieldMetadataDateKind'; export * from './utils/image/getImageAbsoluteURI'; export * from './utils/strings'; export * from './workspace'; + diff --git a/packages/twenty-website/src/content/developers/self-hosting/setup.mdx b/packages/twenty-website/src/content/developers/self-hosting/setup.mdx index 4158c4fd1465..d0110ae57d64 100644 --- a/packages/twenty-website/src/content/developers/self-hosting/setup.mdx +++ b/packages/twenty-website/src/content/developers/self-hosting/setup.mdx @@ -79,7 +79,6 @@ Then you can set the following environment variables: - `AUTH_MICROSOFT_ENABLED=true` - `AUTH_MICROSOFT_CLIENT_ID=` -- `AUTH_MICROSOFT_TENANT_ID=` - `AUTH_MICROSOFT_CLIENT_SECRET=` - `AUTH_MICROSOFT_CALLBACK_URL=https:///auth/microsoft/redirect` if you want to use Microsoft SSO - `AUTH_MICROSOFT_APIS_CALLBACK_URL=https:///auth/microsoft-apis/get-access-token` @@ -189,9 +188,8 @@ yarn command:prod cron:calendar:ongoing-stale ['AUTH_GOOGLE_CALLBACK_URL', 'https://[YourDomain]/auth/google/redirect', 'Google auth callback'], ['AUTH_MICROSOFT_ENABLED', 'false', 'Enable Microsoft SSO login'], ['AUTH_MICROSOFT_CLIENT_ID', '', 'Microsoft client ID'], - ['AUTH_MICROSOFT_TENANT_ID', '', 'Microsoft tenant ID'], ['AUTH_MICROSOFT_CLIENT_SECRET', '', 'Microsoft client secret'], - ['AUTH_MICROSOFT_CALLBACK_URL', 'http://[YourDomain]/auth/microsoft/redirect', 'Microsoft auth callback'], + ['AUTH_MICROSOFT_CALLBACK_URL', 'https://[YourDomain]/auth/microsoft/redirect', 'Microsoft auth callback'], ['AUTH_MICROSOFT_APIS_CALLBACK_URL', 'http://[YourDomain]/auth/microsoft-apis/get-access-token', 'Microsoft APIs auth callback'], ['IS_MULTIWORKSPACE_ENABLED', 'false', 'Allows the use of multiple workspaces. Requires a web server that can manage wildcards for subdomains.'], ['PASSWORD_RESET_TOKEN_EXPIRES_IN', '5m', 'Password reset token expiration time'],