From d8eb00e12060be0e4cf2aa71c8a65b1dd1e6bc00 Mon Sep 17 00:00:00 2001 From: Romain Billard Date: Wed, 1 Jul 2026 15:20:54 +0200 Subject: [PATCH 1/9] Add support for multiple versions --- .../blueprint-configuration-view.tsx | 29 ++++++++- .../blueprint-create-context.ts | 1 + .../blueprint-creation-flow.spec.tsx | 60 ++++++++++++++++++- .../blueprint/blueprint-creation-flow.tsx | 35 ++++++++++- .../blueprint-creation-utils.spec.ts | 18 ++++++ .../blueprint-creation-utils.ts | 10 ++++ .../blueprint-step-summary.tsx | 2 +- 7 files changed, 147 insertions(+), 8 deletions(-) diff --git a/libs/domains/services/feature/src/lib/service-creation-flow/blueprint/blueprint-configuration-view/blueprint-configuration-view.tsx b/libs/domains/services/feature/src/lib/service-creation-flow/blueprint/blueprint-configuration-view/blueprint-configuration-view.tsx index 451ba0b4e41..1b5ea842652 100644 --- a/libs/domains/services/feature/src/lib/service-creation-flow/blueprint/blueprint-configuration-view/blueprint-configuration-view.tsx +++ b/libs/domains/services/feature/src/lib/service-creation-flow/blueprint/blueprint-configuration-view/blueprint-configuration-view.tsx @@ -1,7 +1,8 @@ import { useNavigate, useSearch } from '@tanstack/react-router' -import { useEffect, useState } from 'react' +import { useEffect, useMemo, useState } from 'react' +import { type Value } from '@qovery/shared/interfaces' import { type ServiceCreateSection } from '@qovery/shared/router' -import { Button, FunnelFlowBody, Icon, InputText } from '@qovery/shared/ui' +import { Button, FunnelFlowBody, Icon, InputSelect, InputText } from '@qovery/shared/ui' import { useBlueprintCreateContext } from '../blueprint-create-context/blueprint-create-context' import { type BlueprintFieldValue, @@ -10,6 +11,7 @@ import { getFieldValidationError, getStringFieldValue, isFieldValid, + sortBlueprintMajorVersions, } from '../blueprint-creation-utils/blueprint-creation-utils' import { BlueprintManifestVariableInput } from './blueprint-creation-components/blueprint-manifest-variable-input/blueprint-manifest-variable-input' import { BlueprintSection } from './blueprint-creation-components/blueprint-section/blueprint-section' @@ -36,7 +38,16 @@ export function BlueprintConfigurationView() { const initialSection = isBlueprintConfigurationSection(search.section) ? search.section : 'service-information' const [currentSection, setCurrentSection] = useState(initialSection) const serviceName = form.watch('serviceName') + const versionTag = form.watch('versionTag') const blueprintFieldValues = form.watch('fields') + const blueprintVersionOptions = useMemo( + () => + sortBlueprintMajorVersions(blueprint.majorVersions).map((majorVersion) => ({ + label: majorVersion.serviceVersion, + value: majorVersion.latestTag, + })), + [blueprint.majorVersions] + ) const hasOverrideFields = optionalBlueprintFields.length > 0 || overridableContextBlueprintFields.length > 0 const isServiceInformationValid = Boolean(serviceName.trim()) const isBlueprintSetupValid = requiredBlueprintFields.every((field) => @@ -86,7 +97,19 @@ export function BlueprintConfigurationView() { onChange={(event) => form.setValue('serviceName', event.currentTarget.value)} autoFocus /> - + {blueprintVersionOptions.length > 1 ? ( + + form.setValue('versionTag', Array.isArray(value) ? value[0] : value, { shouldDirty: true }) + } + isSearchable={blueprintVersionOptions.length > 6} + /> + ) : ( + + )} - + navigateToSection('blueprint-setup')} /> )} @@ -131,79 +79,215 @@ export function BlueprintConfigurationView() { disabled={!isServiceInformationValid} iconName="chart-bullet" title="Blueprint setup" - onClick={() => setCurrentSection('blueprint-setup')} + onClick={() => navigateToSection('blueprint-setup')} > {currentSection === 'blueprint-setup' && ( - <> - {requiredBlueprintFields.map((field, index) => ( - updateFieldValue(field.name, value)} - /> - ))} - - + navigateToSection('overrides')} /> )} - setCurrentSection('overrides')} - > - {hasOverrideFields && ( - <> - {optionalBlueprintFields.map((field, index) => ( - updateFieldValue(field.name, value)} - /> - ))} - {overridableContextBlueprintFields.map((field, index) => ( - updateFieldValue(field.name, event.currentTarget.value)} - /> - ))} - - )} - + + {currentSection === 'service-information' ? ( + navigateToSection('overrides')} /> + ) : ( + navigateToSection('overrides')} /> + )} -
- -
+ {currentSection === 'overrides' ? : } ) } + +interface ServiceInformationSectionContentProps { + onContinue: () => void +} + +function ServiceInformationSectionContent({ onContinue }: ServiceInformationSectionContentProps) { + const { blueprint, form, serviceVersion } = useBlueprintCreateContext() + const serviceName = form.watch('serviceName') + const versionTag = form.watch('versionTag') + const isServiceInformationValid = useIsServiceInformationValid() + const blueprintVersionOptions = useMemo( + () => + sortBlueprintMajorVersions(blueprint.majorVersions).map((majorVersion) => ({ + label: majorVersion.serviceVersion, + value: majorVersion.latestTag, + })), + [blueprint.majorVersions] + ) + + return ( + <> + form.setValue('serviceName', event.currentTarget.value)} + autoFocus + /> + {blueprintVersionOptions.length > 1 ? ( + + form.setValue('versionTag', Array.isArray(value) ? value[0] : value, { shouldDirty: true }) + } + isSearchable={blueprintVersionOptions.length > 6} + /> + ) : ( + + )} + + + ) +} + +interface BlueprintSetupSectionContentProps { + onContinue: () => void +} + +function BlueprintSetupSectionContent({ onContinue }: BlueprintSetupSectionContentProps) { + const { form } = useBlueprintCreateContext() + const { requiredBlueprintFields } = useBlueprintManifestFields() + const blueprintFieldValues = form.watch('fields') + const isBlueprintSetupValid = useIsBlueprintSetupValid() + const updateFieldValue = (name: string, value: BlueprintFieldValue) => { + form.setValue(getBlueprintFieldPath(name), value, { shouldDirty: true, shouldValidate: true }) + } + + return ( + <> + {requiredBlueprintFields.map((field, index) => ( + updateFieldValue(field.name, value)} + /> + ))} + + + ) +} + +interface BlueprintOverridesSectionProps { + currentSection: Exclude + onClick: () => void +} + +function BlueprintOverridesSection({ currentSection, onClick }: BlueprintOverridesSectionProps) { + const { optionalBlueprintFields, overridableContextBlueprintFields } = useBlueprintManifestFields() + const isBlueprintSetupValid = useIsBlueprintSetupValid() + const hasOverrideFields = optionalBlueprintFields.length > 0 || overridableContextBlueprintFields.length > 0 + + return ( + + {currentSection === 'overrides' && hasOverrideFields && } + + ) +} + +function OverridesSectionContent() { + const { form } = useBlueprintCreateContext() + const { optionalBlueprintFields, overridableContextBlueprintFields } = useBlueprintManifestFields() + const blueprintFieldValues = form.watch('fields') + const updateFieldValue = (name: string, value: BlueprintFieldValue) => { + form.setValue(getBlueprintFieldPath(name), value, { shouldDirty: true, shouldValidate: true }) + } + + return ( + <> + {optionalBlueprintFields.map((field, index) => ( + updateFieldValue(field.name, value)} + /> + ))} + {overridableContextBlueprintFields.map((field, index) => ( + updateFieldValue(field.name, event.currentTarget.value)} + /> + ))} + + ) +} + +function ConfirmConfigurationFooter() { + const navigate = useNavigate() + const { creationFlowUrl } = useBlueprintCreateContext() + const isBlueprintSetupValid = useIsBlueprintSetupValid() + + return ( +
+ +
+ ) +} + +function DisabledConfirmConfigurationFooter() { + return ( +
+ +
+ ) +} + +function useIsBlueprintSetupValid() { + const { form } = useBlueprintCreateContext() + const { requiredBlueprintFields } = useBlueprintManifestFields() + const blueprintFieldValues = form.watch('fields') + + return requiredBlueprintFields.every((field) => isFieldValid(field, blueprintFieldValues[field.name])) +} + +function useIsServiceInformationValid() { + const { form } = useBlueprintCreateContext() + const serviceName = form.watch('serviceName') + + return Boolean(serviceName.trim()) +} diff --git a/libs/domains/services/feature/src/lib/service-creation-flow/blueprint/blueprint-configuration-view/blueprint-creation-components/overrides-section/overrides-section.tsx b/libs/domains/services/feature/src/lib/service-creation-flow/blueprint/blueprint-configuration-view/blueprint-creation-components/overrides-section-card/overrides-section-card.tsx similarity index 89% rename from libs/domains/services/feature/src/lib/service-creation-flow/blueprint/blueprint-configuration-view/blueprint-creation-components/overrides-section/overrides-section.tsx rename to libs/domains/services/feature/src/lib/service-creation-flow/blueprint/blueprint-configuration-view/blueprint-creation-components/overrides-section-card/overrides-section-card.tsx index 3a28626c809..bb026ebc65b 100644 --- a/libs/domains/services/feature/src/lib/service-creation-flow/blueprint/blueprint-configuration-view/blueprint-creation-components/overrides-section/overrides-section.tsx +++ b/libs/domains/services/feature/src/lib/service-creation-flow/blueprint/blueprint-configuration-view/blueprint-creation-components/overrides-section-card/overrides-section-card.tsx @@ -2,14 +2,14 @@ import { type ReactNode } from 'react' import { Heading, Icon, Section } from '@qovery/shared/ui' import { BlueprintSection } from '../blueprint-section/blueprint-section' -export interface OverridesSectionProps { +export interface OverridesSectionCardProps { active: boolean children?: ReactNode disabled?: boolean onClick: () => void } -export function OverridesSection({ active, children, disabled = false, onClick }: OverridesSectionProps) { +export function OverridesSectionCard({ active, children, disabled = false, onClick }: OverridesSectionCardProps) { if (!active) { return ( onViewDetails: () => void serviceVersion: string - requiredBlueprintFields: BlueprintManifestVariableField[] - optionalBlueprintFields: BlueprintManifestVariableField[] - overridableContextBlueprintFields: OverridableBlueprintManifestContextVariableField[] } export interface BlueprintCreationFlowProps extends PropsWithChildren { 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 9991c7aafd3..657e6d25d76 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 @@ -1,18 +1,20 @@ import { useParams } from '@tanstack/react-router' import { act, within } from '@testing-library/react' import type { BlueprintItem, BlueprintManifestResponseResultsInner } from 'qovery-typescript-axios' -import { type ReactNode, useEffect, useState } from 'react' +import { type ReactNode, useEffect, useLayoutEffect, useState } from 'react' import selectEvent from 'react-select-event' import { renderWithProviders, screen, waitFor } from '@qovery/shared/util-tests' import { - BlueprintConfigurationView, BlueprintCreationFlow, + BlueprintManifestFieldsProvider, + BlueprintOverridesConfigurationSection, + BlueprintServiceInformationSection, + BlueprintSetupSection, BlueprintStepSummary, useBlueprintCreateContext, } from './blueprint-creation-flow' const mockNavigate = jest.fn() -const mockUseSearch = jest.fn() const mockUseBlueprintCatalogServiceManifest = jest.fn() const mockUseBlueprintCatalogServiceReadme = jest.fn() const mockUseBlueprintServiceCreatedSocket = jest.fn() @@ -23,7 +25,6 @@ jest.mock('@tanstack/react-router', () => ({ ...jest.requireActual('@tanstack/react-router'), useNavigate: () => mockNavigate, useParams: jest.fn(), - useSearch: () => mockUseSearch(), })) jest.mock('@qovery/shared/ui', () => { @@ -121,25 +122,50 @@ function renderBlueprintFlow(children: JSX.Element) { function FillFormValues({ children }: { children: JSX.Element }) { const { form } = useBlueprintCreateContext() - - useEffect(() => { - form.setValue('serviceName', 'custom-postgres') - form.setValue('fields', { - db_name: 'production', - db_username: 'postgres', - db_password: 'super-secret', - skip_final_snapshot: true, + const [ready, setReady] = useState(false) + + useLayoutEffect(() => { + form.reset({ + ...form.getValues(), + serviceName: 'custom-postgres', + loadedVersionTag: 'aws/postgres/17/1.0.0', + fields: { + db_name: 'production', + db_username: 'postgres', + db_password: 'super-secret', + skip_final_snapshot: true, + }, }) + setReady(true) }, [form]) + if (!ready) { + return null + } + return children } +function WithBlueprintManifestFields({ children }: { children: JSX.Element }) { + return {children} +} + +type BlueprintRouteStep = 'service-information' | 'blueprint-setup' | 'overrides' | 'summary' + function BlueprintFlowRouteHarness({ flowBlueprint = blueprint }: { flowBlueprint?: BlueprintItem }) { - const [step, setStep] = useState<'configuration' | 'summary'>('configuration') + const [step, setStep] = useState('service-information') useEffect(() => { mockNavigate.mockImplementation((options: { to?: string }) => { + if (options.to?.endsWith('/service-information')) { + setStep('service-information') + } + if (options.to?.endsWith('/blueprint-setup')) { + setStep('blueprint-setup') + } + if (options.to?.endsWith('/overrides')) { + setStep('overrides') + } if (options.to?.endsWith('/summary')) { setStep('summary') } @@ -148,7 +174,22 @@ function BlueprintFlowRouteHarness({ flowBlueprint = blueprint }: { flowBlueprin return ( - {step === 'configuration' ? : } + {step === 'service-information' && } + {step === 'blueprint-setup' && ( + + + + )} + {step === 'overrides' && ( + + + + )} + {step === 'summary' && ( + + + + )} ) } @@ -165,7 +206,9 @@ describe('BlueprintCreationFlow', () => { provider: 'AWS', serviceFamily: 'postgres', }) - mockUseBlueprintCatalogServiceManifest.mockReturnValue({ data: manifestFields }) + mockUseBlueprintCatalogServiceManifest.mockReturnValue({ + data: manifestFields, + }) mockUseBlueprintCatalogServiceReadme.mockReturnValue({ data: { content: '# AWS RDS PostgreSQL\n\nBlueprint documentation', @@ -173,13 +216,12 @@ describe('BlueprintCreationFlow', () => { }, }) mockCreateBlueprint.mockResolvedValue({ environment_id: 'env-1' }) - mockUseSearch.mockReturnValue({}) }) it('should open the blueprint details drawer from the configuration header', async () => { jest.useFakeTimers() - const { userEvent } = renderBlueprintFlow() + const { userEvent } = renderBlueprintFlow() await userEvent.click(screen.getByRole('button', { name: 'AWS RDS PostgreSQL' })) @@ -195,7 +237,7 @@ describe('BlueprintCreationFlow', () => { it('should navigate to the blueprint summary from the confirm blueprint configuration button', async () => { jest.useFakeTimers() - const { userEvent } = renderBlueprintFlow() + const { userEvent } = renderWithProviders() await userEvent.click(screen.getByRole('button', { name: /continue/i })) await userEvent.type(await screen.findByLabelText('Db name'), 'production') @@ -212,7 +254,7 @@ describe('BlueprintCreationFlow', () => { it('should focus the first blueprint setup field after continuing from service information', async () => { jest.useFakeTimers() - const { userEvent } = renderBlueprintFlow() + const { userEvent } = renderWithProviders() await userEvent.type(screen.getByLabelText('Service name'), 'custom-postgres') await userEvent.click(screen.getByRole('button', { name: /continue/i })) @@ -220,26 +262,20 @@ describe('BlueprintCreationFlow', () => { expect(await screen.findByLabelText('Db name')).toHaveFocus() }) - it('should not load manifest fields when a blueprint has no service family', () => { + it('should not load manifest fields from the service information route', () => { renderWithProviders( - + ) - expect(mockUseBlueprintCatalogServiceManifest).toHaveBeenCalledWith( - expect.objectContaining({ - provider: 'AWS', - serviceFamily: '', - enabled: false, - }) - ) + expect(mockUseBlueprintCatalogServiceManifest).not.toHaveBeenCalled() }) it('should focus the first overrides field after continuing from blueprint setup', async () => { jest.useFakeTimers() - const { userEvent } = renderBlueprintFlow() + const { userEvent } = renderWithProviders() await userEvent.click(screen.getByRole('button', { name: /continue/i })) await userEvent.type(await screen.findByLabelText('Db name'), 'production') @@ -250,78 +286,42 @@ describe('BlueprintCreationFlow', () => { expect(await screen.findByRole('checkbox', { name: 'Skip final snapshot' })).toHaveFocus() }) - it('should open the requested configuration section from route search', async () => { - mockUseSearch.mockReturnValue({ section: 'overrides' }) - - renderBlueprintFlow() - - expect(await screen.findByText(/Use overrides to customize how your service is built or run/)).toBeInTheDocument() - expect(screen.getByRole('checkbox', { name: 'Skip final snapshot' })).toBeInTheDocument() - }) - - it('should fallback to service information when the route search section is invalid', () => { - mockUseSearch.mockReturnValue({ section: 'unknown-section' }) - - renderBlueprintFlow() - - expect(screen.getByLabelText('Service name')).toBeInTheDocument() - expect(screen.queryByLabelText('Db name')).not.toBeInTheDocument() - expect(screen.queryByText(/Use overrides to customize how your service is built or run/)).not.toBeInTheDocument() - }) - - it('should update the current section when the route search section changes to a valid section', async () => { - mockUseSearch.mockReturnValue({}) - - const { rerender } = renderBlueprintFlow() - - expect(screen.getByLabelText('Service name')).toBeInTheDocument() - - mockUseSearch.mockReturnValue({ section: 'blueprint-setup' }) - rerender( - - - - ) - - expect(await screen.findByLabelText('Db name')).toBeInTheDocument() - expect(screen.queryByLabelText('Service name')).not.toBeInTheDocument() - }) - it('should navigate to the matching configuration section from summary edit buttons', async () => { jest.useFakeTimers() const { userEvent } = renderBlueprintFlow( - - - + + + + + ) await screen.findByText(/custom-postgres/) await userEvent.click(screen.getByRole('button', { name: 'Edit service information' })) expect(mockNavigate).toHaveBeenLastCalledWith({ - to: '/organization/org-1/project/proj-1/environment/env-1/service/create/blueprint/AWS/postgres', - search: { section: 'service-information' }, + to: '/organization/org-1/project/proj-1/environment/env-1/service/create/blueprint/AWS/postgres/service-information', }) await userEvent.click(screen.getByRole('button', { name: 'Edit blueprint setup' })) expect(mockNavigate).toHaveBeenLastCalledWith({ - to: '/organization/org-1/project/proj-1/environment/env-1/service/create/blueprint/AWS/postgres', - search: { section: 'blueprint-setup' }, + to: '/organization/org-1/project/proj-1/environment/env-1/service/create/blueprint/AWS/postgres/blueprint-setup', }) await userEvent.click(screen.getByRole('button', { name: 'Edit overrides' })) expect(mockNavigate).toHaveBeenLastCalledWith({ - to: '/organization/org-1/project/proj-1/environment/env-1/service/create/blueprint/AWS/postgres', - search: { section: 'overrides' }, + to: '/organization/org-1/project/proj-1/environment/env-1/service/create/blueprint/AWS/postgres/overrides', }) }) it('should render the summary with the previously filled blueprint values', async () => { renderBlueprintFlow( - - - + + + + + ) expect(screen.getByText('Ready to create your blueprint service')).toBeInTheDocument() @@ -351,11 +351,15 @@ describe('BlueprintCreationFlow', () => { }) it('should redirect to configuration when summary is opened without required blueprint values', async () => { - renderBlueprintFlow() + renderBlueprintFlow( + + + + ) await waitFor(() => { expect(mockNavigate).toHaveBeenCalledWith({ - to: '/organization/org-1/project/proj-1/environment/env-1/service/create/blueprint/AWS/postgres', + to: '/organization/org-1/project/proj-1/environment/env-1/service/create/blueprint/AWS/postgres/blueprint-setup', }) }) }) @@ -364,9 +368,11 @@ describe('BlueprintCreationFlow', () => { jest.useFakeTimers() const { userEvent } = renderBlueprintFlow( - - - + + + + + ) await screen.findByText(/custom-postgres/) @@ -421,25 +427,18 @@ describe('BlueprintCreationFlow', () => { const { userEvent } = renderWithProviders() expect(screen.getByText('17')).toBeInTheDocument() - await waitFor(() => { - expect(mockUseBlueprintCatalogServiceManifest).toHaveBeenLastCalledWith( - expect.objectContaining({ - serviceVersion: '17', - }) - ) - }) + expect(mockUseBlueprintCatalogServiceManifest).not.toHaveBeenCalled() await selectEvent.select(screen.getByLabelText('Blueprint version'), '16') - - await waitFor(() => { - expect(mockUseBlueprintCatalogServiceManifest).toHaveBeenLastCalledWith( - expect.objectContaining({ - serviceVersion: '16', - }) - ) - }) + expect(mockUseBlueprintCatalogServiceManifest).not.toHaveBeenCalled() await userEvent.click(screen.getByRole('button', { name: /continue/i })) + expect(mockUseBlueprintCatalogServiceManifest).toHaveBeenCalledWith( + expect.objectContaining({ + serviceVersion: '16', + suspense: true, + }) + ) await userEvent.type(await screen.findByLabelText('Db name'), 'production') await userEvent.type(screen.getByLabelText('Db username'), 'postgres') await userEvent.type(screen.getByLabelText('Db password'), 'super-secret') @@ -473,9 +472,11 @@ describe('BlueprintCreationFlow', () => { ) const { userEvent } = renderBlueprintFlow( - - - + + + + + ) await screen.findByText(/custom-postgres/) @@ -559,9 +560,11 @@ describe('BlueprintCreationFlow', () => { ) const { userEvent } = renderBlueprintFlow( - - - + + + + + ) await screen.findByText(/custom-postgres/) diff --git a/libs/domains/services/feature/src/lib/service-creation-flow/blueprint/blueprint-creation-flow.tsx b/libs/domains/services/feature/src/lib/service-creation-flow/blueprint/blueprint-creation-flow.tsx index bba650f4f25..a234cdaf065 100644 --- a/libs/domains/services/feature/src/lib/service-creation-flow/blueprint/blueprint-creation-flow.tsx +++ b/libs/domains/services/feature/src/lib/service-creation-flow/blueprint/blueprint-creation-flow.tsx @@ -1,22 +1,14 @@ import { useParams } from '@tanstack/react-router' -import { useEffect, useMemo, useRef, useState } from 'react' +import { useMemo, useState } from 'react' import { FormProvider, useForm } from 'react-hook-form' import { FunnelFlow } from '@qovery/shared/ui' import { BlueprintDetailsPanel } from '../../blueprint-details-panel/blueprint-details-panel' -import { useBlueprintCatalogServiceManifest } from '../../hooks/use-blueprint-catalog-service-manifest/use-blueprint-catalog-service-manifest' import { BlueprintCreateContext, type BlueprintCreateFormData, type BlueprintCreationFlowProps, } from './blueprint-create-context/blueprint-create-context' -import { - getDefaultContextFieldValue, - getDefaultFieldValue, - isOptionalVariableField, - isOverridableContextVariableField, - isRequiredVariableField, - sortBlueprintMajorVersions, -} from './blueprint-creation-utils/blueprint-creation-utils' +import { sortBlueprintMajorVersions } from './blueprint-creation-utils/blueprint-creation-utils' export { BlueprintCreateContext, @@ -25,7 +17,16 @@ export { type BlueprintCreationFlowProps, useBlueprintCreateContext, } from './blueprint-create-context/blueprint-create-context' -export { BlueprintConfigurationView } from './blueprint-configuration-view/blueprint-configuration-view' +export { + BlueprintConfigurationView, + BlueprintOverridesConfigurationSection, + BlueprintServiceInformationSection, + BlueprintSetupSection, +} from './blueprint-configuration-view/blueprint-configuration-view' +export { + BlueprintManifestFieldsProvider, + useBlueprintManifestFields, +} from './blueprint-manifest-context/blueprint-manifest-context' export { BlueprintStepSummary } from './blueprint-step-summary/blueprint-step-summary' export const blueprintCreationSteps: { title: string }[] = [{ title: 'Configuration' }, { title: 'Summary' }] @@ -38,12 +39,20 @@ export function BlueprintCreationFlow({ blueprint, children, onExit }: Blueprint [blueprint.majorVersions] ) const defaultBlueprintVersion = sortedBlueprintVersions[0] - const [selectedVersionTag, setSelectedVersionTag] = useState(defaultBlueprintVersion?.latestTag ?? '') + const defaultVersionTag = defaultBlueprintVersion?.latestTag ?? '' + const form = useForm({ + defaultValues: { + serviceName: blueprint.name, + versionTag: defaultVersionTag, + fields: {}, + }, + mode: 'onChange', + }) + const selectedVersionTag = form.watch('versionTag') const selectedBlueprintVersion = sortedBlueprintVersions.find((majorVersion) => majorVersion.latestTag === selectedVersionTag) ?? defaultBlueprintVersion const serviceVersion = selectedBlueprintVersion?.serviceVersion ?? 'latest' - const versionTag = selectedBlueprintVersion?.latestTag ?? selectedVersionTag const serviceFamily = blueprint.serviceFamily ?? '' const { environmentId = '', @@ -52,65 +61,6 @@ export function BlueprintCreationFlow({ blueprint, children, onExit }: Blueprint provider = blueprint.provider, } = useParams({ strict: false }) const creationFlowUrl = `/organization/${organizationId}/project/${projectId}/environment/${environmentId}/service/create/blueprint/${encodeURIComponent(provider)}/${encodeURIComponent(serviceFamily)}` - const { data: blueprintManifestFields = [] } = useBlueprintCatalogServiceManifest({ - organizationId, - provider: blueprint.provider, - serviceFamily, - serviceVersion, - environmentId, - enabled: Boolean(serviceVersion) && Boolean(serviceFamily) && Boolean(environmentId), - suspense: true, - }) - - const requiredBlueprintFields = useMemo( - () => blueprintManifestFields.filter(isRequiredVariableField), - [blueprintManifestFields] - ) - const optionalBlueprintFields = useMemo( - () => blueprintManifestFields.filter(isOptionalVariableField), - [blueprintManifestFields] - ) - const overridableContextBlueprintFields = useMemo( - () => blueprintManifestFields.filter(isOverridableContextVariableField), - [blueprintManifestFields] - ) - const defaultBlueprintFieldValues = useMemo(() => { - const fieldsWithDefaultValue = [...requiredBlueprintFields, ...optionalBlueprintFields] - - return { - ...Object.fromEntries(fieldsWithDefaultValue.map((field) => [field.name, getDefaultFieldValue(field)])), - ...Object.fromEntries( - overridableContextBlueprintFields.map((field) => [field.name, getDefaultContextFieldValue(field)]) - ), - } - }, [optionalBlueprintFields, overridableContextBlueprintFields, requiredBlueprintFields]) - const form = useForm({ - defaultValues: { - serviceName: blueprint.name, - versionTag, - fields: defaultBlueprintFieldValues, - }, - mode: 'onChange', - }) - const watchedVersionTag = form.watch('versionTag') - const previousVersionTagRef = useRef(versionTag) - - useEffect(() => { - setSelectedVersionTag(watchedVersionTag) - }, [watchedVersionTag]) - - useEffect(() => { - if (previousVersionTagRef.current === versionTag) { - return - } - - previousVersionTagRef.current = versionTag - form.reset({ - ...form.getValues(), - versionTag, - fields: defaultBlueprintFieldValues, - }) - }, [defaultBlueprintFieldValues, form, versionTag]) return ( setSelectedBlueprint(blueprint), serviceVersion, - requiredBlueprintFields, - optionalBlueprintFields, - overridableContextBlueprintFields, }} > diff --git a/libs/domains/services/feature/src/lib/service-creation-flow/blueprint/blueprint-creation-utils/blueprint-creation-utils.ts b/libs/domains/services/feature/src/lib/service-creation-flow/blueprint/blueprint-creation-utils/blueprint-creation-utils.ts index e490769c541..234434de8f5 100644 --- a/libs/domains/services/feature/src/lib/service-creation-flow/blueprint/blueprint-creation-utils/blueprint-creation-utils.ts +++ b/libs/domains/services/feature/src/lib/service-creation-flow/blueprint/blueprint-creation-utils/blueprint-creation-utils.ts @@ -41,6 +41,20 @@ export function getDefaultContextFieldValue(field: BlueprintManifestContextVaria 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 : '' } 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 new file mode 100644 index 00000000000..f1de20d906d --- /dev/null +++ b/libs/domains/services/feature/src/lib/service-creation-flow/blueprint/blueprint-manifest-context/blueprint-manifest-context.tsx @@ -0,0 +1,88 @@ +import { useParams } from '@tanstack/react-router' +import { + type BlueprintManifestResponseResultsInner, + 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' + +interface BlueprintManifestContextInterface { + blueprintManifestFields: BlueprintManifestResponseResultsInner[] + requiredBlueprintFields: BlueprintManifestVariableField[] + optionalBlueprintFields: BlueprintManifestVariableField[] + overridableContextBlueprintFields: OverridableBlueprintManifestContextVariableField[] +} + +const BlueprintManifestContext = createContext(undefined) + +export function BlueprintManifestFieldsProvider({ children }: PropsWithChildren) { + const { blueprint, form, organizationId, serviceVersion } = useBlueprintCreateContext() + const { environmentId = '', serviceFamily = blueprint.serviceFamily ?? '' } = useParams({ strict: false }) + const versionTag = form.watch('versionTag') + const { data: blueprintManifestFields = [] } = useBlueprintCatalogServiceManifest({ + organizationId, + provider: blueprint.provider, + serviceFamily, + serviceVersion, + environmentId, + suspense: true, + }) + + const requiredBlueprintFields = useMemo( + () => blueprintManifestFields.filter(isRequiredVariableField), + [blueprintManifestFields] + ) + const optionalBlueprintFields = useMemo( + () => blueprintManifestFields.filter(isOptionalVariableField), + [blueprintManifestFields] + ) + const overridableContextBlueprintFields = useMemo( + () => blueprintManifestFields.filter(isOverridableContextVariableField), + [blueprintManifestFields] + ) + + useEffect(() => { + const formValues = form.getValues() + + if (formValues.loadedVersionTag === versionTag) { + return + } + + form.reset({ + ...formValues, + loadedVersionTag: versionTag, + fields: getDefaultBlueprintFieldValues(blueprintManifestFields), + }) + }, [blueprintManifestFields, form, versionTag]) + + return ( + + {children} + + ) +} + +export function useBlueprintManifestFields() { + const blueprintManifestContext = useContext(BlueprintManifestContext) + + if (!blueprintManifestContext) { + throw new Error('useBlueprintManifestFields must be used within a BlueprintManifestFieldsProvider') + } + + return blueprintManifestContext +} 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 1d2d4cd66f3..197a89d65cc 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 @@ -2,7 +2,6 @@ import { useNavigate, useParams } from '@tanstack/react-router' import posthog from 'posthog-js' import { type BlueprintCreateRequest } from 'qovery-typescript-axios' import { useCallback, useEffect, useRef, useState } from 'react' -import { type ServiceCreateSection } from '@qovery/shared/router' 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' @@ -13,22 +12,18 @@ import { getSummaryFieldValue, isFieldValid, } from '../blueprint-creation-utils/blueprint-creation-utils' +import { useBlueprintManifestFields } from '../blueprint-manifest-context/blueprint-manifest-context' + +type BlueprintConfigurationSection = 'service-information' | 'blueprint-setup' | 'overrides' const BLUEPRINT_SERVICE_CREATED_FALLBACK_TIMEOUT_MS = 30_000 export function BlueprintStepSummary() { const navigate = useNavigate() const { organizationId = '', projectId = '', environmentId = '' } = useParams({ strict: false }) - const { - blueprint, - creationFlowUrl, - form, - optionalBlueprintFields, - overridableContextBlueprintFields, - requiredBlueprintFields, - serviceVersion, - setCurrentStep, - } = useBlueprintCreateContext() + const { blueprint, creationFlowUrl, form, serviceVersion, setCurrentStep } = useBlueprintCreateContext() + const { optionalBlueprintFields, overridableContextBlueprintFields, requiredBlueprintFields } = + useBlueprintManifestFields() const [submitMode, setSubmitMode] = useState<'create' | 'create-and-deploy' | null>(null) const [isWaitingForServiceCreated, setIsWaitingForServiceCreated] = useState(false) const [pendingBlueprintCreation, setPendingBlueprintCreation] = useState<{ @@ -45,8 +40,8 @@ export function BlueprintStepSummary() { const blueprintFields = [...variableFields, ...overridableContextBlueprintFields] const isBlueprintSetupValid = requiredBlueprintFields.every((field) => isFieldValid(field, fields[field.name])) - const handleEditSection = (section: ServiceCreateSection) => { - navigate({ to: creationFlowUrl, search: { section } }) + const handleEditSection = (section: BlueprintConfigurationSection) => { + navigate({ to: `${creationFlowUrl}/${section}` }) } const navigateToEnvironmentOverview = useCallback(() => { @@ -109,7 +104,7 @@ export function BlueprintStepSummary() { useEffect(() => { if (!serviceName.trim() || !isBlueprintSetupValid) { - navigate({ to: creationFlowUrl }) + navigate({ to: `${creationFlowUrl}/blueprint-setup` }) return } }, [creationFlowUrl, isBlueprintSetupValid, navigate, serviceName]) @@ -283,7 +278,12 @@ export function BlueprintStepSummary() {
-
diff --git a/libs/shared/router/src/lib/router-types/service-container-create-params.ts b/libs/shared/router/src/lib/router-types/service-container-create-params.ts index 941af759c58..1d31693c9b9 100644 --- a/libs/shared/router/src/lib/router-types/service-container-create-params.ts +++ b/libs/shared/router/src/lib/router-types/service-container-create-params.ts @@ -1,16 +1,11 @@ import { z } from 'zod' const serviceCreateSourceEnum = z.enum(['application', 'container']) -const serviceCreateSectionEnum = z.enum(['service-information', 'blueprint-setup', 'overrides']) - -export type ServiceCreateSection = z.infer export const serviceCreateParamsSchema = z.object({ source: serviceCreateSourceEnum.optional(), template: z.string().optional(), option: z.string().optional(), - // Used by blueprint service creation to reopen a specific configuration section from the summary. - section: serviceCreateSectionEnum.optional(), }) export type ServiceCreateParams = z.infer From d2f8b9872a96b8199cdd352fbb394ef9266c6420 Mon Sep 17 00:00:00 2001 From: Romain Billard Date: Fri, 3 Jul 2026 14:29:57 +0200 Subject: [PATCH 6/9] Skeleton is not longer displayed when clicking on "Continue" --- .../use-blueprint-catalog-service-manifest.ts | 29 ++++++++++++++++++- .../blueprint-configuration-view.tsx | 26 +++++++++++++---- .../blueprint-creation-flow.spec.tsx | 20 +++++++++++++ 3 files changed, 69 insertions(+), 6 deletions(-) diff --git a/libs/domains/services/feature/src/lib/hooks/use-blueprint-catalog-service-manifest/use-blueprint-catalog-service-manifest.ts b/libs/domains/services/feature/src/lib/hooks/use-blueprint-catalog-service-manifest/use-blueprint-catalog-service-manifest.ts index 1e80f409152..18aa7c64a28 100644 --- a/libs/domains/services/feature/src/lib/hooks/use-blueprint-catalog-service-manifest/use-blueprint-catalog-service-manifest.ts +++ b/libs/domains/services/feature/src/lib/hooks/use-blueprint-catalog-service-manifest/use-blueprint-catalog-service-manifest.ts @@ -1,4 +1,5 @@ -import { useQuery } from '@tanstack/react-query' +import { useCallback } from 'react' +import { useQuery, useQueryClient } from '@tanstack/react-query' import { queries } from '@qovery/state/util-queries' export interface UseBlueprintCatalogServiceManifestProps { @@ -39,4 +40,30 @@ export function useBlueprintCatalogServiceManifest({ }) } +export function usePrefetchBlueprintCatalogServiceManifest({ + organizationId, + provider, + serviceFamily, + serviceVersion, + environmentId = '', +}: UseBlueprintCatalogServiceManifestProps) { + const queryClient = useQueryClient() + + return useCallback(async () => { + if (!organizationId || !provider || !serviceFamily || !serviceVersion || !environmentId) { + return + } + + await queryClient.prefetchQuery( + queries.services.blueprintCatalogServiceManifest({ + organizationId, + provider, + serviceFamily, + serviceVersion, + environmentId, + }) + ) + }, [environmentId, organizationId, provider, queryClient, serviceFamily, serviceVersion]) +} + export default useBlueprintCatalogServiceManifest diff --git a/libs/domains/services/feature/src/lib/service-creation-flow/blueprint/blueprint-configuration-view/blueprint-configuration-view.tsx b/libs/domains/services/feature/src/lib/service-creation-flow/blueprint/blueprint-configuration-view/blueprint-configuration-view.tsx index 875f0ccc536..fdea71decc1 100644 --- a/libs/domains/services/feature/src/lib/service-creation-flow/blueprint/blueprint-configuration-view/blueprint-configuration-view.tsx +++ b/libs/domains/services/feature/src/lib/service-creation-flow/blueprint/blueprint-configuration-view/blueprint-configuration-view.tsx @@ -1,7 +1,8 @@ -import { useNavigate } from '@tanstack/react-router' -import { useEffect, useMemo } from 'react' +import { useNavigate, useParams } from '@tanstack/react-router' +import { useEffect, useMemo, useState } from 'react' import { type Value } from '@qovery/shared/interfaces' import { Button, FunnelFlowBody, Icon, InputSelect, InputText } from '@qovery/shared/ui' +import { usePrefetchBlueprintCatalogServiceManifest } from '../../../hooks/use-blueprint-catalog-service-manifest/use-blueprint-catalog-service-manifest' import { useBlueprintCreateContext } from '../blueprint-create-context/blueprint-create-context' import { type BlueprintFieldValue, @@ -103,7 +104,16 @@ interface ServiceInformationSectionContentProps { } function ServiceInformationSectionContent({ onContinue }: ServiceInformationSectionContentProps) { - const { blueprint, form, serviceVersion } = useBlueprintCreateContext() + const { blueprint, form, organizationId, serviceVersion } = useBlueprintCreateContext() + const { environmentId = '', serviceFamily = blueprint.serviceFamily ?? '' } = useParams({ strict: false }) + const prefetchBlueprintManifestFields = usePrefetchBlueprintCatalogServiceManifest({ + organizationId, + provider: blueprint.provider, + serviceFamily, + serviceVersion, + environmentId, + }) + const [isLoadingBlueprintSetup, setIsLoadingBlueprintSetup] = useState(false) const serviceName = form.watch('serviceName') const versionTag = form.watch('versionTag') const isServiceInformationValid = useIsServiceInformationValid() @@ -115,6 +125,11 @@ function ServiceInformationSectionContent({ onContinue }: ServiceInformationSect })), [blueprint.majorVersions] ) + const handleContinue = async () => { + setIsLoadingBlueprintSetup(true) + await prefetchBlueprintManifestFields() + onContinue() + } return ( <> @@ -143,8 +158,9 @@ function ServiceInformationSectionContent({ onContinue }: ServiceInformationSect size="md" color="neutral" className="w-fit" - disabled={!isServiceInformationValid} - onClick={onContinue} + disabled={!isServiceInformationValid || isLoadingBlueprintSetup} + loading={isLoadingBlueprintSetup} + onClick={handleContinue} > Continue 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 657e6d25d76..2f579e89c28 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 @@ -16,6 +16,7 @@ import { const mockNavigate = jest.fn() const mockUseBlueprintCatalogServiceManifest = jest.fn() +const mockPrefetchBlueprintManifestFields = jest.fn() const mockUseBlueprintCatalogServiceReadme = jest.fn() const mockUseBlueprintServiceCreatedSocket = jest.fn() const mockCreateBlueprint = jest.fn() @@ -45,6 +46,7 @@ jest.mock('@qovery/shared/ui', () => { jest.mock('../../hooks/use-blueprint-catalog-service-manifest/use-blueprint-catalog-service-manifest', () => ({ useBlueprintCatalogServiceManifest: (props: unknown) => mockUseBlueprintCatalogServiceManifest(props), + usePrefetchBlueprintCatalogServiceManifest: () => mockPrefetchBlueprintManifestFields, })) jest.mock('../../hooks/use-blueprint-catalog-service-readme/use-blueprint-catalog-service-readme', () => ({ @@ -209,6 +211,7 @@ describe('BlueprintCreationFlow', () => { mockUseBlueprintCatalogServiceManifest.mockReturnValue({ data: manifestFields, }) + mockPrefetchBlueprintManifestFields.mockResolvedValue(undefined) mockUseBlueprintCatalogServiceReadme.mockReturnValue({ data: { content: '# AWS RDS PostgreSQL\n\nBlueprint documentation', @@ -262,6 +265,23 @@ describe('BlueprintCreationFlow', () => { expect(await screen.findByLabelText('Db name')).toHaveFocus() }) + it('should prefetch blueprint setup fields before navigating from service information', async () => { + mockPrefetchBlueprintManifestFields.mockImplementation(async () => { + expect(mockNavigate).not.toHaveBeenCalledWith({ + to: '/organization/org-1/project/proj-1/environment/env-1/service/create/blueprint/AWS/postgres/blueprint-setup', + }) + }) + + const { userEvent } = renderWithProviders() + + await userEvent.click(screen.getByRole('button', { name: /continue/i })) + + expect(mockPrefetchBlueprintManifestFields).toHaveBeenCalled() + expect(mockNavigate).toHaveBeenCalledWith({ + to: '/organization/org-1/project/proj-1/environment/env-1/service/create/blueprint/AWS/postgres/blueprint-setup', + }) + }) + it('should not load manifest fields from the service information route', () => { renderWithProviders( From 1b54e45c72d0e20bf23d060769727d80d72ff0e5 Mon Sep 17 00:00:00 2001 From: Romain Billard Date: Fri, 3 Jul 2026 15:09:43 +0200 Subject: [PATCH 7/9] Improve the creation flow by making the "overrides" section optional --- .../blueprint-configuration-view.tsx | 37 ++++++---------- .../blueprint-creation-flow.spec.tsx | 43 ++++++++++++++++--- 2 files changed, 49 insertions(+), 31 deletions(-) diff --git a/libs/domains/services/feature/src/lib/service-creation-flow/blueprint/blueprint-configuration-view/blueprint-configuration-view.tsx b/libs/domains/services/feature/src/lib/service-creation-flow/blueprint/blueprint-configuration-view/blueprint-configuration-view.tsx index fdea71decc1..d3cc7b1348c 100644 --- a/libs/domains/services/feature/src/lib/service-creation-flow/blueprint/blueprint-configuration-view/blueprint-configuration-view.tsx +++ b/libs/domains/services/feature/src/lib/service-creation-flow/blueprint/blueprint-configuration-view/blueprint-configuration-view.tsx @@ -82,9 +82,7 @@ export function BlueprintConfigurationView({ currentSection }: BlueprintConfigur title="Blueprint setup" onClick={() => navigateToSection('blueprint-setup')} > - {currentSection === 'blueprint-setup' && ( - navigateToSection('overrides')} /> - )} + {currentSection === 'blueprint-setup' && } {currentSection === 'service-information' ? ( @@ -94,7 +92,11 @@ export function BlueprintConfigurationView({ currentSection }: BlueprintConfigur )}
- {currentSection === 'overrides' ? : } + {currentSection === 'service-information' ? ( + + ) : ( + + )} ) } @@ -127,7 +129,7 @@ function ServiceInformationSectionContent({ onContinue }: ServiceInformationSect ) const handleContinue = async () => { setIsLoadingBlueprintSetup(true) - await prefetchBlueprintManifestFields() + await prefetchBlueprintManifestFields().catch(() => undefined) onContinue() } @@ -169,15 +171,10 @@ function ServiceInformationSectionContent({ onContinue }: ServiceInformationSect ) } -interface BlueprintSetupSectionContentProps { - onContinue: () => void -} - -function BlueprintSetupSectionContent({ onContinue }: BlueprintSetupSectionContentProps) { +function BlueprintSetupSectionContent() { const { form } = useBlueprintCreateContext() const { requiredBlueprintFields } = useBlueprintManifestFields() const blueprintFieldValues = form.watch('fields') - const isBlueprintSetupValid = useIsBlueprintSetupValid() const updateFieldValue = (name: string, value: BlueprintFieldValue) => { form.setValue(getBlueprintFieldPath(name), value, { shouldDirty: true, shouldValidate: true }) } @@ -194,17 +191,6 @@ function BlueprintSetupSectionContent({ onContinue }: BlueprintSetupSectionConte onChange={(value) => updateFieldValue(field.name, value)} /> ))} - ) } @@ -261,9 +247,10 @@ function OverridesSectionContent() { ) } -function ConfirmConfigurationFooter() { +function ConfirmBlueprintCreationFooter() { const navigate = useNavigate() const { creationFlowUrl } = useBlueprintCreateContext() + const isServiceInformationValid = useIsServiceInformationValid() const isBlueprintSetupValid = useIsBlueprintSetupValid() return ( @@ -272,7 +259,7 @@ function ConfirmConfigurationFooter() { type="button" size="lg" className="w-full justify-center" - disabled={!isBlueprintSetupValid} + disabled={!isServiceInformationValid || !isBlueprintSetupValid} onClick={() => navigate({ to: `${creationFlowUrl}/summary` })} > Confirm blueprint configuration @@ -282,7 +269,7 @@ function ConfirmConfigurationFooter() { ) } -function DisabledConfirmConfigurationFooter() { +function DisabledConfirmBlueprintCreationFooter() { return (