From 0daf663413c80321fdd086ed6dcb39c4991f176d Mon Sep 17 00:00:00 2001 From: Romain Billard Date: Mon, 14 Sep 2026 17:36:32 +0200 Subject: [PATCH 01/29] Initial clean up --- .../service/$serviceId/variables/route.tsx | 7 ++- .../service-settings-layout.spec.tsx | 63 +++++++++++++++++++ .../service-settings-layout.tsx | 24 ++++--- .../service-variables-utils.spec.ts | 14 +++++ .../service-variables-utils.ts | 6 +- 5 files changed, 100 insertions(+), 14 deletions(-) create mode 100644 libs/domains/service-settings/feature/src/lib/service-settings-layout/service-settings-layout.spec.tsx create mode 100644 libs/domains/services/feature/src/lib/service-variables-tabs/service-variables-utils.spec.ts 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/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..b25c4320d30 --- /dev/null +++ b/libs/domains/service-settings/feature/src/lib/service-settings-layout/service-settings-layout.spec.tsx @@ -0,0 +1,63 @@ +import { type ReactNode } from 'react' +import { 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('Resources')).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() + }) +}) 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..40fa83f6691 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' @@ -159,15 +159,19 @@ export function ServiceSettingsLayout({ children }: ServiceSettingsLayoutProps) advancedSettingsLink, dangerZoneLink, ]) - .with({ serviceType: 'TERRAFORM' }, () => [ - generalLink, - terraformConfigurationLink, - terraformArgumentsLink, - resourcesLink, - deploymentRestrictionsLink, - advancedSettingsLink, - dangerZoneLink, - ]) + .with({ serviceType: 'TERRAFORM' }, () => + isBlueprintService(service) + ? [generalLink, resourcesLink, deploymentRestrictionsLink, advancedSettingsLink, dangerZoneLink] + : [ + 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/feature/src/lib/service-variables-tabs/service-variables-utils.spec.ts b/libs/domains/services/feature/src/lib/service-variables-tabs/service-variables-utils.spec.ts new file mode 100644 index 00000000000..b78f83d27fc --- /dev/null +++ b/libs/domains/services/feature/src/lib/service-variables-tabs/service-variables-utils.spec.ts @@ -0,0 +1,14 @@ +import { terraformFactoryMock } from '@qovery/shared/factories' +import { hasTerraformVariablesTab } from './service-variables-utils' + +describe('hasTerraformVariablesTab', () => { + it('hides the Terraform variables tab for blueprint services', () => { + const blueprintService = { ...terraformFactoryMock(1)[0], blueprint_id: 'blueprint-id' } + + expect(hasTerraformVariablesTab(blueprintService)).toBe(false) + }) + + it('shows the Terraform variables tab for regular Terraform services', () => { + expect(hasTerraformVariablesTab(terraformFactoryMock(1)[0])).toBe(true) + }) +}) diff --git a/libs/domains/services/feature/src/lib/service-variables-tabs/service-variables-utils.ts b/libs/domains/services/feature/src/lib/service-variables-tabs/service-variables-utils.ts index b935cd1ca5f..df4bfa419df 100644 --- a/libs/domains/services/feature/src/lib/service-variables-tabs/service-variables-utils.ts +++ b/libs/domains/services/feature/src/lib/service-variables-tabs/service-variables-utils.ts @@ -1,5 +1,5 @@ import { match } from 'ts-pattern' -import { type ServiceType } from '@qovery/domains/services/data-access' +import { type AnyService, type ServiceType, isBlueprintService } from '@qovery/domains/services/data-access' export type ServiceVariableScope = 'APPLICATION' | 'CONTAINER' | 'JOB' | 'HELM' | 'TERRAFORM' | 'AGENTIC_WORKFLOW' @@ -21,3 +21,7 @@ export function getServiceVariableScope( .with('AGENTIC_WORKFLOW', () => 'AGENTIC_WORKFLOW' as const) .otherwise(() => fallbackScope) } + +export function hasTerraformVariablesTab(service?: AnyService) { + return service?.serviceType === 'TERRAFORM' && !isBlueprintService(service) +} From 828c7b1ef908fc650d73ae61e5b423b3b75cff43 Mon Sep 17 00:00:00 2001 From: Romain Billard Date: Mon, 14 Sep 2026 18:06:37 +0200 Subject: [PATCH 02/29] Add blueprint-specific settings --- .../blueprint-general-settings.spec.tsx | 50 ++++ .../blueprint-general-settings.tsx | 219 ++++++++++++++++++ .../service-general-settings.tsx | 7 +- libs/domains/services/feature/src/index.ts | 9 + 4 files changed, 284 insertions(+), 1 deletion(-) create mode 100644 libs/domains/service-settings/feature/src/lib/service-general-settings/blueprint-general-settings/blueprint-general-settings.spec.tsx create mode 100644 libs/domains/service-settings/feature/src/lib/service-general-settings/blueprint-general-settings/blueprint-general-settings.tsx 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..6e197c41eaa --- /dev/null +++ b/libs/domains/service-settings/feature/src/lib/service-general-settings/blueprint-general-settings/blueprint-general-settings.spec.tsx @@ -0,0 +1,50 @@ +import { type ReactNode } from 'react' +import { terraformFactoryMock } from '@qovery/shared/factories' +import { renderWithProviders, screen } from '@qovery/shared/util-tests' +import { BlueprintGeneralSettings } from './blueprint-general-settings' + +const mockUseBlueprint = jest.fn() +const mockUseBlueprintCatalogServiceManifest = jest.fn() + +jest.mock('@qovery/domains/services/feature', () => ({ + useBlueprint: (...args: unknown[]) => mockUseBlueprint(...args), + useBlueprintCatalogServiceManifest: (...args: unknown[]) => mockUseBlueprintCatalogServiceManifest(...args), + usePreviewBlueprintUpdate: () => ({ mutateAsync: jest.fn(), isLoading: false }), + useUpdateBlueprint: () => ({ mutateAsync: jest.fn(), isLoading: false }), + useDeployBlueprint: () => ({ mutateAsync: jest.fn(), isLoading: false }), + getDefaultFieldValue: jest.fn(), + getFallbackServiceIcon: jest.fn(), + getFieldValidationError: jest.fn(), + isFieldValid: jest.fn(), + isOptionalVariableField: jest.fn(), + isRequiredVariableField: jest.fn(), + BlueprintManifestVariableInput: () => null, + BlueprintSection: ({ title, children }: { title: string; children: ReactNode }) => ( +
+ {title} + {children} +
+ ), + OverridesSectionCard: ({ children }: { children: ReactNode }) =>
{children}
, +})) + +describe('BlueprintGeneralSettings', () => { + const service = { ...terraformFactoryMock(1)[0], blueprint_id: 'blueprint-id' } + + it('loads the catalog form from the Blueprint tag returned by the existing read endpoint', () => { + mockUseBlueprintCatalogServiceManifest.mockReturnValue({ data: [], isLoading: false }) + mockUseBlueprint.mockReturnValue({ + data: { name: service.name, tag: 'aws/postgres/17/1.0.0' }, + isLoading: false, + }) + + renderWithProviders( + + ) + + expect(screen.getByText('Blueprint setup')).toBeInTheDocument() + expect(mockUseBlueprintCatalogServiceManifest).toHaveBeenCalledWith( + expect.objectContaining({ provider: 'aws', serviceFamily: 'postgres', serviceVersion: '17' }) + ) + }) +}) 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..90012117b18 --- /dev/null +++ b/libs/domains/service-settings/feature/src/lib/service-general-settings/blueprint-general-settings/blueprint-general-settings.tsx @@ -0,0 +1,219 @@ +import { type BlueprintManifestVariableField } from 'qovery-typescript-axios' +import { useMemo, useState } from 'react' +import { type BlueprintService } from '@qovery/domains/services/data-access' +import { + type BlueprintFieldValue, + BlueprintManifestVariableInput, + BlueprintSection, + OverridesSectionCard, + getDefaultFieldValue, + getFallbackServiceIcon, + getFieldValidationError, + isFieldValid, + isOptionalVariableField, + isRequiredVariableField, + useBlueprint, + useBlueprintCatalogServiceManifest, + useDeployBlueprint, + usePreviewBlueprintUpdate, + useUpdateBlueprint, +} from '@qovery/domains/services/feature' +import { SettingsHeading } from '@qovery/shared/console-shared' +import { Button, LoaderSpinner, Section, toast } from '@qovery/shared/ui' + +interface PersistedVariable { + name: string + value: string | null + is_secret: boolean +} + +interface BlueprintSettingsDetails { + name: string + tag: string +} + +interface BlueprintGeneralSettingsProps { + service: BlueprintService + environmentId: string + organizationId: string +} + +function isBlueprintSettingsDetails(data: unknown): data is BlueprintSettingsDetails { + console.log('data', data) + if (!data || typeof data !== 'object') return false + + const details = data as Partial + return typeof details.name === 'string' && typeof details.tag === 'string' +} + +function parseBlueprintTag(tag: string | undefined) { + const [provider = '', serviceFamily = '', serviceVersion = ''] = tag?.split('/') ?? [] + return { provider, serviceFamily, serviceVersion } +} + +function getPersistedVariables(service: BlueprintService): PersistedVariable[] { + if (service.serviceType !== 'TERRAFORM') return [] + + return service.terraform_variables_source.tf_vars.flatMap((variable) => + variable.key + ? [ + { + name: variable.key, + value: variable.secret ? null : variable.value ?? null, + is_secret: variable.secret ?? false, + }, + ] + : [] + ) +} + +export function BlueprintGeneralSettings({ service, environmentId, organizationId }: BlueprintGeneralSettingsProps) { + const { data, isLoading } = useBlueprint({ blueprintId: service.blueprint_id }) + console.log('data useBlueprint', data) + 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 details = isBlueprintSettingsDetails(data) ? data : undefined + const { provider, serviceFamily, serviceVersion } = parseBlueprintTag(details?.tag) + const { data: fields = [], isLoading: isManifestLoading } = useBlueprintCatalogServiceManifest({ + organizationId, + provider, + serviceFamily, + serviceVersion, + environmentId, + enabled: Boolean(details), + }) + const variablesByName = useMemo( + () => new Map(getPersistedVariables(service).map((variable) => [variable.name, variable])), + [service] + ) + 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 = { ...initialValues, ...changes } + const requiredFields = fields.filter(isRequiredVariableField) + const optionalFields = fields.filter(isOptionalVariableField) + const isValid = requiredFields.every( + (field) => + isFieldValid(field, values[field.name]) || + (field.is_secret && variablesByName.get(field.name)?.is_secret && changes[field.name] === undefined) + ) + const isSaving = isPreviewLoading || isUpdateLoading || isDeployLoading + + const save = async () => { + if (!details || !isValid) return + + const variables = Object.fromEntries( + Object.entries(changes).map(([name, value]) => [ + name, + { value: String(value), is_secret: variablesByName.get(name)?.is_secret ?? false }, + ]) + ) + const payload = { + name: details.name, + tag: details.tag, + icon: service.icon_uri ?? getFallbackServiceIcon(service.service_type), + variables, + } + + await previewBlueprintUpdate({ blueprintId: service.blueprint_id, payload }) + await updateBlueprint({ blueprintId: service.blueprint_id, payload }) + await deployBlueprint({ blueprintId: service.blueprint_id }) + setChanges({}) + toast('success', 'Blueprint update started') + } + + if (isLoading || (details && isManifestLoading)) { + return + } + + if (!details) { + return ( +
+ +
+

Blueprint configuration is unavailable.

+

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

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

{details.name}

+

{details.tag}

+
+ + {requiredFields.map((field, index) => ( + setChanges((current) => ({ ...current, [field.name]: value }))} + /> + ))} + + undefined}> + {optionalFields.map((field) => ( + setChanges((current) => ({ ...current, [field.name]: value }))} + /> + ))} + +
+ +
+
+
+ ) +} 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..d57465125cd 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, @@ -22,6 +22,7 @@ import { SettingsHeading } from '@qovery/shared/console-shared' import { Button, LoaderSpinner, Section, useModal } from '@qovery/shared/ui' import { useDocumentTitle } from '@qovery/shared/util-hooks' import { ApplicationGeneralSettings } from './application-general-settings/application-general-settings' +import { BlueprintGeneralSettings } from './blueprint-general-settings/blueprint-general-settings' import { ContainerGeneralSettings } from './container-general-settings/container-general-settings' import { DatabaseGeneralSettings } from './database-general-settings/database-general-settings' import { HelmGeneralSettings } from './helm-general-settings/helm-general-settings' @@ -134,6 +135,10 @@ function ServiceGeneralSettingsContent({ organization }: ServiceGeneralSettingsP return null } + if (isBlueprintService(service)) { + return + } + const headingDescription = service.serviceType === 'DATABASE' ? 'These general settings allow you to set up the database name, type and version.' diff --git a/libs/domains/services/feature/src/index.ts b/libs/domains/services/feature/src/index.ts index c7d455dfa7c..e0ebde7d89f 100644 --- a/libs/domains/services/feature/src/index.ts +++ b/libs/domains/services/feature/src/index.ts @@ -40,6 +40,8 @@ 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 +115,14 @@ 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-blueprint/use-blueprint' +export * from './lib/hooks/use-deploy-blueprint/use-deploy-blueprint' +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-blueprint-update-flow/blueprint-update-flow' +export { getFallbackServiceIcon } 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' From 97631a5e21b6b8237e3dc6fb0f26590e464266e7 Mon Sep 17 00:00:00 2001 From: Romain Billard Date: Tue, 15 Sep 2026 11:37:36 +0200 Subject: [PATCH 03/29] Add preview step --- .../blueprint-general-settings.spec.tsx | 83 ++++++++++++++++-- .../blueprint-general-settings.tsx | 84 +++++++++++++++---- .../blueprint-update-flow-shell.tsx | 8 +- .../blueprint-update-flow.tsx | 2 + .../blueprint-update-preview-step.tsx | 4 +- 5 files changed, 154 insertions(+), 27 deletions(-) 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 index 6e197c41eaa..572e4507919 100644 --- 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 @@ -5,20 +5,31 @@ import { BlueprintGeneralSettings } from './blueprint-general-settings' const mockUseBlueprint = jest.fn() const mockUseBlueprintCatalogServiceManifest = jest.fn() +const mockPreviewBlueprintUpdate = jest.fn() +const mockUpdateBlueprint = jest.fn() +const mockDeployBlueprint = jest.fn() + +jest.mock('@qovery/domains/environments/feature', () => ({ + useEnvironment: () => ({ data: { cluster_id: 'cluster-id' } }), +})) jest.mock('@qovery/domains/services/feature', () => ({ useBlueprint: (...args: unknown[]) => mockUseBlueprint(...args), useBlueprintCatalogServiceManifest: (...args: unknown[]) => mockUseBlueprintCatalogServiceManifest(...args), - usePreviewBlueprintUpdate: () => ({ mutateAsync: jest.fn(), isLoading: false }), - useUpdateBlueprint: () => ({ mutateAsync: jest.fn(), isLoading: false }), - useDeployBlueprint: () => ({ mutateAsync: jest.fn(), isLoading: false }), + 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: jest.fn(), - isOptionalVariableField: jest.fn(), - isRequiredVariableField: jest.fn(), - BlueprintManifestVariableInput: () => null, + isFieldValid: () => true, + isOptionalVariableField: (field: { required: boolean; kind: string }) => field.kind === 'variable' && !field.required, + isRequiredVariableField: (field: { required: boolean; kind: string }) => field.kind === 'variable' && field.required, + BlueprintManifestVariableInput: ({ onChange }: { onChange: (value: string) => void }) => ( + + ), BlueprintSection: ({ title, children }: { title: string; children: ReactNode }) => (
{title} @@ -26,6 +37,12 @@ jest.mock('@qovery/domains/services/feature', () => ({
), OverridesSectionCard: ({ children }: { children: ReactNode }) =>
{children}
, + BlueprintUpdateFlowShell: ({ children }: { children: ReactNode }) =>
{children}
, + BlueprintPreview: ({ onConfirm }: { onConfirm: () => Promise }) => ( + + ), })) describe('BlueprintGeneralSettings', () => { @@ -47,4 +64,56 @@ describe('BlueprintGeneralSettings', () => { expect.objectContaining({ provider: 'aws', serviceFamily: 'postgres', serviceVersion: '17' }) ) }) + + 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, + }) + mockPreviewBlueprintUpdate.mockResolvedValue({ preview_id: 'preview-id' }) + + const { userEvent } = renderWithProviders( + + ) + + await userEvent.click(screen.getByRole('button', { name: 'Edit value' })) + await userEvent.click(screen.getByRole('button', { name: 'Preview changes' })) + + expect(mockPreviewBlueprintUpdate).toHaveBeenCalledTimes(1) + 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(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) + }) }) 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 index 90012117b18..ccf8acc1c97 100644 --- 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 @@ -1,10 +1,13 @@ import { type BlueprintManifestVariableField } from 'qovery-typescript-axios' -import { useMemo, useState } from 'react' +import { useCallback, useMemo, useState } from 'react' +import { useEnvironment } from '@qovery/domains/environments/feature' import { type BlueprintService } from '@qovery/domains/services/data-access' import { type BlueprintFieldValue, BlueprintManifestVariableInput, + BlueprintPreview, BlueprintSection, + BlueprintUpdateFlowShell, OverridesSectionCard, getDefaultFieldValue, getFallbackServiceIcon, @@ -69,7 +72,7 @@ function getPersistedVariables(service: BlueprintService): PersistedVariable[] { export function BlueprintGeneralSettings({ service, environmentId, organizationId }: BlueprintGeneralSettingsProps) { const { data, isLoading } = useBlueprint({ blueprintId: service.blueprint_id }) - console.log('data useBlueprint', data) + const { data: environment } = useEnvironment({ environmentId }) const { mutateAsync: previewBlueprintUpdate, isLoading: isPreviewLoading } = usePreviewBlueprintUpdate() const { mutateAsync: updateBlueprint, isLoading: isUpdateLoading } = useUpdateBlueprint({ environmentId, @@ -82,6 +85,9 @@ export function BlueprintGeneralSettings({ service, environmentId, organizationI serviceType: service.serviceType, }) const [changes, setChanges] = useState>({}) + 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) @@ -97,6 +103,15 @@ export function BlueprintGeneralSettings({ service, environmentId, organizationI () => new Map(getPersistedVariables(service).map((variable) => [variable.name, variable])), [service] ) + 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( @@ -125,28 +140,49 @@ export function BlueprintGeneralSettings({ service, environmentId, organizationI isFieldValid(field, values[field.name]) || (field.is_secret && variablesByName.get(field.name)?.is_secret && changes[field.name] === undefined) ) - const isSaving = isPreviewLoading || isUpdateLoading || isDeployLoading - - const save = async () => { - if (!details || !isValid) return + const isSaving = isUpdateLoading || isDeployLoading + const payload = useMemo(() => { const variables = Object.fromEntries( Object.entries(changes).map(([name, value]) => [ name, - { value: String(value), is_secret: variablesByName.get(name)?.is_secret ?? false }, + { value: String(value), is_secret: manifestVariablesByName.get(name)?.is_secret ?? false }, ]) ) - const payload = { - name: details.name, - tag: details.tag, - icon: service.icon_uri ?? getFallbackServiceIcon(service.service_type), - variables, + + 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 + + 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]) + + const confirmAndDeploy = async () => { + if (!details || !isValid) return + if (!payload) return - await previewBlueprintUpdate({ blueprintId: service.blueprint_id, payload }) await updateBlueprint({ blueprintId: service.blueprint_id, payload }) await deployBlueprint({ blueprintId: service.blueprint_id }) setChanges({}) + setStep('review') toast('success', 'Blueprint update started') } @@ -171,6 +207,22 @@ export function BlueprintGeneralSettings({ service, environmentId, organizationI ) } + if (step === 'preview') { + return ( + setStep('review')}> + setStep('review')} + onConfirm={confirmAndDeploy} + onRetry={requestPreview} + /> + + ) + } + return (
@@ -206,11 +258,11 @@ export function BlueprintGeneralSettings({ service, environmentId, organizationI
diff --git a/libs/domains/services/feature/src/lib/service-blueprint-update-flow/blueprint-update-flow-shell.tsx b/libs/domains/services/feature/src/lib/service-blueprint-update-flow/blueprint-update-flow-shell.tsx index def9cb8418a..a79addc6667 100644 --- a/libs/domains/services/feature/src/lib/service-blueprint-update-flow/blueprint-update-flow-shell.tsx +++ b/libs/domains/services/feature/src/lib/service-blueprint-update-flow/blueprint-update-flow-shell.tsx @@ -6,10 +6,14 @@ export function BlueprintUpdateFlowShell({ children, currentStep, onExit, + previewTitle = 'Preview changes', + reviewTitle = 'Review update', }: { children: ReactNode currentStep: 1 | 2 onExit: () => void + previewTitle?: string + reviewTitle?: string }) { return (
@@ -19,9 +23,9 @@ export function BlueprintUpdateFlowShell({
- 1} active={currentStep === 1} number={1} title="Review update" /> + 1} active={currentStep === 1} number={1} title={reviewTitle} /> - +
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.tsx b/libs/domains/services/feature/src/lib/service-blueprint-update-flow/blueprint-update-preview-step.tsx index 1f62d963d83..f65a75dccde 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 @@ -26,7 +26,7 @@ export function BlueprintUpdatePreviewStep({ onBack }: { onBack: () => void }) { }, [requestPreview]) return ( - void }) { ) } -function BlueprintUpdatePreview({ +export function BlueprintPreview({ clusterId, loading, onBack, From 9daaed3ad26cdfa89377293dbf8274c5acca0799 Mon Sep 17 00:00:00 2001 From: Romain Billard Date: Tue, 15 Sep 2026 17:56:57 +0200 Subject: [PATCH 04/29] Add flow to preview changes --- .../blueprint-general-settings.spec.tsx | 58 ++++++- .../blueprint-general-settings.tsx | 72 ++++++--- .../blueprint-update-preview-step.spec.tsx | 41 ++++- .../blueprint-update-preview-step.tsx | 144 +++++++++++------- .../src/lib/components/modal/modal-root.tsx | 6 +- .../ui/src/lib/components/modal/modal.tsx | 23 ++- .../components/modal/use-modal/use-modal.tsx | 19 +-- 7 files changed, 258 insertions(+), 105 deletions(-) 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 index 572e4507919..a74ca4fda5f 100644 --- 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 @@ -38,10 +38,15 @@ jest.mock('@qovery/domains/services/feature', () => ({ ), OverridesSectionCard: ({ children }: { children: ReactNode }) =>
{children}
, BlueprintUpdateFlowShell: ({ children }: { children: ReactNode }) =>
{children}
, - BlueprintPreview: ({ onConfirm }: { onConfirm: () => Promise }) => ( - + BlueprintPreview: ({ onBack, onConfirm }: { onBack: () => void; onConfirm: () => Promise }) => ( + <> + + + ), })) @@ -116,4 +121,49 @@ describe('BlueprintGeneralSettings', () => { }) expect(mockDeployBlueprint).toHaveBeenCalledTimes(1) }) + + 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: '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 index ccf8acc1c97..bad78fa7ba0 100644 --- 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 @@ -1,5 +1,6 @@ +import * as Dialog from '@radix-ui/react-dialog' import { type BlueprintManifestVariableField } from 'qovery-typescript-axios' -import { useCallback, useMemo, useState } from 'react' +import { useCallback, useEffect, useMemo, useState } from 'react' import { useEnvironment } from '@qovery/domains/environments/feature' import { type BlueprintService } from '@qovery/domains/services/data-access' import { @@ -7,7 +8,6 @@ import { BlueprintManifestVariableInput, BlueprintPreview, BlueprintSection, - BlueprintUpdateFlowShell, OverridesSectionCard, getDefaultFieldValue, getFallbackServiceIcon, @@ -22,7 +22,7 @@ import { useUpdateBlueprint, } from '@qovery/domains/services/feature' import { SettingsHeading } from '@qovery/shared/console-shared' -import { Button, LoaderSpinner, Section, toast } from '@qovery/shared/ui' +import { Button, LoaderSpinner, Section, toast, useModal } from '@qovery/shared/ui' interface PersistedVariable { name: string @@ -71,6 +71,7 @@ function getPersistedVariables(service: BlueprintService): PersistedVariable[] { } export function BlueprintGeneralSettings({ service, environmentId, organizationId }: BlueprintGeneralSettingsProps) { + const { closeModal, openModal } = useModal() const { data, isLoading } = useBlueprint({ blueprintId: service.blueprint_id }) const { data: environment } = useEnvironment({ environmentId }) const { mutateAsync: previewBlueprintUpdate, isLoading: isPreviewLoading } = usePreviewBlueprintUpdate() @@ -142,6 +143,11 @@ export function BlueprintGeneralSettings({ service, environmentId, organizationI ) const isSaving = isUpdateLoading || isDeployLoading + const closePreview = useCallback(() => { + closeModal() + setStep('review') + }, [closeModal]) + const payload = useMemo(() => { const variables = Object.fromEntries( Object.entries(changes).map(([name, value]) => [ @@ -175,16 +181,54 @@ export function BlueprintGeneralSettings({ service, environmentId, organizationI } }, [isValid, payload, previewBlueprintUpdate, service.blueprint_id]) - const confirmAndDeploy = async () => { + const confirmAndDeploy = useCallback(async () => { if (!details || !isValid) return if (!payload) return await updateBlueprint({ blueprintId: service.blueprint_id, payload }) await deployBlueprint({ blueprintId: service.blueprint_id }) setChanges({}) - setStep('review') + closePreview() toast('success', 'Blueprint update started') - } + }, [closePreview, deployBlueprint, details, isValid, payload, service.blueprint_id, 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 || (details && isManifestLoading)) { return @@ -207,22 +251,6 @@ export function BlueprintGeneralSettings({ service, environmentId, organizationI ) } - if (step === 'preview') { - return ( - setStep('review')}> - setStep('review')} - onConfirm={confirmAndDeploy} - onRetry={requestPreview} - /> - - ) - } - return (
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..94af14e5654 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,43 @@ 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('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 f65a75dccde..16232223e46 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 @@ -46,6 +46,7 @@ export function BlueprintPreview({ onRetry, previewError, previewId, + layout = 'page', }: { clusterId?: string loading: boolean @@ -54,6 +55,7 @@ export function BlueprintPreview({ onRetry: () => Promise previewError: boolean previewId?: string + layout?: 'page' | 'modal' }) { if (!clusterId || !previewId) { return ( @@ -63,6 +65,7 @@ export function BlueprintPreview({ onConfirm={onConfirm} onRetry={onRetry} outcome={previewError ? { type: 'error' } : { type: 'pending' }} + layout={layout} /> ) } @@ -76,6 +79,7 @@ export function BlueprintPreview({ onRetry={onRetry} previewError={previewError} previewId={previewId} + layout={layout} /> ) } @@ -88,6 +92,7 @@ function BlueprintUpdatePreviewWithSocket({ onRetry, previewError, previewId, + layout, }: { clusterId: string loading: boolean @@ -96,6 +101,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 +113,7 @@ function BlueprintUpdatePreviewWithSocket({ onConfirm={onConfirm} onRetry={onRetry} outcome={previewError ? { type: 'error' } : outcome} + layout={layout} /> ) } @@ -117,72 +124,101 @@ 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 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]' + : 'min-h-[180px]' - 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()} -
-
+ 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 ( + + {body} + {footer} ) } diff --git a/libs/shared/ui/src/lib/components/modal/modal-root.tsx b/libs/shared/ui/src/lib/components/modal/modal-root.tsx index 84039b2ab52..7ad6cdfe9a9 100644 --- a/libs/shared/ui/src/lib/components/modal/modal-root.tsx +++ b/libs/shared/ui/src/lib/components/modal/modal-root.tsx @@ -1,10 +1,8 @@ import { type Dispatch, type ReactNode, type SetStateAction, createContext, useState } from 'react' import ModalAlert from '../modal-alert/modal-alert' -import Modal from './modal' +import Modal, { type ModalProps } from './modal' -interface ModalOptions { - width?: number - fullScreen?: boolean +export type ModalOptions = Pick & { /** * This is a workaround to avoid radix dialog restriction. * Radix use [react-remove-scroll](https://www.npmjs.com/package/react-remove-scroll) to prevent wheel / scroll event directly on `` node diff --git a/libs/shared/ui/src/lib/components/modal/modal.tsx b/libs/shared/ui/src/lib/components/modal/modal.tsx index 3f7dc454a10..00dc9093e15 100644 --- a/libs/shared/ui/src/lib/components/modal/modal.tsx +++ b/libs/shared/ui/src/lib/components/modal/modal.tsx @@ -10,7 +10,9 @@ export interface ModalProps { trigger?: ReactNode defaultOpen?: boolean buttonClose?: boolean + dismissible?: boolean width?: number | string + height?: number | string fullScreen?: boolean className?: string externalOpen?: boolean @@ -42,9 +44,11 @@ export const Modal = (props: ModalProps) => { className = '', defaultOpen = false, buttonClose = true, + dismissible = true, externalOpen = false, setExternalOpen, fakeModal = false, + height, } = props const [open, setOpen] = useState(defaultOpen) @@ -82,6 +86,11 @@ export const Modal = (props: ModalProps) => { ]) const handleOutsideClick = (event: React.MouseEvent) => { + if (!dismissible) { + event.preventDefault() + return + } + if (isToastInteraction(event)) { event.preventDefault() event.stopPropagation() @@ -102,6 +111,7 @@ export const Modal = (props: ModalProps) => { onOpenChange={ setExternalOpen ? () => { + if (!dismissible) return if (alertClickOutside) { setModalAlertOpen(true) } else { @@ -109,6 +119,7 @@ export const Modal = (props: ModalProps) => { } } : () => { + if (!dismissible) return if (alertClickOutside) { setModalAlertOpen(true) } else { @@ -144,18 +155,24 @@ export const Modal = (props: ModalProps) => { event.stopPropagation() } }} + onEscapeKeyDown={(event) => { + if (!dismissible) event.preventDefault() + }} style={ fullScreen ? { width: 'calc(100vw - 48px)', height: 'calc(100vh - 48px)', top: 24 } - : { width: `${width}px` } + : { + width: typeof width === 'number' ? `${width}px` : width, + ...(height ? { height: typeof height === 'number' ? `${height}px` : height } : {}), + } } className={`modal__content fixed left-1/2 top-[84px] z-modal overflow-hidden rounded-md border border-neutral bg-background shadow-[0_0_32px_rgba(0,0,0,0.08)] ${className}`} > -
+
{cloneElement(children, { setOpen: setExternalOpen ? setExternalOpen : setOpen, })} - {buttonClose && ( + {buttonClose && dismissible && ( + BlueprintManifestVariableInput: ({ + onChange, + value, + }: { + onChange: (value: string) => void + value: string | boolean | undefined + }) => ( + <> + Current value: {String(value ?? '')} + + ), BlueprintSection: ({ title, children }: { title: string; children: ReactNode }) => (
@@ -50,9 +68,23 @@ jest.mock('@qovery/domains/services/feature', () => ({ ), })) -describe('BlueprintGeneralSettings', () => { - const service = { ...terraformFactoryMock(1)[0], blueprint_id: 'blueprint-id' } +function BlueprintGeneralSettingsHarness() { + const [queryClient] = useState(() => new QueryClient()) + const [visible, setVisible] = useState(true) + return ( + + + {visible ? ( + + ) : null} + + ) +} + +describe('BlueprintGeneralSettings', () => { it('loads the catalog form from the Blueprint tag returned by the existing read endpoint', () => { mockUseBlueprintCatalogServiceManifest.mockReturnValue({ data: [], isLoading: false }) mockUseBlueprint.mockReturnValue({ @@ -110,6 +142,7 @@ describe('BlueprintGeneralSettings', () => { 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', @@ -122,6 +155,39 @@ describe('BlueprintGeneralSettings', () => { expect(mockDeployBlueprint).toHaveBeenCalledTimes(1) }) + 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: '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' })) + + 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: [ 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 index bad78fa7ba0..3a75e1fd9c2 100644 --- 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 @@ -1,4 +1,5 @@ import * as Dialog from '@radix-ui/react-dialog' +import { useQuery, useQueryClient } from '@tanstack/react-query' import { type BlueprintManifestVariableField } from 'qovery-typescript-axios' import { useCallback, useEffect, useMemo, useState } from 'react' import { useEnvironment } from '@qovery/domains/environments/feature' @@ -35,12 +36,27 @@ interface BlueprintSettingsDetails { tag: string } +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 { console.log('data', data) if (!data || typeof data !== 'object') return false @@ -70,6 +86,72 @@ function getPersistedVariables(service: BlueprintService): PersistedVariable[] { ) } +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({ service, environmentId, organizationId }: BlueprintGeneralSettingsProps) { const { closeModal, openModal } = useModal() const { data, isLoading } = useBlueprint({ blueprintId: service.blueprint_id }) @@ -104,6 +186,11 @@ export function BlueprintGeneralSettings({ service, environmentId, organizationI () => new Map(getPersistedVariables(service).map((variable) => [variable.name, variable])), [service] ) + const { + optimisticSettings: { values: optimisticChanges, secretNames: optimisticSecretNames }, + update: updateOptimisticSettings, + remove: removeOptimisticSettings, + } = useOptimisticBlueprintSettings({ serviceId: service.id, persistedVariables: variablesByName }) const manifestVariablesByName = useMemo( () => new Map( @@ -133,13 +220,15 @@ export function BlueprintGeneralSettings({ service, environmentId, organizationI ), [fields, variablesByName] ) - const values = { ...initialValues, ...changes } + const values = { ...initialValues, ...optimisticChanges, ...changes } const requiredFields = fields.filter(isRequiredVariableField) const optionalFields = fields.filter(isOptionalVariableField) const isValid = requiredFields.every( (field) => isFieldValid(field, values[field.name]) || - (field.is_secret && variablesByName.get(field.name)?.is_secret && changes[field.name] === undefined) + (field.is_secret && + (variablesByName.get(field.name)?.is_secret || optimisticSecretNames.includes(field.name)) && + changes[field.name] === undefined) ) const isSaving = isUpdateLoading || isDeployLoading @@ -185,12 +274,36 @@ export function BlueprintGeneralSettings({ service, environmentId, organizationI if (!details || !isValid) return if (!payload) return - await updateBlueprint({ blueprintId: service.blueprint_id, payload }) - await deployBlueprint({ blueprintId: service.blueprint_id }) + 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() - toast('success', 'Blueprint update started') - }, [closePreview, deployBlueprint, details, isValid, payload, service.blueprint_id, updateBlueprint]) + + try { + await updateBlueprint({ blueprintId: service.blueprint_id, payload }) + await deployBlueprint({ blueprintId: service.blueprint_id }) + toast('success', 'Blueprint update started') + } catch { + removeOptimisticSettings(confirmedSettings) + } + }, [ + changes, + closePreview, + deployBlueprint, + details, + isValid, + manifestVariablesByName, + payload, + removeOptimisticSettings, + service.blueprint_id, + updateOptimisticSettings, + updateBlueprint, + ]) useEffect(() => { if (step !== 'preview') return From 409132fb33e0b2e07a049edea846e26b69fd6de1 Mon Sep 17 00:00:00 2001 From: Romain Billard Date: Wed, 16 Sep 2026 14:59:24 +0200 Subject: [PATCH 06/29] Remove useless settings sections --- apps/console/src/routeTree.gen.ts | 26 ++++++++ .../settings/blueprint-configuration.tsx | 25 ++++++++ .../service-settings/feature/src/index.ts | 1 + .../service-general-settings.tsx | 9 +-- .../terraform-general-settings.spec.tsx | 11 +++- .../terraform-general-settings.tsx | 60 ++++++++++--------- .../service-settings-layout.spec.tsx | 4 ++ .../service-settings-layout.tsx | 13 +++- 8 files changed, 112 insertions(+), 37 deletions(-) create mode 100644 apps/console/src/routes/_authenticated/organization/$organizationId/project/$projectId/environment/$environmentId/service/$serviceId/settings/blueprint-configuration.tsx 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..b9ca7d79fb2 --- /dev/null +++ b/apps/console/src/routes/_authenticated/organization/$organizationId/project/$projectId/environment/$environmentId/service/$serviceId/settings/blueprint-configuration.tsx @@ -0,0 +1,25 @@ +import { 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, 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 (!organization || !service || !isBlueprintService(service)) { + return null + } + + return +} 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/service-general-settings.tsx b/libs/domains/service-settings/feature/src/lib/service-general-settings/service-general-settings.tsx index d57465125cd..84004cc6b81 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 { isBlueprintService, isEditableService } from '@qovery/domains/services/data-access' +import { isEditableService } from '@qovery/domains/services/data-access' import { type ServiceGeneralData, buildServiceGeneralPayload, @@ -22,7 +22,6 @@ import { SettingsHeading } from '@qovery/shared/console-shared' import { Button, LoaderSpinner, Section, useModal } from '@qovery/shared/ui' import { useDocumentTitle } from '@qovery/shared/util-hooks' import { ApplicationGeneralSettings } from './application-general-settings/application-general-settings' -import { BlueprintGeneralSettings } from './blueprint-general-settings/blueprint-general-settings' import { ContainerGeneralSettings } from './container-general-settings/container-general-settings' import { DatabaseGeneralSettings } from './database-general-settings/database-general-settings' import { HelmGeneralSettings } from './helm-general-settings/helm-general-settings' @@ -135,13 +134,11 @@ function ServiceGeneralSettingsContent({ organization }: ServiceGeneralSettingsP return null } - if (isBlueprintService(service)) { - return - } - const headingDescription = service.serviceType === 'DATABASE' ? 'These general settings allow you to set up the database name, type and version.' + : service.serviceType === 'TERRAFORM' && service.blueprint_id + ? '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) 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..79f93ac33c7 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 @@ -18,6 +18,7 @@ export interface TerraformGeneralSettingsProps { export function TerraformGeneralSettings({ service, organization }: TerraformGeneralSettingsProps) { const { control } = useFormContext() + const isBlueprintService = Boolean(service.blueprint_id) return ( <> @@ -26,36 +27,39 @@ export function TerraformGeneralSettings({ service, organization }: TerraformGen
-
- Source - -
+ {!isBlueprintService && ( + <> +
+ 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 index b25c4320d30..cec909a3e6f 100644 --- 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 @@ -45,7 +45,9 @@ describe('ServiceSettingsLayout', () => { 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', () => { @@ -59,5 +61,7 @@ describe('ServiceSettingsLayout', () => { 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() }) }) 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 40fa83f6691..14f148339c0 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 @@ -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', @@ -161,7 +166,13 @@ export function ServiceSettingsLayout({ children }: ServiceSettingsLayoutProps) ]) .with({ serviceType: 'TERRAFORM' }, () => isBlueprintService(service) - ? [generalLink, resourcesLink, deploymentRestrictionsLink, advancedSettingsLink, dangerZoneLink] + ? [ + generalLink, + blueprintConfigurationLink, + resourcesLink, + advancedSettingsLink, + dangerZoneLink, + ] : [ generalLink, terraformConfigurationLink, From e4b3c6db3f2036bc2ca92dda43388de2402062b8 Mon Sep 17 00:00:00 2001 From: Romain Billard Date: Wed, 16 Sep 2026 16:12:19 +0200 Subject: [PATCH 07/29] Support helm-based blueprint services + small refactor --- .../blueprint-general-settings.spec.tsx | 78 ++++++++++++++++- .../blueprint-general-settings.tsx | 67 ++++++++------ .../helm-general-settings.spec.tsx | 17 ++++ .../helm-general-settings.tsx | 66 +++++++------- .../service-general-settings.tsx | 6 +- .../service-settings-layout.spec.tsx | 34 +++++++- .../service-settings-layout.tsx | 33 ++++--- libs/domains/services/feature/src/index.ts | 2 + .../blueprint-metadata.tsx | 87 +++++++++++++++++++ .../blueprint-update-badge.spec.tsx | 2 +- .../blueprint-update-badge.tsx | 2 +- .../service-version-cell.spec.tsx | 10 +-- .../service-header/service-header.tsx | 83 +----------------- 13 files changed, 323 insertions(+), 164 deletions(-) create mode 100644 libs/domains/services/feature/src/lib/service-blueprint-update-flow/blueprint-metadata.tsx 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 index 64283ddfc85..862c50484fa 100644 --- 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 @@ -1,6 +1,6 @@ import { QueryClient, QueryClientProvider } from '@tanstack/react-query' import { type ReactNode, useState } from 'react' -import { terraformFactoryMock } from '@qovery/shared/factories' +import { helmFactoryMock, terraformFactoryMock } from '@qovery/shared/factories' import { renderWithProviders, screen } from '@qovery/shared/util-tests' import { BlueprintGeneralSettings } from './blueprint-general-settings' @@ -9,6 +9,7 @@ const mockUseBlueprintCatalogServiceManifest = jest.fn() const mockPreviewBlueprintUpdate = jest.fn() const mockUpdateBlueprint = jest.fn() const mockDeployBlueprint = jest.fn() +const mockBlueprintMetadata = jest.fn() const service = { ...terraformFactoryMock(1)[0], blueprint_id: 'blueprint-id', @@ -66,6 +67,16 @@ jest.mock('@qovery/domains/services/feature', () => ({ ), + 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() { @@ -88,7 +99,7 @@ describe('BlueprintGeneralSettings', () => { it('loads the catalog form from the Blueprint tag returned by the existing read endpoint', () => { mockUseBlueprintCatalogServiceManifest.mockReturnValue({ data: [], isLoading: false }) mockUseBlueprint.mockReturnValue({ - data: { name: service.name, tag: 'aws/postgres/17/1.0.0' }, + data: { name: 'aws-rds-postgresql', tag: 'aws/postgres/17/1.0.0' }, isLoading: false, }) @@ -97,11 +108,74 @@ describe('BlueprintGeneralSettings', () => { ) 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' }) ) }) + 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, + }) + 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: undefined, + 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: [ 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 index 3a75e1fd9c2..315b46635fe 100644 --- 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 @@ -1,15 +1,21 @@ import * as Dialog from '@radix-ui/react-dialog' import { useQuery, useQueryClient } from '@tanstack/react-query' -import { type BlueprintManifestVariableField } from 'qovery-typescript-axios' -import { useCallback, useEffect, useMemo, useState } from 'react' +import { + 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 } from '@qovery/domains/services/data-access' import { type BlueprintFieldValue, BlueprintManifestVariableInput, + BlueprintMetadata, + BlueprintMetadataSkeleton, BlueprintPreview, BlueprintSection, OverridesSectionCard, + formatBlueprintName, getDefaultFieldValue, getFallbackServiceIcon, getFieldValidationError, @@ -34,6 +40,10 @@ interface PersistedVariable { interface BlueprintSettingsDetails { name: string tag: string + variables?: PersistedVariable[] + manifest?: { + results: BlueprintManifestResponseResultsInner[] + } } interface OptimisticBlueprintSettings { @@ -58,11 +68,16 @@ function getOptimisticBlueprintSettingsQueryKey(serviceId: string) { } function isBlueprintSettingsDetails(data: unknown): data is BlueprintSettingsDetails { - console.log('data', data) if (!data || typeof data !== 'object') return false const details = data as Partial - return typeof details.name === 'string' && typeof details.tag === 'string' + return ( + typeof details.name === 'string' && + typeof details.tag === 'string' && + (details.variables === undefined || Array.isArray(details.variables)) && + (details.manifest === undefined || + (details.manifest !== null && Array.isArray(details.manifest.results))) + ) } function parseBlueprintTag(tag: string | undefined) { @@ -70,20 +85,12 @@ function parseBlueprintTag(tag: string | undefined) { return { provider, serviceFamily, serviceVersion } } -function getPersistedVariables(service: BlueprintService): PersistedVariable[] { - if (service.serviceType !== 'TERRAFORM') return [] - - return service.terraform_variables_source.tf_vars.flatMap((variable) => - variable.key - ? [ - { - name: variable.key, - value: variable.secret ? null : variable.value ?? null, - is_secret: variable.secret ?? false, - }, - ] - : [] - ) +function getPersistedVariables(details: BlueprintSettingsDetails): PersistedVariable[] { + return details.variables ?? [] +} + +function getBlueprintGitRepository(service: BlueprintService) { + return service.serviceType === 'TERRAFORM' ? service.terraform_files_source?.git?.git_repository : undefined } function useOptimisticBlueprintSettings({ @@ -174,17 +181,19 @@ export function BlueprintGeneralSettings({ service, environmentId, organizationI const details = isBlueprintSettingsDetails(data) ? data : undefined const { provider, serviceFamily, serviceVersion } = parseBlueprintTag(details?.tag) - const { data: fields = [], isLoading: isManifestLoading } = useBlueprintCatalogServiceManifest({ + const manifestFields = details?.manifest?.results + const { data: catalogFields = [], isLoading: isCatalogManifestLoading } = useBlueprintCatalogServiceManifest({ organizationId, provider, serviceFamily, serviceVersion, environmentId, - enabled: Boolean(details), + enabled: Boolean(details && !manifestFields), }) + const fields = manifestFields ?? catalogFields const variablesByName = useMemo( - () => new Map(getPersistedVariables(service).map((variable) => [variable.name, variable])), - [service] + () => new Map((details ? getPersistedVariables(details) : []).map((variable) => [variable.name, variable])), + [details] ) const { optimisticSettings: { values: optimisticChanges, secretNames: optimisticSecretNames }, @@ -343,7 +352,7 @@ export function BlueprintGeneralSettings({ service, environmentId, organizationI step, ]) - if (isLoading || (details && isManifestLoading)) { + if (isLoading || (details && !manifestFields && isCatalogManifestLoading)) { return } @@ -369,8 +378,16 @@ export function BlueprintGeneralSettings({ service, environmentId, organizationI
-

{details.name}

-

{details.tag}

+

{formatBlueprintName(details.name)}

+
+ }> + + +
{requiredFields.map((field, index) => ( 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 84004cc6b81..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,9 +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.' - : service.serviceType === 'TERRAFORM' && service.blueprint_id + : 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.' + : '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-settings-layout/service-settings-layout.spec.tsx b/libs/domains/service-settings/feature/src/lib/service-settings-layout/service-settings-layout.spec.tsx index cec909a3e6f..1f9f3a25d6d 100644 --- 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 @@ -1,5 +1,5 @@ import { type ReactNode } from 'react' -import { terraformFactoryMock } from '@qovery/shared/factories' +import { helmFactoryMock, terraformFactoryMock } from '@qovery/shared/factories' import { renderWithProviders, screen } from '@qovery/shared/util-tests' import { ServiceSettingsLayout } from './service-settings-layout' @@ -64,4 +64,36 @@ describe('ServiceSettingsLayout', () => { 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.getByText('Resources')).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 14f148339c0..2256bbe7296 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 @@ -120,6 +120,13 @@ 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 blueprintSettingsLinks = [ + generalLink, + blueprintConfigurationLink, + resourcesLink, + advancedSettingsLink, + dangerZoneLink, + ] const linksSettings: SidebarSettingsItem[] = isAgenticWorkflow(service) ? [ @@ -155,24 +162,22 @@ export function ServiceSettingsLayout({ children }: ServiceSettingsLayoutProps) advancedSettingsLink, dangerZoneLink, ]) - .with({ serviceType: 'HELM' }, (helm) => [ - generalLink, - valuesOverrideLink, - networkingLink, - domainLink, - ...(isHelmGitSource(helm.source) ? [deploymentRestrictionsLink] : []), - advancedSettingsLink, - dangerZoneLink, - ]) - .with({ serviceType: 'TERRAFORM' }, () => - isBlueprintService(service) - ? [ + .with({ serviceType: 'HELM' }, (helm) => + isBlueprintService(helm) + ? blueprintSettingsLinks + : [ generalLink, - blueprintConfigurationLink, - resourcesLink, + valuesOverrideLink, + networkingLink, + domainLink, + ...(isHelmGitSource(helm.source) ? [deploymentRestrictionsLink] : []), advancedSettingsLink, dangerZoneLink, ] + ) + .with({ serviceType: 'TERRAFORM' }, () => + isBlueprintService(service) + ? blueprintSettingsLinks : [ generalLink, terraformConfigurationLink, diff --git a/libs/domains/services/feature/src/index.ts b/libs/domains/services/feature/src/index.ts index e0ebde7d89f..d91a66d29c1 100644 --- a/libs/domains/services/feature/src/index.ts +++ b/libs/domains/services/feature/src/index.ts @@ -35,6 +35,7 @@ export * from './lib/hooks/use-blueprint/use-blueprint' 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' @@ -122,6 +123,7 @@ export * from './lib/hooks/use-deploy-blueprint/use-deploy-blueprint' 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-blueprint-update-flow/blueprint-update-flow' +export * from './lib/service-blueprint-update-flow/blueprint-metadata' export { getFallbackServiceIcon } 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' 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..d018d4499ea --- /dev/null +++ b/libs/domains/services/feature/src/lib/service-blueprint-update-flow/blueprint-metadata.tsx @@ -0,0 +1,87 @@ +import { 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 }: { gitRepository?: ApplicationGitRepository }) { + return ( + <> + + {gitRepository && } + + + ) +} + +export 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 && ( + + )} + + ) +} 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..11ebafa528a 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 @@ -74,7 +74,7 @@ describe('BlueprintUpdateBadge', () => { /> ) - await userEvent.click(screen.getByRole('button', { name: 'Update available' })) + await userEvent.click(screen.getByRole('button', { name: 'Blueprint update available' })) expect(mockNavigate).toHaveBeenCalledWith({ to: '/organization/$organizationId/project/$projectId/environment/$environmentId/service/$serviceId/update/blueprint', 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..0723522ae12 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 @@ -66,7 +66,7 @@ export function BlueprintUpdateBadge({ > - Update available + Blueprint update available ) 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..1678955edd3 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 @@ -56,7 +56,7 @@ const withBlueprintTag = (service: Terraform, tag: string) => describe('ServiceVersionCell', () => { it.each([ { isUpToDate: true, status: 'Up to date' }, - { isUpToDate: false, status: 'Update available' }, + { 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.tsx b/libs/domains/services/feature/src/lib/service-overview/service-header/service-header.tsx index cd56f3c399d..1c8292541fe 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 @@ -24,7 +24,6 @@ import { ExternalLink, Heading, Icon, - Skeleton, Tooltip, Truncate, toast, @@ -35,13 +34,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' @@ -165,84 +162,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({ From 488ea3f13565e279c4e2875fbb9c64eb3b6917ea Mon Sep 17 00:00:00 2001 From: Romain Billard Date: Thu, 17 Sep 2026 15:51:38 +0200 Subject: [PATCH 08/29] Align with latest backend changes --- .../blueprint-general-settings.spec.tsx | 10 ++ .../blueprint-general-settings.tsx | 132 +++++++++++++++--- .../src/lib/domains-services-data-access.ts | 8 ++ libs/domains/services/feature/src/index.ts | 1 + .../use-blueprint-variables.ts | 19 +++ .../blueprint-update-badge.spec.tsx | 2 +- .../blueprint-update-badge.tsx | 2 +- .../service-version-cell.spec.tsx | 2 +- .../service-header/service-header.spec.tsx | 12 +- package.json | 2 +- yarn.lock | 10 +- 11 files changed, 164 insertions(+), 36 deletions(-) create mode 100644 libs/domains/services/feature/src/lib/hooks/use-blueprint-variables/use-blueprint-variables.ts 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 index 862c50484fa..35384409a29 100644 --- 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 @@ -5,6 +5,7 @@ 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() @@ -25,6 +26,7 @@ jest.mock('@qovery/domains/environments/feature', () => ({ 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 }), @@ -96,6 +98,10 @@ function BlueprintGeneralSettingsHarness() { } describe('BlueprintGeneralSettings', () => { + beforeEach(() => { + mockUseBlueprintVariables.mockReturnValue({ data: [], isLoading: false }) + }) + it('loads the catalog form from the Blueprint tag returned by the existing read endpoint', () => { mockUseBlueprintCatalogServiceManifest.mockReturnValue({ data: [], isLoading: false }) mockUseBlueprint.mockReturnValue({ @@ -144,6 +150,10 @@ describe('BlueprintGeneralSettings', () => { }, isLoading: false, }) + mockUseBlueprintVariables.mockReturnValue({ + data: [{ name: 'replicas', value: '3', is_secret: false }], + isLoading: false, + }) mockPreviewBlueprintUpdate.mockResolvedValue({ preview_id: 'preview-id' }) const { userEvent } = renderWithProviders( 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 index 315b46635fe..e39c5a2eca4 100644 --- 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 @@ -1,6 +1,7 @@ import * as Dialog from '@radix-ui/react-dialog' import { useQuery, useQueryClient } from '@tanstack/react-query' import { + type BlueprintConfigurationVariable, type BlueprintManifestResponseResultsInner, type BlueprintManifestVariableField, } from 'qovery-typescript-axios' @@ -24,6 +25,7 @@ import { isRequiredVariableField, useBlueprint, useBlueprintCatalogServiceManifest, + useBlueprintVariables, useDeployBlueprint, usePreviewBlueprintUpdate, useUpdateBlueprint, @@ -31,16 +33,9 @@ import { import { SettingsHeading } from '@qovery/shared/console-shared' import { Button, LoaderSpinner, Section, toast, useModal } from '@qovery/shared/ui' -interface PersistedVariable { - name: string - value: string | null - is_secret: boolean -} - interface BlueprintSettingsDetails { name: string tag: string - variables?: PersistedVariable[] manifest?: { results: BlueprintManifestResponseResultsInner[] } @@ -74,9 +69,7 @@ function isBlueprintSettingsDetails(data: unknown): data is BlueprintSettingsDet return ( typeof details.name === 'string' && typeof details.tag === 'string' && - (details.variables === undefined || Array.isArray(details.variables)) && - (details.manifest === undefined || - (details.manifest !== null && Array.isArray(details.manifest.results))) + (details.manifest === undefined || (details.manifest !== null && Array.isArray(details.manifest.results))) ) } @@ -85,10 +78,6 @@ function parseBlueprintTag(tag: string | undefined) { return { provider, serviceFamily, serviceVersion } } -function getPersistedVariables(details: BlueprintSettingsDetails): PersistedVariable[] { - return details.variables ?? [] -} - function getBlueprintGitRepository(service: BlueprintService) { return service.serviceType === 'TERRAFORM' ? service.terraform_files_source?.git?.git_repository : undefined } @@ -98,7 +87,7 @@ function useOptimisticBlueprintSettings({ persistedVariables, }: { serviceId: string - persistedVariables: Map + persistedVariables: Map }) { const queryClient = useQueryClient() const queryKey = useMemo(() => getOptimisticBlueprintSettingsQueryKey(serviceId), [serviceId]) @@ -159,9 +148,20 @@ function useOptimisticBlueprintSettings({ return { optimisticSettings, update, remove } } -export function BlueprintGeneralSettings({ service, environmentId, organizationId }: BlueprintGeneralSettingsProps) { +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({ @@ -182,18 +182,19 @@ export function BlueprintGeneralSettings({ service, environmentId, organizationI 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: Boolean(details && !manifestFields), + enabled: isCatalogManifestEnabled, }) const fields = manifestFields ?? catalogFields const variablesByName = useMemo( - () => new Map((details ? getPersistedVariables(details) : []).map((variable) => [variable.name, variable])), - [details] + () => new Map(persistedVariables.map((variable) => [variable.name, variable])), + [persistedVariables] ) const { optimisticSettings: { values: optimisticChanges, secretNames: optimisticSecretNames }, @@ -229,9 +230,98 @@ export function BlueprintGeneralSettings({ service, environmentId, organizationI ), [fields, variablesByName] ) - const values = { ...initialValues, ...optimisticChanges, ...changes } + const values = useMemo( + () => ({ ...initialValues, ...optimisticChanges, ...changes }), + [changes, initialValues, optimisticChanges] + ) const requiredFields = fields.filter(isRequiredVariableField) const optionalFields = fields.filter(isOptionalVariableField) + const formValueSources = useMemo( + () => + fields + .filter((field): field is BlueprintManifestVariableField => field.kind === 'variable') + .map((field) => { + const persistedVariable = variablesByName.get(field.name) + + return { + field: field.name, + schema: manifestFields ? 'useBlueprint -> response.manifest.results' : 'useBlueprintCatalogServiceManifest', + value: + field.name in changes + ? 'local useState(changes)' + : field.name in optimisticChanges + ? 'React Query optimistic Blueprint Settings cache' + : persistedVariable + ? 'useBlueprintVariables -> response[]' + : 'getDefaultFieldValue(field)', + isSecret: field.is_secret, + hasValue: values[field.name] !== undefined && values[field.name] !== '', + } + }), + [changes, fields, manifestFields, optimisticChanges, values, variablesByName] + ) + const blueprintEndpoint = `/blueprint/${encodeURIComponent(service.blueprint_id)}` + const serviceEndpoint = + service.serviceType === 'HELM' + ? `/helm/${encodeURIComponent(service.id)}` + : `/terraform/${encodeURIComponent(service.id)}` + const catalogManifestEndpoint = `/organization/${encodeURIComponent( + organizationId + )}/blueprint/catalog/${encodeURIComponent(provider)}/${encodeURIComponent(serviceFamily)}/${encodeURIComponent( + serviceVersion + )}/manifest?environmentId=${encodeURIComponent(environmentId)}` + + useEffect(() => { + console.groupCollapsed('Blueprint configuration form data sources') + console.table([ + { + hook: 'useService (settings route)', + endpoint: `GET ${serviceEndpoint}`, + enabled: true, + provides: 'Service prop for identity and metadata; it does not provide form field values', + }, + { + hook: 'useBlueprint', + endpoint: `GET ${blueprintEndpoint}`, + enabled: true, + provides: 'Blueprint details and manifest fields', + }, + { + hook: 'useBlueprintVariables', + endpoint: `GET ${blueprintEndpoint}/variables`, + enabled: true, + provides: 'Persisted Blueprint variables; secret values are omitted by the API', + }, + { + hook: 'useBlueprintCatalogServiceManifest', + endpoint: `GET ${catalogManifestEndpoint}`, + enabled: isCatalogManifestEnabled, + provides: 'Catalog manifest fields only; used when useBlueprint has no manifest', + }, + ]) + console.table(formValueSources) + console.log('useService (settings route) payload', service) + console.log('useBlueprint payload', data) + console.log('useBlueprintVariables payload', persistedVariables) + console.log('useBlueprintCatalogServiceManifest payload', catalogFields) + console.log('React Query optimistic Blueprint Settings payload', optimisticChanges) + console.log('local useState(changes) payload', changes) + console.log('computed Blueprint form values payload', values) + console.groupEnd() + }, [ + blueprintEndpoint, + catalogFields, + catalogManifestEndpoint, + changes, + data, + formValueSources, + isCatalogManifestEnabled, + optimisticChanges, + persistedVariables, + service, + serviceEndpoint, + values, + ]) const isValid = requiredFields.every( (field) => isFieldValid(field, values[field.name]) || @@ -352,7 +442,7 @@ export function BlueprintGeneralSettings({ service, environmentId, organizationI step, ]) - if (isLoading || (details && !manifestFields && isCatalogManifestLoading)) { + if (isLoading || isVariablesLoading || (details && !manifestFields && isCatalogManifestLoading)) { return } 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..c354b7f5d87 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, @@ -337,6 +338,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 d91a66d29c1..ed7e7614c03 100644 --- a/libs/domains/services/feature/src/index.ts +++ b/libs/domains/services/feature/src/index.ts @@ -32,6 +32,7 @@ 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' 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..12038ec563a --- /dev/null +++ b/libs/domains/services/feature/src/lib/hooks/use-blueprint-variables/use-blueprint-variables.ts @@ -0,0 +1,19 @@ +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, + staleTime: 0, + }) +} + +export default useBlueprintVariables 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 11ebafa528a..beffcb79979 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 @@ -47,7 +47,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 () => { 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 0723522ae12..22999ed3ca8 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 @@ -35,7 +35,7 @@ export function BlueprintUpdateBadge({ return ( - Up to date + Blueprint is up to date ) } 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 1678955edd3..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,7 +55,7 @@ const withBlueprintTag = (service: Terraform, tag: string) => describe('ServiceVersionCell', () => { it.each([ - { isUpToDate: true, status: 'Up to date' }, + { 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({ 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..58f2c758743 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 @@ -467,7 +467,7 @@ describe('ServiceHeader', () => { ) expect(screen.queryByText('GitHub')).not.toBeInTheDocument() expect(screen.queryByText('main')).not.toBeInTheDocument() - expect(screen.getByText('Up to date')).toBeInTheDocument() + expect(screen.getByText('Blueprint is up to date')).toBeInTheDocument() expect(screen.queryByText('Update available')).not.toBeInTheDocument() }) @@ -490,7 +490,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', () => { @@ -500,7 +500,7 @@ describe('ServiceHeader', () => { 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.queryByText('Blueprint is up to date')).not.toBeInTheDocument() expect(screen.queryByText('Update available')).not.toBeInTheDocument() }) @@ -555,7 +555,7 @@ describe('ServiceHeader', () => { await userEvent.click(screen.getByRole('button', { name: /update available/i })) - expect(screen.queryByText('Up to date')).not.toBeInTheDocument() + expect(screen.queryByText('Blueprint is up to date')).not.toBeInTheDocument() expect(mockNavigate).toHaveBeenCalledWith({ to: '/organization/$organizationId/project/$projectId/environment/$environmentId/service/$serviceId/update/blueprint', params: { @@ -588,7 +588,7 @@ describe('ServiceHeader', () => { await userEvent.click(screen.getByRole('button', { name: /update available/i })) - expect(screen.queryByText('Up to date')).not.toBeInTheDocument() + expect(screen.queryByText('Blueprint is up to date')).not.toBeInTheDocument() expect( await screen.findByRole('heading', { name: 'aws-s3-bucket blueprint update from 1.0 to 2.0' }) ).toBeInTheDocument() @@ -623,7 +623,7 @@ describe('ServiceHeader', () => { 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/package.json b/package.json index b8b6166df49..ea387a2fd3b 100644 --- a/package.json +++ b/package.json @@ -81,7 +81,7 @@ "mermaid": "11.6.0", "monaco-editor": "0.53.0", "posthog-js": "1.345.1", - "qovery-typescript-axios": "1.1.979", + "qovery-typescript-axios": "1.1.980", "react": "18.3.1", "react-country-flag": "3.0.2", "react-datepicker": "4.12.0", diff --git a/yarn.lock b/yarn.lock index b95894ed7ef..c35b3819fdf 100644 --- a/yarn.lock +++ b/yarn.lock @@ -6438,7 +6438,7 @@ __metadata: prettier: 3.2.5 prettier-plugin-tailwindcss: 0.5.14 pretty-quick: 4.0.0 - qovery-typescript-axios: 1.1.979 + qovery-typescript-axios: 1.1.980 qovery-ws-typescript-axios: 0.1.646 react: 18.3.1 react-country-flag: 3.0.2 @@ -25964,12 +25964,12 @@ __metadata: languageName: node linkType: hard -"qovery-typescript-axios@npm:1.1.979": - version: 1.1.979 - resolution: "qovery-typescript-axios@npm:1.1.979" +"qovery-typescript-axios@npm:1.1.980": + version: 1.1.980 + resolution: "qovery-typescript-axios@npm:1.1.980" dependencies: axios: 1.18.1 - checksum: 412bf2ca0adea62b3e4bd20461052c37f8b179a8cb84d327d4f119180377db7d73a2cc1b8ba7bce92b0ee69ba0afbf96a75cc29d9fcb23f48604719849f2e84e + checksum: 696b175a1356a2dfd125244fcb273f035235d0b1ebec155f11875bc489eba1b81db9de94141aaca5060e50e5e77d8ad76912886080f98bc191ec66b2ba17d450 languageName: node linkType: hard From e7c14d80d94b7fa6b50cafc185974009ae7bf8c2 Mon Sep 17 00:00:00 2001 From: Romain Billard Date: Thu, 17 Sep 2026 15:55:20 +0200 Subject: [PATCH 09/29] Remove blueprint settings debug logging --- .../blueprint-general-settings.tsx | 86 ------------------- 1 file changed, 86 deletions(-) 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 index e39c5a2eca4..dc2710109e1 100644 --- 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 @@ -236,92 +236,6 @@ function BlueprintGeneralSettingsContent({ service, environmentId, organizationI ) const requiredFields = fields.filter(isRequiredVariableField) const optionalFields = fields.filter(isOptionalVariableField) - const formValueSources = useMemo( - () => - fields - .filter((field): field is BlueprintManifestVariableField => field.kind === 'variable') - .map((field) => { - const persistedVariable = variablesByName.get(field.name) - - return { - field: field.name, - schema: manifestFields ? 'useBlueprint -> response.manifest.results' : 'useBlueprintCatalogServiceManifest', - value: - field.name in changes - ? 'local useState(changes)' - : field.name in optimisticChanges - ? 'React Query optimistic Blueprint Settings cache' - : persistedVariable - ? 'useBlueprintVariables -> response[]' - : 'getDefaultFieldValue(field)', - isSecret: field.is_secret, - hasValue: values[field.name] !== undefined && values[field.name] !== '', - } - }), - [changes, fields, manifestFields, optimisticChanges, values, variablesByName] - ) - const blueprintEndpoint = `/blueprint/${encodeURIComponent(service.blueprint_id)}` - const serviceEndpoint = - service.serviceType === 'HELM' - ? `/helm/${encodeURIComponent(service.id)}` - : `/terraform/${encodeURIComponent(service.id)}` - const catalogManifestEndpoint = `/organization/${encodeURIComponent( - organizationId - )}/blueprint/catalog/${encodeURIComponent(provider)}/${encodeURIComponent(serviceFamily)}/${encodeURIComponent( - serviceVersion - )}/manifest?environmentId=${encodeURIComponent(environmentId)}` - - useEffect(() => { - console.groupCollapsed('Blueprint configuration form data sources') - console.table([ - { - hook: 'useService (settings route)', - endpoint: `GET ${serviceEndpoint}`, - enabled: true, - provides: 'Service prop for identity and metadata; it does not provide form field values', - }, - { - hook: 'useBlueprint', - endpoint: `GET ${blueprintEndpoint}`, - enabled: true, - provides: 'Blueprint details and manifest fields', - }, - { - hook: 'useBlueprintVariables', - endpoint: `GET ${blueprintEndpoint}/variables`, - enabled: true, - provides: 'Persisted Blueprint variables; secret values are omitted by the API', - }, - { - hook: 'useBlueprintCatalogServiceManifest', - endpoint: `GET ${catalogManifestEndpoint}`, - enabled: isCatalogManifestEnabled, - provides: 'Catalog manifest fields only; used when useBlueprint has no manifest', - }, - ]) - console.table(formValueSources) - console.log('useService (settings route) payload', service) - console.log('useBlueprint payload', data) - console.log('useBlueprintVariables payload', persistedVariables) - console.log('useBlueprintCatalogServiceManifest payload', catalogFields) - console.log('React Query optimistic Blueprint Settings payload', optimisticChanges) - console.log('local useState(changes) payload', changes) - console.log('computed Blueprint form values payload', values) - console.groupEnd() - }, [ - blueprintEndpoint, - catalogFields, - catalogManifestEndpoint, - changes, - data, - formValueSources, - isCatalogManifestEnabled, - optimisticChanges, - persistedVariables, - service, - serviceEndpoint, - values, - ]) const isValid = requiredFields.every( (field) => isFieldValid(field, values[field.name]) || From dadef449147303536559e0da0f9e9c50dc8ee195 Mon Sep 17 00:00:00 2001 From: Romain Billard Date: Thu, 17 Sep 2026 16:31:16 +0200 Subject: [PATCH 10/29] "Overrides" section is collapsed by default --- .../blueprint-general-settings.spec.tsx | 16 +++++++++++++++- .../blueprint-general-settings.tsx | 7 ++++++- 2 files changed, 21 insertions(+), 2 deletions(-) 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 index 35384409a29..a8714b35e7a 100644 --- 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 @@ -57,7 +57,15 @@ jest.mock('@qovery/domains/services/feature', () => ({ {children}
), - OverridesSectionCard: ({ children }: { children: ReactNode }) =>
{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 }) => ( <> @@ -209,6 +217,9 @@ describe('BlueprintGeneralSettings', () => { ) + expect(screen.getByRole('button', { name: 'Configure' })).toBeInTheDocument() + expect(screen.queryByText('Current value:')).not.toBeInTheDocument() + 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' })) @@ -260,6 +271,7 @@ describe('BlueprintGeneralSettings', () => { 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' })) @@ -269,6 +281,7 @@ describe('BlueprintGeneralSettings', () => { 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() }) @@ -295,6 +308,7 @@ describe('BlueprintGeneralSettings', () => { ) + 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' })) 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 index dc2710109e1..28115351c40 100644 --- 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 @@ -175,6 +175,7 @@ function BlueprintGeneralSettingsContent({ service, environmentId, organizationI 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) @@ -405,7 +406,11 @@ function BlueprintGeneralSettingsContent({ service, environmentId, organizationI /> ))} - undefined}> + setIsOverridesExpanded(true)} + > {optionalFields.map((field) => ( Date: Thu, 17 Sep 2026 17:55:30 +0200 Subject: [PATCH 11/29] UI improvements --- .../blueprint-metadata.tsx | 58 +++++++--- .../blueprint-update-preview-step.spec.tsx | 20 ++++ .../blueprint-update-preview-step.tsx | 14 ++- .../service-header/service-header.spec.tsx | 103 ++---------------- .../service-header/service-header.tsx | 29 ++--- 5 files changed, 98 insertions(+), 126 deletions(-) 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 index d018d4499ea..ebb3c3a3e25 100644 --- 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 @@ -1,4 +1,4 @@ -import { useParams } from '@tanstack/react-router' +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' @@ -34,12 +34,22 @@ function BlueprintRepository({ gitRepository }: { gitRepository: ApplicationGitR ) } -export function BlueprintMetadataSkeleton({ gitRepository }: { gitRepository?: ApplicationGitRepository }) { +export function BlueprintMetadataSkeleton({ + gitRepository, + showVersion = true, + showRepository = true, + showUpdateBadge = true, +}: { + gitRepository?: ApplicationGitRepository + showVersion?: boolean + showRepository?: boolean + showUpdateBadge?: boolean +}) { return ( <> - - {gitRepository && } - + {showVersion && } + {showRepository && gitRepository && } + {showUpdateBadge && } ) } @@ -48,12 +58,20 @@ 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 = '' } = useParams({ strict: false }) + 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. @@ -64,17 +82,29 @@ export function BlueprintMetadata({ throwOnError: false, }) const currentVersion = tag ? getBlueprintServiceVersion(tag) : undefined + const versionBadge = currentVersion && currentVersion !== 'default' && ( + + + v{currentVersion} + + ) return ( <> - {currentVersion && currentVersion !== 'default' && ( - - - v{currentVersion} - - )} - {gitRepository && } - {blueprintUpdate && ( + {showVersion && + (linkVersionToSettings ? ( + + {versionBadge} + + ) : ( + versionBadge + ))} + {showRepository && gitRepository && } + {showUpdateBadge && blueprintUpdate && ( { 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-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' } }) 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 16232223e46..834f7dea3b1 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 @@ -141,13 +141,15 @@ function BlueprintUpdatePreviewContent({ : 'h-[min(75vh,calc(100vh-320px))] min-h-[260px]' : 'min-h-[180px]' + const isModalPending = layout === 'modal' && outcome.type === 'pending' const body = ( -
+
Preview changes -
+
Raw output
{match(outcome) .with({ type: 'pending' }, () => ) @@ -208,7 +210,7 @@ function BlueprintUpdatePreviewContent({ return (
-
{body}
+
{body}
{footer}
@@ -263,7 +265,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(() => { @@ -275,7 +277,7 @@ function BlueprintUpdateRawOutputSkeleton() { }, []) return ( -
+
{PREVIEW_LOADING_MESSAGES[messageIndex]} 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 58f2c758743..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('Blueprint is 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() }) @@ -499,7 +501,7 @@ describe('ServiceHeader', () => { renderServiceHeader('terraform-mock') expect(screen.getByRole('heading', { name: 'aws-s3-bucket' })).toBeInTheDocument() - expect(screen.getByRole('link', { name: /qovery-blueprints/ })).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('Blueprint is 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('Blueprint is 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,7 +533,7 @@ 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', () => { 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 1c8292541fe..6364de4b2a1 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 @@ -94,6 +94,7 @@ 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 = 'blueprint_id' in service ? service.blueprint_id : undefined useClusterRunningStatusSocket({ organizationId, clusterId: environment.cluster_id }) @@ -134,6 +135,20 @@ function ServiceHeaderIdentity({ environment, service }: ServiceHeaderIdentityPr )} + {blueprintId && ( + <> + + }> + + + + )}
@@ -217,14 +232,7 @@ function ServiceHeaderMetadata({ service }: ServiceHeaderMetadataProps) { return (
- {gitRepository && - (blueprintId ? ( - }> - - - ) : ( - - ))} + {gitRepository && !blueprintId && } {isArgoCdService && 'manifest_revision' in service && service.manifest_revision && (
- {!isBlueprintService && ( + {!isBlueprint && ( <>
Source From 4f04ab3f3aa1f3de3e291c9625bf939bd5a2fc91 Mon Sep 17 00:00:00 2001 From: Romain Billard Date: Fri, 18 Sep 2026 11:57:00 +0200 Subject: [PATCH 22/29] Hide blueprint version badge when unavailable --- .../src/lib/service-blueprint-update-flow/blueprint-metadata.tsx | 1 + 1 file changed, 1 insertion(+) 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 index ebb3c3a3e25..ee4fd80f65b 100644 --- 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 @@ -92,6 +92,7 @@ export function BlueprintMetadata({ return ( <> {showVersion && + versionBadge && (linkVersionToSettings ? ( Date: Fri, 18 Sep 2026 13:59:28 +0200 Subject: [PATCH 23/29] Support Helm blueprint repository metadata - Share blueprint repository resolution across settings and service header - Pass Helm Git source repositories to blueprint metadata --- .../blueprint-general-settings.spec.tsx | 2 +- .../blueprint-general-settings.tsx | 6 +----- .../src/lib/domains-services-data-access.ts | 14 +++++++++++++- .../service-header/service-header.tsx | 10 +++++++++- 4 files changed, 24 insertions(+), 8 deletions(-) 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 index 4c1747bdce3..66c8d7431c9 100644 --- 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 @@ -189,7 +189,7 @@ describe('BlueprintGeneralSettings', () => { expect(mockBlueprintMetadata).toHaveBeenCalledWith( expect.objectContaining({ blueprintId: 'helm-blueprint-id', - gitRepository: undefined, + gitRepository: helmBlueprintService.source.git.git_repository, service: helmBlueprintService, }) ) 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 index 59e8b17aa3d..a9c34c842d0 100644 --- 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 @@ -8,7 +8,7 @@ import { } from 'qovery-typescript-axios' import { Suspense, useCallback, useEffect, useMemo, useState } from 'react' import { useEnvironment } from '@qovery/domains/environments/feature' -import { type BlueprintService } from '@qovery/domains/services/data-access' +import { type BlueprintService, getBlueprintGitRepository } from '@qovery/domains/services/data-access' import { type BlueprintFieldValue, BlueprintManifestVariableInput, @@ -79,10 +79,6 @@ function parseBlueprintTag(tag: string | undefined) { return { provider, serviceFamily, serviceVersion } } -function getBlueprintGitRepository(service: BlueprintService) { - return service.serviceType === 'TERRAFORM' ? service.terraform_files_source?.git?.git_repository : undefined -} - function useOptimisticBlueprintSettings({ serviceId, persistedVariables, 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 c354b7f5d87..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 @@ -95,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() @@ -245,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' } 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 6364de4b2a1..a8ba1e66a03 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, @@ -95,6 +101,7 @@ function ServiceHeaderIdentity({ environment, service }: ServiceHeaderIdentityPr const isArgoCdService = isArgoCd(service) const isAgenticWorkflowService = isAgenticWorkflow(service) const blueprintId = 'blueprint_id' in service ? service.blueprint_id : undefined + const blueprintGitRepository = isBlueprintService(service) ? getBlueprintGitRepository(service) : undefined useClusterRunningStatusSocket({ organizationId, clusterId: environment.cluster_id }) @@ -141,6 +148,7 @@ function ServiceHeaderIdentity({ environment, service }: ServiceHeaderIdentityPr }> Date: Fri, 18 Sep 2026 14:38:46 +0200 Subject: [PATCH 24/29] Hide resources for Helm blueprint services - Keep Resources available for Terraform blueprints - Update sidebar and blueprint settings coverage --- .../blueprint-general-settings.spec.tsx | 5 +++++ .../service-settings-layout.spec.tsx | 2 +- .../service-settings-layout/service-settings-layout.tsx | 7 ++++--- 3 files changed, 10 insertions(+), 4 deletions(-) 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 index 66c8d7431c9..651e1260443 100644 --- 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 @@ -224,6 +224,10 @@ describe('BlueprintGeneralSettings', () => { 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( @@ -233,6 +237,7 @@ describe('BlueprintGeneralSettings', () => { 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' })) 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 index 1f9f3a25d6d..91a97122cef 100644 --- 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 @@ -75,7 +75,7 @@ describe('ServiceSettingsLayout', () => { ) expect(screen.getByText('Blueprint configuration')).toBeInTheDocument() - expect(screen.getByText('Resources')).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() 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 2256bbe7296..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 @@ -120,13 +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 blueprintSettingsLinks = [ + const blueprintTerraformSettingsLinks = [ generalLink, blueprintConfigurationLink, resourcesLink, advancedSettingsLink, dangerZoneLink, ] + const blueprintHelmSettingsLinks = [generalLink, blueprintConfigurationLink, advancedSettingsLink, dangerZoneLink] const linksSettings: SidebarSettingsItem[] = isAgenticWorkflow(service) ? [ @@ -164,7 +165,7 @@ export function ServiceSettingsLayout({ children }: ServiceSettingsLayoutProps) ]) .with({ serviceType: 'HELM' }, (helm) => isBlueprintService(helm) - ? blueprintSettingsLinks + ? blueprintHelmSettingsLinks : [ generalLink, valuesOverrideLink, @@ -177,7 +178,7 @@ export function ServiceSettingsLayout({ children }: ServiceSettingsLayoutProps) ) .with({ serviceType: 'TERRAFORM' }, () => isBlueprintService(service) - ? blueprintSettingsLinks + ? blueprintTerraformSettingsLinks : [ generalLink, terraformConfigurationLink, From e545e68944494653ef59be1529242646d0cef671 Mon Sep 17 00:00:00 2001 From: Romain Billard Date: Fri, 18 Sep 2026 14:47:13 +0200 Subject: [PATCH 25/29] Keep blueprint settings after deployment failure - Roll back optimistic values only when the settings update fails - Preserve persisted values when deployment fails --- .../blueprint-general-settings.spec.tsx | 35 +++++++++++++++++++ .../blueprint-general-settings.tsx | 19 ++++++---- 2 files changed, 48 insertions(+), 6 deletions(-) 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 index 651e1260443..930c0a4cefb 100644 --- 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 @@ -278,6 +278,41 @@ describe('BlueprintGeneralSettings', () => { }) }) + 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: [ 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 index a9c34c842d0..c71af76d9ad 100644 --- 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 @@ -311,15 +311,22 @@ function BlueprintGeneralSettingsContent({ service, environmentId, organizationI try { await updateBlueprint({ blueprintId: service.blueprint_id, payload }) - posthog.capture('blueprint_settings_updated', { - blueprint_id: service.blueprint_id, - service_id: service.id, - service_type: service.serviceType, - }) + } 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 { - removeOptimisticSettings(confirmedSettings) + // The settings update already succeeded, so keep the optimistic values visible. } }, [ changes, From 6f256f9d3d4cff62cfe52bf27132647c75b1cf4a Mon Sep 17 00:00:00 2001 From: Romain Billard Date: Fri, 18 Sep 2026 14:56:29 +0200 Subject: [PATCH 26/29] Fix blueprint preview modal pending layout - Remove the fixed minimum height while preview output is pending - Use merged Tailwind classes and add regression coverage --- .../blueprint-update-preview-step.spec.tsx | 1 + .../blueprint-update-preview-step.tsx | 25 ++++++++++++------- 2 files changed, 17 insertions(+), 9 deletions(-) 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 1cae4e1168f..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 @@ -189,6 +189,7 @@ describe('BlueprintUpdatePreviewStep', () => { ) 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') }) 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 834f7dea3b1..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, @@ -134,22 +135,28 @@ function BlueprintUpdatePreviewContent({ layout?: 'page' | 'modal' }) { const canConfirm = outcome.type === 'diff' || outcome.type === 'no-changes' + const isModalPending = layout === 'modal' && outcome.type === 'pending' const rawOutputContainerHeightClassName = outcome.type === 'diff' ? layout === 'modal' ? 'min-h-[220px]' : 'h-[min(75vh,calc(100vh-320px))] min-h-[260px]' - : 'min-h-[180px]' - - const isModalPending = layout === 'modal' && outcome.type === 'pending' + : isModalPending + ? '' + : 'min-h-[180px]' const body = ( -
+
Preview changes -
+
Raw output
{match(outcome) .with({ type: 'pending' }, () => ) @@ -183,11 +190,11 @@ function BlueprintUpdatePreviewContent({ ) const footer = (