From 7d30b7577dc9d6bf62c469118a7cb6b21ed5e2b9 Mon Sep 17 00:00:00 2001 From: Ana Sofia Marin Alexandre <61988046+anamarn@users.noreply.github.com> Date: Tue, 21 Jan 2025 16:19:29 -0300 Subject: [PATCH 01/18] add fetch billing products from tables instead of env variables (#9601) Solves https://github.com/twentyhq/private-issues/issues/237 **TLDR:** - Fetches billing products and prices from the tables BilllingProducts and BillingPrices instead of fetching the product from the environment variables and the prices from the stripe API. - Adds new feature flag for this feature - Fixes calls used to fetch stripe products and prices for the command Billing Sync Plans Data. **In order to test:** 1. Have the environment variable IS_BILLING_ENABLED set to true and add the other required environment variables for Billing to work 2. Do a database reset (to ensure that the new feature flag is properly added and that the billing tables are created) 3. Run the command: `npx nx run twenty-server:command billing:sync-plans-data` (if you don't do that the products and prices will not be present in the database) 4. Run the server , the frontend, the worker, and the stripe listen command (`stripe listen --forward-to http://localhost:3000/billing/webhooks`) 5. Buy a subscription for the Acme workspace and play with the project **Doing** I think there is some room of progress for the function formatProductPrices, I used a similar version that was done before, I'll look into that. --- .../typeorm-seeds/core/feature-flags.ts | 5 + ...856478-addNonNullableProductDescription.ts | 25 ++ .../billing/billing.controller.ts | 8 +- .../core-modules/billing/billing.exception.ts | 1 + .../core-modules/billing/billing.module.ts | 10 +- .../core-modules/billing/billing.resolver.ts | 80 +++++-- .../billing-sync-plans-data.command.ts | 14 +- .../billing/dto/product-prices.entity.ts | 12 - .../dtos/billing-price-licensed.dto.ts | 15 ++ .../billing/dtos/billing-price-metered.dto.ts | 20 ++ .../billing/dtos/billing-price-tier.dto.ts | 13 + .../billing/dtos/billing-price-union.dto.ts | 16 ++ .../billing-product-price.dto.ts} | 2 +- .../billing/dtos/billing-product.dto.ts | 24 ++ .../billing-trial-period.dto.ts} | 2 +- .../inputs/billing-checkout-session.input.ts} | 2 +- .../inputs/billing-product.input.ts} | 2 +- .../inputs}/billing-session.input.ts | 0 .../dtos/outputs/billing-plan.output.ts | 19 ++ .../outputs/billing-product-prices.output.ts | 12 + .../outputs/billing-session.output.ts} | 2 +- .../outputs/billing-update.output.ts} | 2 +- .../entities/billing-product.entity.ts | 4 +- .../enums/billing-price-tiers-mode.enum.ts | 6 + .../billing/services/billing-plan.service.ts | 107 +++++++++ .../services/billing-subscription.service.ts | 66 +++++- .../stripe/services/stripe-price.service.ts | 16 +- .../stripe/services/stripe-product.service.ts | 5 +- .../types/billing-get-plan-result.type.ts | 9 + .../types/billing-product-metadata.type.ts | 1 + ...tabase-product-to-graphql-dto.util.spec.ts | 224 ++++++++++++++++++ ...stripe-valid-product-metadata.util.spec.ts | 4 + ...ripe-meter-to-database-meter.util.spec.ts} | 6 +- ...ripe-price-to-database-price.util.spec.ts} | 24 +- ...-product-to-database-product.util.spec.ts} | 8 +- ...at-database-product-to-graphql-dto.util.ts | 70 ++++++ .../is-stripe-valid-product-metadata.util.ts | 4 +- ...ent-to-entitlement-repository-data.util.ts | 23 -- ...rm-stripe-meter-to-database-meter.util.ts} | 2 +- ...rm-stripe-price-to-database-price.util.ts} | 4 +- ...tripe-product-to-database-product.util.ts} | 4 +- ...-subscription-item-repository-data.util.ts | 26 -- .../billing-webhook-entitlement.service.ts | 4 +- .../services/billing-webhook-price.service.ts | 9 +- .../billing-webhook-product.service.ts | 6 +- .../billing-webhook-subscription.service.ts | 18 +- ...vent-to-database-entitlement.util.spec.ts} | 22 +- ...rice-event-to-database-price.util.spec.ts} | 44 +--- ...ct-event-to-database-product.util.spec.ts} | 8 +- ...n-event-to-database-customer.util.spec.ts} | 11 +- ...ent-to-database-subscription.util.spec.ts} | 32 ++- ...ated-event-to-database-entitlement.util.ts | 22 ++ ...ipe-price-event-to-database-price.util.ts} | 2 +- ...product-event-to-database-product.util.ts} | 4 +- ...iption-event-to-database-customer.util.ts} | 2 +- ...vent-to-database-subscription-item.util.ts | 23 ++ ...on-event-to-database-subscription.util.ts} | 2 +- .../client-config/client-config.entity.ts | 6 +- .../enums/feature-flag-key.enum.ts | 1 + 59 files changed, 870 insertions(+), 245 deletions(-) create mode 100644 packages/twenty-server/src/database/typeorm/core/migrations/billing/1737127856478-addNonNullableProductDescription.ts delete mode 100644 packages/twenty-server/src/engine/core-modules/billing/dto/product-prices.entity.ts create mode 100644 packages/twenty-server/src/engine/core-modules/billing/dtos/billing-price-licensed.dto.ts create mode 100644 packages/twenty-server/src/engine/core-modules/billing/dtos/billing-price-metered.dto.ts create mode 100644 packages/twenty-server/src/engine/core-modules/billing/dtos/billing-price-tier.dto.ts create mode 100644 packages/twenty-server/src/engine/core-modules/billing/dtos/billing-price-union.dto.ts rename packages/twenty-server/src/engine/core-modules/billing/{dto/product-price.entity.ts => dtos/billing-product-price.dto.ts} (92%) create mode 100644 packages/twenty-server/src/engine/core-modules/billing/dtos/billing-product.dto.ts rename packages/twenty-server/src/engine/core-modules/billing/{dto/trial-period.dto.ts => dtos/billing-trial-period.dto.ts} (85%) rename packages/twenty-server/src/engine/core-modules/billing/{dto/checkout-session.input.ts => dtos/inputs/billing-checkout-session.input.ts} (95%) rename packages/twenty-server/src/engine/core-modules/billing/{dto/product.input.ts => dtos/inputs/billing-product.input.ts} (89%) rename packages/twenty-server/src/engine/core-modules/billing/{dto => dtos/inputs}/billing-session.input.ts (100%) create mode 100644 packages/twenty-server/src/engine/core-modules/billing/dtos/outputs/billing-plan.output.ts create mode 100644 packages/twenty-server/src/engine/core-modules/billing/dtos/outputs/billing-product-prices.output.ts rename packages/twenty-server/src/engine/core-modules/billing/{dto/session.entity.ts => dtos/outputs/billing-session.output.ts} (78%) rename packages/twenty-server/src/engine/core-modules/billing/{dto/update-billing.entity.ts => dtos/outputs/billing-update.output.ts} (84%) create mode 100644 packages/twenty-server/src/engine/core-modules/billing/services/billing-plan.service.ts create mode 100644 packages/twenty-server/src/engine/core-modules/billing/types/billing-get-plan-result.type.ts create mode 100644 packages/twenty-server/src/engine/core-modules/billing/utils/__tests__/format-database-product-to-graphql-dto.util.spec.ts rename packages/twenty-server/src/engine/core-modules/billing/utils/__tests__/{transform-stripe-meter-data-to-meter-repository-data.util.spec.ts => transform-stripe-meter-to-database-meter.util.spec.ts} (89%) rename packages/twenty-server/src/engine/core-modules/billing/utils/__tests__/{transform-stripe-price-data-to-price-repository-data.util.spec.ts => transform-stripe-price-to-database-price.util.spec.ts} (86%) rename packages/twenty-server/src/engine/core-modules/billing/utils/__tests__/{transform-stripe-product-data-to-product-repository-data.util.spec.ts => transform-stripe-product-to-database-product.util.spec.ts} (83%) create mode 100644 packages/twenty-server/src/engine/core-modules/billing/utils/format-database-product-to-graphql-dto.util.ts delete mode 100644 packages/twenty-server/src/engine/core-modules/billing/utils/transform-stripe-entitlement-updated-event-to-entitlement-repository-data.util.ts rename packages/twenty-server/src/engine/core-modules/billing/utils/{transform-stripe-meter-data-to-meter-repository-data.util.ts => transform-stripe-meter-to-database-meter.util.ts} (94%) rename packages/twenty-server/src/engine/core-modules/billing/utils/{transform-stripe-price-data-to-price-repository-data.util.ts => transform-stripe-price-to-database-price.util.ts} (97%) rename packages/twenty-server/src/engine/core-modules/billing/utils/{transform-stripe-product-data-to-product-repository-data.util.ts => transform-stripe-product-to-database-product.util.ts} (84%) delete mode 100644 packages/twenty-server/src/engine/core-modules/billing/utils/transform-stripe-subscription-event-to-subscription-item-repository-data.util.ts rename packages/twenty-server/src/engine/core-modules/billing/{ => webhooks}/services/billing-webhook-entitlement.service.ts (86%) rename packages/twenty-server/src/engine/core-modules/billing/{ => webhooks}/services/billing-webhook-price.service.ts (83%) rename packages/twenty-server/src/engine/core-modules/billing/{ => webhooks}/services/billing-webhook-product.service.ts (87%) rename packages/twenty-server/src/engine/core-modules/billing/{ => webhooks}/services/billing-webhook-subscription.service.ts (81%) rename packages/twenty-server/src/engine/core-modules/billing/{utils/__tests__/transform-stripe-entitlement-updated-event-to-entitlement-repository-data.util.spec.ts => webhooks/utils/__tests__/transform-stripe-entitlement-updated-event-to-database-entitlement.util.spec.ts} (77%) rename packages/twenty-server/src/engine/core-modules/billing/{utils/__tests__/transform-stripe-price-event-to-price-repository-data.util.spec.ts => webhooks/utils/__tests__/transform-stripe-price-event-to-database-price.util.spec.ts} (83%) rename packages/twenty-server/src/engine/core-modules/billing/{utils/__tests__/transform-stripe-product-event-to-product-repository-data.util.spec.ts => webhooks/utils/__tests__/transform-stripe-product-event-to-database-product.util.spec.ts} (84%) rename packages/twenty-server/src/engine/core-modules/billing/{utils/__tests__/transform-stripe-subscription-event-to-customer-repository-data.util.spec.ts => webhooks/utils/__tests__/transform-stripe-subscription-event-to-database-customer.util.spec.ts} (80%) rename packages/twenty-server/src/engine/core-modules/billing/{utils/__tests__/transform-stripe-subscription-event-to-subscription-repository-data.util.spec.ts => webhooks/utils/__tests__/transform-stripe-subscription-event-to-database-subscription.util.spec.ts} (84%) create mode 100644 packages/twenty-server/src/engine/core-modules/billing/webhooks/utils/transform-stripe-entitlement-updated-event-to-database-entitlement.util.ts rename packages/twenty-server/src/engine/core-modules/billing/{utils/transform-stripe-price-event-to-price-repository-data.util.ts => webhooks/utils/transform-stripe-price-event-to-database-price.util.ts} (98%) rename packages/twenty-server/src/engine/core-modules/billing/{utils/transform-stripe-product-event-to-product-repository-data.util.ts => webhooks/utils/transform-stripe-product-event-to-database-product.util.ts} (85%) rename packages/twenty-server/src/engine/core-modules/billing/{utils/transform-stripe-subscription-event-to-customer-repository-data.util.ts => webhooks/utils/transform-stripe-subscription-event-to-database-customer.util.ts} (80%) create mode 100644 packages/twenty-server/src/engine/core-modules/billing/webhooks/utils/transform-stripe-subscription-event-to-database-subscription-item.util.ts rename packages/twenty-server/src/engine/core-modules/billing/{utils/transform-stripe-subscription-event-to-subscription-repository-data.util.ts => webhooks/utils/transform-stripe-subscription-event-to-database-subscription.util.ts} (97%) 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..fd7da0e983c6 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 @@ -50,6 +50,11 @@ export const seedFeatureFlags = async ( workspaceId: workspaceId, value: true, }, + { + key: FeatureFlagKey.IsBillingPlansEnabled, + workspaceId: workspaceId, + value: true, + }, { key: FeatureFlagKey.IsGmailSendEmailScopeEnabled, workspaceId: workspaceId, 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/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..8e5ae8fc1ccf 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 @@ -12,5 +12,6 @@ export class BillingException extends CustomException { export enum BillingExceptionCode { BILLING_CUSTOMER_NOT_FOUND = 'BILLING_CUSTOMER_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..11df9347c956 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,24 @@ 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 { 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 +32,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 +65,7 @@ export class BillingResolver { }; } - @Mutation(() => SessionEntity) + @Mutation(() => BillingSessionOutput) @UseGuards(WorkspaceAuthGuard, UserAuthGuard) async checkoutSession( @AuthWorkspace() workspace: Workspace, @@ -62,15 +76,37 @@ export class BillingResolver { successUrlPath, plan, requirePaymentMethod, - }: CheckoutSessionInput, + }: BillingCheckoutSessionInput, ) { - const productPrice = await this.stripePriceService.getStripePrice( - AvailableProduct.BasePlan, - recurringInterval, - ); + const isBillingPlansEnabled = + await this.featureFlagService.isFeatureEnabled( + FeatureFlagKey.IsBillingPlansEnabled, + workspace.id, + ); + + let productPrice; + + if (isBillingPlansEnabled) { + const baseProduct = await this.billingPlanService.getPlanBaseProduct( + plan ?? BillingPlanKey.PRO, + ); + + if (!baseProduct) { + throw new GraphQLError('Base product not found'); + } + + productPrice = baseProduct.billingPrices.find( + (price) => price.interval === recurringInterval, + ); + } else { + 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', ); } @@ -87,11 +123,19 @@ export class BillingResolver { }; } - @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..ba3d35678d6c --- /dev/null +++ b/packages/twenty-server/src/engine/core-modules/billing/services/billing-plan.service.ts @@ -0,0 +1,107 @@ +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 { 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'; + +@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, + }; + }); + } +} 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-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-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/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..41cf38df94d0 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 @@ -15,4 +15,5 @@ export enum FeatureFlagKey { IsCommandMenuV2Enabled = 'IS_COMMAND_MENU_V2_ENABLED', IsJsonFilterEnabled = 'IS_JSON_FILTER_ENABLED', IsLocalizationEnabled = 'IS_LOCALIZATION_ENABLED', + IsBillingPlansEnabled = 'IS_BILLING_PLANS_ENABLED', } From 26aca9508b4f72d7a4e04da3489f46d52737d12b Mon Sep 17 00:00:00 2001 From: Baptiste Devessier Date: Wed, 22 Jan 2025 11:03:36 +0100 Subject: [PATCH 02/18] Align the workflow visualizer's nodes on the left (#9776) **This PR implements a new layout for the visualizer, but the dimensions of the nodes will change soon. I used hard-coded dimensions, like `40px`, but I'll update them when I work on fixing the nodes' design. I think we can merge this PR first and then fix the nodes' design.** https://github.com/user-attachments/assets/580fa812-ee8e-4452-b6ac-ca55ecb31759 --- .../components/WorkflowDiagramBaseStepNode.tsx | 2 ++ .../components/WorkflowDiagramCreateStepNode.tsx | 3 +++ .../workflow-diagram/utils/getOrganizedDiagram.ts | 13 +++++++++---- 3 files changed, 14 insertions(+), 4 deletions(-) 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/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/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 } }; }), From 441b88b7e1b44ac28608ee0961a073e88065242b Mon Sep 17 00:00:00 2001 From: Thomas Trompette Date: Wed, 22 Jan 2025 14:40:44 +0100 Subject: [PATCH 03/18] Seed workflow views and favorites in upgrade to 0.41 (#9785) - Sync metadata to create workflow entities, since those are not behind a flag anymore - Seed workflow views - Seed workspace favorite for workflow - Put all steps in upgrade command --- .../commands/database-command.module.ts | 2 + .../0-41/0-41-seed-workflow-views.command.ts | 203 ++++++++++++++++++ .../0-41/0-41-upgrade-version.command.ts | 48 +++++ .../0-41/0-41-upgrade-version.module.ts | 33 +++ .../commands/seed-workflow-views.command.ts | 185 ---------------- .../commands/workflow-command.module.ts | 20 -- .../workflow/common/workflow-common.module.ts | 6 +- 7 files changed, 288 insertions(+), 209 deletions(-) create mode 100644 packages/twenty-server/src/database/commands/upgrade-version/0-41/0-41-seed-workflow-views.command.ts create mode 100644 packages/twenty-server/src/database/commands/upgrade-version/0-41/0-41-upgrade-version.command.ts create mode 100644 packages/twenty-server/src/database/commands/upgrade-version/0-41/0-41-upgrade-version.module.ts delete mode 100644 packages/twenty-server/src/modules/workflow/common/commands/seed-workflow-views.command.ts delete mode 100644 packages/twenty-server/src/modules/workflow/common/commands/workflow-command.module.ts 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-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..a60dbc99460e --- /dev/null +++ b/packages/twenty-server/src/database/commands/upgrade-version/0-41/0-41-upgrade-version.command.ts @@ -0,0 +1,48 @@ +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 { 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, + ) { + 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, + ); + } +} 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..4c4026052693 --- /dev/null +++ b/packages/twenty-server/src/database/commands/upgrade-version/0-41/0-41-upgrade-version.module.ts @@ -0,0 +1,33 @@ +import { Module } from '@nestjs/common'; +import { TypeOrmModule } from '@nestjs/typeorm'; + +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 { 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, + DataSourceModule, + ObjectMetadataModule, + WorkspaceSyncMetadataCommandsModule, + WorkspaceSyncMetadataModule, + WorkspaceHealthModule, + ], + providers: [ + SyncWorkspaceLoggerService, + SyncWorkspaceMetadataCommand, + SeedWorkflowViewsCommand, + UpgradeTo0_41Command, + ], +}) +export class UpgradeTo0_41CommandModule {} 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'), From 82139958877cb9a9fdbdcabf7c890735f8007b6d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Rapha=C3=ABl=20Bosi?= <71827178+bosiraphael@users.noreply.github.com> Date: Wed, 22 Jan 2025 14:44:10 +0100 Subject: [PATCH 04/18] Open showpage on workflow creation (#9714) - Created an new component state `isRecordEditableNameRenamingComponentState` - Updated `useCreateNewTableRecord` to open the ShowPage on workflow creation - Refactored `RecordEditableName` and its components to remove the useEffect (This was causing the recordName state to be updated after the focus on `NavigationDrawerInput`, but we want the text so be selected after the update). - Introduced a new component `EditableBreadcrumbItem` - Created an autosizing text input: This is done by a hack using a span inside a div and the input position is set to absolute and takes the size of the div. There are two problems that I didn't manage to fix: If the text is too long, the title overflows, and the letter spacing is different between the span and the input creating a small offset. https://github.com/user-attachments/assets/4aa1e177-7458-4691-b0c8-96567b482206 New text input component: https://github.com/user-attachments/assets/94565546-fe2b-457d-a1d8-907007e0e2ce --- .../tests/workflow-creation.spec.ts | 19 +-- .../favorites/components/FavoritesFolders.tsx | 23 ++-- .../ObjectRecordShowPageBreadcrumb.tsx} | 58 +++------ .../hooks/useCreateNewTableRecords.ts | 83 +++++++++--- .../states/isUpdatingRecordEditableName.ts | 6 + .../shouldRedirectToShowPageOnCreation.ts | 11 ++ .../ui/input/components/TextInputV2.tsx | 60 +++++++-- .../__stories__/TextInputV2.stories.tsx | 18 ++- .../ui/layout/page/components/PageHeader.tsx | 19 ++- .../components/EditableBreadcrumbItem.tsx | 120 ++++++++++++++++++ .../EditableBreadcrumbItem.stories.tsx | 68 ++++++++++ .../hooks/useOpenEditableBreadCrumbItem.ts | 19 +++ .../EditableBreadcrumbItemHotkeyScope.ts | 3 + .../components/NavigationDrawerInput.tsx | 87 +++---------- .../components/NavigationDrawerItemInput.tsx | 5 - .../object-record/RecordShowPageHeader.tsx | 9 +- 16 files changed, 429 insertions(+), 179 deletions(-) rename packages/twenty-front/src/modules/object-record/{components/RecordEditableName.tsx => record-show/components/ObjectRecordShowPageBreadcrumb.tsx} (53%) create mode 100644 packages/twenty-front/src/modules/object-record/states/isUpdatingRecordEditableName.ts create mode 100644 packages/twenty-front/src/modules/object-record/utils/shouldRedirectToShowPageOnCreation.ts create mode 100644 packages/twenty-front/src/modules/ui/navigation/bread-crumb/components/EditableBreadcrumbItem.tsx create mode 100644 packages/twenty-front/src/modules/ui/navigation/bread-crumb/components/__stories__/EditableBreadcrumbItem.stories.tsx create mode 100644 packages/twenty-front/src/modules/ui/navigation/bread-crumb/hooks/useOpenEditableBreadCrumbItem.ts create mode 100644 packages/twenty-front/src/modules/ui/navigation/bread-crumb/types/EditableBreadcrumbItemHotkeyScope.ts delete mode 100644 packages/twenty-front/src/modules/ui/navigation/navigation-drawer/components/NavigationDrawerItemInput.tsx 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/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) => ( 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/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/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/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/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/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/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 From d75955950696a5a160572321a2ebbbe3d23c51b7 Mon Sep 17 00:00:00 2001 From: Marie <51697796+ijreilly@users.noreply.github.com> Date: Wed, 22 Jan 2025 15:05:38 +0100 Subject: [PATCH 05/18] Fix COUNT operation on view group aggregate header (#9789) Fixes [sentry](https://twenty-v7.sentry.io/issues/6235128210/?referrer=discord¬ification_uuid=898a081c-f8c7-42b8-b598-7660470a1975&alert_rule_id=15135099&alert_type=issue) In a [previous work](https://github.com/twentyhq/twenty/pull/9749) I set the default field to run totalCount aggregate operation on to the "name" field, which I was wrong think was present on all objects. --- ...ildRecordGqlFieldsAggregateForView.test.ts | 6 +- .../computeAggregateValueAndLabel.test.ts | 10 +- .../buildRecordGqlFieldsAggregateForView.ts | 7 +- .../utils/computeAggregateValueAndLabel.ts | 11 +- .../hooks/useAggregateRecordsForHeader.ts | 3 - ...etAvailableAggregationsFromObjectFields.ts | 105 ++++++++++-------- ...le-aggregations-from-object-fields.util.ts | 3 +- .../FieldForTotalCountAggregateOperation.ts | 1 + packages/twenty-shared/src/index.ts | 2 + 9 files changed, 74 insertions(+), 74 deletions(-) create mode 100644 packages/twenty-shared/src/constants/FieldForTotalCountAggregateOperation.ts 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..a078013e86db 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 = { @@ -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-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/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-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-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'; + From 80c9ebfd4ec1cbaaf4fabbb123590daae76ad635 Mon Sep 17 00:00:00 2001 From: martmull Date: Wed, 22 Jan 2025 15:53:40 +0100 Subject: [PATCH 06/18] Remove isGmailSendEmailScopeEnabled featureFlag (#9787) as title --- .../src/generated-metadata/graphql.ts | 127 +++++++++++----- .../twenty-front/src/generated/graphql.tsx | 137 ++++++++++++------ .../src/testing/mock-data/config.ts | 4 +- .../typeorm-seeds/core/feature-flags.ts | 5 - ...pis-oauth-exchange-code-for-token.guard.ts | 19 +-- .../google-apis-oauth-request-code.guard.ts | 12 +- .../auth/services/google-apis.service.ts | 10 +- .../google-apis-oauth-common.auth.strategy.ts | 3 +- ...h-exchange-code-for-token.auth.strategy.ts | 3 +- ...e-apis-oauth-request-code.auth.strategy.ts | 3 +- .../utils/get-google-apis-oauth-scopes.ts | 13 +- .../enums/feature-flag-key.enum.ts | 1 - 12 files changed, 196 insertions(+), 141 deletions(-) diff --git a/packages/twenty-front/src/generated-metadata/graphql.ts b/packages/twenty-front/src/generated-metadata/graphql.ts index 18a4b164a1c0..e243f4ff7c81 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,12 @@ 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 +631,7 @@ export type Mutation = { activateWorkspace: Workspace; authorizeApp: AuthorizeApp; challenge: LoginToken; - checkoutSession: SessionEntity; + checkoutSession: BillingSessionOutput; computeStepOutputSchema: Scalars['JSON']['output']; createDraftFromWorkflowVersion: WorkflowVersion; createOIDCIdentityProvider: SetupSsoOutput; @@ -593,7 +676,7 @@ export type Mutation = { syncRemoteTableSchemaChanges: RemoteTable; track: Analytics; unsyncRemoteTable: RemoteTable; - updateBillingSubscription: UpdateBillingEntity; + updateBillingSubscription: BillingUpdateOutput; updateLabPublicFeatureFlag: Scalars['Boolean']['output']; updateOneField: Field; updateOneObject: Object; @@ -982,20 +1065,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 +1094,7 @@ export type PublishServerlessFunctionInput = { export type Query = { __typename?: 'Query'; - billingPortalSession: SessionEntity; + billingPortalSession: BillingSessionOutput; checkUserExists: UserExistsOutput; checkWorkspaceInviteHashIsValid: WorkspaceInviteHashValid; clientConfig: ClientConfig; @@ -1043,7 +1112,7 @@ export type Query = { findWorkspaceInvitations: Array; getAvailablePackages: Scalars['JSON']['output']; getPostgresCredentials?: Maybe; - getProductPrices: ProductPricesEntity; + getProductPrices: BillingProductPricesOutput; getPublicWorkspaceDataBySubdomain: PublicWorkspaceDataOutput; getServerlessFunctionSourceCode?: Maybe; getTimelineCalendarEventsFromCompanyId: TimelineCalendarEventsWithTotal; @@ -1055,6 +1124,7 @@ export type Query = { listSSOIdentityProvidersByWorkspaceId: Array; object: Object; objects: ObjectConnection; + plans: Array; relation: Relation; relations: RelationConnection; validatePasswordResetToken: ValidatePasswordResetToken; @@ -1368,11 +1438,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 +1562,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 +1579,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..54fc0c64ceee 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,12 @@ 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 +556,7 @@ export type Mutation = { activateWorkspace: Workspace; authorizeApp: AuthorizeApp; challenge: LoginToken; - checkoutSession: SessionEntity; + checkoutSession: BillingSessionOutput; computeStepOutputSchema: Scalars['JSON']; createDraftFromWorkflowVersion: WorkflowVersion; createOIDCIdentityProvider: SetupSsoOutput; @@ -511,7 +594,7 @@ export type Mutation = { signUp: SignUpOutput; skipSyncEmailOnboardingStep: OnboardingStepSuccess; track: Analytics; - updateBillingSubscription: UpdateBillingEntity; + updateBillingSubscription: BillingUpdateOutput; updateLabPublicFeatureFlag: Scalars['Boolean']; updateOneField: Field; updateOneObject: Object; @@ -849,20 +932,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 +961,7 @@ export type PublishServerlessFunctionInput = { export type Query = { __typename?: 'Query'; - billingPortalSession: SessionEntity; + billingPortalSession: BillingSessionOutput; checkUserExists: UserExistsOutput; checkWorkspaceInviteHashIsValid: WorkspaceInviteHashValid; clientConfig: ClientConfig; @@ -907,7 +976,7 @@ export type Query = { findWorkspaceInvitations: Array; getAvailablePackages: Scalars['JSON']; getPostgresCredentials?: Maybe; - getProductPrices: ProductPricesEntity; + getProductPrices: BillingProductPricesOutput; getPublicWorkspaceDataBySubdomain: PublicWorkspaceDataOutput; getServerlessFunctionSourceCode?: Maybe; getTimelineCalendarEventsFromCompanyId: TimelineCalendarEventsWithTotal; @@ -919,6 +988,7 @@ export type Query = { listSSOIdentityProvidersByWorkspaceId: Array; object: Object; objects: ObjectConnection; + plans: Array; validatePasswordResetToken: ValidatePasswordResetToken; }; @@ -1158,11 +1228,6 @@ export enum ServerlessFunctionSyncStatus { READY = 'READY' } -export type SessionEntity = { - __typename?: 'SessionEntity'; - url?: Maybe; -}; - export type SetupOidcSsoInput = { clientID: Scalars['String']; clientSecret: Scalars['String']; @@ -1287,12 +1352,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 +1369,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 +2041,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 +2051,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/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-server/src/database/typeorm-seeds/core/feature-flags.ts b/packages/twenty-server/src/database/typeorm-seeds/core/feature-flags.ts index fd7da0e983c6..976cbd9809e4 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 @@ -55,11 +55,6 @@ export const seedFeatureFlags = async ( workspaceId: workspaceId, value: true, }, - { - key: FeatureFlagKey.IsGmailSendEmailScopeEnabled, - workspaceId: workspaceId, - value: true, - }, { key: FeatureFlagKey.IsUniqueIndexesEnabled, workspaceId: workspaceId, 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/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/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/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 41cf38df94d0..00ab59655f25 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 @@ -7,7 +7,6 @@ export enum FeatureFlagKey { 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', From 8ab01ebef44d9efe6bd46748e72f744b8da984ed Mon Sep 17 00:00:00 2001 From: Paul Rastoin <45004772+prastoin@users.noreply.github.com> Date: Wed, 22 Jan 2025 16:32:57 +0100 Subject: [PATCH 07/18] [BUG] Record settings not saved (#9762) # Introduction By initially fixing this Fixes #9381, discovered other behavior that have been fix. Overall we encountered a bug that corrupts a workspace and make the browser + api crash This issue https://github.com/twentyhq/core-team-issues/issues/25 suggests a refactor that has final save button instead of auto-save ## `labelIdentifierFieldMetadataId` form default value The default value resulted in being undefined, resulting in react hook form `labelIdentifierFieldMetadataId` is required field error. ### Fix Setting default value fallback to `null` as field is `nullable` ## `SettingsDataModelObjectSettingsFormCard` never triggers form Unless I'm mistaken in production touching any fields within `SettingsDataModelObjectSettingsFormCard` would never trigger form submission until you also modify `SettingsDataModelObjectAboutForm` fields ### Fix Provide and apply `onblur` that triggers the form on both `SettingsDataModelObjectSettingsFormCard` inputs ## Wrong default `labelIdentifierFieldMetadataItem` on first page render When landing on the page for the first time, if a custom `labelIdentifierFieldMetadataItem` has been set it won't be computed within the `PreviewCard`. Occurs when `labelIdentifierFieldMetadataId` form default value is undefined, due to `any` injection. ### Fix In the `getLabelIdentifierFieldMetadataItem` check the `labelIdentifierFieldMetadataIdFormValue` definition, if undefined fallback to current `objectMetadata` identifier --------- Co-authored-by: Charles Bochet --- .../__tests__/useSignInWithGoogle.test.ts | 1 + .../__tests__/useSignInWithMicrosoft.test.ts | 1 + .../useCreateOneObjectMetadataItem.ts | 2 +- .../useDeleteOneObjectMetadataItem.ts | 2 +- .../useFilteredObjectMetadataItems.ts | 2 +- .../types/ObjectMetadataItem.ts | 7 ++- .../__tests__/isLabelIdentifierField.test.ts | 16 ++++- ...bjectMetadataItemsToObjectMetadataItems.ts | 30 ++++++---- .../objectMetadataItemSchema.test.ts | 44 +++++++++++++- .../objectMetadataItemSchema.ts | 2 +- .../useAggregateRecordsQuery.test.tsx | 5 +- .../__tests__/turnSortsIntoOrderBy.test.ts | 1 + ...ildRecordGqlFieldsAggregateForView.test.ts | 2 +- .../useLimitPerMetadataItem.test.tsx | 1 + .../__tests__/generateAggregateQuery.test.ts | 2 + .../preview/hooks/useFieldPreviewValue.ts | 1 + .../components/tabs/ObjectSettings.tsx | 5 +- .../SettingsDataModelObjectAboutForm.tsx | 1 + ...SettingsDataModelObjectIdentifiersForm.tsx | 60 +++++++++---------- ...ettingsDataModelObjectSettingsFormCard.tsx | 34 ++++------- .../generated/mock-metadata-query-result.ts | 2 +- .../generatedMockObjectMetadataItems.ts | 29 +++++---- 22 files changed, 161 insertions(+), 89 deletions(-) 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/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-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 a078013e86db..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 @@ -18,7 +18,7 @@ describe('buildRecordGqlFieldsAggregateForView', () => { isActive: true, isSystem: false, isRemote: false, - labelIdentifierFieldMetadataId: null, + labelIdentifierFieldMetadataId: '06b33746-5293-4d07-9f7f-ebf5ad396064', imageIdentifierFieldMetadataId: null, isLabelSyncedWithName: true, fields: [ 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/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/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/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, + ), + })), + }; + }); From 984dc4dec0f281951c23571b71d541f9251a41b5 Mon Sep 17 00:00:00 2001 From: Paul Rastoin <45004772+prastoin@users.noreply.github.com> Date: Wed, 22 Jan 2025 16:33:48 +0100 Subject: [PATCH 08/18] [MISC] Twenty-emails as package (#9770) Charles has done everything within https://github.com/twentyhq/twenty/pull/9754 Last thing to be done is removing the custom module `path` configured in the `tsconfig.json` close https://github.com/twentyhq/core-team-issues/issues/282 --- packages/twenty-server/tsconfig.json | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) 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": { From b6626099481bc22aae15b450a0050c3472107776 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?J=C3=A9r=C3=A9my=20M?= Date: Wed, 22 Jan 2025 17:01:54 +0100 Subject: [PATCH 09/18] feat: add targetFieldMetadataId and migration script for relations (#9793) Fix https://github.com/twentyhq/core-team-issues/issues/238 and https://github.com/twentyhq/core-team-issues/issues/239 --- ...ate-relations-to-field-metadata.command.ts | 140 ++++++++++++++++++ .../0-41/0-41-upgrade-version.command.ts | 8 + .../0-41/0-41-upgrade-version.module.ts | 4 + ...tionTargetFieldAndObjectToFieldMetadata.ts | 55 +++++++ .../field-metadata/field-metadata.entity.ts | 27 ++++ .../field-metadata-settings.interface.ts | 9 ++ .../relation-on-delete-action.interface.ts | 6 + .../interfaces/relation-type.interface.ts | 5 + .../object-metadata/object-metadata.entity.ts | 6 + 9 files changed, 260 insertions(+) create mode 100644 packages/twenty-server/src/database/commands/upgrade-version/0-41/0-41-migrate-relations-to-field-metadata.command.ts create mode 100644 packages/twenty-server/src/database/typeorm/metadata/migrations/1737561084251-addRelationTargetFieldAndObjectToFieldMetadata.ts create mode 100644 packages/twenty-server/src/engine/metadata-modules/field-metadata/interfaces/relation-on-delete-action.interface.ts create mode 100644 packages/twenty-server/src/engine/metadata-modules/field-metadata/interfaces/relation-type.interface.ts 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-upgrade-version.command.ts b/packages/twenty-server/src/database/commands/upgrade-version/0-41/0-41-upgrade-version.command.ts index a60dbc99460e..5a8d6216e07b 100644 --- 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 @@ -5,6 +5,7 @@ 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'; @@ -19,6 +20,7 @@ export class UpgradeTo0_41Command extends ActiveWorkspacesCommandRunner { protected readonly workspaceRepository: Repository, private readonly seedWorkflowViewsCommand: SeedWorkflowViewsCommand, private readonly syncWorkspaceMetadataCommand: SyncWorkspaceMetadataCommand, + private readonly migrateRelationsToFieldMetadata: MigrateRelationsToFieldMetadataCommand, ) { super(workspaceRepository); } @@ -44,5 +46,11 @@ export class UpgradeTo0_41Command extends ActiveWorkspacesCommandRunner { 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 index 4c4026052693..fe417eaa2b7a 100644 --- 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 @@ -1,11 +1,13 @@ 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'; @@ -16,6 +18,7 @@ import { WorkspaceSyncMetadataModule } from 'src/engine/workspace-manager/worksp @Module({ imports: [ TypeOrmModule.forFeature([Workspace], 'core'), + TypeOrmModule.forFeature([FieldMetadataEntity], 'metadata'), TypeORMModule, DataSourceModule, ObjectMetadataModule, @@ -28,6 +31,7 @@ import { WorkspaceSyncMetadataModule } from 'src/engine/workspace-manager/worksp SyncWorkspaceMetadataCommand, SeedWorkflowViewsCommand, UpgradeTo0_41Command, + MigrateRelationsToFieldMetadataCommand, ], }) export class UpgradeTo0_41CommandModule {} 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/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', }) From d34060557872f9b507f2757ac2885c9dbee18f97 Mon Sep 17 00:00:00 2001 From: Charles Bochet Date: Wed, 22 Jan 2025 17:48:27 +0100 Subject: [PATCH 10/18] Fix View Picker broken (#9798) In this PR: - fixing a regression introduced in a recent PR (#9735) - fixing a typing issue in ViewPicker @ehconitin FYI --- .../twenty-front/src/modules/views/hooks/useSetViewInUrl.ts | 2 +- .../views/view-picker/components/ViewPickerListContent.tsx | 3 +-- .../views/view-picker/components/ViewPickerOptionDropdown.tsx | 2 +- 3 files changed, 3 insertions(+), 4 deletions(-) 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; }; From 5902dbd0b4042deca5a454b0db3fe5d07a790191 Mon Sep 17 00:00:00 2001 From: Weiko Date: Wed, 22 Jan 2025 17:49:11 +0100 Subject: [PATCH 11/18] Fix signup submit disabled when waiting for captcha without provider (#9796) --- .../src/modules/auth/hooks/useAuth.ts | 8 +++---- .../components/SignInUpWithCredentials.tsx | 22 +++++++++---------- .../CaptchaProviderScriptLoaderEffect.tsx | 18 ++++++--------- .../hooks/useRequestFreshCaptchaToken.ts | 14 +++++------- .../components/ClientConfigProviderEffect.tsx | 8 +++---- ...aptchaProviderState.ts => captchaState.ts} | 4 ++-- 6 files changed, 33 insertions(+), 41 deletions(-) rename packages/twenty-front/src/modules/client-config/states/{captchaProviderState.ts => captchaState.ts} (59%) 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/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/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, }); From e881616822bd819a473a5b9e136e97f04b291a96 Mon Sep 17 00:00:00 2001 From: Charles Bochet Date: Wed, 22 Jan 2025 18:11:21 +0100 Subject: [PATCH 12/18] Fix captcha token not being loaded early enough (#9800) I've re-investigated the captchaToken being invalid on workspace domain url in case where email is passed in the URL. We need to be a bit more granular --- .../components/SignInUpGlobalScopeForm.tsx | 2 +- .../SignInUpWorkspaceScopeFormEffect.tsx | 41 +++++++++++++++---- 2 files changed, 35 insertions(+), 8 deletions(-) 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/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 <>; From 3ab193f2987f0ed6d7fb17dddbff43f3a6b17a3c Mon Sep 17 00:00:00 2001 From: martmull Date: Wed, 22 Jan 2025 22:39:52 +0100 Subject: [PATCH 13/18] Remove isServerlessFunctionSettingsEnabled feature flag (#9797) Removes `isFunctionSettingsEnabled` feature flag We consider this featureFlag as false for everyone. We decided to keep the code in the code base for now --- packages/twenty-front/src/generated-metadata/graphql.ts | 1 - packages/twenty-front/src/generated/graphql.tsx | 1 - .../twenty-front/src/modules/app/components/AppRouter.tsx | 8 ++++---- .../src/modules/app/components/SettingsRoutes.tsx | 7 +++---- .../src/modules/app/hooks/useCreateAppRouter.tsx | 6 ++---- .../settings/components/SettingsNavigationDrawerItems.tsx | 8 +++++--- .../src/database/typeorm-seeds/core/feature-flags.ts | 5 ----- .../feature-flag/enums/feature-flag-key.enum.ts | 1 - 8 files changed, 14 insertions(+), 23 deletions(-) diff --git a/packages/twenty-front/src/generated-metadata/graphql.ts b/packages/twenty-front/src/generated-metadata/graphql.ts index e243f4ff7c81..2e838d5dbef4 100644 --- a/packages/twenty-front/src/generated-metadata/graphql.ts +++ b/packages/twenty-front/src/generated-metadata/graphql.ts @@ -460,7 +460,6 @@ export enum FeatureFlagKey { IsCopilotEnabled = 'IsCopilotEnabled', IsEventObjectEnabled = 'IsEventObjectEnabled', IsFreeAccessEnabled = 'IsFreeAccessEnabled', - IsFunctionSettingsEnabled = 'IsFunctionSettingsEnabled', IsJsonFilterEnabled = 'IsJsonFilterEnabled', IsLocalizationEnabled = 'IsLocalizationEnabled', IsMicrosoftSyncEnabled = 'IsMicrosoftSyncEnabled', diff --git a/packages/twenty-front/src/generated/graphql.tsx b/packages/twenty-front/src/generated/graphql.tsx index 54fc0c64ceee..61ef992049f7 100644 --- a/packages/twenty-front/src/generated/graphql.tsx +++ b/packages/twenty-front/src/generated/graphql.tsx @@ -392,7 +392,6 @@ export enum FeatureFlagKey { IsCopilotEnabled = 'IsCopilotEnabled', IsEventObjectEnabled = 'IsEventObjectEnabled', IsFreeAccessEnabled = 'IsFreeAccessEnabled', - IsFunctionSettingsEnabled = 'IsFunctionSettingsEnabled', IsJsonFilterEnabled = 'IsJsonFilterEnabled', IsLocalizationEnabled = 'IsLocalizationEnabled', IsMicrosoftSyncEnabled = 'IsMicrosoftSyncEnabled', 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/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-server/src/database/typeorm-seeds/core/feature-flags.ts b/packages/twenty-server/src/database/typeorm-seeds/core/feature-flags.ts index 976cbd9809e4..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, 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 00ab59655f25..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,7 +5,6 @@ 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', IsAnalyticsV2Enabled = 'IS_ANALYTICS_V2_ENABLED', IsUniqueIndexesEnabled = 'IS_UNIQUE_INDEXES_ENABLED', From bddca094511d0355d956d88d92c4b22b201d6d51 Mon Sep 17 00:00:00 2001 From: Lucas Bordeau Date: Thu, 23 Jan 2025 11:09:44 +0100 Subject: [PATCH 14/18] Refactored table filters to consume new currentRecordFilters component state (#9652) This PR implements a first real use case, now currentRecordFilters component state acts as the global record filter reference. It is set by the view initially and can be reset to view filters state at any point. This new state is also modified by two new upsertRecordFilter / removeRecordFilter hooks that will be drop-in replacement of the actual upsertCombinedViewFilter and removeCombinediewFilter hooks. This PR implements the logic to manipulate record filters but only reads it to make the table find many request, all other features are still relying on the old view filter implementation. Advanced filters are ignored because they are hidden and because this effort is made precisely to allow the completion of the advanced filters feature. --- .../RecordIndexActionMenuBar.stories.tsx | 102 ++++----- .../components/CommandMenuContainer.tsx | 59 +++--- .../__stories__/CommandMenu.stories.tsx | 19 +- .../hooks/useDeleteFavoriteFolder.ts | 9 +- .../hooks/usePrefetchedFavoritesData.ts | 4 +- .../usePrefetchedFavoritesFoldersData.ts | 4 +- .../ObjectFilterDropdownSourceSelect.tsx | 9 + .../MultipleFiltersDropdownButton.stories.tsx | 21 +- ...vailableFilterDefinitionsComponentState.ts | 1 + .../ObjectOptionsDropdownContent.stories.tsx | 33 +-- .../__tests__/useRemoveRecordFilter.test.tsx | 117 +++++++++++ .../__tests__/useUpsertRecordFilter.test.tsx | 112 ++++++++++ .../hooks/useApplyRecordFilter.ts | 24 +-- .../hooks/useRemoveRecordFilter.ts | 46 ++++ .../hooks/useUpsertRecordFilter.ts | 54 +++++ .../RecordFiltersComponentInstanceContext.ts | 4 + .../currentRecordFiltersComponentState.ts | 11 + .../__tests__/isMatchingArrayFilter.test.ts | 128 +++++++++++ .../useFindManyRecordIndexTableParams.ts | 13 +- .../hooks/useHandleToggleColumnFilter.ts | 5 + .../hooks/useHandleToggleTrashColumnFilter.ts | 11 +- .../components/RightDrawerRecord.tsx | 49 +++-- .../RecordTableEmptyStateSoftDelete.tsx | 21 +- .../components/PrefetchRunQueriesEffect.tsx | 8 +- ... useUpsertRecordsInCacheForPrefetchKey.ts} | 3 +- .../SignInBackgroundMockContainer.tsx | 49 +++-- .../EditableFilterDropdownButton.tsx | 9 +- .../components/QueryParamsFiltersEffect.tsx | 6 + .../views/components/VariantFilterChip.tsx | 5 + .../src/modules/views/components/ViewBar.tsx | 2 + .../views/components/ViewBarDetails.tsx | 6 + .../views/components/ViewBarFilterEffect.tsx | 22 +- .../components/ViewBarRecordFilterEffect.tsx | 50 +++++ ...ViewFiltersToCurrentRecordFilters.test.tsx | 198 ++++++++++++++++++ ...ViewFiltersToCurrentRecordFilters.test.tsx | 108 ++++++++++ ...urrentViewFiltersToCurrentRecordFilters.ts | 42 ++++ ...eApplyViewFiltersToCurrentRecordFilters.ts | 31 +++ .../pages/object-record/RecordIndexPage.tsx | 39 ++-- .../pages/object-record/RecordShowPage.tsx | 105 +++++----- .../src/testing/decorators/PageDecorator.tsx | 9 +- ...taAndApolloMocksAndContextStoreWrapper.tsx | 39 ++-- .../getJestMetadataAndApolloMocksWrapper.tsx | 18 +- 42 files changed, 1303 insertions(+), 302 deletions(-) create mode 100644 packages/twenty-front/src/modules/object-record/record-filter/hooks/__tests__/useRemoveRecordFilter.test.tsx create mode 100644 packages/twenty-front/src/modules/object-record/record-filter/hooks/__tests__/useUpsertRecordFilter.test.tsx create mode 100644 packages/twenty-front/src/modules/object-record/record-filter/hooks/useRemoveRecordFilter.ts create mode 100644 packages/twenty-front/src/modules/object-record/record-filter/hooks/useUpsertRecordFilter.ts create mode 100644 packages/twenty-front/src/modules/object-record/record-filter/states/context/RecordFiltersComponentInstanceContext.ts create mode 100644 packages/twenty-front/src/modules/object-record/record-filter/states/currentRecordFiltersComponentState.ts create mode 100644 packages/twenty-front/src/modules/object-record/record-filter/utils/__tests__/isMatchingArrayFilter.test.ts rename packages/twenty-front/src/modules/prefetch/hooks/internal/{usePrefetchRunQuery.ts => useUpsertRecordsInCacheForPrefetchKey.ts} (94%) create mode 100644 packages/twenty-front/src/modules/views/components/ViewBarRecordFilterEffect.tsx create mode 100644 packages/twenty-front/src/modules/views/hooks/__tests__/useApplyCurrentViewFiltersToCurrentRecordFilters.test.tsx create mode 100644 packages/twenty-front/src/modules/views/hooks/__tests__/useApplyViewFiltersToCurrentRecordFilters.test.tsx create mode 100644 packages/twenty-front/src/modules/views/hooks/useApplyCurrentViewFiltersToCurrentRecordFilters.ts create mode 100644 packages/twenty-front/src/modules/views/hooks/useApplyViewFiltersToCurrentRecordFilters.ts 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/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/__stories__/CommandMenu.stories.tsx b/packages/twenty-front/src/modules/command-menu/components/__stories__/CommandMenu.stories.tsx index efd71ca5ecd5..b7e2dcd684df 100644 --- a/packages/twenty-front/src/modules/command-menu/components/__stories__/CommandMenu.stories.tsx +++ b/packages/twenty-front/src/modules/command-menu/components/__stories__/CommandMenu.stories.tsx @@ -19,6 +19,7 @@ import { ActionMenuComponentInstanceContext } from '@/action-menu/states/context import { CommandMenuRouter } from '@/command-menu/components/CommandMenuRouter'; import { isCommandMenuOpenedState } from '@/command-menu/states/isCommandMenuOpenedState'; import { ContextStoreComponentInstanceContext } from '@/context-store/states/contexts/ContextStoreComponentInstanceContext'; +import { RecordFiltersComponentInstanceContext } from '@/object-record/record-filter/states/context/RecordFiltersComponentInstanceContext'; import { I18nFrontDecorator } from '~/testing/decorators/I18nFrontDecorator'; import { JestContextStoreSetter } from '~/testing/jest/JestContextStoreSetter'; import { CommandMenu } from '../CommandMenu'; @@ -29,17 +30,21 @@ const openTimeout = 50; const ContextStoreDecorator: Decorator = (Story) => { return ( - - - - - - - + + + + + + + ); }; diff --git a/packages/twenty-front/src/modules/favorites/hooks/useDeleteFavoriteFolder.ts b/packages/twenty-front/src/modules/favorites/hooks/useDeleteFavoriteFolder.ts index 4c0925ca622a..c10adccb88f3 100644 --- a/packages/twenty-front/src/modules/favorites/hooks/useDeleteFavoriteFolder.ts +++ b/packages/twenty-front/src/modules/favorites/hooks/useDeleteFavoriteFolder.ts @@ -4,7 +4,7 @@ import { CoreObjectNameSingular } from '@/object-metadata/types/CoreObjectNameSi import { useReadFindManyRecordsQueryInCache } from '@/object-record/cache/hooks/useReadFindManyRecordsQueryInCache'; import { useDeleteOneRecord } from '@/object-record/hooks/useDeleteOneRecord'; 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'; export const useDeleteFavoriteFolder = () => { @@ -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-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/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/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/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/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/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/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/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} + + + From 2f0fa7ae3eaecc06782aeda1bf8a884fd82a066f Mon Sep 17 00:00:00 2001 From: Guillim Date: Thu, 23 Jan 2025 12:08:08 +0100 Subject: [PATCH 15/18] Microsoft-multi-tenant (#9801) Microsoft fixes --- packages/twenty-server/.env.example | 1 - .../core-modules/auth/auth.exception.ts | 1 + ...pis-oauth-exchange-code-for-token.guard.ts | 37 ++++++++++++++----- ...crosoft-apis-oauth-common.auth.strategy.ts | 2 +- .../strategies/microsoft.auth.strategy.ts | 2 +- .../utils/get-microsoft-apis-oauth-scopes.ts | 1 + .../environment/environment-variables.ts | 4 -- ...microsoft-oauth2-client-manager.service.ts | 6 +-- ...contacts-from-company-or-workspace.util.ts | 7 +++- .../twenty-server/src/utils/is-work-email.ts | 4 ++ .../content/developers/self-hosting/setup.mdx | 4 +- 11 files changed, 43 insertions(+), 26 deletions(-) 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/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/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/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-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/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/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/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-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'], From e7ba1c82b492b14eeedfd8708393c9235bbf82d7 Mon Sep 17 00:00:00 2001 From: Guillim Date: Thu, 23 Jan 2025 12:22:03 +0100 Subject: [PATCH 16/18] isMicrosoftAuthEnabled = true (#9812) --- ...e-entity-default-microsoft-auth-enabled.ts | 19 +++++++++++++++++++ .../workspace/workspace.entity.ts | 2 +- 2 files changed, 20 insertions(+), 1 deletion(-) create mode 100644 packages/twenty-server/src/database/typeorm/core/migrations/common/1737630672873-workspace-entity-default-microsoft-auth-enabled.ts 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/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; } From cc53cb3b7b11c23dff0652b23a21795586944f8c Mon Sep 17 00:00:00 2001 From: Ana Sofia Marin Alexandre <61988046+anamarn@users.noreply.github.com> Date: Thu, 23 Jan 2025 10:28:13 -0300 Subject: [PATCH 17/18] Add metered product to checkout session (#9788) Solves https://github.com/twentyhq/private-issues/issues/238 **TLDR:** Add metered product in the checkout session when purchasing a subscription **In order to test:** 1. Have the environment variable IS_BILLING_ENABLED set to true and add the other required environment variables for Billing to work 2. Do a database reset (to ensure that the new feature flag is properly added and that the billing tables are created) 3. Run the command: npx nx run twenty-server:command billing:sync-plans-data (if you don't do that the products and prices will not be present in the database) 4. Run the server , the frontend, the worker, and the stripe listen command (stripe listen --forward-to http://localhost:3000/billing/webhooks) 5. Buy a subscription for the Acme workspace , in the checkout session you should see that there is two products --- .../core-modules/billing/billing.exception.ts | 1 + .../core-modules/billing/billing.resolver.ts | 58 ++++++---- .../billing/services/billing-plan.service.ts | 46 ++++++++ .../billing-portal.workspace-service.ts | 108 +++++++++++++----- .../services/stripe-checkout.service.ts | 21 ++-- ...billing-get-prices-per-plan-result.type.ts | 7 ++ ...portal-checkout-session-parameters.type.ts | 14 +++ 7 files changed, 193 insertions(+), 62 deletions(-) create mode 100644 packages/twenty-server/src/engine/core-modules/billing/types/billing-get-prices-per-plan-result.type.ts create mode 100644 packages/twenty-server/src/engine/core-modules/billing/types/billing-portal-checkout-session-parameters.type.ts 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 8e5ae8fc1ccf..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,7 @@ 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.resolver.ts b/packages/twenty-server/src/engine/core-modules/billing/billing.resolver.ts index 11df9347c956..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 @@ -16,6 +16,7 @@ import { BillingPlanService } from 'src/engine/core-modules/billing/services/bil 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'; @@ -84,42 +85,49 @@ export class BillingResolver { workspace.id, ); - let productPrice; + const checkoutSessionParams: BillingPortalCheckoutSessionParameters = { + user, + workspace, + successUrlPath, + plan: plan ?? BillingPlanKey.PRO, + requirePaymentMethod, + }; if (isBillingPlansEnabled) { - const baseProduct = await this.billingPlanService.getPlanBaseProduct( - plan ?? BillingPlanKey.PRO, - ); - - if (!baseProduct) { - throw new GraphQLError('Base product not found'); - } - - productPrice = baseProduct.billingPrices.find( - (price) => price.interval === recurringInterval, - ); - } else { - productPrice = await this.stripePriceService.getStripePrice( - AvailableProduct.BasePlan, - recurringInterval, - ); + 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 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, }; } 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 index ba3d35678d6c..cecf60a80630 100644 --- 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 @@ -9,8 +9,10 @@ import { } 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 { @@ -104,4 +106,48 @@ export class BillingPlanService { }; }); } + + 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/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/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; +}; From 337b6a86ab40ce2e4cebfbd91677bab880be18b9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Rapha=C3=ABl=20Bosi?= <71827178+bosiraphael@users.noreply.github.com> Date: Thu, 23 Jan 2025 14:44:21 +0100 Subject: [PATCH 18/18] 251 create top bar chips inside the command menu (#9809) Closes #https://github.com/twentyhq/core-team-issues/issues/251 https://github.com/user-attachments/assets/065c97fe-1daf-4b48-9d57-6bbb96d24ede --- .../hooks/useOpenCalendarEventRightDrawer.ts | 6 +- .../hooks/useOpenCopilotRightDrawer.ts | 6 +- .../useOpenEmailThreadRightDrawer.test.ts | 5 + .../hooks/useOpenEmailThreadRightDrawer.ts | 6 +- .../components/CommandMenuContextChip.tsx | 63 +++++ .../CommandMenuContextRecordChip.tsx | 56 ++-- .../CommandMenuContextRecordChipAvatars.tsx | 18 +- .../components/CommandMenuTopBar.tsx | 14 + .../CommandMenuContextChip.stories.tsx | 53 ++++ .../CommandMenuContextRecordChip.stories.tsx | 261 ++++++++++++++++++ .../command-menu/hooks/useCommandMenu.ts | 10 + .../states/commandMenuPageTitle.ts | 10 + .../right-drawer/hooks/useRightDrawer.ts | 14 +- .../WorkflowDiagramCanvasEditableEffect.tsx | 20 +- .../WorkflowDiagramCanvasReadonlyEffect.tsx | 13 +- .../WorkflowDiagramStepNodeBase.tsx | 23 +- .../hooks/useStartNodeCreation.ts | 6 +- .../utils/getWorkflowNodeIcon.ts | 56 ++++ .../hooks/__tests__/useCreateStep.test.ts | 2 +- .../workflow-steps/hooks/useCreateStep.ts | 13 +- ...DrawerWorkflowSelectTriggerTypeContent.tsx | 10 +- 21 files changed, 582 insertions(+), 83 deletions(-) create mode 100644 packages/twenty-front/src/modules/command-menu/components/CommandMenuContextChip.tsx create mode 100644 packages/twenty-front/src/modules/command-menu/components/__stories__/CommandMenuContextChip.stories.tsx create mode 100644 packages/twenty-front/src/modules/command-menu/components/__stories__/CommandMenuContextRecordChip.stories.tsx create mode 100644 packages/twenty-front/src/modules/command-menu/states/commandMenuPageTitle.ts create mode 100644 packages/twenty-front/src/modules/workflow/workflow-diagram/utils/getWorkflowNodeIcon.ts 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/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 && ( = { + 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/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/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/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/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, + }); }} /> ))}