From c5da5c33a5ce5607401f13ac37843727d67da921 Mon Sep 17 00:00:00 2001 From: Romain Billard Date: Mon, 6 Jul 2026 16:00:38 +0200 Subject: [PATCH 01/35] feat(blueprints): add basis for blueprint update flow --- apps/console/src/routeTree.gen.ts | 25 + .../service/$serviceId/update/blueprint.tsx | 37 + .../organization/$organizationId/route.tsx | 1 + .../_authenticated/organization/route.tsx | 1 + .../src/lib/domains-services-data-access.ts | 19 + libs/domains/services/feature/src/index.ts | 3 + .../use-preview-blueprint-update.ts | 12 + .../use-update-blueprint.ts | 37 + .../service-header/service-header.spec.tsx | 69 +- .../service-header/service-header.tsx | 106 ++- .../blueprint/blueprint-update-flow.tsx | 661 ++++++++++++++++++ 11 files changed, 960 insertions(+), 11 deletions(-) create mode 100644 apps/console/src/routes/_authenticated/organization/$organizationId/project/$projectId/environment/$environmentId/service/$serviceId/update/blueprint.tsx create mode 100644 libs/domains/services/feature/src/lib/hooks/use-preview-blueprint-update/use-preview-blueprint-update.ts create mode 100644 libs/domains/services/feature/src/lib/hooks/use-update-blueprint/use-update-blueprint.ts create mode 100644 libs/domains/services/feature/src/lib/service-update-flow/blueprint/blueprint-update-flow.tsx diff --git a/apps/console/src/routeTree.gen.ts b/apps/console/src/routeTree.gen.ts index 866abbfcc35..55b3852e0cd 100644 --- a/apps/console/src/routeTree.gen.ts +++ b/apps/console/src/routeTree.gen.ts @@ -163,6 +163,7 @@ import { Route as AuthenticatedOrganizationOrganizationIdProjectProjectIdEnviron import { Route as AuthenticatedOrganizationOrganizationIdProjectProjectIdEnvironmentEnvironmentIdServiceCreateSlugGeneralRouteImport } from './routes/_authenticated/organization/$organizationId/project/$projectId/environment/$environmentId/service/create/$slug/general' import { Route as AuthenticatedOrganizationOrganizationIdProjectProjectIdEnvironmentEnvironmentIdServiceServiceIdVariablesExternalSecretsRouteImport } from './routes/_authenticated/organization/$organizationId/project/$projectId/environment/$environmentId/service/$serviceId/variables/external-secrets' import { Route as AuthenticatedOrganizationOrganizationIdProjectProjectIdEnvironmentEnvironmentIdServiceServiceIdVariablesBuiltInRouteImport } from './routes/_authenticated/organization/$organizationId/project/$projectId/environment/$environmentId/service/$serviceId/variables/built-in' +import { Route as AuthenticatedOrganizationOrganizationIdProjectProjectIdEnvironmentEnvironmentIdServiceServiceIdUpdateBlueprintRouteImport } from './routes/_authenticated/organization/$organizationId/project/$projectId/environment/$environmentId/service/$serviceId/update/blueprint' import { Route as AuthenticatedOrganizationOrganizationIdProjectProjectIdEnvironmentEnvironmentIdServiceServiceIdSettingsValuesOverrideFileRouteImport } from './routes/_authenticated/organization/$organizationId/project/$projectId/environment/$environmentId/service/$serviceId/settings/values-override-file' import { Route as AuthenticatedOrganizationOrganizationIdProjectProjectIdEnvironmentEnvironmentIdServiceServiceIdSettingsValuesOverrideArgumentsRouteImport } from './routes/_authenticated/organization/$organizationId/project/$projectId/environment/$environmentId/service/$serviceId/settings/values-override-arguments' import { Route as AuthenticatedOrganizationOrganizationIdProjectProjectIdEnvironmentEnvironmentIdServiceServiceIdSettingsTerraformVariablesRouteImport } from './routes/_authenticated/organization/$organizationId/project/$projectId/environment/$environmentId/service/$serviceId/settings/terraform-variables' @@ -1444,6 +1445,14 @@ const AuthenticatedOrganizationOrganizationIdProjectProjectIdEnvironmentEnvironm AuthenticatedOrganizationOrganizationIdProjectProjectIdEnvironmentEnvironmentIdServiceServiceIdVariablesRouteRoute, } as any, ) +const AuthenticatedOrganizationOrganizationIdProjectProjectIdEnvironmentEnvironmentIdServiceServiceIdUpdateBlueprintRoute = + AuthenticatedOrganizationOrganizationIdProjectProjectIdEnvironmentEnvironmentIdServiceServiceIdUpdateBlueprintRouteImport.update( + { + id: '/project/$projectId/environment/$environmentId/service/$serviceId/update/blueprint', + path: '/project/$projectId/environment/$environmentId/service/$serviceId/update/blueprint', + getParentRoute: () => AuthenticatedOrganizationOrganizationIdRouteRoute, + } as any, + ) const AuthenticatedOrganizationOrganizationIdProjectProjectIdEnvironmentEnvironmentIdServiceServiceIdSettingsValuesOverrideFileRoute = AuthenticatedOrganizationOrganizationIdProjectProjectIdEnvironmentEnvironmentIdServiceServiceIdSettingsValuesOverrideFileRouteImport.update( { @@ -1827,6 +1836,7 @@ export interface FileRoutesByFullPath { '/organization/$organizationId/project/$projectId/environment/$environmentId/service/$serviceId/settings/terraform-variables': typeof AuthenticatedOrganizationOrganizationIdProjectProjectIdEnvironmentEnvironmentIdServiceServiceIdSettingsTerraformVariablesRoute '/organization/$organizationId/project/$projectId/environment/$environmentId/service/$serviceId/settings/values-override-arguments': typeof AuthenticatedOrganizationOrganizationIdProjectProjectIdEnvironmentEnvironmentIdServiceServiceIdSettingsValuesOverrideArgumentsRoute '/organization/$organizationId/project/$projectId/environment/$environmentId/service/$serviceId/settings/values-override-file': typeof AuthenticatedOrganizationOrganizationIdProjectProjectIdEnvironmentEnvironmentIdServiceServiceIdSettingsValuesOverrideFileRoute + '/organization/$organizationId/project/$projectId/environment/$environmentId/service/$serviceId/update/blueprint': typeof AuthenticatedOrganizationOrganizationIdProjectProjectIdEnvironmentEnvironmentIdServiceServiceIdUpdateBlueprintRoute '/organization/$organizationId/project/$projectId/environment/$environmentId/service/$serviceId/variables/built-in': typeof AuthenticatedOrganizationOrganizationIdProjectProjectIdEnvironmentEnvironmentIdServiceServiceIdVariablesBuiltInRoute '/organization/$organizationId/project/$projectId/environment/$environmentId/service/$serviceId/variables/external-secrets': typeof AuthenticatedOrganizationOrganizationIdProjectProjectIdEnvironmentEnvironmentIdServiceServiceIdVariablesExternalSecretsRoute '/organization/$organizationId/project/$projectId/environment/$environmentId/service/create/$slug/general': typeof AuthenticatedOrganizationOrganizationIdProjectProjectIdEnvironmentEnvironmentIdServiceCreateSlugGeneralRoute @@ -1991,6 +2001,7 @@ export interface FileRoutesByTo { '/organization/$organizationId/project/$projectId/environment/$environmentId/service/$serviceId/settings/terraform-variables': typeof AuthenticatedOrganizationOrganizationIdProjectProjectIdEnvironmentEnvironmentIdServiceServiceIdSettingsTerraformVariablesRoute '/organization/$organizationId/project/$projectId/environment/$environmentId/service/$serviceId/settings/values-override-arguments': typeof AuthenticatedOrganizationOrganizationIdProjectProjectIdEnvironmentEnvironmentIdServiceServiceIdSettingsValuesOverrideArgumentsRoute '/organization/$organizationId/project/$projectId/environment/$environmentId/service/$serviceId/settings/values-override-file': typeof AuthenticatedOrganizationOrganizationIdProjectProjectIdEnvironmentEnvironmentIdServiceServiceIdSettingsValuesOverrideFileRoute + '/organization/$organizationId/project/$projectId/environment/$environmentId/service/$serviceId/update/blueprint': typeof AuthenticatedOrganizationOrganizationIdProjectProjectIdEnvironmentEnvironmentIdServiceServiceIdUpdateBlueprintRoute '/organization/$organizationId/project/$projectId/environment/$environmentId/service/$serviceId/variables/built-in': typeof AuthenticatedOrganizationOrganizationIdProjectProjectIdEnvironmentEnvironmentIdServiceServiceIdVariablesBuiltInRoute '/organization/$organizationId/project/$projectId/environment/$environmentId/service/$serviceId/variables/external-secrets': typeof AuthenticatedOrganizationOrganizationIdProjectProjectIdEnvironmentEnvironmentIdServiceServiceIdVariablesExternalSecretsRoute '/organization/$organizationId/project/$projectId/environment/$environmentId/service/create/$slug/general': typeof AuthenticatedOrganizationOrganizationIdProjectProjectIdEnvironmentEnvironmentIdServiceCreateSlugGeneralRoute @@ -2175,6 +2186,7 @@ export interface FileRoutesById { '/_authenticated/organization/$organizationId/project/$projectId/environment/$environmentId/service/$serviceId/settings/terraform-variables': typeof AuthenticatedOrganizationOrganizationIdProjectProjectIdEnvironmentEnvironmentIdServiceServiceIdSettingsTerraformVariablesRoute '/_authenticated/organization/$organizationId/project/$projectId/environment/$environmentId/service/$serviceId/settings/values-override-arguments': typeof AuthenticatedOrganizationOrganizationIdProjectProjectIdEnvironmentEnvironmentIdServiceServiceIdSettingsValuesOverrideArgumentsRoute '/_authenticated/organization/$organizationId/project/$projectId/environment/$environmentId/service/$serviceId/settings/values-override-file': typeof AuthenticatedOrganizationOrganizationIdProjectProjectIdEnvironmentEnvironmentIdServiceServiceIdSettingsValuesOverrideFileRoute + '/_authenticated/organization/$organizationId/project/$projectId/environment/$environmentId/service/$serviceId/update/blueprint': typeof AuthenticatedOrganizationOrganizationIdProjectProjectIdEnvironmentEnvironmentIdServiceServiceIdUpdateBlueprintRoute '/_authenticated/organization/$organizationId/project/$projectId/environment/$environmentId/service/$serviceId/variables/built-in': typeof AuthenticatedOrganizationOrganizationIdProjectProjectIdEnvironmentEnvironmentIdServiceServiceIdVariablesBuiltInRoute '/_authenticated/organization/$organizationId/project/$projectId/environment/$environmentId/service/$serviceId/variables/external-secrets': typeof AuthenticatedOrganizationOrganizationIdProjectProjectIdEnvironmentEnvironmentIdServiceServiceIdVariablesExternalSecretsRoute '/_authenticated/organization/$organizationId/project/$projectId/environment/$environmentId/service/create/$slug/general': typeof AuthenticatedOrganizationOrganizationIdProjectProjectIdEnvironmentEnvironmentIdServiceCreateSlugGeneralRoute @@ -2360,6 +2372,7 @@ export interface FileRouteTypes { | '/organization/$organizationId/project/$projectId/environment/$environmentId/service/$serviceId/settings/terraform-variables' | '/organization/$organizationId/project/$projectId/environment/$environmentId/service/$serviceId/settings/values-override-arguments' | '/organization/$organizationId/project/$projectId/environment/$environmentId/service/$serviceId/settings/values-override-file' + | '/organization/$organizationId/project/$projectId/environment/$environmentId/service/$serviceId/update/blueprint' | '/organization/$organizationId/project/$projectId/environment/$environmentId/service/$serviceId/variables/built-in' | '/organization/$organizationId/project/$projectId/environment/$environmentId/service/$serviceId/variables/external-secrets' | '/organization/$organizationId/project/$projectId/environment/$environmentId/service/create/$slug/general' @@ -2524,6 +2537,7 @@ export interface FileRouteTypes { | '/organization/$organizationId/project/$projectId/environment/$environmentId/service/$serviceId/settings/terraform-variables' | '/organization/$organizationId/project/$projectId/environment/$environmentId/service/$serviceId/settings/values-override-arguments' | '/organization/$organizationId/project/$projectId/environment/$environmentId/service/$serviceId/settings/values-override-file' + | '/organization/$organizationId/project/$projectId/environment/$environmentId/service/$serviceId/update/blueprint' | '/organization/$organizationId/project/$projectId/environment/$environmentId/service/$serviceId/variables/built-in' | '/organization/$organizationId/project/$projectId/environment/$environmentId/service/$serviceId/variables/external-secrets' | '/organization/$organizationId/project/$projectId/environment/$environmentId/service/create/$slug/general' @@ -2707,6 +2721,7 @@ export interface FileRouteTypes { | '/_authenticated/organization/$organizationId/project/$projectId/environment/$environmentId/service/$serviceId/settings/terraform-variables' | '/_authenticated/organization/$organizationId/project/$projectId/environment/$environmentId/service/$serviceId/settings/values-override-arguments' | '/_authenticated/organization/$organizationId/project/$projectId/environment/$environmentId/service/$serviceId/settings/values-override-file' + | '/_authenticated/organization/$organizationId/project/$projectId/environment/$environmentId/service/$serviceId/update/blueprint' | '/_authenticated/organization/$organizationId/project/$projectId/environment/$environmentId/service/$serviceId/variables/built-in' | '/_authenticated/organization/$organizationId/project/$projectId/environment/$environmentId/service/$serviceId/variables/external-secrets' | '/_authenticated/organization/$organizationId/project/$projectId/environment/$environmentId/service/create/$slug/general' @@ -3846,6 +3861,13 @@ declare module '@tanstack/react-router' { preLoaderRoute: typeof AuthenticatedOrganizationOrganizationIdProjectProjectIdEnvironmentEnvironmentIdServiceServiceIdVariablesBuiltInRouteImport parentRoute: typeof AuthenticatedOrganizationOrganizationIdProjectProjectIdEnvironmentEnvironmentIdServiceServiceIdVariablesRouteRoute } + '/_authenticated/organization/$organizationId/project/$projectId/environment/$environmentId/service/$serviceId/update/blueprint': { + id: '/_authenticated/organization/$organizationId/project/$projectId/environment/$environmentId/service/$serviceId/update/blueprint' + path: '/project/$projectId/environment/$environmentId/service/$serviceId/update/blueprint' + fullPath: '/organization/$organizationId/project/$projectId/environment/$environmentId/service/$serviceId/update/blueprint' + preLoaderRoute: typeof AuthenticatedOrganizationOrganizationIdProjectProjectIdEnvironmentEnvironmentIdServiceServiceIdUpdateBlueprintRouteImport + parentRoute: typeof AuthenticatedOrganizationOrganizationIdRouteRoute + } '/_authenticated/organization/$organizationId/project/$projectId/environment/$environmentId/service/$serviceId/settings/values-override-file': { id: '/_authenticated/organization/$organizationId/project/$projectId/environment/$environmentId/service/$serviceId/settings/values-override-file' path: '/values-override-file' @@ -4681,6 +4703,7 @@ interface AuthenticatedOrganizationOrganizationIdRouteRouteChildren { AuthenticatedOrganizationOrganizationIdProjectProjectIdEnvironmentEnvironmentIdDeploymentDeploymentIdIndexRoute: typeof AuthenticatedOrganizationOrganizationIdProjectProjectIdEnvironmentEnvironmentIdDeploymentDeploymentIdIndexRoute AuthenticatedOrganizationOrganizationIdProjectProjectIdEnvironmentEnvironmentIdServiceServiceIdIndexRoute: typeof AuthenticatedOrganizationOrganizationIdProjectProjectIdEnvironmentEnvironmentIdServiceServiceIdIndexRoute AuthenticatedOrganizationOrganizationIdProjectProjectIdEnvironmentEnvironmentIdServiceCreateIndexRoute: typeof AuthenticatedOrganizationOrganizationIdProjectProjectIdEnvironmentEnvironmentIdServiceCreateIndexRoute + AuthenticatedOrganizationOrganizationIdProjectProjectIdEnvironmentEnvironmentIdServiceServiceIdUpdateBlueprintRoute: typeof AuthenticatedOrganizationOrganizationIdProjectProjectIdEnvironmentEnvironmentIdServiceServiceIdUpdateBlueprintRoute AuthenticatedOrganizationOrganizationIdProjectProjectIdEnvironmentEnvironmentIdServiceServiceIdDeploymentsIndexRoute: typeof AuthenticatedOrganizationOrganizationIdProjectProjectIdEnvironmentEnvironmentIdServiceServiceIdDeploymentsIndexRoute AuthenticatedOrganizationOrganizationIdProjectProjectIdEnvironmentEnvironmentIdServiceCreateBlueprintProviderServiceFamilyRouteRoute: typeof AuthenticatedOrganizationOrganizationIdProjectProjectIdEnvironmentEnvironmentIdServiceCreateBlueprintProviderServiceFamilyRouteRouteWithChildren AuthenticatedOrganizationOrganizationIdProjectProjectIdEnvironmentEnvironmentIdServiceServiceIdDeploymentsLogsExecutionIdRoute: typeof AuthenticatedOrganizationOrganizationIdProjectProjectIdEnvironmentEnvironmentIdServiceServiceIdDeploymentsLogsExecutionIdRoute @@ -4776,6 +4799,8 @@ const AuthenticatedOrganizationOrganizationIdRouteRouteChildren: AuthenticatedOr AuthenticatedOrganizationOrganizationIdProjectProjectIdEnvironmentEnvironmentIdServiceServiceIdIndexRoute, AuthenticatedOrganizationOrganizationIdProjectProjectIdEnvironmentEnvironmentIdServiceCreateIndexRoute: AuthenticatedOrganizationOrganizationIdProjectProjectIdEnvironmentEnvironmentIdServiceCreateIndexRoute, + AuthenticatedOrganizationOrganizationIdProjectProjectIdEnvironmentEnvironmentIdServiceServiceIdUpdateBlueprintRoute: + AuthenticatedOrganizationOrganizationIdProjectProjectIdEnvironmentEnvironmentIdServiceServiceIdUpdateBlueprintRoute, AuthenticatedOrganizationOrganizationIdProjectProjectIdEnvironmentEnvironmentIdServiceServiceIdDeploymentsIndexRoute: AuthenticatedOrganizationOrganizationIdProjectProjectIdEnvironmentEnvironmentIdServiceServiceIdDeploymentsIndexRoute, AuthenticatedOrganizationOrganizationIdProjectProjectIdEnvironmentEnvironmentIdServiceCreateBlueprintProviderServiceFamilyRouteRoute: diff --git a/apps/console/src/routes/_authenticated/organization/$organizationId/project/$projectId/environment/$environmentId/service/$serviceId/update/blueprint.tsx b/apps/console/src/routes/_authenticated/organization/$organizationId/project/$projectId/environment/$environmentId/service/$serviceId/update/blueprint.tsx new file mode 100644 index 00000000000..33de773a25d --- /dev/null +++ b/apps/console/src/routes/_authenticated/organization/$organizationId/project/$projectId/environment/$environmentId/service/$serviceId/update/blueprint.tsx @@ -0,0 +1,37 @@ +import { createFileRoute, useNavigate } from '@tanstack/react-router' +import { useCallback, useEffect } from 'react' +import { BlueprintUpdateFlow, useService } from '@qovery/domains/services/feature' + +export const Route = createFileRoute( + '/_authenticated/organization/$organizationId/project/$projectId/environment/$environmentId/service/$serviceId/update/blueprint' +)({ + component: RouteComponent, +}) + +function RouteComponent() { + const { organizationId, projectId, environmentId, serviceId } = Route.useParams() + const navigate = useNavigate() + const { data: service } = useService({ environmentId, serviceId, suspense: true }) + const blueprintId = service && 'blueprint_id' in service ? service.blueprint_id : undefined + const navigateToOverview = useCallback(() => { + navigate({ + to: '/organization/$organizationId/project/$projectId/environment/$environmentId/service/$serviceId/overview', + params: { organizationId, projectId, environmentId, serviceId }, + }) + }, [environmentId, navigate, organizationId, projectId, serviceId]) + + useEffect(() => { + if (service && !blueprintId) navigateToOverview() + }, [blueprintId, navigateToOverview, service]) + + if (!service || !blueprintId) return null + + return ( + + ) +} diff --git a/apps/console/src/routes/_authenticated/organization/$organizationId/route.tsx b/apps/console/src/routes/_authenticated/organization/$organizationId/route.tsx index a32b43ec801..79cc12afcf5 100644 --- a/apps/console/src/routes/_authenticated/organization/$organizationId/route.tsx +++ b/apps/console/src/routes/_authenticated/organization/$organizationId/route.tsx @@ -56,6 +56,7 @@ const hiddenProgressCardRouteIds: FileRouteTypes['id'][] = [ '/_authenticated/organization/$organizationId/project/$projectId/environment/$environmentId/service/create/helm', '/_authenticated/organization/$organizationId/project/$projectId/environment/$environmentId/service/create/lifecycle-job', '/_authenticated/organization/$organizationId/project/$projectId/environment/$environmentId/service/create/terraform', + '/_authenticated/organization/$organizationId/project/$projectId/environment/$environmentId/service/$serviceId/update/blueprint', ] function RouteComponent() { diff --git a/apps/console/src/routes/_authenticated/organization/route.tsx b/apps/console/src/routes/_authenticated/organization/route.tsx index 584d56f5445..dbb2c1271a2 100644 --- a/apps/console/src/routes/_authenticated/organization/route.tsx +++ b/apps/console/src/routes/_authenticated/organization/route.tsx @@ -493,6 +493,7 @@ const bypassLayoutRouteIds: FileRouteTypes['id'][] = [ '/_authenticated/organization/$organizationId/project/$projectId/environment/$environmentId/service/create/terraform', '/_authenticated/organization/$organizationId/project/$projectId/environment/$environmentId/service/create/lifecycle-job', '/_authenticated/organization/$organizationId/project/$projectId/environment/$environmentId/service/create/cron-job', + '/_authenticated/organization/$organizationId/project/$projectId/environment/$environmentId/service/$serviceId/update/blueprint', ] function useBypassLayout(): boolean { 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 2e48a837e0d..835ee0ff981 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 { BlueprintCatalogApi, type BlueprintCreateRequest, BlueprintMainCallsApi, + type BlueprintUpdateRequest, type CleanFailedJobsRequest, ContainerActionsApi, type ContainerAdvancedSettings, @@ -769,6 +770,16 @@ type CreateBlueprintRequest = { deploy?: boolean } +type PreviewBlueprintUpdateRequest = { + blueprintId: string + payload: BlueprintUpdateRequest +} + +type UpdateBlueprintRequest = { + blueprintId: string + payload: BlueprintUpdateRequest +} + type EditServiceRequest = { serviceId: string payload: @@ -1042,6 +1053,14 @@ export const mutations = { const response = await blueprintApi.createBlueprint(environmentId, payload, deploy) return response.data }, + async previewBlueprintUpdate({ blueprintId, payload }: PreviewBlueprintUpdateRequest) { + const response = await blueprintApi.previewBlueprintUpdate(blueprintId, payload) + return response.data + }, + async updateBlueprint({ blueprintId, payload }: UpdateBlueprintRequest) { + const response = await blueprintApi.updateBlueprint(blueprintId, payload) + return response.data + }, async editService({ serviceId, payload }: EditServiceRequest) { const { mutation } = match(payload) .with({ serviceType: 'APPLICATION' }, ({ serviceType, ...payload }) => ({ diff --git a/libs/domains/services/feature/src/index.ts b/libs/domains/services/feature/src/index.ts index 5bdab058317..ab5fc8e38ab 100644 --- a/libs/domains/services/feature/src/index.ts +++ b/libs/domains/services/feature/src/index.ts @@ -34,6 +34,8 @@ 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/hooks/use-blueprint-update/use-blueprint-update' +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/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' @@ -102,6 +104,7 @@ 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/service-update-flow/blueprint/blueprint-update-flow' export * from './lib/application-container-healthchecks/application-container-healthchecks-form/application-container-healthchecks-form' export * from './lib/application-container-healthchecks/healthchecks-utils' export * from './lib/application-container-healthchecks/step-healthchecks/step-healthchecks' diff --git a/libs/domains/services/feature/src/lib/hooks/use-preview-blueprint-update/use-preview-blueprint-update.ts b/libs/domains/services/feature/src/lib/hooks/use-preview-blueprint-update/use-preview-blueprint-update.ts new file mode 100644 index 00000000000..dfaccf163bf --- /dev/null +++ b/libs/domains/services/feature/src/lib/hooks/use-preview-blueprint-update/use-preview-blueprint-update.ts @@ -0,0 +1,12 @@ +import { useMutation } from '@tanstack/react-query' +import { mutations } from '@qovery/domains/services/data-access' + +export function usePreviewBlueprintUpdate() { + return useMutation(mutations.previewBlueprintUpdate, { + meta: { + notifyOnError: true, + }, + }) +} + +export default usePreviewBlueprintUpdate diff --git a/libs/domains/services/feature/src/lib/hooks/use-update-blueprint/use-update-blueprint.ts b/libs/domains/services/feature/src/lib/hooks/use-update-blueprint/use-update-blueprint.ts new file mode 100644 index 00000000000..4cef8e0f902 --- /dev/null +++ b/libs/domains/services/feature/src/lib/hooks/use-update-blueprint/use-update-blueprint.ts @@ -0,0 +1,37 @@ +import { useMutation, useQueryClient } from '@tanstack/react-query' +import { mutations } from '@qovery/domains/services/data-access' +import { queries } from '@qovery/state/util-queries' + +export function useUpdateBlueprint({ + environmentId, + serviceId, + serviceType, +}: { + environmentId: string + serviceId: string + serviceType: Parameters[0]['serviceType'] +}) { + const queryClient = useQueryClient() + + return useMutation(mutations.updateBlueprint, { + onSuccess(response, { blueprintId }) { + queryClient.invalidateQueries({ + queryKey: queries.services.list(response.environment_id).queryKey, + }) + queryClient.invalidateQueries({ + queryKey: queries.services.details({ serviceId, serviceType }).queryKey, + }) + queryClient.invalidateQueries({ + queryKey: queries.services.blueprintUpdate({ blueprintId }).queryKey, + }) + queryClient.invalidateQueries({ + queryKey: queries.services.listStatuses(environmentId).queryKey, + }) + }, + meta: { + notifyOnError: true, + }, + }) +} + +export default useUpdateBlueprint 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 91f595522d7..150a7700168 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 @@ -8,6 +8,9 @@ import { ServiceHeader } from './service-header' const mockCopyToClipboard = jest.fn() const mockGetDatabaseConnectionUri = jest.fn(() => 'postgres://copied-uri') const mockUseBlueprintUpdate = jest.fn() +const mockOpenModal = jest.fn() +const mockCloseModal = jest.fn() +const mockNavigate = jest.fn() const services = { 'application-mock': { id: 'ebb84aa8-91c2-40fb-916d-3a158db354b7', @@ -90,12 +93,19 @@ const services = { }, }, blueprint_id: 'blueprint-id', + tag: 'aws/s3/1.2', }, } jest.mock('@tanstack/react-router', () => ({ ...jest.requireActual('@tanstack/react-router'), - useParams: () => ({ organizationId: '', projectId: '' }), + useNavigate: () => mockNavigate, + useParams: () => ({ + organizationId: 'org-id', + projectId: 'project-id', + environmentId: 'environment-id', + serviceId: 'terraform-mock', + }), Link: ({ children, to, @@ -117,6 +127,10 @@ jest.mock('@qovery/shared/ui', () => ({ ...jest.requireActual('@qovery/shared/ui'), toast: jest.fn(), Heading: ({ children }: { children?: ReactNode }) =>

{children}

, + useModal: () => ({ + openModal: mockOpenModal, + closeModal: mockCloseModal, + }), })) jest.mock('@qovery/shared/util-hooks', () => ({ @@ -126,6 +140,13 @@ jest.mock('@qovery/shared/util-hooks', () => ({ jest.mock('@qovery/domains/clusters/feature', () => ({ ...jest.requireActual('@qovery/domains/clusters/feature'), + ClusterRunningStatusIndicator: () =>
cluster-running-status
, + useCluster: () => ({ + data: { + id: 'cluster-id', + name: 'my-cluster', + }, + }), useClusterRunningStatusSocket: jest.fn(), })) @@ -376,6 +397,14 @@ describe('ServiceHeader', () => { data: { is_up_to_date: true, latest_tag: 'aws/s3/1.0.0', + new_required_values: [], + new_optional_values: [], + now_required_values: [], + updated_values: [], + removed_values: [], + engine_diff: { + updated_values: [], + }, }, }) @@ -386,18 +415,48 @@ describe('ServiceHeader', () => { expect(screen.queryByText('Update available')).not.toBeInTheDocument() }) - it('renders an update available badge for an outdated blueprint service', () => { + it('opens the blueprint update modal from the update available badge', async () => { mockUseBlueprintUpdate.mockReturnValue({ data: { is_up_to_date: false, - latest_tag: 'aws/s3/2.0.0', + latest_tag: 'aws/s3/2.0', + new_required_values: [], + new_optional_values: [], + now_required_values: [], + updated_values: [], + removed_values: [], + engine_diff: { + updated_values: [], + }, }, }) - renderServiceHeader('terraform-mock') + const { userEvent } = renderServiceHeader('terraform-mock') - expect(screen.getByText('Update available')).toBeInTheDocument() + await userEvent.click(screen.getByRole('button', { name: /update available/i })) + + expect(mockOpenModal).toHaveBeenCalledTimes(1) + renderWithProviders(mockOpenModal.mock.calls[0][0].content) + + expect(screen.getAllByText('Update available')[0]).toBeInTheDocument() + expect(screen.getByRole('heading', { name: 'Blueprint update from 1.2 to 2.0' })).toBeInTheDocument() + expect(screen.getByRole('link', { name: 'release notes' })).toBeInTheDocument() + expect(screen.queryByText('0')).not.toBeInTheDocument() + expect(screen.getByRole('button', { name: 'Dismiss' })).toBeInTheDocument() expect(screen.queryByText('Up to date')).not.toBeInTheDocument() + + await userEvent.click(screen.getByRole('button', { name: 'Review & update' })) + + expect(mockCloseModal).toHaveBeenCalled() + 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('renders a skeleton while the blueprint update badge is loading', () => { 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 a8f01a93036..e5e4cda4e0a 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 @@ -1,5 +1,10 @@ -import { Link, useParams } from '@tanstack/react-router' -import { type ApplicationGitRepository, type Credentials, type Environment } from 'qovery-typescript-axios' +import { Link, useNavigate, useParams } from '@tanstack/react-router' +import { + type ApplicationGitRepository, + type BlueprintUpdateResponse, + type Credentials, + type Environment, +} from 'qovery-typescript-axios' import { Suspense } from 'react' import { P, match } from 'ts-pattern' import { @@ -28,6 +33,7 @@ import { Tooltip, Truncate, toast, + useModal, } from '@qovery/shared/ui' import { buildGitProviderUrl } from '@qovery/shared/util-git' import { useCopyToClipboard } from '@qovery/shared/util-hooks' @@ -163,8 +169,81 @@ function BlueprintUpdateBadgeSkeleton() { return } -function BlueprintUpdateBadge({ blueprintId }: { blueprintId: string }) { +interface BlueprintUpdateModalProps { + blueprintUpdate: BlueprintUpdateResponse + currentVersion?: string + onClose: () => void + onReviewUpdate: () => void +} + +const BLUEPRINT_RELEASE_NOTES_URL = 'https://github.com/Qovery/service-catalog/releases' + +function getVersionFromTag(tag?: string) { + return tag?.split('/').at(-1) +} + +function getServiceBlueprintVersion(service: AnyService) { + if ('tag' in service && typeof service.tag === 'string') { + return getVersionFromTag(service.tag) + } + + return undefined +} + +function BlueprintUpdateModal({ blueprintUpdate, currentVersion, onClose, onReviewUpdate }: BlueprintUpdateModalProps) { + const latestVersion = getVersionFromTag(blueprintUpdate.latest_tag) ?? blueprintUpdate.latest_tag + + return ( +
+
+

+ Blueprint update from {currentVersion ?? 'current version'} to {latestVersion} +

+

+ This update improves bucket performance for large object transfers and hardens IAM permission scoping. See the + full{' '} + + release notes + {' '} + for more details. +

+
+ +
+ + +
+
+ ) +} + +function BlueprintUpdateBadge({ blueprintId, service }: { blueprintId: string; service: AnyService }) { const { data: blueprintUpdate } = useBlueprintUpdate({ blueprintId, suspense: true }) + const { openModal, closeModal } = useModal() + const navigate = useNavigate() + const { + organizationId = '', + projectId = '', + environmentId = '', + serviceId = service.id, + } = useParams({ strict: false }) + const currentVersion = getServiceBlueprintVersion(service) + const openUpdateFlow = () => { + closeModal() + navigate({ + to: '/organization/$organizationId/project/$projectId/environment/$environmentId/service/$serviceId/update/blueprint', + params: { organizationId, projectId, environmentId, serviceId }, + }) + } if (!blueprintUpdate) { return null @@ -176,12 +255,27 @@ function BlueprintUpdateBadge({ blueprintId }: { blueprintId: string }) { Up to date ) : ( - + ) } @@ -325,7 +419,7 @@ function ServiceHeaderMetadata({ service }: ServiceHeaderMetadataProps) { )} {blueprintId && ( }> - + )} {databaseSource && ( diff --git a/libs/domains/services/feature/src/lib/service-update-flow/blueprint/blueprint-update-flow.tsx b/libs/domains/services/feature/src/lib/service-update-flow/blueprint/blueprint-update-flow.tsx new file mode 100644 index 00000000000..bbdb7e880bf --- /dev/null +++ b/libs/domains/services/feature/src/lib/service-update-flow/blueprint/blueprint-update-flow.tsx @@ -0,0 +1,661 @@ +import { type IconName } from '@fortawesome/fontawesome-common-types' +import { type ReactNode, useEffect, useState } from 'react' +import { type AnyService } from '@qovery/domains/services/data-access' +import { Button, FunnelFlowBody, Icon, InputText, LogoIcon, Skeleton, Tooltip, toast } from '@qovery/shared/ui' +import { useBlueprintUpdate } from '../../hooks/use-blueprint-update/use-blueprint-update' +import { usePreviewBlueprintUpdate } from '../../hooks/use-preview-blueprint-update/use-preview-blueprint-update' +import { useUpdateBlueprint } from '../../hooks/use-update-blueprint/use-update-blueprint' + +type BlueprintUpdateSection = 'required' | 'optional' | 'modified' | 'removed' + +type BlueprintUpdateVariablePatch = Record + +export interface BlueprintUpdateFlowProps { + blueprintId: string + environmentId: string + onExit: () => void + service: AnyService +} + +const updateSections: Array<{ + id: BlueprintUpdateSection + title: string + iconName: IconName + description?: string +}> = [ + { + id: 'required', + title: 'New required values', + iconName: 'circle-plus', + description: + "These are new fields we have added that don't have a default value and require you to enter a specific value for the update.", + }, + { + id: 'optional', + title: 'New optional values', + iconName: 'chart-bullet', + description: 'These new fields have a default value, but you can override them if needed.', + }, + { + id: 'modified', + title: 'Modified values', + iconName: 'arrows-rotate', + description: "Existing values that I've seen their default updated. Your overrides stays prioritaire.", + }, + { + id: 'removed', + title: 'Removed values', + iconName: 'circle-minus', + }, +] + +export function BlueprintUpdateFlow({ blueprintId, environmentId, onExit, service }: BlueprintUpdateFlowProps) { + const { data: blueprintUpdate } = useBlueprintUpdate({ blueprintId, suspense: true }) + const { + mutateAsync: previewBlueprintUpdate, + data: preview, + isLoading: isPreviewLoading, + } = usePreviewBlueprintUpdate() + const { mutateAsync: updateBlueprint, isLoading: isUpdateLoading } = useUpdateBlueprint({ + environmentId, + serviceId: service.id, + serviceType: service.service_type, + }) + const [currentStep, setCurrentStep] = useState<'review' | 'preview'>('review') + const [activeSection, setActiveSection] = useState('required') + const [completedSections, setCompletedSections] = useState([]) + const [values, setValues] = useState>({}) + + useEffect(() => { + if (blueprintUpdate && Object.keys(values).length === 0) { + setValues(getInitialUpdateValues(blueprintUpdate)) + } + }, [blueprintUpdate, values]) + + if (!blueprintUpdate) return null + + const requiredValues = [...blueprintUpdate.new_required_values, ...blueprintUpdate.now_required_values] + const sectionHasContent = { + required: requiredValues.length > 0, + optional: blueprintUpdate.new_optional_values.length > 0, + modified: blueprintUpdate.updated_values.length > 0 || blueprintUpdate.engine_diff.updated_values.length > 0, + removed: blueprintUpdate.removed_values.length > 0, + } + const visibleSections = updateSections.filter(({ id }) => sectionHasContent[id]) + const reviewSections = visibleSections.length > 0 ? visibleSections : updateSections.slice(0, 1) + const activeSectionIndex = reviewSections.findIndex(({ id }) => id === activeSection) + const isRequiredValid = requiredValues.every(({ name }) => Boolean(values[name]?.trim())) + const isReviewComplete = + reviewSections.every(({ id }) => completedSections.includes(id)) || reviewSections.length === 0 + const latestVersion = getVersionFromTag(blueprintUpdate.latest_tag) ?? blueprintUpdate.latest_tag + const title = `${service.name} blueprint update to ${latestVersion}` + const payload = buildBlueprintUpdatePayload({ + icon: service.icon_uri ?? getFallbackServiceIcon(service.service_type), + name: service.name, + tag: blueprintUpdate.latest_tag, + values, + optionalValues: blueprintUpdate.new_optional_values, + requiredValues, + updatedValues: [...blueprintUpdate.updated_values, ...blueprintUpdate.engine_diff.updated_values], + }) + + const completeActiveSection = () => { + if (activeSection === 'required' && !isRequiredValid) return + const nextCompletedSections = completedSections.includes(activeSection) + ? completedSections + : [...completedSections, activeSection] + const nextSection = reviewSections[activeSectionIndex + 1]?.id + + setCompletedSections(nextCompletedSections) + if (nextSection) setActiveSection(nextSection) + } + + const handlePreview = async () => { + if (!isReviewComplete) return + + await previewBlueprintUpdate({ blueprintId, payload }) + setCurrentStep('preview') + } + + const handleUpdate = async () => { + await updateBlueprint({ blueprintId, payload }) + toast('success', 'Blueprint update started') + onExit() + } + + return ( + + {currentStep === 'review' ? ( + +
+

{title}

+
+ +
+ {reviewSections.map((section) => ( + setActiveSection(section.id)} + > + {activeSection === section.id && ( + setValues((currentValues) => ({ ...currentValues, [name]: value }))} + onContinue={completeActiveSection} + continueDisabled={section.id === 'required' && !isRequiredValid} + /> + )} + + ))} +
+ +
+ +
+
+ ) : ( + setCurrentStep('review')} + onConfirm={handleUpdate} + loading={isUpdateLoading} + /> + )} +
+ ) +} + +function BlueprintUpdateFlowShell({ + children, + currentStep, + onExit, +}: { + children: ReactNode + currentStep: 1 | 2 + onExit: () => void +}) { + return ( +
+
+
+
+ +
+
+ 1} active={currentStep === 1} number={1} title="Review update" /> + + +
+
+
+ +
+
+
{children}
+
+ ) +} + +function StepIndicator({ + active, + completed, + number, + title, +}: { + active?: boolean + completed?: boolean + number: number + title: string +}) { + return ( +
+ {completed ? ( + + ) : ( + + {number} + + )} + + {title} + +
+ ) +} + +function BlueprintUpdateSectionCard({ + active, + children, + completed, + description, + iconName, + onClick, + title, +}: { + active: boolean + children: ReactNode + completed: boolean + description?: string + iconName: IconName + onClick: () => void + title: string +}) { + return ( +
+ + {children &&
{children}
} +
+ ) +} + +function BlueprintUpdateSectionContent({ + continueDisabled, + onChange, + onContinue, + optionalValues, + removedValues, + requiredValues, + section, + updatedValues, + values, +}: { + continueDisabled: boolean + onChange: (name: string, value: string) => void + onContinue: () => void + optionalValues: Array<{ name: string; default_value?: string | null }> + removedValues: Array<{ name: string }> + requiredValues: Array<{ name: string }> + section: BlueprintUpdateSection + updatedValues: Array<{ + name: string + current_default_value?: string | null + new_default_value?: string | null + current_value?: string | null + }> + values: Record +}) { + return ( + <> + {section === 'required' && ( +
+ {requiredValues.map(({ name }, index) => ( + onChange(name, event.currentTarget.value)} + /> + ))} +
+ )} + + {section === 'optional' && ( +
+ {optionalValues.map(({ name, default_value: defaultValue }, index) => ( + onChange(name, event.currentTarget.value)} + /> + ))} +
+ )} + + {section === 'modified' && ( + + )} + + {section === 'removed' && } + + + + ) +} + +function UpdatedValuesList({ + editableValues, + onChange, + values, +}: { + editableValues: Record + onChange: (name: string, value: string) => void + values: Array<{ + name: string + current_default_value?: string | null + new_default_value?: string | null + current_value?: string | null + }> +}) { + const [editedValueName, setEditedValueName] = useState() + + if (values.length === 0) return

No modified values.

+ + return ( +
+ {values.map((value) => { + const label = formatUpdateFieldLabel(value.name) + const editedValue = editableValues[value.name] ?? value.current_value ?? '' + const hasCurrentOverride = Boolean(value.current_value) + const hasEditedOverride = editedValue.length > 0 + const editing = editedValueName === value.name + + return ( +
+
+
+

{label}

+
+
+ Default: + {formatUpdateValue(value.current_default_value)} + + {formatUpdateValue(value.new_default_value)} +
+ {hasEditedOverride && ( +
+ Override: + {editedValue} +
+ )} +
+
+ +
+ {hasCurrentOverride && ( + + Reset the override to the new default value. Overrides are kept by default when a blueprint + default changes. + + } + > + + + )} + + + +
+
+ + {editing && ( + onChange(value.name, event.currentTarget.value)} + /> + )} +
+ ) + })} +
+ ) +} + +function RemovedValuesList({ values }: { values: Array<{ name: string }> }) { + if (values.length === 0) return

No removed values.

+ + return ( +
+ {values.map((value) => ( +
+

{formatUpdateFieldLabel(value.name)}

+ Deleted +
+ ))} +
+ ) +} + +function CodeChip({ children, color = 'neutral' }: { children: ReactNode; color?: 'neutral' | 'info' | 'negative' }) { + const className = { + neutral: 'border-neutral bg-surface-neutral-component text-neutral', + info: 'border-info-subtle bg-surface-info-component text-info', + negative: 'border-negative-subtle bg-surface-negative-component text-negative', + }[color] + + return {children} +} + +function BlueprintUpdatePreview({ + loading, + onBack, + onConfirm, + previewId, + serviceType, +}: { + loading: boolean + onBack: () => void + onConfirm: () => void + previewId?: string + serviceType?: string +}) { + return ( + +
+

Preview changes

+ +
+
+

Estimated deployment impact

+
+ Generated by AI + +
+
+
+
+ + Preview generated +
+

+ The blueprint update preview has been created. Review the generated preview before confirming the update. +

+
+ Preview ID: + {previewId ?? 'pending'} + {serviceType && ( + <> + Service type: + {serviceType} + + )} +
+
+
+ +
+

Raw output

+
+ {previewId ? ( +
{`Preview ${previewId} is ready. Raw infrastructure output is not returned by the current API response.`}
+ ) : ( + + )} +
+
+
+ +
+ + +
+
+ ) +} + +function getInitialUpdateValues(blueprintUpdate: NonNullable['data']>) { + return { + ...Object.fromEntries(blueprintUpdate.new_optional_values.map((value) => [value.name, value.default_value ?? ''])), + ...Object.fromEntries(blueprintUpdate.updated_values.map((value) => [value.name, value.current_value ?? ''])), + ...Object.fromEntries( + blueprintUpdate.engine_diff.updated_values.map((value) => [value.name, value.current_value ?? '']) + ), + } +} + +function buildBlueprintUpdatePayload({ + icon, + name, + optionalValues, + requiredValues, + tag, + updatedValues, + values, +}: { + icon: string + name: string + optionalValues: Array<{ name: string; default_value?: string | null }> + requiredValues: Array<{ name: string }> + tag: string + updatedValues: Array<{ name: string; current_value?: string | null }> + values: Record +}) { + const variables: BlueprintUpdateVariablePatch = {} + + requiredValues.forEach(({ name }) => { + const value = values[name]?.trim() + if (value) variables[name] = { value } + }) + optionalValues.forEach(({ name, default_value: defaultValue }) => { + const value = values[name]?.trim() + if (value && value !== defaultValue) variables[name] = { value } + }) + updatedValues.forEach(({ name, current_value: currentValue }) => { + const value = values[name]?.trim() + if (value && value !== currentValue) variables[name] = { value } + }) + + return { + name, + tag, + icon, + variables, + } +} + +function formatUpdateFieldLabel(name: string) { + const label = name.replace(/_/g, ' ') + return `${label.charAt(0).toUpperCase()}${label.slice(1)}` +} + +function formatUpdateValue(value?: string | null) { + return value && value.length > 0 ? value : '-' +} + +function getVersionFromTag(tag: string) { + return tag.split('/').filter(Boolean).at(-1) +} + +function getFallbackServiceIcon(serviceType: AnyService['service_type']) { + if (serviceType === 'HELM') return 'app://qovery-console/helm' + if (serviceType === 'TERRAFORM') return 'app://qovery-console/terraform' + return 'app://qovery-console/application' +} + +export default BlueprintUpdateFlow From 871c874172de713298600c2abb784dfac145f3cd Mon Sep 17 00:00:00 2001 From: Romain Billard Date: Mon, 6 Jul 2026 18:00:35 +0200 Subject: [PATCH 02/35] Add other steps of the blueprint update flow --- .../service/$serviceId/update/blueprint.tsx | 4 + libs/domains/services/feature/src/index.ts | 1 + ...e-blueprint-update-preview-socket.spec.tsx | 180 +++++++++++++++ .../use-blueprint-update-preview-socket.ts | 214 ++++++++++++++++++ .../blueprint/blueprint-update-flow.tsx | 192 ++++++++++++---- .../use-react-query-ws-subscription.spec.tsx | 16 ++ .../use-react-query-ws-subscription.ts | 14 +- 7 files changed, 578 insertions(+), 43 deletions(-) create mode 100644 libs/domains/services/feature/src/lib/hooks/use-blueprint-update-preview-socket/use-blueprint-update-preview-socket.spec.tsx create mode 100644 libs/domains/services/feature/src/lib/hooks/use-blueprint-update-preview-socket/use-blueprint-update-preview-socket.ts diff --git a/apps/console/src/routes/_authenticated/organization/$organizationId/project/$projectId/environment/$environmentId/service/$serviceId/update/blueprint.tsx b/apps/console/src/routes/_authenticated/organization/$organizationId/project/$projectId/environment/$environmentId/service/$serviceId/update/blueprint.tsx index 33de773a25d..3cac902e4e0 100644 --- a/apps/console/src/routes/_authenticated/organization/$organizationId/project/$projectId/environment/$environmentId/service/$serviceId/update/blueprint.tsx +++ b/apps/console/src/routes/_authenticated/organization/$organizationId/project/$projectId/environment/$environmentId/service/$serviceId/update/blueprint.tsx @@ -1,5 +1,6 @@ import { createFileRoute, useNavigate } from '@tanstack/react-router' import { useCallback, useEffect } from 'react' +import { useEnvironment } from '@qovery/domains/environments/feature' import { BlueprintUpdateFlow, useService } from '@qovery/domains/services/feature' export const Route = createFileRoute( @@ -11,6 +12,7 @@ export const Route = createFileRoute( function RouteComponent() { const { organizationId, projectId, environmentId, serviceId } = Route.useParams() const navigate = useNavigate() + const { data: environment } = useEnvironment({ environmentId, suspense: true }) const { data: service } = useService({ environmentId, serviceId, suspense: true }) const blueprintId = service && 'blueprint_id' in service ? service.blueprint_id : undefined const navigateToOverview = useCallback(() => { @@ -29,7 +31,9 @@ function RouteComponent() { return ( diff --git a/libs/domains/services/feature/src/index.ts b/libs/domains/services/feature/src/index.ts index ab5fc8e38ab..c461f4b9f7e 100644 --- a/libs/domains/services/feature/src/index.ts +++ b/libs/domains/services/feature/src/index.ts @@ -34,6 +34,7 @@ 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/hooks/use-blueprint-update/use-blueprint-update' +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/hooks/use-blueprint-service-created-socket/use-blueprint-service-created-socket' diff --git a/libs/domains/services/feature/src/lib/hooks/use-blueprint-update-preview-socket/use-blueprint-update-preview-socket.spec.tsx b/libs/domains/services/feature/src/lib/hooks/use-blueprint-update-preview-socket/use-blueprint-update-preview-socket.spec.tsx new file mode 100644 index 00000000000..b6ccc90e7fd --- /dev/null +++ b/libs/domains/services/feature/src/lib/hooks/use-blueprint-update-preview-socket/use-blueprint-update-preview-socket.spec.tsx @@ -0,0 +1,180 @@ +import { type QueryClient } from '@tanstack/react-query' +import { act, renderHook } from '@testing-library/react' +import { useReactQueryWsSubscription } from '@qovery/state/util-queries' +import { useBlueprintUpdatePreviewSocket } from './use-blueprint-update-preview-socket' + +jest.mock('@qovery/shared/util-node-env', () => ({ + QOVERY_WS: 'wss://ws.qovery.com', +})) + +jest.mock('@qovery/state/util-queries', () => ({ + useReactQueryWsSubscription: jest.fn(), +})) + +const useReactQueryWsSubscriptionMock = jest.mocked(useReactQueryWsSubscription) + +describe('useBlueprintUpdatePreviewSocket', () => { + beforeEach(() => { + jest.clearAllMocks() + }) + + it('should subscribe to the blueprint preview endpoint with organization, cluster and preview id', () => { + renderHook(() => + useBlueprintUpdatePreviewSocket({ + organizationId: 'org-1', + clusterId: 'cluster-1', + previewId: 'preview-1', + }) + ) + + expect(useReactQueryWsSubscriptionMock).toHaveBeenCalledWith( + expect.objectContaining({ + url: 'wss://ws.qovery.com/blueprint/preview', + urlSearchParams: { + organization: 'org-1', + cluster: 'cluster-1', + preview_id: 'preview-1', + }, + enabled: true, + }) + ) + }) + + it('should not subscribe until all params are available', () => { + renderHook(() => + useBlueprintUpdatePreviewSocket({ + organizationId: 'org-1', + previewId: 'preview-1', + }) + ) + + expect(useReactQueryWsSubscriptionMock).toHaveBeenCalledWith(expect.objectContaining({ enabled: false })) + }) + + it('should expose deployment impact and raw output from socket messages', () => { + const { result } = renderHook(() => + useBlueprintUpdatePreviewSocket({ + organizationId: 'org-1', + clusterId: 'cluster-1', + previewId: 'preview-1', + }) + ) + const subscriptionConfig = useReactQueryWsSubscriptionMock.mock.calls[0]?.[0] + + act(() => { + subscriptionConfig?.onOpen?.({} as QueryClient, {} as Event) + expect(result.current.hasReceivedMessage).toBe(false) + subscriptionConfig?.onMessage?.({} as QueryClient, { + deployment_impact: { + severity: 'high', + description: [ + 'Upgrading engine_version forces a full recreation on AWS.', + 'Verify your database endpoint before confirming.', + ], + impacted_services: ['Postgres database', 'Redis cache'], + }, + raw_output: '# Terraform will perform the following actions:', + }) + }) + + expect(result.current.isLoading).toBe(false) + expect(result.current.hasReceivedMessage).toBe(true) + expect(result.current.impact).toEqual({ + level: 'high', + description: [ + 'Upgrading engine_version forces a full recreation on AWS.', + 'Verify your database endpoint before confirming.', + ], + impactedServices: ['Postgres database', 'Redis cache'], + }) + expect(result.current.rawOutput).toBe('# Terraform will perform the following actions:') + }) + + it('should append plain text output chunks', () => { + const { result } = renderHook(() => + useBlueprintUpdatePreviewSocket({ + organizationId: 'org-1', + clusterId: 'cluster-1', + previewId: 'preview-1', + }) + ) + const subscriptionConfig = useReactQueryWsSubscriptionMock.mock.calls[0]?.[0] + + act(() => { + subscriptionConfig?.onMessage?.({} as QueryClient, '# first line') + subscriptionConfig?.onMessage?.({} as QueryClient, '# second line') + }) + + expect(result.current.rawOutput).toBe('# first line\n# second line') + expect(result.current.hasReceivedMessage).toBe(true) + }) + + it('should use the payload property as raw output', () => { + const { result } = renderHook(() => + useBlueprintUpdatePreviewSocket({ + organizationId: 'org-1', + clusterId: 'cluster-1', + previewId: 'preview-1', + }) + ) + const subscriptionConfig = useReactQueryWsSubscriptionMock.mock.calls[0]?.[0] + + act(() => { + subscriptionConfig?.onMessage?.({} as QueryClient, { + type: 'diff', + payload: '\nTerraform will perform the following actions:\n\n # aws_db_instance.this will be created', + service_type: 'TERRAFORM', + }) + }) + + expect(result.current.rawOutput).toBe( + '\nTerraform will perform the following actions:\n\n # aws_db_instance.this will be created' + ) + expect(result.current.hasReceivedMessage).toBe(true) + }) + + it('should reset message state when preview id changes', () => { + const { result, rerender } = renderHook( + ({ previewId }) => + useBlueprintUpdatePreviewSocket({ + organizationId: 'org-1', + clusterId: 'cluster-1', + previewId, + }), + { initialProps: { previewId: 'preview-1' } } + ) + const subscriptionConfig = useReactQueryWsSubscriptionMock.mock.calls[0]?.[0] + + act(() => { + subscriptionConfig?.onMessage?.({} as QueryClient, '# output') + }) + + expect(result.current.hasReceivedMessage).toBe(true) + + rerender({ previewId: 'preview-2' }) + + expect(result.current.hasReceivedMessage).toBe(false) + expect(result.current.rawOutput).toBe('') + }) + + it('should keep websocket lifecycle handlers stable after state updates', () => { + renderHook(() => + useBlueprintUpdatePreviewSocket({ + organizationId: 'org-1', + clusterId: 'cluster-1', + previewId: 'preview-1', + }) + ) + const subscriptionConfig = useReactQueryWsSubscriptionMock.mock.calls[0]?.[0] + + act(() => { + subscriptionConfig?.onOpen?.({} as QueryClient, {} as Event) + }) + + const nextSubscriptionConfig = useReactQueryWsSubscriptionMock.mock.calls.at(-1)?.[0] + expect(nextSubscriptionConfig?.onOpen).toBe(subscriptionConfig?.onOpen) + expect(nextSubscriptionConfig?.onError).toBe(subscriptionConfig?.onError) + expect(nextSubscriptionConfig?.onClose).toBe(subscriptionConfig?.onClose) + expect(nextSubscriptionConfig?.onMessage).toBe(subscriptionConfig?.onMessage) + }) +}) diff --git a/libs/domains/services/feature/src/lib/hooks/use-blueprint-update-preview-socket/use-blueprint-update-preview-socket.ts b/libs/domains/services/feature/src/lib/hooks/use-blueprint-update-preview-socket/use-blueprint-update-preview-socket.ts new file mode 100644 index 00000000000..07d4b57d7ec --- /dev/null +++ b/libs/domains/services/feature/src/lib/hooks/use-blueprint-update-preview-socket/use-blueprint-update-preview-socket.ts @@ -0,0 +1,214 @@ +import { type QueryClient } from '@tanstack/react-query' +import { useCallback, useEffect, useState } from 'react' +import { QOVERY_WS } from '@qovery/shared/util-node-env' +import { useReactQueryWsSubscription } from '@qovery/state/util-queries' + +export type BlueprintUpdatePreviewImpactLevel = 'low' | 'medium' | 'high' | 'unknown' + +export interface BlueprintUpdatePreviewImpact { + level: BlueprintUpdatePreviewImpactLevel + description: string[] + impactedServices: string[] +} + +export interface BlueprintUpdatePreviewSocketData { + impact?: BlueprintUpdatePreviewImpact + rawOutput: string + isLoading: boolean + hasError: boolean + hasReceivedMessage: boolean +} + +export interface UseBlueprintUpdatePreviewSocketProps { + organizationId?: string + clusterId?: string + previewId?: string + enabled?: boolean +} + +type PreviewSocketObjectMessage = Record + +export function useBlueprintUpdatePreviewSocket({ + organizationId, + clusterId, + previewId, + enabled = true, +}: UseBlueprintUpdatePreviewSocketProps): BlueprintUpdatePreviewSocketData { + const [impact, setImpact] = useState() + const [rawOutput, setRawOutput] = useState('') + const [isLoading, setIsLoading] = useState(false) + const [hasError, setHasError] = useState(false) + const [hasReceivedMessage, setHasReceivedMessage] = useState(false) + + useEffect(() => { + setImpact(undefined) + setRawOutput('') + setIsLoading(false) + setHasError(false) + setHasReceivedMessage(false) + }, [clusterId, organizationId, previewId]) + + const appendRawOutput = useCallback((nextRawOutput: string) => { + setRawOutput((currentRawOutput) => { + if (!currentRawOutput) return nextRawOutput + return `${currentRawOutput}${nextRawOutput.startsWith('\n') ? '' : '\n'}${nextRawOutput}` + }) + }, []) + + const handleMessage = useCallback( + (_: QueryClient, message: unknown) => { + setIsLoading(false) + setHasReceivedMessage(true) + + const nextImpact = getPreviewImpact(message) + if (nextImpact) { + setImpact(nextImpact) + } + + const nextRawOutput = getPreviewRawOutput(message) + if (nextRawOutput) { + appendRawOutput(nextRawOutput) + } + }, + [appendRawOutput] + ) + + const handleOpen = useCallback(() => { + setIsLoading(true) + setHasError(false) + }, []) + + const handleError = useCallback(() => { + setIsLoading(false) + setHasError(true) + }, []) + + const handleClose = useCallback(() => { + setIsLoading(false) + }, []) + + useReactQueryWsSubscription({ + url: QOVERY_WS + '/blueprint/preview', + urlSearchParams: { + organization: organizationId, + cluster: clusterId, + preview_id: previewId, + }, + enabled: enabled && Boolean(organizationId) && Boolean(clusterId) && Boolean(previewId), + onOpen: handleOpen, + onMessage: handleMessage, + onError: handleError, + onClose: handleClose, + }) + + return { + impact, + rawOutput, + isLoading, + hasError, + hasReceivedMessage, + } +} + +function getPreviewImpact(message: unknown): BlueprintUpdatePreviewImpact | undefined { + const payload = getObjectMessage(message) + if (!payload) return undefined + + const impactPayload = getObjectValue(payload, 'deployment_impact') ?? getObjectValue(payload, 'deploymentImpact') + const source = impactPayload ?? (isImpactMessage(payload) ? payload : undefined) + if (!source) return undefined + + const description = getStringArrayValue(source, 'description') + .concat(getStringArrayValue(source, 'summary')) + .concat(getStringArrayValue(source, 'message')) + const impactedServices = + getStringArrayValue(source, 'impacted_services').length > 0 + ? getStringArrayValue(source, 'impacted_services') + : getStringArrayValue(source, 'impactedServices').concat(getStringArrayValue(source, 'services')) + + return { + level: getImpactLevel(source), + description, + impactedServices, + } +} + +function getPreviewRawOutput(message: unknown): string | undefined { + if (typeof message === 'string') return message + + const payload = getObjectMessage(message) + if (!payload) return undefined + + const rawOutput = + getStringValue(payload, 'payload') ?? + getStringValue(payload, 'raw_output') ?? + getStringValue(payload, 'rawOutput') ?? + getStringValue(payload, 'output') ?? + getStringValue(payload, 'line') ?? + getStringValue(payload, 'log') + + if (rawOutput) return rawOutput + + const messageValue = getStringValue(payload, 'message') + const type = getStringValue(payload, 'type') ?? getStringValue(payload, 'event') + if (messageValue && type && /raw|output|log/i.test(type)) { + return messageValue + } + + const logs = getStringArrayValue(payload, 'logs') + return logs.length > 0 ? logs.join('\n') : undefined +} + +function getObjectMessage(message: unknown): PreviewSocketObjectMessage | undefined { + if (!message || typeof message !== 'object' || Array.isArray(message)) { + return undefined + } + + return message as PreviewSocketObjectMessage +} + +function getObjectValue(payload: PreviewSocketObjectMessage, key: string): PreviewSocketObjectMessage | undefined { + const value = payload[key] + if (!value || typeof value !== 'object' || Array.isArray(value)) { + return undefined + } + + return value as PreviewSocketObjectMessage +} + +function getStringValue(payload: PreviewSocketObjectMessage, key: string): string | undefined { + const value = payload[key] + return typeof value === 'string' && value.length > 0 ? value : undefined +} + +function getStringArrayValue(payload: PreviewSocketObjectMessage, key: string): string[] { + const value = payload[key] + if (typeof value === 'string' && value.length > 0) { + return value.split('\n\n').filter(Boolean) + } + if (Array.isArray(value)) { + return value.filter((item): item is string => typeof item === 'string' && item.length > 0) + } + + return [] +} + +function getImpactLevel(payload: PreviewSocketObjectMessage): BlueprintUpdatePreviewImpactLevel { + const level = + getStringValue(payload, 'level') ?? getStringValue(payload, 'severity') ?? getStringValue(payload, 'impact') + + if (!level) return 'unknown' + + const normalizedLevel = level.toLowerCase() + if (normalizedLevel.includes('high')) return 'high' + if (normalizedLevel.includes('medium')) return 'medium' + if (normalizedLevel.includes('low')) return 'low' + return 'unknown' +} + +function isImpactMessage(payload: PreviewSocketObjectMessage): boolean { + const type = getStringValue(payload, 'type') ?? getStringValue(payload, 'event') + return Boolean(type && /impact|summary|analysis/i.test(type)) +} + +export default useBlueprintUpdatePreviewSocket diff --git a/libs/domains/services/feature/src/lib/service-update-flow/blueprint/blueprint-update-flow.tsx b/libs/domains/services/feature/src/lib/service-update-flow/blueprint/blueprint-update-flow.tsx index bbdb7e880bf..7b95e2d8ca4 100644 --- a/libs/domains/services/feature/src/lib/service-update-flow/blueprint/blueprint-update-flow.tsx +++ b/libs/domains/services/feature/src/lib/service-update-flow/blueprint/blueprint-update-flow.tsx @@ -2,6 +2,11 @@ import { type IconName } from '@fortawesome/fontawesome-common-types' import { type ReactNode, useEffect, useState } from 'react' import { type AnyService } from '@qovery/domains/services/data-access' import { Button, FunnelFlowBody, Icon, InputText, LogoIcon, Skeleton, Tooltip, toast } from '@qovery/shared/ui' +import { + type BlueprintUpdatePreviewImpact, + type BlueprintUpdatePreviewImpactLevel, + useBlueprintUpdatePreviewSocket, +} from '../../hooks/use-blueprint-update-preview-socket/use-blueprint-update-preview-socket' import { useBlueprintUpdate } from '../../hooks/use-blueprint-update/use-blueprint-update' import { usePreviewBlueprintUpdate } from '../../hooks/use-preview-blueprint-update/use-preview-blueprint-update' import { useUpdateBlueprint } from '../../hooks/use-update-blueprint/use-update-blueprint' @@ -12,8 +17,10 @@ type BlueprintUpdateVariablePatch = Record void + organizationId: string service: AnyService } @@ -49,7 +56,14 @@ const updateSections: Array<{ }, ] -export function BlueprintUpdateFlow({ blueprintId, environmentId, onExit, service }: BlueprintUpdateFlowProps) { +export function BlueprintUpdateFlow({ + blueprintId, + clusterId, + environmentId, + onExit, + organizationId, + service, +}: BlueprintUpdateFlowProps) { const { data: blueprintUpdate } = useBlueprintUpdate({ blueprintId, suspense: true }) const { mutateAsync: previewBlueprintUpdate, @@ -65,12 +79,14 @@ export function BlueprintUpdateFlow({ blueprintId, environmentId, onExit, servic const [activeSection, setActiveSection] = useState('required') const [completedSections, setCompletedSections] = useState([]) const [values, setValues] = useState>({}) + const [initializedBlueprintId, setInitializedBlueprintId] = useState() useEffect(() => { - if (blueprintUpdate && Object.keys(values).length === 0) { + if (blueprintUpdate && initializedBlueprintId !== blueprintId) { setValues(getInitialUpdateValues(blueprintUpdate)) + setInitializedBlueprintId(blueprintId) } - }, [blueprintUpdate, values]) + }, [blueprintId, blueprintUpdate, initializedBlueprintId]) if (!blueprintUpdate) return null @@ -175,8 +191,9 @@ export function BlueprintUpdateFlow({ blueprintId, environmentId, onExit, servic ) : ( setCurrentStep('review')} onConfirm={handleUpdate} loading={isUpdateLoading} @@ -405,9 +422,10 @@ function UpdatedValuesList({
{values.map((value) => { const label = formatUpdateFieldLabel(value.name) - const editedValue = editableValues[value.name] ?? value.current_value ?? '' - const hasCurrentOverride = Boolean(value.current_value) - const hasEditedOverride = editedValue.length > 0 + const hasManualOverride = Object.prototype.hasOwnProperty.call(editableValues, value.name) + const editedValue = hasManualOverride ? editableValues[value.name] ?? '' : value.current_value ?? '' + const newDefaultValue = value.new_default_value ?? '' + const hasEditedOverride = hasManualOverride && editedValue !== newDefaultValue const editing = editedValueName === value.name return ( @@ -432,12 +450,11 @@ function UpdatedValuesList({
- {hasCurrentOverride && ( + {hasEditedOverride && ( - Reset the override to the new default value. Overrides are kept by default when a blueprint - default changes. + Reset the override to the new default value } > @@ -518,18 +535,32 @@ function CodeChip({ children, color = 'neutral' }: { children: ReactNode; color? } function BlueprintUpdatePreview({ + clusterId, loading, onBack, onConfirm, + organizationId, previewId, - serviceType, }: { + clusterId?: string loading: boolean onBack: () => void onConfirm: () => void + organizationId: string previewId?: string - serviceType?: string }) { + const { + impact, + rawOutput, + isLoading: isPreviewOutputLoading, + hasError: hasPreviewOutputError, + hasReceivedMessage: hasReceivedPreviewMessage, + } = useBlueprintUpdatePreviewSocket({ + organizationId, + clusterId, + previewId, + }) + return (
@@ -543,32 +574,22 @@ function BlueprintUpdatePreview({
-
-
- - Preview generated -
-

- The blueprint update preview has been created. Review the generated preview before confirming the update. -

-
- Preview ID: - {previewId ?? 'pending'} - {serviceType && ( - <> - Service type: - {serviceType} - - )} -
-
+

Raw output

-
- {previewId ? ( -
{`Preview ${previewId} is ready. Raw infrastructure output is not returned by the current API response.`}
+
+ {rawOutput ? ( +
{rawOutput}
+ ) : previewId ? ( +

+ {hasPreviewOutputError ? 'Unable to load preview output.' : 'Waiting for preview output…'} +

) : ( )} @@ -580,7 +601,14 @@ function BlueprintUpdatePreview({ - @@ -589,13 +617,93 @@ function BlueprintUpdatePreview({ ) } +function PreviewImpactCard({ + hasError, + impact, + loading, +}: { + hasError: boolean + impact?: BlueprintUpdatePreviewImpact + loading: boolean +}) { + if (loading) { + return ( +
+ +
+ + + +
+
+ ) + } + + if (!impact || (impact.description.length === 0 && impact.impactedServices.length === 0)) { + return ( +
+
+ + {hasError ? 'Preview unavailable' : 'Preview pending'} +
+

+ {hasError + ? 'The deployment impact could not be loaded. You can go back and generate the preview again.' + : 'Deployment impact will appear here when available.'} +

+
+ ) + } + + return ( +
+ + {impact.description.length > 0 && ( +
+ {impact.description.map((description) => ( +

{description}

+ ))} +
+ )} + {impact.impactedServices.length > 0 && ( +
+

Impacted services

+
+ {impact.impactedServices.map((service) => ( + {service} + ))} +
+
+ )} +
+ ) +} + +function PreviewImpactBadge({ level }: { level: BlueprintUpdatePreviewImpactLevel }) { + const badgeClassName = { + high: 'border-negative-component bg-surface-negative-component text-negative', + medium: 'border-warning-component bg-surface-warning-component text-warning', + low: 'border-positive-component bg-surface-positive-component text-positive', + unknown: 'border-info-subtle bg-surface-info-component text-info', + }[level] + + const iconName = level === 'high' || level === 'medium' ? 'circle-exclamation' : 'circle-info' + + return ( +
+ + {getImpactLabel(level)} +
+ ) +} + +function getImpactLabel(level: BlueprintUpdatePreviewImpactLevel) { + return `${level.charAt(0).toUpperCase()}${level.slice(1)} impact` +} + function getInitialUpdateValues(blueprintUpdate: NonNullable['data']>) { return { ...Object.fromEntries(blueprintUpdate.new_optional_values.map((value) => [value.name, value.default_value ?? ''])), - ...Object.fromEntries(blueprintUpdate.updated_values.map((value) => [value.name, value.current_value ?? ''])), - ...Object.fromEntries( - blueprintUpdate.engine_diff.updated_values.map((value) => [value.name, value.current_value ?? '']) - ), } } @@ -613,7 +721,7 @@ function buildBlueprintUpdatePayload({ optionalValues: Array<{ name: string; default_value?: string | null }> requiredValues: Array<{ name: string }> tag: string - updatedValues: Array<{ name: string; current_value?: string | null }> + updatedValues: Array<{ name: string; new_default_value?: string | null }> values: Record }) { const variables: BlueprintUpdateVariablePatch = {} @@ -626,9 +734,9 @@ function buildBlueprintUpdatePayload({ const value = values[name]?.trim() if (value && value !== defaultValue) variables[name] = { value } }) - updatedValues.forEach(({ name, current_value: currentValue }) => { + updatedValues.forEach(({ name, new_default_value: newDefaultValue }) => { const value = values[name]?.trim() - if (value && value !== currentValue) variables[name] = { value } + if (value && value !== newDefaultValue) variables[name] = { value } }) return { diff --git a/libs/state/util-queries/src/lib/use-react-query-ws-subscription/use-react-query-ws-subscription.spec.tsx b/libs/state/util-queries/src/lib/use-react-query-ws-subscription/use-react-query-ws-subscription.spec.tsx index 50e220f3020..256a2a35bdc 100644 --- a/libs/state/util-queries/src/lib/use-react-query-ws-subscription/use-react-query-ws-subscription.spec.tsx +++ b/libs/state/util-queries/src/lib/use-react-query-ws-subscription/use-react-query-ws-subscription.spec.tsx @@ -101,6 +101,22 @@ describe('useReactQueryWsSubscription', () => { unmount() }) + it('should call onMessage handler with plain text messages', async () => { + server.close() + server = new WS('ws://localhost:4321') + const onMessage = jest.fn() + const { unmount } = renderHook(() => useReactQueryWsSubscription({ url: 'ws://localhost:4321', onMessage })) + const queryClient = useQueryClient() + const connection = await server.connected + + server.send('raw output line') + + expect(queryClient.invalidateQueries).not.toHaveBeenCalled() + expect(onMessage).toHaveBeenNthCalledWith(1, queryClient, 'raw output line') + connection.close() + unmount() + }) + it('should do nothing when not enabled', async () => { const onMessage = jest.fn() const { unmount } = renderHook(() => diff --git a/libs/state/util-queries/src/lib/use-react-query-ws-subscription/use-react-query-ws-subscription.ts b/libs/state/util-queries/src/lib/use-react-query-ws-subscription/use-react-query-ws-subscription.ts index 9c7d1b96f99..9854f204ff5 100644 --- a/libs/state/util-queries/src/lib/use-react-query-ws-subscription/use-react-query-ws-subscription.ts +++ b/libs/state/util-queries/src/lib/use-react-query-ws-subscription/use-react-query-ws-subscription.ts @@ -32,6 +32,18 @@ function isInvalidateOperation(data: any): data is InvalidateOperation { return Array.isArray(data?.entity) } +function parseWebSocketMessageData(data: MessageEvent['data']) { + if (typeof data !== 'string') { + return data + } + + try { + return JSON.parse(data) + } catch { + return data + } +} + // TODO: Add better naming for the hook we can use it without ReactQuery export function useReactQueryWsSubscription({ url, @@ -87,7 +99,7 @@ export function useReactQueryWsSubscription({ onOpen?.(queryClient, event) } websocket.onmessage = async (event) => { - const data = typeof event.data === 'string' ? JSON.parse(event.data) : event.data + const data = parseWebSocketMessageData(event.data) if (isInvalidateOperation(data)) { const queryKey = [...data.entity, data.id].filter(Boolean) From 5e63991fd6c514a39dd74a9baea90b5a5231ad90 Mon Sep 17 00:00:00 2001 From: Romain Billard Date: Tue, 7 Jul 2026 15:55:55 +0200 Subject: [PATCH 03/35] First section should be open without user interaction --- .../blueprint/blueprint-update-flow.tsx | 19 ++++++++++++++++++- 1 file changed, 18 insertions(+), 1 deletion(-) diff --git a/libs/domains/services/feature/src/lib/service-update-flow/blueprint/blueprint-update-flow.tsx b/libs/domains/services/feature/src/lib/service-update-flow/blueprint/blueprint-update-flow.tsx index 7b95e2d8ca4..09f78e7dc75 100644 --- a/libs/domains/services/feature/src/lib/service-update-flow/blueprint/blueprint-update-flow.tsx +++ b/libs/domains/services/feature/src/lib/service-update-flow/blueprint/blueprint-update-flow.tsx @@ -76,7 +76,9 @@ export function BlueprintUpdateFlow({ serviceType: service.service_type, }) const [currentStep, setCurrentStep] = useState<'review' | 'preview'>('review') - const [activeSection, setActiveSection] = useState('required') + const [activeSection, setActiveSection] = useState(() => + blueprintUpdate ? getFirstAvailableUpdateSection(blueprintUpdate) : 'required' + ) const [completedSections, setCompletedSections] = useState([]) const [values, setValues] = useState>({}) const [initializedBlueprintId, setInitializedBlueprintId] = useState() @@ -84,6 +86,7 @@ export function BlueprintUpdateFlow({ useEffect(() => { if (blueprintUpdate && initializedBlueprintId !== blueprintId) { setValues(getInitialUpdateValues(blueprintUpdate)) + setActiveSection(getFirstAvailableUpdateSection(blueprintUpdate)) setInitializedBlueprintId(blueprintId) } }, [blueprintId, blueprintUpdate, initializedBlueprintId]) @@ -707,6 +710,20 @@ function getInitialUpdateValues(blueprintUpdate: NonNullable['data']> +): BlueprintUpdateSection { + if (blueprintUpdate.new_required_values.length > 0 || blueprintUpdate.now_required_values.length > 0) { + return 'required' + } + if (blueprintUpdate.new_optional_values.length > 0) return 'optional' + if (blueprintUpdate.updated_values.length > 0 || blueprintUpdate.engine_diff.updated_values.length > 0) { + return 'modified' + } + if (blueprintUpdate.removed_values.length > 0) return 'removed' + return 'required' +} + function buildBlueprintUpdatePayload({ icon, name, From f6b2d0da60ca94e4e22a2695344e8f9247f26911 Mon Sep 17 00:00:00 2001 From: Romain Billard Date: Tue, 7 Jul 2026 16:03:42 +0200 Subject: [PATCH 04/35] Fix lint issue --- .../blueprint/blueprint-update-flow.tsx | 16 +++++++--------- 1 file changed, 7 insertions(+), 9 deletions(-) diff --git a/libs/domains/services/feature/src/lib/service-update-flow/blueprint/blueprint-update-flow.tsx b/libs/domains/services/feature/src/lib/service-update-flow/blueprint/blueprint-update-flow.tsx index 09f78e7dc75..8d72567af23 100644 --- a/libs/domains/services/feature/src/lib/service-update-flow/blueprint/blueprint-update-flow.tsx +++ b/libs/domains/services/feature/src/lib/service-update-flow/blueprint/blueprint-update-flow.tsx @@ -454,13 +454,7 @@ function UpdatedValuesList({
{hasEditedOverride && ( - - Reset the override to the new default value - - } - > + Reset the override to the new default value}> @@ -693,7 +689,9 @@ function PreviewImpactBadge({ level }: { level: BlueprintUpdatePreviewImpactLeve const iconName = level === 'high' || level === 'medium' ? 'circle-exclamation' : 'circle-info' return ( -
+
{getImpactLabel(level)}
From 517f8005471ef9e23d6a8e09625f794763895736 Mon Sep 17 00:00:00 2001 From: Romain Billard Date: Tue, 7 Jul 2026 17:44:37 +0200 Subject: [PATCH 05/35] Support form field types --- .../blueprint-field-utils.ts | 135 +++++++++++ .../blueprint-manifest-variable-input.tsx | 14 +- .../checkbox-field.tsx | 0 .../blueprint-configuration-view.tsx | 10 +- .../blueprint-create-context.ts | 2 +- .../blueprint-creation-utils.ts | 136 +---------- .../blueprint-manifest-context.tsx | 6 +- .../blueprint-step-summary.tsx | 10 +- .../blueprint/blueprint-update-flow.tsx | 226 ++++++++++++------ package.json | 2 +- yarn.lock | 10 +- 11 files changed, 323 insertions(+), 228 deletions(-) create mode 100644 libs/domains/services/feature/src/lib/blueprint-field-utils/blueprint-field-utils.ts rename libs/domains/services/feature/src/lib/{service-creation-flow/blueprint/blueprint-configuration-view/blueprint-creation-components => }/blueprint-manifest-variable-input/blueprint-manifest-variable-input.tsx (85%) rename libs/domains/services/feature/src/lib/{service-creation-flow/blueprint/blueprint-configuration-view/blueprint-creation-components/checkbox-field => blueprint-manifest-variable-input}/checkbox-field.tsx (100%) diff --git a/libs/domains/services/feature/src/lib/blueprint-field-utils/blueprint-field-utils.ts b/libs/domains/services/feature/src/lib/blueprint-field-utils/blueprint-field-utils.ts new file mode 100644 index 00000000000..761bda23046 --- /dev/null +++ b/libs/domains/services/feature/src/lib/blueprint-field-utils/blueprint-field-utils.ts @@ -0,0 +1,135 @@ +import { + type BlueprintManifestContextVariableField, + type BlueprintManifestResponseResultsInner, + type BlueprintManifestVariableField, +} from 'qovery-typescript-axios' + +export type BlueprintFieldValue = string | boolean +export type BlueprintFieldValues = Record +export type BlueprintFieldPath = `fields.${string}` + +export type OverridableBlueprintManifestContextVariableField = BlueprintManifestContextVariableField & { + overridable?: boolean +} + +export function getBlueprintFieldPath(name: string): BlueprintFieldPath { + return `fields.${name}` +} + +export function formatFieldLabel(name: string) { + const label = name.replace(/_/g, ' ') + return `${label.charAt(0).toUpperCase()}${label.slice(1)}` +} + +export function getDefaultFieldValue(field: BlueprintManifestVariableField): BlueprintFieldValue { + if (field.type.type === 'bool') return field.default_value === 'true' + return field.default_value ?? '' +} + +export function getDefaultContextFieldValue(field: BlueprintManifestContextVariableField): BlueprintFieldValue { + return field.value ?? '' +} + +export function getDefaultBlueprintFieldValues(blueprintManifestFields: BlueprintManifestResponseResultsInner[]) { + const requiredBlueprintFields = blueprintManifestFields.filter(isRequiredVariableField) + const optionalBlueprintFields = blueprintManifestFields.filter(isOptionalVariableField) + const overridableContextBlueprintFields = blueprintManifestFields.filter(isOverridableContextVariableField) + const fieldsWithDefaultValue = [...requiredBlueprintFields, ...optionalBlueprintFields] + + return { + ...Object.fromEntries(fieldsWithDefaultValue.map((field) => [field.name, getDefaultFieldValue(field)])), + ...Object.fromEntries( + overridableContextBlueprintFields.map((field) => [field.name, getDefaultContextFieldValue(field)]) + ), + } +} + +export function getStringFieldValue(value: BlueprintFieldValue | undefined) { + return typeof value === 'string' ? value : '' +} + +export function getBooleanFieldValue(value: BlueprintFieldValue | undefined) { + return typeof value === 'boolean' ? value : false +} + +export function isFieldValueFulfilled(value: BlueprintFieldValue | undefined) { + if (typeof value === 'boolean') return true + return Boolean(value?.trim()) +} + +export function isFieldValueMatchingPattern( + field: BlueprintManifestVariableField, + value: BlueprintFieldValue | undefined +) { + if (typeof value !== 'string' || !value || !field.type.pattern) return true + + try { + return new RegExp(field.type.pattern).test(value) + } catch { + return true + } +} + +export function getFieldLengthValidationError( + field: BlueprintManifestVariableField, + value: BlueprintFieldValue | undefined +) { + if (typeof value !== 'string' || !value) return undefined + + const { min_length: minLength, max_length: maxLength } = field.type + const hasMinLength = typeof minLength === 'number' + const hasMaxLength = typeof maxLength === 'number' + + if (hasMinLength && hasMaxLength && (value.length < minLength || value.length > maxLength)) { + return `Value must be between ${minLength} and ${maxLength} characters.` + } + + if (hasMinLength && value.length < minLength) return `Value must be at least ${minLength} characters.` + if (hasMaxLength && value.length > maxLength) return `Value must be at most ${maxLength} characters.` + + return undefined +} + +export function getFieldValidationError(field: BlueprintManifestVariableField, value: BlueprintFieldValue | undefined) { + const lengthValidationError = getFieldLengthValidationError(field, value) + if (lengthValidationError) return lengthValidationError + + if (!isFieldValueMatchingPattern(field, value)) return 'Value does not match the expected format.' + return undefined +} + +export function isFieldValid(field: BlueprintManifestVariableField, value: BlueprintFieldValue | undefined) { + if (field.required && !isFieldValueFulfilled(value)) return false + return !getFieldValidationError(field, value) +} + +export function isVariableField(field: BlueprintManifestResponseResultsInner): field is BlueprintManifestVariableField { + return field.kind === 'variable' +} + +export function isRequiredVariableField( + field: BlueprintManifestResponseResultsInner +): field is BlueprintManifestVariableField { + return isVariableField(field) && field.required +} + +export function isOptionalVariableField( + field: BlueprintManifestResponseResultsInner +): field is BlueprintManifestVariableField { + return isVariableField(field) && !field.required +} + +export function isOverridableContextVariableField( + field: BlueprintManifestResponseResultsInner +): field is OverridableBlueprintManifestContextVariableField { + return field.kind === 'contextVariable' && 'overridable' in field && field.overridable === true +} + +export function getSummaryFieldValue( + field: BlueprintManifestVariableField | OverridableBlueprintManifestContextVariableField, + value: BlueprintFieldValue | undefined +) { + if (typeof value === 'boolean') return value ? 'Enabled' : 'Disabled' + if (field.kind === 'variable' && field.is_secret && value) return '••••••••' + return value +} diff --git a/libs/domains/services/feature/src/lib/service-creation-flow/blueprint/blueprint-configuration-view/blueprint-creation-components/blueprint-manifest-variable-input/blueprint-manifest-variable-input.tsx b/libs/domains/services/feature/src/lib/blueprint-manifest-variable-input/blueprint-manifest-variable-input.tsx similarity index 85% rename from libs/domains/services/feature/src/lib/service-creation-flow/blueprint/blueprint-configuration-view/blueprint-creation-components/blueprint-manifest-variable-input/blueprint-manifest-variable-input.tsx rename to libs/domains/services/feature/src/lib/blueprint-manifest-variable-input/blueprint-manifest-variable-input.tsx index 45710393463..64efab45748 100644 --- a/libs/domains/services/feature/src/lib/service-creation-flow/blueprint/blueprint-configuration-view/blueprint-creation-components/blueprint-manifest-variable-input/blueprint-manifest-variable-input.tsx +++ b/libs/domains/services/feature/src/lib/blueprint-manifest-variable-input/blueprint-manifest-variable-input.tsx @@ -5,13 +5,14 @@ import { formatFieldLabel, getBooleanFieldValue, getStringFieldValue, -} from '../../../blueprint-creation-utils/blueprint-creation-utils' -import { CheckboxField } from '../checkbox-field/checkbox-field' +} from '../blueprint-field-utils/blueprint-field-utils' +import { CheckboxField } from './checkbox-field' export interface BlueprintManifestVariableInputProps { autoFocus?: boolean error?: string field: BlueprintManifestVariableField + label?: string onChange: (value: BlueprintFieldValue) => void value: BlueprintFieldValue | undefined } @@ -20,15 +21,16 @@ export function BlueprintManifestVariableInput({ autoFocus, error, field, + label, onChange, value, }: BlueprintManifestVariableInputProps) { - const label = formatFieldLabel(field.name) + const inputLabel = label ?? formatFieldLabel(field.name) if (field.allowed_values?.length) { return ( ({ label: allowedValue, value: allowedValue }))} autoFocus={autoFocus} @@ -46,7 +48,7 @@ export function BlueprintManifestVariableInput({ autoFocus={autoFocus} checked={getBooleanFieldValue(value)} description={field.description ?? ''} - label={label} + label={inputLabel} name={field.name} onChange={onChange} /> @@ -56,7 +58,7 @@ export function BlueprintManifestVariableInput({ return ( -export type BlueprintFieldPath = `fields.${string}` - -export type OverridableBlueprintManifestContextVariableField = BlueprintManifestContextVariableField & { - overridable?: boolean -} - -export function getBlueprintFieldPath(name: string): BlueprintFieldPath { - return `fields.${name}` -} - -export function formatFieldLabel(name: string) { - const label = name.replace(/_/g, ' ') - return `${label.charAt(0).toUpperCase()}${label.slice(1)}` -} +import { + type BlueprintFieldValues, + type OverridableBlueprintManifestContextVariableField, +} from '../../../blueprint-field-utils/blueprint-field-utils' export function sortBlueprintMajorVersions(versions: BlueprintMajorVersion[]) { return [...versions].sort((a, b) => @@ -32,119 +17,6 @@ export function sortBlueprintMajorVersions(versions: BlueprintMajorVersion[]) { ) } -export function getDefaultFieldValue(field: BlueprintManifestVariableField): BlueprintFieldValue { - if (field.type.type === 'bool') return field.default_value === 'true' - return field.default_value ?? '' -} - -export function getDefaultContextFieldValue(field: BlueprintManifestContextVariableField): BlueprintFieldValue { - return field.value ?? '' -} - -export function getDefaultBlueprintFieldValues(blueprintManifestFields: BlueprintManifestResponseResultsInner[]) { - const requiredBlueprintFields = blueprintManifestFields.filter(isRequiredVariableField) - const optionalBlueprintFields = blueprintManifestFields.filter(isOptionalVariableField) - const overridableContextBlueprintFields = blueprintManifestFields.filter(isOverridableContextVariableField) - const fieldsWithDefaultValue = [...requiredBlueprintFields, ...optionalBlueprintFields] - - return { - ...Object.fromEntries(fieldsWithDefaultValue.map((field) => [field.name, getDefaultFieldValue(field)])), - ...Object.fromEntries( - overridableContextBlueprintFields.map((field) => [field.name, getDefaultContextFieldValue(field)]) - ), - } -} - -export function getStringFieldValue(value: BlueprintFieldValue | undefined) { - return typeof value === 'string' ? value : '' -} - -export function getBooleanFieldValue(value: BlueprintFieldValue | undefined) { - return typeof value === 'boolean' ? value : false -} - -export function isFieldValueFulfilled(value: BlueprintFieldValue | undefined) { - if (typeof value === 'boolean') return true - return Boolean(value?.trim()) -} - -export function isFieldValueMatchingPattern( - field: BlueprintManifestVariableField, - value: BlueprintFieldValue | undefined -) { - if (typeof value !== 'string' || !value || !field.type.pattern) return true - - try { - return new RegExp(field.type.pattern).test(value) - } catch { - return true - } -} - -export function getFieldLengthValidationError( - field: BlueprintManifestVariableField, - value: BlueprintFieldValue | undefined -) { - if (typeof value !== 'string' || !value) return undefined - - const { min_length: minLength, max_length: maxLength } = field.type - const hasMinLength = typeof minLength === 'number' - const hasMaxLength = typeof maxLength === 'number' - - if (hasMinLength && hasMaxLength && (value.length < minLength || value.length > maxLength)) { - return `Value must be between ${minLength} and ${maxLength} characters.` - } - - if (hasMinLength && value.length < minLength) return `Value must be at least ${minLength} characters.` - if (hasMaxLength && value.length > maxLength) return `Value must be at most ${maxLength} characters.` - - return undefined -} - -export function getFieldValidationError(field: BlueprintManifestVariableField, value: BlueprintFieldValue | undefined) { - const lengthValidationError = getFieldLengthValidationError(field, value) - if (lengthValidationError) return lengthValidationError - - if (!isFieldValueMatchingPattern(field, value)) return 'Value does not match the expected format.' - return undefined -} - -export function isFieldValid(field: BlueprintManifestVariableField, value: BlueprintFieldValue | undefined) { - if (field.required && !isFieldValueFulfilled(value)) return false - return !getFieldValidationError(field, value) -} - -export function isVariableField(field: BlueprintManifestResponseResultsInner): field is BlueprintManifestVariableField { - return field.kind === 'variable' -} - -export function isRequiredVariableField( - field: BlueprintManifestResponseResultsInner -): field is BlueprintManifestVariableField { - return isVariableField(field) && field.required -} - -export function isOptionalVariableField( - field: BlueprintManifestResponseResultsInner -): field is BlueprintManifestVariableField { - return isVariableField(field) && !field.required -} - -export function isOverridableContextVariableField( - field: BlueprintManifestResponseResultsInner -): field is OverridableBlueprintManifestContextVariableField { - return field.kind === 'contextVariable' && 'overridable' in field && field.overridable === true -} - -export function getSummaryFieldValue( - field: BlueprintManifestVariableField | OverridableBlueprintManifestContextVariableField, - value: BlueprintFieldValue | undefined -) { - if (typeof value === 'boolean') return value ? 'Enabled' : 'Disabled' - if (field.kind === 'variable' && field.is_secret && value) return '••••••••' - return value -} - export function buildBlueprintVariables( fields: BlueprintFieldValues, blueprintFields: Array diff --git a/libs/domains/services/feature/src/lib/service-creation-flow/blueprint/blueprint-manifest-context/blueprint-manifest-context.tsx b/libs/domains/services/feature/src/lib/service-creation-flow/blueprint/blueprint-manifest-context/blueprint-manifest-context.tsx index f1de20d906d..88f0e088a3a 100644 --- a/libs/domains/services/feature/src/lib/service-creation-flow/blueprint/blueprint-manifest-context/blueprint-manifest-context.tsx +++ b/libs/domains/services/feature/src/lib/service-creation-flow/blueprint/blueprint-manifest-context/blueprint-manifest-context.tsx @@ -4,15 +4,15 @@ import { type BlueprintManifestVariableField, } from 'qovery-typescript-axios' import { type PropsWithChildren, createContext, useContext, useEffect, useMemo } from 'react' -import { useBlueprintCatalogServiceManifest } from '../../../hooks/use-blueprint-catalog-service-manifest/use-blueprint-catalog-service-manifest' -import { useBlueprintCreateContext } from '../blueprint-create-context/blueprint-create-context' import { type OverridableBlueprintManifestContextVariableField, getDefaultBlueprintFieldValues, isOptionalVariableField, isOverridableContextVariableField, isRequiredVariableField, -} from '../blueprint-creation-utils/blueprint-creation-utils' +} from '../../../blueprint-field-utils/blueprint-field-utils' +import { useBlueprintCatalogServiceManifest } from '../../../hooks/use-blueprint-catalog-service-manifest/use-blueprint-catalog-service-manifest' +import { useBlueprintCreateContext } from '../blueprint-create-context/blueprint-create-context' interface BlueprintManifestContextInterface { blueprintManifestFields: BlueprintManifestResponseResultsInner[] diff --git a/libs/domains/services/feature/src/lib/service-creation-flow/blueprint/blueprint-step-summary/blueprint-step-summary.tsx b/libs/domains/services/feature/src/lib/service-creation-flow/blueprint/blueprint-step-summary/blueprint-step-summary.tsx index 197a89d65cc..259792b591b 100644 --- a/libs/domains/services/feature/src/lib/service-creation-flow/blueprint/blueprint-step-summary/blueprint-step-summary.tsx +++ b/libs/domains/services/feature/src/lib/service-creation-flow/blueprint/blueprint-step-summary/blueprint-step-summary.tsx @@ -3,15 +3,15 @@ import posthog from 'posthog-js' import { type BlueprintCreateRequest } from 'qovery-typescript-axios' import { useCallback, useEffect, useRef, useState } from 'react' import { Button, FunnelFlowBody, Heading, Icon, Section, SummaryValue, toast } from '@qovery/shared/ui' -import { useBlueprintServiceCreatedSocket } from '../../../hooks/use-blueprint-service-created-socket/use-blueprint-service-created-socket' -import { useCreateBlueprint } from '../../../hooks/use-create-blueprint/use-create-blueprint' -import { useBlueprintCreateContext } from '../blueprint-create-context/blueprint-create-context' import { - buildBlueprintVariables, formatFieldLabel, getSummaryFieldValue, isFieldValid, -} from '../blueprint-creation-utils/blueprint-creation-utils' +} from '../../../blueprint-field-utils/blueprint-field-utils' +import { useBlueprintServiceCreatedSocket } from '../../../hooks/use-blueprint-service-created-socket/use-blueprint-service-created-socket' +import { useCreateBlueprint } from '../../../hooks/use-create-blueprint/use-create-blueprint' +import { useBlueprintCreateContext } from '../blueprint-create-context/blueprint-create-context' +import { buildBlueprintVariables } from '../blueprint-creation-utils/blueprint-creation-utils' import { useBlueprintManifestFields } from '../blueprint-manifest-context/blueprint-manifest-context' type BlueprintConfigurationSection = 'service-information' | 'blueprint-setup' | 'overrides' diff --git a/libs/domains/services/feature/src/lib/service-update-flow/blueprint/blueprint-update-flow.tsx b/libs/domains/services/feature/src/lib/service-update-flow/blueprint/blueprint-update-flow.tsx index 8d72567af23..b553bb4fd3e 100644 --- a/libs/domains/services/feature/src/lib/service-update-flow/blueprint/blueprint-update-flow.tsx +++ b/libs/domains/services/feature/src/lib/service-update-flow/blueprint/blueprint-update-flow.tsx @@ -1,7 +1,22 @@ import { type IconName } from '@fortawesome/fontawesome-common-types' +import { + type BlueprintManifestVariableField, + type BlueprintUpdateEngineFieldChange, + type BlueprintUpdateNewOptionalValue, + type BlueprintUpdateNewRequiredValue, + type BlueprintUpdateRemovedValue, + type BlueprintUpdateUpdatedValue, +} from 'qovery-typescript-axios' import { type ReactNode, useEffect, useState } from 'react' import { type AnyService } from '@qovery/domains/services/data-access' import { Button, FunnelFlowBody, Icon, InputText, LogoIcon, Skeleton, Tooltip, toast } from '@qovery/shared/ui' +import { + type BlueprintFieldValue, + type BlueprintFieldValues, + getFieldValidationError, + isFieldValid, +} from '../../blueprint-field-utils/blueprint-field-utils' +import { BlueprintManifestVariableInput } from '../../blueprint-manifest-variable-input/blueprint-manifest-variable-input' import { type BlueprintUpdatePreviewImpact, type BlueprintUpdatePreviewImpactLevel, @@ -14,6 +29,11 @@ import { useUpdateBlueprint } from '../../hooks/use-update-blueprint/use-update- type BlueprintUpdateSection = 'required' | 'optional' | 'modified' | 'removed' type BlueprintUpdateVariablePatch = Record +type BlueprintUpdateField = + | BlueprintUpdateNewRequiredValue + | BlueprintUpdateNewOptionalValue + | BlueprintUpdateUpdatedValue +type BlueprintUpdateEditableValue = BlueprintUpdateUpdatedValue | BlueprintUpdateEngineFieldChange export interface BlueprintUpdateFlowProps { blueprintId: string @@ -80,7 +100,7 @@ export function BlueprintUpdateFlow({ blueprintUpdate ? getFirstAvailableUpdateSection(blueprintUpdate) : 'required' ) const [completedSections, setCompletedSections] = useState([]) - const [values, setValues] = useState>({}) + const [values, setValues] = useState({}) const [initializedBlueprintId, setInitializedBlueprintId] = useState() useEffect(() => { @@ -103,7 +123,9 @@ export function BlueprintUpdateFlow({ const visibleSections = updateSections.filter(({ id }) => sectionHasContent[id]) const reviewSections = visibleSections.length > 0 ? visibleSections : updateSections.slice(0, 1) const activeSectionIndex = reviewSections.findIndex(({ id }) => id === activeSection) - const isRequiredValid = requiredValues.every(({ name }) => Boolean(values[name]?.trim())) + const isRequiredValid = requiredValues.every((value) => + isFieldValid(getBlueprintUpdateVariableField(value, true), values[value.name]) + ) const isReviewComplete = reviewSections.every(({ id }) => completedSections.includes(id)) || reviewSections.length === 0 const latestVersion = getVersionFromTag(blueprintUpdate.latest_tag) ?? blueprintUpdate.latest_tag @@ -335,50 +357,52 @@ function BlueprintUpdateSectionContent({ values, }: { continueDisabled: boolean - onChange: (name: string, value: string) => void + onChange: (name: string, value: BlueprintFieldValue) => void onContinue: () => void - optionalValues: Array<{ name: string; default_value?: string | null }> - removedValues: Array<{ name: string }> - requiredValues: Array<{ name: string }> + optionalValues: BlueprintUpdateNewOptionalValue[] + removedValues: BlueprintUpdateRemovedValue[] + requiredValues: BlueprintUpdateNewRequiredValue[] section: BlueprintUpdateSection - updatedValues: Array<{ - name: string - current_default_value?: string | null - new_default_value?: string | null - current_value?: string | null - }> - values: Record + updatedValues: BlueprintUpdateEditableValue[] + values: BlueprintFieldValues }) { return ( <> {section === 'required' && (
- {requiredValues.map(({ name }, index) => ( - onChange(name, event.currentTarget.value)} - /> - ))} + {requiredValues.map((value, index) => { + const field = getBlueprintUpdateVariableField(value, true) + + return ( + onChange(value.name, fieldValue)} + /> + ) + })}
)} {section === 'optional' && (
- {optionalValues.map(({ name, default_value: defaultValue }, index) => ( - onChange(name, event.currentTarget.value)} - /> - ))} + {optionalValues.map((value, index) => { + const field = getBlueprintUpdateVariableField(value, false, value.default_value) + + return ( + onChange(value.name, fieldValue)} + /> + ) + })}
)} @@ -408,14 +432,9 @@ function UpdatedValuesList({ onChange, values, }: { - editableValues: Record - onChange: (name: string, value: string) => void - values: Array<{ - name: string - current_default_value?: string | null - new_default_value?: string | null - current_value?: string | null - }> + editableValues: BlueprintFieldValues + onChange: (name: string, value: BlueprintFieldValue) => void + values: BlueprintUpdateEditableValue[] }) { const [editedValueName, setEditedValueName] = useState() @@ -426,10 +445,16 @@ function UpdatedValuesList({ {values.map((value) => { const label = formatUpdateFieldLabel(value.name) const hasManualOverride = Object.prototype.hasOwnProperty.call(editableValues, value.name) - const editedValue = hasManualOverride ? editableValues[value.name] ?? '' : value.current_value ?? '' + const editedValue = hasManualOverride + ? editableValues[value.name] ?? '' + : getBlueprintUpdateFieldValue(value, value.current_value) const newDefaultValue = value.new_default_value ?? '' - const hasEditedOverride = hasManualOverride && editedValue !== newDefaultValue + const hasEditedOverride = hasManualOverride && getBlueprintUpdatePayloadValue(editedValue) !== newDefaultValue const editing = editedValueName === value.name + const canUseTypedInput = isBlueprintUpdateVariableField(value) + const field = canUseTypedInput + ? getBlueprintUpdateVariableField(value, false, value.new_default_value) + : undefined return (
@@ -446,7 +471,7 @@ function UpdatedValuesList({ {hasEditedOverride && (
Override: - {editedValue} + {formatUpdateValue(editedValue)}
)}
@@ -463,7 +488,7 @@ function UpdatedValuesList({ iconOnly aria-label={`Reset ${label} override to default`} onClick={() => { - onChange(value.name, value.new_default_value ?? '') + onChange(value.name, getBlueprintUpdateFieldValue(value, value.new_default_value)) setEditedValueName(undefined) }} > @@ -489,15 +514,25 @@ function UpdatedValuesList({
- {editing && ( - onChange(value.name, event.currentTarget.value)} - /> - )} + {editing && + (field ? ( + onChange(value.name, fieldValue)} + /> + ) : ( + onChange(value.name, event.currentTarget.value)} + /> + ))}
) })} @@ -704,7 +739,12 @@ function getImpactLabel(level: BlueprintUpdatePreviewImpactLevel) { function getInitialUpdateValues(blueprintUpdate: NonNullable['data']>) { return { - ...Object.fromEntries(blueprintUpdate.new_optional_values.map((value) => [value.name, value.default_value ?? ''])), + ...Object.fromEntries( + blueprintUpdate.new_optional_values.map((value) => [ + value.name, + getBlueprintUpdateFieldValue(value, value.default_value), + ]) + ), } } @@ -733,25 +773,30 @@ function buildBlueprintUpdatePayload({ }: { icon: string name: string - optionalValues: Array<{ name: string; default_value?: string | null }> - requiredValues: Array<{ name: string }> + optionalValues: BlueprintUpdateNewOptionalValue[] + requiredValues: BlueprintUpdateNewRequiredValue[] tag: string - updatedValues: Array<{ name: string; new_default_value?: string | null }> - values: Record + updatedValues: BlueprintUpdateEditableValue[] + values: BlueprintFieldValues }) { const variables: BlueprintUpdateVariablePatch = {} - requiredValues.forEach(({ name }) => { - const value = values[name]?.trim() - if (value) variables[name] = { value } + requiredValues.forEach((field) => { + const value = getBlueprintUpdatePayloadValue(values[field.name]) + if (value) variables[field.name] = { value, is_secret: field.is_secret } }) - optionalValues.forEach(({ name, default_value: defaultValue }) => { - const value = values[name]?.trim() - if (value && value !== defaultValue) variables[name] = { value } + optionalValues.forEach((field) => { + const value = getBlueprintUpdatePayloadValue(values[field.name]) + if (value && value !== field.default_value) variables[field.name] = { value, is_secret: field.is_secret } }) - updatedValues.forEach(({ name, new_default_value: newDefaultValue }) => { - const value = values[name]?.trim() - if (value && value !== newDefaultValue) variables[name] = { value } + updatedValues.forEach((field) => { + const value = getBlueprintUpdatePayloadValue(values[field.name]) + if (!value || value === field.new_default_value) return + + variables[field.name] = { + value, + ...(isBlueprintUpdateVariableField(field) ? { is_secret: field.is_secret } : {}), + } }) return { @@ -762,12 +807,53 @@ function buildBlueprintUpdatePayload({ } } +function getBlueprintUpdateVariableField( + field: BlueprintUpdateField, + required: boolean, + defaultValue?: string | null +): BlueprintManifestVariableField { + return { + kind: 'variable', + name: field.name, + type: field.type, + required, + is_secret: field.is_secret, + allowed_values: field.allowed_values, + default_value: defaultValue, + } +} + +function isBlueprintUpdateVariableField( + field: BlueprintUpdateEditableValue | BlueprintUpdateNewOptionalValue +): field is BlueprintUpdateUpdatedValue | BlueprintUpdateNewOptionalValue { + return 'type' in field && 'is_secret' in field +} + +function getBlueprintUpdateFieldValue( + field: BlueprintUpdateEditableValue | BlueprintUpdateNewOptionalValue, + value?: string | null +): BlueprintFieldValue { + if (isBlueprintUpdateVariableField(field) && field.type.type === 'bool' && !field.allowed_values?.length) { + return value === 'true' + } + + return value ?? '' +} + +function getBlueprintUpdatePayloadValue(value: BlueprintFieldValue | undefined) { + if (typeof value === 'boolean') return String(value) + + const trimmedValue = value?.trim() + return trimmedValue ? trimmedValue : undefined +} + function formatUpdateFieldLabel(name: string) { const label = name.replace(/_/g, ' ') return `${label.charAt(0).toUpperCase()}${label.slice(1)}` } -function formatUpdateValue(value?: string | null) { +function formatUpdateValue(value?: BlueprintFieldValue | string | null) { + if (typeof value === 'boolean') return String(value) return value && value.length > 0 ? value : '-' } diff --git a/package.json b/package.json index c7c4212cafb..d9920530f5f 100644 --- a/package.json +++ b/package.json @@ -77,7 +77,7 @@ "mermaid": "11.6.0", "monaco-editor": "0.53.0", "posthog-js": "1.345.1", - "qovery-typescript-axios": "1.1.919", + "qovery-typescript-axios": "1.1.920", "react": "18.3.1", "react-country-flag": "3.0.2", "react-datepicker": "4.12.0", diff --git a/yarn.lock b/yarn.lock index bad0e7431eb..f8ddeabf67c 100644 --- a/yarn.lock +++ b/yarn.lock @@ -6343,7 +6343,7 @@ __metadata: prettier: 3.2.5 prettier-plugin-tailwindcss: 0.5.14 pretty-quick: 4.0.0 - qovery-typescript-axios: 1.1.919 + qovery-typescript-axios: 1.1.920 qovery-ws-typescript-axios: 0.1.586 react: 18.3.1 react-country-flag: 3.0.2 @@ -25862,12 +25862,12 @@ __metadata: languageName: node linkType: hard -"qovery-typescript-axios@npm:1.1.919": - version: 1.1.919 - resolution: "qovery-typescript-axios@npm:1.1.919" +"qovery-typescript-axios@npm:1.1.920": + version: 1.1.920 + resolution: "qovery-typescript-axios@npm:1.1.920" dependencies: axios: 1.15.2 - checksum: dea1b215b607bc3e9c94ab1faa5389f5d1319981f0a775c7bfab2a1e1c42ba42c1e6893b7d6763b8a19e0856156dd116adf4e8aa07503d43237e29ac0dd4e553 + checksum: ea8634a4c39cffb4127fd69ff1e9f59948db6ab35ddce30afd8a32312ae5a2394e88196b6ab25f47bf5a8d1801c839d5b63e8d7ca708da1da77eb9bdf6fe0e68 languageName: node linkType: hard From 1cd82ac1b68f84be4ae8fcc739698e52067ce9a2 Mon Sep 17 00:00:00 2001 From: Romain Billard Date: Wed, 8 Jul 2026 09:27:39 +0200 Subject: [PATCH 06/35] Remove useless AI section and update the form field for boolean values --- .../blueprint-manifest-variable-input.tsx | 33 +++-- .../checkbox-field.tsx | 44 ------ .../blueprint/blueprint-update-flow.tsx | 129 +++--------------- 3 files changed, 35 insertions(+), 171 deletions(-) delete mode 100644 libs/domains/services/feature/src/lib/blueprint-manifest-variable-input/checkbox-field.tsx diff --git a/libs/domains/services/feature/src/lib/blueprint-manifest-variable-input/blueprint-manifest-variable-input.tsx b/libs/domains/services/feature/src/lib/blueprint-manifest-variable-input/blueprint-manifest-variable-input.tsx index 64efab45748..a8b1505e047 100644 --- a/libs/domains/services/feature/src/lib/blueprint-manifest-variable-input/blueprint-manifest-variable-input.tsx +++ b/libs/domains/services/feature/src/lib/blueprint-manifest-variable-input/blueprint-manifest-variable-input.tsx @@ -1,12 +1,11 @@ import { type BlueprintManifestVariableField } from 'qovery-typescript-axios' -import { InputSelect, InputText } from '@qovery/shared/ui' +import { InputSelect, InputText, InputToggle } from '@qovery/shared/ui' import { type BlueprintFieldValue, formatFieldLabel, getBooleanFieldValue, getStringFieldValue, } from '../blueprint-field-utils/blueprint-field-utils' -import { CheckboxField } from './checkbox-field' export interface BlueprintManifestVariableInputProps { autoFocus?: boolean @@ -27,6 +26,23 @@ export function BlueprintManifestVariableInput({ }: BlueprintManifestVariableInputProps) { const inputLabel = label ?? formatFieldLabel(field.name) + if (field.type.type === 'bool') { + return ( +
+ +
+ ) + } + if (field.allowed_values?.length) { return ( - ) - } - return ( void -} - -export function CheckboxField({ - autoFocus, - checked, - children, - description, - label, - name, - onChange, -}: CheckboxFieldProps) { - return ( -
-
- { - if (checked === 'indeterminate') return - onChange(checked) - }} - /> - -
-

{description}

- {children} -
- ) -} diff --git a/libs/domains/services/feature/src/lib/service-update-flow/blueprint/blueprint-update-flow.tsx b/libs/domains/services/feature/src/lib/service-update-flow/blueprint/blueprint-update-flow.tsx index b553bb4fd3e..a9a403f6060 100644 --- a/libs/domains/services/feature/src/lib/service-update-flow/blueprint/blueprint-update-flow.tsx +++ b/libs/domains/services/feature/src/lib/service-update-flow/blueprint/blueprint-update-flow.tsx @@ -9,7 +9,17 @@ import { } from 'qovery-typescript-axios' import { type ReactNode, useEffect, useState } from 'react' import { type AnyService } from '@qovery/domains/services/data-access' -import { Button, FunnelFlowBody, Icon, InputText, LogoIcon, Skeleton, Tooltip, toast } from '@qovery/shared/ui' +import { + Button, + FunnelFlowBody, + Icon, + InputText, + LoaderSpinner, + LogoIcon, + Skeleton, + Tooltip, + toast, +} from '@qovery/shared/ui' import { type BlueprintFieldValue, type BlueprintFieldValues, @@ -17,11 +27,7 @@ import { isFieldValid, } from '../../blueprint-field-utils/blueprint-field-utils' import { BlueprintManifestVariableInput } from '../../blueprint-manifest-variable-input/blueprint-manifest-variable-input' -import { - type BlueprintUpdatePreviewImpact, - type BlueprintUpdatePreviewImpactLevel, - useBlueprintUpdatePreviewSocket, -} from '../../hooks/use-blueprint-update-preview-socket/use-blueprint-update-preview-socket' +import { useBlueprintUpdatePreviewSocket } from '../../hooks/use-blueprint-update-preview-socket/use-blueprint-update-preview-socket' import { useBlueprintUpdate } from '../../hooks/use-blueprint-update/use-blueprint-update' import { usePreviewBlueprintUpdate } from '../../hooks/use-preview-blueprint-update/use-preview-blueprint-update' import { useUpdateBlueprint } from '../../hooks/use-update-blueprint/use-update-blueprint' @@ -447,7 +453,7 @@ function UpdatedValuesList({ const hasManualOverride = Object.prototype.hasOwnProperty.call(editableValues, value.name) const editedValue = hasManualOverride ? editableValues[value.name] ?? '' - : getBlueprintUpdateFieldValue(value, value.current_value) + : getBlueprintUpdateFieldValue(value, value.new_default_value) const newDefaultValue = value.new_default_value ?? '' const hasEditedOverride = hasManualOverride && getBlueprintUpdatePayloadValue(editedValue) !== newDefaultValue const editing = editedValueName === value.name @@ -584,7 +590,6 @@ function BlueprintUpdatePreview({ previewId?: string }) { const { - impact, rawOutput, isLoading: isPreviewOutputLoading, hasError: hasPreviewOutputError, @@ -600,30 +605,16 @@ function BlueprintUpdatePreview({

Preview changes

-
-
-

Estimated deployment impact

-
- Generated by AI - -
-
- -
-

Raw output

{rawOutput ? (
{rawOutput}
) : previewId ? ( -

- {hasPreviewOutputError ? 'Unable to load preview output.' : 'Waiting for preview output…'} -

+
+ {!hasPreviewOutputError && } +

{hasPreviewOutputError ? 'Unable to load preview output.' : 'Waiting for preview output…'}

+
) : ( )} @@ -651,92 +642,6 @@ function BlueprintUpdatePreview({ ) } -function PreviewImpactCard({ - hasError, - impact, - loading, -}: { - hasError: boolean - impact?: BlueprintUpdatePreviewImpact - loading: boolean -}) { - if (loading) { - return ( -
- -
- - - -
-
- ) - } - - if (!impact || (impact.description.length === 0 && impact.impactedServices.length === 0)) { - return ( -
-
- - {hasError ? 'Preview unavailable' : 'Preview pending'} -
-

- {hasError - ? 'The deployment impact could not be loaded. You can go back and generate the preview again.' - : 'Deployment impact will appear here when available.'} -

-
- ) - } - - return ( -
- - {impact.description.length > 0 && ( -
- {impact.description.map((description) => ( -

{description}

- ))} -
- )} - {impact.impactedServices.length > 0 && ( -
-

Impacted services

-
- {impact.impactedServices.map((service) => ( - {service} - ))} -
-
- )} -
- ) -} - -function PreviewImpactBadge({ level }: { level: BlueprintUpdatePreviewImpactLevel }) { - const badgeClassName = { - high: 'border-negative-component bg-surface-negative-component text-negative', - medium: 'border-warning-component bg-surface-warning-component text-warning', - low: 'border-positive-component bg-surface-positive-component text-positive', - unknown: 'border-info-subtle bg-surface-info-component text-info', - }[level] - - const iconName = level === 'high' || level === 'medium' ? 'circle-exclamation' : 'circle-info' - - return ( -
- - {getImpactLabel(level)} -
- ) -} - -function getImpactLabel(level: BlueprintUpdatePreviewImpactLevel) { - return `${level.charAt(0).toUpperCase()}${level.slice(1)} impact` -} - function getInitialUpdateValues(blueprintUpdate: NonNullable['data']>) { return { ...Object.fromEntries( From 80032a39d902de8df37a7fbd62356ed927670d46 Mon Sep 17 00:00:00 2001 From: Romain Billard Date: Wed, 8 Jul 2026 09:42:09 +0200 Subject: [PATCH 07/35] UI tweak --- .../lib/service-update-flow/blueprint/blueprint-update-flow.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/libs/domains/services/feature/src/lib/service-update-flow/blueprint/blueprint-update-flow.tsx b/libs/domains/services/feature/src/lib/service-update-flow/blueprint/blueprint-update-flow.tsx index a9a403f6060..2062d29a29e 100644 --- a/libs/domains/services/feature/src/lib/service-update-flow/blueprint/blueprint-update-flow.tsx +++ b/libs/domains/services/feature/src/lib/service-update-flow/blueprint/blueprint-update-flow.tsx @@ -173,7 +173,7 @@ export function BlueprintUpdateFlow({ return ( {currentStep === 'review' ? ( - +

{title}

From b2f262947153e4ff79fa9130b184b93585e354f9 Mon Sep 17 00:00:00 2001 From: Romain Billard Date: Wed, 8 Jul 2026 13:05:17 +0200 Subject: [PATCH 08/35] Fix unit tests --- .../blueprint-manifest-variable-input.tsx | 1 + .../blueprint/blueprint-creation-flow.spec.tsx | 2 +- .../blueprint-creation-utils/blueprint-creation-utils.spec.ts | 4 +++- .../src/lib/components/inputs/input-toggle/input-toggle.tsx | 3 +++ 4 files changed, 8 insertions(+), 2 deletions(-) diff --git a/libs/domains/services/feature/src/lib/blueprint-manifest-variable-input/blueprint-manifest-variable-input.tsx b/libs/domains/services/feature/src/lib/blueprint-manifest-variable-input/blueprint-manifest-variable-input.tsx index a8b1505e047..16af7cce676 100644 --- a/libs/domains/services/feature/src/lib/blueprint-manifest-variable-input/blueprint-manifest-variable-input.tsx +++ b/libs/domains/services/feature/src/lib/blueprint-manifest-variable-input/blueprint-manifest-variable-input.tsx @@ -37,6 +37,7 @@ export function BlueprintManifestVariableInput({ title={inputLabel} description={field.description ?? undefined} ariaLabel={inputLabel} + autoFocus={autoFocus} onChange={onChange} />
diff --git a/libs/domains/services/feature/src/lib/service-creation-flow/blueprint/blueprint-creation-flow.spec.tsx b/libs/domains/services/feature/src/lib/service-creation-flow/blueprint/blueprint-creation-flow.spec.tsx index 199389a7114..e2dff460239 100644 --- a/libs/domains/services/feature/src/lib/service-creation-flow/blueprint/blueprint-creation-flow.spec.tsx +++ b/libs/domains/services/feature/src/lib/service-creation-flow/blueprint/blueprint-creation-flow.spec.tsx @@ -286,7 +286,7 @@ describe('BlueprintCreationFlow', () => { await userEvent.type(screen.getByLabelText('Db password'), 'super-secret') await userEvent.click(screen.getByRole('button', { name: /overrides/i })) - expect(await screen.findByRole('checkbox', { name: 'Skip final snapshot' })).toHaveFocus() + expect(await screen.findByRole('switch', { name: 'Skip final snapshot' })).toHaveFocus() }) it('should navigate to the matching configuration section from summary edit buttons', async () => { diff --git a/libs/domains/services/feature/src/lib/service-creation-flow/blueprint/blueprint-creation-utils/blueprint-creation-utils.spec.ts b/libs/domains/services/feature/src/lib/service-creation-flow/blueprint/blueprint-creation-utils/blueprint-creation-utils.spec.ts index 83a2cf41d72..2cf28de458c 100644 --- a/libs/domains/services/feature/src/lib/service-creation-flow/blueprint/blueprint-creation-utils/blueprint-creation-utils.spec.ts +++ b/libs/domains/services/feature/src/lib/service-creation-flow/blueprint/blueprint-creation-utils/blueprint-creation-utils.spec.ts @@ -6,7 +6,6 @@ import { } from 'qovery-typescript-axios' import { type OverridableBlueprintManifestContextVariableField, - buildBlueprintVariables, formatFieldLabel, getBlueprintFieldPath, getBooleanFieldValue, @@ -23,6 +22,9 @@ import { isOverridableContextVariableField, isRequiredVariableField, isVariableField, +} from '../../../blueprint-field-utils/blueprint-field-utils' +import { + buildBlueprintVariables, sortBlueprintMajorVersions, } from './blueprint-creation-utils' diff --git a/libs/shared/ui/src/lib/components/inputs/input-toggle/input-toggle.tsx b/libs/shared/ui/src/lib/components/inputs/input-toggle/input-toggle.tsx index 395ef98d19f..a6c5e7987ac 100644 --- a/libs/shared/ui/src/lib/components/inputs/input-toggle/input-toggle.tsx +++ b/libs/shared/ui/src/lib/components/inputs/input-toggle/input-toggle.tsx @@ -14,6 +14,7 @@ export interface InputToggleProps { align?: 'center' | 'top' disabled?: boolean name?: string + autoFocus?: boolean } export function InputToggle(props: InputToggleProps) { @@ -28,6 +29,7 @@ export function InputToggle(props: InputToggleProps) { disabled = false, name, ariaLabel, + autoFocus, } = props const switchId = useId() @@ -76,6 +78,7 @@ export function InputToggle(props: InputToggleProps) { checked={isChecked} disabled={disabled} name={name} + autoFocus={autoFocus} value={String(isChecked)} className={clsx( toggleSizeBg, From 49e824626c534d2a63fa94ca5fe12480d13e758a Mon Sep 17 00:00:00 2001 From: Romain Billard Date: Wed, 8 Jul 2026 13:52:18 +0200 Subject: [PATCH 09/35] Fix lint issues --- .../blueprint-creation-utils.spec.ts | 5 +---- 1 file changed, 1 insertion(+), 4 deletions(-) diff --git a/libs/domains/services/feature/src/lib/service-creation-flow/blueprint/blueprint-creation-utils/blueprint-creation-utils.spec.ts b/libs/domains/services/feature/src/lib/service-creation-flow/blueprint/blueprint-creation-utils/blueprint-creation-utils.spec.ts index 2cf28de458c..71647d7d7ea 100644 --- a/libs/domains/services/feature/src/lib/service-creation-flow/blueprint/blueprint-creation-utils/blueprint-creation-utils.spec.ts +++ b/libs/domains/services/feature/src/lib/service-creation-flow/blueprint/blueprint-creation-utils/blueprint-creation-utils.spec.ts @@ -23,10 +23,7 @@ import { isRequiredVariableField, isVariableField, } from '../../../blueprint-field-utils/blueprint-field-utils' -import { - buildBlueprintVariables, - sortBlueprintMajorVersions, -} from './blueprint-creation-utils' +import { buildBlueprintVariables, sortBlueprintMajorVersions } from './blueprint-creation-utils' function createVariableField(overrides: Partial = {}): BlueprintManifestVariableField { return { From 1cbb3dfafb64a91d3e4511972fbc8f4a2a940177 Mon Sep 17 00:00:00 2001 From: Romain Billard Date: Wed, 8 Jul 2026 17:10:42 +0200 Subject: [PATCH 10/35] Remove introduction modal --- .../service-header/service-header.spec.tsx | 20 +---- .../service-header/service-header.tsx | 81 +------------------ .../blueprint/blueprint-update-flow.tsx | 13 ++- 3 files changed, 15 insertions(+), 99 deletions(-) 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 150a7700168..8196d0bb588 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 @@ -8,8 +8,6 @@ import { ServiceHeader } from './service-header' const mockCopyToClipboard = jest.fn() const mockGetDatabaseConnectionUri = jest.fn(() => 'postgres://copied-uri') const mockUseBlueprintUpdate = jest.fn() -const mockOpenModal = jest.fn() -const mockCloseModal = jest.fn() const mockNavigate = jest.fn() const services = { 'application-mock': { @@ -127,10 +125,6 @@ jest.mock('@qovery/shared/ui', () => ({ ...jest.requireActual('@qovery/shared/ui'), toast: jest.fn(), Heading: ({ children }: { children?: ReactNode }) =>

{children}

, - useModal: () => ({ - openModal: mockOpenModal, - closeModal: mockCloseModal, - }), })) jest.mock('@qovery/shared/util-hooks', () => ({ @@ -415,7 +409,7 @@ describe('ServiceHeader', () => { expect(screen.queryByText('Update available')).not.toBeInTheDocument() }) - it('opens the blueprint update modal from the update available badge', async () => { + it('opens the blueprint update flow from the update available badge', async () => { mockUseBlueprintUpdate.mockReturnValue({ data: { is_up_to_date: false, @@ -435,19 +429,7 @@ describe('ServiceHeader', () => { await userEvent.click(screen.getByRole('button', { name: /update available/i })) - expect(mockOpenModal).toHaveBeenCalledTimes(1) - renderWithProviders(mockOpenModal.mock.calls[0][0].content) - - expect(screen.getAllByText('Update available')[0]).toBeInTheDocument() - expect(screen.getByRole('heading', { name: 'Blueprint update from 1.2 to 2.0' })).toBeInTheDocument() - expect(screen.getByRole('link', { name: 'release notes' })).toBeInTheDocument() - expect(screen.queryByText('0')).not.toBeInTheDocument() - expect(screen.getByRole('button', { name: 'Dismiss' })).toBeInTheDocument() expect(screen.queryByText('Up to date')).not.toBeInTheDocument() - - await userEvent.click(screen.getByRole('button', { name: 'Review & update' })) - - expect(mockCloseModal).toHaveBeenCalled() expect(mockNavigate).toHaveBeenCalledWith({ to: '/organization/$organizationId/project/$projectId/environment/$environmentId/service/$serviceId/update/blueprint', params: { diff --git a/libs/domains/services/feature/src/lib/service-overview/service-header/service-header.tsx b/libs/domains/services/feature/src/lib/service-overview/service-header/service-header.tsx index e5e4cda4e0a..3b6b5a56e00 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 @@ -1,10 +1,5 @@ import { Link, useNavigate, useParams } from '@tanstack/react-router' -import { - type ApplicationGitRepository, - type BlueprintUpdateResponse, - type Credentials, - type Environment, -} from 'qovery-typescript-axios' +import { type ApplicationGitRepository, type Credentials, type Environment } from 'qovery-typescript-axios' import { Suspense } from 'react' import { P, match } from 'ts-pattern' import { @@ -33,7 +28,6 @@ import { Tooltip, Truncate, toast, - useModal, } from '@qovery/shared/ui' import { buildGitProviderUrl } from '@qovery/shared/util-git' import { useCopyToClipboard } from '@qovery/shared/util-hooks' @@ -169,66 +163,8 @@ function BlueprintUpdateBadgeSkeleton() { return } -interface BlueprintUpdateModalProps { - blueprintUpdate: BlueprintUpdateResponse - currentVersion?: string - onClose: () => void - onReviewUpdate: () => void -} - -const BLUEPRINT_RELEASE_NOTES_URL = 'https://github.com/Qovery/service-catalog/releases' - -function getVersionFromTag(tag?: string) { - return tag?.split('/').at(-1) -} - -function getServiceBlueprintVersion(service: AnyService) { - if ('tag' in service && typeof service.tag === 'string') { - return getVersionFromTag(service.tag) - } - - return undefined -} - -function BlueprintUpdateModal({ blueprintUpdate, currentVersion, onClose, onReviewUpdate }: BlueprintUpdateModalProps) { - const latestVersion = getVersionFromTag(blueprintUpdate.latest_tag) ?? blueprintUpdate.latest_tag - - return ( -
-
-

- Blueprint update from {currentVersion ?? 'current version'} to {latestVersion} -

-

- This update improves bucket performance for large object transfers and hardens IAM permission scoping. See the - full{' '} - - release notes - {' '} - for more details. -

-
- -
- - -
-
- ) -} - function BlueprintUpdateBadge({ blueprintId, service }: { blueprintId: string; service: AnyService }) { const { data: blueprintUpdate } = useBlueprintUpdate({ blueprintId, suspense: true }) - const { openModal, closeModal } = useModal() const navigate = useNavigate() const { organizationId = '', @@ -236,9 +172,7 @@ function BlueprintUpdateBadge({ blueprintId, service }: { blueprintId: string; s environmentId = '', serviceId = service.id, } = useParams({ strict: false }) - const currentVersion = getServiceBlueprintVersion(service) const openUpdateFlow = () => { - closeModal() navigate({ to: '/organization/$organizationId/project/$projectId/environment/$environmentId/service/$serviceId/update/blueprint', params: { organizationId, projectId, environmentId, serviceId }, @@ -258,18 +192,7 @@ function BlueprintUpdateBadge({ blueprintId, service }: { blueprintId: string; s + {showContinueButton && ( + + )} ) } From 5282e34c963b11e8d2146511aba42d80e177d6e5 Mon Sep 17 00:00:00 2001 From: Romain Billard Date: Wed, 8 Jul 2026 17:40:53 +0200 Subject: [PATCH 12/35] Align width of update flow steps --- .../lib/service-update-flow/blueprint/blueprint-update-flow.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/libs/domains/services/feature/src/lib/service-update-flow/blueprint/blueprint-update-flow.tsx b/libs/domains/services/feature/src/lib/service-update-flow/blueprint/blueprint-update-flow.tsx index 852f29c1994..a0abb3fbfea 100644 --- a/libs/domains/services/feature/src/lib/service-update-flow/blueprint/blueprint-update-flow.tsx +++ b/libs/domains/services/feature/src/lib/service-update-flow/blueprint/blueprint-update-flow.tsx @@ -177,7 +177,7 @@ export function BlueprintUpdateFlow({ return ( {currentStep === 'review' ? ( - +

{title}

Date: Wed, 8 Jul 2026 17:48:03 +0200 Subject: [PATCH 13/35] Improve output loading state --- .../blueprint/blueprint-update-flow.tsx | 28 +++++++++++++++---- 1 file changed, 22 insertions(+), 6 deletions(-) diff --git a/libs/domains/services/feature/src/lib/service-update-flow/blueprint/blueprint-update-flow.tsx b/libs/domains/services/feature/src/lib/service-update-flow/blueprint/blueprint-update-flow.tsx index a0abb3fbfea..d4e4f82d10c 100644 --- a/libs/domains/services/feature/src/lib/service-update-flow/blueprint/blueprint-update-flow.tsx +++ b/libs/domains/services/feature/src/lib/service-update-flow/blueprint/blueprint-update-flow.tsx @@ -14,7 +14,6 @@ import { FunnelFlowBody, Icon, InputText, - LoaderSpinner, LogoIcon, Skeleton, Tooltip, @@ -629,12 +628,13 @@ function BlueprintUpdatePreview({ {rawOutput ? (
{rawOutput}
) : previewId ? ( -
- {!hasPreviewOutputError && } -

{hasPreviewOutputError ? 'Unable to load preview output.' : 'Waiting for preview output…'}

-
+ hasPreviewOutputError ? ( +

Unable to load preview output.

+ ) : ( + + ) ) : ( - + )}
@@ -660,6 +660,22 @@ function BlueprintUpdatePreview({ ) } +function BlueprintUpdateRawOutputSkeleton() { + return ( +
+
+ + Generating preview output +
+ + + + + +
+ ) +} + function getInitialUpdateValues(blueprintUpdate: NonNullable['data']>) { return { ...Object.fromEntries( From 4322bcf9bcbf4f8ba72001f38080f212aacf4ca2 Mon Sep 17 00:00:00 2001 From: Romain Billard Date: Wed, 8 Jul 2026 17:57:12 +0200 Subject: [PATCH 14/35] Increase height of raw output container --- .../lib/service-update-flow/blueprint/blueprint-update-flow.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/libs/domains/services/feature/src/lib/service-update-flow/blueprint/blueprint-update-flow.tsx b/libs/domains/services/feature/src/lib/service-update-flow/blueprint/blueprint-update-flow.tsx index d4e4f82d10c..081f74958f6 100644 --- a/libs/domains/services/feature/src/lib/service-update-flow/blueprint/blueprint-update-flow.tsx +++ b/libs/domains/services/feature/src/lib/service-update-flow/blueprint/blueprint-update-flow.tsx @@ -624,7 +624,7 @@ function BlueprintUpdatePreview({

Raw output

-
+
{rawOutput ? (
{rawOutput}
) : previewId ? ( From e495fa113a94caddad5e980aceb12f1c704fb7a2 Mon Sep 17 00:00:00 2001 From: Romain Billard Date: Wed, 8 Jul 2026 17:57:41 +0200 Subject: [PATCH 15/35] Add basic syntax highlighting for diffs --- .../blueprint/blueprint-update-flow.tsx | 27 ++++++++++++++++++- 1 file changed, 26 insertions(+), 1 deletion(-) diff --git a/libs/domains/services/feature/src/lib/service-update-flow/blueprint/blueprint-update-flow.tsx b/libs/domains/services/feature/src/lib/service-update-flow/blueprint/blueprint-update-flow.tsx index 081f74958f6..99026c33d2b 100644 --- a/libs/domains/services/feature/src/lib/service-update-flow/blueprint/blueprint-update-flow.tsx +++ b/libs/domains/services/feature/src/lib/service-update-flow/blueprint/blueprint-update-flow.tsx @@ -626,7 +626,7 @@ function BlueprintUpdatePreview({

Raw output

{rawOutput ? ( -
{rawOutput}
+ ) : previewId ? ( hasPreviewOutputError ? (

Unable to load preview output.

@@ -660,6 +660,21 @@ function BlueprintUpdatePreview({ ) } +function BlueprintUpdateRawOutput({ rawOutput }: { rawOutput: string }) { + const lines = rawOutput.split('\n') + + return ( +
+      {lines.map((line, index) => (
+        
+          {line}
+          {index < lines.length - 1 ? '\n' : null}
+        
+      ))}
+    
+ ) +} + function BlueprintUpdateRawOutputSkeleton() { return (
@@ -676,6 +691,16 @@ function BlueprintUpdateRawOutputSkeleton() { ) } +function getRawOutputLineClassName(line: string) { + const prefix = line.trimStart().charAt(0) + + if (prefix === '+') return 'text-positive' + if (prefix === '-') return 'text-negative' + if (prefix === '~') return 'text-info' + + return undefined +} + function getInitialUpdateValues(blueprintUpdate: NonNullable['data']>) { return { ...Object.fromEntries( From 0920f23e0b55c731845148cd0fcf25771ea90a7b Mon Sep 17 00:00:00 2001 From: Romain Billard Date: Wed, 8 Jul 2026 18:33:03 +0200 Subject: [PATCH 16/35] Improve loader look --- .../blueprint/blueprint-update-flow.tsx | 19 ++++++++++++------- 1 file changed, 12 insertions(+), 7 deletions(-) diff --git a/libs/domains/services/feature/src/lib/service-update-flow/blueprint/blueprint-update-flow.tsx b/libs/domains/services/feature/src/lib/service-update-flow/blueprint/blueprint-update-flow.tsx index 99026c33d2b..006cb04319c 100644 --- a/libs/domains/services/feature/src/lib/service-update-flow/blueprint/blueprint-update-flow.tsx +++ b/libs/domains/services/feature/src/lib/service-update-flow/blueprint/blueprint-update-flow.tsx @@ -616,6 +616,9 @@ function BlueprintUpdatePreview({ clusterId, previewId, }) + const rawOutputContainerHeightClassName = rawOutput + ? 'h-[min(75vh,calc(100vh-260px))] min-h-[260px]' + : 'min-h-[180px]' return ( @@ -624,7 +627,9 @@ function BlueprintUpdatePreview({

Raw output

-
+
{rawOutput ? ( ) : previewId ? ( @@ -676,17 +681,17 @@ function BlueprintUpdateRawOutput({ rawOutput }: { rawOutput: string }) { } function BlueprintUpdateRawOutputSkeleton() { + const skeletonLineWidths = ['46%', '28%', '72%', '64%', '82%', '34%'] + return ( -
+
Generating preview output
- - - - - + {skeletonLineWidths.map((width, index) => ( + + ))}
) } From 2d9deccbfc3f00ad20818e8455cb03ad3119b3d7 Mon Sep 17 00:00:00 2001 From: Romain Billard Date: Thu, 9 Jul 2026 09:33:08 +0200 Subject: [PATCH 17/35] Fix lint issues --- .../blueprint/blueprint-update-flow.tsx | 11 +---------- 1 file changed, 1 insertion(+), 10 deletions(-) diff --git a/libs/domains/services/feature/src/lib/service-update-flow/blueprint/blueprint-update-flow.tsx b/libs/domains/services/feature/src/lib/service-update-flow/blueprint/blueprint-update-flow.tsx index 006cb04319c..ee9e1338507 100644 --- a/libs/domains/services/feature/src/lib/service-update-flow/blueprint/blueprint-update-flow.tsx +++ b/libs/domains/services/feature/src/lib/service-update-flow/blueprint/blueprint-update-flow.tsx @@ -9,16 +9,7 @@ import { } from 'qovery-typescript-axios' import { type ReactNode, useEffect, useState } from 'react' import { type AnyService } from '@qovery/domains/services/data-access' -import { - Button, - FunnelFlowBody, - Icon, - InputText, - LogoIcon, - Skeleton, - Tooltip, - toast, -} from '@qovery/shared/ui' +import { Button, FunnelFlowBody, Icon, InputText, LogoIcon, Skeleton, Tooltip, toast } from '@qovery/shared/ui' import { type BlueprintFieldValue, type BlueprintFieldValues, From 91e70b8860939387d3a3ecdae15787941d012d13 Mon Sep 17 00:00:00 2001 From: Romain Billard Date: Thu, 9 Jul 2026 10:50:26 +0200 Subject: [PATCH 18/35] Clean component's props --- .../service-header/service-header.tsx | 14 ++++++++------ 1 file changed, 8 insertions(+), 6 deletions(-) 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 3b6b5a56e00..fd98ef0f9d1 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 @@ -36,6 +36,7 @@ import { ArgoCdServiceActions } from '../../argocd-service-actions/argocd-servic import AutoDeployBadge from '../../auto-deploy-badge/auto-deploy-badge' import { useBlueprintUpdate } from '../../hooks/use-blueprint-update/use-blueprint-update' import { useMasterCredentials } from '../../hooks/use-master-credentials/use-master-credentials' +import { useService } from '../../hooks/use-service/use-service' import { getDatabaseConnectionUri } from '../../service-access-modal/service-access-modal' import { ServiceActions } from '../../service-actions/service-actions' import { ServiceAvatar } from '../../service-avatar/service-avatar' @@ -163,19 +164,20 @@ function BlueprintUpdateBadgeSkeleton() { return } -function BlueprintUpdateBadge({ blueprintId, service }: { blueprintId: string; service: AnyService }) { - const { data: blueprintUpdate } = useBlueprintUpdate({ blueprintId, suspense: true }) - const navigate = useNavigate() +function BlueprintUpdateBadge({ blueprintId }: { blueprintId: string }) { const { organizationId = '', projectId = '', environmentId = '', - serviceId = service.id, + serviceId = '', } = useParams({ strict: false }) + const { data: service } = useService({ environmentId, serviceId, suspense: true }) + const { data: blueprintUpdate } = useBlueprintUpdate({ blueprintId, suspense: true }) + const navigate = useNavigate() const openUpdateFlow = () => { navigate({ to: '/organization/$organizationId/project/$projectId/environment/$environmentId/service/$serviceId/update/blueprint', - params: { organizationId, projectId, environmentId, serviceId }, + params: { organizationId, projectId, environmentId, serviceId: service?.id ?? serviceId }, }) } @@ -342,7 +344,7 @@ function ServiceHeaderMetadata({ service }: ServiceHeaderMetadataProps) { )} {blueprintId && ( }> - + )} {databaseSource && ( From 55893681a381ae0b0e741cf543ec31b7de470b71 Mon Sep 17 00:00:00 2001 From: Romain Billard Date: Thu, 9 Jul 2026 11:08:00 +0200 Subject: [PATCH 19/35] Fix lint issue --- .../lib/service-overview/service-header/service-header.tsx | 7 +------ 1 file changed, 1 insertion(+), 6 deletions(-) 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 fd98ef0f9d1..505203d5c73 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 @@ -165,12 +165,7 @@ function BlueprintUpdateBadgeSkeleton() { } function BlueprintUpdateBadge({ blueprintId }: { blueprintId: string }) { - const { - organizationId = '', - projectId = '', - environmentId = '', - serviceId = '', - } = useParams({ strict: false }) + const { organizationId = '', projectId = '', environmentId = '', serviceId = '' } = useParams({ strict: false }) const { data: service } = useService({ environmentId, serviceId, suspense: true }) const { data: blueprintUpdate } = useBlueprintUpdate({ blueprintId, suspense: true }) const navigate = useNavigate() From bb63c7dba7fd4b4ccfb4345f3280a1230c10481d Mon Sep 17 00:00:00 2001 From: Romain Billard Date: Thu, 9 Jul 2026 11:36:46 +0200 Subject: [PATCH 20/35] Remove useless CodeChip component --- .../blueprint/blueprint-update-flow.tsx | 28 +++++++++---------- 1 file changed, 13 insertions(+), 15 deletions(-) diff --git a/libs/domains/services/feature/src/lib/service-update-flow/blueprint/blueprint-update-flow.tsx b/libs/domains/services/feature/src/lib/service-update-flow/blueprint/blueprint-update-flow.tsx index ee9e1338507..2bf7954cac7 100644 --- a/libs/domains/services/feature/src/lib/service-update-flow/blueprint/blueprint-update-flow.tsx +++ b/libs/domains/services/feature/src/lib/service-update-flow/blueprint/blueprint-update-flow.tsx @@ -9,7 +9,7 @@ import { } from 'qovery-typescript-axios' import { type ReactNode, useEffect, useState } from 'react' import { type AnyService } from '@qovery/domains/services/data-access' -import { Button, FunnelFlowBody, Icon, InputText, LogoIcon, Skeleton, Tooltip, toast } from '@qovery/shared/ui' +import { Badge, Button, FunnelFlowBody, Icon, InputText, LogoIcon, Skeleton, Tooltip, toast } from '@qovery/shared/ui' import { type BlueprintFieldValue, type BlueprintFieldValues, @@ -478,14 +478,20 @@ function UpdatedValuesList({
Default: - {formatUpdateValue(value.current_default_value)} + + {formatUpdateValue(value.current_default_value)} + - {formatUpdateValue(value.new_default_value)} + + {formatUpdateValue(value.new_default_value)} +
{hasEditedOverride && (
Override: - {formatUpdateValue(editedValue)} + + {formatUpdateValue(editedValue)} +
)}
@@ -565,23 +571,15 @@ function RemovedValuesList({ values }: { values: Array<{ name: string }> }) { className="flex items-center justify-between gap-3 border-b border-neutral p-3 last:border-b-0" >

{formatUpdateFieldLabel(value.name)}

- Deleted + + Deleted +
))}
) } -function CodeChip({ children, color = 'neutral' }: { children: ReactNode; color?: 'neutral' | 'info' | 'negative' }) { - const className = { - neutral: 'border-neutral bg-surface-neutral-component text-neutral', - info: 'border-info-subtle bg-surface-info-component text-info', - negative: 'border-negative-subtle bg-surface-negative-component text-negative', - }[color] - - return {children} -} - function BlueprintUpdatePreview({ clusterId, loading, From a6d13549ab592f106fd648aeb809decf69bff2a3 Mon Sep 17 00:00:00 2001 From: Romain Billard Date: Thu, 9 Jul 2026 14:06:14 +0200 Subject: [PATCH 21/35] Fix wording --- .../lib/service-update-flow/blueprint/blueprint-update-flow.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/libs/domains/services/feature/src/lib/service-update-flow/blueprint/blueprint-update-flow.tsx b/libs/domains/services/feature/src/lib/service-update-flow/blueprint/blueprint-update-flow.tsx index 2bf7954cac7..5f68646f14a 100644 --- a/libs/domains/services/feature/src/lib/service-update-flow/blueprint/blueprint-update-flow.tsx +++ b/libs/domains/services/feature/src/lib/service-update-flow/blueprint/blueprint-update-flow.tsx @@ -63,7 +63,7 @@ const updateSections: Array<{ id: 'modified', title: 'Modified values', iconName: 'arrows-rotate', - description: "Existing values that I've seen their default updated. Your overrides stays prioritaire.", + description: "Existing values for which the default value has been updated. Your overrides has priority over the default value.", }, { id: 'removed', From 328278881a6371a9b6e32cd58e96aaeebf2e7824 Mon Sep 17 00:00:00 2001 From: Romain Billard Date: Thu, 9 Jul 2026 18:04:38 +0200 Subject: [PATCH 22/35] Split update flow into child routes and add introduction modal when no user input is needed for the update --- apps/console/src/routeTree.gen.ts | 112 ++++-- .../$serviceId/update/blueprint/index.tsx | 24 ++ .../$serviceId/update/blueprint/preview.tsx | 24 ++ .../{blueprint.tsx => blueprint/route.tsx} | 9 +- .../service-header/service-header.spec.tsx | 76 +++- .../service-header/service-header.tsx | 75 +++- .../blueprint/blueprint-update-flow.tsx | 351 ++++++++++++------ 7 files changed, 529 insertions(+), 142 deletions(-) create mode 100644 apps/console/src/routes/_authenticated/organization/$organizationId/project/$projectId/environment/$environmentId/service/$serviceId/update/blueprint/index.tsx create mode 100644 apps/console/src/routes/_authenticated/organization/$organizationId/project/$projectId/environment/$environmentId/service/$serviceId/update/blueprint/preview.tsx rename apps/console/src/routes/_authenticated/organization/$organizationId/project/$projectId/environment/$environmentId/service/$serviceId/update/{blueprint.tsx => blueprint/route.tsx} (85%) diff --git a/apps/console/src/routeTree.gen.ts b/apps/console/src/routeTree.gen.ts index 55b3852e0cd..50660620478 100644 --- a/apps/console/src/routeTree.gen.ts +++ b/apps/console/src/routeTree.gen.ts @@ -163,7 +163,6 @@ import { Route as AuthenticatedOrganizationOrganizationIdProjectProjectIdEnviron import { Route as AuthenticatedOrganizationOrganizationIdProjectProjectIdEnvironmentEnvironmentIdServiceCreateSlugGeneralRouteImport } from './routes/_authenticated/organization/$organizationId/project/$projectId/environment/$environmentId/service/create/$slug/general' import { Route as AuthenticatedOrganizationOrganizationIdProjectProjectIdEnvironmentEnvironmentIdServiceServiceIdVariablesExternalSecretsRouteImport } from './routes/_authenticated/organization/$organizationId/project/$projectId/environment/$environmentId/service/$serviceId/variables/external-secrets' import { Route as AuthenticatedOrganizationOrganizationIdProjectProjectIdEnvironmentEnvironmentIdServiceServiceIdVariablesBuiltInRouteImport } from './routes/_authenticated/organization/$organizationId/project/$projectId/environment/$environmentId/service/$serviceId/variables/built-in' -import { Route as AuthenticatedOrganizationOrganizationIdProjectProjectIdEnvironmentEnvironmentIdServiceServiceIdUpdateBlueprintRouteImport } from './routes/_authenticated/organization/$organizationId/project/$projectId/environment/$environmentId/service/$serviceId/update/blueprint' import { Route as AuthenticatedOrganizationOrganizationIdProjectProjectIdEnvironmentEnvironmentIdServiceServiceIdSettingsValuesOverrideFileRouteImport } from './routes/_authenticated/organization/$organizationId/project/$projectId/environment/$environmentId/service/$serviceId/settings/values-override-file' import { Route as AuthenticatedOrganizationOrganizationIdProjectProjectIdEnvironmentEnvironmentIdServiceServiceIdSettingsValuesOverrideArgumentsRouteImport } from './routes/_authenticated/organization/$organizationId/project/$projectId/environment/$environmentId/service/$serviceId/settings/values-override-arguments' import { Route as AuthenticatedOrganizationOrganizationIdProjectProjectIdEnvironmentEnvironmentIdServiceServiceIdSettingsTerraformVariablesRouteImport } from './routes/_authenticated/organization/$organizationId/project/$projectId/environment/$environmentId/service/$serviceId/settings/terraform-variables' @@ -183,6 +182,9 @@ import { Route as AuthenticatedOrganizationOrganizationIdProjectProjectIdEnviron import { Route as AuthenticatedOrganizationOrganizationIdProjectProjectIdEnvironmentEnvironmentIdServiceServiceIdSettingsAdvancedSettingsRouteImport } from './routes/_authenticated/organization/$organizationId/project/$projectId/environment/$environmentId/service/$serviceId/settings/advanced-settings' import { Route as AuthenticatedOrganizationOrganizationIdProjectProjectIdEnvironmentEnvironmentIdServiceServiceIdMonitoringDashboardRouteImport } from './routes/_authenticated/organization/$organizationId/project/$projectId/environment/$environmentId/service/$serviceId/monitoring/dashboard' import { Route as AuthenticatedOrganizationOrganizationIdProjectProjectIdEnvironmentEnvironmentIdServiceServiceIdMonitoringAlertsRouteImport } from './routes/_authenticated/organization/$organizationId/project/$projectId/environment/$environmentId/service/$serviceId/monitoring/alerts' +import { Route as AuthenticatedOrganizationOrganizationIdProjectProjectIdEnvironmentEnvironmentIdServiceServiceIdUpdateBlueprintRouteRouteImport } from './routes/_authenticated/organization/$organizationId/project/$projectId/environment/$environmentId/service/$serviceId/update/blueprint/route' +import { Route as AuthenticatedOrganizationOrganizationIdProjectProjectIdEnvironmentEnvironmentIdServiceServiceIdUpdateBlueprintIndexRouteImport } from './routes/_authenticated/organization/$organizationId/project/$projectId/environment/$environmentId/service/$serviceId/update/blueprint/index' +import { Route as AuthenticatedOrganizationOrganizationIdProjectProjectIdEnvironmentEnvironmentIdServiceServiceIdUpdateBlueprintPreviewRouteImport } from './routes/_authenticated/organization/$organizationId/project/$projectId/environment/$environmentId/service/$serviceId/update/blueprint/preview' import { Route as AuthenticatedOrganizationOrganizationIdProjectProjectIdEnvironmentEnvironmentIdServiceServiceIdDeploymentsLogsExecutionIdRouteImport } from './routes/_authenticated/organization/$organizationId/project/$projectId/environment/$environmentId/service/$serviceId/deployments/logs/$executionId' import { Route as AuthenticatedOrganizationOrganizationIdProjectProjectIdEnvironmentEnvironmentIdServiceCreateBlueprintProviderServiceFamilyRouteRouteImport } from './routes/_authenticated/organization/$organizationId/project/$projectId/environment/$environmentId/service/create/blueprint/$provider/$serviceFamily/route' import { Route as AuthenticatedOrganizationOrganizationIdProjectProjectIdEnvironmentEnvironmentIdServiceCreateBlueprintProviderServiceFamilyIndexRouteImport } from './routes/_authenticated/organization/$organizationId/project/$projectId/environment/$environmentId/service/create/blueprint/$provider/$serviceFamily/index' @@ -1445,14 +1447,6 @@ const AuthenticatedOrganizationOrganizationIdProjectProjectIdEnvironmentEnvironm AuthenticatedOrganizationOrganizationIdProjectProjectIdEnvironmentEnvironmentIdServiceServiceIdVariablesRouteRoute, } as any, ) -const AuthenticatedOrganizationOrganizationIdProjectProjectIdEnvironmentEnvironmentIdServiceServiceIdUpdateBlueprintRoute = - AuthenticatedOrganizationOrganizationIdProjectProjectIdEnvironmentEnvironmentIdServiceServiceIdUpdateBlueprintRouteImport.update( - { - id: '/project/$projectId/environment/$environmentId/service/$serviceId/update/blueprint', - path: '/project/$projectId/environment/$environmentId/service/$serviceId/update/blueprint', - getParentRoute: () => AuthenticatedOrganizationOrganizationIdRouteRoute, - } as any, - ) const AuthenticatedOrganizationOrganizationIdProjectProjectIdEnvironmentEnvironmentIdServiceServiceIdSettingsValuesOverrideFileRoute = AuthenticatedOrganizationOrganizationIdProjectProjectIdEnvironmentEnvironmentIdServiceServiceIdSettingsValuesOverrideFileRouteImport.update( { @@ -1624,6 +1618,32 @@ const AuthenticatedOrganizationOrganizationIdProjectProjectIdEnvironmentEnvironm AuthenticatedOrganizationOrganizationIdProjectProjectIdEnvironmentEnvironmentIdServiceServiceIdMonitoringRouteRoute, } as any, ) +const AuthenticatedOrganizationOrganizationIdProjectProjectIdEnvironmentEnvironmentIdServiceServiceIdUpdateBlueprintRouteRoute = + AuthenticatedOrganizationOrganizationIdProjectProjectIdEnvironmentEnvironmentIdServiceServiceIdUpdateBlueprintRouteRouteImport.update( + { + id: '/project/$projectId/environment/$environmentId/service/$serviceId/update/blueprint', + path: '/project/$projectId/environment/$environmentId/service/$serviceId/update/blueprint', + getParentRoute: () => AuthenticatedOrganizationOrganizationIdRouteRoute, + } as any, + ) +const AuthenticatedOrganizationOrganizationIdProjectProjectIdEnvironmentEnvironmentIdServiceServiceIdUpdateBlueprintIndexRoute = + AuthenticatedOrganizationOrganizationIdProjectProjectIdEnvironmentEnvironmentIdServiceServiceIdUpdateBlueprintIndexRouteImport.update( + { + id: '/', + path: '/', + getParentRoute: () => + AuthenticatedOrganizationOrganizationIdProjectProjectIdEnvironmentEnvironmentIdServiceServiceIdUpdateBlueprintRouteRoute, + } as any, + ) +const AuthenticatedOrganizationOrganizationIdProjectProjectIdEnvironmentEnvironmentIdServiceServiceIdUpdateBlueprintPreviewRoute = + AuthenticatedOrganizationOrganizationIdProjectProjectIdEnvironmentEnvironmentIdServiceServiceIdUpdateBlueprintPreviewRouteImport.update( + { + id: '/preview', + path: '/preview', + getParentRoute: () => + AuthenticatedOrganizationOrganizationIdProjectProjectIdEnvironmentEnvironmentIdServiceServiceIdUpdateBlueprintRouteRoute, + } as any, + ) const AuthenticatedOrganizationOrganizationIdProjectProjectIdEnvironmentEnvironmentIdServiceServiceIdDeploymentsLogsExecutionIdRoute = AuthenticatedOrganizationOrganizationIdProjectProjectIdEnvironmentEnvironmentIdServiceServiceIdDeploymentsLogsExecutionIdRouteImport.update( { @@ -1817,6 +1837,7 @@ export interface FileRoutesByFullPath { '/organization/$organizationId/project/$projectId/environment/$environmentId/deployment/$deploymentId': typeof AuthenticatedOrganizationOrganizationIdProjectProjectIdEnvironmentEnvironmentIdDeploymentDeploymentIdIndexRoute '/organization/$organizationId/project/$projectId/environment/$environmentId/service/$serviceId': typeof AuthenticatedOrganizationOrganizationIdProjectProjectIdEnvironmentEnvironmentIdServiceServiceIdIndexRoute '/organization/$organizationId/project/$projectId/environment/$environmentId/service/create': typeof AuthenticatedOrganizationOrganizationIdProjectProjectIdEnvironmentEnvironmentIdServiceCreateIndexRoute + '/organization/$organizationId/project/$projectId/environment/$environmentId/service/$serviceId/update/blueprint': typeof AuthenticatedOrganizationOrganizationIdProjectProjectIdEnvironmentEnvironmentIdServiceServiceIdUpdateBlueprintRouteRouteWithChildren '/organization/$organizationId/project/$projectId/environment/$environmentId/service/$serviceId/monitoring/alerts': typeof AuthenticatedOrganizationOrganizationIdProjectProjectIdEnvironmentEnvironmentIdServiceServiceIdMonitoringAlertsRouteWithChildren '/organization/$organizationId/project/$projectId/environment/$environmentId/service/$serviceId/monitoring/dashboard': typeof AuthenticatedOrganizationOrganizationIdProjectProjectIdEnvironmentEnvironmentIdServiceServiceIdMonitoringDashboardRoute '/organization/$organizationId/project/$projectId/environment/$environmentId/service/$serviceId/settings/advanced-settings': typeof AuthenticatedOrganizationOrganizationIdProjectProjectIdEnvironmentEnvironmentIdServiceServiceIdSettingsAdvancedSettingsRoute @@ -1836,7 +1857,6 @@ export interface FileRoutesByFullPath { '/organization/$organizationId/project/$projectId/environment/$environmentId/service/$serviceId/settings/terraform-variables': typeof AuthenticatedOrganizationOrganizationIdProjectProjectIdEnvironmentEnvironmentIdServiceServiceIdSettingsTerraformVariablesRoute '/organization/$organizationId/project/$projectId/environment/$environmentId/service/$serviceId/settings/values-override-arguments': typeof AuthenticatedOrganizationOrganizationIdProjectProjectIdEnvironmentEnvironmentIdServiceServiceIdSettingsValuesOverrideArgumentsRoute '/organization/$organizationId/project/$projectId/environment/$environmentId/service/$serviceId/settings/values-override-file': typeof AuthenticatedOrganizationOrganizationIdProjectProjectIdEnvironmentEnvironmentIdServiceServiceIdSettingsValuesOverrideFileRoute - '/organization/$organizationId/project/$projectId/environment/$environmentId/service/$serviceId/update/blueprint': typeof AuthenticatedOrganizationOrganizationIdProjectProjectIdEnvironmentEnvironmentIdServiceServiceIdUpdateBlueprintRoute '/organization/$organizationId/project/$projectId/environment/$environmentId/service/$serviceId/variables/built-in': typeof AuthenticatedOrganizationOrganizationIdProjectProjectIdEnvironmentEnvironmentIdServiceServiceIdVariablesBuiltInRoute '/organization/$organizationId/project/$projectId/environment/$environmentId/service/$serviceId/variables/external-secrets': typeof AuthenticatedOrganizationOrganizationIdProjectProjectIdEnvironmentEnvironmentIdServiceServiceIdVariablesExternalSecretsRoute '/organization/$organizationId/project/$projectId/environment/$environmentId/service/create/$slug/general': typeof AuthenticatedOrganizationOrganizationIdProjectProjectIdEnvironmentEnvironmentIdServiceCreateSlugGeneralRoute @@ -1880,6 +1900,8 @@ export interface FileRoutesByFullPath { '/organization/$organizationId/project/$projectId/environment/$environmentId/service/create/terraform/': typeof AuthenticatedOrganizationOrganizationIdProjectProjectIdEnvironmentEnvironmentIdServiceCreateTerraformIndexRoute '/organization/$organizationId/project/$projectId/environment/$environmentId/service/create/blueprint/$provider/$serviceFamily': typeof AuthenticatedOrganizationOrganizationIdProjectProjectIdEnvironmentEnvironmentIdServiceCreateBlueprintProviderServiceFamilyRouteRouteWithChildren '/organization/$organizationId/project/$projectId/environment/$environmentId/service/$serviceId/deployments/logs/$executionId': typeof AuthenticatedOrganizationOrganizationIdProjectProjectIdEnvironmentEnvironmentIdServiceServiceIdDeploymentsLogsExecutionIdRoute + '/organization/$organizationId/project/$projectId/environment/$environmentId/service/$serviceId/update/blueprint/preview': typeof AuthenticatedOrganizationOrganizationIdProjectProjectIdEnvironmentEnvironmentIdServiceServiceIdUpdateBlueprintPreviewRoute + '/organization/$organizationId/project/$projectId/environment/$environmentId/service/$serviceId/update/blueprint/': typeof AuthenticatedOrganizationOrganizationIdProjectProjectIdEnvironmentEnvironmentIdServiceServiceIdUpdateBlueprintIndexRoute '/organization/$organizationId/project/$projectId/environment/$environmentId/service/$serviceId/monitoring/alerts/$alertId/edit': typeof AuthenticatedOrganizationOrganizationIdProjectProjectIdEnvironmentEnvironmentIdServiceServiceIdMonitoringAlertsAlertIdEditRoute '/organization/$organizationId/project/$projectId/environment/$environmentId/service/create/blueprint/$provider/$serviceFamily/blueprint-setup': typeof AuthenticatedOrganizationOrganizationIdProjectProjectIdEnvironmentEnvironmentIdServiceCreateBlueprintProviderServiceFamilyBlueprintSetupRoute '/organization/$organizationId/project/$projectId/environment/$environmentId/service/create/blueprint/$provider/$serviceFamily/overrides': typeof AuthenticatedOrganizationOrganizationIdProjectProjectIdEnvironmentEnvironmentIdServiceCreateBlueprintProviderServiceFamilyOverridesRoute @@ -2001,7 +2023,6 @@ export interface FileRoutesByTo { '/organization/$organizationId/project/$projectId/environment/$environmentId/service/$serviceId/settings/terraform-variables': typeof AuthenticatedOrganizationOrganizationIdProjectProjectIdEnvironmentEnvironmentIdServiceServiceIdSettingsTerraformVariablesRoute '/organization/$organizationId/project/$projectId/environment/$environmentId/service/$serviceId/settings/values-override-arguments': typeof AuthenticatedOrganizationOrganizationIdProjectProjectIdEnvironmentEnvironmentIdServiceServiceIdSettingsValuesOverrideArgumentsRoute '/organization/$organizationId/project/$projectId/environment/$environmentId/service/$serviceId/settings/values-override-file': typeof AuthenticatedOrganizationOrganizationIdProjectProjectIdEnvironmentEnvironmentIdServiceServiceIdSettingsValuesOverrideFileRoute - '/organization/$organizationId/project/$projectId/environment/$environmentId/service/$serviceId/update/blueprint': typeof AuthenticatedOrganizationOrganizationIdProjectProjectIdEnvironmentEnvironmentIdServiceServiceIdUpdateBlueprintRoute '/organization/$organizationId/project/$projectId/environment/$environmentId/service/$serviceId/variables/built-in': typeof AuthenticatedOrganizationOrganizationIdProjectProjectIdEnvironmentEnvironmentIdServiceServiceIdVariablesBuiltInRoute '/organization/$organizationId/project/$projectId/environment/$environmentId/service/$serviceId/variables/external-secrets': typeof AuthenticatedOrganizationOrganizationIdProjectProjectIdEnvironmentEnvironmentIdServiceServiceIdVariablesExternalSecretsRoute '/organization/$organizationId/project/$projectId/environment/$environmentId/service/create/$slug/general': typeof AuthenticatedOrganizationOrganizationIdProjectProjectIdEnvironmentEnvironmentIdServiceCreateSlugGeneralRoute @@ -2044,6 +2065,8 @@ export interface FileRoutesByTo { '/organization/$organizationId/project/$projectId/environment/$environmentId/service/create/lifecycle-job': typeof AuthenticatedOrganizationOrganizationIdProjectProjectIdEnvironmentEnvironmentIdServiceCreateLifecycleJobIndexRoute '/organization/$organizationId/project/$projectId/environment/$environmentId/service/create/terraform': typeof AuthenticatedOrganizationOrganizationIdProjectProjectIdEnvironmentEnvironmentIdServiceCreateTerraformIndexRoute '/organization/$organizationId/project/$projectId/environment/$environmentId/service/$serviceId/deployments/logs/$executionId': typeof AuthenticatedOrganizationOrganizationIdProjectProjectIdEnvironmentEnvironmentIdServiceServiceIdDeploymentsLogsExecutionIdRoute + '/organization/$organizationId/project/$projectId/environment/$environmentId/service/$serviceId/update/blueprint/preview': typeof AuthenticatedOrganizationOrganizationIdProjectProjectIdEnvironmentEnvironmentIdServiceServiceIdUpdateBlueprintPreviewRoute + '/organization/$organizationId/project/$projectId/environment/$environmentId/service/$serviceId/update/blueprint': typeof AuthenticatedOrganizationOrganizationIdProjectProjectIdEnvironmentEnvironmentIdServiceServiceIdUpdateBlueprintIndexRoute '/organization/$organizationId/project/$projectId/environment/$environmentId/service/$serviceId/monitoring/alerts/$alertId/edit': typeof AuthenticatedOrganizationOrganizationIdProjectProjectIdEnvironmentEnvironmentIdServiceServiceIdMonitoringAlertsAlertIdEditRoute '/organization/$organizationId/project/$projectId/environment/$environmentId/service/create/blueprint/$provider/$serviceFamily/blueprint-setup': typeof AuthenticatedOrganizationOrganizationIdProjectProjectIdEnvironmentEnvironmentIdServiceCreateBlueprintProviderServiceFamilyBlueprintSetupRoute '/organization/$organizationId/project/$projectId/environment/$environmentId/service/create/blueprint/$provider/$serviceFamily/overrides': typeof AuthenticatedOrganizationOrganizationIdProjectProjectIdEnvironmentEnvironmentIdServiceCreateBlueprintProviderServiceFamilyOverridesRoute @@ -2167,6 +2190,7 @@ export interface FileRoutesById { '/_authenticated/organization/$organizationId/project/$projectId/environment/$environmentId/deployment/$deploymentId/': typeof AuthenticatedOrganizationOrganizationIdProjectProjectIdEnvironmentEnvironmentIdDeploymentDeploymentIdIndexRoute '/_authenticated/organization/$organizationId/project/$projectId/environment/$environmentId/service/$serviceId/': typeof AuthenticatedOrganizationOrganizationIdProjectProjectIdEnvironmentEnvironmentIdServiceServiceIdIndexRoute '/_authenticated/organization/$organizationId/project/$projectId/environment/$environmentId/service/create/': typeof AuthenticatedOrganizationOrganizationIdProjectProjectIdEnvironmentEnvironmentIdServiceCreateIndexRoute + '/_authenticated/organization/$organizationId/project/$projectId/environment/$environmentId/service/$serviceId/update/blueprint': typeof AuthenticatedOrganizationOrganizationIdProjectProjectIdEnvironmentEnvironmentIdServiceServiceIdUpdateBlueprintRouteRouteWithChildren '/_authenticated/organization/$organizationId/project/$projectId/environment/$environmentId/service/$serviceId/monitoring/alerts': typeof AuthenticatedOrganizationOrganizationIdProjectProjectIdEnvironmentEnvironmentIdServiceServiceIdMonitoringAlertsRouteWithChildren '/_authenticated/organization/$organizationId/project/$projectId/environment/$environmentId/service/$serviceId/monitoring/dashboard': typeof AuthenticatedOrganizationOrganizationIdProjectProjectIdEnvironmentEnvironmentIdServiceServiceIdMonitoringDashboardRoute '/_authenticated/organization/$organizationId/project/$projectId/environment/$environmentId/service/$serviceId/settings/advanced-settings': typeof AuthenticatedOrganizationOrganizationIdProjectProjectIdEnvironmentEnvironmentIdServiceServiceIdSettingsAdvancedSettingsRoute @@ -2186,7 +2210,6 @@ export interface FileRoutesById { '/_authenticated/organization/$organizationId/project/$projectId/environment/$environmentId/service/$serviceId/settings/terraform-variables': typeof AuthenticatedOrganizationOrganizationIdProjectProjectIdEnvironmentEnvironmentIdServiceServiceIdSettingsTerraformVariablesRoute '/_authenticated/organization/$organizationId/project/$projectId/environment/$environmentId/service/$serviceId/settings/values-override-arguments': typeof AuthenticatedOrganizationOrganizationIdProjectProjectIdEnvironmentEnvironmentIdServiceServiceIdSettingsValuesOverrideArgumentsRoute '/_authenticated/organization/$organizationId/project/$projectId/environment/$environmentId/service/$serviceId/settings/values-override-file': typeof AuthenticatedOrganizationOrganizationIdProjectProjectIdEnvironmentEnvironmentIdServiceServiceIdSettingsValuesOverrideFileRoute - '/_authenticated/organization/$organizationId/project/$projectId/environment/$environmentId/service/$serviceId/update/blueprint': typeof AuthenticatedOrganizationOrganizationIdProjectProjectIdEnvironmentEnvironmentIdServiceServiceIdUpdateBlueprintRoute '/_authenticated/organization/$organizationId/project/$projectId/environment/$environmentId/service/$serviceId/variables/built-in': typeof AuthenticatedOrganizationOrganizationIdProjectProjectIdEnvironmentEnvironmentIdServiceServiceIdVariablesBuiltInRoute '/_authenticated/organization/$organizationId/project/$projectId/environment/$environmentId/service/$serviceId/variables/external-secrets': typeof AuthenticatedOrganizationOrganizationIdProjectProjectIdEnvironmentEnvironmentIdServiceServiceIdVariablesExternalSecretsRoute '/_authenticated/organization/$organizationId/project/$projectId/environment/$environmentId/service/create/$slug/general': typeof AuthenticatedOrganizationOrganizationIdProjectProjectIdEnvironmentEnvironmentIdServiceCreateSlugGeneralRoute @@ -2230,6 +2253,8 @@ export interface FileRoutesById { '/_authenticated/organization/$organizationId/project/$projectId/environment/$environmentId/service/create/terraform/': typeof AuthenticatedOrganizationOrganizationIdProjectProjectIdEnvironmentEnvironmentIdServiceCreateTerraformIndexRoute '/_authenticated/organization/$organizationId/project/$projectId/environment/$environmentId/service/create/blueprint/$provider/$serviceFamily': typeof AuthenticatedOrganizationOrganizationIdProjectProjectIdEnvironmentEnvironmentIdServiceCreateBlueprintProviderServiceFamilyRouteRouteWithChildren '/_authenticated/organization/$organizationId/project/$projectId/environment/$environmentId/service/$serviceId/deployments/logs/$executionId': typeof AuthenticatedOrganizationOrganizationIdProjectProjectIdEnvironmentEnvironmentIdServiceServiceIdDeploymentsLogsExecutionIdRoute + '/_authenticated/organization/$organizationId/project/$projectId/environment/$environmentId/service/$serviceId/update/blueprint/preview': typeof AuthenticatedOrganizationOrganizationIdProjectProjectIdEnvironmentEnvironmentIdServiceServiceIdUpdateBlueprintPreviewRoute + '/_authenticated/organization/$organizationId/project/$projectId/environment/$environmentId/service/$serviceId/update/blueprint/': typeof AuthenticatedOrganizationOrganizationIdProjectProjectIdEnvironmentEnvironmentIdServiceServiceIdUpdateBlueprintIndexRoute '/_authenticated/organization/$organizationId/project/$projectId/environment/$environmentId/service/$serviceId/monitoring/alerts/$alertId/edit': typeof AuthenticatedOrganizationOrganizationIdProjectProjectIdEnvironmentEnvironmentIdServiceServiceIdMonitoringAlertsAlertIdEditRoute '/_authenticated/organization/$organizationId/project/$projectId/environment/$environmentId/service/create/blueprint/$provider/$serviceFamily/blueprint-setup': typeof AuthenticatedOrganizationOrganizationIdProjectProjectIdEnvironmentEnvironmentIdServiceCreateBlueprintProviderServiceFamilyBlueprintSetupRoute '/_authenticated/organization/$organizationId/project/$projectId/environment/$environmentId/service/create/blueprint/$provider/$serviceFamily/overrides': typeof AuthenticatedOrganizationOrganizationIdProjectProjectIdEnvironmentEnvironmentIdServiceCreateBlueprintProviderServiceFamilyOverridesRoute @@ -2353,6 +2378,7 @@ export interface FileRouteTypes { | '/organization/$organizationId/project/$projectId/environment/$environmentId/deployment/$deploymentId' | '/organization/$organizationId/project/$projectId/environment/$environmentId/service/$serviceId' | '/organization/$organizationId/project/$projectId/environment/$environmentId/service/create' + | '/organization/$organizationId/project/$projectId/environment/$environmentId/service/$serviceId/update/blueprint' | '/organization/$organizationId/project/$projectId/environment/$environmentId/service/$serviceId/monitoring/alerts' | '/organization/$organizationId/project/$projectId/environment/$environmentId/service/$serviceId/monitoring/dashboard' | '/organization/$organizationId/project/$projectId/environment/$environmentId/service/$serviceId/settings/advanced-settings' @@ -2372,7 +2398,6 @@ export interface FileRouteTypes { | '/organization/$organizationId/project/$projectId/environment/$environmentId/service/$serviceId/settings/terraform-variables' | '/organization/$organizationId/project/$projectId/environment/$environmentId/service/$serviceId/settings/values-override-arguments' | '/organization/$organizationId/project/$projectId/environment/$environmentId/service/$serviceId/settings/values-override-file' - | '/organization/$organizationId/project/$projectId/environment/$environmentId/service/$serviceId/update/blueprint' | '/organization/$organizationId/project/$projectId/environment/$environmentId/service/$serviceId/variables/built-in' | '/organization/$organizationId/project/$projectId/environment/$environmentId/service/$serviceId/variables/external-secrets' | '/organization/$organizationId/project/$projectId/environment/$environmentId/service/create/$slug/general' @@ -2416,6 +2441,8 @@ export interface FileRouteTypes { | '/organization/$organizationId/project/$projectId/environment/$environmentId/service/create/terraform/' | '/organization/$organizationId/project/$projectId/environment/$environmentId/service/create/blueprint/$provider/$serviceFamily' | '/organization/$organizationId/project/$projectId/environment/$environmentId/service/$serviceId/deployments/logs/$executionId' + | '/organization/$organizationId/project/$projectId/environment/$environmentId/service/$serviceId/update/blueprint/preview' + | '/organization/$organizationId/project/$projectId/environment/$environmentId/service/$serviceId/update/blueprint/' | '/organization/$organizationId/project/$projectId/environment/$environmentId/service/$serviceId/monitoring/alerts/$alertId/edit' | '/organization/$organizationId/project/$projectId/environment/$environmentId/service/create/blueprint/$provider/$serviceFamily/blueprint-setup' | '/organization/$organizationId/project/$projectId/environment/$environmentId/service/create/blueprint/$provider/$serviceFamily/overrides' @@ -2537,7 +2564,6 @@ export interface FileRouteTypes { | '/organization/$organizationId/project/$projectId/environment/$environmentId/service/$serviceId/settings/terraform-variables' | '/organization/$organizationId/project/$projectId/environment/$environmentId/service/$serviceId/settings/values-override-arguments' | '/organization/$organizationId/project/$projectId/environment/$environmentId/service/$serviceId/settings/values-override-file' - | '/organization/$organizationId/project/$projectId/environment/$environmentId/service/$serviceId/update/blueprint' | '/organization/$organizationId/project/$projectId/environment/$environmentId/service/$serviceId/variables/built-in' | '/organization/$organizationId/project/$projectId/environment/$environmentId/service/$serviceId/variables/external-secrets' | '/organization/$organizationId/project/$projectId/environment/$environmentId/service/create/$slug/general' @@ -2580,6 +2606,8 @@ export interface FileRouteTypes { | '/organization/$organizationId/project/$projectId/environment/$environmentId/service/create/lifecycle-job' | '/organization/$organizationId/project/$projectId/environment/$environmentId/service/create/terraform' | '/organization/$organizationId/project/$projectId/environment/$environmentId/service/$serviceId/deployments/logs/$executionId' + | '/organization/$organizationId/project/$projectId/environment/$environmentId/service/$serviceId/update/blueprint/preview' + | '/organization/$organizationId/project/$projectId/environment/$environmentId/service/$serviceId/update/blueprint' | '/organization/$organizationId/project/$projectId/environment/$environmentId/service/$serviceId/monitoring/alerts/$alertId/edit' | '/organization/$organizationId/project/$projectId/environment/$environmentId/service/create/blueprint/$provider/$serviceFamily/blueprint-setup' | '/organization/$organizationId/project/$projectId/environment/$environmentId/service/create/blueprint/$provider/$serviceFamily/overrides' @@ -2702,6 +2730,7 @@ export interface FileRouteTypes { | '/_authenticated/organization/$organizationId/project/$projectId/environment/$environmentId/deployment/$deploymentId/' | '/_authenticated/organization/$organizationId/project/$projectId/environment/$environmentId/service/$serviceId/' | '/_authenticated/organization/$organizationId/project/$projectId/environment/$environmentId/service/create/' + | '/_authenticated/organization/$organizationId/project/$projectId/environment/$environmentId/service/$serviceId/update/blueprint' | '/_authenticated/organization/$organizationId/project/$projectId/environment/$environmentId/service/$serviceId/monitoring/alerts' | '/_authenticated/organization/$organizationId/project/$projectId/environment/$environmentId/service/$serviceId/monitoring/dashboard' | '/_authenticated/organization/$organizationId/project/$projectId/environment/$environmentId/service/$serviceId/settings/advanced-settings' @@ -2721,7 +2750,6 @@ export interface FileRouteTypes { | '/_authenticated/organization/$organizationId/project/$projectId/environment/$environmentId/service/$serviceId/settings/terraform-variables' | '/_authenticated/organization/$organizationId/project/$projectId/environment/$environmentId/service/$serviceId/settings/values-override-arguments' | '/_authenticated/organization/$organizationId/project/$projectId/environment/$environmentId/service/$serviceId/settings/values-override-file' - | '/_authenticated/organization/$organizationId/project/$projectId/environment/$environmentId/service/$serviceId/update/blueprint' | '/_authenticated/organization/$organizationId/project/$projectId/environment/$environmentId/service/$serviceId/variables/built-in' | '/_authenticated/organization/$organizationId/project/$projectId/environment/$environmentId/service/$serviceId/variables/external-secrets' | '/_authenticated/organization/$organizationId/project/$projectId/environment/$environmentId/service/create/$slug/general' @@ -2765,6 +2793,8 @@ export interface FileRouteTypes { | '/_authenticated/organization/$organizationId/project/$projectId/environment/$environmentId/service/create/terraform/' | '/_authenticated/organization/$organizationId/project/$projectId/environment/$environmentId/service/create/blueprint/$provider/$serviceFamily' | '/_authenticated/organization/$organizationId/project/$projectId/environment/$environmentId/service/$serviceId/deployments/logs/$executionId' + | '/_authenticated/organization/$organizationId/project/$projectId/environment/$environmentId/service/$serviceId/update/blueprint/preview' + | '/_authenticated/organization/$organizationId/project/$projectId/environment/$environmentId/service/$serviceId/update/blueprint/' | '/_authenticated/organization/$organizationId/project/$projectId/environment/$environmentId/service/$serviceId/monitoring/alerts/$alertId/edit' | '/_authenticated/organization/$organizationId/project/$projectId/environment/$environmentId/service/create/blueprint/$provider/$serviceFamily/blueprint-setup' | '/_authenticated/organization/$organizationId/project/$projectId/environment/$environmentId/service/create/blueprint/$provider/$serviceFamily/overrides' @@ -3861,13 +3891,6 @@ declare module '@tanstack/react-router' { preLoaderRoute: typeof AuthenticatedOrganizationOrganizationIdProjectProjectIdEnvironmentEnvironmentIdServiceServiceIdVariablesBuiltInRouteImport parentRoute: typeof AuthenticatedOrganizationOrganizationIdProjectProjectIdEnvironmentEnvironmentIdServiceServiceIdVariablesRouteRoute } - '/_authenticated/organization/$organizationId/project/$projectId/environment/$environmentId/service/$serviceId/update/blueprint': { - id: '/_authenticated/organization/$organizationId/project/$projectId/environment/$environmentId/service/$serviceId/update/blueprint' - path: '/project/$projectId/environment/$environmentId/service/$serviceId/update/blueprint' - fullPath: '/organization/$organizationId/project/$projectId/environment/$environmentId/service/$serviceId/update/blueprint' - preLoaderRoute: typeof AuthenticatedOrganizationOrganizationIdProjectProjectIdEnvironmentEnvironmentIdServiceServiceIdUpdateBlueprintRouteImport - parentRoute: typeof AuthenticatedOrganizationOrganizationIdRouteRoute - } '/_authenticated/organization/$organizationId/project/$projectId/environment/$environmentId/service/$serviceId/settings/values-override-file': { id: '/_authenticated/organization/$organizationId/project/$projectId/environment/$environmentId/service/$serviceId/settings/values-override-file' path: '/values-override-file' @@ -4001,6 +4024,27 @@ declare module '@tanstack/react-router' { preLoaderRoute: typeof AuthenticatedOrganizationOrganizationIdProjectProjectIdEnvironmentEnvironmentIdServiceServiceIdMonitoringAlertsRouteImport parentRoute: typeof AuthenticatedOrganizationOrganizationIdProjectProjectIdEnvironmentEnvironmentIdServiceServiceIdMonitoringRouteRoute } + '/_authenticated/organization/$organizationId/project/$projectId/environment/$environmentId/service/$serviceId/update/blueprint': { + id: '/_authenticated/organization/$organizationId/project/$projectId/environment/$environmentId/service/$serviceId/update/blueprint' + path: '/project/$projectId/environment/$environmentId/service/$serviceId/update/blueprint' + fullPath: '/organization/$organizationId/project/$projectId/environment/$environmentId/service/$serviceId/update/blueprint' + preLoaderRoute: typeof AuthenticatedOrganizationOrganizationIdProjectProjectIdEnvironmentEnvironmentIdServiceServiceIdUpdateBlueprintRouteRouteImport + parentRoute: typeof AuthenticatedOrganizationOrganizationIdRouteRoute + } + '/_authenticated/organization/$organizationId/project/$projectId/environment/$environmentId/service/$serviceId/update/blueprint/': { + id: '/_authenticated/organization/$organizationId/project/$projectId/environment/$environmentId/service/$serviceId/update/blueprint/' + path: '/' + fullPath: '/organization/$organizationId/project/$projectId/environment/$environmentId/service/$serviceId/update/blueprint/' + preLoaderRoute: typeof AuthenticatedOrganizationOrganizationIdProjectProjectIdEnvironmentEnvironmentIdServiceServiceIdUpdateBlueprintIndexRouteImport + parentRoute: typeof AuthenticatedOrganizationOrganizationIdProjectProjectIdEnvironmentEnvironmentIdServiceServiceIdUpdateBlueprintRouteRoute + } + '/_authenticated/organization/$organizationId/project/$projectId/environment/$environmentId/service/$serviceId/update/blueprint/preview': { + id: '/_authenticated/organization/$organizationId/project/$projectId/environment/$environmentId/service/$serviceId/update/blueprint/preview' + path: '/preview' + fullPath: '/organization/$organizationId/project/$projectId/environment/$environmentId/service/$serviceId/update/blueprint/preview' + preLoaderRoute: typeof AuthenticatedOrganizationOrganizationIdProjectProjectIdEnvironmentEnvironmentIdServiceServiceIdUpdateBlueprintPreviewRouteImport + parentRoute: typeof AuthenticatedOrganizationOrganizationIdProjectProjectIdEnvironmentEnvironmentIdServiceServiceIdUpdateBlueprintRouteRoute + } '/_authenticated/organization/$organizationId/project/$projectId/environment/$environmentId/service/$serviceId/deployments/logs/$executionId': { id: '/_authenticated/organization/$organizationId/project/$projectId/environment/$environmentId/service/$serviceId/deployments/logs/$executionId' path: '/project/$projectId/environment/$environmentId/service/$serviceId/deployments/logs/$executionId' @@ -4631,6 +4675,24 @@ const AuthenticatedOrganizationOrganizationIdProjectProjectIdEnvironmentEnvironm AuthenticatedOrganizationOrganizationIdProjectProjectIdEnvironmentEnvironmentIdServiceCreateTerraformRouteRouteChildren, ) +interface AuthenticatedOrganizationOrganizationIdProjectProjectIdEnvironmentEnvironmentIdServiceServiceIdUpdateBlueprintRouteRouteChildren { + AuthenticatedOrganizationOrganizationIdProjectProjectIdEnvironmentEnvironmentIdServiceServiceIdUpdateBlueprintPreviewRoute: typeof AuthenticatedOrganizationOrganizationIdProjectProjectIdEnvironmentEnvironmentIdServiceServiceIdUpdateBlueprintPreviewRoute + AuthenticatedOrganizationOrganizationIdProjectProjectIdEnvironmentEnvironmentIdServiceServiceIdUpdateBlueprintIndexRoute: typeof AuthenticatedOrganizationOrganizationIdProjectProjectIdEnvironmentEnvironmentIdServiceServiceIdUpdateBlueprintIndexRoute +} + +const AuthenticatedOrganizationOrganizationIdProjectProjectIdEnvironmentEnvironmentIdServiceServiceIdUpdateBlueprintRouteRouteChildren: AuthenticatedOrganizationOrganizationIdProjectProjectIdEnvironmentEnvironmentIdServiceServiceIdUpdateBlueprintRouteRouteChildren = + { + AuthenticatedOrganizationOrganizationIdProjectProjectIdEnvironmentEnvironmentIdServiceServiceIdUpdateBlueprintPreviewRoute: + AuthenticatedOrganizationOrganizationIdProjectProjectIdEnvironmentEnvironmentIdServiceServiceIdUpdateBlueprintPreviewRoute, + AuthenticatedOrganizationOrganizationIdProjectProjectIdEnvironmentEnvironmentIdServiceServiceIdUpdateBlueprintIndexRoute: + AuthenticatedOrganizationOrganizationIdProjectProjectIdEnvironmentEnvironmentIdServiceServiceIdUpdateBlueprintIndexRoute, + } + +const AuthenticatedOrganizationOrganizationIdProjectProjectIdEnvironmentEnvironmentIdServiceServiceIdUpdateBlueprintRouteRouteWithChildren = + AuthenticatedOrganizationOrganizationIdProjectProjectIdEnvironmentEnvironmentIdServiceServiceIdUpdateBlueprintRouteRoute._addFileChildren( + AuthenticatedOrganizationOrganizationIdProjectProjectIdEnvironmentEnvironmentIdServiceServiceIdUpdateBlueprintRouteRouteChildren, + ) + interface AuthenticatedOrganizationOrganizationIdProjectProjectIdEnvironmentEnvironmentIdServiceCreateBlueprintProviderServiceFamilyRouteRouteChildren { AuthenticatedOrganizationOrganizationIdProjectProjectIdEnvironmentEnvironmentIdServiceCreateBlueprintProviderServiceFamilyBlueprintSetupRoute: typeof AuthenticatedOrganizationOrganizationIdProjectProjectIdEnvironmentEnvironmentIdServiceCreateBlueprintProviderServiceFamilyBlueprintSetupRoute AuthenticatedOrganizationOrganizationIdProjectProjectIdEnvironmentEnvironmentIdServiceCreateBlueprintProviderServiceFamilyOverridesRoute: typeof AuthenticatedOrganizationOrganizationIdProjectProjectIdEnvironmentEnvironmentIdServiceCreateBlueprintProviderServiceFamilyOverridesRoute @@ -4703,7 +4765,7 @@ interface AuthenticatedOrganizationOrganizationIdRouteRouteChildren { AuthenticatedOrganizationOrganizationIdProjectProjectIdEnvironmentEnvironmentIdDeploymentDeploymentIdIndexRoute: typeof AuthenticatedOrganizationOrganizationIdProjectProjectIdEnvironmentEnvironmentIdDeploymentDeploymentIdIndexRoute AuthenticatedOrganizationOrganizationIdProjectProjectIdEnvironmentEnvironmentIdServiceServiceIdIndexRoute: typeof AuthenticatedOrganizationOrganizationIdProjectProjectIdEnvironmentEnvironmentIdServiceServiceIdIndexRoute AuthenticatedOrganizationOrganizationIdProjectProjectIdEnvironmentEnvironmentIdServiceCreateIndexRoute: typeof AuthenticatedOrganizationOrganizationIdProjectProjectIdEnvironmentEnvironmentIdServiceCreateIndexRoute - AuthenticatedOrganizationOrganizationIdProjectProjectIdEnvironmentEnvironmentIdServiceServiceIdUpdateBlueprintRoute: typeof AuthenticatedOrganizationOrganizationIdProjectProjectIdEnvironmentEnvironmentIdServiceServiceIdUpdateBlueprintRoute + AuthenticatedOrganizationOrganizationIdProjectProjectIdEnvironmentEnvironmentIdServiceServiceIdUpdateBlueprintRouteRoute: typeof AuthenticatedOrganizationOrganizationIdProjectProjectIdEnvironmentEnvironmentIdServiceServiceIdUpdateBlueprintRouteRouteWithChildren AuthenticatedOrganizationOrganizationIdProjectProjectIdEnvironmentEnvironmentIdServiceServiceIdDeploymentsIndexRoute: typeof AuthenticatedOrganizationOrganizationIdProjectProjectIdEnvironmentEnvironmentIdServiceServiceIdDeploymentsIndexRoute AuthenticatedOrganizationOrganizationIdProjectProjectIdEnvironmentEnvironmentIdServiceCreateBlueprintProviderServiceFamilyRouteRoute: typeof AuthenticatedOrganizationOrganizationIdProjectProjectIdEnvironmentEnvironmentIdServiceCreateBlueprintProviderServiceFamilyRouteRouteWithChildren AuthenticatedOrganizationOrganizationIdProjectProjectIdEnvironmentEnvironmentIdServiceServiceIdDeploymentsLogsExecutionIdRoute: typeof AuthenticatedOrganizationOrganizationIdProjectProjectIdEnvironmentEnvironmentIdServiceServiceIdDeploymentsLogsExecutionIdRoute @@ -4799,8 +4861,8 @@ const AuthenticatedOrganizationOrganizationIdRouteRouteChildren: AuthenticatedOr AuthenticatedOrganizationOrganizationIdProjectProjectIdEnvironmentEnvironmentIdServiceServiceIdIndexRoute, AuthenticatedOrganizationOrganizationIdProjectProjectIdEnvironmentEnvironmentIdServiceCreateIndexRoute: AuthenticatedOrganizationOrganizationIdProjectProjectIdEnvironmentEnvironmentIdServiceCreateIndexRoute, - AuthenticatedOrganizationOrganizationIdProjectProjectIdEnvironmentEnvironmentIdServiceServiceIdUpdateBlueprintRoute: - AuthenticatedOrganizationOrganizationIdProjectProjectIdEnvironmentEnvironmentIdServiceServiceIdUpdateBlueprintRoute, + AuthenticatedOrganizationOrganizationIdProjectProjectIdEnvironmentEnvironmentIdServiceServiceIdUpdateBlueprintRouteRoute: + AuthenticatedOrganizationOrganizationIdProjectProjectIdEnvironmentEnvironmentIdServiceServiceIdUpdateBlueprintRouteRouteWithChildren, AuthenticatedOrganizationOrganizationIdProjectProjectIdEnvironmentEnvironmentIdServiceServiceIdDeploymentsIndexRoute: AuthenticatedOrganizationOrganizationIdProjectProjectIdEnvironmentEnvironmentIdServiceServiceIdDeploymentsIndexRoute, AuthenticatedOrganizationOrganizationIdProjectProjectIdEnvironmentEnvironmentIdServiceCreateBlueprintProviderServiceFamilyRouteRoute: diff --git a/apps/console/src/routes/_authenticated/organization/$organizationId/project/$projectId/environment/$environmentId/service/$serviceId/update/blueprint/index.tsx b/apps/console/src/routes/_authenticated/organization/$organizationId/project/$projectId/environment/$environmentId/service/$serviceId/update/blueprint/index.tsx new file mode 100644 index 00000000000..be8eac21546 --- /dev/null +++ b/apps/console/src/routes/_authenticated/organization/$organizationId/project/$projectId/environment/$environmentId/service/$serviceId/update/blueprint/index.tsx @@ -0,0 +1,24 @@ +import { createFileRoute, useNavigate } from '@tanstack/react-router' +import { BlueprintUpdateReviewStep } from '@qovery/domains/services/feature' + +export const Route = createFileRoute( + '/_authenticated/organization/$organizationId/project/$projectId/environment/$environmentId/service/$serviceId/update/blueprint/' +)({ + component: RouteComponent, +}) + +function RouteComponent() { + const { organizationId, projectId, environmentId, serviceId } = Route.useParams() + const navigate = useNavigate() + + return ( + { + navigate({ + to: '/organization/$organizationId/project/$projectId/environment/$environmentId/service/$serviceId/update/blueprint/preview', + params: { organizationId, projectId, environmentId, serviceId }, + }) + }} + /> + ) +} diff --git a/apps/console/src/routes/_authenticated/organization/$organizationId/project/$projectId/environment/$environmentId/service/$serviceId/update/blueprint/preview.tsx b/apps/console/src/routes/_authenticated/organization/$organizationId/project/$projectId/environment/$environmentId/service/$serviceId/update/blueprint/preview.tsx new file mode 100644 index 00000000000..8fb63ded067 --- /dev/null +++ b/apps/console/src/routes/_authenticated/organization/$organizationId/project/$projectId/environment/$environmentId/service/$serviceId/update/blueprint/preview.tsx @@ -0,0 +1,24 @@ +import { createFileRoute, useNavigate } from '@tanstack/react-router' +import { BlueprintUpdatePreviewStep } from '@qovery/domains/services/feature' + +export const Route = createFileRoute( + '/_authenticated/organization/$organizationId/project/$projectId/environment/$environmentId/service/$serviceId/update/blueprint/preview' +)({ + component: RouteComponent, +}) + +function RouteComponent() { + const { organizationId, projectId, environmentId, serviceId } = Route.useParams() + const navigate = useNavigate() + + return ( + { + navigate({ + to: '/organization/$organizationId/project/$projectId/environment/$environmentId/service/$serviceId/update/blueprint', + params: { organizationId, projectId, environmentId, serviceId }, + }) + }} + /> + ) +} diff --git a/apps/console/src/routes/_authenticated/organization/$organizationId/project/$projectId/environment/$environmentId/service/$serviceId/update/blueprint.tsx b/apps/console/src/routes/_authenticated/organization/$organizationId/project/$projectId/environment/$environmentId/service/$serviceId/update/blueprint/route.tsx similarity index 85% rename from apps/console/src/routes/_authenticated/organization/$organizationId/project/$projectId/environment/$environmentId/service/$serviceId/update/blueprint.tsx rename to apps/console/src/routes/_authenticated/organization/$organizationId/project/$projectId/environment/$environmentId/service/$serviceId/update/blueprint/route.tsx index 3cac902e4e0..1fa7896d1b8 100644 --- a/apps/console/src/routes/_authenticated/organization/$organizationId/project/$projectId/environment/$environmentId/service/$serviceId/update/blueprint.tsx +++ b/apps/console/src/routes/_authenticated/organization/$organizationId/project/$projectId/environment/$environmentId/service/$serviceId/update/blueprint/route.tsx @@ -1,4 +1,4 @@ -import { createFileRoute, useNavigate } from '@tanstack/react-router' +import { Outlet, createFileRoute, useLocation, useNavigate } from '@tanstack/react-router' import { useCallback, useEffect } from 'react' import { useEnvironment } from '@qovery/domains/environments/feature' import { BlueprintUpdateFlow, useService } from '@qovery/domains/services/feature' @@ -11,10 +11,12 @@ export const Route = createFileRoute( function RouteComponent() { const { organizationId, projectId, environmentId, serviceId } = Route.useParams() + const { pathname } = useLocation() const navigate = useNavigate() const { data: environment } = useEnvironment({ environmentId, suspense: true }) const { data: service } = useService({ environmentId, serviceId, suspense: true }) const blueprintId = service && 'blueprint_id' in service ? service.blueprint_id : undefined + const currentStep = pathname.endsWith('/preview') ? 2 : 1 const navigateToOverview = useCallback(() => { navigate({ to: '/organization/$organizationId/project/$projectId/environment/$environmentId/service/$serviceId/overview', @@ -32,10 +34,13 @@ function RouteComponent() { + > + + ) } 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 8196d0bb588..75ae905eaf6 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 @@ -149,7 +149,7 @@ jest.mock('../../hooks/use-service/use-service', () => ({ serviceId, }: { environmentId: string - serviceId: 'application-mock' | 'database-mock' | 'job-mock' + serviceId: 'application-mock' | 'database-mock' | 'job-mock' | 'terraform-mock' }) => { const mocks = { 'application-mock': { @@ -275,6 +275,29 @@ jest.mock('../../hooks/use-service/use-service', () => ({ liveness_probe: null, }, }, + 'terraform-mock': { + id: 'terraform-mock', + serviceType: 'TERRAFORM', + service_type: 'TERRAFORM', + name: 'aws-s3-bucket', + description: 'Provisioned from AWS S3 Bucket blueprint', + icon_uri: null, + environment: { + id: 'environment-id', + }, + terraform_files_source: { + git: { + git_repository: { + provider: 'GITHUB', + url: 'https://github.com/qovery-blueprints/s3.git', + name: 'qovery-blueprints/s3', + branch: 'main', + }, + }, + }, + blueprint_id: 'blueprint-id', + tag: 'aws/s3/1.2', + }, } return { data: mocks[serviceId], @@ -409,7 +432,7 @@ describe('ServiceHeader', () => { expect(screen.queryByText('Update available')).not.toBeInTheDocument() }) - it('opens the blueprint update flow from the update available badge', async () => { + 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, @@ -417,7 +440,17 @@ describe('ServiceHeader', () => { new_required_values: [], new_optional_values: [], now_required_values: [], - updated_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: [], @@ -441,6 +474,43 @@ describe('ServiceHeader', () => { }) }) + it('opens a confirmation modal before previewing a blueprint update without review values', async () => { + mockUseBlueprintUpdate.mockReturnValue({ + data: { + is_up_to_date: false, + latest_tag: 'aws/s3/2.0', + new_required_values: [], + new_optional_values: [], + now_required_values: [], + updated_values: [], + removed_values: [], + engine_diff: { + updated_values: [], + }, + }, + }) + + const { userEvent } = renderServiceHeader('terraform-mock') + + await userEvent.click(screen.getByRole('button', { name: /update available/i })) + + expect(screen.queryByText('Up to date')).not.toBeInTheDocument() + expect(await screen.findByRole('heading', { name: 'aws-s3-bucket blueprint update to 2.0' })).toBeInTheDocument() + expect(screen.getByText('No configuration input is required. Continue to preview the update.')).toBeInTheDocument() + + await userEvent.click(screen.getByRole('button', { name: /continue/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 a skeleton while the blueprint update badge is loading', () => { mockUseBlueprintUpdate.mockImplementation(() => { throw new Promise(() => undefined) 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 505203d5c73..1f256e27ad6 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 @@ -1,4 +1,5 @@ import { Link, useNavigate, useParams } from '@tanstack/react-router' +import * as Dialog from '@radix-ui/react-dialog' import { type ApplicationGitRepository, type Credentials, type Environment } from 'qovery-typescript-axios' import { Suspense } from 'react' import { P, match } from 'ts-pattern' @@ -28,6 +29,7 @@ import { Tooltip, Truncate, toast, + useModal, } from '@qovery/shared/ui' import { buildGitProviderUrl } from '@qovery/shared/util-git' import { useCopyToClipboard } from '@qovery/shared/util-hooks' @@ -42,6 +44,11 @@ import { ServiceActions } from '../../service-actions/service-actions' import { ServiceAvatar } from '../../service-avatar/service-avatar' import { ServiceLinksPopover } from '../../service-links-popover/service-links-popover' import { ServiceStateChip } from '../../service-state-chip/service-state-chip' +import { + BLUEPRINT_RELEASE_NOTES_URL, + getBlueprintUpdateVersion, + hasBlueprintUpdateReviewSections, +} from '../../service-update-flow/blueprint/blueprint-update-flow' export function GitRepository({ gitRepository }: { gitRepository: ApplicationGitRepository }) { return ( @@ -169,9 +176,13 @@ function BlueprintUpdateBadge({ blueprintId }: { blueprintId: string }) { const { data: service } = useService({ environmentId, serviceId, suspense: true }) const { data: blueprintUpdate } = useBlueprintUpdate({ blueprintId, suspense: true }) const navigate = useNavigate() - const openUpdateFlow = () => { + const { openModal } = useModal() + const openUpdateFlow = (step?: 'preview') => { navigate({ - to: '/organization/$organizationId/project/$projectId/environment/$environmentId/service/$serviceId/update/blueprint', + to: + step === 'preview' + ? '/organization/$organizationId/project/$projectId/environment/$environmentId/service/$serviceId/update/blueprint/preview' + : '/organization/$organizationId/project/$projectId/environment/$environmentId/service/$serviceId/update/blueprint', params: { organizationId, projectId, environmentId, serviceId: service?.id ?? serviceId }, }) } @@ -189,7 +200,23 @@ function BlueprintUpdateBadge({ blueprintId }: { blueprintId: string }) {
+ +
+
+ ) +} + function ServiceHeaderMetadata({ service }: ServiceHeaderMetadataProps) { const { organizationId = '', projectId = '', environmentId = '', serviceId = '' } = useParams({ strict: false }) const { data: masterCredentials } = useMasterCredentials({ diff --git a/libs/domains/services/feature/src/lib/service-update-flow/blueprint/blueprint-update-flow.tsx b/libs/domains/services/feature/src/lib/service-update-flow/blueprint/blueprint-update-flow.tsx index 5f68646f14a..ba552657a28 100644 --- a/libs/domains/services/feature/src/lib/service-update-flow/blueprint/blueprint-update-flow.tsx +++ b/libs/domains/services/feature/src/lib/service-update-flow/blueprint/blueprint-update-flow.tsx @@ -5,9 +5,10 @@ import { type BlueprintUpdateNewOptionalValue, type BlueprintUpdateNewRequiredValue, type BlueprintUpdateRemovedValue, + type BlueprintUpdateResponse, type BlueprintUpdateUpdatedValue, } from 'qovery-typescript-axios' -import { type ReactNode, useEffect, useState } from 'react' +import { type ReactNode, createContext, useCallback, useContext, useEffect, useMemo, useState } from 'react' import { type AnyService } from '@qovery/domains/services/data-access' import { Badge, Button, FunnelFlowBody, Icon, InputText, LogoIcon, Skeleton, Tooltip, toast } from '@qovery/shared/ui' import { @@ -33,13 +34,49 @@ type BlueprintUpdateEditableValue = BlueprintUpdateUpdatedValue | BlueprintUpdat export interface BlueprintUpdateFlowProps { blueprintId: string + children: ReactNode clusterId?: string + currentStep: 1 | 2 environmentId: string onExit: () => void organizationId: string service: AnyService } +interface BlueprintUpdateFlowContextValue { + activeSection: BlueprintUpdateSection + blueprintUpdate: BlueprintUpdateResponse + canContinueReview: boolean + clusterId?: string + completeActiveSection: () => void + completedSections: BlueprintUpdateSection[] + handleUpdate: () => Promise + isPreviewLoading: boolean + isRequiredValid: boolean + isUpdateLoading: boolean + onChange: (name: string, value: BlueprintFieldValue) => void + organizationId: string + previewId?: string + removedValues: BlueprintUpdateRemovedValue[] + requestPreview: () => Promise + requiredValues: BlueprintUpdateNewRequiredValue[] + reviewSections: Array<(typeof updateSections)[number]> + service: AnyService + setActiveSection: (section: BlueprintUpdateSection) => void + title: string + updatedValues: BlueprintUpdateEditableValue[] + values: BlueprintFieldValues +} + +const BlueprintUpdateFlowContext = createContext(undefined) + +export function useBlueprintUpdateFlowContext() { + const context = useContext(BlueprintUpdateFlowContext) + if (!context) throw new Error('useBlueprintUpdateFlowContext must be used within BlueprintUpdateFlow') + + return context +} + const updateSections: Array<{ id: BlueprintUpdateSection title: string @@ -72,11 +109,30 @@ const updateSections: Array<{ }, ] -const BLUEPRINT_RELEASE_NOTES_URL = 'https://github.com/Qovery/service-catalog/releases' +export const blueprintUpdateSteps: { title: string }[] = [{ title: 'Review update' }, { title: 'Preview changes' }] + +export const BLUEPRINT_RELEASE_NOTES_URL = 'https://github.com/Qovery/service-catalog/releases' + +export function hasBlueprintUpdateReviewSections(blueprintUpdate: BlueprintUpdateResponse) { + return ( + blueprintUpdate.new_required_values.length > 0 || + blueprintUpdate.now_required_values.length > 0 || + blueprintUpdate.new_optional_values.length > 0 || + blueprintUpdate.updated_values.length > 0 || + blueprintUpdate.engine_diff.updated_values.length > 0 || + blueprintUpdate.removed_values.length > 0 + ) +} + +export function getBlueprintUpdateVersion(tag: string) { + return tag.split('/').filter(Boolean).at(-1) +} export function BlueprintUpdateFlow({ blueprintId, + children, clusterId, + currentStep, environmentId, onExit, organizationId, @@ -85,7 +141,6 @@ export function BlueprintUpdateFlow({ const { data: blueprintUpdate } = useBlueprintUpdate({ blueprintId, suspense: true }) const { mutateAsync: previewBlueprintUpdate, - data: preview, isLoading: isPreviewLoading, } = usePreviewBlueprintUpdate() const { mutateAsync: updateBlueprint, isLoading: isUpdateLoading } = useUpdateBlueprint({ @@ -93,11 +148,12 @@ export function BlueprintUpdateFlow({ serviceId: service.id, serviceType: service.service_type, }) - const [currentStep, setCurrentStep] = useState<'review' | 'preview'>('review') const [activeSection, setActiveSection] = useState(() => blueprintUpdate ? getFirstAvailableUpdateSection(blueprintUpdate) : 'required' ) const [completedSections, setCompletedSections] = useState([]) + const [previewId, setPreviewId] = useState() + const [previewPayloadKey, setPreviewPayloadKey] = useState() const [values, setValues] = useState({}) const [initializedBlueprintId, setInitializedBlueprintId] = useState() @@ -109,17 +165,22 @@ export function BlueprintUpdateFlow({ } }, [blueprintId, blueprintUpdate, initializedBlueprintId]) - if (!blueprintUpdate) return null - - const requiredValues = [...blueprintUpdate.new_required_values, ...blueprintUpdate.now_required_values] + const blueprintUpdateData = blueprintUpdate as BlueprintUpdateResponse + const requiredValues = useMemo( + () => [...blueprintUpdateData.new_required_values, ...blueprintUpdateData.now_required_values], + [blueprintUpdateData.new_required_values, blueprintUpdateData.now_required_values] + ) + const updatedValues = useMemo( + () => [...blueprintUpdateData.updated_values, ...blueprintUpdateData.engine_diff.updated_values], + [blueprintUpdateData.engine_diff.updated_values, blueprintUpdateData.updated_values] + ) const sectionHasContent = { required: requiredValues.length > 0, - optional: blueprintUpdate.new_optional_values.length > 0, - modified: blueprintUpdate.updated_values.length > 0 || blueprintUpdate.engine_diff.updated_values.length > 0, - removed: blueprintUpdate.removed_values.length > 0, + optional: blueprintUpdateData.new_optional_values.length > 0, + modified: updatedValues.length > 0, + removed: blueprintUpdateData.removed_values.length > 0, } - const visibleSections = updateSections.filter(({ id }) => sectionHasContent[id]) - const reviewSections = visibleSections.length > 0 ? visibleSections : updateSections.slice(0, 1) + const reviewSections = updateSections.filter(({ id }) => sectionHasContent[id]) const hasSingleReviewSection = reviewSections.length === 1 const activeSectionIndex = reviewSections.findIndex(({ id }) => id === activeSection) const isRequiredValid = requiredValues.every((value) => @@ -128,19 +189,23 @@ export function BlueprintUpdateFlow({ const isReviewComplete = reviewSections.every(({ id }) => completedSections.includes(id)) || reviewSections.length === 0 const canContinueReview = hasSingleReviewSection ? activeSection !== 'required' || isRequiredValid : isReviewComplete - const latestVersion = getVersionFromTag(blueprintUpdate.latest_tag) ?? blueprintUpdate.latest_tag + const latestVersion = getBlueprintUpdateVersion(blueprintUpdateData.latest_tag) ?? blueprintUpdateData.latest_tag const title = `${service.name} blueprint update to ${latestVersion}` - const payload = buildBlueprintUpdatePayload({ - icon: service.icon_uri ?? getFallbackServiceIcon(service.service_type), - name: service.name, - tag: blueprintUpdate.latest_tag, - values, - optionalValues: blueprintUpdate.new_optional_values, - requiredValues, - updatedValues: [...blueprintUpdate.updated_values, ...blueprintUpdate.engine_diff.updated_values], - }) + const payload = useMemo( + () => + buildBlueprintUpdatePayload({ + icon: service.icon_uri ?? getFallbackServiceIcon(service.service_type), + name: service.name, + tag: blueprintUpdateData.latest_tag, + values, + optionalValues: blueprintUpdateData.new_optional_values, + requiredValues, + updatedValues, + }), + [blueprintUpdateData.latest_tag, blueprintUpdateData.new_optional_values, requiredValues, service, updatedValues, values] + ) - const completeActiveSection = () => { + const completeActiveSection = useCallback(() => { if (activeSection === 'required' && !isRequiredValid) return const nextCompletedSections = completedSections.includes(activeSection) ? completedSections @@ -149,92 +214,171 @@ export function BlueprintUpdateFlow({ setCompletedSections(nextCompletedSections) if (nextSection) setActiveSection(nextSection) - } + }, [activeSection, activeSectionIndex, completedSections, isRequiredValid, reviewSections]) - const handlePreview = async () => { - if (!canContinueReview) return + const requestPreview = useCallback(async () => { + const payloadKey = JSON.stringify(payload) + if (previewPayloadKey === payloadKey) return - await previewBlueprintUpdate({ blueprintId, payload }) - setCurrentStep('preview') - } + setPreviewPayloadKey(payloadKey) + setPreviewId(undefined) - const handleUpdate = async () => { + try { + const preview = await previewBlueprintUpdate({ blueprintId, payload }) + setPreviewId(preview?.preview_id) + } catch (error) { + setPreviewPayloadKey(undefined) + throw error + } + }, [blueprintId, payload, previewBlueprintUpdate, previewPayloadKey]) + + const handleUpdate = useCallback(async () => { await updateBlueprint({ blueprintId, payload }) toast('success', 'Blueprint update started') onExit() + }, [blueprintId, onExit, payload, updateBlueprint]) + + const contextValue: BlueprintUpdateFlowContextValue = { + activeSection, + blueprintUpdate: blueprintUpdateData, + canContinueReview, + clusterId, + completeActiveSection, + completedSections, + handleUpdate, + isPreviewLoading, + isRequiredValid, + isUpdateLoading, + onChange: (name, value) => setValues((currentValues) => ({ ...currentValues, [name]: value })), + organizationId, + previewId, + removedValues: blueprintUpdateData.removed_values, + requestPreview, + requiredValues, + reviewSections, + service, + setActiveSection, + title, + updatedValues, + values, } return ( - - {currentStep === 'review' ? ( - -
-

{title}

-
- Release notes - - -
- -
- {reviewSections.map((section) => ( - setActiveSection(section.id)} - > - {activeSection === section.id && ( - setValues((currentValues) => ({ ...currentValues, [name]: value }))} - onContinue={completeActiveSection} - continueDisabled={section.id === 'required' && !isRequiredValid} - showContinueButton={!hasSingleReviewSection} - /> - )} - - ))} -
+ + + {children} + + + ) +} + +export function BlueprintUpdateReviewStep({ onContinue }: { onContinue: () => void }) { + const { + activeSection, + blueprintUpdate, + canContinueReview, + completeActiveSection, + completedSections, + isRequiredValid, + requiredValues, + reviewSections, + setActiveSection, + title, + updatedValues, + values, + onChange, + } = useBlueprintUpdateFlowContext() + const hasSingleReviewSection = reviewSections.length === 1 -
- -
- - ) : ( - setCurrentStep('review')} - onConfirm={handleUpdate} - loading={isUpdateLoading} - /> - )} - + {activeSection === section.id && ( + + )} + + )) + ) : ( +

+ No configuration input is required. Continue to preview the update. +

+ )} +
+ +
+ +
+ + ) +} + +export function BlueprintUpdatePreviewStep({ onBack }: { onBack: () => void }) { + const { + clusterId, + handleUpdate, + isUpdateLoading, + organizationId, + previewId, + requestPreview, + } = useBlueprintUpdateFlowContext() + + useEffect(() => { + requestPreview() + }, [requestPreview]) + + return ( + ) } @@ -598,7 +742,6 @@ function BlueprintUpdatePreview({ const { rawOutput, isLoading: isPreviewOutputLoading, - hasError: hasPreviewOutputError, hasReceivedMessage: hasReceivedPreviewMessage, } = useBlueprintUpdatePreviewSocket({ organizationId, @@ -621,12 +764,6 @@ function BlueprintUpdatePreview({ > {rawOutput ? ( - ) : previewId ? ( - hasPreviewOutputError ? ( -

Unable to load preview output.

- ) : ( - - ) ) : ( )} @@ -815,10 +952,6 @@ function formatUpdateValue(value?: BlueprintFieldValue | string | null) { return value && value.length > 0 ? value : '-' } -function getVersionFromTag(tag: string) { - return tag.split('/').filter(Boolean).at(-1) -} - function getFallbackServiceIcon(serviceType: AnyService['service_type']) { if (serviceType === 'HELM') return 'app://qovery-console/helm' if (serviceType === 'TERRAFORM') return 'app://qovery-console/terraform' From b7c98d479c1c911514df68c5a559381e9ac54490 Mon Sep 17 00:00:00 2001 From: Romain Billard Date: Thu, 9 Jul 2026 18:05:20 +0200 Subject: [PATCH 23/35] Fix lint issues --- .../service-header/service-header.tsx | 2 +- .../blueprint/blueprint-update-flow.tsx | 33 ++++++++----------- 2 files changed, 15 insertions(+), 20 deletions(-) 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 1f256e27ad6..0eee043db9b 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 @@ -1,5 +1,5 @@ -import { Link, useNavigate, useParams } from '@tanstack/react-router' import * as Dialog from '@radix-ui/react-dialog' +import { Link, useNavigate, useParams } from '@tanstack/react-router' import { type ApplicationGitRepository, type Credentials, type Environment } from 'qovery-typescript-axios' import { Suspense } from 'react' import { P, match } from 'ts-pattern' diff --git a/libs/domains/services/feature/src/lib/service-update-flow/blueprint/blueprint-update-flow.tsx b/libs/domains/services/feature/src/lib/service-update-flow/blueprint/blueprint-update-flow.tsx index ba552657a28..b44928e13aa 100644 --- a/libs/domains/services/feature/src/lib/service-update-flow/blueprint/blueprint-update-flow.tsx +++ b/libs/domains/services/feature/src/lib/service-update-flow/blueprint/blueprint-update-flow.tsx @@ -100,7 +100,8 @@ const updateSections: Array<{ id: 'modified', title: 'Modified values', iconName: 'arrows-rotate', - description: "Existing values for which the default value has been updated. Your overrides has priority over the default value.", + description: + 'Existing values for which the default value has been updated. Your overrides has priority over the default value.', }, { id: 'removed', @@ -139,10 +140,7 @@ export function BlueprintUpdateFlow({ service, }: BlueprintUpdateFlowProps) { const { data: blueprintUpdate } = useBlueprintUpdate({ blueprintId, suspense: true }) - const { - mutateAsync: previewBlueprintUpdate, - isLoading: isPreviewLoading, - } = usePreviewBlueprintUpdate() + const { mutateAsync: previewBlueprintUpdate, isLoading: isPreviewLoading } = usePreviewBlueprintUpdate() const { mutateAsync: updateBlueprint, isLoading: isUpdateLoading } = useUpdateBlueprint({ environmentId, serviceId: service.id, @@ -202,7 +200,14 @@ export function BlueprintUpdateFlow({ requiredValues, updatedValues, }), - [blueprintUpdateData.latest_tag, blueprintUpdateData.new_optional_values, requiredValues, service, updatedValues, values] + [ + blueprintUpdateData.latest_tag, + blueprintUpdateData.new_optional_values, + requiredValues, + service, + updatedValues, + values, + ] ) const completeActiveSection = useCallback(() => { @@ -357,14 +362,8 @@ export function BlueprintUpdateReviewStep({ onContinue }: { onContinue: () => vo } export function BlueprintUpdatePreviewStep({ onBack }: { onBack: () => void }) { - const { - clusterId, - handleUpdate, - isUpdateLoading, - organizationId, - previewId, - requestPreview, - } = useBlueprintUpdateFlowContext() + const { clusterId, handleUpdate, isUpdateLoading, organizationId, previewId, requestPreview } = + useBlueprintUpdateFlowContext() useEffect(() => { requestPreview() @@ -762,11 +761,7 @@ function BlueprintUpdatePreview({
- {rawOutput ? ( - - ) : ( - - )} + {rawOutput ? : }
From 6e94d4e196a83899936959fe57ba0b3e30c4da0a Mon Sep 17 00:00:00 2001 From: Romain Billard Date: Fri, 10 Jul 2026 09:23:08 +0200 Subject: [PATCH 24/35] Handle undefined edited overrides in blueprint update flow --- .../service-update-flow/blueprint/blueprint-update-flow.tsx | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/libs/domains/services/feature/src/lib/service-update-flow/blueprint/blueprint-update-flow.tsx b/libs/domains/services/feature/src/lib/service-update-flow/blueprint/blueprint-update-flow.tsx index b44928e13aa..22686c7f5af 100644 --- a/libs/domains/services/feature/src/lib/service-update-flow/blueprint/blueprint-update-flow.tsx +++ b/libs/domains/services/feature/src/lib/service-update-flow/blueprint/blueprint-update-flow.tsx @@ -606,7 +606,8 @@ function UpdatedValuesList({ ? editableValues[value.name] ?? '' : getBlueprintUpdateFieldValue(value, value.new_default_value) const newDefaultValue = value.new_default_value ?? '' - const hasEditedOverride = hasManualOverride && getBlueprintUpdatePayloadValue(editedValue) !== newDefaultValue + const overrideValue = getBlueprintUpdatePayloadValue(editedValue) + const hasEditedOverride = hasManualOverride && overrideValue !== undefined && overrideValue !== newDefaultValue const editing = editedValueName === value.name const canUseTypedInput = isBlueprintUpdateVariableField(value) const field = canUseTypedInput From e5bc1c456c165651eae3838af2603f7b44d404e8 Mon Sep 17 00:00:00 2001 From: Romain Billard Date: Fri, 10 Jul 2026 16:03:29 +0200 Subject: [PATCH 25/35] Add unit tests for getDefaultBlueprintFieldValues --- .../blueprint-field-utils.spec.ts | 72 +++++++++++++++++++ 1 file changed, 72 insertions(+) create mode 100644 libs/domains/services/feature/src/lib/blueprint-field-utils/blueprint-field-utils.spec.ts diff --git a/libs/domains/services/feature/src/lib/blueprint-field-utils/blueprint-field-utils.spec.ts b/libs/domains/services/feature/src/lib/blueprint-field-utils/blueprint-field-utils.spec.ts new file mode 100644 index 00000000000..42116c51ba8 --- /dev/null +++ b/libs/domains/services/feature/src/lib/blueprint-field-utils/blueprint-field-utils.spec.ts @@ -0,0 +1,72 @@ +import { + type BlueprintManifestContextVariableField, + type BlueprintManifestResponseResultsInner, + type BlueprintManifestVariableField, +} from 'qovery-typescript-axios' +import { getDefaultBlueprintFieldValues } from './blueprint-field-utils' + +function createVariableField(overrides: Partial = {}): BlueprintManifestVariableField { + return { + kind: 'variable', + name: 'field', + required: true, + is_secret: false, + description: 'Field', + type: { type: 'string' }, + ...overrides, + } as BlueprintManifestVariableField +} + +function createContextVariableField( + overrides: Partial = {} +): BlueprintManifestContextVariableField { + return { + kind: 'contextVariable', + name: 'context_field', + description: 'Context field', + source: 'environment', + value: 'context-value', + ...overrides, + } as BlueprintManifestContextVariableField +} + +describe('getDefaultBlueprintFieldValues', () => { + it('returns defaults for required, optional, and overridable context fields', () => { + const fields: BlueprintManifestResponseResultsInner[] = [ + createVariableField({ name: 'required_string', required: true, default_value: 'required-value' }), + createVariableField({ name: 'optional_string', required: false, default_value: 'optional-value' }), + { + ...createVariableField({ name: 'required_bool', required: true, type: { type: 'bool' }, default_value: 'true' }), + }, + { + ...createContextVariableField({ name: 'overridable_context', value: 'context-value' }), + overridable: true, + }, + ] as BlueprintManifestResponseResultsInner[] + + expect(getDefaultBlueprintFieldValues(fields)).toEqual({ + required_string: 'required-value', + optional_string: 'optional-value', + required_bool: true, + overridable_context: 'context-value', + }) + }) + + it('excludes non-overridable context fields and preserves empty values for missing defaults', () => { + const fields: BlueprintManifestResponseResultsInner[] = [ + createVariableField({ name: 'required_without_default', required: true }), + createVariableField({ name: 'optional_without_default', required: false }), + createContextVariableField({ name: 'context_without_override', value: 'ignored' }), + { + ...createContextVariableField({ name: 'overridable_without_value', value: undefined }), + overridable: true, + }, + ] as BlueprintManifestResponseResultsInner[] + + expect(getDefaultBlueprintFieldValues(fields)).toEqual({ + required_without_default: '', + optional_without_default: '', + overridable_without_value: '', + }) + }) +}) From 9f2369e350cf74bb18069ac3619dacdc45be353d Mon Sep 17 00:00:00 2001 From: Romain Billard Date: Fri, 10 Jul 2026 16:15:09 +0200 Subject: [PATCH 26/35] Use clsx --- .../blueprint/blueprint-update-flow.tsx | 37 +++++++++++++------ 1 file changed, 25 insertions(+), 12 deletions(-) diff --git a/libs/domains/services/feature/src/lib/service-update-flow/blueprint/blueprint-update-flow.tsx b/libs/domains/services/feature/src/lib/service-update-flow/blueprint/blueprint-update-flow.tsx index 22686c7f5af..2576c59352b 100644 --- a/libs/domains/services/feature/src/lib/service-update-flow/blueprint/blueprint-update-flow.tsx +++ b/libs/domains/services/feature/src/lib/service-update-flow/blueprint/blueprint-update-flow.tsx @@ -1,3 +1,4 @@ +import clsx from 'clsx' import { type IconName } from '@fortawesome/fontawesome-common-types' import { type BlueprintManifestVariableField, @@ -10,7 +11,19 @@ import { } from 'qovery-typescript-axios' import { type ReactNode, createContext, useCallback, useContext, useEffect, useMemo, useState } from 'react' import { type AnyService } from '@qovery/domains/services/data-access' -import { Badge, Button, FunnelFlowBody, Icon, InputText, LogoIcon, Skeleton, Tooltip, toast } from '@qovery/shared/ui' +import { + Badge, + Button, + FunnelFlowBody, + Heading, + Icon, + InputText, + LogoIcon, + Section, + Skeleton, + Tooltip, + toast, +} from '@qovery/shared/ui' import { type BlueprintFieldValue, type BlueprintFieldValues, @@ -439,16 +452,15 @@ function StepIndicator({ ) : ( {number} )} - + {title} @@ -473,7 +485,7 @@ function BlueprintUpdateSectionCard({ title: string }) { return ( -
+
{children &&
{children}
} -
+
) } From e0f59924ad993e717e62533c6ac8dcb2ddf152b8 Mon Sep 17 00:00:00 2001 From: Romain Billard Date: Fri, 10 Jul 2026 16:24:26 +0200 Subject: [PATCH 27/35] Use Section and Heading components --- .../blueprint/blueprint-update-flow.tsx | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/libs/domains/services/feature/src/lib/service-update-flow/blueprint/blueprint-update-flow.tsx b/libs/domains/services/feature/src/lib/service-update-flow/blueprint/blueprint-update-flow.tsx index 2576c59352b..8b8298794fa 100644 --- a/libs/domains/services/feature/src/lib/service-update-flow/blueprint/blueprint-update-flow.tsx +++ b/libs/domains/services/feature/src/lib/service-update-flow/blueprint/blueprint-update-flow.tsx @@ -767,18 +767,18 @@ function BlueprintUpdatePreview({ return ( -
-

Preview changes

+
+ Preview changes -
-

Raw output

+
+ Raw output
{rawOutput ? : }
-
-
+ +
+ + +
{children}
+ + ) +} + +function StepIndicator({ + active, + completed, + number, + title, +}: { + active?: boolean + completed?: boolean + number: number + title: string +}) { + return ( +
+ {completed ? ( + + ) : ( + + {number} + + )} + + {title} + +
+ ) +} diff --git a/libs/domains/services/feature/src/lib/service-update-flow/blueprint/blueprint-update-flow.tsx b/libs/domains/services/feature/src/lib/service-update-flow/blueprint/blueprint-update-flow.tsx index 8b8298794fa..c56063ee23b 100644 --- a/libs/domains/services/feature/src/lib/service-update-flow/blueprint/blueprint-update-flow.tsx +++ b/libs/domains/services/feature/src/lib/service-update-flow/blueprint/blueprint-update-flow.tsx @@ -1,49 +1,41 @@ -import clsx from 'clsx' -import { type IconName } from '@fortawesome/fontawesome-common-types' import { - type BlueprintManifestVariableField, - type BlueprintUpdateEngineFieldChange, - type BlueprintUpdateNewOptionalValue, - type BlueprintUpdateNewRequiredValue, - type BlueprintUpdateRemovedValue, type BlueprintUpdateResponse, - type BlueprintUpdateUpdatedValue, } from 'qovery-typescript-axios' -import { type ReactNode, createContext, useCallback, useContext, useEffect, useMemo, useState } from 'react' +import { type ReactNode, useCallback, useEffect, useMemo, useState } from 'react' import { type AnyService } from '@qovery/domains/services/data-access' -import { - Badge, - Button, - FunnelFlowBody, - Heading, - Icon, - InputText, - LogoIcon, - Section, - Skeleton, - Tooltip, - toast, -} from '@qovery/shared/ui' +import { toast } from '@qovery/shared/ui' import { type BlueprintFieldValue, type BlueprintFieldValues, - getFieldValidationError, isFieldValid, } from '../../blueprint-field-utils/blueprint-field-utils' -import { BlueprintManifestVariableInput } from '../../blueprint-manifest-variable-input/blueprint-manifest-variable-input' -import { useBlueprintUpdatePreviewSocket } from '../../hooks/use-blueprint-update-preview-socket/use-blueprint-update-preview-socket' import { useBlueprintUpdate } from '../../hooks/use-blueprint-update/use-blueprint-update' import { usePreviewBlueprintUpdate } from '../../hooks/use-preview-blueprint-update/use-preview-blueprint-update' import { useUpdateBlueprint } from '../../hooks/use-update-blueprint/use-update-blueprint' - -type BlueprintUpdateSection = 'required' | 'optional' | 'modified' | 'removed' - -type BlueprintUpdateVariablePatch = Record -type BlueprintUpdateField = - | BlueprintUpdateNewRequiredValue - | BlueprintUpdateNewOptionalValue - | BlueprintUpdateUpdatedValue -type BlueprintUpdateEditableValue = BlueprintUpdateUpdatedValue | BlueprintUpdateEngineFieldChange +import { BlueprintUpdateFlowProvider } from './blueprint-update-context' +import { BlueprintUpdateFlowShell } from './blueprint-update-flow-shell' +import { + buildBlueprintUpdatePayload, + getBlueprintUpdateFieldValue, + getBlueprintUpdateVersion, + getFallbackServiceIcon, + getFirstAvailableUpdateSection, + getBlueprintUpdateVariableField, + type BlueprintUpdateEditableValue, + type BlueprintUpdateSection, + updateSections, +} from './blueprint-update-utils' + +export type { BlueprintUpdateFlowContextValue } from './blueprint-update-context' +export { useBlueprintUpdateFlowContext } from './blueprint-update-context' +export { + BLUEPRINT_RELEASE_NOTES_URL, + blueprintUpdateSteps, + getBlueprintUpdateVersion, + hasBlueprintUpdateReviewSections, +} from './blueprint-update-utils' +export { BlueprintUpdatePreviewStep } from './blueprint-update-preview-step' +export { BlueprintUpdateReviewStep } from './blueprint-update-review-step' export interface BlueprintUpdateFlowProps { blueprintId: string @@ -56,92 +48,6 @@ export interface BlueprintUpdateFlowProps { service: AnyService } -interface BlueprintUpdateFlowContextValue { - activeSection: BlueprintUpdateSection - blueprintUpdate: BlueprintUpdateResponse - canContinueReview: boolean - clusterId?: string - completeActiveSection: () => void - completedSections: BlueprintUpdateSection[] - handleUpdate: () => Promise - isPreviewLoading: boolean - isRequiredValid: boolean - isUpdateLoading: boolean - onChange: (name: string, value: BlueprintFieldValue) => void - organizationId: string - previewId?: string - removedValues: BlueprintUpdateRemovedValue[] - requestPreview: () => Promise - requiredValues: BlueprintUpdateNewRequiredValue[] - reviewSections: Array<(typeof updateSections)[number]> - service: AnyService - setActiveSection: (section: BlueprintUpdateSection) => void - title: string - updatedValues: BlueprintUpdateEditableValue[] - values: BlueprintFieldValues -} - -const BlueprintUpdateFlowContext = createContext(undefined) - -export function useBlueprintUpdateFlowContext() { - const context = useContext(BlueprintUpdateFlowContext) - if (!context) throw new Error('useBlueprintUpdateFlowContext must be used within BlueprintUpdateFlow') - - return context -} - -const updateSections: Array<{ - id: BlueprintUpdateSection - title: string - iconName: IconName - description?: string -}> = [ - { - id: 'required', - title: 'New required values', - iconName: 'circle-plus', - description: - "These are new fields we have added that don't have a default value and require you to enter a specific value for the update.", - }, - { - id: 'optional', - title: 'New optional values', - iconName: 'chart-bullet', - description: 'These new fields have a default value, but you can override them if needed.', - }, - { - id: 'modified', - title: 'Modified values', - iconName: 'arrows-rotate', - description: - 'Existing values for which the default value has been updated. Your overrides has priority over the default value.', - }, - { - id: 'removed', - title: 'Removed values', - iconName: 'circle-minus', - }, -] - -export const blueprintUpdateSteps: { title: string }[] = [{ title: 'Review update' }, { title: 'Preview changes' }] - -export const BLUEPRINT_RELEASE_NOTES_URL = 'https://github.com/Qovery/service-catalog/releases' - -export function hasBlueprintUpdateReviewSections(blueprintUpdate: BlueprintUpdateResponse) { - return ( - blueprintUpdate.new_required_values.length > 0 || - blueprintUpdate.now_required_values.length > 0 || - blueprintUpdate.new_optional_values.length > 0 || - blueprintUpdate.updated_values.length > 0 || - blueprintUpdate.engine_diff.updated_values.length > 0 || - blueprintUpdate.removed_values.length > 0 - ) -} - -export function getBlueprintUpdateVersion(tag: string) { - return tag.split('/').filter(Boolean).at(-1) -} - export function BlueprintUpdateFlow({ blueprintId, children, @@ -170,7 +76,14 @@ export function BlueprintUpdateFlow({ useEffect(() => { if (blueprintUpdate && initializedBlueprintId !== blueprintId) { - setValues(getInitialUpdateValues(blueprintUpdate)) + setValues( + Object.fromEntries( + blueprintUpdate.new_optional_values.map((value) => [ + value.name, + getBlueprintUpdateFieldValue(value, value.default_value), + ]) + ) + ) setActiveSection(getFirstAvailableUpdateSection(blueprintUpdate)) setInitializedBlueprintId(blueprintId) } @@ -192,14 +105,14 @@ export function BlueprintUpdateFlow({ removed: blueprintUpdateData.removed_values.length > 0, } const reviewSections = updateSections.filter(({ id }) => sectionHasContent[id]) - const hasSingleReviewSection = reviewSections.length === 1 const activeSectionIndex = reviewSections.findIndex(({ id }) => id === activeSection) const isRequiredValid = requiredValues.every((value) => isFieldValid(getBlueprintUpdateVariableField(value, true), values[value.name]) ) - const isReviewComplete = - reviewSections.every(({ id }) => completedSections.includes(id)) || reviewSections.length === 0 - const canContinueReview = hasSingleReviewSection ? activeSection !== 'required' || isRequiredValid : isReviewComplete + const isReviewComplete = reviewSections.every(({ id }) => completedSections.includes(id)) || reviewSections.length === 0 + const canContinueReview = reviewSections.length === 1 + ? activeSection !== 'required' || isRequiredValid + : isReviewComplete const latestVersion = getBlueprintUpdateVersion(blueprintUpdateData.latest_tag) ?? blueprintUpdateData.latest_tag const title = `${service.name} blueprint update to ${latestVersion}` const payload = useMemo( @@ -213,14 +126,7 @@ export function BlueprintUpdateFlow({ requiredValues, updatedValues, }), - [ - blueprintUpdateData.latest_tag, - blueprintUpdateData.new_optional_values, - requiredValues, - service, - updatedValues, - values, - ] + [blueprintUpdateData.latest_tag, blueprintUpdateData.new_optional_values, requiredValues, service, updatedValues, values] ) const completeActiveSection = useCallback(() => { @@ -256,7 +162,7 @@ export function BlueprintUpdateFlow({ onExit() }, [blueprintId, onExit, payload, updateBlueprint]) - const contextValue: BlueprintUpdateFlowContextValue = { + const contextValue = { activeSection, blueprintUpdate: blueprintUpdateData, canContinueReview, @@ -267,7 +173,8 @@ export function BlueprintUpdateFlow({ isPreviewLoading, isRequiredValid, isUpdateLoading, - onChange: (name, value) => setValues((currentValues) => ({ ...currentValues, [name]: value })), + onChange: (name: string, value: BlueprintFieldValue) => + setValues((currentValues) => ({ ...currentValues, [name]: value })), organizationId, previewId, removedValues: blueprintUpdateData.removed_values, @@ -277,694 +184,17 @@ export function BlueprintUpdateFlow({ service, setActiveSection, title, - updatedValues, + updatedValues: updatedValues as BlueprintUpdateEditableValue[], values, } return ( - + {children} - - ) -} - -export function BlueprintUpdateReviewStep({ onContinue }: { onContinue: () => void }) { - const { - activeSection, - blueprintUpdate, - canContinueReview, - completeActiveSection, - completedSections, - isRequiredValid, - requiredValues, - reviewSections, - setActiveSection, - title, - updatedValues, - values, - onChange, - } = useBlueprintUpdateFlowContext() - const hasSingleReviewSection = reviewSections.length === 1 - - return ( - -
-

{title}

- - Release notes - - -
- -
- {reviewSections.length > 0 ? ( - reviewSections.map((section) => ( - setActiveSection(section.id)} - > - {activeSection === section.id && ( - - )} - - )) - ) : ( -

- No configuration input is required. Continue to preview the update. -

- )} -
- -
- -
-
- ) -} - -export function BlueprintUpdatePreviewStep({ onBack }: { onBack: () => void }) { - const { clusterId, handleUpdate, isUpdateLoading, organizationId, previewId, requestPreview } = - useBlueprintUpdateFlowContext() - - useEffect(() => { - requestPreview() - }, [requestPreview]) - - return ( - - ) -} - -function BlueprintUpdateFlowShell({ - children, - currentStep, - onExit, -}: { - children: ReactNode - currentStep: 1 | 2 - onExit: () => void -}) { - return ( -
-
-
-
- -
-
- 1} active={currentStep === 1} number={1} title="Review update" /> - - -
-
-
- -
-
-
{children}
-
- ) -} - -function StepIndicator({ - active, - completed, - number, - title, -}: { - active?: boolean - completed?: boolean - number: number - title: string -}) { - return ( -
- {completed ? ( - - ) : ( - - {number} - - )} - - {title} - -
- ) -} - -function BlueprintUpdateSectionCard({ - active, - children, - completed, - description, - iconName, - onClick, - title, -}: { - active: boolean - children: ReactNode - completed: boolean - description?: string - iconName: IconName - onClick: () => void - title: string -}) { - return ( -
- - {children &&
{children}
} -
- ) -} - -function BlueprintUpdateSectionContent({ - continueDisabled, - onChange, - onContinue, - optionalValues, - removedValues, - requiredValues, - section, - showContinueButton, - updatedValues, - values, -}: { - continueDisabled: boolean - onChange: (name: string, value: BlueprintFieldValue) => void - onContinue: () => void - optionalValues: BlueprintUpdateNewOptionalValue[] - removedValues: BlueprintUpdateRemovedValue[] - requiredValues: BlueprintUpdateNewRequiredValue[] - section: BlueprintUpdateSection - showContinueButton: boolean - updatedValues: BlueprintUpdateEditableValue[] - values: BlueprintFieldValues -}) { - return ( - <> - {section === 'required' && ( -
- {requiredValues.map((value, index) => { - const field = getBlueprintUpdateVariableField(value, true) - - return ( - onChange(value.name, fieldValue)} - /> - ) - })} -
- )} - - {section === 'optional' && ( -
- {optionalValues.map((value, index) => { - const field = getBlueprintUpdateVariableField(value, false, value.default_value) - - return ( - onChange(value.name, fieldValue)} - /> - ) - })} -
- )} - - {section === 'modified' && ( - - )} - - {section === 'removed' && } - - {showContinueButton && ( - - )} - - ) -} - -function UpdatedValuesList({ - editableValues, - onChange, - values, -}: { - editableValues: BlueprintFieldValues - onChange: (name: string, value: BlueprintFieldValue) => void - values: BlueprintUpdateEditableValue[] -}) { - const [editedValueName, setEditedValueName] = useState() - - if (values.length === 0) return

No modified values.

- - return ( -
- {values.map((value) => { - const label = formatUpdateFieldLabel(value.name) - const hasManualOverride = Object.prototype.hasOwnProperty.call(editableValues, value.name) - const editedValue = hasManualOverride - ? editableValues[value.name] ?? '' - : getBlueprintUpdateFieldValue(value, value.new_default_value) - const newDefaultValue = value.new_default_value ?? '' - const overrideValue = getBlueprintUpdatePayloadValue(editedValue) - const hasEditedOverride = hasManualOverride && overrideValue !== undefined && overrideValue !== newDefaultValue - const editing = editedValueName === value.name - const canUseTypedInput = isBlueprintUpdateVariableField(value) - const field = canUseTypedInput - ? getBlueprintUpdateVariableField(value, false, value.new_default_value) - : undefined - - return ( -
-
-
-

{label}

-
-
- Default: - - {formatUpdateValue(value.current_default_value)} - - - - {formatUpdateValue(value.new_default_value)} - -
- {hasEditedOverride && ( -
- Override: - - {formatUpdateValue(editedValue)} - -
- )} -
-
- -
- {hasEditedOverride && ( - Reset the override to the new default value}> - - - )} - - - -
-
- - {editing && - (field ? ( - onChange(value.name, fieldValue)} - /> - ) : ( - onChange(value.name, event.currentTarget.value)} - /> - ))} -
- ) - })} -
- ) -} - -function RemovedValuesList({ values }: { values: Array<{ name: string }> }) { - if (values.length === 0) return

No removed values.

- - return ( -
- {values.map((value) => ( -
-

{formatUpdateFieldLabel(value.name)}

- - Deleted - -
- ))} -
- ) -} - -function BlueprintUpdatePreview({ - clusterId, - loading, - onBack, - onConfirm, - organizationId, - previewId, -}: { - clusterId?: string - loading: boolean - onBack: () => void - onConfirm: () => void - organizationId: string - previewId?: string -}) { - const { - rawOutput, - isLoading: isPreviewOutputLoading, - hasReceivedMessage: hasReceivedPreviewMessage, - } = useBlueprintUpdatePreviewSocket({ - organizationId, - clusterId, - previewId, - }) - const rawOutputContainerHeightClassName = rawOutput - ? 'h-[min(75vh,calc(100vh-260px))] min-h-[260px]' - : 'min-h-[180px]' - - return ( - -
- Preview changes - -
- Raw output -
- {rawOutput ? : } -
-
-
- -
- - -
-
- ) -} - -function BlueprintUpdateRawOutput({ rawOutput }: { rawOutput: string }) { - const lines = rawOutput.split('\n') - - return ( -
-      {lines.map((line, index) => (
-        
-          {line}
-          {index < lines.length - 1 ? '\n' : null}
-        
-      ))}
-    
- ) -} - -function BlueprintUpdateRawOutputSkeleton() { - const skeletonLineWidths = ['46%', '28%', '72%', '64%', '82%', '34%'] - - return ( -
-
- - Generating preview output -
- {skeletonLineWidths.map((width, index) => ( - - ))} -
+ ) } -function getRawOutputLineClassName(line: string) { - const prefix = line.trimStart().charAt(0) - - if (prefix === '+') return 'text-positive' - if (prefix === '-') return 'text-negative' - if (prefix === '~') return 'text-info' - - return undefined -} - -function getInitialUpdateValues(blueprintUpdate: NonNullable['data']>) { - return { - ...Object.fromEntries( - blueprintUpdate.new_optional_values.map((value) => [ - value.name, - getBlueprintUpdateFieldValue(value, value.default_value), - ]) - ), - } -} - -function getFirstAvailableUpdateSection( - blueprintUpdate: NonNullable['data']> -): BlueprintUpdateSection { - if (blueprintUpdate.new_required_values.length > 0 || blueprintUpdate.now_required_values.length > 0) { - return 'required' - } - if (blueprintUpdate.new_optional_values.length > 0) return 'optional' - if (blueprintUpdate.updated_values.length > 0 || blueprintUpdate.engine_diff.updated_values.length > 0) { - return 'modified' - } - if (blueprintUpdate.removed_values.length > 0) return 'removed' - return 'required' -} - -function buildBlueprintUpdatePayload({ - icon, - name, - optionalValues, - requiredValues, - tag, - updatedValues, - values, -}: { - icon: string - name: string - optionalValues: BlueprintUpdateNewOptionalValue[] - requiredValues: BlueprintUpdateNewRequiredValue[] - tag: string - updatedValues: BlueprintUpdateEditableValue[] - values: BlueprintFieldValues -}) { - const variables: BlueprintUpdateVariablePatch = {} - - requiredValues.forEach((field) => { - const value = getBlueprintUpdatePayloadValue(values[field.name]) - if (value) variables[field.name] = { value, is_secret: field.is_secret } - }) - optionalValues.forEach((field) => { - const value = getBlueprintUpdatePayloadValue(values[field.name]) - if (value && value !== field.default_value) variables[field.name] = { value, is_secret: field.is_secret } - }) - updatedValues.forEach((field) => { - const value = getBlueprintUpdatePayloadValue(values[field.name]) - if (!value || value === field.new_default_value) return - - variables[field.name] = { - value, - ...(isBlueprintUpdateVariableField(field) ? { is_secret: field.is_secret } : {}), - } - }) - - return { - name, - tag, - icon, - variables, - } -} - -function getBlueprintUpdateVariableField( - field: BlueprintUpdateField, - required: boolean, - defaultValue?: string | null -): BlueprintManifestVariableField { - return { - kind: 'variable', - name: field.name, - type: field.type, - required, - is_secret: field.is_secret, - allowed_values: field.allowed_values, - default_value: defaultValue, - } -} - -function isBlueprintUpdateVariableField( - field: BlueprintUpdateEditableValue | BlueprintUpdateNewOptionalValue -): field is BlueprintUpdateUpdatedValue | BlueprintUpdateNewOptionalValue { - return 'type' in field && 'is_secret' in field -} - -function getBlueprintUpdateFieldValue( - field: BlueprintUpdateEditableValue | BlueprintUpdateNewOptionalValue, - value?: string | null -): BlueprintFieldValue { - if (isBlueprintUpdateVariableField(field) && field.type.type === 'bool' && !field.allowed_values?.length) { - return value === 'true' - } - - return value ?? '' -} - -function getBlueprintUpdatePayloadValue(value: BlueprintFieldValue | undefined) { - if (typeof value === 'boolean') return String(value) - - const trimmedValue = value?.trim() - return trimmedValue ? trimmedValue : undefined -} - -function formatUpdateFieldLabel(name: string) { - const label = name.replace(/_/g, ' ') - return `${label.charAt(0).toUpperCase()}${label.slice(1)}` -} - -function formatUpdateValue(value?: BlueprintFieldValue | string | null) { - if (typeof value === 'boolean') return String(value) - return value && value.length > 0 ? value : '-' -} - -function getFallbackServiceIcon(serviceType: AnyService['service_type']) { - if (serviceType === 'HELM') return 'app://qovery-console/helm' - if (serviceType === 'TERRAFORM') return 'app://qovery-console/terraform' - return 'app://qovery-console/application' -} - export default BlueprintUpdateFlow diff --git a/libs/domains/services/feature/src/lib/service-update-flow/blueprint/blueprint-update-loading-modal.spec.tsx b/libs/domains/services/feature/src/lib/service-update-flow/blueprint/blueprint-update-loading-modal.spec.tsx new file mode 100644 index 00000000000..f7b831556ba --- /dev/null +++ b/libs/domains/services/feature/src/lib/service-update-flow/blueprint/blueprint-update-loading-modal.spec.tsx @@ -0,0 +1,42 @@ +import { renderWithProviders, screen } from '@qovery/shared/util-tests' +import { BlueprintUpdateLoadingModal } from './blueprint-update-loading-modal' + +const props = { + onEditConfig: jest.fn(), + onRetry: jest.fn(), + open: true, + serviceName: 'AWS S3 Bucket', +} + +describe('BlueprintUpdateLoadingModal', () => { + it('shows the locked loading state', () => { + renderWithProviders() + + expect(screen.getByRole('dialog')).toBeInTheDocument() + expect(screen.getByText('Creating')).toBeInTheDocument() + expect(screen.getByText('AWS S3 Bucket')).toBeInTheDocument() + expect(screen.getByLabelText('Blueprint update logs')).toHaveAttribute('aria-busy', 'true') + expect(screen.queryByRole('button', { name: 'Retry' })).not.toBeInTheDocument() + }) + + it('shows retry and edit actions after an error', async () => { + const onEditConfig = jest.fn() + const onRetry = jest.fn() + const { userEvent } = renderWithProviders( + + ) + + expect(screen.getByText('Update failed')).toBeInTheDocument() + await userEvent.click(screen.getByRole('button', { name: /Edit config/i })) + await userEvent.click(screen.getByRole('button', { name: /Retry/i })) + + expect(onEditConfig).toHaveBeenCalledTimes(1) + expect(onRetry).toHaveBeenCalledTimes(1) + }) +}) diff --git a/libs/domains/services/feature/src/lib/service-update-flow/blueprint/blueprint-update-loading-modal.tsx b/libs/domains/services/feature/src/lib/service-update-flow/blueprint/blueprint-update-loading-modal.tsx new file mode 100644 index 00000000000..9eba8a4d529 --- /dev/null +++ b/libs/domains/services/feature/src/lib/service-update-flow/blueprint/blueprint-update-loading-modal.tsx @@ -0,0 +1,91 @@ +import * as Dialog from '@radix-ui/react-dialog' +import { Button, Icon, LoaderSpinner } from '@qovery/shared/ui' + +export type BlueprintUpdateModalState = 'loading' | 'error' + +export interface BlueprintUpdateLoadingModalProps { + errorMessage?: string + onEditConfig: () => void + onRetry: () => void + open: boolean + serviceName: string + state: BlueprintUpdateModalState +} + +const creationLogs = [ + ['00:00:00', 'Initializing service modules...'], + ['00:12:34', 'Loading configuration files...'], + ['00:15:47', 'Establishing database connections...'], + ['00:18:22', 'Fetching user data...'], + ['00:20:10', 'Processing request queue...'], + ['00:22:55', 'Updating cache entries...'], + ['00:25:03', 'Synchronizing with external API...'], + ['00:27:41', 'Validating input parameters...'], + ['00:30:18', 'Executing scheduled tasks...'], + ['00:33:29', 'Committing transaction logs...'], + ['00:35:50', 'Cleaning up temporary files...'], + ['00:38:12', 'Restarting background workers...'], + ['00:40:45', 'Monitoring system health...'], +] + +export function BlueprintUpdateLoadingModal({ + errorMessage, + onEditConfig, + onRetry, + open, + serviceName, + state, +}: BlueprintUpdateLoadingModalProps) { + return ( + undefined}> + + + event.preventDefault()} + onInteractOutside={(event) => event.preventDefault()} + onPointerDownOutside={(event) => event.preventDefault()} + > +
+ + Creating {serviceName} + + Blueprint update status + {state === 'error' ? ( +
+ + +
+ ) : ( + + )} +
+
+ {creationLogs.map(([timestamp, message]) => ( +
+ {timestamp} + {message} +
+ ))} + {state === 'error' && ( +
+ 00:43:07 + {errorMessage ?? 'Unable to update the blueprint.'} +
+ )} +
+
+
+
+ ) +} diff --git a/libs/domains/services/feature/src/lib/service-update-flow/blueprint/blueprint-update-preview-step.spec.tsx b/libs/domains/services/feature/src/lib/service-update-flow/blueprint/blueprint-update-preview-step.spec.tsx new file mode 100644 index 00000000000..ec44986bb72 --- /dev/null +++ b/libs/domains/services/feature/src/lib/service-update-flow/blueprint/blueprint-update-preview-step.spec.tsx @@ -0,0 +1,67 @@ +import { renderWithProviders, screen } from '@qovery/shared/util-tests' +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' + +jest.mock('../../hooks/use-blueprint-update-preview-socket/use-blueprint-update-preview-socket', () => ({ + useBlueprintUpdatePreviewSocket: jest.fn(), +})) + +jest.mock('./blueprint-update-context', () => ({ + useBlueprintUpdateFlowContext: jest.fn(), +})) + +describe('BlueprintUpdatePreviewStep', () => { + it('requests a preview and keeps confirmation disabled while output is loading', () => { + const requestPreview = jest.fn() + const handleUpdate = jest.fn() + const context = { + clusterId: 'cluster-id', + handleUpdate, + isUpdateLoading: false, + organizationId: 'organization-id', + previewId: 'preview-id', + requestPreview, + service: { name: 'AWS S3 Bucket' }, + } as ReturnType + + jest.mocked(useBlueprintUpdateFlowContext).mockReturnValue(context) + jest.mocked(useBlueprintUpdatePreviewSocket).mockReturnValue({ + hasReceivedMessage: false, + isLoading: true, + rawOutput: undefined, + }) + + renderWithProviders() + + expect(screen.getByRole('heading', { name: 'Preview changes' })).toBeInTheDocument() + expect(screen.getByLabelText('Waiting for preview output')).toBeInTheDocument() + expect(screen.getByRole('button', { name: /Confirm & deploy update/i })).toBeDisabled() + expect(requestPreview).toHaveBeenCalledTimes(1) + expect(handleUpdate).not.toHaveBeenCalled() + }) + + it('renders received output and enables confirmation', () => { + jest.mocked(useBlueprintUpdateFlowContext).mockReturnValue({ + clusterId: 'cluster-id', + handleUpdate: jest.fn(), + isUpdateLoading: false, + organizationId: 'organization-id', + previewId: 'preview-id', + requestPreview: jest.fn(), + service: { name: 'AWS S3 Bucket' }, + } as ReturnType) + jest.mocked(useBlueprintUpdatePreviewSocket).mockReturnValue({ + hasReceivedMessage: true, + isLoading: false, + rawOutput: '+ created\n- removed\n~ changed', + }) + + renderWithProviders() + + expect(screen.getByText('+ created')).toHaveClass('text-positive') + expect(screen.getByText('- removed')).toHaveClass('text-negative') + expect(screen.getByText('~ changed')).toHaveClass('text-info') + expect(screen.getByRole('button', { name: /Confirm & deploy update/i })).toBeEnabled() + }) +}) diff --git a/libs/domains/services/feature/src/lib/service-update-flow/blueprint/blueprint-update-preview-step.tsx b/libs/domains/services/feature/src/lib/service-update-flow/blueprint/blueprint-update-preview-step.tsx new file mode 100644 index 00000000000..9544b611a19 --- /dev/null +++ b/libs/domains/services/feature/src/lib/service-update-flow/blueprint/blueprint-update-preview-step.tsx @@ -0,0 +1,148 @@ +import { useEffect, useState } from 'react' +import { Button, FunnelFlowBody, Heading, Icon, Section, Skeleton } from '@qovery/shared/ui' +import { useBlueprintUpdatePreviewSocket } from '../../hooks/use-blueprint-update-preview-socket/use-blueprint-update-preview-socket' +import { useBlueprintUpdateFlowContext } from './blueprint-update-context' +import { BlueprintUpdateLoadingModal } from './blueprint-update-loading-modal' +import { getRawOutputLineClassName } from './blueprint-update-utils' + +export function BlueprintUpdatePreviewStep({ onBack }: { onBack: () => void }) { + const { clusterId, handleUpdate, isUpdateLoading, organizationId, previewId, requestPreview, service } = + useBlueprintUpdateFlowContext() + + useEffect(() => { + requestPreview() + }, [requestPreview]) + + return ( + + ) +} + +function BlueprintUpdatePreview({ + clusterId, + loading, + onBack, + onConfirm, + organizationId, + previewId, + serviceName, +}: { + clusterId?: string + loading: boolean + onBack: () => void + onConfirm: () => Promise + organizationId: string + previewId?: string + serviceName: string +}) { + const [submissionState, setSubmissionState] = useState<'loading' | 'error'>() + const [errorMessage, setErrorMessage] = useState() + const { + rawOutput, + isLoading: isPreviewOutputLoading, + hasReceivedMessage: hasReceivedPreviewMessage, + } = useBlueprintUpdatePreviewSocket({ organizationId, clusterId, previewId }) + const rawOutputContainerHeightClassName = rawOutput + ? 'h-[min(75vh,calc(100vh-260px))] min-h-[260px]' + : 'min-h-[180px]' + + const handleConfirm = async () => { + setErrorMessage(undefined) + setSubmissionState('loading') + + try { + await onConfirm() + } catch (error) { + setErrorMessage(error instanceof Error && error.message ? error.message : undefined) + setSubmissionState('error') + } + } + + const handleEditConfig = () => { + setSubmissionState(undefined) + setErrorMessage(undefined) + onBack() + } + + return ( + +
+ Preview changes + +
+ Raw output +
+ {rawOutput ? : } +
+
+
+ +
+ + +
+ + +
+ ) +} + +function BlueprintUpdateRawOutput({ rawOutput }: { rawOutput: string }) { + const lines = rawOutput.split('\n') + + return ( +
+      {lines.map((line, index) => (
+        
+          {line}
+          {index < lines.length - 1 ? '\n' : null}
+        
+      ))}
+    
+ ) +} + +function BlueprintUpdateRawOutputSkeleton() { + const skeletonLineWidths = ['46%', '28%', '72%', '64%', '82%', '34%'] + + return ( +
+
+ + Generating preview output +
+ {skeletonLineWidths.map((width, index) => ( + + ))} +
+ ) +} diff --git a/libs/domains/services/feature/src/lib/service-update-flow/blueprint/blueprint-update-review-step.tsx b/libs/domains/services/feature/src/lib/service-update-flow/blueprint/blueprint-update-review-step.tsx new file mode 100644 index 00000000000..311cf4a23c6 --- /dev/null +++ b/libs/domains/services/feature/src/lib/service-update-flow/blueprint/blueprint-update-review-step.tsx @@ -0,0 +1,356 @@ +import { type IconName } from '@fortawesome/fontawesome-common-types' +import { + type BlueprintUpdateNewOptionalValue, + type BlueprintUpdateNewRequiredValue, + type BlueprintUpdateRemovedValue, +} from 'qovery-typescript-axios' +import { type ReactNode, useState } from 'react' +import { Badge, Button, FunnelFlowBody, Heading, Icon, InputText, Section, Tooltip } from '@qovery/shared/ui' +import { + type BlueprintFieldValue, + type BlueprintFieldValues, + getFieldValidationError, +} from '../../blueprint-field-utils/blueprint-field-utils' +import { BlueprintManifestVariableInput } from '../../blueprint-manifest-variable-input/blueprint-manifest-variable-input' +import { useBlueprintUpdateFlowContext } from './blueprint-update-context' +import { + BLUEPRINT_RELEASE_NOTES_URL, + type BlueprintUpdateEditableValue, + type BlueprintUpdateSection, + getBlueprintUpdateFieldValue, + getBlueprintUpdatePayloadValue, + getBlueprintUpdateVariableField, + isBlueprintUpdateVariableField, + formatUpdateFieldLabel, + formatUpdateValue, +} from './blueprint-update-utils' + +export function BlueprintUpdateReviewStep({ onContinue }: { onContinue: () => void }) { + const { + activeSection, + blueprintUpdate, + canContinueReview, + completeActiveSection, + completedSections, + isRequiredValid, + requiredValues, + reviewSections, + setActiveSection, + title, + updatedValues, + values, + onChange, + } = useBlueprintUpdateFlowContext() + const hasSingleReviewSection = reviewSections.length === 1 + + return ( + +
+

{title}

+ + Release notes + + +
+ +
+ {reviewSections.length > 0 ? ( + reviewSections.map((section) => ( + setActiveSection(section.id)} + > + {activeSection === section.id && ( + + )} + + )) + ) : ( +

+ No configuration input is required. Continue to preview the update. +

+ )} +
+ +
+ +
+
+ ) +} + +function BlueprintUpdateSectionCard({ + active, + children, + completed, + description, + iconName, + onClick, + title, +}: { + active: boolean + children: ReactNode + completed: boolean + description?: string + iconName: IconName + onClick: () => void + title: string +}) { + return ( +
+ + {children &&
{children}
} +
+ ) +} + +function BlueprintUpdateSectionContent({ + continueDisabled, + onChange, + onContinue, + optionalValues, + removedValues, + requiredValues, + section, + showContinueButton, + updatedValues, + values, +}: { + continueDisabled: boolean + onChange: (name: string, value: BlueprintFieldValue) => void + onContinue: () => void + optionalValues: BlueprintUpdateNewOptionalValue[] + removedValues: BlueprintUpdateRemovedValue[] + requiredValues: BlueprintUpdateNewRequiredValue[] + section: BlueprintUpdateSection + showContinueButton: boolean + updatedValues: BlueprintUpdateEditableValue[] + values: BlueprintFieldValues +}) { + return ( + <> + {section === 'required' && ( +
+ {requiredValues.map((value, index) => { + const field = getBlueprintUpdateVariableField(value, true) + + return ( + onChange(value.name, fieldValue)} + /> + ) + })} +
+ )} + + {section === 'optional' && ( +
+ {optionalValues.map((value, index) => { + const field = getBlueprintUpdateVariableField(value, false, value.default_value) + + return ( + onChange(value.name, fieldValue)} + /> + ) + })} +
+ )} + + {section === 'modified' && } + {section === 'removed' && } + + {showContinueButton && ( + + )} + + ) +} + +function UpdatedValuesList({ + editableValues, + onChange, + values, +}: { + editableValues: BlueprintFieldValues + onChange: (name: string, value: BlueprintFieldValue) => void + values: BlueprintUpdateEditableValue[] +}) { + const [editedValueName, setEditedValueName] = useState() + + if (values.length === 0) return

No modified values.

+ + return ( +
+ {values.map((value) => { + const label = formatUpdateFieldLabel(value.name) + const hasManualOverride = Object.prototype.hasOwnProperty.call(editableValues, value.name) + const editedValue = hasManualOverride + ? editableValues[value.name] ?? '' + : getBlueprintUpdateFieldValue(value, value.new_default_value) + const newDefaultValue = value.new_default_value ?? '' + const overrideValue = getBlueprintUpdatePayloadValue(editedValue) + const hasEditedOverride = hasManualOverride && overrideValue !== undefined && overrideValue !== newDefaultValue + const editing = editedValueName === value.name + const canUseTypedInput = isBlueprintUpdateVariableField(value) + const field = canUseTypedInput + ? getBlueprintUpdateVariableField(value, false, value.new_default_value) + : undefined + + return ( +
+
+
+

{label}

+
+
+ Default: + + {formatUpdateValue(value.current_default_value)} + + + + {formatUpdateValue(value.new_default_value)} + +
+ {hasEditedOverride && ( +
+ Override: + + {formatUpdateValue(editedValue)} + +
+ )} +
+
+ +
+ {hasEditedOverride && ( + Reset the override to the new default value}> + + + )} + + + +
+
+ + {editing && + (field ? ( + onChange(value.name, fieldValue)} + /> + ) : ( + onChange(value.name, event.currentTarget.value)} + /> + ))} +
+ ) + })} +
+ ) +} + +function RemovedValuesList({ values }: { values: BlueprintUpdateRemovedValue[] }) { + if (values.length === 0) return

No removed values.

+ + return ( +
+ {values.map((value) => ( +
+

{formatUpdateFieldLabel(value.name)}

+ + Deleted + +
+ ))} +
+ ) +} diff --git a/libs/domains/services/feature/src/lib/service-update-flow/blueprint/blueprint-update-utils.spec.ts b/libs/domains/services/feature/src/lib/service-update-flow/blueprint/blueprint-update-utils.spec.ts new file mode 100644 index 00000000000..663285fa92a --- /dev/null +++ b/libs/domains/services/feature/src/lib/service-update-flow/blueprint/blueprint-update-utils.spec.ts @@ -0,0 +1,97 @@ +import { + buildBlueprintUpdatePayload, + getBlueprintUpdateFieldValue, + getBlueprintUpdatePayloadValue, + getBlueprintUpdateVersion, + getFirstAvailableUpdateSection, + getRawOutputLineClassName, + hasBlueprintUpdateReviewSections, +} from './blueprint-update-utils' +import { type BlueprintUpdateResponse } from 'qovery-typescript-axios' + +function createBlueprintUpdate(overrides: Partial = {}): BlueprintUpdateResponse { + return { + is_up_to_date: false, + latest_tag: 'AWS/postgres/17/1.2.5', + new_required_values: [], + new_optional_values: [], + now_required_values: [], + updated_values: [], + removed_values: [], + engine_diff: { updated_values: [] }, + ...overrides, + } +} + +describe('blueprint update utils', () => { + it('extracts the version from a blueprint tag', () => { + expect(getBlueprintUpdateVersion('AWS/postgres/17/1.2.5/')).toBe('1.2.5') + }) + + it('selects the first section that needs user attention', () => { + expect(getFirstAvailableUpdateSection(createBlueprintUpdate({ new_required_values: [{ name: 'required', type: { type: 'string' }, is_secret: false }] }))).toBe('required') + expect(getFirstAvailableUpdateSection(createBlueprintUpdate({ new_optional_values: [{ name: 'optional', default_value: 'value', type: { type: 'string' }, is_secret: false }] }))).toBe('optional') + expect(getFirstAvailableUpdateSection(createBlueprintUpdate({ engine_diff: { updated_values: [{ name: 'engine', current_default_value: 'old', new_default_value: 'new' }] } }))).toBe('modified') + expect(getFirstAvailableUpdateSection(createBlueprintUpdate({ removed_values: [{ name: 'removed' }] }))).toBe('removed') + }) + + it('reports whether the update contains review sections', () => { + expect(hasBlueprintUpdateReviewSections(createBlueprintUpdate())).toBe(false) + expect(hasBlueprintUpdateReviewSections(createBlueprintUpdate({ removed_values: [{ name: 'removed' }] }))).toBe(true) + }) + + it('normalizes field values for the update payload', () => { + expect(getBlueprintUpdatePayloadValue(' value ')).toBe('value') + expect(getBlueprintUpdatePayloadValue(true)).toBe('true') + expect(getBlueprintUpdatePayloadValue(' ')).toBeUndefined() + }) + + it('converts unrestricted boolean fields to boolean form values', () => { + const field = { name: 'enabled', type: { type: 'bool' }, is_secret: false, allowed_values: null } + + expect(getBlueprintUpdateFieldValue(field, 'true')).toBe(true) + expect(getBlueprintUpdateFieldValue(field, 'false')).toBe(false) + }) + + it('builds a patch with only changed variables', () => { + const requiredValues = [{ name: 'required', type: { type: 'string' }, is_secret: true }] + const optionalValues = [{ name: 'optional', default_value: 'default', type: { type: 'string' }, is_secret: false }] + const updatedValues = [ + { name: 'changed', current_default_value: 'old', new_default_value: 'new', type: { type: 'string' }, is_secret: false }, + { name: 'engine_changed', current_default_value: 'old', new_default_value: 'new' }, + ] + + expect( + buildBlueprintUpdatePayload({ + icon: 'icon', + name: 'service', + tag: 'AWS/postgres/17/1.2.5', + requiredValues, + optionalValues, + updatedValues, + values: { + required: 'secret', + optional: 'default', + changed: 'override', + engine_changed: 'engine-override', + }, + }) + ).toEqual({ + icon: 'icon', + name: 'service', + tag: 'AWS/postgres/17/1.2.5', + variables: { + required: { value: 'secret', is_secret: true }, + changed: { value: 'override', is_secret: false }, + engine_changed: { value: 'engine-override' }, + }, + }) + }) + + it('maps raw output prefixes to semantic colors', () => { + expect(getRawOutputLineClassName(' + added')).toBe('text-positive') + expect(getRawOutputLineClassName(' - removed')).toBe('text-negative') + expect(getRawOutputLineClassName(' ~ changed')).toBe('text-info') + expect(getRawOutputLineClassName(' unchanged')).toBeUndefined() + }) +}) diff --git a/libs/domains/services/feature/src/lib/service-update-flow/blueprint/blueprint-update-utils.ts b/libs/domains/services/feature/src/lib/service-update-flow/blueprint/blueprint-update-utils.ts new file mode 100644 index 00000000000..36fb1832064 --- /dev/null +++ b/libs/domains/services/feature/src/lib/service-update-flow/blueprint/blueprint-update-utils.ts @@ -0,0 +1,198 @@ +import { type IconName } from '@fortawesome/fontawesome-common-types' +import { + type BlueprintManifestVariableField, + type BlueprintUpdateEngineFieldChange, + type BlueprintUpdateNewOptionalValue, + type BlueprintUpdateNewRequiredValue, + type BlueprintUpdateResponse, + type BlueprintUpdateUpdatedValue, +} from 'qovery-typescript-axios' +import { type AnyService } from '@qovery/domains/services/data-access' +import { type BlueprintFieldValue, type BlueprintFieldValues } from '../../blueprint-field-utils/blueprint-field-utils' + +export type BlueprintUpdateSection = 'required' | 'optional' | 'modified' | 'removed' +export type BlueprintUpdateVariablePatch = Record +export type BlueprintUpdateField = + | BlueprintUpdateNewRequiredValue + | BlueprintUpdateNewOptionalValue + | BlueprintUpdateUpdatedValue +export type BlueprintUpdateEditableValue = BlueprintUpdateUpdatedValue | BlueprintUpdateEngineFieldChange + +export const updateSections: Array<{ + id: BlueprintUpdateSection + title: string + iconName: IconName + description?: string +}> = [ + { + id: 'required', + title: 'New required values', + iconName: 'circle-plus', + description: + "These are new fields we have added that don't have a default value and require you to enter a specific value for the update.", + }, + { + id: 'optional', + title: 'New optional values', + iconName: 'chart-bullet', + description: 'These new fields have a default value, but you can override them if needed.', + }, + { + id: 'modified', + title: 'Modified values', + iconName: 'arrows-rotate', + description: + 'Existing values for which the default value has been updated. Your overrides has priority over the default value.', + }, + { + id: 'removed', + title: 'Removed values', + iconName: 'circle-minus', + }, +] +export type BlueprintUpdateReviewSection = (typeof updateSections)[number] + +export const blueprintUpdateSteps: { title: string }[] = [{ title: 'Review update' }, { title: 'Preview changes' }] +export const BLUEPRINT_RELEASE_NOTES_URL = 'https://github.com/Qovery/service-catalog/releases' + +export function hasBlueprintUpdateReviewSections(blueprintUpdate: BlueprintUpdateResponse) { + return ( + blueprintUpdate.new_required_values.length > 0 || + blueprintUpdate.now_required_values.length > 0 || + blueprintUpdate.new_optional_values.length > 0 || + blueprintUpdate.updated_values.length > 0 || + blueprintUpdate.engine_diff.updated_values.length > 0 || + blueprintUpdate.removed_values.length > 0 + ) +} + +export function getBlueprintUpdateVersion(tag: string) { + return tag.split('/').filter(Boolean).at(-1) +} + +export function getInitialUpdateValues(blueprintUpdate: BlueprintUpdateResponse) { + return Object.fromEntries( + blueprintUpdate.new_optional_values.map((value) => [ + value.name, + getBlueprintUpdateFieldValue(value, value.default_value), + ]) + ) +} + +export function getFirstAvailableUpdateSection(blueprintUpdate: BlueprintUpdateResponse): BlueprintUpdateSection { + if (blueprintUpdate.new_required_values.length > 0 || blueprintUpdate.now_required_values.length > 0) { + return 'required' + } + if (blueprintUpdate.new_optional_values.length > 0) return 'optional' + if (blueprintUpdate.updated_values.length > 0 || blueprintUpdate.engine_diff.updated_values.length > 0) { + return 'modified' + } + if (blueprintUpdate.removed_values.length > 0) return 'removed' + return 'required' +} + +export function buildBlueprintUpdatePayload({ + icon, + name, + optionalValues, + requiredValues, + tag, + updatedValues, + values, +}: { + icon: string + name: string + optionalValues: BlueprintUpdateNewOptionalValue[] + requiredValues: BlueprintUpdateNewRequiredValue[] + tag: string + updatedValues: BlueprintUpdateEditableValue[] + values: BlueprintFieldValues +}) { + const variables: BlueprintUpdateVariablePatch = {} + + requiredValues.forEach((field) => { + const value = getBlueprintUpdatePayloadValue(values[field.name]) + if (value) variables[field.name] = { value, is_secret: field.is_secret } + }) + optionalValues.forEach((field) => { + const value = getBlueprintUpdatePayloadValue(values[field.name]) + if (value && value !== field.default_value) variables[field.name] = { value, is_secret: field.is_secret } + }) + updatedValues.forEach((field) => { + const value = getBlueprintUpdatePayloadValue(values[field.name]) + if (!value || value === field.new_default_value) return + + variables[field.name] = { + value, + ...(isBlueprintUpdateVariableField(field) ? { is_secret: field.is_secret } : {}), + } + }) + + return { name, tag, icon, variables } +} + +export function getBlueprintUpdateVariableField( + field: BlueprintUpdateField, + required: boolean, + defaultValue?: string | null +): BlueprintManifestVariableField { + return { + kind: 'variable', + name: field.name, + type: field.type, + required, + is_secret: field.is_secret, + allowed_values: field.allowed_values, + default_value: defaultValue, + } +} + +export function isBlueprintUpdateVariableField( + field: BlueprintUpdateEditableValue | BlueprintUpdateNewOptionalValue +): field is BlueprintUpdateUpdatedValue | BlueprintUpdateNewOptionalValue { + return 'type' in field && 'is_secret' in field +} + +export function getBlueprintUpdateFieldValue( + field: BlueprintUpdateEditableValue | BlueprintUpdateNewOptionalValue, + value?: string | null +): BlueprintFieldValue { + if (isBlueprintUpdateVariableField(field) && field.type.type === 'bool' && !field.allowed_values?.length) { + return value === 'true' + } + + return value ?? '' +} + +export function getBlueprintUpdatePayloadValue(value: BlueprintFieldValue | undefined) { + if (typeof value === 'boolean') return String(value) + + const trimmedValue = value?.trim() + return trimmedValue ? trimmedValue : undefined +} + +export function formatUpdateFieldLabel(name: string) { + const label = name.replace(/_/g, ' ') + return `${label.charAt(0).toUpperCase()}${label.slice(1)}` +} + +export function formatUpdateValue(value?: BlueprintFieldValue | string | null) { + if (typeof value === 'boolean') return String(value) + return value && value.length > 0 ? value : '-' +} + +export function getFallbackServiceIcon(serviceType: AnyService['service_type']) { + if (serviceType === 'HELM') return 'app://qovery-console/helm' + if (serviceType === 'TERRAFORM') return 'app://qovery-console/terraform' + return 'app://qovery-console/application' +} + +export function getRawOutputLineClassName(line: string) { + const prefix = line.trimStart().charAt(0) + + if (prefix === '+') return 'text-positive' + if (prefix === '-') return 'text-negative' + if (prefix === '~') return 'text-info' + + return undefined +} From 0f3892190684788ab913b1b5433d56f38243bf95 Mon Sep 17 00:00:00 2001 From: Romain Billard Date: Fri, 10 Jul 2026 17:19:12 +0200 Subject: [PATCH 30/35] Remove LoadingModal for now --- .../blueprint-update-loading-modal.spec.tsx | 42 --------- .../blueprint-update-loading-modal.tsx | 91 ------------------- .../blueprint-update-preview-step.tsx | 41 +-------- 3 files changed, 4 insertions(+), 170 deletions(-) delete mode 100644 libs/domains/services/feature/src/lib/service-update-flow/blueprint/blueprint-update-loading-modal.spec.tsx delete mode 100644 libs/domains/services/feature/src/lib/service-update-flow/blueprint/blueprint-update-loading-modal.tsx diff --git a/libs/domains/services/feature/src/lib/service-update-flow/blueprint/blueprint-update-loading-modal.spec.tsx b/libs/domains/services/feature/src/lib/service-update-flow/blueprint/blueprint-update-loading-modal.spec.tsx deleted file mode 100644 index f7b831556ba..00000000000 --- a/libs/domains/services/feature/src/lib/service-update-flow/blueprint/blueprint-update-loading-modal.spec.tsx +++ /dev/null @@ -1,42 +0,0 @@ -import { renderWithProviders, screen } from '@qovery/shared/util-tests' -import { BlueprintUpdateLoadingModal } from './blueprint-update-loading-modal' - -const props = { - onEditConfig: jest.fn(), - onRetry: jest.fn(), - open: true, - serviceName: 'AWS S3 Bucket', -} - -describe('BlueprintUpdateLoadingModal', () => { - it('shows the locked loading state', () => { - renderWithProviders() - - expect(screen.getByRole('dialog')).toBeInTheDocument() - expect(screen.getByText('Creating')).toBeInTheDocument() - expect(screen.getByText('AWS S3 Bucket')).toBeInTheDocument() - expect(screen.getByLabelText('Blueprint update logs')).toHaveAttribute('aria-busy', 'true') - expect(screen.queryByRole('button', { name: 'Retry' })).not.toBeInTheDocument() - }) - - it('shows retry and edit actions after an error', async () => { - const onEditConfig = jest.fn() - const onRetry = jest.fn() - const { userEvent } = renderWithProviders( - - ) - - expect(screen.getByText('Update failed')).toBeInTheDocument() - await userEvent.click(screen.getByRole('button', { name: /Edit config/i })) - await userEvent.click(screen.getByRole('button', { name: /Retry/i })) - - expect(onEditConfig).toHaveBeenCalledTimes(1) - expect(onRetry).toHaveBeenCalledTimes(1) - }) -}) diff --git a/libs/domains/services/feature/src/lib/service-update-flow/blueprint/blueprint-update-loading-modal.tsx b/libs/domains/services/feature/src/lib/service-update-flow/blueprint/blueprint-update-loading-modal.tsx deleted file mode 100644 index 9eba8a4d529..00000000000 --- a/libs/domains/services/feature/src/lib/service-update-flow/blueprint/blueprint-update-loading-modal.tsx +++ /dev/null @@ -1,91 +0,0 @@ -import * as Dialog from '@radix-ui/react-dialog' -import { Button, Icon, LoaderSpinner } from '@qovery/shared/ui' - -export type BlueprintUpdateModalState = 'loading' | 'error' - -export interface BlueprintUpdateLoadingModalProps { - errorMessage?: string - onEditConfig: () => void - onRetry: () => void - open: boolean - serviceName: string - state: BlueprintUpdateModalState -} - -const creationLogs = [ - ['00:00:00', 'Initializing service modules...'], - ['00:12:34', 'Loading configuration files...'], - ['00:15:47', 'Establishing database connections...'], - ['00:18:22', 'Fetching user data...'], - ['00:20:10', 'Processing request queue...'], - ['00:22:55', 'Updating cache entries...'], - ['00:25:03', 'Synchronizing with external API...'], - ['00:27:41', 'Validating input parameters...'], - ['00:30:18', 'Executing scheduled tasks...'], - ['00:33:29', 'Committing transaction logs...'], - ['00:35:50', 'Cleaning up temporary files...'], - ['00:38:12', 'Restarting background workers...'], - ['00:40:45', 'Monitoring system health...'], -] - -export function BlueprintUpdateLoadingModal({ - errorMessage, - onEditConfig, - onRetry, - open, - serviceName, - state, -}: BlueprintUpdateLoadingModalProps) { - return ( - undefined}> - - - event.preventDefault()} - onInteractOutside={(event) => event.preventDefault()} - onPointerDownOutside={(event) => event.preventDefault()} - > -
- - Creating {serviceName} - - Blueprint update status - {state === 'error' ? ( -
- - -
- ) : ( - - )} -
-
- {creationLogs.map(([timestamp, message]) => ( -
- {timestamp} - {message} -
- ))} - {state === 'error' && ( -
- 00:43:07 - {errorMessage ?? 'Unable to update the blueprint.'} -
- )} -
-
-
-
- ) -} diff --git a/libs/domains/services/feature/src/lib/service-update-flow/blueprint/blueprint-update-preview-step.tsx b/libs/domains/services/feature/src/lib/service-update-flow/blueprint/blueprint-update-preview-step.tsx index 9544b611a19..6d4d3afa942 100644 --- a/libs/domains/services/feature/src/lib/service-update-flow/blueprint/blueprint-update-preview-step.tsx +++ b/libs/domains/services/feature/src/lib/service-update-flow/blueprint/blueprint-update-preview-step.tsx @@ -1,12 +1,11 @@ -import { useEffect, useState } from 'react' +import { useEffect } from 'react' import { Button, FunnelFlowBody, Heading, Icon, Section, Skeleton } from '@qovery/shared/ui' import { useBlueprintUpdatePreviewSocket } from '../../hooks/use-blueprint-update-preview-socket/use-blueprint-update-preview-socket' import { useBlueprintUpdateFlowContext } from './blueprint-update-context' -import { BlueprintUpdateLoadingModal } from './blueprint-update-loading-modal' import { getRawOutputLineClassName } from './blueprint-update-utils' export function BlueprintUpdatePreviewStep({ onBack }: { onBack: () => void }) { - const { clusterId, handleUpdate, isUpdateLoading, organizationId, previewId, requestPreview, service } = + const { clusterId, handleUpdate, isUpdateLoading, organizationId, previewId, requestPreview } = useBlueprintUpdateFlowContext() useEffect(() => { @@ -21,7 +20,6 @@ export function BlueprintUpdatePreviewStep({ onBack }: { onBack: () => void }) { onBack={onBack} onConfirm={handleUpdate} loading={isUpdateLoading} - serviceName={service.name} /> ) } @@ -33,7 +31,6 @@ function BlueprintUpdatePreview({ onConfirm, organizationId, previewId, - serviceName, }: { clusterId?: string loading: boolean @@ -41,10 +38,7 @@ function BlueprintUpdatePreview({ onConfirm: () => Promise organizationId: string previewId?: string - serviceName: string }) { - const [submissionState, setSubmissionState] = useState<'loading' | 'error'>() - const [errorMessage, setErrorMessage] = useState() const { rawOutput, isLoading: isPreviewOutputLoading, @@ -54,24 +48,6 @@ function BlueprintUpdatePreview({ ? 'h-[min(75vh,calc(100vh-260px))] min-h-[260px]' : 'min-h-[180px]' - const handleConfirm = async () => { - setErrorMessage(undefined) - setSubmissionState('loading') - - try { - await onConfirm() - } catch (error) { - setErrorMessage(error instanceof Error && error.message ? error.message : undefined) - setSubmissionState('error') - } - } - - const handleEditConfig = () => { - setSubmissionState(undefined) - setErrorMessage(undefined) - onBack() - } - return (
@@ -95,23 +71,14 @@ function BlueprintUpdatePreview({ type="button" size="lg" className="flex-1 justify-center" - disabled={!hasReceivedPreviewMessage || isPreviewOutputLoading || loading || submissionState !== undefined} + disabled={!hasReceivedPreviewMessage || isPreviewOutputLoading || loading} loading={loading} - onClick={handleConfirm} + onClick={onConfirm} > Confirm & deploy update
- -
) } From 38bddb16b12d7e8e4dd92466236d72ffea06a65a Mon Sep 17 00:00:00 2001 From: Romain Billard Date: Mon, 13 Jul 2026 14:49:15 +0200 Subject: [PATCH 31/35] Bump API version and improve the Preview step of the blueprint update flow --- .../$serviceId/update/blueprint/route.tsx | 1 - ...e-blueprint-update-preview-socket.spec.tsx | 74 ++------- .../use-blueprint-update-preview-socket.ts | 140 +----------------- .../blueprint/blueprint-update-context.tsx | 1 - .../blueprint/blueprint-update-flow.tsx | 3 - .../blueprint-update-preview-step.spec.tsx | 16 +- .../blueprint-update-preview-step.tsx | 73 ++++++++- package.json | 2 +- yarn.lock | 10 +- 9 files changed, 111 insertions(+), 209 deletions(-) diff --git a/apps/console/src/routes/_authenticated/organization/$organizationId/project/$projectId/environment/$environmentId/service/$serviceId/update/blueprint/route.tsx b/apps/console/src/routes/_authenticated/organization/$organizationId/project/$projectId/environment/$environmentId/service/$serviceId/update/blueprint/route.tsx index 1fa7896d1b8..f8128d0ae0b 100644 --- a/apps/console/src/routes/_authenticated/organization/$organizationId/project/$projectId/environment/$environmentId/service/$serviceId/update/blueprint/route.tsx +++ b/apps/console/src/routes/_authenticated/organization/$organizationId/project/$projectId/environment/$environmentId/service/$serviceId/update/blueprint/route.tsx @@ -36,7 +36,6 @@ function RouteComponent() { clusterId={environment?.cluster_id} currentStep={currentStep} environmentId={environmentId} - organizationId={organizationId} service={service} onExit={navigateToOverview} > diff --git a/libs/domains/services/feature/src/lib/hooks/use-blueprint-update-preview-socket/use-blueprint-update-preview-socket.spec.tsx b/libs/domains/services/feature/src/lib/hooks/use-blueprint-update-preview-socket/use-blueprint-update-preview-socket.spec.tsx index b6ccc90e7fd..75a0fdad6a7 100644 --- a/libs/domains/services/feature/src/lib/hooks/use-blueprint-update-preview-socket/use-blueprint-update-preview-socket.spec.tsx +++ b/libs/domains/services/feature/src/lib/hooks/use-blueprint-update-preview-socket/use-blueprint-update-preview-socket.spec.tsx @@ -40,18 +40,7 @@ describe('useBlueprintUpdatePreviewSocket', () => { ) }) - it('should not subscribe until all params are available', () => { - renderHook(() => - useBlueprintUpdatePreviewSocket({ - organizationId: 'org-1', - previewId: 'preview-1', - }) - ) - - expect(useReactQueryWsSubscriptionMock).toHaveBeenCalledWith(expect.objectContaining({ enabled: false })) - }) - - it('should expose deployment impact and raw output from socket messages', () => { + it('should expose raw output from a diff result', () => { const { result } = renderHook(() => useBlueprintUpdatePreviewSocket({ organizationId: 'org-1', @@ -65,51 +54,22 @@ describe('useBlueprintUpdatePreviewSocket', () => { subscriptionConfig?.onOpen?.({} as QueryClient, {} as Event) expect(result.current.hasReceivedMessage).toBe(false) subscriptionConfig?.onMessage?.({} as QueryClient, { - deployment_impact: { - severity: 'high', - description: [ - 'Upgrading engine_version forces a full recreation on AWS.', - 'Verify your database endpoint before confirming.', - ], - impacted_services: ['Postgres database', 'Redis cache'], - }, - raw_output: '# Terraform will perform the following actions:', + type: 'diff', + payload: '# Terraform will perform the following actions:', + service_type: 'TERRAFORM', }) }) expect(result.current.isLoading).toBe(false) expect(result.current.hasReceivedMessage).toBe(true) - expect(result.current.impact).toEqual({ - level: 'high', - description: [ - 'Upgrading engine_version forces a full recreation on AWS.', - 'Verify your database endpoint before confirming.', - ], - impactedServices: ['Postgres database', 'Redis cache'], - }) expect(result.current.rawOutput).toBe('# Terraform will perform the following actions:') }) - it('should append plain text output chunks', () => { - const { result } = renderHook(() => - useBlueprintUpdatePreviewSocket({ - organizationId: 'org-1', - clusterId: 'cluster-1', - previewId: 'preview-1', - }) - ) - const subscriptionConfig = useReactQueryWsSubscriptionMock.mock.calls[0]?.[0] - - act(() => { - subscriptionConfig?.onMessage?.({} as QueryClient, '# first line') - subscriptionConfig?.onMessage?.({} as QueryClient, '# second line') - }) - - expect(result.current.rawOutput).toBe('# first line\n# second line') - expect(result.current.hasReceivedMessage).toBe(true) - }) - - it('should use the payload property as raw output', () => { + it.each([ + { type: 'error', message: 'Preview failed' }, + { type: 'cancelled' }, + { type: 'timeout' }, + ])('should complete without raw output for a $type result', (message) => { const { result } = renderHook(() => useBlueprintUpdatePreviewSocket({ organizationId: 'org-1', @@ -120,16 +80,10 @@ describe('useBlueprintUpdatePreviewSocket', () => { const subscriptionConfig = useReactQueryWsSubscriptionMock.mock.calls[0]?.[0] act(() => { - subscriptionConfig?.onMessage?.({} as QueryClient, { - type: 'diff', - payload: '\nTerraform will perform the following actions:\n\n # aws_db_instance.this will be created', - service_type: 'TERRAFORM', - }) + subscriptionConfig?.onMessage?.({} as QueryClient, message) }) - expect(result.current.rawOutput).toBe( - '\nTerraform will perform the following actions:\n\n # aws_db_instance.this will be created' - ) + expect(result.current.rawOutput).toBe('') expect(result.current.hasReceivedMessage).toBe(true) }) @@ -146,7 +100,11 @@ describe('useBlueprintUpdatePreviewSocket', () => { const subscriptionConfig = useReactQueryWsSubscriptionMock.mock.calls[0]?.[0] act(() => { - subscriptionConfig?.onMessage?.({} as QueryClient, '# output') + subscriptionConfig?.onMessage?.({} as QueryClient, { + type: 'diff', + payload: '# output', + service_type: 'TERRAFORM', + }) }) expect(result.current.hasReceivedMessage).toBe(true) diff --git a/libs/domains/services/feature/src/lib/hooks/use-blueprint-update-preview-socket/use-blueprint-update-preview-socket.ts b/libs/domains/services/feature/src/lib/hooks/use-blueprint-update-preview-socket/use-blueprint-update-preview-socket.ts index 07d4b57d7ec..8d1a3052caf 100644 --- a/libs/domains/services/feature/src/lib/hooks/use-blueprint-update-preview-socket/use-blueprint-update-preview-socket.ts +++ b/libs/domains/services/feature/src/lib/hooks/use-blueprint-update-preview-socket/use-blueprint-update-preview-socket.ts @@ -1,18 +1,11 @@ import { type QueryClient } from '@tanstack/react-query' +import { type BlueprintPreviewResult } from 'qovery-ws-typescript-axios' import { useCallback, useEffect, useState } from 'react' +import { match } from 'ts-pattern' import { QOVERY_WS } from '@qovery/shared/util-node-env' import { useReactQueryWsSubscription } from '@qovery/state/util-queries' -export type BlueprintUpdatePreviewImpactLevel = 'low' | 'medium' | 'high' | 'unknown' - -export interface BlueprintUpdatePreviewImpact { - level: BlueprintUpdatePreviewImpactLevel - description: string[] - impactedServices: string[] -} - export interface BlueprintUpdatePreviewSocketData { - impact?: BlueprintUpdatePreviewImpact rawOutput: string isLoading: boolean hasError: boolean @@ -26,51 +19,34 @@ export interface UseBlueprintUpdatePreviewSocketProps { enabled?: boolean } -type PreviewSocketObjectMessage = Record - export function useBlueprintUpdatePreviewSocket({ organizationId, clusterId, previewId, enabled = true, }: UseBlueprintUpdatePreviewSocketProps): BlueprintUpdatePreviewSocketData { - const [impact, setImpact] = useState() const [rawOutput, setRawOutput] = useState('') const [isLoading, setIsLoading] = useState(false) const [hasError, setHasError] = useState(false) const [hasReceivedMessage, setHasReceivedMessage] = useState(false) useEffect(() => { - setImpact(undefined) setRawOutput('') setIsLoading(false) setHasError(false) setHasReceivedMessage(false) }, [clusterId, organizationId, previewId]) - const appendRawOutput = useCallback((nextRawOutput: string) => { - setRawOutput((currentRawOutput) => { - if (!currentRawOutput) return nextRawOutput - return `${currentRawOutput}${nextRawOutput.startsWith('\n') ? '' : '\n'}${nextRawOutput}` - }) - }, []) - const handleMessage = useCallback( - (_: QueryClient, message: unknown) => { + (_: QueryClient, message: BlueprintPreviewResult) => { setIsLoading(false) setHasReceivedMessage(true) - const nextImpact = getPreviewImpact(message) - if (nextImpact) { - setImpact(nextImpact) - } - - const nextRawOutput = getPreviewRawOutput(message) - if (nextRawOutput) { - appendRawOutput(nextRawOutput) - } + match(message) + .with({ type: 'diff' }, ({ payload }) => setRawOutput(payload)) + .otherwise(() => undefined) }, - [appendRawOutput] + [] ) const handleOpen = useCallback(() => { @@ -102,7 +78,6 @@ export function useBlueprintUpdatePreviewSocket({ }) return { - impact, rawOutput, isLoading, hasError, @@ -110,105 +85,4 @@ export function useBlueprintUpdatePreviewSocket({ } } -function getPreviewImpact(message: unknown): BlueprintUpdatePreviewImpact | undefined { - const payload = getObjectMessage(message) - if (!payload) return undefined - - const impactPayload = getObjectValue(payload, 'deployment_impact') ?? getObjectValue(payload, 'deploymentImpact') - const source = impactPayload ?? (isImpactMessage(payload) ? payload : undefined) - if (!source) return undefined - - const description = getStringArrayValue(source, 'description') - .concat(getStringArrayValue(source, 'summary')) - .concat(getStringArrayValue(source, 'message')) - const impactedServices = - getStringArrayValue(source, 'impacted_services').length > 0 - ? getStringArrayValue(source, 'impacted_services') - : getStringArrayValue(source, 'impactedServices').concat(getStringArrayValue(source, 'services')) - - return { - level: getImpactLevel(source), - description, - impactedServices, - } -} - -function getPreviewRawOutput(message: unknown): string | undefined { - if (typeof message === 'string') return message - - const payload = getObjectMessage(message) - if (!payload) return undefined - - const rawOutput = - getStringValue(payload, 'payload') ?? - getStringValue(payload, 'raw_output') ?? - getStringValue(payload, 'rawOutput') ?? - getStringValue(payload, 'output') ?? - getStringValue(payload, 'line') ?? - getStringValue(payload, 'log') - - if (rawOutput) return rawOutput - - const messageValue = getStringValue(payload, 'message') - const type = getStringValue(payload, 'type') ?? getStringValue(payload, 'event') - if (messageValue && type && /raw|output|log/i.test(type)) { - return messageValue - } - - const logs = getStringArrayValue(payload, 'logs') - return logs.length > 0 ? logs.join('\n') : undefined -} - -function getObjectMessage(message: unknown): PreviewSocketObjectMessage | undefined { - if (!message || typeof message !== 'object' || Array.isArray(message)) { - return undefined - } - - return message as PreviewSocketObjectMessage -} - -function getObjectValue(payload: PreviewSocketObjectMessage, key: string): PreviewSocketObjectMessage | undefined { - const value = payload[key] - if (!value || typeof value !== 'object' || Array.isArray(value)) { - return undefined - } - - return value as PreviewSocketObjectMessage -} - -function getStringValue(payload: PreviewSocketObjectMessage, key: string): string | undefined { - const value = payload[key] - return typeof value === 'string' && value.length > 0 ? value : undefined -} - -function getStringArrayValue(payload: PreviewSocketObjectMessage, key: string): string[] { - const value = payload[key] - if (typeof value === 'string' && value.length > 0) { - return value.split('\n\n').filter(Boolean) - } - if (Array.isArray(value)) { - return value.filter((item): item is string => typeof item === 'string' && item.length > 0) - } - - return [] -} - -function getImpactLevel(payload: PreviewSocketObjectMessage): BlueprintUpdatePreviewImpactLevel { - const level = - getStringValue(payload, 'level') ?? getStringValue(payload, 'severity') ?? getStringValue(payload, 'impact') - - if (!level) return 'unknown' - - const normalizedLevel = level.toLowerCase() - if (normalizedLevel.includes('high')) return 'high' - if (normalizedLevel.includes('medium')) return 'medium' - if (normalizedLevel.includes('low')) return 'low' - return 'unknown' -} - -function isImpactMessage(payload: PreviewSocketObjectMessage): boolean { - const type = getStringValue(payload, 'type') ?? getStringValue(payload, 'event') - return Boolean(type && /impact|summary|analysis/i.test(type)) -} - export default useBlueprintUpdatePreviewSocket diff --git a/libs/domains/services/feature/src/lib/service-update-flow/blueprint/blueprint-update-context.tsx b/libs/domains/services/feature/src/lib/service-update-flow/blueprint/blueprint-update-context.tsx index dd1a037c280..1e747a74da6 100644 --- a/libs/domains/services/feature/src/lib/service-update-flow/blueprint/blueprint-update-context.tsx +++ b/libs/domains/services/feature/src/lib/service-update-flow/blueprint/blueprint-update-context.tsx @@ -20,7 +20,6 @@ export interface BlueprintUpdateFlowContextValue { isRequiredValid: boolean isUpdateLoading: boolean onChange: (name: string, value: BlueprintFieldValue) => void - organizationId: string previewId?: string removedValues: BlueprintUpdateRemovedValue[] requestPreview: () => Promise diff --git a/libs/domains/services/feature/src/lib/service-update-flow/blueprint/blueprint-update-flow.tsx b/libs/domains/services/feature/src/lib/service-update-flow/blueprint/blueprint-update-flow.tsx index c56063ee23b..e1d0d97f879 100644 --- a/libs/domains/services/feature/src/lib/service-update-flow/blueprint/blueprint-update-flow.tsx +++ b/libs/domains/services/feature/src/lib/service-update-flow/blueprint/blueprint-update-flow.tsx @@ -44,7 +44,6 @@ export interface BlueprintUpdateFlowProps { currentStep: 1 | 2 environmentId: string onExit: () => void - organizationId: string service: AnyService } @@ -55,7 +54,6 @@ export function BlueprintUpdateFlow({ currentStep, environmentId, onExit, - organizationId, service, }: BlueprintUpdateFlowProps) { const { data: blueprintUpdate } = useBlueprintUpdate({ blueprintId, suspense: true }) @@ -175,7 +173,6 @@ export function BlueprintUpdateFlow({ isUpdateLoading, onChange: (name: string, value: BlueprintFieldValue) => setValues((currentValues) => ({ ...currentValues, [name]: value })), - organizationId, previewId, removedValues: blueprintUpdateData.removed_values, requestPreview, diff --git a/libs/domains/services/feature/src/lib/service-update-flow/blueprint/blueprint-update-preview-step.spec.tsx b/libs/domains/services/feature/src/lib/service-update-flow/blueprint/blueprint-update-preview-step.spec.tsx index ec44986bb72..d0fce422f83 100644 --- a/libs/domains/services/feature/src/lib/service-update-flow/blueprint/blueprint-update-preview-step.spec.tsx +++ b/libs/domains/services/feature/src/lib/service-update-flow/blueprint/blueprint-update-preview-step.spec.tsx @@ -1,3 +1,4 @@ +import { useParams } from '@tanstack/react-router' import { renderWithProviders, screen } from '@qovery/shared/util-tests' import { useBlueprintUpdatePreviewSocket } from '../../hooks/use-blueprint-update-preview-socket/use-blueprint-update-preview-socket' import { useBlueprintUpdateFlowContext } from './blueprint-update-context' @@ -7,11 +8,19 @@ jest.mock('../../hooks/use-blueprint-update-preview-socket/use-blueprint-update- useBlueprintUpdatePreviewSocket: jest.fn(), })) +jest.mock('@tanstack/react-router', () => ({ + useParams: jest.fn(), +})) + jest.mock('./blueprint-update-context', () => ({ useBlueprintUpdateFlowContext: jest.fn(), })) describe('BlueprintUpdatePreviewStep', () => { + beforeEach(() => { + jest.mocked(useParams).mockReturnValue({ organizationId: 'organization-id' }) + }) + it('requests a preview and keeps confirmation disabled while output is loading', () => { const requestPreview = jest.fn() const handleUpdate = jest.fn() @@ -19,7 +28,6 @@ describe('BlueprintUpdatePreviewStep', () => { clusterId: 'cluster-id', handleUpdate, isUpdateLoading: false, - organizationId: 'organization-id', previewId: 'preview-id', requestPreview, service: { name: 'AWS S3 Bucket' }, @@ -38,6 +46,11 @@ describe('BlueprintUpdatePreviewStep', () => { expect(screen.getByLabelText('Waiting for preview output')).toBeInTheDocument() expect(screen.getByRole('button', { name: /Confirm & deploy update/i })).toBeDisabled() expect(requestPreview).toHaveBeenCalledTimes(1) + expect(useBlueprintUpdatePreviewSocket).toHaveBeenCalledWith({ + clusterId: 'cluster-id', + organizationId: 'organization-id', + previewId: 'preview-id', + }) expect(handleUpdate).not.toHaveBeenCalled() }) @@ -46,7 +59,6 @@ describe('BlueprintUpdatePreviewStep', () => { clusterId: 'cluster-id', handleUpdate: jest.fn(), isUpdateLoading: false, - organizationId: 'organization-id', previewId: 'preview-id', requestPreview: jest.fn(), service: { name: 'AWS S3 Bucket' }, diff --git a/libs/domains/services/feature/src/lib/service-update-flow/blueprint/blueprint-update-preview-step.tsx b/libs/domains/services/feature/src/lib/service-update-flow/blueprint/blueprint-update-preview-step.tsx index 6d4d3afa942..b13f33ee9bd 100644 --- a/libs/domains/services/feature/src/lib/service-update-flow/blueprint/blueprint-update-preview-step.tsx +++ b/libs/domains/services/feature/src/lib/service-update-flow/blueprint/blueprint-update-preview-step.tsx @@ -1,3 +1,4 @@ +import { useParams } from '@tanstack/react-router' import { useEffect } from 'react' import { Button, FunnelFlowBody, Heading, Icon, Section, Skeleton } from '@qovery/shared/ui' import { useBlueprintUpdatePreviewSocket } from '../../hooks/use-blueprint-update-preview-socket/use-blueprint-update-preview-socket' @@ -5,8 +6,7 @@ import { useBlueprintUpdateFlowContext } from './blueprint-update-context' import { getRawOutputLineClassName } from './blueprint-update-utils' export function BlueprintUpdatePreviewStep({ onBack }: { onBack: () => void }) { - const { clusterId, handleUpdate, isUpdateLoading, organizationId, previewId, requestPreview } = - useBlueprintUpdateFlowContext() + const { clusterId, handleUpdate, isUpdateLoading, previewId, requestPreview } = useBlueprintUpdateFlowContext() useEffect(() => { requestPreview() @@ -16,7 +16,6 @@ export function BlueprintUpdatePreviewStep({ onBack }: { onBack: () => void }) { void onConfirm: () => Promise - organizationId: string previewId?: string }) { + if (!clusterId || !previewId) { + return ( + + ) + } + + return ( + + ) +} + +function BlueprintUpdatePreviewWithSocket({ + clusterId, + loading, + onBack, + onConfirm, + previewId, +}: { + clusterId: string + loading: boolean + onBack: () => void + onConfirm: () => Promise + previewId: string +}) { + const { organizationId = '' } = useParams({ strict: false }) const { rawOutput, isLoading: isPreviewOutputLoading, hasReceivedMessage: hasReceivedPreviewMessage, } = useBlueprintUpdatePreviewSocket({ organizationId, clusterId, previewId }) + + return ( + + ) +} + +function BlueprintUpdatePreviewContent({ + hasReceivedPreviewMessage, + isPreviewOutputLoading, + loading, + onBack, + onConfirm, + rawOutput, +}: { + hasReceivedPreviewMessage: boolean + isPreviewOutputLoading: boolean + loading: boolean + onBack: () => void + onConfirm: () => Promise + rawOutput: string +}) { const rawOutputContainerHeightClassName = rawOutput ? 'h-[min(75vh,calc(100vh-260px))] min-h-[260px]' : 'min-h-[180px]' diff --git a/package.json b/package.json index d9920530f5f..de3d8048b56 100644 --- a/package.json +++ b/package.json @@ -182,7 +182,7 @@ "prettier": "3.2.5", "prettier-plugin-tailwindcss": "0.5.14", "pretty-quick": "4.0.0", - "qovery-ws-typescript-axios": "0.1.586", + "qovery-ws-typescript-axios": "0.1.621", "react-docgen-typescript-plugin": "1.0.6", "react-flat-providers": "2.2.0", "react-refresh": "0.14.0", diff --git a/yarn.lock b/yarn.lock index f8ddeabf67c..1a6101f9168 100644 --- a/yarn.lock +++ b/yarn.lock @@ -6344,7 +6344,7 @@ __metadata: prettier-plugin-tailwindcss: 0.5.14 pretty-quick: 4.0.0 qovery-typescript-axios: 1.1.920 - qovery-ws-typescript-axios: 0.1.586 + qovery-ws-typescript-axios: 0.1.621 react: 18.3.1 react-country-flag: 3.0.2 react-datepicker: 4.12.0 @@ -25871,12 +25871,12 @@ __metadata: languageName: node linkType: hard -"qovery-ws-typescript-axios@npm:0.1.586": - version: 0.1.586 - resolution: "qovery-ws-typescript-axios@npm:0.1.586" +"qovery-ws-typescript-axios@npm:0.1.621": + version: 0.1.621 + resolution: "qovery-ws-typescript-axios@npm:0.1.621" dependencies: axios: 1.15.2 - checksum: 5ebeaed1d1dbd7bde290186422f086a3394509b7b8829c9cd66b8c78ac62b1abf42460bd2932e29eb6fa48c267a69534221336c8cbd45b49f421a19dcbf8c8db + checksum: c688dacc49f8adf2986f412a9da5c66d366324e3676a3790b925198bfc06d3edb1595cb09e609caa460c67a19ccac47bf84244f22792260f1f56f4fe8221bd90 languageName: node linkType: hard From d5960226e718393b5041389d50b6b5920b178ee6 Mon Sep 17 00:00:00 2001 From: Romain Billard Date: Mon, 13 Jul 2026 16:12:04 +0200 Subject: [PATCH 32/35] Improve preview loading state and fix Badge usage --- .../blueprint-update-preview-step.spec.tsx | 29 +++++++++++++++++++ .../blueprint-update-preview-step.tsx | 21 ++++++++++++-- .../blueprint-update-review-step.tsx | 18 ++++++------ 3 files changed, 57 insertions(+), 11 deletions(-) diff --git a/libs/domains/services/feature/src/lib/service-update-flow/blueprint/blueprint-update-preview-step.spec.tsx b/libs/domains/services/feature/src/lib/service-update-flow/blueprint/blueprint-update-preview-step.spec.tsx index d0fce422f83..c4c2b977252 100644 --- a/libs/domains/services/feature/src/lib/service-update-flow/blueprint/blueprint-update-preview-step.spec.tsx +++ b/libs/domains/services/feature/src/lib/service-update-flow/blueprint/blueprint-update-preview-step.spec.tsx @@ -1,4 +1,5 @@ import { useParams } from '@tanstack/react-router' +import { act } from '@testing-library/react' import { renderWithProviders, screen } from '@qovery/shared/util-tests' import { useBlueprintUpdatePreviewSocket } from '../../hooks/use-blueprint-update-preview-socket/use-blueprint-update-preview-socket' import { useBlueprintUpdateFlowContext } from './blueprint-update-context' @@ -76,4 +77,32 @@ describe('BlueprintUpdatePreviewStep', () => { expect(screen.getByText('~ changed')).toHaveClass('text-info') expect(screen.getByRole('button', { name: /Confirm & deploy update/i })).toBeEnabled() }) + + it('updates the loading message while waiting for preview output', () => { + jest.useFakeTimers() + jest.mocked(useBlueprintUpdateFlowContext).mockReturnValue({ + clusterId: 'cluster-id', + handleUpdate: jest.fn(), + isUpdateLoading: false, + previewId: 'preview-id', + requestPreview: jest.fn(), + service: { name: 'AWS S3 Bucket' }, + } as ReturnType) + jest.mocked(useBlueprintUpdatePreviewSocket).mockReturnValue({ + hasReceivedMessage: false, + isLoading: true, + rawOutput: undefined, + }) + + renderWithProviders() + + expect(screen.getByText('Generating preview output')).toBeInTheDocument() + + act(() => { + jest.advanceTimersByTime(8_000) + }) + + expect(screen.getByText('Analyzing the planned changes')).toBeInTheDocument() + jest.useRealTimers() + }) }) diff --git a/libs/domains/services/feature/src/lib/service-update-flow/blueprint/blueprint-update-preview-step.tsx b/libs/domains/services/feature/src/lib/service-update-flow/blueprint/blueprint-update-preview-step.tsx index b13f33ee9bd..269b495f5a1 100644 --- a/libs/domains/services/feature/src/lib/service-update-flow/blueprint/blueprint-update-preview-step.tsx +++ b/libs/domains/services/feature/src/lib/service-update-flow/blueprint/blueprint-update-preview-step.tsx @@ -1,10 +1,18 @@ import { useParams } from '@tanstack/react-router' -import { useEffect } from 'react' +import { useEffect, useState } from 'react' import { Button, FunnelFlowBody, Heading, Icon, Section, Skeleton } from '@qovery/shared/ui' import { useBlueprintUpdatePreviewSocket } from '../../hooks/use-blueprint-update-preview-socket/use-blueprint-update-preview-socket' import { useBlueprintUpdateFlowContext } from './blueprint-update-context' import { getRawOutputLineClassName } from './blueprint-update-utils' +const PREVIEW_LOADING_MESSAGES = [ + 'Generating preview output', + 'Analyzing the planned changes', + 'Still working on your preview', + 'Finalizing preview output', +] +const PREVIEW_LOADING_MESSAGE_INTERVAL = 8_000 + export function BlueprintUpdatePreviewStep({ onBack }: { onBack: () => void }) { const { clusterId, handleUpdate, isUpdateLoading, previewId, requestPreview } = useBlueprintUpdateFlowContext() @@ -163,12 +171,21 @@ function BlueprintUpdateRawOutput({ rawOutput }: { rawOutput: string }) { function BlueprintUpdateRawOutputSkeleton() { const skeletonLineWidths = ['46%', '28%', '72%', '64%', '82%', '34%'] + const [messageIndex, setMessageIndex] = useState(0) + + useEffect(() => { + const intervalId = window.setInterval(() => { + setMessageIndex((currentIndex) => (currentIndex + 1) % PREVIEW_LOADING_MESSAGES.length) + }, PREVIEW_LOADING_MESSAGE_INTERVAL) + + return () => window.clearInterval(intervalId) + }, []) return (
- Generating preview output + {PREVIEW_LOADING_MESSAGES[messageIndex]}
{skeletonLineWidths.map((width, index) => ( diff --git a/libs/domains/services/feature/src/lib/service-update-flow/blueprint/blueprint-update-review-step.tsx b/libs/domains/services/feature/src/lib/service-update-flow/blueprint/blueprint-update-review-step.tsx index 311cf4a23c6..c29226b9fa2 100644 --- a/libs/domains/services/feature/src/lib/service-update-flow/blueprint/blueprint-update-review-step.tsx +++ b/libs/domains/services/feature/src/lib/service-update-flow/blueprint/blueprint-update-review-step.tsx @@ -255,29 +255,29 @@ function UpdatedValuesList({

{label}

-
+
Default: - + {formatUpdateValue(value.current_default_value)} - + - + {formatUpdateValue(value.new_default_value)} - +
{hasEditedOverride && (
Override: - + {formatUpdateValue(editedValue)} - +
)}
-
+
{hasEditedOverride && ( Reset the override to the new default value}>
diff --git a/libs/domains/services/feature/src/lib/service-update-flow/blueprint/blueprint-update-context.tsx b/libs/domains/services/feature/src/lib/service-update-flow/blueprint/blueprint-update-context.tsx index 1e747a74da6..3e5e91097db 100644 --- a/libs/domains/services/feature/src/lib/service-update-flow/blueprint/blueprint-update-context.tsx +++ b/libs/domains/services/feature/src/lib/service-update-flow/blueprint/blueprint-update-context.tsx @@ -1,4 +1,8 @@ -import { type BlueprintUpdateNewRequiredValue, type BlueprintUpdateRemovedValue, type BlueprintUpdateResponse } from 'qovery-typescript-axios' +import { + type BlueprintUpdateNewRequiredValue, + type BlueprintUpdateRemovedValue, + type BlueprintUpdateResponse, +} from 'qovery-typescript-axios' import { type ReactNode, createContext, useContext } from 'react' import { type AnyService } from '@qovery/domains/services/data-access' import { type BlueprintFieldValue, type BlueprintFieldValues } from '../../blueprint-field-utils/blueprint-field-utils' diff --git a/libs/domains/services/feature/src/lib/service-update-flow/blueprint/blueprint-update-flow-shell.tsx b/libs/domains/services/feature/src/lib/service-update-flow/blueprint/blueprint-update-flow-shell.tsx index b4532f803c3..def9cb8418a 100644 --- a/libs/domains/services/feature/src/lib/service-update-flow/blueprint/blueprint-update-flow-shell.tsx +++ b/libs/domains/services/feature/src/lib/service-update-flow/blueprint/blueprint-update-flow-shell.tsx @@ -25,7 +25,15 @@ export function BlueprintUpdateFlowShell({
-
@@ -54,7 +62,9 @@ function StepIndicator({ {number} diff --git a/libs/domains/services/feature/src/lib/service-update-flow/blueprint/blueprint-update-flow.tsx b/libs/domains/services/feature/src/lib/service-update-flow/blueprint/blueprint-update-flow.tsx index e1d0d97f879..dc2bed888a5 100644 --- a/libs/domains/services/feature/src/lib/service-update-flow/blueprint/blueprint-update-flow.tsx +++ b/libs/domains/services/feature/src/lib/service-update-flow/blueprint/blueprint-update-flow.tsx @@ -1,6 +1,4 @@ -import { - type BlueprintUpdateResponse, -} from 'qovery-typescript-axios' +import { type BlueprintUpdateResponse } from 'qovery-typescript-axios' import { type ReactNode, useCallback, useEffect, useMemo, useState } from 'react' import { type AnyService } from '@qovery/domains/services/data-access' import { toast } from '@qovery/shared/ui' @@ -15,14 +13,14 @@ import { useUpdateBlueprint } from '../../hooks/use-update-blueprint/use-update- import { BlueprintUpdateFlowProvider } from './blueprint-update-context' import { BlueprintUpdateFlowShell } from './blueprint-update-flow-shell' import { + type BlueprintUpdateEditableValue, + type BlueprintUpdateSection, buildBlueprintUpdatePayload, getBlueprintUpdateFieldValue, + getBlueprintUpdateVariableField, getBlueprintUpdateVersion, getFallbackServiceIcon, getFirstAvailableUpdateSection, - getBlueprintUpdateVariableField, - type BlueprintUpdateEditableValue, - type BlueprintUpdateSection, updateSections, } from './blueprint-update-utils' @@ -107,10 +105,10 @@ export function BlueprintUpdateFlow({ const isRequiredValid = requiredValues.every((value) => isFieldValid(getBlueprintUpdateVariableField(value, true), values[value.name]) ) - const isReviewComplete = reviewSections.every(({ id }) => completedSections.includes(id)) || reviewSections.length === 0 - const canContinueReview = reviewSections.length === 1 - ? activeSection !== 'required' || isRequiredValid - : isReviewComplete + const isReviewComplete = + reviewSections.every(({ id }) => completedSections.includes(id)) || reviewSections.length === 0 + const canContinueReview = + reviewSections.length === 1 ? activeSection !== 'required' || isRequiredValid : isReviewComplete const latestVersion = getBlueprintUpdateVersion(blueprintUpdateData.latest_tag) ?? blueprintUpdateData.latest_tag const title = `${service.name} blueprint update to ${latestVersion}` const payload = useMemo( @@ -124,7 +122,14 @@ export function BlueprintUpdateFlow({ requiredValues, updatedValues, }), - [blueprintUpdateData.latest_tag, blueprintUpdateData.new_optional_values, requiredValues, service, updatedValues, values] + [ + blueprintUpdateData.latest_tag, + blueprintUpdateData.new_optional_values, + requiredValues, + service, + updatedValues, + values, + ] ) const completeActiveSection = useCallback(() => { diff --git a/libs/domains/services/feature/src/lib/service-update-flow/blueprint/blueprint-update-review-step.tsx b/libs/domains/services/feature/src/lib/service-update-flow/blueprint/blueprint-update-review-step.tsx index c29226b9fa2..afaaa94e479 100644 --- a/libs/domains/services/feature/src/lib/service-update-flow/blueprint/blueprint-update-review-step.tsx +++ b/libs/domains/services/feature/src/lib/service-update-flow/blueprint/blueprint-update-review-step.tsx @@ -17,12 +17,12 @@ import { BLUEPRINT_RELEASE_NOTES_URL, type BlueprintUpdateEditableValue, type BlueprintUpdateSection, + formatUpdateFieldLabel, + formatUpdateValue, getBlueprintUpdateFieldValue, getBlueprintUpdatePayloadValue, getBlueprintUpdateVariableField, isBlueprintUpdateVariableField, - formatUpdateFieldLabel, - formatUpdateValue, } from './blueprint-update-utils' export function BlueprintUpdateReviewStep({ onContinue }: { onContinue: () => void }) { @@ -94,7 +94,13 @@ export function BlueprintUpdateReviewStep({ onContinue }: { onContinue: () => vo
- @@ -207,11 +213,20 @@ function BlueprintUpdateSectionContent({
)} - {section === 'modified' && } + {section === 'modified' && ( + + )} {section === 'removed' && } {showContinueButton && ( - @@ -304,7 +319,9 @@ function UpdatedValuesList({ size="xs" iconOnly aria-label={`Edit ${label} override`} - onClick={() => setEditedValueName((currentName) => (currentName === value.name ? undefined : value.name))} + onClick={() => + setEditedValueName((currentName) => (currentName === value.name ? undefined : value.name)) + } > @@ -344,7 +361,10 @@ function RemovedValuesList({ values }: { values: BlueprintUpdateRemovedValue[] } return (
{values.map((value) => ( -
+

{formatUpdateFieldLabel(value.name)}

Deleted diff --git a/libs/domains/services/feature/src/lib/service-update-flow/blueprint/blueprint-update-utils.spec.ts b/libs/domains/services/feature/src/lib/service-update-flow/blueprint/blueprint-update-utils.spec.ts index 663285fa92a..cdb572e9187 100644 --- a/libs/domains/services/feature/src/lib/service-update-flow/blueprint/blueprint-update-utils.spec.ts +++ b/libs/domains/services/feature/src/lib/service-update-flow/blueprint/blueprint-update-utils.spec.ts @@ -1,3 +1,4 @@ +import { type BlueprintUpdateResponse } from 'qovery-typescript-axios' import { buildBlueprintUpdatePayload, getBlueprintUpdateFieldValue, @@ -7,7 +8,6 @@ import { getRawOutputLineClassName, hasBlueprintUpdateReviewSections, } from './blueprint-update-utils' -import { type BlueprintUpdateResponse } from 'qovery-typescript-axios' function createBlueprintUpdate(overrides: Partial = {}): BlueprintUpdateResponse { return { @@ -29,15 +29,39 @@ describe('blueprint update utils', () => { }) it('selects the first section that needs user attention', () => { - expect(getFirstAvailableUpdateSection(createBlueprintUpdate({ new_required_values: [{ name: 'required', type: { type: 'string' }, is_secret: false }] }))).toBe('required') - expect(getFirstAvailableUpdateSection(createBlueprintUpdate({ new_optional_values: [{ name: 'optional', default_value: 'value', type: { type: 'string' }, is_secret: false }] }))).toBe('optional') - expect(getFirstAvailableUpdateSection(createBlueprintUpdate({ engine_diff: { updated_values: [{ name: 'engine', current_default_value: 'old', new_default_value: 'new' }] } }))).toBe('modified') - expect(getFirstAvailableUpdateSection(createBlueprintUpdate({ removed_values: [{ name: 'removed' }] }))).toBe('removed') + expect( + getFirstAvailableUpdateSection( + createBlueprintUpdate({ + new_required_values: [{ name: 'required', type: { type: 'string' }, is_secret: false }], + }) + ) + ).toBe('required') + expect( + getFirstAvailableUpdateSection( + createBlueprintUpdate({ + new_optional_values: [ + { name: 'optional', default_value: 'value', type: { type: 'string' }, is_secret: false }, + ], + }) + ) + ).toBe('optional') + expect( + getFirstAvailableUpdateSection( + createBlueprintUpdate({ + engine_diff: { updated_values: [{ name: 'engine', current_default_value: 'old', new_default_value: 'new' }] }, + }) + ) + ).toBe('modified') + expect(getFirstAvailableUpdateSection(createBlueprintUpdate({ removed_values: [{ name: 'removed' }] }))).toBe( + 'removed' + ) }) it('reports whether the update contains review sections', () => { expect(hasBlueprintUpdateReviewSections(createBlueprintUpdate())).toBe(false) - expect(hasBlueprintUpdateReviewSections(createBlueprintUpdate({ removed_values: [{ name: 'removed' }] }))).toBe(true) + expect(hasBlueprintUpdateReviewSections(createBlueprintUpdate({ removed_values: [{ name: 'removed' }] }))).toBe( + true + ) }) it('normalizes field values for the update payload', () => { @@ -57,7 +81,13 @@ describe('blueprint update utils', () => { const requiredValues = [{ name: 'required', type: { type: 'string' }, is_secret: true }] const optionalValues = [{ name: 'optional', default_value: 'default', type: { type: 'string' }, is_secret: false }] const updatedValues = [ - { name: 'changed', current_default_value: 'old', new_default_value: 'new', type: { type: 'string' }, is_secret: false }, + { + name: 'changed', + current_default_value: 'old', + new_default_value: 'new', + type: { type: 'string' }, + is_secret: false, + }, { name: 'engine_changed', current_default_value: 'old', new_default_value: 'new' }, ] From c43ed5f9432ad2c5476153a24b805fbf06b8c1fe Mon Sep 17 00:00:00 2001 From: Romain Billard Date: Mon, 13 Jul 2026 16:47:01 +0200 Subject: [PATCH 34/35] Add missing unit tests --- .../blueprint-field-utils.spec.ts | 156 +++++++++++++++++- 1 file changed, 155 insertions(+), 1 deletion(-) diff --git a/libs/domains/services/feature/src/lib/blueprint-field-utils/blueprint-field-utils.spec.ts b/libs/domains/services/feature/src/lib/blueprint-field-utils/blueprint-field-utils.spec.ts index f8d50844401..2d210cfa0d8 100644 --- a/libs/domains/services/feature/src/lib/blueprint-field-utils/blueprint-field-utils.spec.ts +++ b/libs/domains/services/feature/src/lib/blueprint-field-utils/blueprint-field-utils.spec.ts @@ -3,7 +3,25 @@ import { type BlueprintManifestResponseResultsInner, type BlueprintManifestVariableField, } from 'qovery-typescript-axios' -import { getDefaultBlueprintFieldValues } from './blueprint-field-utils' +import { + formatFieldLabel, + getBlueprintFieldPath, + getBooleanFieldValue, + getDefaultBlueprintFieldValues, + getDefaultContextFieldValue, + getDefaultFieldValue, + getFieldLengthValidationError, + getFieldValidationError, + getStringFieldValue, + getSummaryFieldValue, + isFieldValid, + isFieldValueFulfilled, + isFieldValueMatchingPattern, + isOptionalVariableField, + isOverridableContextVariableField, + isRequiredVariableField, + isVariableField, +} from './blueprint-field-utils' function createVariableField(overrides: Partial = {}): BlueprintManifestVariableField { return { @@ -75,3 +93,139 @@ describe('getDefaultBlueprintFieldValues', () => { }) }) }) + +describe('field formatting', () => { + it('builds a field path', () => { + expect(getBlueprintFieldPath('database_name')).toBe('fields.database_name') + }) + + it('formats a field label', () => { + expect(formatFieldLabel('database_name')).toBe('Database name') + }) +}) + +describe('default values', () => { + it('returns boolean and string variable defaults', () => { + expect(getDefaultFieldValue(createVariableField({ type: { type: 'bool' }, default_value: 'true' }))).toBe(true) + expect(getDefaultFieldValue(createVariableField({ type: { type: 'bool' }, default_value: 'false' }))).toBe(false) + expect(getDefaultFieldValue(createVariableField({ default_value: 'value' }))).toBe('value') + expect(getDefaultFieldValue(createVariableField())).toBe('') + }) + + it('returns the context value or an empty string', () => { + expect(getDefaultContextFieldValue(createContextVariableField({ value: 'value' }))).toBe('value') + expect(getDefaultContextFieldValue(createContextVariableField({ value: undefined }))).toBe('') + }) +}) + +describe('field value accessors', () => { + it('gets string values without coercing booleans', () => { + expect(getStringFieldValue('value')).toBe('value') + expect(getStringFieldValue(true)).toBe('') + expect(getStringFieldValue(undefined)).toBe('') + }) + + it('gets boolean values without coercing strings', () => { + expect(getBooleanFieldValue(true)).toBe(true) + expect(getBooleanFieldValue('true')).toBe(false) + expect(getBooleanFieldValue(undefined)).toBe(false) + }) + + it.each([ + ['a non-empty string', 'value', true], + ['a whitespace-only string', ' ', false], + ['a boolean', false, true], + ['an undefined value', undefined, false], + ] as const)('reports whether %s is fulfilled', (_, value, expected) => { + expect(isFieldValueFulfilled(value)).toBe(expected) + }) +}) + +describe('field validation', () => { + it('matches a field pattern and ignores empty or malformed patterns', () => { + const field = createVariableField({ type: { type: 'string', pattern: '^value$' } }) + + expect(isFieldValueMatchingPattern(field, 'value')).toBe(true) + expect(isFieldValueMatchingPattern(field, 'other')).toBe(false) + expect(isFieldValueMatchingPattern(field, '')).toBe(true) + expect(isFieldValueMatchingPattern(createVariableField({ type: { type: 'string', pattern: '[' } }), 'value')).toBe( + true + ) + }) + + it('validates minimum and maximum lengths', () => { + const rangeField = createVariableField({ type: { type: 'string', min_length: 2, max_length: 4 } }) + const minField = createVariableField({ type: { type: 'string', min_length: 2 } }) + const maxField = createVariableField({ type: { type: 'string', max_length: 4 } }) + + expect(getFieldLengthValidationError(rangeField, 'a')).toBe('Value must be between 2 and 4 characters.') + expect(getFieldLengthValidationError(rangeField, 'value')).toBe('Value must be between 2 and 4 characters.') + expect(getFieldLengthValidationError(rangeField, 'test')).toBeUndefined() + expect(getFieldLengthValidationError(minField, 'a')).toBe('Value must be at least 2 characters.') + expect(getFieldLengthValidationError(maxField, 'value')).toBe('Value must be at most 4 characters.') + expect(getFieldLengthValidationError(rangeField, false)).toBeUndefined() + }) + + it('prioritizes length errors before pattern errors', () => { + const field = createVariableField({ type: { type: 'string', min_length: 2, pattern: '^value$' } }) + + expect(getFieldValidationError(field, 'a')).toBe('Value must be at least 2 characters.') + expect(getFieldValidationError(field, 'other')).toBe('Value does not match the expected format.') + expect(getFieldValidationError(field, 'value')).toBeUndefined() + }) + + it('requires a fulfilled value only for required fields', () => { + const requiredField = createVariableField({ required: true }) + const optionalField = createVariableField({ required: false }) + + expect(isFieldValid(requiredField, ' ')).toBe(false) + expect(isFieldValid(requiredField, false)).toBe(true) + expect(isFieldValid(optionalField, undefined)).toBe(true) + expect(isFieldValid(createVariableField({ type: { type: 'string', pattern: '^value$' } }), 'other')).toBe(false) + }) +}) + +describe('field type guards', () => { + it('identifies variable fields and their required state', () => { + const requiredField = createVariableField({ required: true }) as BlueprintManifestResponseResultsInner + const optionalField = createVariableField({ required: false }) as BlueprintManifestResponseResultsInner + const contextField = createContextVariableField() as BlueprintManifestResponseResultsInner + + expect(isVariableField(requiredField)).toBe(true) + expect(isVariableField(contextField)).toBe(false) + expect(isRequiredVariableField(requiredField)).toBe(true) + expect(isRequiredVariableField(optionalField)).toBe(false) + expect(isOptionalVariableField(requiredField)).toBe(false) + expect(isOptionalVariableField(optionalField)).toBe(true) + }) + + it('identifies explicitly overridable context fields', () => { + const overridableField = { + ...createContextVariableField(), + overridable: true, + } as BlueprintManifestResponseResultsInner + const nonOverridableField = createContextVariableField() as BlueprintManifestResponseResultsInner + + expect(isOverridableContextVariableField(overridableField)).toBe(true) + expect(isOverridableContextVariableField(nonOverridableField)).toBe(false) + }) +}) + +describe('getSummaryFieldValue', () => { + it('formats boolean values, masks secrets, and preserves regular values', () => { + const secretField = createVariableField({ is_secret: true }) + const regularField = createVariableField({ is_secret: false }) + const contextField = { + ...createContextVariableField(), + overridable: true, + } + + expect(getSummaryFieldValue(regularField, true)).toBe('Enabled') + expect(getSummaryFieldValue(regularField, false)).toBe('Disabled') + expect(getSummaryFieldValue(secretField, 'secret')).toBe('••••••••') + expect(getSummaryFieldValue(secretField, '')).toBe('') + expect(getSummaryFieldValue(regularField, 'value')).toBe('value') + expect(getSummaryFieldValue(contextField, 'context-value')).toBe('context-value') + expect(getSummaryFieldValue(regularField, undefined)).toBeUndefined() + }) +}) From 0f4476dd1c7e5830ee1588333ff2e237e2ec3e8b Mon Sep 17 00:00:00 2001 From: Romain Billard Date: Mon, 13 Jul 2026 16:52:01 +0200 Subject: [PATCH 35/35] Use PropsWithChildren instead of passing down children prop --- .../service-update-flow/blueprint/blueprint-update-flow.tsx | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/libs/domains/services/feature/src/lib/service-update-flow/blueprint/blueprint-update-flow.tsx b/libs/domains/services/feature/src/lib/service-update-flow/blueprint/blueprint-update-flow.tsx index dc2bed888a5..e0e974832df 100644 --- a/libs/domains/services/feature/src/lib/service-update-flow/blueprint/blueprint-update-flow.tsx +++ b/libs/domains/services/feature/src/lib/service-update-flow/blueprint/blueprint-update-flow.tsx @@ -1,5 +1,5 @@ import { type BlueprintUpdateResponse } from 'qovery-typescript-axios' -import { type ReactNode, useCallback, useEffect, useMemo, useState } from 'react' +import { type PropsWithChildren, useCallback, useEffect, useMemo, useState } from 'react' import { type AnyService } from '@qovery/domains/services/data-access' import { toast } from '@qovery/shared/ui' import { @@ -35,9 +35,8 @@ export { export { BlueprintUpdatePreviewStep } from './blueprint-update-preview-step' export { BlueprintUpdateReviewStep } from './blueprint-update-review-step' -export interface BlueprintUpdateFlowProps { +export interface BlueprintUpdateFlowProps extends PropsWithChildren { blueprintId: string - children: ReactNode clusterId?: string currentStep: 1 | 2 environmentId: string