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/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/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/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, }, }) 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/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/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-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' && ( ({ 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') 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', }, 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 +}