diff --git a/apps/console/src/routeTree.gen.ts b/apps/console/src/routeTree.gen.ts index c3d9ea47ef7..4327c6663da 100644 --- a/apps/console/src/routeTree.gen.ts +++ b/apps/console/src/routeTree.gen.ts @@ -193,6 +193,7 @@ import { Route as AuthenticatedOrganizationOrganizationIdProjectProjectIdEnviron import { Route as AuthenticatedOrganizationOrganizationIdProjectProjectIdEnvironmentEnvironmentIdServiceServiceIdSettingsDangerZoneRouteImport } from './routes/_authenticated/organization/$organizationId/project/$projectId/environment/$environmentId/service/$serviceId/settings/danger-zone' import { Route as AuthenticatedOrganizationOrganizationIdProjectProjectIdEnvironmentEnvironmentIdServiceServiceIdSettingsConnectionsRouteImport } from './routes/_authenticated/organization/$organizationId/project/$projectId/environment/$environmentId/service/$serviceId/settings/connections' import { Route as AuthenticatedOrganizationOrganizationIdProjectProjectIdEnvironmentEnvironmentIdServiceServiceIdSettingsConfigureRouteImport } from './routes/_authenticated/organization/$organizationId/project/$projectId/environment/$environmentId/service/$serviceId/settings/configure' +import { Route as AuthenticatedOrganizationOrganizationIdProjectProjectIdEnvironmentEnvironmentIdServiceServiceIdSettingsBlueprintConfigurationRouteImport } from './routes/_authenticated/organization/$organizationId/project/$projectId/environment/$environmentId/service/$serviceId/settings/blueprint-configuration' import { Route as AuthenticatedOrganizationOrganizationIdProjectProjectIdEnvironmentEnvironmentIdServiceServiceIdSettingsAutomationsRouteImport } from './routes/_authenticated/organization/$organizationId/project/$projectId/environment/$environmentId/service/$serviceId/settings/automations' import { Route as AuthenticatedOrganizationOrganizationIdProjectProjectIdEnvironmentEnvironmentIdServiceServiceIdSettingsAiConfigurationRouteImport } from './routes/_authenticated/organization/$organizationId/project/$projectId/environment/$environmentId/service/$serviceId/settings/ai-configuration' import { Route as AuthenticatedOrganizationOrganizationIdProjectProjectIdEnvironmentEnvironmentIdServiceServiceIdSettingsAdvancedSettingsRouteImport } from './routes/_authenticated/organization/$organizationId/project/$projectId/environment/$environmentId/service/$serviceId/settings/advanced-settings' @@ -1724,6 +1725,15 @@ const AuthenticatedOrganizationOrganizationIdProjectProjectIdEnvironmentEnvironm AuthenticatedOrganizationOrganizationIdProjectProjectIdEnvironmentEnvironmentIdServiceServiceIdSettingsRouteRoute, } as any, ) +const AuthenticatedOrganizationOrganizationIdProjectProjectIdEnvironmentEnvironmentIdServiceServiceIdSettingsBlueprintConfigurationRoute = + AuthenticatedOrganizationOrganizationIdProjectProjectIdEnvironmentEnvironmentIdServiceServiceIdSettingsBlueprintConfigurationRouteImport.update( + { + id: '/blueprint-configuration', + path: '/blueprint-configuration', + getParentRoute: () => + AuthenticatedOrganizationOrganizationIdProjectProjectIdEnvironmentEnvironmentIdServiceServiceIdSettingsRouteRoute, + } as any, + ) const AuthenticatedOrganizationOrganizationIdProjectProjectIdEnvironmentEnvironmentIdServiceServiceIdSettingsAutomationsRoute = AuthenticatedOrganizationOrganizationIdProjectProjectIdEnvironmentEnvironmentIdServiceServiceIdSettingsAutomationsRouteImport.update( { @@ -2002,6 +2012,7 @@ export interface FileRoutesByFullPath { '/organization/$organizationId/project/$projectId/environment/$environmentId/service/$serviceId/settings/advanced-settings': typeof AuthenticatedOrganizationOrganizationIdProjectProjectIdEnvironmentEnvironmentIdServiceServiceIdSettingsAdvancedSettingsRoute '/organization/$organizationId/project/$projectId/environment/$environmentId/service/$serviceId/settings/ai-configuration': typeof AuthenticatedOrganizationOrganizationIdProjectProjectIdEnvironmentEnvironmentIdServiceServiceIdSettingsAiConfigurationRoute '/organization/$organizationId/project/$projectId/environment/$environmentId/service/$serviceId/settings/automations': typeof AuthenticatedOrganizationOrganizationIdProjectProjectIdEnvironmentEnvironmentIdServiceServiceIdSettingsAutomationsRoute + '/organization/$organizationId/project/$projectId/environment/$environmentId/service/$serviceId/settings/blueprint-configuration': typeof AuthenticatedOrganizationOrganizationIdProjectProjectIdEnvironmentEnvironmentIdServiceServiceIdSettingsBlueprintConfigurationRoute '/organization/$organizationId/project/$projectId/environment/$environmentId/service/$serviceId/settings/configure': typeof AuthenticatedOrganizationOrganizationIdProjectProjectIdEnvironmentEnvironmentIdServiceServiceIdSettingsConfigureRoute '/organization/$organizationId/project/$projectId/environment/$environmentId/service/$serviceId/settings/connections': typeof AuthenticatedOrganizationOrganizationIdProjectProjectIdEnvironmentEnvironmentIdServiceServiceIdSettingsConnectionsRoute '/organization/$organizationId/project/$projectId/environment/$environmentId/service/$serviceId/settings/danger-zone': typeof AuthenticatedOrganizationOrganizationIdProjectProjectIdEnvironmentEnvironmentIdServiceServiceIdSettingsDangerZoneRoute @@ -2182,6 +2193,7 @@ export interface FileRoutesByTo { '/organization/$organizationId/project/$projectId/environment/$environmentId/service/$serviceId/settings/advanced-settings': typeof AuthenticatedOrganizationOrganizationIdProjectProjectIdEnvironmentEnvironmentIdServiceServiceIdSettingsAdvancedSettingsRoute '/organization/$organizationId/project/$projectId/environment/$environmentId/service/$serviceId/settings/ai-configuration': typeof AuthenticatedOrganizationOrganizationIdProjectProjectIdEnvironmentEnvironmentIdServiceServiceIdSettingsAiConfigurationRoute '/organization/$organizationId/project/$projectId/environment/$environmentId/service/$serviceId/settings/automations': typeof AuthenticatedOrganizationOrganizationIdProjectProjectIdEnvironmentEnvironmentIdServiceServiceIdSettingsAutomationsRoute + '/organization/$organizationId/project/$projectId/environment/$environmentId/service/$serviceId/settings/blueprint-configuration': typeof AuthenticatedOrganizationOrganizationIdProjectProjectIdEnvironmentEnvironmentIdServiceServiceIdSettingsBlueprintConfigurationRoute '/organization/$organizationId/project/$projectId/environment/$environmentId/service/$serviceId/settings/configure': typeof AuthenticatedOrganizationOrganizationIdProjectProjectIdEnvironmentEnvironmentIdServiceServiceIdSettingsConfigureRoute '/organization/$organizationId/project/$projectId/environment/$environmentId/service/$serviceId/settings/connections': typeof AuthenticatedOrganizationOrganizationIdProjectProjectIdEnvironmentEnvironmentIdServiceServiceIdSettingsConnectionsRoute '/organization/$organizationId/project/$projectId/environment/$environmentId/service/$serviceId/settings/danger-zone': typeof AuthenticatedOrganizationOrganizationIdProjectProjectIdEnvironmentEnvironmentIdServiceServiceIdSettingsDangerZoneRoute @@ -2385,6 +2397,7 @@ export interface FileRoutesById { '/_authenticated/organization/$organizationId/project/$projectId/environment/$environmentId/service/$serviceId/settings/advanced-settings': typeof AuthenticatedOrganizationOrganizationIdProjectProjectIdEnvironmentEnvironmentIdServiceServiceIdSettingsAdvancedSettingsRoute '/_authenticated/organization/$organizationId/project/$projectId/environment/$environmentId/service/$serviceId/settings/ai-configuration': typeof AuthenticatedOrganizationOrganizationIdProjectProjectIdEnvironmentEnvironmentIdServiceServiceIdSettingsAiConfigurationRoute '/_authenticated/organization/$organizationId/project/$projectId/environment/$environmentId/service/$serviceId/settings/automations': typeof AuthenticatedOrganizationOrganizationIdProjectProjectIdEnvironmentEnvironmentIdServiceServiceIdSettingsAutomationsRoute + '/_authenticated/organization/$organizationId/project/$projectId/environment/$environmentId/service/$serviceId/settings/blueprint-configuration': typeof AuthenticatedOrganizationOrganizationIdProjectProjectIdEnvironmentEnvironmentIdServiceServiceIdSettingsBlueprintConfigurationRoute '/_authenticated/organization/$organizationId/project/$projectId/environment/$environmentId/service/$serviceId/settings/configure': typeof AuthenticatedOrganizationOrganizationIdProjectProjectIdEnvironmentEnvironmentIdServiceServiceIdSettingsConfigureRoute '/_authenticated/organization/$organizationId/project/$projectId/environment/$environmentId/service/$serviceId/settings/connections': typeof AuthenticatedOrganizationOrganizationIdProjectProjectIdEnvironmentEnvironmentIdServiceServiceIdSettingsConnectionsRoute '/_authenticated/organization/$organizationId/project/$projectId/environment/$environmentId/service/$serviceId/settings/danger-zone': typeof AuthenticatedOrganizationOrganizationIdProjectProjectIdEnvironmentEnvironmentIdServiceServiceIdSettingsDangerZoneRoute @@ -2589,6 +2602,7 @@ export interface FileRouteTypes { | '/organization/$organizationId/project/$projectId/environment/$environmentId/service/$serviceId/settings/advanced-settings' | '/organization/$organizationId/project/$projectId/environment/$environmentId/service/$serviceId/settings/ai-configuration' | '/organization/$organizationId/project/$projectId/environment/$environmentId/service/$serviceId/settings/automations' + | '/organization/$organizationId/project/$projectId/environment/$environmentId/service/$serviceId/settings/blueprint-configuration' | '/organization/$organizationId/project/$projectId/environment/$environmentId/service/$serviceId/settings/configure' | '/organization/$organizationId/project/$projectId/environment/$environmentId/service/$serviceId/settings/connections' | '/organization/$organizationId/project/$projectId/environment/$environmentId/service/$serviceId/settings/danger-zone' @@ -2769,6 +2783,7 @@ export interface FileRouteTypes { | '/organization/$organizationId/project/$projectId/environment/$environmentId/service/$serviceId/settings/advanced-settings' | '/organization/$organizationId/project/$projectId/environment/$environmentId/service/$serviceId/settings/ai-configuration' | '/organization/$organizationId/project/$projectId/environment/$environmentId/service/$serviceId/settings/automations' + | '/organization/$organizationId/project/$projectId/environment/$environmentId/service/$serviceId/settings/blueprint-configuration' | '/organization/$organizationId/project/$projectId/environment/$environmentId/service/$serviceId/settings/configure' | '/organization/$organizationId/project/$projectId/environment/$environmentId/service/$serviceId/settings/connections' | '/organization/$organizationId/project/$projectId/environment/$environmentId/service/$serviceId/settings/danger-zone' @@ -2971,6 +2986,7 @@ export interface FileRouteTypes { | '/_authenticated/organization/$organizationId/project/$projectId/environment/$environmentId/service/$serviceId/settings/advanced-settings' | '/_authenticated/organization/$organizationId/project/$projectId/environment/$environmentId/service/$serviceId/settings/ai-configuration' | '/_authenticated/organization/$organizationId/project/$projectId/environment/$environmentId/service/$serviceId/settings/automations' + | '/_authenticated/organization/$organizationId/project/$projectId/environment/$environmentId/service/$serviceId/settings/blueprint-configuration' | '/_authenticated/organization/$organizationId/project/$projectId/environment/$environmentId/service/$serviceId/settings/configure' | '/_authenticated/organization/$organizationId/project/$projectId/environment/$environmentId/service/$serviceId/settings/connections' | '/_authenticated/organization/$organizationId/project/$projectId/environment/$environmentId/service/$serviceId/settings/danger-zone' @@ -4344,6 +4360,13 @@ declare module '@tanstack/react-router' { preLoaderRoute: typeof AuthenticatedOrganizationOrganizationIdProjectProjectIdEnvironmentEnvironmentIdServiceServiceIdSettingsConfigureRouteImport parentRoute: typeof AuthenticatedOrganizationOrganizationIdProjectProjectIdEnvironmentEnvironmentIdServiceServiceIdSettingsRouteRoute } + '/_authenticated/organization/$organizationId/project/$projectId/environment/$environmentId/service/$serviceId/settings/blueprint-configuration': { + id: '/_authenticated/organization/$organizationId/project/$projectId/environment/$environmentId/service/$serviceId/settings/blueprint-configuration' + path: '/blueprint-configuration' + fullPath: '/organization/$organizationId/project/$projectId/environment/$environmentId/service/$serviceId/settings/blueprint-configuration' + preLoaderRoute: typeof AuthenticatedOrganizationOrganizationIdProjectProjectIdEnvironmentEnvironmentIdServiceServiceIdSettingsBlueprintConfigurationRouteImport + parentRoute: typeof AuthenticatedOrganizationOrganizationIdProjectProjectIdEnvironmentEnvironmentIdServiceServiceIdSettingsRouteRoute + } '/_authenticated/organization/$organizationId/project/$projectId/environment/$environmentId/service/$serviceId/settings/automations': { id: '/_authenticated/organization/$organizationId/project/$projectId/environment/$environmentId/service/$serviceId/settings/automations' path: '/automations' @@ -4797,6 +4820,7 @@ interface AuthenticatedOrganizationOrganizationIdProjectProjectIdEnvironmentEnvi AuthenticatedOrganizationOrganizationIdProjectProjectIdEnvironmentEnvironmentIdServiceServiceIdSettingsAdvancedSettingsRoute: typeof AuthenticatedOrganizationOrganizationIdProjectProjectIdEnvironmentEnvironmentIdServiceServiceIdSettingsAdvancedSettingsRoute AuthenticatedOrganizationOrganizationIdProjectProjectIdEnvironmentEnvironmentIdServiceServiceIdSettingsAiConfigurationRoute: typeof AuthenticatedOrganizationOrganizationIdProjectProjectIdEnvironmentEnvironmentIdServiceServiceIdSettingsAiConfigurationRoute AuthenticatedOrganizationOrganizationIdProjectProjectIdEnvironmentEnvironmentIdServiceServiceIdSettingsAutomationsRoute: typeof AuthenticatedOrganizationOrganizationIdProjectProjectIdEnvironmentEnvironmentIdServiceServiceIdSettingsAutomationsRoute + AuthenticatedOrganizationOrganizationIdProjectProjectIdEnvironmentEnvironmentIdServiceServiceIdSettingsBlueprintConfigurationRoute: typeof AuthenticatedOrganizationOrganizationIdProjectProjectIdEnvironmentEnvironmentIdServiceServiceIdSettingsBlueprintConfigurationRoute AuthenticatedOrganizationOrganizationIdProjectProjectIdEnvironmentEnvironmentIdServiceServiceIdSettingsConfigureRoute: typeof AuthenticatedOrganizationOrganizationIdProjectProjectIdEnvironmentEnvironmentIdServiceServiceIdSettingsConfigureRoute AuthenticatedOrganizationOrganizationIdProjectProjectIdEnvironmentEnvironmentIdServiceServiceIdSettingsConnectionsRoute: typeof AuthenticatedOrganizationOrganizationIdProjectProjectIdEnvironmentEnvironmentIdServiceServiceIdSettingsConnectionsRoute AuthenticatedOrganizationOrganizationIdProjectProjectIdEnvironmentEnvironmentIdServiceServiceIdSettingsDangerZoneRoute: typeof AuthenticatedOrganizationOrganizationIdProjectProjectIdEnvironmentEnvironmentIdServiceServiceIdSettingsDangerZoneRoute @@ -4826,6 +4850,8 @@ const AuthenticatedOrganizationOrganizationIdProjectProjectIdEnvironmentEnvironm AuthenticatedOrganizationOrganizationIdProjectProjectIdEnvironmentEnvironmentIdServiceServiceIdSettingsAiConfigurationRoute, AuthenticatedOrganizationOrganizationIdProjectProjectIdEnvironmentEnvironmentIdServiceServiceIdSettingsAutomationsRoute: AuthenticatedOrganizationOrganizationIdProjectProjectIdEnvironmentEnvironmentIdServiceServiceIdSettingsAutomationsRoute, + AuthenticatedOrganizationOrganizationIdProjectProjectIdEnvironmentEnvironmentIdServiceServiceIdSettingsBlueprintConfigurationRoute: + AuthenticatedOrganizationOrganizationIdProjectProjectIdEnvironmentEnvironmentIdServiceServiceIdSettingsBlueprintConfigurationRoute, AuthenticatedOrganizationOrganizationIdProjectProjectIdEnvironmentEnvironmentIdServiceServiceIdSettingsConfigureRoute: AuthenticatedOrganizationOrganizationIdProjectProjectIdEnvironmentEnvironmentIdServiceServiceIdSettingsConfigureRoute, AuthenticatedOrganizationOrganizationIdProjectProjectIdEnvironmentEnvironmentIdServiceServiceIdSettingsConnectionsRoute: diff --git a/apps/console/src/routes/_authenticated/organization/$organizationId/project/$projectId/environment/$environmentId/service/$serviceId/settings/blueprint-configuration.tsx b/apps/console/src/routes/_authenticated/organization/$organizationId/project/$projectId/environment/$environmentId/service/$serviceId/settings/blueprint-configuration.tsx new file mode 100644 index 00000000000..74ae78874ae --- /dev/null +++ b/apps/console/src/routes/_authenticated/organization/$organizationId/project/$projectId/environment/$environmentId/service/$serviceId/settings/blueprint-configuration.tsx @@ -0,0 +1,39 @@ +import { Navigate, createFileRoute } from '@tanstack/react-router' +import { useOrganization } from '@qovery/domains/organizations/feature' +import { BlueprintGeneralSettings } from '@qovery/domains/service-settings/feature' +import { isBlueprintService } from '@qovery/domains/services/data-access' +import { useService } from '@qovery/domains/services/feature' +import { useDocumentTitle } from '@qovery/shared/util-hooks' + +export const Route = createFileRoute( + '/_authenticated/organization/$organizationId/project/$projectId/environment/$environmentId/service/$serviceId/settings/blueprint-configuration' +)({ + component: RouteComponent, +}) + +function RouteComponent() { + const { organizationId, projectId, environmentId, serviceId } = Route.useParams() + const { data: organization } = useOrganization({ organizationId, suspense: true }) + const { data: service } = useService({ environmentId, serviceId, suspense: true }) + useDocumentTitle('Blueprint configuration - Service settings') + + if (!service) { + return null + } + + if (!isBlueprintService(service)) { + return ( + + ) + } + + if (!organization) { + return null + } + + return +} diff --git a/apps/console/src/routes/_authenticated/organization/$organizationId/project/$projectId/environment/$environmentId/service/$serviceId/update/blueprint/route.tsx b/apps/console/src/routes/_authenticated/organization/$organizationId/project/$projectId/environment/$environmentId/service/$serviceId/update/blueprint/route.tsx index f8128d0ae0b..7b80d63892f 100644 --- a/apps/console/src/routes/_authenticated/organization/$organizationId/project/$projectId/environment/$environmentId/service/$serviceId/update/blueprint/route.tsx +++ b/apps/console/src/routes/_authenticated/organization/$organizationId/project/$projectId/environment/$environmentId/service/$serviceId/update/blueprint/route.tsx @@ -23,6 +23,12 @@ function RouteComponent() { params: { organizationId, projectId, environmentId, serviceId }, }) }, [environmentId, navigate, organizationId, projectId, serviceId]) + const navigateToBlueprintSettings = useCallback(() => { + navigate({ + to: '/organization/$organizationId/project/$projectId/environment/$environmentId/service/$serviceId/settings/blueprint-configuration', + params: { organizationId, projectId, environmentId, serviceId }, + }) + }, [environmentId, navigate, organizationId, projectId, serviceId]) useEffect(() => { if (service && !blueprintId) navigateToOverview() @@ -37,7 +43,7 @@ function RouteComponent() { currentStep={currentStep} environmentId={environmentId} service={service} - onExit={navigateToOverview} + onExit={navigateToBlueprintSettings} > 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 35f54ac877e..47fe1ff4364 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 @@ -3,7 +3,7 @@ import { Outlet, createFileRoute, useMatchRoute, useNavigate } from '@tanstack/r import { useFeatureFlagEnabled } from 'posthog-js/react' import { Suspense, useEffect } from 'react' import { isAgenticWorkflow } from '@qovery/domains/services/data-access' -import { useService } from '@qovery/domains/services/feature' +import { hasTerraformVariablesTab, useService } from '@qovery/domains/services/feature' import { Badge, Heading, Icon, LoaderSpinner, Navbar, Section } from '@qovery/shared/ui' export const Route = createFileRoute( @@ -82,7 +82,8 @@ function RouteComponent() { if (shouldRedirect) return null - const serviceTabs = service?.serviceType === 'TERRAFORM' ? [...tabs, terraformTab] : tabs + const hasTerraformVariables = hasTerraformVariablesTab(service) + const serviceTabs = hasTerraformVariables ? [...tabs, terraformTab] : tabs const activeTabId = serviceTabs.find((tab) => matchRoute({ to: tab.routeId }))?.id return ( @@ -105,7 +106,7 @@ function RouteComponent() { ))} - {service?.serviceType === 'TERRAFORM' && ( + {hasTerraformVariables && ( <>
diff --git a/apps/console/src/routes/_authenticated/organization/$organizationId/project/$projectId/environment/$environmentId/service/$serviceId/variables/terraform.tsx b/apps/console/src/routes/_authenticated/organization/$organizationId/project/$projectId/environment/$environmentId/service/$serviceId/variables/terraform.tsx index b1f3dde2a12..6394a4c0ad6 100644 --- a/apps/console/src/routes/_authenticated/organization/$organizationId/project/$projectId/environment/$environmentId/service/$serviceId/variables/terraform.tsx +++ b/apps/console/src/routes/_authenticated/organization/$organizationId/project/$projectId/environment/$environmentId/service/$serviceId/variables/terraform.tsx @@ -7,7 +7,7 @@ import { TerraformVariablesTable, useTerraformVariablesContext, } from '@qovery/domains/service-terraform/feature' -import { type Terraform } from '@qovery/domains/services/data-access' +import { type Terraform, isBlueprintService } from '@qovery/domains/services/data-access' import { type TerraformGeneralData, useEditService, useService } from '@qovery/domains/services/feature' import { Button, LoaderSpinner } from '@qovery/shared/ui' import { useDocumentTitle } from '@qovery/shared/util-hooks' @@ -87,7 +87,7 @@ function RouteComponent() { const { data: service } = useService({ environmentId, serviceId, suspense: true }) useDocumentTitle('Terraform variables - Service') - if (service?.serviceType !== 'TERRAFORM') { + if (!service || isBlueprintService(service) || service.serviceType !== 'TERRAFORM') { return null } diff --git a/libs/domains/service-settings/feature/src/index.ts b/libs/domains/service-settings/feature/src/index.ts index cfd181dcf67..80f1cbff7a5 100644 --- a/libs/domains/service-settings/feature/src/index.ts +++ b/libs/domains/service-settings/feature/src/index.ts @@ -1,4 +1,5 @@ export * from './lib/service-general-settings/service-general-settings' +export * from './lib/service-general-settings/blueprint-general-settings/blueprint-general-settings' export * from './lib/service-resources-settings/service-resources-settings' export * from './lib/service-settings-layout/service-settings-layout' export * from './lib/service-danger-zone-settings/service-danger-zone-settings' diff --git a/libs/domains/service-settings/feature/src/lib/service-general-settings/blueprint-general-settings/blueprint-general-settings.spec.tsx b/libs/domains/service-settings/feature/src/lib/service-general-settings/blueprint-general-settings/blueprint-general-settings.spec.tsx new file mode 100644 index 00000000000..930c0a4cefb --- /dev/null +++ b/libs/domains/service-settings/feature/src/lib/service-general-settings/blueprint-general-settings/blueprint-general-settings.spec.tsx @@ -0,0 +1,426 @@ +import { QueryClient, QueryClientProvider } from '@tanstack/react-query' +import posthog from 'posthog-js' +import { type ReactNode, useState } from 'react' +import { helmFactoryMock, terraformFactoryMock } from '@qovery/shared/factories' +import { renderWithProviders, screen } from '@qovery/shared/util-tests' +import { BlueprintGeneralSettings } from './blueprint-general-settings' + +const mockUseBlueprint = jest.fn() +const mockUseBlueprintVariables = jest.fn().mockReturnValue({ data: [], isLoading: false }) +const mockUseBlueprintCatalogServiceManifest = jest.fn() +const mockPreviewBlueprintUpdate = jest.fn() +const mockUpdateBlueprint = jest.fn() +const mockDeployBlueprint = jest.fn() +const mockBlueprintMetadata = jest.fn() +const mockIsFieldValid = jest.fn(() => true) + +jest.mock('posthog-js', () => ({ + capture: jest.fn(), +})) +const service = { + ...terraformFactoryMock(1)[0], + blueprint_id: 'blueprint-id', + terraform_variables_source: { + tf_vars: [{ key: 'database_name', value: 'outdated-value', secret: false }], + tf_var_file_paths: [], + }, +} + +jest.mock('@qovery/domains/environments/feature', () => ({ + useEnvironment: () => ({ data: { cluster_id: 'cluster-id' } }), +})) + +jest.mock('@qovery/domains/services/feature', () => ({ + useBlueprint: (...args: unknown[]) => mockUseBlueprint(...args), + useBlueprintVariables: (...args: unknown[]) => mockUseBlueprintVariables(...args), + useBlueprintCatalogServiceManifest: (...args: unknown[]) => mockUseBlueprintCatalogServiceManifest(...args), + usePreviewBlueprintUpdate: () => ({ mutateAsync: mockPreviewBlueprintUpdate, isLoading: false }), + useUpdateBlueprint: () => ({ mutateAsync: mockUpdateBlueprint, isLoading: false }), + useDeployBlueprint: () => ({ mutateAsync: mockDeployBlueprint, isLoading: false }), + getDefaultFieldValue: jest.fn(), + getFallbackServiceIcon: jest.fn(), + getFieldValidationError: jest.fn(), + isFieldValid: (...args: unknown[]) => mockIsFieldValid(...args), + isOptionalVariableField: (field: { required: boolean; kind: string }) => field.kind === 'variable' && !field.required, + isRequiredVariableField: (field: { required: boolean; kind: string }) => field.kind === 'variable' && field.required, + BlueprintManifestVariableInput: ({ + onChange, + value, + }: { + onChange: (value: string) => void + value: string | boolean | undefined + }) => ( + <> + Current value: {String(value ?? '')} + + + ), + BlueprintSection: ({ title, children }: { title: string; children: ReactNode }) => ( +
+ {title} + {children} +
+ ), + OverridesSectionCard: ({ + active, + children, + onClick, + }: { + active: boolean + children: ReactNode + onClick: () => void + }) => (active ?
{children}
: ), + BlueprintUpdateFlowShell: ({ children }: { children: ReactNode }) =>
{children}
, + BlueprintPreview: ({ onBack, onConfirm }: { onBack: () => void; onConfirm: () => Promise }) => ( + <> + + + + ), + BlueprintMetadata: (props: unknown) => { + mockBlueprintMetadata(props) + return Blueprint metadata + }, + BlueprintMetadataSkeleton: () => Loading blueprint metadata, + formatBlueprintName: (name: string) => + name + .split('-') + .map((part) => part.charAt(0).toUpperCase() + part.slice(1)) + .join(' '), +})) + +function BlueprintGeneralSettingsHarness() { + const [queryClient] = useState(() => new QueryClient()) + const [visible, setVisible] = useState(true) + + return ( + + + {visible ? ( + + ) : null} + + ) +} + +describe('BlueprintGeneralSettings', () => { + beforeEach(() => { + mockUseBlueprintVariables.mockReturnValue({ data: [], isLoading: false }) + mockIsFieldValid.mockReturnValue(true) + jest.mocked(posthog.capture).mockClear() + }) + + it('loads the catalog form from the Blueprint tag returned by the existing read endpoint', () => { + mockUseBlueprintCatalogServiceManifest.mockReturnValue({ data: [], isLoading: false }) + mockUseBlueprint.mockReturnValue({ + data: { name: 'aws-rds-postgresql', tag: 'aws/postgres/17/1.0.0' }, + isLoading: false, + }) + + renderWithProviders( + + ) + + expect(screen.getByText('Blueprint setup')).toBeInTheDocument() + expect(screen.getByText('Aws Rds Postgresql')).toBeInTheDocument() + expect(screen.getByText('Blueprint metadata')).toBeInTheDocument() + expect(mockBlueprintMetadata).toHaveBeenCalledWith( + expect.objectContaining({ + blueprintId: 'blueprint-id', + gitRepository: service.terraform_files_source.git?.git_repository, + service, + }) + ) + expect(mockUseBlueprintCatalogServiceManifest).toHaveBeenCalledWith( + expect.objectContaining({ provider: 'aws', serviceFamily: 'postgres', serviceVersion: '17' }) + ) + expect(posthog.capture).toHaveBeenCalledWith('blueprint_settings_visited', { + blueprint_id: service.blueprint_id, + service_id: service.id, + service_type: service.serviceType, + }) + }) + + it('uses the Blueprint read model to prefill and preview updates for Helm Blueprint services', async () => { + const helmBlueprintService = { ...helmFactoryMock(1)[0], blueprint_id: 'helm-blueprint-id' } + mockUseBlueprintCatalogServiceManifest.mockReturnValue({ data: [], isLoading: false }) + mockUseBlueprint.mockReturnValue({ + data: { + name: 'rabbitmq', + tag: 'helm/rabbitmq/1.0.0', + variables: [{ name: 'replicas', value: '3', is_secret: false }], + manifest: { + results: [ + { + kind: 'variable', + name: 'replicas', + required: true, + is_secret: false, + type: { type: 'string' }, + }, + ], + }, + }, + isLoading: false, + }) + mockUseBlueprintVariables.mockReturnValue({ + data: [{ name: 'replicas', value: '3', is_secret: false }], + isLoading: false, + }) + mockPreviewBlueprintUpdate.mockResolvedValue({ preview_id: 'preview-id' }) + + const { userEvent } = renderWithProviders( + + ) + + expect(screen.getByText('Current value: 3')).toBeInTheDocument() + expect(mockBlueprintMetadata).toHaveBeenCalledWith( + expect.objectContaining({ + blueprintId: 'helm-blueprint-id', + gitRepository: helmBlueprintService.source.git.git_repository, + service: helmBlueprintService, + }) + ) + + await userEvent.click(screen.getByRole('button', { name: 'Edit value' })) + await userEvent.click(screen.getByRole('button', { name: 'Preview changes' })) + + expect(mockPreviewBlueprintUpdate).toHaveBeenCalledWith({ + blueprintId: 'helm-blueprint-id', + payload: expect.objectContaining({ + variables: { + replicas: { value: 'updated-value', is_secret: false }, + }, + }), + }) + }) + + it('only persists and deploys after the user confirms the preview', async () => { + mockUseBlueprintCatalogServiceManifest.mockReturnValue({ + data: [ + { + kind: 'variable', + name: 'database_name', + required: false, + is_secret: true, + type: { type: 'string' }, + }, + ], + isLoading: false, + }) + mockUseBlueprint.mockReturnValue({ + data: { name: service.name, tag: 'aws/postgres/17/1.0.0' }, + isLoading: false, + }) + mockUseBlueprintVariables.mockReturnValue({ + data: [{ name: 'database_name', value: 'persisted-secret', is_secret: true }], + isLoading: false, + }) + mockPreviewBlueprintUpdate.mockResolvedValue({ preview_id: 'preview-id' }) + + const { userEvent } = renderWithProviders( + + ) + + expect(screen.getByRole('button', { name: 'Configure' })).toBeInTheDocument() + expect(screen.queryByText('Current value:')).not.toBeInTheDocument() + await userEvent.click(screen.getByRole('button', { name: 'Configure' })) + expect(screen.getByText(/^Current value:\s*$/)).toBeInTheDocument() + await userEvent.click(screen.getByRole('button', { name: 'Edit value' })) + await userEvent.click(screen.getByRole('button', { name: 'Preview changes' })) + + expect(mockPreviewBlueprintUpdate).toHaveBeenCalledTimes(1) + expect(posthog.capture).toHaveBeenCalledWith('blueprint_settings_preview_triggered', { + blueprint_id: 'blueprint-id', + service_id: service.id, + service_type: service.serviceType, + }) + expect(mockPreviewBlueprintUpdate).toHaveBeenCalledWith({ + blueprintId: 'blueprint-id', + payload: expect.objectContaining({ + variables: { + database_name: { value: 'updated-value', is_secret: true }, + }, + }), + }) + expect(mockUpdateBlueprint).not.toHaveBeenCalled() + expect(mockDeployBlueprint).not.toHaveBeenCalled() + + await userEvent.click(screen.getByRole('button', { name: 'Confirm & deploy update' })) + + expect(screen.queryByRole('dialog')).not.toBeInTheDocument() + expect(mockUpdateBlueprint).toHaveBeenCalledTimes(1) + expect(mockUpdateBlueprint).toHaveBeenCalledWith({ + blueprintId: 'blueprint-id', + payload: expect.objectContaining({ + variables: { + database_name: { value: 'updated-value', is_secret: true }, + }, + }), + }) + expect(mockDeployBlueprint).toHaveBeenCalledTimes(1) + expect(posthog.capture).toHaveBeenCalledWith('blueprint_settings_updated', { + blueprint_id: service.blueprint_id, + service_id: service.id, + service_type: service.serviceType, + }) + }) + + it('keeps persisted optimistic values when deployment fails after the update succeeds', async () => { + mockUseBlueprintCatalogServiceManifest.mockReturnValue({ + data: [ + { + kind: 'variable', + name: 'database_name', + required: false, + is_secret: false, + type: { type: 'string' }, + }, + ], + isLoading: false, + }) + mockUseBlueprint.mockReturnValue({ + data: { name: service.name, tag: 'aws/postgres/17/1.0.0' }, + isLoading: false, + }) + mockPreviewBlueprintUpdate.mockResolvedValue({ preview_id: 'preview-id' }) + mockUpdateBlueprint.mockResolvedValue(undefined) + mockDeployBlueprint.mockRejectedValue(new Error('deployment failed')) + + const { userEvent } = renderWithProviders( + + ) + + await userEvent.click(screen.getByRole('button', { name: 'Configure' })) + await userEvent.click(screen.getByRole('button', { name: 'Edit value' })) + await userEvent.click(screen.getByRole('button', { name: 'Preview changes' })) + await userEvent.click(screen.getByRole('button', { name: 'Confirm & deploy update' })) + + expect(screen.getByText('Current value: updated-value')).toBeInTheDocument() + expect(mockUpdateBlueprint).toHaveBeenCalledTimes(1) + expect(mockDeployBlueprint).toHaveBeenCalledTimes(1) + }) + + it('disables Preview when an optional field has an invalid value', async () => { + mockUseBlueprintCatalogServiceManifest.mockReturnValue({ + data: [ + { + kind: 'variable', + name: 'database_name', + required: false, + is_secret: false, + type: { type: 'string' }, + }, + ], + isLoading: false, + }) + mockUseBlueprint.mockReturnValue({ + data: { name: service.name, tag: 'aws/postgres/17/1.0.0' }, + isLoading: false, + }) + mockIsFieldValid.mockReturnValue(false) + + const { userEvent } = renderWithProviders( + + ) + + await userEvent.click(screen.getByRole('button', { name: 'Configure' })) + await userEvent.click(screen.getByRole('button', { name: 'Edit value' })) + + expect(screen.getByRole('button', { name: 'Preview changes' })).toBeDisabled() + expect(mockPreviewBlueprintUpdate).not.toHaveBeenCalled() + }) + + it('keeps confirmed non-secret values visible when Settings remounts before the service read model catches up', async () => { + mockUseBlueprintCatalogServiceManifest.mockReturnValue({ + data: [ + { + kind: 'variable', + name: 'database_name', + required: false, + is_secret: false, + type: { type: 'string' }, + }, + ], + isLoading: false, + }) + mockUseBlueprint.mockReturnValue({ + data: { name: service.name, tag: 'aws/postgres/17/1.0.0' }, + isLoading: false, + }) + mockPreviewBlueprintUpdate.mockResolvedValue({ preview_id: 'preview-id' }) + + const { userEvent } = renderWithProviders() + + await userEvent.click(screen.getByRole('button', { name: 'Configure' })) + await userEvent.click(screen.getByRole('button', { name: 'Edit value' })) + await userEvent.click(screen.getByRole('button', { name: 'Preview changes' })) + await userEvent.click(screen.getByRole('button', { name: 'Confirm & deploy update' })) + + expect(screen.getByText('Current value: updated-value')).toBeInTheDocument() + + await userEvent.click(screen.getByRole('button', { name: 'Navigate to Overview' })) + await userEvent.click(screen.getByRole('button', { name: 'Navigate to Settings' })) + + await userEvent.click(screen.getByRole('button', { name: 'Configure' })) + expect(screen.getByText('Current value: updated-value')).toBeInTheDocument() + }) + + it('keeps the preview modal open until the user explicitly returns to the configuration', async () => { + mockUseBlueprintCatalogServiceManifest.mockReturnValue({ + data: [ + { + kind: 'variable', + name: 'database_name', + required: false, + is_secret: false, + type: { type: 'string' }, + }, + ], + isLoading: false, + }) + mockUseBlueprint.mockReturnValue({ + data: { name: service.name, tag: 'aws/postgres/17/1.0.0' }, + isLoading: false, + }) + mockPreviewBlueprintUpdate.mockResolvedValue({ preview_id: 'preview-id' }) + + const { userEvent } = renderWithProviders( + + ) + + await userEvent.click(screen.getByRole('button', { name: 'Configure' })) + await userEvent.click(screen.getByRole('button', { name: 'Edit value' })) + await userEvent.click(screen.getByRole('button', { name: 'Preview changes' })) + + expect(screen.getByRole('dialog')).toHaveStyle({ + height: 'min(65vh, 680px)', + width: 'min(50vw, 900px)', + }) + + await userEvent.click(screen.getByTestId('overlay')) + + expect(screen.getByRole('dialog')).toBeInTheDocument() + + await userEvent.keyboard('{Escape}') + + expect(screen.getByRole('dialog')).toBeInTheDocument() + + await userEvent.click(screen.getByRole('button', { name: 'Back to configuration' })) + + expect(screen.queryByRole('dialog')).not.toBeInTheDocument() + expect(screen.getByRole('button', { name: 'Preview changes' })).toBeEnabled() + }) +}) diff --git a/libs/domains/service-settings/feature/src/lib/service-general-settings/blueprint-general-settings/blueprint-general-settings.tsx b/libs/domains/service-settings/feature/src/lib/service-general-settings/blueprint-general-settings/blueprint-general-settings.tsx new file mode 100644 index 00000000000..c71af76d9ad --- /dev/null +++ b/libs/domains/service-settings/feature/src/lib/service-general-settings/blueprint-general-settings/blueprint-general-settings.tsx @@ -0,0 +1,463 @@ +import * as Dialog from '@radix-ui/react-dialog' +import { useQuery, useQueryClient } from '@tanstack/react-query' +import posthog from 'posthog-js' +import { + type BlueprintConfigurationVariable, + type BlueprintManifestResponseResultsInner, + type BlueprintManifestVariableField, +} from 'qovery-typescript-axios' +import { Suspense, useCallback, useEffect, useMemo, useState } from 'react' +import { useEnvironment } from '@qovery/domains/environments/feature' +import { type BlueprintService, getBlueprintGitRepository } from '@qovery/domains/services/data-access' +import { + type BlueprintFieldValue, + BlueprintManifestVariableInput, + BlueprintMetadata, + BlueprintMetadataSkeleton, + BlueprintPreview, + BlueprintSection, + OverridesSectionCard, + formatBlueprintName, + getDefaultFieldValue, + getFallbackServiceIcon, + getFieldValidationError, + isFieldValid, + isOptionalVariableField, + isRequiredVariableField, + useBlueprint, + useBlueprintCatalogServiceManifest, + useBlueprintVariables, + useDeployBlueprint, + usePreviewBlueprintUpdate, + useUpdateBlueprint, +} from '@qovery/domains/services/feature' +import { SettingsHeading } from '@qovery/shared/console-shared' +import { Button, LoaderSpinner, Section, toast, useModal } from '@qovery/shared/ui' + +interface BlueprintSettingsDetails { + name: string + tag: string + manifest?: { + results: BlueprintManifestResponseResultsInner[] + } +} + +interface OptimisticBlueprintSettings { + values: Record + secretNames: string[] +} + +interface BlueprintGeneralSettingsProps { + service: BlueprintService + environmentId: string + organizationId: string +} + +const EMPTY_OPTIMISTIC_BLUEPRINT_SETTINGS: OptimisticBlueprintSettings = { + values: {}, + secretNames: [], +} +const OPTIMISTIC_BLUEPRINT_SETTINGS_CACHE_TIME_MS = 30 * 60 * 1000 + +function getOptimisticBlueprintSettingsQueryKey(serviceId: string) { + return ['blueprint-settings', serviceId, 'optimistic-values'] as const +} + +function isBlueprintSettingsDetails(data: unknown): data is BlueprintSettingsDetails { + if (!data || typeof data !== 'object') return false + + const details = data as Partial + return ( + typeof details.name === 'string' && + typeof details.tag === 'string' && + (details.manifest === undefined || (details.manifest !== null && Array.isArray(details.manifest.results))) + ) +} + +function parseBlueprintTag(tag: string | undefined) { + const [provider = '', serviceFamily = '', serviceVersion = ''] = tag?.split('/') ?? [] + return { provider, serviceFamily, serviceVersion } +} + +function useOptimisticBlueprintSettings({ + serviceId, + persistedVariables, +}: { + serviceId: string + persistedVariables: Map +}) { + const queryClient = useQueryClient() + const queryKey = useMemo(() => getOptimisticBlueprintSettingsQueryKey(serviceId), [serviceId]) + const { data: optimisticSettings = EMPTY_OPTIMISTIC_BLUEPRINT_SETTINGS } = useQuery({ + queryKey, + queryFn: () => EMPTY_OPTIMISTIC_BLUEPRINT_SETTINGS, + enabled: false, + cacheTime: OPTIMISTIC_BLUEPRINT_SETTINGS_CACHE_TIME_MS, + }) + + const update = useCallback( + (settings: OptimisticBlueprintSettings) => { + queryClient.setQueryData(queryKey, (currentSettings) => ({ + values: { ...currentSettings?.values, ...settings.values }, + secretNames: [...new Set([...(currentSettings?.secretNames ?? []), ...settings.secretNames])], + })) + }, + [queryClient, queryKey] + ) + + const remove = useCallback( + (settings: OptimisticBlueprintSettings) => { + queryClient.setQueryData(queryKey, (currentSettings) => { + if (!currentSettings) return EMPTY_OPTIMISTIC_BLUEPRINT_SETTINGS + + return { + values: Object.fromEntries( + Object.entries(currentSettings.values).filter(([name, value]) => settings.values[name] !== value) + ), + secretNames: currentSettings.secretNames.filter((name) => !settings.secretNames.includes(name)), + } + }) + }, + [queryClient, queryKey] + ) + + useEffect(() => { + const remainingValues = Object.fromEntries( + Object.entries(optimisticSettings.values).filter( + ([name, value]) => persistedVariables.get(name)?.value !== String(value) + ) + ) as Record + const remainingSecretNames = optimisticSettings.secretNames.filter( + (name) => !persistedVariables.get(name)?.is_secret + ) + + if ( + Object.keys(remainingValues).length !== Object.keys(optimisticSettings.values).length || + remainingSecretNames.length !== optimisticSettings.secretNames.length + ) { + queryClient.setQueryData(queryKey, { + values: remainingValues, + secretNames: remainingSecretNames, + }) + } + }, [optimisticSettings, persistedVariables, queryClient, queryKey]) + + return { optimisticSettings, update, remove } +} + +export function BlueprintGeneralSettings(props: BlueprintGeneralSettingsProps) { + return ( + }> + + + ) +} + +function BlueprintGeneralSettingsContent({ service, environmentId, organizationId }: BlueprintGeneralSettingsProps) { + const { closeModal, openModal } = useModal() + const { data, isLoading } = useBlueprint({ blueprintId: service.blueprint_id }) + const { data: persistedVariables = [], isLoading: isVariablesLoading } = useBlueprintVariables({ + blueprintId: service.blueprint_id, + }) + const { data: environment } = useEnvironment({ environmentId }) + const { mutateAsync: previewBlueprintUpdate, isLoading: isPreviewLoading } = usePreviewBlueprintUpdate() + const { mutateAsync: updateBlueprint, isLoading: isUpdateLoading } = useUpdateBlueprint({ + environmentId, + serviceId: service.id, + serviceType: service.serviceType, + }) + const { mutateAsync: deployBlueprint, isLoading: isDeployLoading } = useDeployBlueprint({ + environmentId, + serviceId: service.id, + serviceType: service.serviceType, + }) + const [changes, setChanges] = useState>({}) + const [isOverridesExpanded, setIsOverridesExpanded] = useState(false) + const [step, setStep] = useState<'review' | 'preview'>('review') + const [previewId, setPreviewId] = useState() + const [previewError, setPreviewError] = useState(false) + + const details = isBlueprintSettingsDetails(data) ? data : undefined + const { provider, serviceFamily, serviceVersion } = parseBlueprintTag(details?.tag) + const manifestFields = details?.manifest?.results + const isCatalogManifestEnabled = Boolean(details && !manifestFields) + const { data: catalogFields = [], isLoading: isCatalogManifestLoading } = useBlueprintCatalogServiceManifest({ + organizationId, + provider, + serviceFamily, + serviceVersion, + environmentId, + enabled: isCatalogManifestEnabled, + }) + const fields = manifestFields ?? catalogFields + const variablesByName = useMemo( + () => new Map(persistedVariables.map((variable) => [variable.name, variable])), + [persistedVariables] + ) + const { + optimisticSettings: { values: optimisticChanges, secretNames: optimisticSecretNames }, + update: updateOptimisticSettings, + remove: removeOptimisticSettings, + } = useOptimisticBlueprintSettings({ serviceId: service.id, persistedVariables: variablesByName }) + const manifestVariablesByName = useMemo( + () => + new Map( + fields + .filter((field): field is BlueprintManifestVariableField => field.kind === 'variable') + .map((field) => [field.name, field]) + ), + [fields] + ) + const initialValues = useMemo( + () => + Object.fromEntries( + fields + .filter((field): field is BlueprintManifestVariableField => field.kind === 'variable') + .map((field) => { + const persistedVariable = variablesByName.get(field.name) + const persistedValue = persistedVariable?.value + return [ + field.name, + persistedVariable?.is_secret + ? '' + : field.type.type === 'bool' && persistedValue !== null && persistedValue !== undefined + ? persistedValue === 'true' + : persistedValue ?? getDefaultFieldValue(field), + ] + }) + ), + [fields, variablesByName] + ) + const values = useMemo( + () => ({ ...initialValues, ...optimisticChanges, ...changes }), + [changes, initialValues, optimisticChanges] + ) + const requiredFields = fields.filter(isRequiredVariableField) + const optionalFields = fields.filter(isOptionalVariableField) + const isValid = [...requiredFields, ...optionalFields].every( + (field) => + isFieldValid(field, values[field.name]) || + (field.is_secret && + (variablesByName.get(field.name)?.is_secret || optimisticSecretNames.includes(field.name)) && + changes[field.name] === undefined) + ) + const isSaving = isUpdateLoading || isDeployLoading + + useEffect(() => { + posthog.capture('blueprint_settings_visited', { + blueprint_id: service.blueprint_id, + service_id: service.id, + service_type: service.serviceType, + }) + }, [service.blueprint_id, service.id, service.serviceType]) + + const closePreview = useCallback(() => { + closeModal() + setStep('review') + }, [closeModal]) + + const payload = useMemo(() => { + const variables = Object.fromEntries( + Object.entries(changes).map(([name, value]) => [ + name, + { value: String(value), is_secret: manifestVariablesByName.get(name)?.is_secret ?? false }, + ]) + ) + + return details + ? { + name: details.name, + tag: details.tag, + icon: service.icon_uri ?? getFallbackServiceIcon(service.service_type), + variables, + } + : undefined + }, [changes, details, manifestVariablesByName, service.icon_uri, service.service_type]) + + const requestPreview = useCallback(async () => { + if (!payload || !isValid) return + + posthog.capture('blueprint_settings_preview_triggered', { + blueprint_id: service.blueprint_id, + service_id: service.id, + service_type: service.serviceType, + }) + + setPreviewError(false) + setPreviewId(undefined) + setStep('preview') + + try { + const preview = await previewBlueprintUpdate({ blueprintId: service.blueprint_id, payload }) + setPreviewId(preview?.preview_id) + } catch { + setPreviewError(true) + } + }, [isValid, payload, previewBlueprintUpdate, service.blueprint_id, service.id, service.serviceType]) + + const confirmAndDeploy = useCallback(async () => { + if (!details || !isValid) return + if (!payload) return + + const confirmedSecretNames = Object.keys(changes).filter((name) => manifestVariablesByName.get(name)?.is_secret) + const confirmedChanges = Object.fromEntries( + Object.entries(changes).filter(([name]) => !confirmedSecretNames.includes(name)) + ) as Record + + const confirmedSettings = { values: confirmedChanges, secretNames: confirmedSecretNames } + updateOptimisticSettings(confirmedSettings) + setChanges({}) + closePreview() + + try { + await updateBlueprint({ blueprintId: service.blueprint_id, payload }) + } catch { + removeOptimisticSettings(confirmedSettings) + return + } + + posthog.capture('blueprint_settings_updated', { + blueprint_id: service.blueprint_id, + service_id: service.id, + service_type: service.serviceType, + }) + + try { + await deployBlueprint({ blueprintId: service.blueprint_id }) + toast('success', 'Blueprint update started') + } catch { + // The settings update already succeeded, so keep the optimistic values visible. + } + }, [ + changes, + closePreview, + deployBlueprint, + details, + isValid, + manifestVariablesByName, + payload, + removeOptimisticSettings, + service.blueprint_id, + service.id, + service.serviceType, + updateOptimisticSettings, + updateBlueprint, + ]) + + useEffect(() => { + if (step !== 'preview') return + + openModal({ + content: ( + <> + Preview changes + + + ), + options: { + buttonClose: false, + dismissible: false, + height: 'min(65vh, 680px)', + width: 'min(50vw, 900px)', + }, + }) + }, [ + closePreview, + confirmAndDeploy, + environment?.cluster_id, + isSaving, + openModal, + previewError, + previewId, + requestPreview, + step, + ]) + + if (isLoading || isVariablesLoading || (details && !manifestFields && isCatalogManifestLoading)) { + return + } + + if (!details) { + return ( +
+ +
+

Blueprint configuration is unavailable.

+

+ The Blueprint details could not be loaded. Try again in a moment. +

+
+
+ ) + } + + return ( +
+ +
+ +

{formatBlueprintName(details.name)}

+
+ }> + + +
+
+ + {requiredFields.map((field, index) => ( + setChanges((current) => ({ ...current, [field.name]: value }))} + /> + ))} + + setIsOverridesExpanded(true)} + > + {optionalFields.map((field) => ( + setChanges((current) => ({ ...current, [field.name]: value }))} + /> + ))} + +
+ +
+
+
+ ) +} diff --git a/libs/domains/service-settings/feature/src/lib/service-general-settings/helm-general-settings/helm-general-settings.spec.tsx b/libs/domains/service-settings/feature/src/lib/service-general-settings/helm-general-settings/helm-general-settings.spec.tsx index 74c1b01c97e..b82679fd9da 100644 --- a/libs/domains/service-settings/feature/src/lib/service-general-settings/helm-general-settings/helm-general-settings.spec.tsx +++ b/libs/domains/service-settings/feature/src/lib/service-general-settings/helm-general-settings/helm-general-settings.spec.tsx @@ -35,4 +35,21 @@ describe('HelmGeneralSettings', () => { expect(screen.getByText('Source')).toBeInTheDocument() expect(screen.getByText('Deploy')).toBeInTheDocument() }) + + it('only renders the general section for Blueprint Helm services', () => { + const blueprintService = { ...service, blueprint_id: 'blueprint-id' } + + renderWithProviders( + wrapWithReactHookForm(, { + defaultValues: { + name: blueprintService.name, + source_provider: 'HELM_REPOSITORY', + }, + }) + ) + + expect(screen.getByText('General')).toBeInTheDocument() + expect(screen.queryByText('Source')).not.toBeInTheDocument() + expect(screen.queryByText('Deploy')).not.toBeInTheDocument() + }) }) diff --git a/libs/domains/service-settings/feature/src/lib/service-general-settings/helm-general-settings/helm-general-settings.tsx b/libs/domains/service-settings/feature/src/lib/service-general-settings/helm-general-settings/helm-general-settings.tsx index 61e593944f4..d1b3e11405a 100644 --- a/libs/domains/service-settings/feature/src/lib/service-general-settings/helm-general-settings/helm-general-settings.tsx +++ b/libs/domains/service-settings/feature/src/lib/service-general-settings/helm-general-settings/helm-general-settings.tsx @@ -2,7 +2,7 @@ import { type Organization } from 'qovery-typescript-axios' import { useFormContext } from 'react-hook-form' import { EditGitRepositorySettings } from '@qovery/domains/organizations/feature' import { DeploymentSetting, SourceSetting } from '@qovery/domains/service-helm/feature' -import { type Helm } from '@qovery/domains/services/data-access' +import { type Helm, isBlueprintService } from '@qovery/domains/services/data-access' import { AutoDeploySection, GeneralSetting } from '@qovery/domains/services/feature' import { isHelmGitSource } from '@qovery/shared/enums' import { Callout, Heading, Icon, Section } from '@qovery/shared/ui' @@ -15,6 +15,7 @@ export interface HelmGeneralSettingsProps { export function HelmGeneralSettings({ service, organization }: HelmGeneralSettingsProps) { const { watch } = useFormContext() const watchFieldProvider = watch('source_provider') + const isBlueprint = isBlueprintService(service) return ( <> @@ -23,36 +24,41 @@ export function HelmGeneralSettings({ service, organization }: HelmGeneralSettin -
- Source - - {watchFieldProvider === 'GIT' && ( -
- -
- )} -
+ {!isBlueprint && ( + <> +
+ Source + + {watchFieldProvider === 'GIT' && ( +
+ +
+ )} +
-
- Deploy - - {watchFieldProvider === 'GIT' && } - {watchFieldProvider === 'HELM_REPOSITORY' && ( - - - - - - - Git automations are disabled when using Helm repositories (auto-deploy, automatic preview environments) - - - - )} -
+
+ Deploy + + {watchFieldProvider === 'GIT' && } + {watchFieldProvider === 'HELM_REPOSITORY' && ( + + + + + + + Git automations are disabled when using Helm repositories (auto-deploy, automatic preview + environments) + + + + )} +
+ + )} ) } diff --git a/libs/domains/service-settings/feature/src/lib/service-general-settings/service-general-settings.tsx b/libs/domains/service-settings/feature/src/lib/service-general-settings/service-general-settings.tsx index 4a134b16663..b7b3cd056f3 100644 --- a/libs/domains/service-settings/feature/src/lib/service-general-settings/service-general-settings.tsx +++ b/libs/domains/service-settings/feature/src/lib/service-general-settings/service-general-settings.tsx @@ -9,7 +9,7 @@ import { useAnnotationsGroups, useLabelsGroups, } from '@qovery/domains/organizations/feature' -import { isEditableService } from '@qovery/domains/services/data-access' +import { isBlueprintService, isEditableService } from '@qovery/domains/services/data-access' import { type ServiceGeneralData, buildServiceGeneralPayload, @@ -137,7 +137,9 @@ function ServiceGeneralSettingsContent({ organization }: ServiceGeneralSettingsP const headingDescription = service.serviceType === 'DATABASE' ? 'These general settings allow you to set up the database name, type and version.' - : 'These general settings allow you to set up the service name, its source and deployment parameters.' + : isBlueprintService(service) + ? 'These general settings allow you to set up the service name.' + : 'These general settings allow you to set up the service name, its source and deployment parameters.' const formContent = match(service) .with({ serviceType: 'APPLICATION' }, (application) => ( diff --git a/libs/domains/service-settings/feature/src/lib/service-general-settings/terraform-general-settings/terraform-general-settings.spec.tsx b/libs/domains/service-settings/feature/src/lib/service-general-settings/terraform-general-settings/terraform-general-settings.spec.tsx index 79873e3af9f..9dc349bee41 100644 --- a/libs/domains/service-settings/feature/src/lib/service-general-settings/terraform-general-settings/terraform-general-settings.spec.tsx +++ b/libs/domains/service-settings/feature/src/lib/service-general-settings/terraform-general-settings/terraform-general-settings.spec.tsx @@ -22,6 +22,10 @@ describe('TerraformGeneralSettings', () => { const service = terraformFactoryMock(1)[0] const organization = organizationFactoryMock(1)[0] + beforeEach(() => { + mockEditGitRepositorySettings.mockClear() + }) + it('should render main sections', () => { renderWithProviders( wrapWithReactHookForm(, { @@ -37,7 +41,7 @@ describe('TerraformGeneralSettings', () => { expect(screen.getByText('Build and deploy')).toBeInTheDocument() }) - it('hides the source edit action for blueprint services', () => { + it('renders only the general section for blueprint services', () => { renderWithProviders( wrapWithReactHookForm( , @@ -50,6 +54,9 @@ describe('TerraformGeneralSettings', () => { ) ) - expect(mockEditGitRepositorySettings).toHaveBeenLastCalledWith(expect.objectContaining({ showEditAction: false })) + expect(screen.getByText('General')).toBeInTheDocument() + expect(screen.queryByText('Source')).not.toBeInTheDocument() + expect(screen.queryByText('Build and deploy')).not.toBeInTheDocument() + expect(mockEditGitRepositorySettings).not.toHaveBeenCalled() }) }) diff --git a/libs/domains/service-settings/feature/src/lib/service-general-settings/terraform-general-settings/terraform-general-settings.tsx b/libs/domains/service-settings/feature/src/lib/service-general-settings/terraform-general-settings/terraform-general-settings.tsx index bb15c301d36..82b37360308 100644 --- a/libs/domains/service-settings/feature/src/lib/service-general-settings/terraform-general-settings/terraform-general-settings.tsx +++ b/libs/domains/service-settings/feature/src/lib/service-general-settings/terraform-general-settings/terraform-general-settings.tsx @@ -1,7 +1,7 @@ import { type Organization, TerraformAutoDeployConfigTerraformActionEnum } from 'qovery-typescript-axios' import { Controller, useFormContext } from 'react-hook-form' import { EditGitRepositorySettings } from '@qovery/domains/organizations/feature' -import { type Terraform } from '@qovery/domains/services/data-access' +import { type Terraform, isBlueprintService } from '@qovery/domains/services/data-access' import { AutoDeploySection, GeneralSetting } from '@qovery/domains/services/feature' import { Heading, InputSelect, Section } from '@qovery/shared/ui' @@ -18,6 +18,7 @@ export interface TerraformGeneralSettingsProps { export function TerraformGeneralSettings({ service, organization }: TerraformGeneralSettingsProps) { const { control } = useFormContext() + const isBlueprint = isBlueprintService(service) return ( <> @@ -26,36 +27,39 @@ export function TerraformGeneralSettings({ service, organization }: TerraformGen -
- Source - -
+ {!isBlueprint && ( + <> +
+ Source + +
-
- Build and deploy - - ( - + Build and deploy + + ( + + )} /> - )} - /> - -
+ + + + )} ) } diff --git a/libs/domains/service-settings/feature/src/lib/service-settings-layout/service-settings-layout.spec.tsx b/libs/domains/service-settings/feature/src/lib/service-settings-layout/service-settings-layout.spec.tsx new file mode 100644 index 00000000000..91a97122cef --- /dev/null +++ b/libs/domains/service-settings/feature/src/lib/service-settings-layout/service-settings-layout.spec.tsx @@ -0,0 +1,99 @@ +import { type ReactNode } from 'react' +import { helmFactoryMock, terraformFactoryMock } from '@qovery/shared/factories' +import { renderWithProviders, screen } from '@qovery/shared/util-tests' +import { ServiceSettingsLayout } from './service-settings-layout' + +let mockService = terraformFactoryMock(1)[0] + +jest.mock('@tanstack/react-router', () => ({ + useParams: () => ({ + organizationId: 'organization-id', + projectId: 'project-id', + environmentId: 'environment-id', + serviceId: 'service-id', + }), +})) + +jest.mock('@qovery/domains/services/feature', () => ({ + useService: () => ({ data: mockService }), +})) + +jest.mock('@qovery/shared/ui', () => ({ + ...jest.requireActual('@qovery/shared/ui'), + Sidebar: { + Root: ({ children }: { children: ReactNode }) => , + Item: ({ children }: { children: ReactNode }) => , + Group: ({ children, title }: { children: ReactNode; title: string }) => ( +
+

{title}

+ {children} +
+ ), + SubItem: ({ children }: { children: ReactNode }) => , + }, +})) + +describe('ServiceSettingsLayout', () => { + it('hides Terraform-only settings for blueprint services', () => { + mockService = { ...terraformFactoryMock(1)[0], blueprint_id: 'blueprint-id' } + + renderWithProviders( + +
Settings content
+
+ ) + + expect(screen.queryByText('Terraform configuration')).not.toBeInTheDocument() + expect(screen.queryByText('Terraform arguments')).not.toBeInTheDocument() + expect(screen.getByText('Blueprint configuration')).toBeInTheDocument() + expect(screen.getByText('Resources')).toBeInTheDocument() + expect(screen.queryByText('Deployment restrictions')).not.toBeInTheDocument() + }) + + it('keeps Terraform-only settings for regular Terraform services', () => { + mockService = terraformFactoryMock(1)[0] + + renderWithProviders( + +
Settings content
+
+ ) + + expect(screen.getByText('Terraform configuration')).toBeInTheDocument() + expect(screen.getByText('Terraform arguments')).toBeInTheDocument() + expect(screen.queryByText('Blueprint configuration')).not.toBeInTheDocument() + expect(screen.getByText('Deployment restrictions')).toBeInTheDocument() + }) + + it('shows Blueprint settings instead of Helm-specific settings for Blueprint Helm services', () => { + mockService = { ...helmFactoryMock(1)[0], blueprint_id: 'blueprint-id' } + + renderWithProviders( + +
Settings content
+
+ ) + + expect(screen.getByText('Blueprint configuration')).toBeInTheDocument() + expect(screen.queryByText('Resources')).not.toBeInTheDocument() + expect(screen.queryByText('Values')).not.toBeInTheDocument() + expect(screen.queryByText('Networking')).not.toBeInTheDocument() + expect(screen.queryByText('Domain')).not.toBeInTheDocument() + expect(screen.queryByText('Deployment restrictions')).not.toBeInTheDocument() + }) + + it('keeps Helm-specific settings for regular Helm services', () => { + mockService = helmFactoryMock(1)[0] + + renderWithProviders( + +
Settings content
+
+ ) + + expect(screen.queryByText('Blueprint configuration')).not.toBeInTheDocument() + expect(screen.getByText('Values')).toBeInTheDocument() + expect(screen.getByText('Networking')).toBeInTheDocument() + expect(screen.getByText('Domain')).toBeInTheDocument() + }) +}) 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 90ce548d4e8..552f3c602ac 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 @@ -2,7 +2,7 @@ import { type IconName, type IconStyle } from '@fortawesome/fontawesome-common-t import { useParams } from '@tanstack/react-router' import { type ReactNode } from 'react' import { match } from 'ts-pattern' -import { isAgenticWorkflow, isEditableService } from '@qovery/domains/services/data-access' +import { isAgenticWorkflow, isBlueprintService, isEditableService } from '@qovery/domains/services/data-access' import { useService } from '@qovery/domains/services/feature' import { isHelmGitSource, isJobGitSource } from '@qovery/shared/enums' import { Sidebar } from '@qovery/shared/ui' @@ -55,6 +55,11 @@ export function ServiceSettingsLayout({ children }: ServiceSettingsLayoutProps) } const generalLink = linkItem('General', toSettingsPath(pathSettings, '/general'), 'gear') + const blueprintConfigurationLink = linkItem( + 'Blueprint configuration', + toSettingsPath(pathSettings, '/blueprint-configuration'), + 'sliders' + ) const valuesOverrideLink: SidebarSettingsGroupItem = { title: 'Values', @@ -115,6 +120,14 @@ export function ServiceSettingsLayout({ children }: ServiceSettingsLayoutProps) const automationsLink = linkItem('Automations', toSettingsPath(pathSettings, '/automations'), 'stopwatch') const governanceLink = linkItem('Governance', toSettingsPath(pathSettings, '/governance'), 'shield-halved') const outputsLink = linkItem('Outputs', toSettingsPath(pathSettings, '/outputs'), 'wave-pulse') + const blueprintTerraformSettingsLinks = [ + generalLink, + blueprintConfigurationLink, + resourcesLink, + advancedSettingsLink, + dangerZoneLink, + ] + const blueprintHelmSettingsLinks = [generalLink, blueprintConfigurationLink, advancedSettingsLink, dangerZoneLink] const linksSettings: SidebarSettingsItem[] = isAgenticWorkflow(service) ? [ @@ -150,24 +163,32 @@ export function ServiceSettingsLayout({ children }: ServiceSettingsLayoutProps) advancedSettingsLink, dangerZoneLink, ]) - .with({ serviceType: 'HELM' }, (helm) => [ - generalLink, - valuesOverrideLink, - networkingLink, - domainLink, - ...(isHelmGitSource(helm.source) ? [deploymentRestrictionsLink] : []), - advancedSettingsLink, - dangerZoneLink, - ]) - .with({ serviceType: 'TERRAFORM' }, () => [ - generalLink, - terraformConfigurationLink, - terraformArgumentsLink, - resourcesLink, - deploymentRestrictionsLink, - advancedSettingsLink, - dangerZoneLink, - ]) + .with({ serviceType: 'HELM' }, (helm) => + isBlueprintService(helm) + ? blueprintHelmSettingsLinks + : [ + generalLink, + valuesOverrideLink, + networkingLink, + domainLink, + ...(isHelmGitSource(helm.source) ? [deploymentRestrictionsLink] : []), + advancedSettingsLink, + dangerZoneLink, + ] + ) + .with({ serviceType: 'TERRAFORM' }, () => + isBlueprintService(service) + ? blueprintTerraformSettingsLinks + : [ + generalLink, + terraformConfigurationLink, + terraformArgumentsLink, + resourcesLink, + deploymentRestrictionsLink, + advancedSettingsLink, + dangerZoneLink, + ] + ) .with({ serviceType: 'JOB' }, (job) => [ generalLink, ...(job.job_type === 'LIFECYCLE' && isJobGitSource(job.source) ? [dockerfileLink] : []), diff --git a/libs/domains/services/data-access/src/lib/domains-services-data-access.ts b/libs/domains/services/data-access/src/lib/domains-services-data-access.ts index 31f60f4fe47..dc489961c24 100644 --- a/libs/domains/services/data-access/src/lib/domains-services-data-access.ts +++ b/libs/domains/services/data-access/src/lib/domains-services-data-access.ts @@ -16,6 +16,7 @@ import { ApplicationsApi, ArgoCDApi, BlueprintCatalogApi, + type BlueprintConfigurationVariable, type BlueprintCreateRequest, BlueprintMainCallsApi, type BlueprintUpdateRequest, @@ -94,7 +95,7 @@ import { type TerraformStatusDto, } from 'qovery-ws-typescript-axios' import { match } from 'ts-pattern' -import { type ServiceTypeEnum } from '@qovery/shared/enums' +import { type ServiceTypeEnum, isHelmGitSource } from '@qovery/shared/enums' const environmentApi = new EnvironmentMainCallsApi() const environmentActionApi = new EnvironmentActionsApi() @@ -244,6 +245,18 @@ export function isBlueprintService(service: AnyService): service is BlueprintSer return 'blueprint_id' in service && Boolean(service.blueprint_id) } +export function getBlueprintGitRepository(service: BlueprintService) { + if (service.serviceType === 'TERRAFORM') { + return service.terraform_files_source?.git?.git_repository + } + + if (service.serviceType === 'HELM' && isHelmGitSource(service.source)) { + return service.source.git?.git_repository + } + + return undefined +} + export function isArgoCd(service?: AnyService): service is ArgoCd { return service?.service_type === 'ARGOCD_APP' } @@ -337,6 +350,13 @@ export const services = createQueryKeys('services', { return response.data }, }), + blueprintVariables: ({ blueprintId }: { blueprintId: string }) => ({ + queryKey: [blueprintId], + async queryFn(): Promise { + const response = await blueprintApi.getBlueprintVariables(blueprintId) + return response.data + }, + }), blueprintUpdate: ({ blueprintId }: { blueprintId: string }) => ({ queryKey: [blueprintId], async queryFn() { diff --git a/libs/domains/services/feature/src/index.ts b/libs/domains/services/feature/src/index.ts index c7d455dfa7c..f8d78840304 100644 --- a/libs/domains/services/feature/src/index.ts +++ b/libs/domains/services/feature/src/index.ts @@ -32,14 +32,18 @@ export * from './lib/hooks/use-services/use-services' export * from './lib/hooks/use-argocd-services/use-argocd-services' export * from './lib/hooks/use-argocd-manifest/use-argocd-manifest' export * from './lib/hooks/use-blueprint/use-blueprint' +export * from './lib/hooks/use-blueprint-variables/use-blueprint-variables' export * from './lib/hooks/use-blueprint-catalog/use-blueprint-catalog' export * from './lib/hooks/use-blueprint-catalog-service-readme/use-blueprint-catalog-service-readme' export * from './lib/hooks/use-blueprint-catalog-service-manifest/use-blueprint-catalog-service-manifest' +export * from './lib/blueprint-utils/blueprint-utils' export * from './lib/hooks/use-blueprint-update/use-blueprint-update' export * from './lib/hooks/use-blueprint-update-state/use-blueprint-update-state' export * from './lib/hooks/use-blueprint-update-preview-socket/use-blueprint-update-preview-socket' export * from './lib/hooks/use-preview-blueprint-update/use-preview-blueprint-update' export * from './lib/hooks/use-update-blueprint/use-update-blueprint' +export * from './lib/service-creation-flow/blueprint/blueprint-configuration-view/blueprint-creation-components/blueprint-section/blueprint-section' +export * from './lib/service-creation-flow/blueprint/blueprint-configuration-view/blueprint-creation-components/overrides-section-card/overrides-section-card' export * from './lib/hooks/use-blueprint-service-created-socket/use-blueprint-service-created-socket' export * from './lib/blueprint-query-boundary/blueprint-query-boundary' export * from './lib/hooks/use-service-deployment-and-running-statuses/use-service-deployment-and-running-statuses' @@ -113,7 +117,12 @@ export * from './lib/service-creation-flow/database/step-resources/step-resource export * from './lib/service-creation-flow/database/database-summary-view/database-summary-view' export * from './lib/service-creation-flow/database/step-summary/step-summary' export * from './lib/service-creation-flow/blueprint/blueprint-creation-flow' +export * from './lib/blueprint-field-utils/blueprint-field-utils' +export * from './lib/blueprint-manifest-variable-input/blueprint-manifest-variable-input' +export * from './lib/hooks/use-deploy-blueprint/use-deploy-blueprint' export * from './lib/service-blueprint-update-flow/blueprint-update-flow' +export * from './lib/service-blueprint-update-flow/blueprint-metadata' +export * from './lib/service-blueprint-update-flow/blueprint-update-utils' export * from './lib/service-creation-flow/agentic-workflow/agentic-workflow-context' export * from './lib/service-creation-flow/agentic-workflow/agentic-workflow-automation' export * from './lib/service-creation-flow/agentic-workflow/agentic-workflow-templates' diff --git a/libs/domains/services/feature/src/lib/hooks/use-blueprint-variables/use-blueprint-variables.ts b/libs/domains/services/feature/src/lib/hooks/use-blueprint-variables/use-blueprint-variables.ts new file mode 100644 index 00000000000..7d951caddca --- /dev/null +++ b/libs/domains/services/feature/src/lib/hooks/use-blueprint-variables/use-blueprint-variables.ts @@ -0,0 +1,18 @@ +import { useQuery } from '@tanstack/react-query' +import { queries } from '@qovery/state/util-queries' + +export interface UseBlueprintVariablesProps { + blueprintId: string + enabled?: boolean + suspense?: boolean +} + +export function useBlueprintVariables({ blueprintId, enabled = true, suspense = true }: UseBlueprintVariablesProps) { + return useQuery({ + ...queries.services.blueprintVariables({ blueprintId }), + enabled: enabled && Boolean(blueprintId), + suspense, + }) +} + +export default useBlueprintVariables diff --git a/libs/domains/services/feature/src/lib/service-blueprint-update-flow/blueprint-metadata.tsx b/libs/domains/services/feature/src/lib/service-blueprint-update-flow/blueprint-metadata.tsx new file mode 100644 index 00000000000..ee4fd80f65b --- /dev/null +++ b/libs/domains/services/feature/src/lib/service-blueprint-update-flow/blueprint-metadata.tsx @@ -0,0 +1,118 @@ +import { Link, useParams } from '@tanstack/react-router' +import { type ApplicationGitRepository } from 'qovery-typescript-axios' +import { type AnyService } from '@qovery/domains/services/data-access' +import { Badge, ExternalLink, Icon, Skeleton, Truncate } from '@qovery/shared/ui' +import { buildGitProviderUrl } from '@qovery/shared/util-git' +import { useBlueprintUpdateState } from '../hooks/use-blueprint-update-state/use-blueprint-update-state' +import { ServiceAvatar } from '../service-avatar/service-avatar' +import { BlueprintUpdateBadge } from './blueprint-update-badge' +import { getBlueprintServiceVersion } from './blueprint-update-utils' + +function BlueprintUpdateBadgeSkeleton() { + return +} + +function BlueprintRepository({ gitRepository }: { gitRepository: ApplicationGitRepository }) { + if (!gitRepository.url || !gitRepository.name) { + return null + } + + return ( + + {gitRepository.provider && } + + + ) +} + +export function BlueprintMetadataSkeleton({ + gitRepository, + showVersion = true, + showRepository = true, + showUpdateBadge = true, +}: { + gitRepository?: ApplicationGitRepository + showVersion?: boolean + showRepository?: boolean + showUpdateBadge?: boolean +}) { + return ( + <> + {showVersion && } + {showRepository && gitRepository && } + {showUpdateBadge && } + + ) +} + +export function BlueprintMetadata({ + blueprintId, + gitRepository, + service, + linkVersionToSettings = false, + showVersion = true, + showRepository = true, + showUpdateBadge = true, +}: { + blueprintId: string + gitRepository?: ApplicationGitRepository + service: AnyService + linkVersionToSettings?: boolean + showVersion?: boolean + showRepository?: boolean + showUpdateBadge?: boolean +}) { + const { organizationId = '', projectId = '', environmentId = '', serviceId = '' } = useParams({ strict: false }) + // `throwOnError: false` because react-query v4 makes suspense queries throw by default, and there + // is no boundary between here and the organization layout: a blueprint pinned to a tag the + // catalog cannot resolve would replace the whole overview with the generic error page. + const { blueprintUpdate, tag } = useBlueprintUpdateState({ + blueprintId, + localTag: gitRepository?.branch, + suspense: true, + throwOnError: false, + }) + const currentVersion = tag ? getBlueprintServiceVersion(tag) : undefined + const versionBadge = currentVersion && currentVersion !== 'default' && ( + + + v{currentVersion} + + ) + + return ( + <> + {showVersion && + versionBadge && + (linkVersionToSettings ? ( + + {versionBadge} + + ) : ( + versionBadge + ))} + {showRepository && gitRepository && } + {showUpdateBadge && blueprintUpdate && ( + + )} + + ) +} diff --git a/libs/domains/services/feature/src/lib/service-blueprint-update-flow/blueprint-update-badge.spec.tsx b/libs/domains/services/feature/src/lib/service-blueprint-update-flow/blueprint-update-badge.spec.tsx index 1094a11ebd5..2a6ea3fc2ae 100644 --- a/libs/domains/services/feature/src/lib/service-blueprint-update-flow/blueprint-update-badge.spec.tsx +++ b/libs/domains/services/feature/src/lib/service-blueprint-update-flow/blueprint-update-badge.spec.tsx @@ -1,4 +1,5 @@ import { useNavigate } from '@tanstack/react-router' +import posthog from 'posthog-js' import { type BlueprintUpdateResponse } from 'qovery-typescript-axios' import { type AnyService } from '@qovery/domains/services/data-access' import { renderWithProviders, screen } from '@qovery/shared/util-tests' @@ -11,6 +12,10 @@ jest.mock('@tanstack/react-router', () => ({ useNavigate: jest.fn(), })) +jest.mock('posthog-js', () => ({ + capture: jest.fn(), +})) + const service = { id: 'service-id', name: 'AWS MySQL', @@ -47,7 +52,7 @@ describe('BlueprintUpdateBadge', () => { /> ) - expect(screen.getByText('Up to date')).toBeInTheDocument() + expect(screen.getByText('Blueprint is up to date')).toBeInTheDocument() }) it('navigates to the review flow using the service identifiers', async () => { @@ -74,8 +79,12 @@ describe('BlueprintUpdateBadge', () => { /> ) - await userEvent.click(screen.getByRole('button', { name: 'Update available' })) + await userEvent.click(screen.getByRole('button', { name: 'Blueprint update available' })) + expect(posthog.capture).toHaveBeenCalledWith('blueprint_update_available_button_clicked', { + service_id: service.id, + service_type: service.serviceType, + }) expect(mockNavigate).toHaveBeenCalledWith({ to: '/organization/$organizationId/project/$projectId/environment/$environmentId/service/$serviceId/update/blueprint', params: { diff --git a/libs/domains/services/feature/src/lib/service-blueprint-update-flow/blueprint-update-badge.tsx b/libs/domains/services/feature/src/lib/service-blueprint-update-flow/blueprint-update-badge.tsx index 32a0cda4355..02f237ae8c2 100644 --- a/libs/domains/services/feature/src/lib/service-blueprint-update-flow/blueprint-update-badge.tsx +++ b/libs/domains/services/feature/src/lib/service-blueprint-update-flow/blueprint-update-badge.tsx @@ -1,4 +1,5 @@ import { useNavigate } from '@tanstack/react-router' +import posthog from 'posthog-js' import { type BlueprintUpdateResponse } from 'qovery-typescript-axios' import { type AnyService } from '@qovery/domains/services/data-access' import { Badge, Icon, useModal } from '@qovery/shared/ui' @@ -35,7 +36,7 @@ export function BlueprintUpdateBadge({ return ( - Up to date + Blueprint is up to date ) } @@ -45,6 +46,11 @@ export function BlueprintUpdateBadge({ type="button" className="rounded focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-brand" onClick={() => { + posthog.capture('blueprint_update_available_button_clicked', { + service_id: service.id, + service_type: service.serviceType, + }) + if (hasBlueprintUpdateReviewSections(blueprintUpdate)) { openUpdateFlow() return @@ -66,7 +72,7 @@ export function BlueprintUpdateBadge({ > - Update available + Blueprint update available ) diff --git a/libs/domains/services/feature/src/lib/service-blueprint-update-flow/blueprint-update-flow.tsx b/libs/domains/services/feature/src/lib/service-blueprint-update-flow/blueprint-update-flow.tsx index f24b9387255..60462a641fb 100644 --- a/libs/domains/services/feature/src/lib/service-blueprint-update-flow/blueprint-update-flow.tsx +++ b/libs/domains/services/feature/src/lib/service-blueprint-update-flow/blueprint-update-flow.tsx @@ -34,7 +34,9 @@ export { hasBlueprintUpdateReviewSections, } from './blueprint-update-utils' export { BlueprintUpdatePreviewStep } from './blueprint-update-preview-step' +export { BlueprintPreview } from './blueprint-update-preview-step' export { BlueprintUpdateReviewStep } from './blueprint-update-review-step' +export { BlueprintUpdateFlowShell } from './blueprint-update-flow-shell' export interface BlueprintUpdateFlowProps extends PropsWithChildren { blueprintId: string diff --git a/libs/domains/services/feature/src/lib/service-blueprint-update-flow/blueprint-update-preview-step.spec.tsx b/libs/domains/services/feature/src/lib/service-blueprint-update-flow/blueprint-update-preview-step.spec.tsx index c8b19686133..944604772bc 100644 --- a/libs/domains/services/feature/src/lib/service-blueprint-update-flow/blueprint-update-preview-step.spec.tsx +++ b/libs/domains/services/feature/src/lib/service-blueprint-update-flow/blueprint-update-preview-step.spec.tsx @@ -6,7 +6,7 @@ import { useBlueprintUpdatePreviewSocket, } from '../hooks/use-blueprint-update-preview-socket/use-blueprint-update-preview-socket' import { useBlueprintUpdateFlowContext } from './blueprint-update-context' -import { BlueprintUpdatePreviewStep } from './blueprint-update-preview-step' +import { BlueprintPreview, BlueprintUpdatePreviewStep } from './blueprint-update-preview-step' jest.mock('../hooks/use-blueprint-update-preview-socket/use-blueprint-update-preview-socket', () => ({ useBlueprintUpdatePreviewSocket: jest.fn(), @@ -152,4 +152,64 @@ describe('BlueprintUpdatePreviewStep', () => { expect(screen.getByText('Analyzing the planned changes')).toBeInTheDocument() }) + + it('anchors actions within the modal layout instead of the viewport', () => { + renderWithProviders( + + ) + + const modalConfirmButton = screen.getByRole('button', { name: 'Confirm & Deploy changes' }) + + expect(screen.getByRole('button', { name: 'Cancel' })).toBeInTheDocument() + expect(modalConfirmButton.closest('footer')).toHaveClass('absolute', 'bottom-0', 'w-full') + expect(modalConfirmButton.closest('footer')).not.toHaveClass('fixed', 'max-w-[620px]') + }) + + it('fills the modal content area while the preview is loading', () => { + jest.mocked(useBlueprintUpdatePreviewSocket).mockReturnValue({ outcome: { type: 'pending' } }) + + renderWithProviders( + + ) + + expect(screen.getByTestId('blueprint-preview-raw-output')).toHaveClass('flex-1', 'min-h-0') + expect(screen.getByTestId('blueprint-preview-raw-output')).not.toHaveClass('min-h-[180px]') + expect(screen.getByTestId('blueprint-preview-modal-content').firstElementChild).toHaveClass('min-h-full', 'w-full') + }) + + it('scrolls the modal content rather than an inner raw-output container', () => { + jest.mocked(useBlueprintUpdatePreviewSocket).mockReturnValue({ outcome: { type: 'diff', rawOutput: '+ created' } }) + + renderWithProviders( + + ) + + expect(screen.getByTestId('blueprint-preview-modal-content')).toHaveClass('flex-1', 'overflow-y-auto') + expect(screen.getByText('+ created').closest('div')).not.toHaveClass('overflow-auto') + }) }) 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 1f62d963d83..bbf96c07f55 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 @@ -2,6 +2,7 @@ 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 { twMerge } from '@qovery/shared/util-js' import { type BlueprintUpdatePreviewOutcome, useBlueprintUpdatePreviewSocket, @@ -26,7 +27,7 @@ export function BlueprintUpdatePreviewStep({ onBack }: { onBack: () => void }) { }, [requestPreview]) return ( - void }) { ) } -function BlueprintUpdatePreview({ +export function BlueprintPreview({ clusterId, loading, onBack, @@ -46,6 +47,7 @@ function BlueprintUpdatePreview({ onRetry, previewError, previewId, + layout = 'page', }: { clusterId?: string loading: boolean @@ -54,6 +56,7 @@ function BlueprintUpdatePreview({ onRetry: () => Promise previewError: boolean previewId?: string + layout?: 'page' | 'modal' }) { if (!clusterId || !previewId) { return ( @@ -63,6 +66,7 @@ function BlueprintUpdatePreview({ onConfirm={onConfirm} onRetry={onRetry} outcome={previewError ? { type: 'error' } : { type: 'pending' }} + layout={layout} /> ) } @@ -76,6 +80,7 @@ function BlueprintUpdatePreview({ onRetry={onRetry} previewError={previewError} previewId={previewId} + layout={layout} /> ) } @@ -88,6 +93,7 @@ function BlueprintUpdatePreviewWithSocket({ onRetry, previewError, previewId, + layout, }: { clusterId: string loading: boolean @@ -96,6 +102,7 @@ function BlueprintUpdatePreviewWithSocket({ onRetry: () => Promise previewError: boolean previewId: string + layout: 'page' | 'modal' }) { const { organizationId = '' } = useParams({ strict: false }) const { outcome } = useBlueprintUpdatePreviewSocket({ organizationId, clusterId, previewId }) @@ -107,6 +114,7 @@ function BlueprintUpdatePreviewWithSocket({ onConfirm={onConfirm} onRetry={onRetry} outcome={previewError ? { type: 'error' } : outcome} + layout={layout} /> ) } @@ -117,72 +125,109 @@ function BlueprintUpdatePreviewContent({ onConfirm, onRetry, outcome, + layout = 'page', }: { loading: boolean onBack: () => void onConfirm: () => Promise onRetry: () => Promise outcome: BlueprintUpdatePreviewOutcome + layout?: 'page' | 'modal' }) { const canConfirm = outcome.type === 'diff' || outcome.type === 'no-changes' + const isModalPending = layout === 'modal' && outcome.type === 'pending' const rawOutputContainerHeightClassName = - outcome.type === 'diff' ? 'h-[min(75vh,calc(100vh-320px))] min-h-[260px]' : 'min-h-[180px]' + outcome.type === 'diff' + ? layout === 'modal' + ? 'min-h-[220px]' + : 'h-[min(75vh,calc(100vh-320px))] min-h-[260px]' + : isModalPending + ? '' + : 'min-h-[180px]' + const body = ( +
+ Preview changes +
+ Raw output +
+ {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' }, ({ message }) => ( + + )) + .exhaustive()} +
+
+
+ ) + const footer = ( +
+ + +
+ ) + + if (layout === 'modal') { + return ( +
+
+
{body}
+
+ {footer} +
+ ) + } return ( -
- Preview changes -
- Raw output -
- {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' }, ({ message }) => ( - - )) - .exhaustive()} -
-
-
- -
- - -
+ {body} + {footer}
) } @@ -227,7 +272,7 @@ function BlueprintUpdateRawOutput({ rawOutput }: { rawOutput: string }) { } function BlueprintUpdateRawOutputSkeleton() { - const skeletonLineWidths = ['46%', '28%', '72%', '64%', '82%', '34%'] + const skeletonLineWidths = ['46%', '28%', '72%', '64%', '82%', '34%', '58%', '76%', '41%', '88%', '67%', '52%'] const [messageIndex, setMessageIndex] = useState(0) useEffect(() => { @@ -239,7 +284,7 @@ function BlueprintUpdateRawOutputSkeleton() { }, []) return ( -
+
{PREVIEW_LOADING_MESSAGES[messageIndex]} diff --git a/libs/domains/services/feature/src/lib/service-list/service-list-cells/service-version-cell.spec.tsx b/libs/domains/services/feature/src/lib/service-list/service-list-cells/service-version-cell.spec.tsx index 5d91dd57491..16a4de77e96 100644 --- a/libs/domains/services/feature/src/lib/service-list/service-list-cells/service-version-cell.spec.tsx +++ b/libs/domains/services/feature/src/lib/service-list/service-list-cells/service-version-cell.spec.tsx @@ -55,8 +55,8 @@ const withBlueprintTag = (service: Terraform, tag: string) => describe('ServiceVersionCell', () => { it.each([ - { isUpToDate: true, status: 'Up to date' }, - { isUpToDate: false, status: 'Update available' }, + { isUpToDate: true, status: 'Blueprint is up to date' }, + { isUpToDate: false, status: 'Blueprint update available' }, ])('renders the $status state for a blueprint service', ({ isUpToDate, status }) => { jest.mocked(useBlueprintUpdate).mockReturnValue({ data: { is_up_to_date: isUpToDate, current_tag: 'AWS/mysql/8/2.3.4' }, @@ -93,7 +93,7 @@ describe('ServiceVersionCell', () => { expect(screen.getByText('RC test')).toBeInTheDocument() expect(screen.queryByText('v8')).not.toBeInTheDocument() - expect(screen.queryByText('Update available')).not.toBeInTheDocument() + expect(screen.queryByText('Blueprint update available')).not.toBeInTheDocument() }) it('falls back to the pinned git branch when the update check cannot resolve the tag', () => { @@ -124,7 +124,7 @@ describe('ServiceVersionCell', () => { ) expect(screen.getByText('RC test')).toBeInTheDocument() - expect(screen.queryByText('Update available')).not.toBeInTheDocument() + expect(screen.queryByText('Blueprint update available')).not.toBeInTheDocument() }) it('drops a stale update action for a released tag the check can no longer resolve', () => { @@ -136,7 +136,7 @@ describe('ServiceVersionCell', () => { renderWithProviders() - expect(screen.queryByText('Update available')).not.toBeInTheDocument() + expect(screen.queryByText('Blueprint update available')).not.toBeInTheDocument() expect(screen.queryByText('RC test')).not.toBeInTheDocument() // The major still comes off the pinned branch, so it survives the check failing. expect(screen.getByText('v8')).toBeInTheDocument() @@ -242,7 +242,7 @@ describe('ServiceVersionCell', () => { const { userEvent } = renderWithProviders() - await userEvent.click(screen.getByRole('button', { name: 'Update available' })) + await userEvent.click(screen.getByRole('button', { name: 'Blueprint update available' })) expect( await screen.findByRole('heading', { name: 'AWS S3 Bucket blueprint update from 1.2.3 to 2.0.0' }) diff --git a/libs/domains/services/feature/src/lib/service-overview/service-header/service-header.spec.tsx b/libs/domains/services/feature/src/lib/service-overview/service-header/service-header.spec.tsx index e55185548b9..24904475a25 100644 --- a/libs/domains/services/feature/src/lib/service-overview/service-header/service-header.spec.tsx +++ b/libs/domains/services/feature/src/lib/service-overview/service-header/service-header.spec.tsx @@ -436,7 +436,7 @@ describe('ServiceHeader', () => { expect(modelBadge?.querySelector('img')).toHaveAttribute('src', '/assets/ai-tools/claude.svg') }) - it('renders an up to date badge for a current blueprint service', () => { + it('renders blueprint metadata without the update status in the overview', () => { mockUseBlueprintUpdate.mockReturnValue({ data: { is_up_to_date: true, @@ -461,13 +461,15 @@ describe('ServiceHeader', () => { throwOnError: false, }) expect(screen.getByText('v8')).toBeInTheDocument() - expect(screen.getByRole('link', { name: /qovery-blueprints/ })).toHaveAttribute( - 'href', - 'https://github.com/qovery-blueprints/s3' - ) + expect(screen.queryByRole('link', { name: /qovery-blueprints/ })).not.toBeInTheDocument() expect(screen.queryByText('GitHub')).not.toBeInTheDocument() expect(screen.queryByText('main')).not.toBeInTheDocument() - expect(screen.getByText('Up to date')).toBeInTheDocument() + expect(screen.getByRole('link', { name: /v8/ })).toHaveAttribute( + 'href', + '/organization/$organizationId/project/$projectId/environment/$environmentId/service/$serviceId/settings/blueprint-configuration' + ) + expect(screen.queryByText('Blueprint is up to date')).not.toBeInTheDocument() + expect(screen.queryByRole('button', { name: /Blueprint update available/i })).not.toBeInTheDocument() expect(screen.queryByText('Update available')).not.toBeInTheDocument() }) @@ -490,7 +492,7 @@ describe('ServiceHeader', () => { renderServiceHeader('terraform-mock') expect(screen.queryByText('Update available')).not.toBeInTheDocument() - expect(screen.queryByText('Up to date')).not.toBeInTheDocument() + expect(screen.queryByText('Blueprint is up to date')).not.toBeInTheDocument() }) it('still renders the header when the blueprint update check fails', () => { @@ -499,8 +501,8 @@ describe('ServiceHeader', () => { renderServiceHeader('terraform-mock') expect(screen.getByRole('heading', { name: 'aws-s3-bucket' })).toBeInTheDocument() - expect(screen.getByRole('link', { name: /qovery-blueprints/ })).toBeInTheDocument() - expect(screen.queryByText('Up to date')).not.toBeInTheDocument() + expect(screen.queryByRole('link', { name: /qovery-blueprints/ })).not.toBeInTheDocument() + expect(screen.queryByText('Blueprint is up to date')).not.toBeInTheDocument() expect(screen.queryByText('Update available')).not.toBeInTheDocument() }) @@ -524,91 +526,6 @@ describe('ServiceHeader', () => { expect(screen.queryByText('default')).not.toBeInTheDocument() }) - it('opens the blueprint update review flow from the update available badge when values require review', async () => { - mockUseBlueprintUpdate.mockReturnValue({ - data: { - is_up_to_date: false, - current_tag: 'aws/s3/1.0', - latest_tag: 'aws/s3/2.0', - new_required_values: [], - new_optional_values: [], - now_required_values: [], - updated_values: [ - { - name: 'multi_az', - current_default_value: 'false', - new_default_value: 'true', - current_value: 'false', - type: { type: 'bool' }, - allowed_values: null, - is_secret: false, - }, - ], - removed_values: [], - engine_diff: { - updated_values: [], - }, - }, - }) - - const { userEvent } = renderServiceHeader('terraform-mock') - - await userEvent.click(screen.getByRole('button', { name: /update available/i })) - - expect(screen.queryByText('Up to date')).not.toBeInTheDocument() - expect(mockNavigate).toHaveBeenCalledWith({ - to: '/organization/$organizationId/project/$projectId/environment/$environmentId/service/$serviceId/update/blueprint', - params: { - organizationId: 'org-id', - projectId: 'project-id', - environmentId: 'environment-id', - serviceId: 'terraform-mock', - }, - }) - }) - - it('opens a confirmation modal before previewing a blueprint update without review values', async () => { - mockUseBlueprintUpdate.mockReturnValue({ - data: { - is_up_to_date: false, - current_tag: 'aws/s3/1.0', - latest_tag: 'aws/s3/2.0', - new_required_values: [], - new_optional_values: [], - now_required_values: [], - updated_values: [], - removed_values: [], - engine_diff: { - updated_values: [], - }, - }, - }) - - const { userEvent } = renderServiceHeader('terraform-mock') - - await userEvent.click(screen.getByRole('button', { name: /update available/i })) - - expect(screen.queryByText('Up to date')).not.toBeInTheDocument() - expect( - await screen.findByRole('heading', { name: 'aws-s3-bucket blueprint update from 1.0 to 2.0' }) - ).toBeInTheDocument() - expect( - screen.queryByText('No configuration input is required. Continue to preview the update.') - ).not.toBeInTheDocument() - - await userEvent.click(screen.getByRole('button', { name: /preview changes/i })) - - expect(mockNavigate).toHaveBeenCalledWith({ - to: '/organization/$organizationId/project/$projectId/environment/$environmentId/service/$serviceId/update/blueprint/preview', - params: { - organizationId: 'org-id', - projectId: 'project-id', - environmentId: 'environment-id', - serviceId: 'terraform-mock', - }, - }) - }) - it('renders metadata skeletons while the blueprint update is loading', () => { mockUseBlueprintUpdate.mockImplementation(() => { throw new Promise(() => undefined) @@ -616,14 +533,14 @@ describe('ServiceHeader', () => { renderServiceHeader('terraform-mock') - expect(screen.getAllByRole('generic', { busy: true })).toHaveLength(2) + expect(screen.getAllByRole('generic', { busy: true })).toHaveLength(1) }) it('does not check blueprint update availability for non-blueprint services', () => { renderServiceHeader('application-mock') expect(mockUseBlueprintUpdate).not.toHaveBeenCalled() - expect(screen.queryByText('Up to date')).not.toBeInTheDocument() + expect(screen.queryByText('Blueprint is up to date')).not.toBeInTheDocument() expect(screen.queryByText('Update available')).not.toBeInTheDocument() }) }) diff --git a/libs/domains/services/feature/src/lib/service-overview/service-header/service-header.tsx b/libs/domains/services/feature/src/lib/service-overview/service-header/service-header.tsx index cd56f3c399d..2c4b9cc5dad 100644 --- a/libs/domains/services/feature/src/lib/service-overview/service-header/service-header.tsx +++ b/libs/domains/services/feature/src/lib/service-overview/service-header/service-header.tsx @@ -8,7 +8,13 @@ import { useCluster, useClusterRunningStatusSocket, } from '@qovery/domains/clusters/feature' -import { type AnyService, isAgenticWorkflow, isArgoCd } from '@qovery/domains/services/data-access' +import { + type AnyService, + getBlueprintGitRepository, + isAgenticWorkflow, + isArgoCd, + isBlueprintService, +} from '@qovery/domains/services/data-access' import { IconEnum, ServiceTypeEnum, @@ -24,7 +30,6 @@ import { ExternalLink, Heading, Icon, - Skeleton, Tooltip, Truncate, toast, @@ -35,13 +40,11 @@ import { containerRegistryKindToIcon, upperCaseFirstLetter } from '@qovery/share import { AgenticWorkflowServiceActions } from '../../agentic-workflow-service-actions/agentic-workflow-service-actions' import { ArgoCdServiceActions } from '../../argocd-service-actions/argocd-service-actions' import AutoDeployBadge from '../../auto-deploy-badge/auto-deploy-badge' -import { useBlueprintUpdateState } from '../../hooks/use-blueprint-update-state/use-blueprint-update-state' import { useMasterCredentials } from '../../hooks/use-master-credentials/use-master-credentials' import { getDatabaseConnectionUri } from '../../service-access-modal/service-access-modal' import { ServiceActions } from '../../service-actions/service-actions' import { ServiceAvatar } from '../../service-avatar/service-avatar' -import { BlueprintUpdateBadge } from '../../service-blueprint-update-flow/blueprint-update-badge' -import { getBlueprintServiceVersion } from '../../service-blueprint-update-flow/blueprint-update-utils' +import { BlueprintMetadata, BlueprintMetadataSkeleton } from '../../service-blueprint-update-flow/blueprint-metadata' import { ServiceLinksPopover } from '../../service-links-popover/service-links-popover' import { ServiceStateChip } from '../../service-state-chip/service-state-chip' @@ -97,6 +100,8 @@ function ServiceHeaderIdentity({ environment, service }: ServiceHeaderIdentityPr const { data: cluster } = useCluster({ organizationId, clusterId: environment.cluster_id, suspense: true }) const isArgoCdService = isArgoCd(service) const isAgenticWorkflowService = isAgenticWorkflow(service) + const blueprintId = isBlueprintService(service) ? service.blueprint_id : undefined + const blueprintGitRepository = isBlueprintService(service) ? getBlueprintGitRepository(service) : undefined useClusterRunningStatusSocket({ organizationId, clusterId: environment.cluster_id }) @@ -137,6 +142,21 @@ function ServiceHeaderIdentity({ environment, service }: ServiceHeaderIdentityPr )} + {blueprintId && ( + <> + + }> + + + + )}
@@ -165,84 +185,6 @@ interface ServiceHeaderMetadataProps { service: AnyService } -function BlueprintUpdateBadgeSkeleton() { - return -} - -function BlueprintRepository({ gitRepository }: { gitRepository: ApplicationGitRepository }) { - if (!gitRepository.url || !gitRepository.name) { - return null - } - - return ( - - {gitRepository.provider && } - - - ) -} - -function BlueprintMetadataSkeleton({ gitRepository }: { gitRepository?: ApplicationGitRepository }) { - return ( - <> - - {gitRepository && } - - - ) -} - -function BlueprintMetadata({ - blueprintId, - gitRepository, - service, -}: { - blueprintId: string - gitRepository?: ApplicationGitRepository - service: AnyService -}) { - const { organizationId = '', projectId = '' } = useParams({ strict: false }) - // `throwOnError: false` because react-query v4 makes suspense queries throw by default, and there - // is no boundary between here and the organization layout: a blueprint pinned to a tag the - // catalog cannot resolve would replace the whole overview with the generic error page. - const { blueprintUpdate, tag } = useBlueprintUpdateState({ - blueprintId, - localTag: gitRepository?.branch, - suspense: true, - throwOnError: false, - }) - const currentVersion = tag ? getBlueprintServiceVersion(tag) : undefined - - return ( - <> - {currentVersion && currentVersion !== 'default' && ( - - - v{currentVersion} - - )} - {gitRepository && } - {blueprintUpdate && ( - - )} - - ) -} - function ServiceHeaderMetadata({ service }: ServiceHeaderMetadataProps) { const { organizationId = '', projectId = '', environmentId = '', serviceId = '' } = useParams({ strict: false }) const { data: masterCredentials } = useMasterCredentials({ @@ -298,14 +240,7 @@ function ServiceHeaderMetadata({ service }: ServiceHeaderMetadataProps) { return (
- {gitRepository && - (blueprintId ? ( - }> - - - ) : ( - - ))} + {gitRepository && !blueprintId && } {isArgoCdService && 'manifest_revision' in service && service.manifest_revision && (