From cb1e15e0ed5891b42d5d0483295fcc6e75427745 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?R=C3=A9mi=20Bonnet?= Date: Mon, 24 Aug 2026 15:18:03 +0200 Subject: [PATCH 1/5] feat(terraform): move variables to service variables tab (#2897) * feat(terraform): move variables to service variables tab * fix(terraform): align variables tab layout * fix(terraform): move save action outside variables panel * fix(terraform): refine variables tab integration * refactor(terraform): keep variables table styling flexible * chore(terraform): document variables action portal * fix(terraform): refine variables tab actions --- apps/console/src/routeTree.gen.ts | 52 ++++++------- .../service/$serviceId/variables/route.tsx | 20 ++++- .../terraform.tsx} | 74 ++++++++----------- .../service-settings-layout.tsx | 7 -- .../terraform-tfvars-popover.tsx | 2 +- .../terraform-variables-table.tsx | 19 +++-- .../use-contextual-doc-links.spec.ts | 11 +++ .../use-contextual-doc-links.ts | 2 +- 8 files changed, 101 insertions(+), 86 deletions(-) rename apps/console/src/routes/_authenticated/organization/$organizationId/project/$projectId/environment/$environmentId/service/$serviceId/{settings/terraform-variables.tsx => variables/terraform.tsx} (52%) diff --git a/apps/console/src/routeTree.gen.ts b/apps/console/src/routeTree.gen.ts index 46523ec3148..bd2df97dc6b 100644 --- a/apps/console/src/routeTree.gen.ts +++ b/apps/console/src/routeTree.gen.ts @@ -167,11 +167,11 @@ import { Route as AuthenticatedOrganizationOrganizationIdProjectProjectIdEnviron import { Route as AuthenticatedOrganizationOrganizationIdProjectProjectIdEnvironmentEnvironmentIdServiceCreateSlugPortsRouteImport } from './routes/_authenticated/organization/$organizationId/project/$projectId/environment/$environmentId/service/create/$slug/ports' import { Route as AuthenticatedOrganizationOrganizationIdProjectProjectIdEnvironmentEnvironmentIdServiceCreateSlugHealthChecksRouteImport } from './routes/_authenticated/organization/$organizationId/project/$projectId/environment/$environmentId/service/create/$slug/health-checks' import { Route as AuthenticatedOrganizationOrganizationIdProjectProjectIdEnvironmentEnvironmentIdServiceCreateSlugGeneralRouteImport } from './routes/_authenticated/organization/$organizationId/project/$projectId/environment/$environmentId/service/create/$slug/general' +import { Route as AuthenticatedOrganizationOrganizationIdProjectProjectIdEnvironmentEnvironmentIdServiceServiceIdVariablesTerraformRouteImport } from './routes/_authenticated/organization/$organizationId/project/$projectId/environment/$environmentId/service/$serviceId/variables/terraform' import { Route as AuthenticatedOrganizationOrganizationIdProjectProjectIdEnvironmentEnvironmentIdServiceServiceIdVariablesExternalSecretsRouteImport } from './routes/_authenticated/organization/$organizationId/project/$projectId/environment/$environmentId/service/$serviceId/variables/external-secrets' import { Route as AuthenticatedOrganizationOrganizationIdProjectProjectIdEnvironmentEnvironmentIdServiceServiceIdVariablesBuiltInRouteImport } from './routes/_authenticated/organization/$organizationId/project/$projectId/environment/$environmentId/service/$serviceId/variables/built-in' import { Route as AuthenticatedOrganizationOrganizationIdProjectProjectIdEnvironmentEnvironmentIdServiceServiceIdSettingsValuesOverrideFileRouteImport } from './routes/_authenticated/organization/$organizationId/project/$projectId/environment/$environmentId/service/$serviceId/settings/values-override-file' import { Route as AuthenticatedOrganizationOrganizationIdProjectProjectIdEnvironmentEnvironmentIdServiceServiceIdSettingsValuesOverrideArgumentsRouteImport } from './routes/_authenticated/organization/$organizationId/project/$projectId/environment/$environmentId/service/$serviceId/settings/values-override-arguments' -import { Route as AuthenticatedOrganizationOrganizationIdProjectProjectIdEnvironmentEnvironmentIdServiceServiceIdSettingsTerraformVariablesRouteImport } from './routes/_authenticated/organization/$organizationId/project/$projectId/environment/$environmentId/service/$serviceId/settings/terraform-variables' import { Route as AuthenticatedOrganizationOrganizationIdProjectProjectIdEnvironmentEnvironmentIdServiceServiceIdSettingsTerraformConfigurationRouteImport } from './routes/_authenticated/organization/$organizationId/project/$projectId/environment/$environmentId/service/$serviceId/settings/terraform-configuration' import { Route as AuthenticatedOrganizationOrganizationIdProjectProjectIdEnvironmentEnvironmentIdServiceServiceIdSettingsTerraformArgumentsRouteImport } from './routes/_authenticated/organization/$organizationId/project/$projectId/environment/$environmentId/service/$serviceId/settings/terraform-arguments' import { Route as AuthenticatedOrganizationOrganizationIdProjectProjectIdEnvironmentEnvironmentIdServiceServiceIdSettingsStorageRouteImport } from './routes/_authenticated/organization/$organizationId/project/$projectId/environment/$environmentId/service/$serviceId/settings/storage' @@ -1490,6 +1490,15 @@ const AuthenticatedOrganizationOrganizationIdProjectProjectIdEnvironmentEnvironm AuthenticatedOrganizationOrganizationIdProjectProjectIdEnvironmentEnvironmentIdServiceCreateSlugRouteRoute, } as any, ) +const AuthenticatedOrganizationOrganizationIdProjectProjectIdEnvironmentEnvironmentIdServiceServiceIdVariablesTerraformRoute = + AuthenticatedOrganizationOrganizationIdProjectProjectIdEnvironmentEnvironmentIdServiceServiceIdVariablesTerraformRouteImport.update( + { + id: '/terraform', + path: '/terraform', + getParentRoute: () => + AuthenticatedOrganizationOrganizationIdProjectProjectIdEnvironmentEnvironmentIdServiceServiceIdVariablesRouteRoute, + } as any, + ) const AuthenticatedOrganizationOrganizationIdProjectProjectIdEnvironmentEnvironmentIdServiceServiceIdVariablesExternalSecretsRoute = AuthenticatedOrganizationOrganizationIdProjectProjectIdEnvironmentEnvironmentIdServiceServiceIdVariablesExternalSecretsRouteImport.update( { @@ -1526,15 +1535,6 @@ const AuthenticatedOrganizationOrganizationIdProjectProjectIdEnvironmentEnvironm AuthenticatedOrganizationOrganizationIdProjectProjectIdEnvironmentEnvironmentIdServiceServiceIdSettingsRouteRoute, } as any, ) -const AuthenticatedOrganizationOrganizationIdProjectProjectIdEnvironmentEnvironmentIdServiceServiceIdSettingsTerraformVariablesRoute = - AuthenticatedOrganizationOrganizationIdProjectProjectIdEnvironmentEnvironmentIdServiceServiceIdSettingsTerraformVariablesRouteImport.update( - { - id: '/terraform-variables', - path: '/terraform-variables', - getParentRoute: () => - AuthenticatedOrganizationOrganizationIdProjectProjectIdEnvironmentEnvironmentIdServiceServiceIdSettingsRouteRoute, - } as any, - ) const AuthenticatedOrganizationOrganizationIdProjectProjectIdEnvironmentEnvironmentIdServiceServiceIdSettingsTerraformConfigurationRoute = AuthenticatedOrganizationOrganizationIdProjectProjectIdEnvironmentEnvironmentIdServiceServiceIdSettingsTerraformConfigurationRouteImport.update( { @@ -1958,11 +1958,11 @@ export interface FileRoutesByFullPath { '/organization/$organizationId/project/$projectId/environment/$environmentId/service/$serviceId/settings/storage': typeof AuthenticatedOrganizationOrganizationIdProjectProjectIdEnvironmentEnvironmentIdServiceServiceIdSettingsStorageRoute '/organization/$organizationId/project/$projectId/environment/$environmentId/service/$serviceId/settings/terraform-arguments': typeof AuthenticatedOrganizationOrganizationIdProjectProjectIdEnvironmentEnvironmentIdServiceServiceIdSettingsTerraformArgumentsRoute '/organization/$organizationId/project/$projectId/environment/$environmentId/service/$serviceId/settings/terraform-configuration': typeof AuthenticatedOrganizationOrganizationIdProjectProjectIdEnvironmentEnvironmentIdServiceServiceIdSettingsTerraformConfigurationRoute - '/organization/$organizationId/project/$projectId/environment/$environmentId/service/$serviceId/settings/terraform-variables': typeof AuthenticatedOrganizationOrganizationIdProjectProjectIdEnvironmentEnvironmentIdServiceServiceIdSettingsTerraformVariablesRoute '/organization/$organizationId/project/$projectId/environment/$environmentId/service/$serviceId/settings/values-override-arguments': typeof AuthenticatedOrganizationOrganizationIdProjectProjectIdEnvironmentEnvironmentIdServiceServiceIdSettingsValuesOverrideArgumentsRoute '/organization/$organizationId/project/$projectId/environment/$environmentId/service/$serviceId/settings/values-override-file': typeof AuthenticatedOrganizationOrganizationIdProjectProjectIdEnvironmentEnvironmentIdServiceServiceIdSettingsValuesOverrideFileRoute '/organization/$organizationId/project/$projectId/environment/$environmentId/service/$serviceId/variables/built-in': typeof AuthenticatedOrganizationOrganizationIdProjectProjectIdEnvironmentEnvironmentIdServiceServiceIdVariablesBuiltInRoute '/organization/$organizationId/project/$projectId/environment/$environmentId/service/$serviceId/variables/external-secrets': typeof AuthenticatedOrganizationOrganizationIdProjectProjectIdEnvironmentEnvironmentIdServiceServiceIdVariablesExternalSecretsRoute + '/organization/$organizationId/project/$projectId/environment/$environmentId/service/$serviceId/variables/terraform': typeof AuthenticatedOrganizationOrganizationIdProjectProjectIdEnvironmentEnvironmentIdServiceServiceIdVariablesTerraformRoute '/organization/$organizationId/project/$projectId/environment/$environmentId/service/create/$slug/general': typeof AuthenticatedOrganizationOrganizationIdProjectProjectIdEnvironmentEnvironmentIdServiceCreateSlugGeneralRoute '/organization/$organizationId/project/$projectId/environment/$environmentId/service/create/$slug/health-checks': typeof AuthenticatedOrganizationOrganizationIdProjectProjectIdEnvironmentEnvironmentIdServiceCreateSlugHealthChecksRoute '/organization/$organizationId/project/$projectId/environment/$environmentId/service/create/$slug/ports': typeof AuthenticatedOrganizationOrganizationIdProjectProjectIdEnvironmentEnvironmentIdServiceCreateSlugPortsRoute @@ -2133,11 +2133,11 @@ export interface FileRoutesByTo { '/organization/$organizationId/project/$projectId/environment/$environmentId/service/$serviceId/settings/storage': typeof AuthenticatedOrganizationOrganizationIdProjectProjectIdEnvironmentEnvironmentIdServiceServiceIdSettingsStorageRoute '/organization/$organizationId/project/$projectId/environment/$environmentId/service/$serviceId/settings/terraform-arguments': typeof AuthenticatedOrganizationOrganizationIdProjectProjectIdEnvironmentEnvironmentIdServiceServiceIdSettingsTerraformArgumentsRoute '/organization/$organizationId/project/$projectId/environment/$environmentId/service/$serviceId/settings/terraform-configuration': typeof AuthenticatedOrganizationOrganizationIdProjectProjectIdEnvironmentEnvironmentIdServiceServiceIdSettingsTerraformConfigurationRoute - '/organization/$organizationId/project/$projectId/environment/$environmentId/service/$serviceId/settings/terraform-variables': typeof AuthenticatedOrganizationOrganizationIdProjectProjectIdEnvironmentEnvironmentIdServiceServiceIdSettingsTerraformVariablesRoute '/organization/$organizationId/project/$projectId/environment/$environmentId/service/$serviceId/settings/values-override-arguments': typeof AuthenticatedOrganizationOrganizationIdProjectProjectIdEnvironmentEnvironmentIdServiceServiceIdSettingsValuesOverrideArgumentsRoute '/organization/$organizationId/project/$projectId/environment/$environmentId/service/$serviceId/settings/values-override-file': typeof AuthenticatedOrganizationOrganizationIdProjectProjectIdEnvironmentEnvironmentIdServiceServiceIdSettingsValuesOverrideFileRoute '/organization/$organizationId/project/$projectId/environment/$environmentId/service/$serviceId/variables/built-in': typeof AuthenticatedOrganizationOrganizationIdProjectProjectIdEnvironmentEnvironmentIdServiceServiceIdVariablesBuiltInRoute '/organization/$organizationId/project/$projectId/environment/$environmentId/service/$serviceId/variables/external-secrets': typeof AuthenticatedOrganizationOrganizationIdProjectProjectIdEnvironmentEnvironmentIdServiceServiceIdVariablesExternalSecretsRoute + '/organization/$organizationId/project/$projectId/environment/$environmentId/service/$serviceId/variables/terraform': typeof AuthenticatedOrganizationOrganizationIdProjectProjectIdEnvironmentEnvironmentIdServiceServiceIdVariablesTerraformRoute '/organization/$organizationId/project/$projectId/environment/$environmentId/service/create/$slug/general': typeof AuthenticatedOrganizationOrganizationIdProjectProjectIdEnvironmentEnvironmentIdServiceCreateSlugGeneralRoute '/organization/$organizationId/project/$projectId/environment/$environmentId/service/create/$slug/health-checks': typeof AuthenticatedOrganizationOrganizationIdProjectProjectIdEnvironmentEnvironmentIdServiceCreateSlugHealthChecksRoute '/organization/$organizationId/project/$projectId/environment/$environmentId/service/create/$slug/ports': typeof AuthenticatedOrganizationOrganizationIdProjectProjectIdEnvironmentEnvironmentIdServiceCreateSlugPortsRoute @@ -2330,11 +2330,11 @@ export interface FileRoutesById { '/_authenticated/organization/$organizationId/project/$projectId/environment/$environmentId/service/$serviceId/settings/storage': typeof AuthenticatedOrganizationOrganizationIdProjectProjectIdEnvironmentEnvironmentIdServiceServiceIdSettingsStorageRoute '/_authenticated/organization/$organizationId/project/$projectId/environment/$environmentId/service/$serviceId/settings/terraform-arguments': typeof AuthenticatedOrganizationOrganizationIdProjectProjectIdEnvironmentEnvironmentIdServiceServiceIdSettingsTerraformArgumentsRoute '/_authenticated/organization/$organizationId/project/$projectId/environment/$environmentId/service/$serviceId/settings/terraform-configuration': typeof AuthenticatedOrganizationOrganizationIdProjectProjectIdEnvironmentEnvironmentIdServiceServiceIdSettingsTerraformConfigurationRoute - '/_authenticated/organization/$organizationId/project/$projectId/environment/$environmentId/service/$serviceId/settings/terraform-variables': typeof AuthenticatedOrganizationOrganizationIdProjectProjectIdEnvironmentEnvironmentIdServiceServiceIdSettingsTerraformVariablesRoute '/_authenticated/organization/$organizationId/project/$projectId/environment/$environmentId/service/$serviceId/settings/values-override-arguments': typeof AuthenticatedOrganizationOrganizationIdProjectProjectIdEnvironmentEnvironmentIdServiceServiceIdSettingsValuesOverrideArgumentsRoute '/_authenticated/organization/$organizationId/project/$projectId/environment/$environmentId/service/$serviceId/settings/values-override-file': typeof AuthenticatedOrganizationOrganizationIdProjectProjectIdEnvironmentEnvironmentIdServiceServiceIdSettingsValuesOverrideFileRoute '/_authenticated/organization/$organizationId/project/$projectId/environment/$environmentId/service/$serviceId/variables/built-in': typeof AuthenticatedOrganizationOrganizationIdProjectProjectIdEnvironmentEnvironmentIdServiceServiceIdVariablesBuiltInRoute '/_authenticated/organization/$organizationId/project/$projectId/environment/$environmentId/service/$serviceId/variables/external-secrets': typeof AuthenticatedOrganizationOrganizationIdProjectProjectIdEnvironmentEnvironmentIdServiceServiceIdVariablesExternalSecretsRoute + '/_authenticated/organization/$organizationId/project/$projectId/environment/$environmentId/service/$serviceId/variables/terraform': typeof AuthenticatedOrganizationOrganizationIdProjectProjectIdEnvironmentEnvironmentIdServiceServiceIdVariablesTerraformRoute '/_authenticated/organization/$organizationId/project/$projectId/environment/$environmentId/service/create/$slug/general': typeof AuthenticatedOrganizationOrganizationIdProjectProjectIdEnvironmentEnvironmentIdServiceCreateSlugGeneralRoute '/_authenticated/organization/$organizationId/project/$projectId/environment/$environmentId/service/create/$slug/health-checks': typeof AuthenticatedOrganizationOrganizationIdProjectProjectIdEnvironmentEnvironmentIdServiceCreateSlugHealthChecksRoute '/_authenticated/organization/$organizationId/project/$projectId/environment/$environmentId/service/create/$slug/ports': typeof AuthenticatedOrganizationOrganizationIdProjectProjectIdEnvironmentEnvironmentIdServiceCreateSlugPortsRoute @@ -2528,11 +2528,11 @@ export interface FileRouteTypes { | '/organization/$organizationId/project/$projectId/environment/$environmentId/service/$serviceId/settings/storage' | '/organization/$organizationId/project/$projectId/environment/$environmentId/service/$serviceId/settings/terraform-arguments' | '/organization/$organizationId/project/$projectId/environment/$environmentId/service/$serviceId/settings/terraform-configuration' - | '/organization/$organizationId/project/$projectId/environment/$environmentId/service/$serviceId/settings/terraform-variables' | '/organization/$organizationId/project/$projectId/environment/$environmentId/service/$serviceId/settings/values-override-arguments' | '/organization/$organizationId/project/$projectId/environment/$environmentId/service/$serviceId/settings/values-override-file' | '/organization/$organizationId/project/$projectId/environment/$environmentId/service/$serviceId/variables/built-in' | '/organization/$organizationId/project/$projectId/environment/$environmentId/service/$serviceId/variables/external-secrets' + | '/organization/$organizationId/project/$projectId/environment/$environmentId/service/$serviceId/variables/terraform' | '/organization/$organizationId/project/$projectId/environment/$environmentId/service/create/$slug/general' | '/organization/$organizationId/project/$projectId/environment/$environmentId/service/create/$slug/health-checks' | '/organization/$organizationId/project/$projectId/environment/$environmentId/service/create/$slug/ports' @@ -2703,11 +2703,11 @@ export interface FileRouteTypes { | '/organization/$organizationId/project/$projectId/environment/$environmentId/service/$serviceId/settings/storage' | '/organization/$organizationId/project/$projectId/environment/$environmentId/service/$serviceId/settings/terraform-arguments' | '/organization/$organizationId/project/$projectId/environment/$environmentId/service/$serviceId/settings/terraform-configuration' - | '/organization/$organizationId/project/$projectId/environment/$environmentId/service/$serviceId/settings/terraform-variables' | '/organization/$organizationId/project/$projectId/environment/$environmentId/service/$serviceId/settings/values-override-arguments' | '/organization/$organizationId/project/$projectId/environment/$environmentId/service/$serviceId/settings/values-override-file' | '/organization/$organizationId/project/$projectId/environment/$environmentId/service/$serviceId/variables/built-in' | '/organization/$organizationId/project/$projectId/environment/$environmentId/service/$serviceId/variables/external-secrets' + | '/organization/$organizationId/project/$projectId/environment/$environmentId/service/$serviceId/variables/terraform' | '/organization/$organizationId/project/$projectId/environment/$environmentId/service/create/$slug/general' | '/organization/$organizationId/project/$projectId/environment/$environmentId/service/create/$slug/health-checks' | '/organization/$organizationId/project/$projectId/environment/$environmentId/service/create/$slug/ports' @@ -2899,11 +2899,11 @@ export interface FileRouteTypes { | '/_authenticated/organization/$organizationId/project/$projectId/environment/$environmentId/service/$serviceId/settings/storage' | '/_authenticated/organization/$organizationId/project/$projectId/environment/$environmentId/service/$serviceId/settings/terraform-arguments' | '/_authenticated/organization/$organizationId/project/$projectId/environment/$environmentId/service/$serviceId/settings/terraform-configuration' - | '/_authenticated/organization/$organizationId/project/$projectId/environment/$environmentId/service/$serviceId/settings/terraform-variables' | '/_authenticated/organization/$organizationId/project/$projectId/environment/$environmentId/service/$serviceId/settings/values-override-arguments' | '/_authenticated/organization/$organizationId/project/$projectId/environment/$environmentId/service/$serviceId/settings/values-override-file' | '/_authenticated/organization/$organizationId/project/$projectId/environment/$environmentId/service/$serviceId/variables/built-in' | '/_authenticated/organization/$organizationId/project/$projectId/environment/$environmentId/service/$serviceId/variables/external-secrets' + | '/_authenticated/organization/$organizationId/project/$projectId/environment/$environmentId/service/$serviceId/variables/terraform' | '/_authenticated/organization/$organizationId/project/$projectId/environment/$environmentId/service/create/$slug/general' | '/_authenticated/organization/$organizationId/project/$projectId/environment/$environmentId/service/create/$slug/health-checks' | '/_authenticated/organization/$organizationId/project/$projectId/environment/$environmentId/service/create/$slug/ports' @@ -4074,6 +4074,13 @@ declare module '@tanstack/react-router' { preLoaderRoute: typeof AuthenticatedOrganizationOrganizationIdProjectProjectIdEnvironmentEnvironmentIdServiceCreateSlugGeneralRouteImport parentRoute: typeof AuthenticatedOrganizationOrganizationIdProjectProjectIdEnvironmentEnvironmentIdServiceCreateSlugRouteRoute } + '/_authenticated/organization/$organizationId/project/$projectId/environment/$environmentId/service/$serviceId/variables/terraform': { + id: '/_authenticated/organization/$organizationId/project/$projectId/environment/$environmentId/service/$serviceId/variables/terraform' + path: '/terraform' + fullPath: '/organization/$organizationId/project/$projectId/environment/$environmentId/service/$serviceId/variables/terraform' + preLoaderRoute: typeof AuthenticatedOrganizationOrganizationIdProjectProjectIdEnvironmentEnvironmentIdServiceServiceIdVariablesTerraformRouteImport + parentRoute: typeof AuthenticatedOrganizationOrganizationIdProjectProjectIdEnvironmentEnvironmentIdServiceServiceIdVariablesRouteRoute + } '/_authenticated/organization/$organizationId/project/$projectId/environment/$environmentId/service/$serviceId/variables/external-secrets': { id: '/_authenticated/organization/$organizationId/project/$projectId/environment/$environmentId/service/$serviceId/variables/external-secrets' path: '/external-secrets' @@ -4102,13 +4109,6 @@ declare module '@tanstack/react-router' { preLoaderRoute: typeof AuthenticatedOrganizationOrganizationIdProjectProjectIdEnvironmentEnvironmentIdServiceServiceIdSettingsValuesOverrideArgumentsRouteImport parentRoute: typeof AuthenticatedOrganizationOrganizationIdProjectProjectIdEnvironmentEnvironmentIdServiceServiceIdSettingsRouteRoute } - '/_authenticated/organization/$organizationId/project/$projectId/environment/$environmentId/service/$serviceId/settings/terraform-variables': { - id: '/_authenticated/organization/$organizationId/project/$projectId/environment/$environmentId/service/$serviceId/settings/terraform-variables' - path: '/terraform-variables' - fullPath: '/organization/$organizationId/project/$projectId/environment/$environmentId/service/$serviceId/settings/terraform-variables' - preLoaderRoute: typeof AuthenticatedOrganizationOrganizationIdProjectProjectIdEnvironmentEnvironmentIdServiceServiceIdSettingsTerraformVariablesRouteImport - parentRoute: typeof AuthenticatedOrganizationOrganizationIdProjectProjectIdEnvironmentEnvironmentIdServiceServiceIdSettingsRouteRoute - } '/_authenticated/organization/$organizationId/project/$projectId/environment/$environmentId/service/$serviceId/settings/terraform-configuration': { id: '/_authenticated/organization/$organizationId/project/$projectId/environment/$environmentId/service/$serviceId/settings/terraform-configuration' path: '/terraform-configuration' @@ -4661,7 +4661,6 @@ interface AuthenticatedOrganizationOrganizationIdProjectProjectIdEnvironmentEnvi AuthenticatedOrganizationOrganizationIdProjectProjectIdEnvironmentEnvironmentIdServiceServiceIdSettingsStorageRoute: typeof AuthenticatedOrganizationOrganizationIdProjectProjectIdEnvironmentEnvironmentIdServiceServiceIdSettingsStorageRoute AuthenticatedOrganizationOrganizationIdProjectProjectIdEnvironmentEnvironmentIdServiceServiceIdSettingsTerraformArgumentsRoute: typeof AuthenticatedOrganizationOrganizationIdProjectProjectIdEnvironmentEnvironmentIdServiceServiceIdSettingsTerraformArgumentsRoute AuthenticatedOrganizationOrganizationIdProjectProjectIdEnvironmentEnvironmentIdServiceServiceIdSettingsTerraformConfigurationRoute: typeof AuthenticatedOrganizationOrganizationIdProjectProjectIdEnvironmentEnvironmentIdServiceServiceIdSettingsTerraformConfigurationRoute - AuthenticatedOrganizationOrganizationIdProjectProjectIdEnvironmentEnvironmentIdServiceServiceIdSettingsTerraformVariablesRoute: typeof AuthenticatedOrganizationOrganizationIdProjectProjectIdEnvironmentEnvironmentIdServiceServiceIdSettingsTerraformVariablesRoute AuthenticatedOrganizationOrganizationIdProjectProjectIdEnvironmentEnvironmentIdServiceServiceIdSettingsValuesOverrideArgumentsRoute: typeof AuthenticatedOrganizationOrganizationIdProjectProjectIdEnvironmentEnvironmentIdServiceServiceIdSettingsValuesOverrideArgumentsRoute AuthenticatedOrganizationOrganizationIdProjectProjectIdEnvironmentEnvironmentIdServiceServiceIdSettingsValuesOverrideFileRoute: typeof AuthenticatedOrganizationOrganizationIdProjectProjectIdEnvironmentEnvironmentIdServiceServiceIdSettingsValuesOverrideFileRoute AuthenticatedOrganizationOrganizationIdProjectProjectIdEnvironmentEnvironmentIdServiceServiceIdSettingsIndexRoute: typeof AuthenticatedOrganizationOrganizationIdProjectProjectIdEnvironmentEnvironmentIdServiceServiceIdSettingsIndexRoute @@ -4705,8 +4704,6 @@ const AuthenticatedOrganizationOrganizationIdProjectProjectIdEnvironmentEnvironm AuthenticatedOrganizationOrganizationIdProjectProjectIdEnvironmentEnvironmentIdServiceServiceIdSettingsTerraformArgumentsRoute, AuthenticatedOrganizationOrganizationIdProjectProjectIdEnvironmentEnvironmentIdServiceServiceIdSettingsTerraformConfigurationRoute: AuthenticatedOrganizationOrganizationIdProjectProjectIdEnvironmentEnvironmentIdServiceServiceIdSettingsTerraformConfigurationRoute, - AuthenticatedOrganizationOrganizationIdProjectProjectIdEnvironmentEnvironmentIdServiceServiceIdSettingsTerraformVariablesRoute: - AuthenticatedOrganizationOrganizationIdProjectProjectIdEnvironmentEnvironmentIdServiceServiceIdSettingsTerraformVariablesRoute, AuthenticatedOrganizationOrganizationIdProjectProjectIdEnvironmentEnvironmentIdServiceServiceIdSettingsValuesOverrideArgumentsRoute: AuthenticatedOrganizationOrganizationIdProjectProjectIdEnvironmentEnvironmentIdServiceServiceIdSettingsValuesOverrideArgumentsRoute, AuthenticatedOrganizationOrganizationIdProjectProjectIdEnvironmentEnvironmentIdServiceServiceIdSettingsValuesOverrideFileRoute: @@ -4723,6 +4720,7 @@ const AuthenticatedOrganizationOrganizationIdProjectProjectIdEnvironmentEnvironm interface AuthenticatedOrganizationOrganizationIdProjectProjectIdEnvironmentEnvironmentIdServiceServiceIdVariablesRouteRouteChildren { AuthenticatedOrganizationOrganizationIdProjectProjectIdEnvironmentEnvironmentIdServiceServiceIdVariablesBuiltInRoute: typeof AuthenticatedOrganizationOrganizationIdProjectProjectIdEnvironmentEnvironmentIdServiceServiceIdVariablesBuiltInRoute AuthenticatedOrganizationOrganizationIdProjectProjectIdEnvironmentEnvironmentIdServiceServiceIdVariablesExternalSecretsRoute: typeof AuthenticatedOrganizationOrganizationIdProjectProjectIdEnvironmentEnvironmentIdServiceServiceIdVariablesExternalSecretsRoute + AuthenticatedOrganizationOrganizationIdProjectProjectIdEnvironmentEnvironmentIdServiceServiceIdVariablesTerraformRoute: typeof AuthenticatedOrganizationOrganizationIdProjectProjectIdEnvironmentEnvironmentIdServiceServiceIdVariablesTerraformRoute AuthenticatedOrganizationOrganizationIdProjectProjectIdEnvironmentEnvironmentIdServiceServiceIdVariablesIndexRoute: typeof AuthenticatedOrganizationOrganizationIdProjectProjectIdEnvironmentEnvironmentIdServiceServiceIdVariablesIndexRoute } @@ -4732,6 +4730,8 @@ const AuthenticatedOrganizationOrganizationIdProjectProjectIdEnvironmentEnvironm AuthenticatedOrganizationOrganizationIdProjectProjectIdEnvironmentEnvironmentIdServiceServiceIdVariablesBuiltInRoute, AuthenticatedOrganizationOrganizationIdProjectProjectIdEnvironmentEnvironmentIdServiceServiceIdVariablesExternalSecretsRoute: AuthenticatedOrganizationOrganizationIdProjectProjectIdEnvironmentEnvironmentIdServiceServiceIdVariablesExternalSecretsRoute, + AuthenticatedOrganizationOrganizationIdProjectProjectIdEnvironmentEnvironmentIdServiceServiceIdVariablesTerraformRoute: + AuthenticatedOrganizationOrganizationIdProjectProjectIdEnvironmentEnvironmentIdServiceServiceIdVariablesTerraformRoute, AuthenticatedOrganizationOrganizationIdProjectProjectIdEnvironmentEnvironmentIdServiceServiceIdVariablesIndexRoute: AuthenticatedOrganizationOrganizationIdProjectProjectIdEnvironmentEnvironmentIdServiceServiceIdVariablesIndexRoute, } diff --git a/apps/console/src/routes/_authenticated/organization/$organizationId/project/$projectId/environment/$environmentId/service/$serviceId/variables/route.tsx b/apps/console/src/routes/_authenticated/organization/$organizationId/project/$projectId/environment/$environmentId/service/$serviceId/variables/route.tsx index 9ae7f3d3598..35f54ac877e 100644 --- a/apps/console/src/routes/_authenticated/organization/$organizationId/project/$projectId/environment/$environmentId/service/$serviceId/variables/route.tsx +++ b/apps/console/src/routes/_authenticated/organization/$organizationId/project/$projectId/environment/$environmentId/service/$serviceId/variables/route.tsx @@ -36,6 +36,14 @@ const tabs = [ }, ] +const terraformTab = { + id: 'terraform', + label: 'Terraform variables', + iconName: 'key' as IconName, + routeId: + '/organization/$organizationId/project/$projectId/environment/$environmentId/service/$serviceId/variables/terraform', +} + const OutletLoader = () => (
@@ -74,7 +82,8 @@ function RouteComponent() { if (shouldRedirect) return null - const activeTabId = tabs.find((tab) => matchRoute({ to: tab.routeId }))?.id + const serviceTabs = service?.serviceType === 'TERRAFORM' ? [...tabs, terraformTab] : tabs + const activeTabId = serviceTabs.find((tab) => matchRoute({ to: tab.routeId }))?.id return (
@@ -96,6 +105,15 @@ function RouteComponent() { ))} + {service?.serviceType === 'TERRAFORM' && ( + <> +
+ + + + + + )}
diff --git a/apps/console/src/routes/_authenticated/organization/$organizationId/project/$projectId/environment/$environmentId/service/$serviceId/settings/terraform-variables.tsx b/apps/console/src/routes/_authenticated/organization/$organizationId/project/$projectId/environment/$environmentId/service/$serviceId/variables/terraform.tsx similarity index 52% rename from apps/console/src/routes/_authenticated/organization/$organizationId/project/$projectId/environment/$environmentId/service/$serviceId/settings/terraform-variables.tsx rename to apps/console/src/routes/_authenticated/organization/$organizationId/project/$projectId/environment/$environmentId/service/$serviceId/variables/terraform.tsx index 86d2c24f57a..b1f3dde2a12 100644 --- a/apps/console/src/routes/_authenticated/organization/$organizationId/project/$projectId/environment/$environmentId/service/$serviceId/settings/terraform-variables.tsx +++ b/apps/console/src/routes/_authenticated/organization/$organizationId/project/$projectId/environment/$environmentId/service/$serviceId/variables/terraform.tsx @@ -1,4 +1,4 @@ -import { createFileRoute, useParams } from '@tanstack/react-router' +import { createFileRoute } from '@tanstack/react-router' import { Suspense } from 'react' import { FormProvider, useForm, useFormContext } from 'react-hook-form' import { match } from 'ts-pattern' @@ -9,39 +9,32 @@ import { } from '@qovery/domains/service-terraform/feature' import { type Terraform } from '@qovery/domains/services/data-access' import { type TerraformGeneralData, useEditService, useService } from '@qovery/domains/services/feature' -import { SettingsHeading } from '@qovery/shared/console-shared' -import { Button, LoaderSpinner, Section } from '@qovery/shared/ui' +import { Button, LoaderSpinner } from '@qovery/shared/ui' +import { useDocumentTitle } from '@qovery/shared/util-hooks' import { buildEditServicePayload } from '@qovery/shared/util-services' export const Route = createFileRoute( - '/_authenticated/organization/$organizationId/project/$projectId/environment/$environmentId/service/$serviceId/settings/terraform-variables' + '/_authenticated/organization/$organizationId/project/$projectId/environment/$environmentId/service/$serviceId/variables/terraform' )({ component: RouteComponent, }) const TerraformVariablesLoader = () => ( -
- +
+
) -const TerraformVariablesSettingsForm = ({ service }: { service: Terraform }) => { - const { organizationId = '', projectId = '', environmentId = '' } = Route.useParams() +const TerraformVariablesForm = ({ service }: { service: Terraform }) => { + const { organizationId, projectId, environmentId } = Route.useParams() const { handleSubmit } = useFormContext() const { serializeForApi, tfVarFiles, errors } = useTerraformVariablesContext() - const { mutate: editService, isLoading: isLoadingEditService } = useEditService({ organizationId, projectId, environmentId, }) - - if (service?.serviceType !== 'TERRAFORM') { - return null - } - const onSubmit = handleSubmit(() => { - // Edit the service with the updated variables and the updated order of tfvars files const payload = buildEditServicePayload({ service, request: { @@ -59,14 +52,16 @@ const TerraformVariablesSettingsForm = ({ service }: { service: Terraform }) => }) return ( - <> - -
- -
- +
+ 0}> + Save + + } + /> +
) } @@ -74,42 +69,31 @@ const TerraformVariablesContent = ({ service }: { service: Terraform }) => { const methods = useForm({ mode: 'onChange', defaultValues: match(service) - .with({ serviceType: 'TERRAFORM' }, (s) => s) + .with({ serviceType: 'TERRAFORM' }, (terraformService) => terraformService) .otherwise(() => ({})), }) return ( -
- -
- - - - - -
-
+ + + + + ) } -const TerraformVariablesWrapper = () => { - const { serviceId } = useParams({ strict: false }) - const { data: service } = useService({ serviceId }) +function RouteComponent() { + const { environmentId, serviceId } = Route.useParams() + const { data: service } = useService({ environmentId, serviceId, suspense: true }) + useDocumentTitle('Terraform variables - Service') if (service?.serviceType !== 'TERRAFORM') { return null } - return -} - -function RouteComponent() { return ( }> - + ) } diff --git a/libs/domains/service-settings/feature/src/lib/service-settings-layout/service-settings-layout.tsx b/libs/domains/service-settings/feature/src/lib/service-settings-layout/service-settings-layout.tsx index 2d664e64d87..c690bb18d27 100644 --- a/libs/domains/service-settings/feature/src/lib/service-settings-layout/service-settings-layout.tsx +++ b/libs/domains/service-settings/feature/src/lib/service-settings-layout/service-settings-layout.tsx @@ -110,12 +110,6 @@ export function ServiceSettingsLayout({ children }: ServiceSettingsLayoutProps) 'play-circle', 'regular' ) - const terraformVariablesLink = linkItem( - 'Terraform variables', - toSettingsPath(pathSettings, '/terraform-variables'), - 'key' - ) - const aiConfigurationLink = linkItem('AI configuration', toSettingsPath(pathSettings, '/ai-configuration'), 'brain') const connectionsLink = linkItem('Connections', toSettingsPath(pathSettings, '/connections'), 'plug') const outputsLink = linkItem('Outputs', toSettingsPath(pathSettings, '/outputs'), 'paper-plane') @@ -158,7 +152,6 @@ export function ServiceSettingsLayout({ children }: ServiceSettingsLayoutProps) .with({ serviceType: 'TERRAFORM' }, () => [ generalLink, terraformConfigurationLink, - terraformVariablesLink, terraformArgumentsLink, resourcesLink, deploymentRestrictionsLink, diff --git a/libs/domains/service-terraform/feature/src/lib/terraform-variables-settings/terraform-tfvars-popover/terraform-tfvars-popover.tsx b/libs/domains/service-terraform/feature/src/lib/terraform-variables-settings/terraform-tfvars-popover/terraform-tfvars-popover.tsx index b17557d52ac..791e3a91512 100644 --- a/libs/domains/service-terraform/feature/src/lib/terraform-variables-settings/terraform-tfvars-popover/terraform-tfvars-popover.tsx +++ b/libs/domains/service-terraform/feature/src/lib/terraform-variables-settings/terraform-tfvars-popover/terraform-tfvars-popover.tsx @@ -164,7 +164,7 @@ export const TfvarsFilesPopover = () => { ) : ( - diff --git a/libs/domains/service-terraform/feature/src/lib/terraform-variables-settings/terraform-variables-table/terraform-variables-table.tsx b/libs/domains/service-terraform/feature/src/lib/terraform-variables-settings/terraform-variables-table/terraform-variables-table.tsx index 4934d7455c6..5e31e6e01ae 100644 --- a/libs/domains/service-terraform/feature/src/lib/terraform-variables-settings/terraform-variables-table/terraform-variables-table.tsx +++ b/libs/domains/service-terraform/feature/src/lib/terraform-variables-settings/terraform-variables-table/terraform-variables-table.tsx @@ -1,6 +1,6 @@ import { useParams } from '@tanstack/react-router' import { clsx } from 'clsx' -import { useCallback, useEffect, useMemo, useRef, useState } from 'react' +import { type ReactNode, useCallback, useEffect, useMemo, useRef, useState } from 'react' import { DropdownVariable } from '@qovery/domains/variables/feature' import { Badge, Button, Checkbox, Icon, LoaderSpinner, Tooltip, truncateText } from '@qovery/shared/ui' import { twMerge } from '@qovery/shared/util-js' @@ -375,7 +375,13 @@ const TerraformVariablesEmptyState = () => { ) } -export const TerraformVariablesTable = () => { +export const TerraformVariablesTable = ({ + className, + headerActions, +}: { + className?: string + headerActions?: ReactNode +}) => { const { addVariable, fetchTfVarsFiles, areTfVarsFilesLoading, vars, newPath, selectedRows, deleteSelectedRows } = useTerraformVariablesContext() @@ -388,10 +394,13 @@ export const TerraformVariablesTable = () => { }, [addVariable]) return ( -
-
+
+
Variable configuration - +
+ + {headerActions} +
{areTfVarsFilesLoading && newPath.length === 0 ? ( diff --git a/libs/shared/assistant/feature/src/lib/hooks/use-contextual-doc-links/use-contextual-doc-links.spec.ts b/libs/shared/assistant/feature/src/lib/hooks/use-contextual-doc-links/use-contextual-doc-links.spec.ts index cc45266ac17..8ff194d7ffa 100644 --- a/libs/shared/assistant/feature/src/lib/hooks/use-contextual-doc-links/use-contextual-doc-links.spec.ts +++ b/libs/shared/assistant/feature/src/lib/hooks/use-contextual-doc-links/use-contextual-doc-links.spec.ts @@ -43,6 +43,17 @@ describe('useContextualDocLinks', () => { }) }) + it('should match the Terraform variables service route', () => { + setPathname('/organization/org-123/project/project-123/environment/env-123/service/service-123/variables/terraform') + + const { result } = renderHook(() => useContextualDocLinks()) + + expect(result.current).toContainEqual({ + link: 'https://developer.hashicorp.com/terraform/cli/commands', + label: 'Terraform CLI documentation', + }) + }) + it('should react to navigation events driven by the browser history api', () => { setPathname('/organization/org-123/settings/general') diff --git a/libs/shared/assistant/feature/src/lib/hooks/use-contextual-doc-links/use-contextual-doc-links.ts b/libs/shared/assistant/feature/src/lib/hooks/use-contextual-doc-links/use-contextual-doc-links.ts index e8c46004a99..01e0a191c12 100644 --- a/libs/shared/assistant/feature/src/lib/hooks/use-contextual-doc-links/use-contextual-doc-links.ts +++ b/libs/shared/assistant/feature/src/lib/hooks/use-contextual-doc-links/use-contextual-doc-links.ts @@ -910,7 +910,7 @@ const tanstackRouteAliases: Array<{ pattern: string; target: MappingPath }> = [ }, { pattern: - '/organization/:organizationId/project/:projectId/environment/:environmentId/service/:serviceId/settings/terraform-variables', + '/organization/:organizationId/project/:projectId/environment/:environmentId/service/:serviceId/variables/terraform', target: '/organization/:organizationId/project/:projectId/environment/:environmentId/application/:applicationId/settings/terraform-arguments', }, From 9148974722aab462214313cb78691d76e8b86c84 Mon Sep 17 00:00:00 2001 From: Romain Billard Date: Tue, 25 Aug 2026 11:42:39 +0200 Subject: [PATCH 2/5] feat(services): improve delete flow when service has been been deployed (#2901) * feat(services): improve delete flow when service has been been deployed * fix(services): handle uninstall errors without masking modal failures - Propagate confirmation modal deletion failures - Log errors from the post-close removal modal action * test(services): restore mocks after each service action test --- .../service-actions/service-actions.spec.tsx | 125 ++++++++++++++++++ .../lib/service-actions/service-actions.tsx | 45 +++++-- 2 files changed, 157 insertions(+), 13 deletions(-) diff --git a/libs/domains/services/feature/src/lib/service-actions/service-actions.spec.tsx b/libs/domains/services/feature/src/lib/service-actions/service-actions.spec.tsx index 499de6edf00..07abb21715a 100644 --- a/libs/domains/services/feature/src/lib/service-actions/service-actions.spec.tsx +++ b/libs/domains/services/feature/src/lib/service-actions/service-actions.spec.tsx @@ -7,8 +7,10 @@ let mockService = helmFactoryMock(1)[0] const mockEnvironment = environmentFactoryMock(1)[0] const mockNavigate = jest.fn() const mockDeployService = jest.fn() +const mockDeleteService = jest.fn() const mockOpenModal = jest.fn() const mockOpenModalConfirmation = jest.fn() +const mockOpenServiceRemoveModal = jest.fn() const mockCopyToClipboard = jest.fn() let mockDeploymentStatus = { @@ -74,6 +76,19 @@ jest.mock('../hooks/use-deploy-service/use-deploy-service', () => ({ }), })) +jest.mock('../hooks/use-delete-service/use-delete-service', () => ({ + useDeleteService: () => ({ + mutateAsync: mockDeleteService, + }), +})) + +jest.mock('../service-remove-modal/use-service-remove-modal/use-service-remove-modal', () => ({ + __esModule: true, + default: () => ({ + openServiceRemoveModal: mockOpenServiceRemoveModal, + }), +})) + describe('ServiceActions', () => { beforeEach(() => { jest.clearAllMocks() @@ -88,6 +103,10 @@ describe('ServiceActions', () => { } }) + afterEach(() => { + jest.restoreAllMocks() + }) + it('should match manage deployment snapshot', async () => { const { userEvent, baseElement } = renderWithProviders( , @@ -225,4 +244,110 @@ describe('ServiceActions', () => { }) ) }) + + it('should use a simple delete confirmation when the service has never been deployed', async () => { + mockDeploymentStatus = { + state: 'READY', + service_deployment_status: 'NEVER_DEPLOYED', + } + + const { userEvent } = renderWithProviders( + , + { + container: document.body, + } + ) + + await userEvent.click(screen.getByLabelText(/other actions/i)) + await userEvent.click(screen.getByRole('menuitem', { name: /remove service/i })) + + expect(mockOpenModalConfirmation).toHaveBeenCalledWith( + expect.objectContaining({ + title: 'Delete service', + name: mockService.name, + confirmationMethod: 'action', + }) + ) + expect(mockOpenServiceRemoveModal).not.toHaveBeenCalled() + + const { action } = mockOpenModalConfirmation.mock.calls[0][0] + await action() + + expect(mockDeleteService).toHaveBeenCalledWith({ + serviceId: mockService.id, + serviceType: mockService.serviceType, + }) + expect(mockNavigate).toHaveBeenCalledWith({ + to: '/organization/$organizationId/project/$projectId/environment/$environmentId/overview', + params: { + organizationId: mockEnvironment.organization.id, + projectId: mockEnvironment.project.id, + environmentId: mockEnvironment.id, + }, + }) + }) + + it('should propagate deletion errors from the confirmation modal action', async () => { + const error = new Error('Deletion failed') + mockDeleteService.mockRejectedValueOnce(error) + mockDeploymentStatus = { + state: 'READY', + service_deployment_status: 'NEVER_DEPLOYED', + } + + const { userEvent } = renderWithProviders( + , + { + container: document.body, + } + ) + + await userEvent.click(screen.getByLabelText(/other actions/i)) + await userEvent.click(screen.getByRole('menuitem', { name: /remove service/i })) + + const { action } = mockOpenModalConfirmation.mock.calls[0][0] + + await expect(action()).rejects.toBe(error) + expect(mockNavigate).not.toHaveBeenCalled() + }) + + it('should keep the remove modal when the service has already been deployed', async () => { + const { userEvent } = renderWithProviders( + , + { + container: document.body, + } + ) + + await userEvent.click(screen.getByLabelText(/other actions/i)) + await userEvent.click(screen.getByRole('menuitem', { name: /remove service/i })) + + expect(mockOpenServiceRemoveModal).toHaveBeenCalledWith(expect.objectContaining({ title: 'Remove service' })) + expect(mockOpenModalConfirmation).not.toHaveBeenCalled() + }) + + it('should handle deletion errors after the remove modal has closed', async () => { + const error = new Error('Deletion failed') + const consoleErrorSpy = jest.spyOn(console, 'error').mockImplementation() + mockDeleteService.mockRejectedValueOnce(error) + + const { userEvent } = renderWithProviders( + , + { + container: document.body, + } + ) + + await userEvent.click(screen.getByLabelText(/other actions/i)) + await userEvent.click(screen.getByRole('menuitem', { name: /remove service/i })) + + const { actions } = mockOpenServiceRemoveModal.mock.calls[0][0] + const deleteAction = actions.find(({ id }: { id: string }) => id === 'delete') + + await expect( + deleteAction.callback({ action: 'delete', name: 'delete', skipDestroy: false }) + ).resolves.toBeUndefined() + expect(consoleErrorSpy).toHaveBeenCalledWith(error) + expect(mockNavigate).not.toHaveBeenCalled() + }) }) diff --git a/libs/domains/services/feature/src/lib/service-actions/service-actions.tsx b/libs/domains/services/feature/src/lib/service-actions/service-actions.tsx index c131959fb72..6261639b6df 100644 --- a/libs/domains/services/feature/src/lib/service-actions/service-actions.tsx +++ b/libs/domains/services/feature/src/lib/service-actions/service-actions.tsx @@ -673,11 +673,13 @@ function MenuManageDeployment({ function MenuOtherActions({ state, + serviceDeploymentStatus, environment, service, variant, }: { state: StateEnum + serviceDeploymentStatus: ServiceDeploymentStatusEnum environment: Environment service: AnyService variant?: ActionToolbarVariant @@ -688,6 +690,7 @@ function MenuOtherActions({ organization: { id: organizationId }, } = environment const { openModal, closeModal } = useModal() + const { openModalConfirmation } = useModalConfirmation() const { openServiceRemoveModal } = useServiceRemoveModal() const navigate = useNavigate() const { mutateAsync: deleteService } = useDeleteService({ organizationId, environmentId }) @@ -716,7 +719,33 @@ function MenuOtherActions({ ) .otherwise(() => null) - const mutationRemove = async () => { + const mutationDelete = async (skipDestroy?: boolean) => { + if (!isEditableService(service)) { + return + } + + await deleteService({ + serviceId: service.id, + serviceType: service.serviceType, + ...(skipDestroy !== undefined && { skipDestroy }), + }) + navigate({ + to: '/organization/$organizationId/project/$projectId/environment/$environmentId/overview', + params: { organizationId, projectId, environmentId }, + }) + } + + const mutationRemove = () => { + if (serviceDeploymentStatus === ServiceDeploymentStatusEnum.NEVER_DEPLOYED) { + openModalConfirmation({ + title: 'Delete service', + name: service.name, + confirmationMethod: 'action', + action: mutationDelete, + }) + return + } + openServiceRemoveModal({ title: 'Remove service', name: service.name, @@ -794,19 +823,8 @@ function MenuOtherActions({ icon: 'trash-can', color: 'red', callback: async ({ skipDestroy }) => { - if (!isEditableService(service)) { - return - } try { - await deleteService({ - serviceId: service.id, - serviceType: service.serviceType, - skipDestroy, - }) - navigate({ - to: '/organization/$organizationId/project/$projectId/environment/$environmentId/overview', - params: { organizationId, projectId, environmentId }, - }) + await mutationDelete(skipDestroy) } catch (error) { console.error(error) } @@ -987,6 +1005,7 @@ export function ServiceActions({ {variant !== 'deploy-dropdown-only' && ( Date: Tue, 25 Aug 2026 12:17:25 +0200 Subject: [PATCH 3/5] feat(envs): add bulk stop action (#2741) * feat(envs): add bulk stop action Update stop confirmation modal * Add missing aria-labels to checkboxes * Add unit test * UI improvements * fix(envs): preserve checkbox focus on selection - Render environment sections directly to avoid remounts - Add regression coverage for checkbox focus * feat(envs): add success toast for bulk stop - Suppress per-environment success notifications during bulk stops - Show a single success toast after stopping selected environments - Add coverage for notification behavior * Fix unit test --- .../environment-section.spec.tsx | 31 ++++ .../environment-section.tsx | 95 +++++++++-- .../environments-table-action-bar.spec.tsx | 147 +++++++++++++++++ .../environments-table-action-bar.tsx | 99 +++++++++++ .../environments-table.spec.tsx | 79 +++++++-- .../environments-table/environments-table.tsx | 154 ++++++++++++------ .../use-stop-environment.ts | 30 ++-- 7 files changed, 544 insertions(+), 91 deletions(-) create mode 100644 libs/domains/environments/feature/src/lib/environments-table/environments-table-action-bar.spec.tsx create mode 100644 libs/domains/environments/feature/src/lib/environments-table/environments-table-action-bar.tsx diff --git a/libs/domains/environments/feature/src/lib/environments-table/environment-section/environment-section.spec.tsx b/libs/domains/environments/feature/src/lib/environments-table/environment-section/environment-section.spec.tsx index b1574007df7..41f983080c7 100644 --- a/libs/domains/environments/feature/src/lib/environments-table/environment-section/environment-section.spec.tsx +++ b/libs/domains/environments/feature/src/lib/environments-table/environment-section/environment-section.spec.tsx @@ -86,6 +86,37 @@ describe('EnvironmentSection', () => { expect(mockNavigate).not.toHaveBeenCalled() }) + it('should select an environment row without navigating', async () => { + const onEnvironmentSelectionChange = jest.fn() + const { userEvent } = renderWithProviders( + + ) + + await userEvent.click(screen.getAllByRole('checkbox')[1]) + + expect(onEnvironmentSelectionChange).toHaveBeenCalledWith('env-1', true) + expect(mockNavigate).not.toHaveBeenCalled() + }) + + it('should select all environments in a section', async () => { + const onSectionSelectionChange = jest.fn() + const { userEvent } = renderWithProviders( + + ) + + await userEvent.click(screen.getAllByRole('checkbox')[0]) + + expect(onSectionSelectionChange).toHaveBeenCalledWith(['env-1'], true) + }) + it('should display both action buttons when there are no services', async () => { const noServiceOverview = { ...overview, diff --git a/libs/domains/environments/feature/src/lib/environments-table/environment-section/environment-section.tsx b/libs/domains/environments/feature/src/lib/environments-table/environment-section/environment-section.tsx index 6c56d02236d..ff343493176 100644 --- a/libs/domains/environments/feature/src/lib/environments-table/environment-section/environment-section.tsx +++ b/libs/domains/environments/feature/src/lib/environments-table/environment-section/environment-section.tsx @@ -3,7 +3,7 @@ import { EnvironmentModeEnum, type EnvironmentOverviewResponse, StateEnum } from import { type KeyboardEvent, type MouseEvent } from 'react' import { match } from 'ts-pattern' import { ClusterAvatar } from '@qovery/domains/clusters/feature' -import { Button, DeploymentAction, Heading, Icon, Section, TablePrimitives, Tooltip } from '@qovery/shared/ui' +import { Button, Checkbox, DeploymentAction, Heading, Icon, Section, TablePrimitives, Tooltip } from '@qovery/shared/ui' import { timeAgo } from '@qovery/shared/util-dates' import { pluralize, twMerge } from '@qovery/shared/util-js' import { MenuManageDeployment, MenuOtherActions } from '../../environment-action-toolbar/environment-action-toolbar' @@ -14,8 +14,12 @@ import { isArgoCdEnvironment } from '../../utils/argocd' const { Table } = TablePrimitives -const gridLayoutClassName = - 'grid w-full grid-cols-[minmax(280px,2fr)_minmax(220px,1.4fr)_minmax(240px,1.2fr)_minmax(140px,1fr)_96px]' +export const environmentTableGridLayoutClassName = + 'grid w-full grid-cols-[44px_minmax(280px,2fr)_minmax(220px,1.4fr)_minmax(240px,1.2fr)_minmax(140px,1fr)_96px]' +export const environmentSelectionCellClassName = 'flex h-full items-center pl-4' +export const environmentNameCellContentClassName = + 'flex h-full min-w-0 flex-col justify-center gap-1 py-2 pl-0 pr-4 xl:flex-row xl:items-center xl:justify-between xl:gap-2' +export const environmentTableCellClassName = 'h-auto border-l border-neutral py-2' function DisabledManageDeploymentButton({ tooltip }: { tooltip: string }) { return ( @@ -31,14 +35,21 @@ function DisabledManageDeploymentButton({ tooltip }: { tooltip: string }) { ) } -function EnvRow({ overview }: { overview: EnvironmentOverviewResponse }) { +function EnvRow({ + overview, + checked, + onCheckedChange, +}: { + overview: EnvironmentOverviewResponse + checked: boolean + onCheckedChange: (checked: boolean) => void +}) { const navigate = useNavigate() const { organizationId = '', projectId = '' } = useParams({ strict: false }) const { data: environments = [] } = useEnvironments({ projectId, suspense: true }) const environment = environments.find((env) => env.id === overview.id) const isEnvironmentManagedByArgoCd = isArgoCdEnvironment(overview) const lastOperationDate = overview.deployment_status?.last_deployment_date - const cellClassName = 'h-auto border-l border-neutral py-2' const stopRowNavigation = (event: MouseEvent | KeyboardEvent) => { event.stopPropagation() } @@ -57,15 +68,30 @@ function EnvRow({ overview }: { overview: EnvironmentOverviewResponse }) { role="link" className={twMerge( 'w-full hover:cursor-pointer hover:bg-surface-neutral-subtle focus:bg-surface-neutral-subtle', - gridLayoutClassName + environmentTableGridLayoutClassName )} onClick={handleNavigate} onKeyDown={(e) => { if (e.key === 'Enter') handleNavigate() }} > - -
+ +
+ e.stopPropagation()} + onCheckedChange={(checked) => { + if (checked === 'indeterminate') { + return + } + onCheckedChange(checked) + }} + /> +
+
+ +
- +
{overview.services_overview.service_count === 0 ? ( @@ -112,7 +138,7 @@ function EnvRow({ overview }: { overview: EnvironmentOverviewResponse }) {
- +
{overview.cluster && ( - +
{timeAgo(new Date(overview.updated_at ?? Date.now()))} ago
- +
void + selectedEnvironmentIds?: string[] + onEnvironmentSelectionChange?: (environmentId: string, checked: boolean) => void + onSectionSelectionChange?: (environmentIds: string[], checked: boolean) => void }) { const title = match(type) .with('PRODUCTION', () => 'Production') @@ -201,6 +233,11 @@ export function EnvironmentSection({ ) }) + const selectedEnvironmentIdsSet = new Set(selectedEnvironmentIds) + const selectedItemsCount = items.filter(({ id }) => selectedEnvironmentIdsSet.has(id)).length + const isAllRowsSelected = selectedItemsCount === items.length && items.length > 0 + const sectionChecked = selectedItemsCount > 0 && !isAllRowsSelected ? 'indeterminate' : isAllRowsSelected + return (
@@ -215,8 +252,31 @@ export function EnvironmentSection({ ) : ( - - + + +
{ + e.stopPropagation() + }} + > + e.stopPropagation()} + onCheckedChange={(checked) => { + if (checked === 'indeterminate') { + return + } + onSectionSelectionChange?.( + items.map(({ id }) => id), + checked + ) + }} + /> +
+
+ Environment @@ -236,7 +296,12 @@ export function EnvironmentSection({ {items.map((environmentOverview) => ( - + onEnvironmentSelectionChange?.(environmentOverview.id, checked)} + /> ))}
diff --git a/libs/domains/environments/feature/src/lib/environments-table/environments-table-action-bar.spec.tsx b/libs/domains/environments/feature/src/lib/environments-table/environments-table-action-bar.spec.tsx new file mode 100644 index 00000000000..662abd4a852 --- /dev/null +++ b/libs/domains/environments/feature/src/lib/environments-table/environments-table-action-bar.spec.tsx @@ -0,0 +1,147 @@ +import { EnvironmentModeEnum, type EnvironmentOverviewResponse } from 'qovery-typescript-axios' +import { renderWithProviders, screen } from '@qovery/shared/util-tests' +import { EnvironmentsTableActionBar } from './environments-table-action-bar' + +const mockStopEnvironment = jest.fn() +const mockOpenModalConfirmation = jest.fn() +const mockToast = jest.fn() +const mockUseStopEnvironment = jest.fn() + +jest.mock('@qovery/shared/ui', () => ({ + ...jest.requireActual('@qovery/shared/ui'), + useModalConfirmation: () => ({ + openModalConfirmation: mockOpenModalConfirmation, + }), + toast: (status: string, title: string) => mockToast(status, title), +})) + +jest.mock('../hooks/use-stop-environment/use-stop-environment', () => ({ + useStopEnvironment: (props: unknown) => { + mockUseStopEnvironment(props) + return { + mutateAsync: mockStopEnvironment, + } + }, +})) + +const rows: EnvironmentOverviewResponse[] = [ + { + id: 'env-1', + name: 'Stoppable environment', + mode: EnvironmentModeEnum.DEVELOPMENT, + services_overview: { + service_count: 2, + managed_by: 'QOVERY', + }, + deployment_status: { + last_deployment_state: 'DEPLOYED', + }, + }, + { + id: 'env-2', + name: 'Stopped environment', + mode: EnvironmentModeEnum.DEVELOPMENT, + services_overview: { + service_count: 2, + managed_by: 'QOVERY', + }, + deployment_status: { + last_deployment_state: 'STOPPED', + }, + }, +] as EnvironmentOverviewResponse[] + +describe('EnvironmentsTableActionBar', () => { + beforeEach(() => { + jest.useFakeTimers() + mockStopEnvironment.mockReset() + mockOpenModalConfirmation.mockReset() + mockToast.mockReset() + mockUseStopEnvironment.mockReset() + mockStopEnvironment.mockResolvedValue(undefined) + }) + + afterEach(() => { + jest.useRealTimers() + }) + + it('should render successfully', () => { + const { baseElement } = renderWithProviders( + + ) + expect(baseElement).toBeTruthy() + }) + + it('should show selected environment count', () => { + renderWithProviders( + + ) + + expect(screen.getByText('2 selected environments')).toBeInTheDocument() + }) + + it('should disable stop action when no selected environment can be stopped', () => { + renderWithProviders( + + ) + + expect(screen.getByRole('button', { name: /stop selected/i })).toBeDisabled() + + expect(mockOpenModalConfirmation).not.toHaveBeenCalled() + }) + + it('should confirm and stop only stoppable selected environments', async () => { + const resetRowSelection = jest.fn() + const selectedRows = [ + ...rows, + { + id: 'env-3', + name: 'Restarted environment', + mode: EnvironmentModeEnum.DEVELOPMENT, + services_overview: { + service_count: 2, + managed_by: 'QOVERY', + }, + deployment_status: { + last_deployment_state: 'RESTARTED', + }, + }, + ] as EnvironmentOverviewResponse[] + + const { userEvent } = renderWithProviders( + + ) + + await userEvent.click(screen.getByRole('button', { name: /stop selected/i })) + + expect(mockOpenModalConfirmation).toHaveBeenCalledWith( + expect.objectContaining({ + title: 'Confirm stop', + confirmationMethod: 'action', + confirmationAction: 'stop', + }) + ) + + const modalProps = mockOpenModalConfirmation.mock.calls[0][0] + renderWithProviders(modalProps.description) + expect(screen.getByText(/You are going to stop 2 environments/)).toBeInTheDocument() + + renderWithProviders(modalProps.warning) + expect(screen.getByText('Some environments will not be impacted:')).toBeInTheDocument() + expect(screen.getByText('Stopped environment')).toBeInTheDocument() + + await modalProps.action() + + expect(mockStopEnvironment).toHaveBeenCalledTimes(2) + expect(mockStopEnvironment).toHaveBeenCalledWith({ environmentId: 'env-1' }) + expect(mockStopEnvironment).toHaveBeenCalledWith({ environmentId: 'env-3' }) + expect(mockUseStopEnvironment).toHaveBeenCalledWith({ projectId: 'project-1', notifyOnSuccess: false }) + expect(mockToast).toHaveBeenCalledTimes(1) + expect(mockToast).toHaveBeenCalledWith('success', 'Your environments are being stopped') + expect(resetRowSelection).toHaveBeenCalledTimes(1) + }) +}) diff --git a/libs/domains/environments/feature/src/lib/environments-table/environments-table-action-bar.tsx b/libs/domains/environments/feature/src/lib/environments-table/environments-table-action-bar.tsx new file mode 100644 index 00000000000..dc7fabab114 --- /dev/null +++ b/libs/domains/environments/feature/src/lib/environments-table/environments-table-action-bar.tsx @@ -0,0 +1,99 @@ +import { type EnvironmentOverviewResponse } from 'qovery-typescript-axios' +import { Button, Icon, Tooltip, toast, useModalConfirmation } from '@qovery/shared/ui' +import { isStopAvailable, pluralize, twMerge } from '@qovery/shared/util-js' +import { useStopEnvironment } from '../hooks/use-stop-environment/use-stop-environment' + +export interface EnvironmentsTableActionBarProps { + projectId: string + selectedRows: EnvironmentOverviewResponse[] + resetRowSelection: () => void +} + +export function EnvironmentsTableActionBar({ + projectId, + selectedRows, + resetRowSelection, +}: EnvironmentsTableActionBarProps) { + const hasSelection = Boolean(selectedRows.length) + const { openModalConfirmation } = useModalConfirmation() + const { mutateAsync: stopEnvironment } = useStopEnvironment({ projectId, notifyOnSuccess: false }) + + const stoppableEnvironments = selectedRows.filter( + ({ deployment_status }) => + deployment_status?.last_deployment_state && isStopAvailable(deployment_status.last_deployment_state) + ) + + const unstoppableEnvironments = selectedRows.filter( + ({ id: selectedId }) => !stoppableEnvironments.find(({ id }) => id === selectedId) + ) + + const handleStopEnvironments = () => { + const count = stoppableEnvironments.length + + openModalConfirmation({ + title: 'Confirm stop', + description: ( + + You are going to stop {count} {pluralize(count, 'environment')}. To confirm, please type "stop". + + ), + warning: unstoppableEnvironments.length ? ( + <> + Some environments will not be impacted: +
    + {unstoppableEnvironments.map(({ id, name }) => ( +
  • {name}
  • + ))} +
+ + ) : null, + confirmationMethod: 'action', + confirmationAction: 'stop', + action: async () => { + await Promise.all(stoppableEnvironments.map(({ id }) => stopEnvironment({ environmentId: id }))) + toast('success', `Your ${pluralize(count, 'environment')} ${count === 1 ? 'is' : 'are'} being stopped`) + resetRowSelection() + }, + }) + } + + return ( +
+
+
+ + {selectedRows.length} selected {pluralize(selectedRows.length, 'environment')} + +
+ + + + +
+
+
+
+ ) +} + +export default EnvironmentsTableActionBar diff --git a/libs/domains/environments/feature/src/lib/environments-table/environments-table.spec.tsx b/libs/domains/environments/feature/src/lib/environments-table/environments-table.spec.tsx index f71f4dbc731..e6ff7112a38 100644 --- a/libs/domains/environments/feature/src/lib/environments-table/environments-table.spec.tsx +++ b/libs/domains/environments/feature/src/lib/environments-table/environments-table.spec.tsx @@ -1,17 +1,16 @@ -import { EnvironmentModeEnum } from 'qovery-typescript-axios' +import { EnvironmentModeEnum, type EnvironmentOverviewResponse } from 'qovery-typescript-axios' +import type { ReactNode } from 'react' import { renderWithProviders, screen } from '@qovery/shared/util-tests' import { EnvironmentsTable } from './environments-table' const mockUseProject = jest.fn() const mockUseEnvironmentsOverview = jest.fn() - -interface EnvironmentSectionMockProps { - type: string - items: Array<{ name?: string }> -} +const mockNavigate = jest.fn() jest.mock('@tanstack/react-router', () => ({ ...jest.requireActual('@tanstack/react-router'), + Link: ({ children }: { children?: ReactNode }) => {children}, + useNavigate: () => mockNavigate, useParams: () => ({ organizationId: 'org-1', projectId: 'project-1' }), })) @@ -26,19 +25,43 @@ jest.mock('../environment-action-toolbar/environment-action-toolbar', () => ({ MenuOtherActions: () => , })) -jest.mock('./environment-section/environment-section', () => ({ +jest.mock('../environment-state-chip/environment-state-chip', () => ({ + __esModule: true, + default: () => null, +})) + +jest.mock('../hooks/use-environments/use-environments', () => ({ __esModule: true, - EnvironmentSection: ({ type, items }: EnvironmentSectionMockProps) => ( -
{`section:${type}:${items.map(({ name }) => name).join(',')}`}
- ), + default: () => ({ data: [] }), })) +jest.mock('./environments-table-action-bar', () => ({ + EnvironmentsTableActionBar: () =>
, +})) + +function environmentOverview(id: string, mode: EnvironmentModeEnum, name: string): EnvironmentOverviewResponse { + return { + id, + mode, + name, + services_overview: { + service_count: 0, + managed_by: 'QOVERY', + }, + } as EnvironmentOverviewResponse +} + describe('EnvironmentsTable', () => { beforeEach(() => { + jest.useFakeTimers() mockUseProject.mockReset() mockUseEnvironmentsOverview.mockReset() }) + afterEach(() => { + jest.useRealTimers() + }) + it('should render project name and environment sections', () => { mockUseProject.mockReturnValue({ data: { @@ -47,9 +70,9 @@ describe('EnvironmentsTable', () => { }) mockUseEnvironmentsOverview.mockReturnValue({ data: [ - { id: 'env-1', mode: EnvironmentModeEnum.PRODUCTION, name: 'Zulu' }, - { id: 'env-2', mode: EnvironmentModeEnum.PRODUCTION, name: 'Alpha' }, - { id: 'env-3', mode: EnvironmentModeEnum.DEVELOPMENT, name: 'Beta' }, + environmentOverview('env-1', EnvironmentModeEnum.PRODUCTION, 'Zulu'), + environmentOverview('env-2', EnvironmentModeEnum.PRODUCTION, 'Alpha'), + environmentOverview('env-3', EnvironmentModeEnum.DEVELOPMENT, 'Beta'), ], }) @@ -57,11 +80,31 @@ describe('EnvironmentsTable', () => { expect(screen.getByRole('heading', { name: 'Project Alpha' })).toBeInTheDocument() expect(screen.getByRole('button', { name: 'New Environment' })).toBeInTheDocument() - expect(screen.getAllByTestId('environment-section').map((section) => section.textContent)).toEqual([ - 'section:PRODUCTION:Alpha,Zulu', - 'section:DEVELOPMENT:Beta', - 'section:STAGING:', - 'section:PREVIEW:', + expect(screen.getAllByRole('heading').map((heading) => heading.textContent)).toEqual([ + 'Project Alpha', + 'Production', + 'Development', + 'Staging', + 'Ephemeral', + ]) + expect(screen.getAllByRole('link', { name: /^(Alpha|Zulu|Beta)$/ }).map((link) => link.textContent)).toEqual([ + 'Alpha', + 'Zulu', + 'Beta', ]) }) + + it('should preserve checkbox focus when selecting an environment', async () => { + mockUseProject.mockReturnValue({ data: { name: 'Project Alpha' } }) + mockUseEnvironmentsOverview.mockReturnValue({ + data: [environmentOverview('env-1', EnvironmentModeEnum.PRODUCTION, 'Production environment')], + }) + const { userEvent } = renderWithProviders() + const checkbox = screen.getByRole('checkbox', { name: 'Select Production environment' }) + + await userEvent.click(checkbox) + + expect(checkbox).toBeChecked() + expect(checkbox).toHaveFocus() + }) }) diff --git a/libs/domains/environments/feature/src/lib/environments-table/environments-table.tsx b/libs/domains/environments/feature/src/lib/environments-table/environments-table.tsx index 3d1884b4ed8..fc201e68bd5 100644 --- a/libs/domains/environments/feature/src/lib/environments-table/environments-table.tsx +++ b/libs/domains/environments/feature/src/lib/environments-table/environments-table.tsx @@ -1,12 +1,19 @@ import { useParams } from '@tanstack/react-router' import { EnvironmentModeEnum, type EnvironmentOverviewResponse } from 'qovery-typescript-axios' -import { Suspense, useCallback, useMemo } from 'react' +import { Suspense, useCallback, useMemo, useState } from 'react' import { useEnvironmentsOverview, useProject } from '@qovery/domains/projects/feature' import { Button, Heading, Icon, Section, Skeleton, TablePrimitives, useModal } from '@qovery/shared/ui' import { useDocumentTitle } from '@qovery/shared/util-hooks' import CreateCloneEnvironmentModal from '../create-clone-environment-modal/create-clone-environment-modal' import EnvironmentMode from '../environment-mode/environment-mode' -import { EnvironmentSection } from './environment-section/environment-section' +import { + EnvironmentSection, + environmentNameCellContentClassName, + environmentSelectionCellClassName, + environmentTableCellClassName, + environmentTableGridLayoutClassName, +} from './environment-section/environment-section' +import { EnvironmentsTableActionBar } from './environments-table-action-bar' const { Table } = TablePrimitives @@ -23,9 +30,8 @@ const SECTION_TITLES: Record = { [EnvironmentModeEnum.DEVELOPMENT]: 'Development', [EnvironmentModeEnum.PREVIEW]: 'Ephemeral', } - -const skeletonGridLayoutClassName = - 'grid w-full grid-cols-[minmax(280px,2fr)_minmax(220px,1.4fr)_minmax(240px,1.2fr)_minmax(140px,1fr)_96px] items-center' +const BODY_TEXT_SKELETON_HEIGHT = 20 +const ACTION_BUTTON_SKELETON_HEIGHT = 32 function EnvironmentsTableSkeleton() { return ( @@ -47,45 +53,62 @@ function EnvironmentsTableSkeleton() {
- - {[130, 110, 80, 90, 70].map((width, index) => ( - 0 ? 'border-l border-neutral' : ''}`} - > - - - ))} + + +
+ +
+
+ + + + + + + + + + + + + + +
{[...Array(2)].map((_, index) => ( - - -
- - + + +
+ +
+
+ +
+ +
- -
- + +
+
- -
- + +
+
- -
- + +
+
- -
- + +
+
@@ -105,6 +128,7 @@ function EnvironmentsTableContent() { const { organizationId = '', projectId = '' } = useParams({ strict: false }) const { data: project } = useProject({ organizationId, projectId, suspense: true }) const { data: environmentsOverview } = useEnvironmentsOverview({ projectId, suspense: true }) + const [selectedEnvironmentIds, setSelectedEnvironmentIds] = useState([]) const groupedEnvs = useMemo(() => { if (!environmentsOverview) { @@ -144,22 +168,41 @@ function EnvironmentsTableContent() { [projectId, organizationId, closeModal, openModal] ) - const Sections = useCallback(() => { - const filledSections = SECTIONS.filter((section) => groupedEnvs?.has(section)) - const emptySections = SECTIONS.filter((section) => !groupedEnvs?.has(section)) - return ( - <> - {[...filledSections, ...emptySections].map((section) => ( - onCreateEnvClicked(section)} - /> - ))} - - ) - }, [groupedEnvs, onCreateEnvClicked]) + const handleEnvironmentSelectionChange = useCallback((environmentId: string, checked: boolean) => { + setSelectedEnvironmentIds((currentSelectedEnvironmentIds) => { + if (checked) { + return currentSelectedEnvironmentIds.includes(environmentId) + ? currentSelectedEnvironmentIds + : [...currentSelectedEnvironmentIds, environmentId] + } + + return currentSelectedEnvironmentIds.filter((selectedEnvironmentId) => selectedEnvironmentId !== environmentId) + }) + }, []) + + const handleSectionSelectionChange = useCallback((environmentIds: string[], checked: boolean) => { + setSelectedEnvironmentIds((currentSelectedEnvironmentIds) => { + if (checked) { + return Array.from(new Set([...currentSelectedEnvironmentIds, ...environmentIds])) + } + + return currentSelectedEnvironmentIds.filter( + (selectedEnvironmentId) => !environmentIds.includes(selectedEnvironmentId) + ) + }) + }, []) + + const selectedRows = useMemo(() => { + if (!environmentsOverview) { + return [] + } + + const selectedEnvironmentIdsSet = new Set(selectedEnvironmentIds) + return environmentsOverview.filter(({ id }) => selectedEnvironmentIdsSet.has(id)) + }, [environmentsOverview, selectedEnvironmentIds]) + + const filledSections = SECTIONS.filter((section) => groupedEnvs?.has(section)) + const emptySections = SECTIONS.filter((section) => !groupedEnvs?.has(section)) return (
@@ -181,9 +224,24 @@ function EnvironmentsTableContent() {
- + {[...filledSections, ...emptySections].map((section) => ( + onCreateEnvClicked(section)} + selectedEnvironmentIds={selectedEnvironmentIds} + onEnvironmentSelectionChange={handleEnvironmentSelectionChange} + onSectionSelectionChange={handleSectionSelectionChange} + /> + ))}
+ setSelectedEnvironmentIds([])} + />
) } diff --git a/libs/domains/environments/feature/src/lib/hooks/use-stop-environment/use-stop-environment.ts b/libs/domains/environments/feature/src/lib/hooks/use-stop-environment/use-stop-environment.ts index 67b2ad2692d..360c5a2e47e 100644 --- a/libs/domains/environments/feature/src/lib/hooks/use-stop-environment/use-stop-environment.ts +++ b/libs/domains/environments/feature/src/lib/hooks/use-stop-environment/use-stop-environment.ts @@ -3,7 +3,15 @@ import { useNavigate } from '@tanstack/react-router' import { mutations } from '@qovery/domains/environments/data-access' import { queries } from '@qovery/state/util-queries' -export function useStopEnvironment({ projectId, logsLink }: { projectId: string; logsLink?: string }) { +export function useStopEnvironment({ + projectId, + logsLink, + notifyOnSuccess = true, +}: { + projectId: string + logsLink?: string + notifyOnSuccess?: boolean +}) { const queryClient = useQueryClient() const navigate = useNavigate() @@ -21,15 +29,17 @@ export function useStopEnvironment({ projectId, logsLink }: { projectId: string; }) }, meta: { - notifyOnSuccess: { - title: 'Your environment is being stopped', - ...(logsLink - ? { - labelAction: 'See deployment logs', - callback: () => navigate({ to: logsLink }), - } - : {}), - }, + notifyOnSuccess: notifyOnSuccess + ? { + title: 'Your environment is being stopped', + ...(logsLink + ? { + labelAction: 'See deployment logs', + callback: () => navigate({ to: logsLink }), + } + : {}), + } + : false, notifyOnError: true, }, }) From b0d6bf88c171f23079ff60a9c74d134c1e69dac1 Mon Sep 17 00:00:00 2001 From: Antoine Promerova Date: Tue, 25 Aug 2026 16:09:55 +0200 Subject: [PATCH 4/5] fix(blueprint): preview outcome rendering (#2899) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix(blueprint): render every preview outcome instead of spinning forever What: The blueprint update preview now settles on an explicit outcome (pending / diff / no-changes / error / cancelled / timeout) instead of a raw output string plus three booleans. The hook matches all four BlueprintPreviewResult variants exhaustively, treats a diff with a blank payload as "no changes", surfaces the error frame's message, fails fast when the socket closes before any frame, and bounds the wait with a 12-minute watchdog. The step component renders one branch per outcome and gates "Confirm & deploy update" on a successful preview. Why: The websocket contract sends exactly one frame then closes, and it has four variants. The hook only handled `diff` and dropped `error`, `cancelled` and `timeout` through a catch-all, so the component — which keyed its render on whether the raw output string was non-empty — showed the loading skeleton forever in all three cases, and in the empty-diff case too. Confirm was gated on "a message arrived" rather than on the outcome, so it went live while the skeleton was still animating. A failed preview was therefore indistinguishable from one still running, and the user could deploy anyway. Notes: Verified by reproducing first: a temporary spec asserting the broken behaviour (skeleton rendered + Confirm enabled for error/cancelled/timeout and for an empty diff) passed against the unfixed code and fails against this change. The watchdog is 12min, deliberately above the gateway's own 11min limit, so the server's timeout frame wins in the normal case and the watchdog only catches a socket that dies without delivering anything. onOpen is gone: `pending` is now the initial state, so there was nothing left for it to set. This is the console half of a wider fix. The backend still needs two changes to make failures fast rather than merely visible: q-core's preview broker reads only `message.fullDetails` in its `Diff` branch, so engine errors — which arrive at step `Diff` with the text in `error.userLogMessage` — are skipped, and it ignores the engine's `Terminated` step, so a run that ends without a diff waits out the full 10-minute consumer timeout. Separately, the engine's DIFF path drops the resolved timeout and runs `terraform plan` unbounded. * fix(blueprint): center preview outcome messages and contain fake timers in specs What: The raw output container is now a flex column, and the no-changes and failure branches fill it with `flex-1` instead of `h-full`. The failure block carries `font-sans` on its wrapper instead of on the summary span alone. Both preview specs restore real timers in `afterEach` rather than on the last line of the fake-timer test. Why: `h-full` resolves a percentage height against the parent's height, and the container only sets `min-h-[180px]`, leaving its height `auto`. The percentage collapsed to the content height, so `justify-center` had no free space to work with: measured in Chromium, the child was 20px tall with 173px of dead space under it, top-aligning the message in the 180px band. Everything in the failure block except the summary line also inherited `font-mono` from the container. In the specs, `jest.useRealTimers()` sat after the final assertion, so a failing assertion would leave fake timers installed for every later test in the file. Notes: Verified in Chromium: the branch content is now 180px tall with symmetric 13px gaps. The diff branch is unaffected — its `pre` keeps its full height inside the fixed-height container and still scrolls, and its bottom padding is now honored, which flex scroll containers do and block ones truncate. --------- Co-authored-by: Rémi Bonnet Co-authored-by: Rémi Bonnet --- ...e-blueprint-update-preview-socket.spec.tsx | 177 ++++++++++-------- .../use-blueprint-update-preview-socket.ts | 95 ++++++---- .../blueprint-update-preview-step.spec.tsx | 130 +++++++------ .../blueprint-update-preview-step.tsx | 104 +++++----- 4 files changed, 292 insertions(+), 214 deletions(-) diff --git a/libs/domains/services/feature/src/lib/hooks/use-blueprint-update-preview-socket/use-blueprint-update-preview-socket.spec.tsx b/libs/domains/services/feature/src/lib/hooks/use-blueprint-update-preview-socket/use-blueprint-update-preview-socket.spec.tsx index d1d4cead84e..582cbcafeee 100644 --- a/libs/domains/services/feature/src/lib/hooks/use-blueprint-update-preview-socket/use-blueprint-update-preview-socket.spec.tsx +++ b/libs/domains/services/feature/src/lib/hooks/use-blueprint-update-preview-socket/use-blueprint-update-preview-socket.spec.tsx @@ -13,19 +13,31 @@ jest.mock('@qovery/state/util-queries', () => ({ const useReactQueryWsSubscriptionMock = jest.mocked(useReactQueryWsSubscription) +function renderPreviewSocket() { + const { result, rerender } = renderHook( + ({ previewId }) => + useBlueprintUpdatePreviewSocket({ + organizationId: 'org-1', + clusterId: 'cluster-1', + previewId, + }), + { initialProps: { previewId: 'preview-1' } } + ) + + return { result, rerender, config: () => useReactQueryWsSubscriptionMock.mock.calls.at(-1)?.[0] } +} + describe('useBlueprintUpdatePreviewSocket', () => { beforeEach(() => { jest.clearAllMocks() }) + afterEach(() => { + jest.useRealTimers() + }) + it('should subscribe to the blueprint preview endpoint with organization, cluster and preview id', () => { - renderHook(() => - useBlueprintUpdatePreviewSocket({ - organizationId: 'org-1', - clusterId: 'cluster-1', - previewId: 'preview-1', - }) - ) + renderPreviewSocket() expect(useReactQueryWsSubscriptionMock).toHaveBeenCalledWith( expect.objectContaining({ @@ -40,98 +52,117 @@ describe('useBlueprintUpdatePreviewSocket', () => { ) }) + it('should stay pending until a frame arrives', () => { + const { result } = renderPreviewSocket() + + expect(result.current.outcome).toEqual({ type: 'pending' }) + }) + it('should expose raw output from a diff result', () => { - const { result } = renderHook(() => - useBlueprintUpdatePreviewSocket({ - organizationId: 'org-1', - clusterId: 'cluster-1', - previewId: 'preview-1', - }) - ) - const subscriptionConfig = useReactQueryWsSubscriptionMock.mock.calls[0]?.[0] + const { result, config } = renderPreviewSocket() act(() => { - subscriptionConfig?.onOpen?.({} as QueryClient, {} as Event) - expect(result.current.hasReceivedMessage).toBe(false) - subscriptionConfig?.onMessage?.({} as QueryClient, { + config()?.onMessage?.({} as QueryClient, { type: 'diff', payload: '# Terraform will perform the following actions:', service_type: 'TERRAFORM', }) }) - expect(result.current.isLoading).toBe(false) - expect(result.current.hasReceivedMessage).toBe(true) - expect(result.current.rawOutput).toBe('# Terraform will perform the following actions:') + expect(result.current.outcome).toEqual({ + type: 'diff', + rawOutput: '# Terraform will perform the following actions:', + }) }) - it.each([{ type: 'error', message: 'Preview failed' }, { type: 'cancelled' }, { type: 'timeout' }])( - 'should complete without raw output for a $type result', - (message) => { - const { result } = renderHook(() => - useBlueprintUpdatePreviewSocket({ - organizationId: 'org-1', - clusterId: 'cluster-1', - previewId: 'preview-1', - }) - ) - const subscriptionConfig = useReactQueryWsSubscriptionMock.mock.calls[0]?.[0] - - act(() => { - subscriptionConfig?.onMessage?.({} as QueryClient, message) - }) + it.each(['', ' \n '])('should report no-changes for a diff with a blank payload (%j)', (payload) => { + const { result, config } = renderPreviewSocket() - expect(result.current.rawOutput).toBe('') - expect(result.current.hasReceivedMessage).toBe(true) - } - ) + act(() => { + config()?.onMessage?.({} as QueryClient, { type: 'diff', payload, service_type: 'TERRAFORM' }) + }) - it('should reset message state when preview id changes', () => { - const { result, rerender } = renderHook( - ({ previewId }) => - useBlueprintUpdatePreviewSocket({ - organizationId: 'org-1', - clusterId: 'cluster-1', - previewId, - }), - { initialProps: { previewId: 'preview-1' } } - ) - const subscriptionConfig = useReactQueryWsSubscriptionMock.mock.calls[0]?.[0] + expect(result.current.outcome).toEqual({ type: 'no-changes' }) + }) + + it('should surface the message of an error result', () => { + const { result, config } = renderPreviewSocket() act(() => { - subscriptionConfig?.onMessage?.({} as QueryClient, { - type: 'diff', - payload: '# output', - service_type: 'TERRAFORM', - }) + config()?.onMessage?.({} as QueryClient, { type: 'error', message: 'terraform init failed' }) + }) + + expect(result.current.outcome).toEqual({ type: 'error', message: 'terraform init failed' }) + }) + + it.each([{ type: 'cancelled' }, { type: 'timeout' }] as const)('should settle on the $type outcome', ({ type }) => { + const { result, config } = renderPreviewSocket() + + act(() => { + config()?.onMessage?.({} as QueryClient, { type }) + }) + + expect(result.current.outcome).toEqual({ type }) + }) + + it('should fail fast when the socket closes before any frame', () => { + const { result, config } = renderPreviewSocket() + + act(() => { + config()?.onClose?.({} as QueryClient, {} as CloseEvent) + }) + + expect(result.current.outcome).toEqual({ type: 'error' }) + }) + + it('should keep the first outcome when the socket closes after a frame', () => { + const { result, config } = renderPreviewSocket() + + act(() => { + config()?.onMessage?.({} as QueryClient, { type: 'diff', payload: '# output', service_type: 'TERRAFORM' }) + config()?.onClose?.({} as QueryClient, {} as CloseEvent) + }) + + expect(result.current.outcome).toEqual({ type: 'diff', rawOutput: '# output' }) + }) + + it('should time out instead of waiting forever when no frame ever arrives', () => { + jest.useFakeTimers() + const { result } = renderPreviewSocket() + + expect(result.current.outcome).toEqual({ type: 'pending' }) + + act(() => { + jest.advanceTimersByTime(12 * 60 * 1000) }) - expect(result.current.hasReceivedMessage).toBe(true) + expect(result.current.outcome).toEqual({ type: 'timeout' }) + }) + + it('should reset to pending when preview id changes', () => { + const { result, rerender, config } = renderPreviewSocket() + + act(() => { + config()?.onMessage?.({} as QueryClient, { type: 'diff', payload: '# output', service_type: 'TERRAFORM' }) + }) + + expect(result.current.outcome).toEqual({ type: 'diff', rawOutput: '# output' }) rerender({ previewId: 'preview-2' }) - expect(result.current.hasReceivedMessage).toBe(false) - expect(result.current.rawOutput).toBe('') + expect(result.current.outcome).toEqual({ type: 'pending' }) }) it('should keep websocket lifecycle handlers stable after state updates', () => { - renderHook(() => - useBlueprintUpdatePreviewSocket({ - organizationId: 'org-1', - clusterId: 'cluster-1', - previewId: 'preview-1', - }) - ) - const subscriptionConfig = useReactQueryWsSubscriptionMock.mock.calls[0]?.[0] + const { config } = renderPreviewSocket() + const initial = config() act(() => { - subscriptionConfig?.onOpen?.({} as QueryClient, {} as Event) + initial?.onMessage?.({} as QueryClient, { type: 'cancelled' }) }) - const nextSubscriptionConfig = useReactQueryWsSubscriptionMock.mock.calls.at(-1)?.[0] - expect(nextSubscriptionConfig?.onOpen).toBe(subscriptionConfig?.onOpen) - expect(nextSubscriptionConfig?.onError).toBe(subscriptionConfig?.onError) - expect(nextSubscriptionConfig?.onClose).toBe(subscriptionConfig?.onClose) - expect(nextSubscriptionConfig?.onMessage).toBe(subscriptionConfig?.onMessage) + expect(config()?.onError).toBe(initial?.onError) + expect(config()?.onClose).toBe(initial?.onClose) + expect(config()?.onMessage).toBe(initial?.onMessage) }) }) diff --git a/libs/domains/services/feature/src/lib/hooks/use-blueprint-update-preview-socket/use-blueprint-update-preview-socket.ts b/libs/domains/services/feature/src/lib/hooks/use-blueprint-update-preview-socket/use-blueprint-update-preview-socket.ts index a8e18549948..641a167993c 100644 --- a/libs/domains/services/feature/src/lib/hooks/use-blueprint-update-preview-socket/use-blueprint-update-preview-socket.ts +++ b/libs/domains/services/feature/src/lib/hooks/use-blueprint-update-preview-socket/use-blueprint-update-preview-socket.ts @@ -1,15 +1,28 @@ import { type QueryClient } from '@tanstack/react-query' import { type BlueprintPreviewResult } from 'qovery-ws-typescript-axios' -import { useCallback, useEffect, useState } from 'react' +import { useCallback, useEffect, useRef, useState } from 'react' import { match } from 'ts-pattern' import { QOVERY_WS } from '@qovery/shared/util-node-env' import { useReactQueryWsSubscription } from '@qovery/state/util-queries' +// The gateway gives up after 11min and sends its own timeout frame; this only catches a socket +// that dies without ever delivering one, so the UI can never wait forever. +const PREVIEW_WATCHDOG_MS = 12 * 60 * 1000 + +/** + * The backend sends exactly one frame then closes, so the preview always settles on a single + * terminal outcome. `pending` is the only state that may render a spinner. + */ +export type BlueprintUpdatePreviewOutcome = + | { type: 'pending' } + | { type: 'diff'; rawOutput: string } + | { type: 'no-changes' } + | { type: 'error'; message?: string } + | { type: 'cancelled' } + | { type: 'timeout' } + export interface BlueprintUpdatePreviewSocketData { - rawOutput: string - isLoading: boolean - hasError: boolean - hasReceivedMessage: boolean + outcome: BlueprintUpdatePreviewOutcome } export interface UseBlueprintUpdatePreviewSocketProps { @@ -25,40 +38,50 @@ export function useBlueprintUpdatePreviewSocket({ previewId, enabled = true, }: UseBlueprintUpdatePreviewSocketProps): BlueprintUpdatePreviewSocketData { - const [rawOutput, setRawOutput] = useState('') - const [isLoading, setIsLoading] = useState(false) - const [hasError, setHasError] = useState(false) - const [hasReceivedMessage, setHasReceivedMessage] = useState(false) + const [outcome, setOutcome] = useState({ type: 'pending' }) + const outcomeRef = useRef(outcome) + + // First terminal outcome wins — a close event must not overwrite the frame that preceded it. + const settle = useCallback((next: BlueprintUpdatePreviewOutcome) => { + if (outcomeRef.current.type !== 'pending') return + outcomeRef.current = next + setOutcome(next) + }, []) + + const isSubscribed = enabled && Boolean(organizationId) && Boolean(clusterId) && Boolean(previewId) useEffect(() => { - setRawOutput('') - setIsLoading(false) - setHasError(false) - setHasReceivedMessage(false) + outcomeRef.current = { type: 'pending' } + setOutcome({ type: 'pending' }) }, [clusterId, organizationId, previewId]) - const handleMessage = useCallback((_: QueryClient, message: BlueprintPreviewResult) => { - setIsLoading(false) - setHasReceivedMessage(true) + useEffect(() => { + if (!isSubscribed || outcome.type !== 'pending') return - match(message) - .with({ type: 'diff' }, ({ payload }) => setRawOutput(payload)) - .otherwise(() => undefined) - }, []) + const timeoutId = window.setTimeout(() => settle({ type: 'timeout' }), PREVIEW_WATCHDOG_MS) + return () => window.clearTimeout(timeoutId) + }, [isSubscribed, outcome.type, settle]) - const handleOpen = useCallback(() => { - setIsLoading(true) - setHasError(false) - }, []) + const handleMessage = useCallback( + (_: QueryClient, message: BlueprintPreviewResult) => { + settle( + match(message) + .with({ type: 'diff' }, ({ payload }) => + payload?.trim() ? { type: 'diff', rawOutput: payload } : { type: 'no-changes' } + ) + .with({ type: 'error' }, ({ message: reason }) => ({ type: 'error', message: reason })) + .with({ type: 'cancelled' }, () => ({ type: 'cancelled' })) + .with({ type: 'timeout' }, () => ({ type: 'timeout' })) + .exhaustive() + ) + }, + [settle] + ) - const handleError = useCallback(() => { - setIsLoading(false) - setHasError(true) - }, []) + const handleError = useCallback(() => settle({ type: 'error' }), [settle]) - const handleClose = useCallback(() => { - setIsLoading(false) - }, []) + // Reconnection is off, so a close before any frame means the result will never arrive. + const handleClose = useCallback(() => settle({ type: 'error' }), [settle]) useReactQueryWsSubscription({ url: QOVERY_WS + '/blueprint/preview', @@ -67,19 +90,13 @@ export function useBlueprintUpdatePreviewSocket({ cluster: clusterId, preview_id: previewId, }, - enabled: enabled && Boolean(organizationId) && Boolean(clusterId) && Boolean(previewId), - onOpen: handleOpen, + enabled: isSubscribed, onMessage: handleMessage, onError: handleError, onClose: handleClose, }) - return { - rawOutput, - isLoading, - hasError, - hasReceivedMessage, - } + return { outcome } } export default useBlueprintUpdatePreviewSocket diff --git a/libs/domains/services/feature/src/lib/service-blueprint-update-flow/blueprint-update-preview-step.spec.tsx b/libs/domains/services/feature/src/lib/service-blueprint-update-flow/blueprint-update-preview-step.spec.tsx index 3ed4d71cd8a..60d2cddbb48 100644 --- a/libs/domains/services/feature/src/lib/service-blueprint-update-flow/blueprint-update-preview-step.spec.tsx +++ b/libs/domains/services/feature/src/lib/service-blueprint-update-flow/blueprint-update-preview-step.spec.tsx @@ -1,7 +1,10 @@ import { useParams } from '@tanstack/react-router' import { act } from '@testing-library/react' import { renderWithProviders, screen } from '@qovery/shared/util-tests' -import { useBlueprintUpdatePreviewSocket } from '../hooks/use-blueprint-update-preview-socket/use-blueprint-update-preview-socket' +import { + type BlueprintUpdatePreviewOutcome, + useBlueprintUpdatePreviewSocket, +} from '../hooks/use-blueprint-update-preview-socket/use-blueprint-update-preview-socket' import { useBlueprintUpdateFlowContext } from './blueprint-update-context' import { BlueprintUpdatePreviewStep } from './blueprint-update-preview-step' @@ -17,35 +20,45 @@ jest.mock('./blueprint-update-context', () => ({ useBlueprintUpdateFlowContext: jest.fn(), })) +function mockPreview({ + outcome, + ...context +}: { outcome: BlueprintUpdatePreviewOutcome } & Partial>) { + jest.mocked(useBlueprintUpdateFlowContext).mockReturnValue({ + clusterId: 'cluster-id', + handleUpdate: jest.fn(), + isUpdateLoading: false, + previewId: 'preview-id', + requestPreview: jest.fn(), + retryPreview: jest.fn(), + service: { name: 'AWS S3 Bucket' }, + ...context, + } as ReturnType) + jest.mocked(useBlueprintUpdatePreviewSocket).mockReturnValue({ outcome }) +} + +const confirmButton = () => screen.getByRole('button', { name: /Confirm & deploy update/i }) + describe('BlueprintUpdatePreviewStep', () => { beforeEach(() => { + jest.clearAllMocks() jest.mocked(useParams).mockReturnValue({ organizationId: 'organization-id' }) }) - it('requests a preview and keeps confirmation disabled while output is loading', () => { + afterEach(() => { + jest.useRealTimers() + }) + + it('requests a preview and keeps confirmation disabled while output is pending', () => { const requestPreview = jest.fn() const handleUpdate = jest.fn() - const context = { - clusterId: 'cluster-id', - handleUpdate, - isUpdateLoading: false, - previewId: 'preview-id', - requestPreview, - service: { name: 'AWS S3 Bucket' }, - } as ReturnType - - jest.mocked(useBlueprintUpdateFlowContext).mockReturnValue(context) - jest.mocked(useBlueprintUpdatePreviewSocket).mockReturnValue({ - hasReceivedMessage: false, - isLoading: true, - rawOutput: undefined, - }) + mockPreview({ outcome: { type: 'pending' }, requestPreview, handleUpdate }) renderWithProviders() expect(screen.getByRole('heading', { name: 'Preview changes' })).toBeInTheDocument() expect(screen.getByLabelText('Waiting for preview output')).toBeInTheDocument() - expect(screen.getByRole('button', { name: /Confirm & deploy update/i })).toBeDisabled() + expect(confirmButton()).toBeDisabled() expect(requestPreview).toHaveBeenCalledTimes(1) expect(useBlueprintUpdatePreviewSocket).toHaveBeenCalledWith({ clusterId: 'cluster-id', @@ -56,41 +69,53 @@ describe('BlueprintUpdatePreviewStep', () => { }) it('renders received output and enables confirmation', () => { - jest.mocked(useBlueprintUpdateFlowContext).mockReturnValue({ - clusterId: 'cluster-id', - handleUpdate: jest.fn(), - isUpdateLoading: false, - previewId: 'preview-id', - requestPreview: jest.fn(), - service: { name: 'AWS S3 Bucket' }, - } as ReturnType) - jest.mocked(useBlueprintUpdatePreviewSocket).mockReturnValue({ - hasReceivedMessage: true, - isLoading: false, - rawOutput: '+ created\n- removed\n~ changed', - }) + mockPreview({ outcome: { type: 'diff', rawOutput: '+ created\n- removed\n~ changed' } }) renderWithProviders() expect(screen.getByText('+ created')).toHaveClass('text-positive') expect(screen.getByText('- removed')).toHaveClass('text-negative') expect(screen.getByText('~ changed')).toHaveClass('text-info') - expect(screen.getByRole('button', { name: /Confirm & deploy update/i })).toBeEnabled() + expect(confirmButton()).toBeEnabled() }) - it('shows an explicit retry action when preview generation fails', async () => { - const retryPreview = jest.fn() + it('states that nothing changed instead of spinning forever, and still allows confirmation', () => { + mockPreview({ outcome: { type: 'no-changes' } }) - jest.mocked(useBlueprintUpdateFlowContext).mockReturnValue({ - clusterId: 'cluster-id', - handleUpdate: jest.fn(), - isUpdateLoading: false, - previewError: true, - previewId: undefined, - requestPreview: jest.fn(), - retryPreview, - service: { name: 'AWS S3 Bucket' }, - } as ReturnType) + renderWithProviders() + + expect(screen.getByText('No infrastructure changes detected.')).toBeInTheDocument() + expect(screen.queryByLabelText('Waiting for preview output')).not.toBeInTheDocument() + expect(confirmButton()).toBeEnabled() + }) + + it('surfaces the engine error message and blocks confirmation', () => { + mockPreview({ outcome: { type: 'error', message: 'terraform init failed' } }) + + renderWithProviders() + + expect(screen.getByRole('alert')).toHaveTextContent('Unable to generate the preview.') + expect(screen.getByText('terraform init failed')).toBeInTheDocument() + expect(screen.queryByLabelText('Waiting for preview output')).not.toBeInTheDocument() + expect(confirmButton()).toBeDisabled() + }) + + it.each([ + [{ type: 'cancelled' } as const, 'The preview was cancelled.'], + [{ type: 'timeout' } as const, 'The preview timed out before completing.'], + ])('reports the %s outcome and blocks confirmation', (outcome, summary) => { + mockPreview({ outcome }) + + renderWithProviders() + + expect(screen.getByRole('alert')).toHaveTextContent(summary) + expect(screen.queryByLabelText('Waiting for preview output')).not.toBeInTheDocument() + expect(confirmButton()).toBeDisabled() + }) + + it('shows an explicit retry action when the preview request itself fails', async () => { + const retryPreview = jest.fn() + mockPreview({ outcome: { type: 'pending' }, previewError: true, previewId: undefined, retryPreview }) const { userEvent } = renderWithProviders() @@ -99,24 +124,12 @@ describe('BlueprintUpdatePreviewStep', () => { await userEvent.click(screen.getByRole('button', { name: 'Retry preview' })) expect(retryPreview).toHaveBeenCalledTimes(1) - expect(screen.getByRole('button', { name: /Confirm & deploy update/i })).toBeDisabled() + expect(confirmButton()).toBeDisabled() }) it('updates the loading message while waiting for preview output', () => { jest.useFakeTimers() - jest.mocked(useBlueprintUpdateFlowContext).mockReturnValue({ - clusterId: 'cluster-id', - handleUpdate: jest.fn(), - isUpdateLoading: false, - previewId: 'preview-id', - requestPreview: jest.fn(), - service: { name: 'AWS S3 Bucket' }, - } as ReturnType) - jest.mocked(useBlueprintUpdatePreviewSocket).mockReturnValue({ - hasReceivedMessage: false, - isLoading: true, - rawOutput: undefined, - }) + mockPreview({ outcome: { type: 'pending' } }) renderWithProviders() @@ -127,6 +140,5 @@ describe('BlueprintUpdatePreviewStep', () => { }) expect(screen.getByText('Analyzing the planned changes')).toBeInTheDocument() - jest.useRealTimers() }) }) diff --git a/libs/domains/services/feature/src/lib/service-blueprint-update-flow/blueprint-update-preview-step.tsx b/libs/domains/services/feature/src/lib/service-blueprint-update-flow/blueprint-update-preview-step.tsx index 417767c739a..247f0566f25 100644 --- a/libs/domains/services/feature/src/lib/service-blueprint-update-flow/blueprint-update-preview-step.tsx +++ b/libs/domains/services/feature/src/lib/service-blueprint-update-flow/blueprint-update-preview-step.tsx @@ -1,7 +1,11 @@ import { useParams } from '@tanstack/react-router' import { useEffect, useState } from 'react' +import { match } from 'ts-pattern' import { Button, FunnelFlowBody, Heading, Icon, Section, Skeleton } from '@qovery/shared/ui' -import { useBlueprintUpdatePreviewSocket } from '../hooks/use-blueprint-update-preview-socket/use-blueprint-update-preview-socket' +import { + type BlueprintUpdatePreviewOutcome, + useBlueprintUpdatePreviewSocket, +} from '../hooks/use-blueprint-update-preview-socket/use-blueprint-update-preview-socket' import { useBlueprintUpdateFlowContext } from './blueprint-update-context' import { getRawOutputLineClassName } from './blueprint-update-utils' @@ -54,14 +58,11 @@ function BlueprintUpdatePreview({ if (!clusterId || !previewId) { return ( ) } @@ -97,48 +98,35 @@ function BlueprintUpdatePreviewWithSocket({ previewId: string }) { const { organizationId = '' } = useParams({ strict: false }) - const { - rawOutput, - isLoading: isPreviewOutputLoading, - hasReceivedMessage: hasReceivedPreviewMessage, - } = useBlueprintUpdatePreviewSocket({ organizationId, clusterId, previewId }) + const { outcome } = useBlueprintUpdatePreviewSocket({ organizationId, clusterId, previewId }) return ( ) } function BlueprintUpdatePreviewContent({ - hasReceivedPreviewMessage, - isPreviewOutputLoading, loading, onBack, onConfirm, onRetry, - previewError, - rawOutput, + outcome, }: { - hasReceivedPreviewMessage: boolean - isPreviewOutputLoading: boolean loading: boolean onBack: () => void onConfirm: () => Promise onRetry: () => Promise - previewError: boolean - rawOutput: string + outcome: BlueprintUpdatePreviewOutcome }) { - const rawOutputContainerHeightClassName = rawOutput - ? 'h-[min(75vh,calc(100vh-320px))] min-h-[260px]' - : 'min-h-[180px]' + const canConfirm = outcome.type === 'diff' || outcome.type === 'no-changes' + const rawOutputContainerHeightClassName = + outcome.type === 'diff' ? 'h-[min(75vh,calc(100vh-320px))] min-h-[260px]' : 'min-h-[180px]' return ( @@ -147,24 +135,30 @@ function BlueprintUpdatePreviewContent({
Raw output
- {previewError ? ( -
- Unable to generate the preview. - -
- ) : rawOutput ? ( - - ) : ( - - )} + {match(outcome) + .with({ type: 'pending' }, () => ) + .with({ type: 'diff' }, ({ rawOutput }) => ) + .with({ type: 'no-changes' }, () => ( +
+ No infrastructure changes detected. +
+ )) + .with({ type: 'error' }, ({ message }) => ( + + )) + .with({ type: 'cancelled' }, () => ( + + )) + .with({ type: 'timeout' }, () => ( + + )) + .exhaustive()}
@@ -177,7 +171,7 @@ function BlueprintUpdatePreviewContent({ type="button" size="lg" className="flex-1 justify-center" - disabled={!hasReceivedPreviewMessage || isPreviewOutputLoading || loading || previewError} + disabled={!canConfirm || loading} loading={loading} onClick={onConfirm} > @@ -189,6 +183,30 @@ function BlueprintUpdatePreviewContent({ ) } +function BlueprintUpdatePreviewFailure({ + onRetry, + reason, + summary, +}: { + onRetry: () => Promise + reason?: string + summary: string +}) { + return ( +
+ {summary} + {reason ? {reason} : null} + +
+ ) +} + function BlueprintUpdateRawOutput({ rawOutput }: { rawOutput: string }) { const lines = rawOutput.split('\n') From eaaeba16a6563918d9c5a647a171b4bd97e0d8ac Mon Sep 17 00:00:00 2001 From: Julien Dan <41013692+jul-dan@users.noreply.github.com> Date: Wed, 26 Aug 2026 13:48:27 +0200 Subject: [PATCH 5/5] feat(billing): grant full product access during free trial (#2902) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat(billing): grant full product access during free trial Trial organizations no longer need a credit card to create managed clusters. Billing screens now surface an "Activate my plan" CTA that opens the Pylon ask-for-activation form, alongside the existing self-service credit card management. New organizations are created on the Business plan by default so trial access matches this scope. Co-Authored-By: Claude Sonnet 5 * fix(billing): display the org's real plan in the trial activation banner Now that new organizations are created on the Business plan by default, the billing details banner can read the actual plan again instead of hardcoding "Business plan". Co-Authored-By: Claude Sonnet 5 * fix(billing): address cubic review findings on PR #2902 - Always show "Activate my plan" during an active trial in billing summary, even when a credit card is already on file — "Cancel free trial" now renders alongside it instead of replacing it, since activation still requires a Qovery rep regardless of the card. - Align settings-billing-details' isInActiveFreeTrial with the same 90-day upper bound used by useClusterCreationRestriction. - Cover the "Activate my plan" click flow and the trial boundary in settings-billing-details tests. Co-Authored-By: Claude Sonnet 5 * fix(billing): mirror the 90-day trial cap in billing summary The previous fix aligned settings-billing-details' trial check with useClusterCreationRestriction's 90-day upper bound but left settings-billing-summary unbounded, so the two pages disagreed again for remaining_trial_day > 90. Co-Authored-By: Claude Sonnet 5 * refactor(billing): extract the 90-day trial window into a shared util The remainingTrialDay > 0 && <= 90 check was duplicated in four places (both use-cluster-creation-restriction hooks, billing details, billing summary) and had already drifted twice across review rounds. Extracted TRIAL_MAX_DAY and isActiveFreeTrial() to @qovery/shared/util-js as the single source of truth. Co-Authored-By: Claude Sonnet 5 --------- Co-authored-by: Claude Sonnet 5 --- .../organization-free-trial-banner.tsx | 19 +- .../add-credit-card-modal-feature.tsx | 182 ------------------ .../cluster-new/cluster-new.spec.tsx | 67 ++----- .../cluster-new/cluster-new.tsx | 71 ++----- .../use-cluster-creation-restriction.ts | 16 +- .../feature/src/lib/container/container.tsx | 2 +- .../organizations/feature/src/index.ts | 1 - .../add-credit-card-modal.spec.tsx | 58 ------ .../add-credit-card-modal.tsx | 131 ------------- .../free-trial-banner/free-trial-banner.tsx | 21 +- .../use-cluster-creation-restriction.ts | 20 +- .../settings-billing-details.spec.tsx | 33 ++++ .../settings-billing-details.tsx | 28 ++- .../settings-billing-summary.spec.tsx | 50 +++++ .../settings-billing-summary.tsx | 39 ++-- libs/shared/util-js/src/index.ts | 1 + .../src/lib/is-active-free-trial.spec.ts | 22 +++ .../util-js/src/lib/is-active-free-trial.ts | 5 + 18 files changed, 216 insertions(+), 550 deletions(-) delete mode 100644 libs/domains/clusters/feature/src/lib/add-credit-card-modal-feature/add-credit-card-modal-feature.tsx delete mode 100644 libs/domains/organizations/feature/src/lib/add-credit-card-modal/add-credit-card-modal.spec.tsx delete mode 100644 libs/domains/organizations/feature/src/lib/add-credit-card-modal/add-credit-card-modal.tsx create mode 100644 libs/shared/util-js/src/lib/is-active-free-trial.spec.ts create mode 100644 libs/shared/util-js/src/lib/is-active-free-trial.ts diff --git a/apps/console/src/app/components/organization-banners/organization-free-trial-banner.tsx b/apps/console/src/app/components/organization-banners/organization-free-trial-banner.tsx index 2f7e092eb65..806ed834c07 100644 --- a/apps/console/src/app/components/organization-banners/organization-free-trial-banner.tsx +++ b/apps/console/src/app/components/organization-banners/organization-free-trial-banner.tsx @@ -5,22 +5,17 @@ import { Banner } from '@qovery/shared/ui' import { useSupportChat } from '@qovery/shared/util-hooks' import { pluralize } from '@qovery/shared/util-js' -const FREE_TRIAL_ADD_CREDIT_CARD_MESSAGE = - 'You are on a free trial. Add a credit card to unlock managed cluster creation. If you need help, please contact us.' - export function OrganizationFreeTrialBanner() { const { organizationId = '' } = useParams({ strict: false }) const { pathname } = useLocation() const { isClusterCreationRestricted: hasRestriction, - isNoCreditCardRestriction, isInActiveFreeTrial, remainingTrialDays, - hasNoCreditCard, } = useClusterCreationRestriction({ organizationId, }) - const { showChat } = useSupportChat() + const { showChat, showPylonForm } = useSupportChat() const isOnOrganizationBillingSummaryPage = pathname.includes( SETTINGS_URL(organizationId) + SETTINGS_BILLING_SUMMARY_URL @@ -32,7 +27,7 @@ export function OrganizationFreeTrialBanner() { return null } - if (hasRestriction && !isNoCreditCardRestriction) { + if (hasRestriction) { return ( showChat()}> Deployments are restricted on your organization. Please contact support to resolve this issue. @@ -41,11 +36,15 @@ export function OrganizationFreeTrialBanner() { } const days = (remainingTrialDays ?? 0) + 1 - const expiryMessage = `Your free trial plan expires ${days} ${pluralize(days, 'day')} from now. If you need help, please contact us.` - const message = hasNoCreditCard && isNoCreditCardRestriction ? FREE_TRIAL_ADD_CREDIT_CARD_MESSAGE : expiryMessage + const message = `Your free trial plan expires ${days} ${pluralize(days, 'day')} from now. Activate your plan to keep full access to Qovery.` return ( - showChat()}> + showPylonForm('ask-for-activation')} + > {message} ) diff --git a/libs/domains/clusters/feature/src/lib/add-credit-card-modal-feature/add-credit-card-modal-feature.tsx b/libs/domains/clusters/feature/src/lib/add-credit-card-modal-feature/add-credit-card-modal-feature.tsx deleted file mode 100644 index a5171a6604e..00000000000 --- a/libs/domains/clusters/feature/src/lib/add-credit-card-modal-feature/add-credit-card-modal-feature.tsx +++ /dev/null @@ -1,182 +0,0 @@ -import { CardCVV, CardComponent, CardExpiry, CardNumber, Provider } from '@chargebee/chargebee-js-react-wrapper' -import type FieldContainer from '@chargebee/chargebee-js-react-wrapper/dist/components/FieldContainer' -import type CbInstance from '@chargebee/chargebee-js-types/cb-types/models/cb-instance' -import { useMutation, useQueryClient } from '@tanstack/react-query' -import { useEffect, useRef, useState } from 'react' -import { mutations } from '@qovery/domains/organizations/data-access' -import { Button, toastError, useModal } from '@qovery/shared/ui' -import { fieldCardStyles, loadChargebee } from '@qovery/shared/util-payment' -import { type SerializedError } from '@qovery/shared/utils' -import { queries } from '@qovery/state/util-queries' - -export interface AddCreditCardModalFeatureProps { - organizationId: string - onSuccess?: () => void -} - -function getSerializedChargebeeError(error: unknown): SerializedError | Error { - if (error instanceof Error) { - return error - } - - if (typeof error === 'object' && error !== null) { - const chargebeeError = error as { - message?: string - error_msg?: string - type?: string - code?: string - } - - return { - name: chargebeeError.type || 'Chargebee error', - message: chargebeeError.message || chargebeeError.error_msg || 'Unable to tokenize credit card fields.', - code: chargebeeError.code, - } - } - - return { - name: 'Chargebee error', - message: 'Unable to tokenize credit card fields.', - } -} - -export function AddCreditCardModalFeature({ organizationId, onSuccess }: AddCreditCardModalFeatureProps) { - const { closeModal } = useModal() - const queryClient = useQueryClient() - const [loading, setLoading] = useState(false) - const [cbInstance, setCbInstance] = useState(null) - const [isReady, setIsReady] = useState(false) - const cardRef = useRef(null) - - const { mutateAsync: addCreditCard } = useMutation(mutations.addCreditCard, { - onSuccess(_, { organizationId: targetOrganizationId }) { - queryClient.invalidateQueries({ - queryKey: queries.organizations.creditCards({ organizationId: targetOrganizationId }).queryKey, - }) - queryClient.invalidateQueries({ - queryKey: queries.organizations.details({ organizationId: targetOrganizationId }).queryKey, - }) - }, - }) - - useEffect(() => { - let mounted = true - - const initializeChargebee = async () => { - try { - const instance = await loadChargebee() - - if (!mounted) { - return - } - - setCbInstance(instance) - setIsReady(false) - } catch (error) { - console.error('Failed to initialize Chargebee:', error) - } - } - - initializeChargebee() - - return () => { - mounted = false - } - }, []) - - const onSubmit = async () => { - if (!cardRef.current || !organizationId || !cbInstance || !isReady) { - return - } - - setLoading(true) - - try { - const data = await cardRef.current.tokenize({}) - - if (!data.token) { - throw new Error('No token returned from Chargebee') - } - - await addCreditCard({ - organizationId, - creditCardRequest: { - token: data.token, - cvv: '', - number: `****${data.card?.last4 || ''}`, - expiry_year: data.card?.expiry_year || 0, - expiry_month: data.card?.expiry_month || 0, - }, - }) - - onSuccess?.() - closeModal() - } catch (error) { - toastError(getSerializedChargebeeError(error)) - } finally { - setLoading(false) - } - } - - const isModalLoading = !cbInstance || !isReady || loading - - return ( -
-

Add credit card

-

Card information is securely processed by Chargebee.

-
- {cbInstance && ( - - setIsReady(true)} - > -
- - -
-
-
- - -
-
- - -
-
-
-
- )} -
-
- - -
-
- ) -} - -export default AddCreditCardModalFeature diff --git a/libs/domains/clusters/feature/src/lib/cluster-creation-flow/cluster-new/cluster-new.spec.tsx b/libs/domains/clusters/feature/src/lib/cluster-creation-flow/cluster-new/cluster-new.spec.tsx index 08343c16c3c..62b6be19af2 100644 --- a/libs/domains/clusters/feature/src/lib/cluster-creation-flow/cluster-new/cluster-new.spec.tsx +++ b/libs/domains/clusters/feature/src/lib/cluster-creation-flow/cluster-new/cluster-new.spec.tsx @@ -13,7 +13,6 @@ jest.mock('posthog-js/react', () => ({ const mockShowPylonForm = jest.fn() const mockUseClusterCreationRestriction = jest.fn(() => ({ isClusterCreationRestricted: false, - isNoCreditCardRestriction: false, })) jest.mock('../../hooks/use-cluster-creation-restriction/use-cluster-creation-restriction', () => ({ @@ -69,7 +68,6 @@ describe('ClusterNew', () => { mockUseParams.mockReturnValue({ organizationId: 'test-org-id' }) mockUseClusterCreationRestriction.mockReturnValue({ isClusterCreationRestricted: false, - isNoCreditCardRestriction: false, }) }) @@ -161,73 +159,31 @@ describe('ClusterNew', () => { }) }) - it('should show the free-trial callout and keep providers expandable when credit card is missing', async () => { + it('should not restrict managed cluster creation during a free trial', async () => { mockUseClusterCreationRestriction.mockReturnValue({ - isClusterCreationRestricted: true, - isNoCreditCardRestriction: true, + isClusterCreationRestricted: false, }) const { userEvent } = renderWithProviders() - expect(screen.getByText('Add a credit card to create a cluster')).toBeInTheDocument() - - const awsCard = getProviderCard('Amazon Web Services') - expect(awsCard).toBeInTheDocument() - - await userEvent.click(awsCard as Element) - - expect(screen.getByText('Qovery Managed')).toBeInTheDocument() - }) - - it('should open the add credit card modal when clicking a restricted cloud option', async () => { - mockUseClusterCreationRestriction.mockReturnValue({ - isClusterCreationRestricted: true, - isNoCreditCardRestriction: true, - }) - const { userEvent } = renderWithProviders() + expect(screen.queryByText('Cluster creation is restricted')).not.toBeInTheDocument() await userEvent.click(getProviderCard('Amazon Web Services') as Element) - const managedButton = screen.getByText('Qovery Managed').closest('button') - expect(managedButton).toBeInTheDocument() + const managedLink = screen.getByText('Qovery Managed').closest('a') + expect(managedLink).toBeInTheDocument() - await userEvent.click(managedButton as Element) + await userEvent.click(managedLink as Element) - expect(mockOpenModal).toHaveBeenCalledWith( - expect.objectContaining({ - content: expect.objectContaining({ - props: expect.objectContaining({ organizationId: 'test-org-id' }), - }), - }) - ) - expect(posthog.capture).not.toHaveBeenCalled() - }) - - it('should open the add credit card modal when clicking a restricted self-managed standalone card', async () => { - mockUseClusterCreationRestriction.mockReturnValue({ - isClusterCreationRestricted: true, - isNoCreditCardRestriction: true, + expect(mockOpenModal).not.toHaveBeenCalled() + expect(posthog.capture).toHaveBeenCalledWith('select-cluster', { + selectedCloudProvider: 'AWS', + selectedInstallationType: 'managed', }) - const { userEvent } = renderWithProviders() - - const ovhCard = getProviderCard('OVH Cloud') - expect(ovhCard).toBeInTheDocument() - - await userEvent.click(ovhCard as Element) - - expect(mockOpenModal).toHaveBeenCalledWith( - expect.objectContaining({ - content: expect.objectContaining({ - props: expect.objectContaining({ organizationId: 'test-org-id' }), - }), - }) - ) - expect(posthog.capture).not.toHaveBeenCalled() }) - it('should keep the demo card active during free trial restriction', async () => { + it('should keep the demo card active during a billing restriction', async () => { mockUseClusterCreationRestriction.mockReturnValue({ isClusterCreationRestricted: true, - isNoCreditCardRestriction: true, }) const { userEvent } = renderWithProviders() @@ -248,7 +204,6 @@ describe('ClusterNew', () => { it('should show the billing restriction callout and keep restricted cards inactive without modal', async () => { mockUseClusterCreationRestriction.mockReturnValue({ isClusterCreationRestricted: true, - isNoCreditCardRestriction: false, }) const { userEvent } = renderWithProviders() diff --git a/libs/domains/clusters/feature/src/lib/cluster-creation-flow/cluster-new/cluster-new.tsx b/libs/domains/clusters/feature/src/lib/cluster-creation-flow/cluster-new/cluster-new.tsx index c43fcd5e48b..2a908f60276 100644 --- a/libs/domains/clusters/feature/src/lib/cluster-creation-flow/cluster-new/cluster-new.tsx +++ b/libs/domains/clusters/feature/src/lib/cluster-creation-flow/cluster-new/cluster-new.tsx @@ -10,10 +10,9 @@ import { useFeatureFlagEnabled } from 'posthog-js/react' import { CloudProviderEnum } from 'qovery-typescript-axios' import { type MutableRefObject, type ReactElement, cloneElement, useState } from 'react' import { match } from 'ts-pattern' -import { Button, Link as ButtonLink, Callout, Heading, Icon, Section, useModal } from '@qovery/shared/ui' +import { Button, Callout, Heading, Icon, Section, useModal } from '@qovery/shared/ui' import { useClickAway, useSupportChat } from '@qovery/shared/util-hooks' import { twMerge } from '@qovery/shared/util-js' -import { AddCreditCardModalFeature } from '../../add-credit-card-modal-feature/add-credit-card-modal-feature' import { ClusterInstallationGuideModal } from '../../cluster-installation-guide-modal/cluster-installation-guide-modal' import { useClusterCreationRestriction } from '../../hooks/use-cluster-creation-restriction/use-cluster-creation-restriction' @@ -409,7 +408,7 @@ function CardCluster({ title, description, icon, index = 1, ...props }: CardClus export function ClusterNew() { const { organizationId = '' } = useParams({ strict: false }) const { openModal, closeModal } = useModal() - const { isClusterCreationRestricted, isNoCreditCardRestriction } = useClusterCreationRestriction({ + const { isClusterCreationRestricted } = useClusterCreationRestriction({ organizationId, }) @@ -430,17 +429,7 @@ export function ClusterNew() { ), }) - const openCreditCardModal = () => - openModal({ - content: , - }) - - const disabledCloudProviderProps: RestrictedActionProps = isClusterCreationRestricted - ? { - disabled: true, - onDisabledClick: isNoCreditCardRestriction ? openCreditCardModal : undefined, - } - : {} + const disabledCloudProviderProps: RestrictedActionProps = isClusterCreationRestricted ? { disabled: true } : {} const cloudProviders: CardClusterProps[] = [ { @@ -637,46 +626,20 @@ export function ClusterNew() { Or choose your hosting mode

Manage your infrastructure across different hosting mode.

- {isClusterCreationRestricted && - (isNoCreditCardRestriction ? ( - - - - - - Add a credit card to create a cluster - - You need to add a credit card to your account before creating a cluster on a cloud provider. You - won't be charged until your trial ends. -
- - Add credit card - - -
-
-
- ) : ( - - - - - - Cluster creation is restricted - - Your organization has a billing restriction that prevents cluster creation. Please contact support to - resolve this issue. - - - - ))} + {isClusterCreationRestricted && ( + + + + + + Cluster creation is restricted + + Your organization has a billing restriction that prevents cluster creation. Please contact support to + resolve this issue. + + + + )}
{cloudProviders.slice(1).map((props, index) => ( diff --git a/libs/domains/clusters/feature/src/lib/hooks/use-cluster-creation-restriction/use-cluster-creation-restriction.ts b/libs/domains/clusters/feature/src/lib/hooks/use-cluster-creation-restriction/use-cluster-creation-restriction.ts index 10e053ea7ae..6b63fc5c199 100644 --- a/libs/domains/clusters/feature/src/lib/hooks/use-cluster-creation-restriction/use-cluster-creation-restriction.ts +++ b/libs/domains/clusters/feature/src/lib/hooks/use-cluster-creation-restriction/use-cluster-creation-restriction.ts @@ -1,5 +1,6 @@ import { useQuery } from '@tanstack/react-query' import { useMemo } from 'react' +import { isActiveFreeTrial } from '@qovery/shared/util-js' import { queries } from '@qovery/state/util-queries' export interface UseClusterCreationRestrictionProps { @@ -13,32 +14,27 @@ export function useClusterCreationRestriction({ organizationId }: UseClusterCrea const { data: currentCost, isFetched: isFetchedCurrentCost } = useQuery({ ...queries.organizations.currentCost({ organizationId }), }) - const { data: creditCards = [], isFetched: isFetchedCreditCards } = useQuery({ - ...queries.organizations.creditCards({ organizationId }), - }) const remainingTrialDays = currentCost?.remaining_trial_day const billingDeploymentRestriction = organization?.billing_deployment_restriction const isInActiveFreeTrial = useMemo( - () => - isFetchedCurrentCost && remainingTrialDays !== undefined && remainingTrialDays > 0 && remainingTrialDays <= 90, + () => isFetchedCurrentCost && isActiveFreeTrial(remainingTrialDays), [isFetchedCurrentCost, remainingTrialDays] ) const isClusterCreationRestricted = useMemo( - () => isFetchedOrganization && billingDeploymentRestriction != null, + () => + isFetchedOrganization && + billingDeploymentRestriction != null && + billingDeploymentRestriction !== 'NO_CREDIT_CARD', [isFetchedOrganization, billingDeploymentRestriction] ) - const isNoCreditCardRestriction = billingDeploymentRestriction === 'NO_CREDIT_CARD' - const hasNoCreditCard = isFetchedCreditCards && creditCards.length === 0 const isLoading = !isFetchedOrganization || !isFetchedCurrentCost return { isClusterCreationRestricted, - isNoCreditCardRestriction, - hasNoCreditCard, isLoading, isInActiveFreeTrial, billingDeploymentRestriction, diff --git a/libs/domains/onboarding/feature/src/lib/container/container.tsx b/libs/domains/onboarding/feature/src/lib/container/container.tsx index d96991081a4..ea74d723ba6 100644 --- a/libs/domains/onboarding/feature/src/lib/container/container.tsx +++ b/libs/domains/onboarding/feature/src/lib/container/container.tsx @@ -20,7 +20,7 @@ const defaultContext: DefaultContextProps = { organization_name: '', project_name: '', admin_email: '', - selectedPlan: PlanEnum.TEAM_2025, + selectedPlan: PlanEnum.BUSINESS_2025, phone: undefined, cardToken: null, cardLast4: null, diff --git a/libs/domains/organizations/feature/src/index.ts b/libs/domains/organizations/feature/src/index.ts index 5c84ac92b9e..1c2f67c56fb 100644 --- a/libs/domains/organizations/feature/src/index.ts +++ b/libs/domains/organizations/feature/src/index.ts @@ -1,6 +1,5 @@ export * from './lib/annotation-setting/annotation-setting' export * from './lib/annotation-create-edit-modal/annotation-create-edit-modal' -export * from './lib/add-credit-card-modal/add-credit-card-modal' export * from './lib/label-annotation-items-list-modal/label-annotation-items-list-modal' export * from './lib/git-branch-settings/git-branch-settings' export * from './lib/git-provider-setting/git-provider-setting' diff --git a/libs/domains/organizations/feature/src/lib/add-credit-card-modal/add-credit-card-modal.spec.tsx b/libs/domains/organizations/feature/src/lib/add-credit-card-modal/add-credit-card-modal.spec.tsx deleted file mode 100644 index 0e189181017..00000000000 --- a/libs/domains/organizations/feature/src/lib/add-credit-card-modal/add-credit-card-modal.spec.tsx +++ /dev/null @@ -1,58 +0,0 @@ -import type CbInstance from '@chargebee/chargebee-js-types/cb-types/models/cb-instance' -import type { ReactNode } from 'react' -import * as chargebeeUtils from '@qovery/shared/util-payment' -import { renderWithProviders, screen, waitFor } from '@qovery/shared/util-tests' -import * as useAddCreditCardModule from '../hooks/use-add-credit-card/use-add-credit-card' -import AddCreditCardModal, { type AddCreditCardModalProps } from './add-credit-card-modal' - -import SpyInstance = jest.SpyInstance - -const useAddCreditCardSpy: SpyInstance = jest.spyOn(useAddCreditCardModule, 'useAddCreditCard') -const loadChargebeeSpy: SpyInstance = jest.spyOn(chargebeeUtils, 'loadChargebee') - -const props: AddCreditCardModalProps = { - organizationId: '1', -} - -jest.mock('@chargebee/chargebee-js-react-wrapper', () => ({ - Provider: ({ children }: { children: ReactNode }) => children, - CardComponent: ({ onReady, children }: { onReady?: () => void; children: ReactNode }) => { - setTimeout(() => onReady?.(), 0) - return
{children}
- }, - CardNumber: () =>
, - CardExpiry: () =>
, - CardCVV: () =>
, -})) - -describe('AddCreditCardModal', () => { - let mockChargebeeInstance: Pick - - beforeEach(() => { - useAddCreditCardSpy.mockReturnValue({ - mutateAsync: jest.fn(), - }) - - mockChargebeeInstance = { - load: jest.fn().mockResolvedValue(undefined), - } - - loadChargebeeSpy.mockResolvedValue(mockChargebeeInstance) - }) - - it('should render successfully', () => { - const { baseElement } = renderWithProviders() - expect(baseElement).toBeTruthy() - }) - - it('should initialize Chargebee and show loading state', async () => { - renderWithProviders() - - await waitFor(() => { - expect(loadChargebeeSpy).toHaveBeenCalled() - }) - - const button = screen.getByTestId('submit-button') - expect(button).toHaveClass('pointer-events-none') - }) -}) diff --git a/libs/domains/organizations/feature/src/lib/add-credit-card-modal/add-credit-card-modal.tsx b/libs/domains/organizations/feature/src/lib/add-credit-card-modal/add-credit-card-modal.tsx deleted file mode 100644 index a8806748445..00000000000 --- a/libs/domains/organizations/feature/src/lib/add-credit-card-modal/add-credit-card-modal.tsx +++ /dev/null @@ -1,131 +0,0 @@ -import { CardCVV, CardComponent, CardExpiry, CardNumber, Provider } from '@chargebee/chargebee-js-react-wrapper' -import type FieldContainer from '@chargebee/chargebee-js-react-wrapper/dist/components/FieldContainer' -import type CbInstance from '@chargebee/chargebee-js-types/cb-types/models/cb-instance' -import { type FormEvent, useEffect, useRef, useState } from 'react' -import { FormProvider, useForm } from 'react-hook-form' -import { ModalCrud, toastError, useModal } from '@qovery/shared/ui' -import { loadChargebee } from '@qovery/shared/util-payment' -import { type SerializedError } from '@qovery/shared/utils' -import { useAddCreditCard } from '../hooks/use-add-credit-card/use-add-credit-card' - -export interface AddCreditCardModalProps { - organizationId: string - onSuccess?: () => void -} - -export function AddCreditCardModal({ organizationId, onSuccess }: AddCreditCardModalProps) { - const { closeModal } = useModal() - const cardRef = useRef(null) - const [loading, setLoading] = useState(false) - const [isReady, setIsReady] = useState(false) - const [cbInstance, setCbInstance] = useState(null) - const { mutateAsync: addCreditCard } = useAddCreditCard() - - const methods = useForm({ - mode: 'onChange', - defaultValues: {}, - }) - - useEffect(() => { - let mounted = true - - const initializeChargebee = async () => { - try { - const instance = await loadChargebee() - - if (!mounted) { - return - } - - setCbInstance(instance) - } catch (error) { - console.error('Failed to initialize Chargebee:', error) - } - } - - initializeChargebee() - - return () => { - mounted = false - } - }, []) - - const onSubmit = async (card: FieldContainer) => { - if (!card || !organizationId) { - return - } - - setLoading(true) - - try { - const data = await card.tokenize({}) - - if (!data.token) { - throw new Error('No token returned from Chargebee') - } - - await addCreditCard({ - organizationId, - creditCardRequest: { - token: data.token, - cvv: '', - number: `****${data.card?.last4 || ''}`, - expiry_year: data.card?.expiry_year || 0, - expiry_month: data.card?.expiry_month || 0, - }, - }) - - onSuccess?.() - closeModal() - } catch (error) { - toastError(error as unknown as SerializedError) - } finally { - setLoading(false) - } - } - - const handleSubmit = (event: FormEvent) => { - event.preventDefault() - - if (cardRef.current) { - onSubmit(cardRef.current) - } - } - - const isLoading = loading || !cbInstance || !isReady - - return ( - - - {cbInstance && ( - - setIsReady(true)}> -
- - -
-
-
- - -
-
- - -
-
-
-
- )} -
-
- ) -} - -export default AddCreditCardModal diff --git a/libs/domains/organizations/feature/src/lib/free-trial-banner/free-trial-banner.tsx b/libs/domains/organizations/feature/src/lib/free-trial-banner/free-trial-banner.tsx index 4fc0dbca2f1..adbc331d87b 100644 --- a/libs/domains/organizations/feature/src/lib/free-trial-banner/free-trial-banner.tsx +++ b/libs/domains/organizations/feature/src/lib/free-trial-banner/free-trial-banner.tsx @@ -6,22 +6,17 @@ import { useSupportChat } from '@qovery/shared/util-hooks' import { pluralize } from '@qovery/shared/util-js' import useClusterCreationRestriction from '../hooks/use-cluster-creation-restriction/use-cluster-creation-restriction' -const FREE_TRIAL_ADD_CREDIT_CARD_MESSAGE = - 'You are on a free trial. Add a credit card to unlock managed cluster creation. If you need help, please contact us.' - export function FreeTrialBanner() { const { organizationId = '' } = useParams() const { pathname } = useLocation() const { isClusterCreationRestricted: hasRestriction, - isNoCreditCardRestriction, isInActiveFreeTrial, remainingTrialDays, - hasNoCreditCard, } = useClusterCreationRestriction({ organizationId, }) - const { showChat } = useSupportChat() + const { showChat, showPylonForm } = useSupportChat() const isOnOrganizationBillingSummaryPage = pathname.includes( SETTINGS_URL(organizationId) + SETTINGS_BILLING_SUMMARY_URL @@ -39,8 +34,7 @@ export function FreeTrialBanner() { return null } - // Generic restriction (not NO_CREDIT_CARD): deployments are blocked - if (hasRestriction && !isNoCreditCardRestriction) { + if (hasRestriction) { return ( showChat()}> Deployments are restricted on your organization. Please contact support to resolve this issue. @@ -48,13 +42,16 @@ export function FreeTrialBanner() { ) } - // Free trial: ask to add card only when billing restricts cluster creation (NO_CREDIT_CARD), otherwise show expiry countdown const days = (remainingTrialDays ?? 0) + 1 - const expiryMessage = `Your free trial plan expires ${days} ${pluralize(days, 'day')} from now. If you need help, please contact us.` - const message = hasNoCreditCard && isNoCreditCardRestriction ? FREE_TRIAL_ADD_CREDIT_CARD_MESSAGE : expiryMessage + const message = `Your free trial plan expires ${days} ${pluralize(days, 'day')} from now. Activate your plan to keep full access to Qovery.` return ( - showChat()}> + showPylonForm('ask-for-activation')} + > {message} ) diff --git a/libs/domains/organizations/feature/src/lib/hooks/use-cluster-creation-restriction/use-cluster-creation-restriction.ts b/libs/domains/organizations/feature/src/lib/hooks/use-cluster-creation-restriction/use-cluster-creation-restriction.ts index a66d38acd95..241bf9387b8 100644 --- a/libs/domains/organizations/feature/src/lib/hooks/use-cluster-creation-restriction/use-cluster-creation-restriction.ts +++ b/libs/domains/organizations/feature/src/lib/hooks/use-cluster-creation-restriction/use-cluster-creation-restriction.ts @@ -1,5 +1,5 @@ import { useMemo } from 'react' -import useCreditCards from '../use-credit-cards/use-credit-cards' +import { isActiveFreeTrial } from '@qovery/shared/util-js' import useCurrentCost from '../use-current-cost/use-current-cost' import useOrganization from '../use-organization/use-organization' @@ -11,14 +11,12 @@ export interface UseClusterCreationRestrictionProps { * Hook to determine if cluster creation should be restricted. * * Uses the backend-provided `billing_deployment_restriction` field on the organization: - * - null → no restriction - * - 'NO_CREDIT_CARD' → free trial restriction (blocks managed cluster creation, allows demo) + * - null or 'NO_CREDIT_CARD' → no restriction (free trial gives full product access, credit card not required) * - any other string → blocks all deployments */ export function useClusterCreationRestriction({ organizationId }: UseClusterCreationRestrictionProps) { const { data: organization, isFetched: isFetchedOrganization } = useOrganization({ organizationId }) const { data: currentCost, isFetched: isFetchedCurrentCost } = useCurrentCost({ organizationId }) - const { data: creditCards = [], isFetched: isFetchedCreditCards } = useCreditCards({ organizationId }) const remainingTrialDays = currentCost?.remaining_trial_day @@ -26,27 +24,23 @@ export function useClusterCreationRestriction({ organizationId }: UseClusterCrea // Check if user is in active free trial (used by free-trial-banner) const isInActiveFreeTrial = useMemo( - () => - isFetchedCurrentCost && remainingTrialDays !== undefined && remainingTrialDays > 0 && remainingTrialDays <= 90, + () => isFetchedCurrentCost && isActiveFreeTrial(remainingTrialDays), [isFetchedCurrentCost, remainingTrialDays] ) // Cluster creation is restricted when the backend sets a billing deployment restriction const isClusterCreationRestricted = useMemo( - () => isFetchedOrganization && billingDeploymentRestriction != null, + () => + isFetchedOrganization && + billingDeploymentRestriction != null && + billingDeploymentRestriction !== 'NO_CREDIT_CARD', [isFetchedOrganization, billingDeploymentRestriction] ) - const isNoCreditCardRestriction = billingDeploymentRestriction === 'NO_CREDIT_CARD' - - const hasNoCreditCard = isFetchedCreditCards && creditCards.length === 0 - const isLoading = !isFetchedOrganization || !isFetchedCurrentCost return { isClusterCreationRestricted, - isNoCreditCardRestriction, - hasNoCreditCard, isLoading, isInActiveFreeTrial, billingDeploymentRestriction, diff --git a/libs/domains/organizations/feature/src/lib/settings-billing-details/settings-billing-details.spec.tsx b/libs/domains/organizations/feature/src/lib/settings-billing-details/settings-billing-details.spec.tsx index 84f9d172d8e..0bde2de21e6 100644 --- a/libs/domains/organizations/feature/src/lib/settings-billing-details/settings-billing-details.spec.tsx +++ b/libs/domains/organizations/feature/src/lib/settings-billing-details/settings-billing-details.spec.tsx @@ -7,6 +7,7 @@ import { renderWithProviders, screen, waitFor } from '@qovery/shared/util-tests' import * as useAddCreditCardHook from '../hooks/use-add-credit-card/use-add-credit-card' import * as useBillingInfoHook from '../hooks/use-billing-info/use-billing-info' import * as useCreditCardsHook from '../hooks/use-credit-cards/use-credit-cards' +import * as useCurrentCostHook from '../hooks/use-current-cost/use-current-cost' import * as useDeleteCreditCardHook from '../hooks/use-delete-credit-card/use-delete-credit-card' import * as useEditBillingInfoHook from '../hooks/use-edit-billing-info/use-edit-billing-info' import { SettingsBillingDetails } from './settings-billing-details' @@ -15,6 +16,7 @@ const mockOpenModalConfirmation = jest.fn() const mockDeleteCreditCard = jest.fn().mockResolvedValue(undefined) const mockEditBillingInfo = jest.fn().mockResolvedValue(undefined) const mockAddCreditCard = jest.fn().mockResolvedValue(undefined) +const mockShowPylonForm = jest.fn() const defaultBillingInfo: BillingInfoRequest = { first_name: 'John', @@ -34,6 +36,7 @@ const mockCreditCards = creditCardsFactoryMock(3) const useAddCreditCardMock = jest.spyOn(useAddCreditCardHook, 'useAddCreditCard') as jest.Mock const useBillingInfoMock = jest.spyOn(useBillingInfoHook, 'useBillingInfo') as jest.Mock const useCreditCardsMock = jest.spyOn(useCreditCardsHook, 'useCreditCards') as jest.Mock +const useCurrentCostMock = jest.spyOn(useCurrentCostHook, 'useCurrentCost') as jest.Mock const useDeleteCreditCardMock = jest.spyOn(useDeleteCreditCardHook, 'useDeleteCreditCard') as jest.Mock const useEditBillingInfoMock = jest.spyOn(useEditBillingInfoHook, 'useEditBillingInfo') as jest.Mock const fieldCardStylesSpy = jest.spyOn(chargebeeUtils, 'fieldCardStyles') @@ -45,6 +48,7 @@ jest.mock('@tanstack/react-router', () => ({ jest.mock('@qovery/shared/util-hooks', () => ({ useDocumentTitle: jest.fn(), + useSupportChat: () => ({ showPylonForm: mockShowPylonForm }), })) jest.mock('@qovery/shared/ui', () => ({ @@ -84,6 +88,7 @@ describe('SettingsBillingDetails', () => { jest.clearAllMocks() useCreditCardsMock.mockReturnValue({ data: mockCreditCards }) useBillingInfoMock.mockReturnValue({ data: defaultBillingInfo }) + useCurrentCostMock.mockReturnValue({ data: { plan: 'BUSINESS_2025', remaining_trial_day: 0 } }) useDeleteCreditCardMock.mockReturnValue({ mutateAsync: mockDeleteCreditCard }) useEditBillingInfoMock.mockReturnValue({ mutateAsync: mockEditBillingInfo }) useAddCreditCardMock.mockReturnValue({ mutateAsync: mockAddCreditCard }) @@ -173,4 +178,32 @@ describe('SettingsBillingDetails', () => { expect(screen.getByTestId('placeholder-credit-card')).toBeInTheDocument() }) + + it('should show a plan activation banner during an active free trial while still allowing self-service card management', async () => { + useCurrentCostMock.mockReturnValue({ data: { plan: 'BUSINESS_2025', remaining_trial_day: 5 } }) + + const { userEvent } = renderWithProviders() + + expect(screen.getByText(/You are on the Business plan during your free trial/)).toBeInTheDocument() + expect(screen.getAllByTestId('credit-card-row')).toHaveLength(3) + + await userEvent.click(screen.getByText('Activate my plan')) + expect(mockShowPylonForm).toHaveBeenCalledWith('ask-for-activation') + }) + + it('should not show the plan activation banner once the free trial is over', () => { + useCurrentCostMock.mockReturnValue({ data: { plan: 'BUSINESS_2025', remaining_trial_day: 0 } }) + + renderWithProviders() + + expect(screen.queryByText(/You are on the Business plan during your free trial/)).not.toBeInTheDocument() + }) + + it('should not show the plan activation banner beyond the 90-day trial window', () => { + useCurrentCostMock.mockReturnValue({ data: { plan: 'BUSINESS_2025', remaining_trial_day: 91 } }) + + renderWithProviders() + + expect(screen.queryByText(/You are on the Business plan during your free trial/)).not.toBeInTheDocument() + }) }) diff --git a/libs/domains/organizations/feature/src/lib/settings-billing-details/settings-billing-details.tsx b/libs/domains/organizations/feature/src/lib/settings-billing-details/settings-billing-details.tsx index 49e05fec0ad..52dfee80b71 100644 --- a/libs/domains/organizations/feature/src/lib/settings-billing-details/settings-billing-details.tsx +++ b/libs/domains/organizations/feature/src/lib/settings-billing-details/settings-billing-details.tsx @@ -7,14 +7,16 @@ import { Suspense, useRef, useState } from 'react' import { FormProvider, useForm } from 'react-hook-form' import { SettingsHeading } from '@qovery/shared/console-shared' import { countries } from '@qovery/shared/enums' -import { IconFlag, toastError, useModalConfirmation } from '@qovery/shared/ui' +import { Callout, IconFlag, toastError, useModalConfirmation } from '@qovery/shared/ui' import { BlockContent, Button, Icon, InputCreditCard, InputText, Section, Skeleton } from '@qovery/shared/ui' -import { useDocumentTitle } from '@qovery/shared/util-hooks' +import { useDocumentTitle, useSupportChat } from '@qovery/shared/util-hooks' +import { formatPlanDisplay, isActiveFreeTrial } from '@qovery/shared/util-js' import { fieldCardStyles, loadChargebee } from '@qovery/shared/util-payment' import { type SerializedError } from '@qovery/shared/utils' import { useAddCreditCard } from '../hooks/use-add-credit-card/use-add-credit-card' import { useBillingInfo } from '../hooks/use-billing-info/use-billing-info' import { useCreditCards } from '../hooks/use-credit-cards/use-credit-cards' +import { useCurrentCost } from '../hooks/use-current-cost/use-current-cost' import { useDeleteCreditCard } from '../hooks/use-delete-credit-card/use-delete-credit-card' import { useEditBillingInfo } from '../hooks/use-edit-billing-info/use-edit-billing-info' import BillingDetails from './billing-details/billing-details' @@ -24,8 +26,12 @@ function SettingsBillingDetailsContent({ organizationId }: { organizationId: str const { data: creditCards = [] } = useCreditCards({ organizationId, suspense: true }) const { mutateAsync: deleteCreditCard } = useDeleteCreditCard() const { data: billingInfo } = useBillingInfo({ organizationId, suspense: true }) + const { data: currentCost } = useCurrentCost({ organizationId, suspense: true }) const { mutateAsync: editBillingInfo } = useEditBillingInfo() const { mutateAsync: addCreditCard } = useAddCreditCard() + const { showPylonForm } = useSupportChat() + + const isInActiveFreeTrial = isActiveFreeTrial(currentCost?.remaining_trial_day) const [showAddCard, setShowAddCard] = useState(false) const [editInProcess, setEditInProcess] = useState(false) @@ -129,6 +135,24 @@ function SettingsBillingDetailsContent({ organizationId }: { organizationId: str
+ {isInActiveFreeTrial && ( + + + + + + + You are on the {formatPlanDisplay(currentCost?.plan)} during your free trial + + + Activate your plan to keep this level of access after your trial ends. + + + + + )}
diff --git a/libs/domains/organizations/feature/src/lib/settings-billing-summary/settings-billing-summary.spec.tsx b/libs/domains/organizations/feature/src/lib/settings-billing-summary/settings-billing-summary.spec.tsx index b3f9d0cb54d..86df0e8d7f0 100644 --- a/libs/domains/organizations/feature/src/lib/settings-billing-summary/settings-billing-summary.spec.tsx +++ b/libs/domains/organizations/feature/src/lib/settings-billing-summary/settings-billing-summary.spec.tsx @@ -89,6 +89,55 @@ describe('PageOrganizationBillingSummary', () => { expect(pageProps.onPromoCodeClick).toHaveBeenCalled() }) + it('should always show Activate my plan during an active trial, even when a credit card exists', async () => { + const onActivateAccountClick = jest.fn() + const { userEvent } = renderWithProviders( + + ) + + await userEvent.click(screen.getByText('Activate my plan')) + expect(onActivateAccountClick).toHaveBeenCalled() + }) + + it('should only show Cancel free trial when a credit card exists during an active trial', async () => { + const onCancelTrialClick = jest.fn() + const { userEvent, rerender } = renderWithProviders( + + ) + + await userEvent.click(screen.getByText('Cancel free trial')) + expect(onCancelTrialClick).toHaveBeenCalled() + + rerender( + + ) + expect(screen.queryByText('Cancel free trial')).not.toBeInTheDocument() + }) + + it('should not show the trial callout beyond the 90-day trial window', () => { + renderWithProviders( + + ) + + expect(screen.queryByText('Activate my plan')).not.toBeInTheDocument() + expect(screen.queryByText('Cancel free trial')).not.toBeInTheDocument() + }) + it('should not display the payment method box for free plan', () => { renderWithProviders( { } as unknown as ReturnType) useSupportChatMock.mockReturnValue({ showChat: jest.fn(), + showPylonForm: jest.fn(), } as unknown as ReturnType) useDocumentTitleMock.mockImplementation(() => undefined) useModalSpy.mockReturnValue({ diff --git a/libs/domains/organizations/feature/src/lib/settings-billing-summary/settings-billing-summary.tsx b/libs/domains/organizations/feature/src/lib/settings-billing-summary/settings-billing-summary.tsx index 9c06ebd0c38..8a45f7ca907 100644 --- a/libs/domains/organizations/feature/src/lib/settings-billing-summary/settings-billing-summary.tsx +++ b/libs/domains/organizations/feature/src/lib/settings-billing-summary/settings-billing-summary.tsx @@ -9,8 +9,7 @@ import { useModal } from '@qovery/shared/ui' import { Button, Callout, ExternalLink, Icon, Link, Section, Skeleton, imagesCreditCart } from '@qovery/shared/ui' import { dateToFormat } from '@qovery/shared/util-dates' import { useDocumentTitle, useSupportChat } from '@qovery/shared/util-hooks' -import { costToHuman, formatPlanDisplay, pluralize } from '@qovery/shared/util-js' -import { AddCreditCardModal } from '../add-credit-card-modal/add-credit-card-modal' +import { costToHuman, formatPlanDisplay, isActiveFreeTrial, pluralize } from '@qovery/shared/util-js' import { useCreditCards } from '../hooks/use-credit-cards/use-credit-cards' import { useCurrentCost } from '../hooks/use-current-cost/use-current-cost' import InvoicesListFeature from './invoices-list-feature/invoices-list-feature' @@ -26,7 +25,7 @@ export interface PageOrganizationBillingSummaryProps { onShowUsageClick?: () => void onChangePlanClick?: () => void onCancelTrialClick?: () => void - onAddCreditCardClick?: () => void + onActivateAccountClick?: () => void } // This function is used to get the billing recurrence word to display based on the renewal date. @@ -96,7 +95,7 @@ export function PageOrganizationBillingSummary(props: PageOrganizationBillingSum // It's not so accurate, but it's a good enough approximation for now const billingRecurrence = getBillingRecurrenceStr(props.currentCost?.renewal_at) const remainingTrialDay = props.currentCost?.remaining_trial_day ?? 0 - const showTrialCallout = remainingTrialDay !== undefined && remainingTrialDay > 0 + const showTrialCallout = isActiveFreeTrial(props.currentCost?.remaining_trial_day) const showErrorCallout = props.hasCreditCard ?? Boolean(props.creditCard) // This function is used to get the trial start date based on the remaining trial days from the API @@ -145,7 +144,7 @@ export function PageOrganizationBillingSummary(props: PageOrganizationBillingSum {/* Add + 1 because Chargebee return 0 when the trial is ending today */} {showErrorCallout ? `Your free trial plan expires ${remainingTrialDay + 1} ${pluralize(remainingTrialDay + 1, 'day')} from now` - : `No credit card registered, your account will be blocked at the end your trial in ${remainingTrialDay + 1} ${pluralize(remainingTrialDay + 1, 'day')}`} + : `Your account will be blocked at the end of your trial in ${remainingTrialDay + 1} ${pluralize(remainingTrialDay + 1, 'day')}`} {showErrorCallout ? ( <> @@ -154,17 +153,19 @@ export function PageOrganizationBillingSummary(props: PageOrganizationBillingSum subscription will start. You cancel your trial by deleting your organization. ) : ( - <>Add a payment method to avoid service interruption at the end of your trial. + <>Activate your plan to avoid service interruption at the end of your trial. )} - +
+ + {showErrorCallout && ( + + )} +
)}
@@ -235,7 +236,7 @@ function SettingsBillingSummaryContent() { const { data: creditCards = [] } = useCreditCards({ organizationId, suspense: true }) const { data: currentCost } = useCurrentCost({ organizationId, suspense: true }) - const { showChat } = useSupportChat() + const { showChat, showPylonForm } = useSupportChat() const { isQoveryAdminUser } = useUserRole() const openPromoCodeModal = () => { @@ -274,10 +275,8 @@ function SettingsBillingSummaryContent() { navigate({ to: '/organization/$organizationId/settings/danger-zone', params: { organizationId } }) } - const handleAddCreditCardClick = () => { - openModal({ - content: , - }) + const handleActivateAccountClick = () => { + showPylonForm('ask-for-activation') } return ( @@ -289,7 +288,7 @@ function SettingsBillingSummaryContent() { onShowUsageClick={openShowUsageModal} onChangePlanClick={handleChangePlanClick} onCancelTrialClick={handleCancelTrialClick} - onAddCreditCardClick={handleAddCreditCardClick} + onActivateAccountClick={handleActivateAccountClick} /> ) } diff --git a/libs/shared/util-js/src/index.ts b/libs/shared/util-js/src/index.ts index 0c48d3e8f10..f568167b85d 100644 --- a/libs/shared/util-js/src/index.ts +++ b/libs/shared/util-js/src/index.ts @@ -29,3 +29,4 @@ export * from './lib/prepare-variable-import-request' export * from './lib/gcp-wif-validation' export * from './lib/log-selection' export * from './lib/is-firefox-browser' +export * from './lib/is-active-free-trial' diff --git a/libs/shared/util-js/src/lib/is-active-free-trial.spec.ts b/libs/shared/util-js/src/lib/is-active-free-trial.spec.ts new file mode 100644 index 00000000000..5bbfdd6760a --- /dev/null +++ b/libs/shared/util-js/src/lib/is-active-free-trial.spec.ts @@ -0,0 +1,22 @@ +import { isActiveFreeTrial } from './is-active-free-trial' + +describe('isActiveFreeTrial', () => { + it('should return false when remainingTrialDay is undefined', () => { + expect(isActiveFreeTrial(undefined)).toBe(false) + }) + + it('should return false when remainingTrialDay is 0 or negative', () => { + expect(isActiveFreeTrial(0)).toBe(false) + expect(isActiveFreeTrial(-1)).toBe(false) + }) + + it('should return true when remainingTrialDay is within the trial window', () => { + expect(isActiveFreeTrial(1)).toBe(true) + expect(isActiveFreeTrial(14)).toBe(true) + expect(isActiveFreeTrial(90)).toBe(true) + }) + + it('should return false when remainingTrialDay exceeds the trial window', () => { + expect(isActiveFreeTrial(91)).toBe(false) + }) +}) diff --git a/libs/shared/util-js/src/lib/is-active-free-trial.ts b/libs/shared/util-js/src/lib/is-active-free-trial.ts new file mode 100644 index 00000000000..929e65d1aee --- /dev/null +++ b/libs/shared/util-js/src/lib/is-active-free-trial.ts @@ -0,0 +1,5 @@ +export const TRIAL_MAX_DAY = 90 + +export function isActiveFreeTrial(remainingTrialDay?: number): boolean { + return remainingTrialDay !== undefined && remainingTrialDay > 0 && remainingTrialDay <= TRIAL_MAX_DAY +}