From 0fce7f0089f052d6dbb2915fbb86b026f09dcc3e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?R=C3=A9mi=20Bonnet?= Date: Tue, 21 Jul 2026 15:24:53 +0200 Subject: [PATCH 1/3] chore(deps): bump axios to 1.18.1 and update qovery-typescript-axios to 1.1.932 (#2829) * chore(deps): update qovery axios client * fix(ci): expose distributed test failures * fix(ci): terminate leaking jest workers --- .github/workflows/test-build-and-deploy.yml | 5 ++-- package.json | 4 +-- yarn.lock | 29 ++++++++++----------- 3 files changed, 19 insertions(+), 19 deletions(-) diff --git a/.github/workflows/test-build-and-deploy.yml b/.github/workflows/test-build-and-deploy.yml index 3ae1e975393..6991fdac216 100644 --- a/.github/workflows/test-build-and-deploy.yml +++ b/.github/workflows/test-build-and-deploy.yml @@ -133,7 +133,7 @@ jobs: yarn nx start-ci-run \ --distribute-on="3 console-ci" \ --require-explicit-completion \ - --stop-agents-on-failure=false \ + --stop-agents-on-failure=true \ --with-env-vars="NX_PUBLIC_GIT_SHA,NX_PUBLIC_GTM,NX_PUBLIC_INTERCOM,NX_PUBLIC_OAUTH_AUDIENCE,NX_PUBLIC_OAUTH_DOMAIN,NX_PUBLIC_OAUTH_KEY,NX_PUBLIC_POSTHOG,NX_PUBLIC_POSTHOG_APIHOST,NX_PUBLIC_QOVERY_API,NX_PUBLIC_QOVERY_WS,NX_PUBLIC_CHARGEBEE_PUBLISHABLE_KEY,NX_PUBLIC_INSTATUS_APP_ID,NX_PUBLIC_MINTLIFY_API_KEY,NX_PUBLIC_CARGO_API_TOKEN,NX_PUBLIC_PYLON_APP_ID,NX_PUBLIC_DEVOPS_COPILOT_API_BASE_URL" - name: Check formatting @@ -157,7 +157,8 @@ jobs: --ci \ --coverage \ --coverageReporters=lcov \ - --silent + --maxWorkers=2 \ + --forceExit yarn nx run console:build \ --parallel=3 \ diff --git a/package.json b/package.json index de3d8048b56..d1827526e0e 100644 --- a/package.json +++ b/package.json @@ -58,7 +58,7 @@ "@xterm/xterm": "5.5.0", "ansi-to-react": "6.1.6", "autoprefixer": "10.4.13", - "axios": "1.15.2", + "axios": "1.18.1", "class-variance-authority": "0.7.0", "clsx": "2.1.1", "cmdk": "1.1.1", @@ -77,7 +77,7 @@ "mermaid": "11.6.0", "monaco-editor": "0.53.0", "posthog-js": "1.345.1", - "qovery-typescript-axios": "1.1.920", + "qovery-typescript-axios": "1.1.932", "react": "18.3.1", "react-country-flag": "3.0.2", "react-datepicker": "4.12.0", diff --git a/yarn.lock b/yarn.lock index 1a6101f9168..1c0c6960561 100644 --- a/yarn.lock +++ b/yarn.lock @@ -6296,7 +6296,7 @@ __metadata: "@xterm/xterm": 5.5.0 ansi-to-react: 6.1.6 autoprefixer: 10.4.13 - axios: 1.15.2 + axios: 1.18.1 babel-jest: 30.0.5 babel-loader: 8.3.0 chance: 1.1.10 @@ -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.920 + qovery-typescript-axios: 1.1.932 qovery-ws-typescript-axios: 0.1.621 react: 18.3.1 react-country-flag: 3.0.2 @@ -12108,14 +12108,14 @@ __metadata: languageName: node linkType: hard -"axios@npm:1.15.2": - version: 1.15.2 - resolution: "axios@npm:1.15.2" +"axios@npm:1.18.1": + version: 1.18.1 + resolution: "axios@npm:1.18.1" dependencies: - follow-redirects: ^1.15.11 + follow-redirects: ^1.16.0 form-data: ^4.0.5 + https-proxy-agent: ^5.0.1 proxy-from-env: ^2.1.0 - checksum: e7d208b751959c7c6936417b870d6286979e0dff17784ae230d3988e754b6322682cb136e25669b534072b6f44c08715801ab961227eb8cc075323d9fda8ad43 languageName: node linkType: hard @@ -17256,7 +17256,7 @@ __metadata: languageName: node linkType: hard -"follow-redirects@npm:^1.15.11": +"follow-redirects@npm:^1.16.0": version: 1.16.0 resolution: "follow-redirects@npm:1.16.0" peerDependenciesMeta: @@ -18522,7 +18522,7 @@ __metadata: languageName: node linkType: hard -"https-proxy-agent@npm:^5.0.0": +"https-proxy-agent@npm:^5.0.0, https-proxy-agent@npm:^5.0.1": version: 5.0.1 resolution: "https-proxy-agent@npm:5.0.1" dependencies: @@ -25862,12 +25862,11 @@ __metadata: languageName: node linkType: hard -"qovery-typescript-axios@npm:1.1.920": - version: 1.1.920 - resolution: "qovery-typescript-axios@npm:1.1.920" +"qovery-typescript-axios@npm:1.1.932": + version: 1.1.932 + resolution: "qovery-typescript-axios@npm:1.1.932" dependencies: - axios: 1.15.2 - checksum: ea8634a4c39cffb4127fd69ff1e9f59948db6ab35ddce30afd8a32312ae5a2394e88196b6ab25f47bf5a8d1801c839d5b63e8d7ca708da1da77eb9bdf6fe0e68 + axios: 1.18.1 languageName: node linkType: hard @@ -25875,7 +25874,7 @@ __metadata: version: 0.1.621 resolution: "qovery-ws-typescript-axios@npm:0.1.621" dependencies: - axios: 1.15.2 + axios: 1.18.1 checksum: c688dacc49f8adf2986f412a9da5c66d366324e3676a3790b925198bfc06d3edb1595cb09e609caa460c67a19ccac47bf84244f22792260f1f56f4fe8221bd90 languageName: node linkType: hard From 286393ea84c1c7844f769ed485d01a39b4f61b5d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Th=C3=A9o=20Grandin?= Date: Tue, 21 Jul 2026 16:23:18 +0200 Subject: [PATCH 2/3] feat(clusters): add clusters list to breadcrumb navigation (#2831) * feat(clusters): add overview back link * docs(clusters): add back link screenshots * chore(clusters): remove generated preview assets * feat(clusters): add cluster list breadcrumb --- .../app/components/header/breadcrumbs/breadcrumbs.tsx | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/apps/console/src/app/components/header/breadcrumbs/breadcrumbs.tsx b/apps/console/src/app/components/header/breadcrumbs/breadcrumbs.tsx index 16bc14dff60..0611d6fd9d5 100644 --- a/apps/console/src/app/components/header/breadcrumbs/breadcrumbs.tsx +++ b/apps/console/src/app/components/header/breadcrumbs/breadcrumbs.tsx @@ -146,6 +146,17 @@ export function Breadcrumbs() { } if (currentCluster) { + breadcrumbData.push({ + item: { + id: 'clusters', + label: 'Clusters', + path: buildLocation({ + to: '/organization/$organizationId/clusters', + params: { organizationId }, + }).href, + }, + items: [], + }) breadcrumbData.push({ item: { ...currentCluster, From fe2ee4746d84be95ce9a6627a1e06b35e89ab859 Mon Sep 17 00:00:00 2001 From: Romain Billard Date: Thu, 23 Jul 2026 13:27:14 +0200 Subject: [PATCH 3/3] feat: Service Catalog feedback and improvements (#2824) * Rename "Blueprint version" field to "Version" * Blueprint names are now correctly formatted * Filter out incompatible blueprints * Update margin * Add "Configure" button in "Overrides" section header * Remove double titles and fix heading custom sizes for markdown render area * Fix border issues * Add blueprint update badge to version cell * Update wording of the blueprint update modal * UI fixes * Fix overflow issue on preview step of the bueprint update flow * Remove legacy sections when the service catalog FF is disabled * Revamp UI of the "Create new service" page * Fix lint issues * Rename legacy "database" to "container database" * Fix failing unit tests * Fix failing unit test * UI fixes * Update the blueprint update modal's title * Improve service header for blueprint-based services * Prevent users from editing the source of a blueprint-based service * Add min and max validation for number inputs * Add useDeployBlueprint and its associated deployBlueprint mutation * Fix lint issues * Update failing unit tests * Fix double /preview API call * Fix lint issues * Fix failing unit test * Fix typing for serviceType * Use Section and Heading components * Use PropsWithChildren * Use the right environmentId variable * Move isBlueprintService helper to services' data-access lib * Use Heading component * Better retrieve strategy for environmentId and serviceId * Add unit tests for BlueprintUpdateBadge component * Move some files to new service-update-flow folder * Fix lint issues --- .../$environmentId/service/new.tsx | 42 ++-- .../edit-git-repository-settings.tsx | 4 +- .../terraform-general-settings.spec.tsx | 23 +- .../terraform-general-settings.tsx | 1 + .../src/lib/domains-services-data-access.ts | 16 ++ libs/domains/services/feature/src/index.ts | 2 +- .../blueprint-details-panel.spec.tsx | 1 + .../blueprint-details-panel.tsx | 44 ++-- .../blueprint-field-utils.spec.ts | 21 ++ .../blueprint-field-utils.ts | 26 +++ .../blueprint-utils/blueprint-utils.spec.ts | 22 ++ .../lib/blueprint-utils/blueprint-utils.ts | 34 +++ .../use-deploy-blueprint.ts | 34 +++ .../use-update-blueprint.ts | 7 +- .../blueprint-update-badge.spec.tsx | 89 ++++++++ .../blueprint-update-badge.tsx | 73 +++++++ .../blueprint-update-context.tsx | 4 +- .../blueprint-update-flow-shell.spec.tsx | 0 .../blueprint-update-flow-shell.tsx | 0 .../blueprint-update-flow.spec.tsx | 127 +++++++++++ .../blueprint-update-flow.tsx | 56 +++-- ...int-update-no-input-confirmation-modal.tsx | 45 ++++ .../blueprint-update-preview-step.spec.tsx | 28 ++- .../blueprint-update-preview-step.tsx | 53 ++++- .../blueprint-update-review-step.tsx | 8 +- .../blueprint-update-utils.spec.ts | 17 ++ .../blueprint-update-utils.ts | 21 +- .../blueprint-configuration-view.tsx | 19 +- .../blueprint-section/blueprint-section.tsx | 66 +++--- .../overrides-section-card.tsx | 11 +- .../blueprint-creation-flow.spec.tsx | 6 +- .../blueprint/blueprint-creation-flow.tsx | 3 +- .../blueprint-step-summary.tsx | 7 +- .../service-version-cell.spec.tsx | 87 ++++++++ .../service-version-cell.tsx | 87 +++++++- .../src/lib/service-list/service-list.tsx | 4 +- .../blueprint-card/blueprint-card.tsx | 5 +- .../service-new/service-card/service-card.tsx | 42 +++- .../src/lib/service-new/service-new.spec.tsx | 105 ++++++++- .../src/lib/service-new/service-new.tsx | 194 +++++------------ .../service-header/service-header.spec.tsx | 46 +++- .../service-header/service-header.tsx | 203 +++++++----------- .../funnel-flow-body/funnel-flow-body.tsx | 4 +- package.json | 2 +- yarn.lock | 10 +- 45 files changed, 1272 insertions(+), 427 deletions(-) create mode 100644 libs/domains/services/feature/src/lib/blueprint-utils/blueprint-utils.spec.ts create mode 100644 libs/domains/services/feature/src/lib/blueprint-utils/blueprint-utils.ts create mode 100644 libs/domains/services/feature/src/lib/hooks/use-deploy-blueprint/use-deploy-blueprint.ts create mode 100644 libs/domains/services/feature/src/lib/service-blueprint-update-flow/blueprint-update-badge.spec.tsx create mode 100644 libs/domains/services/feature/src/lib/service-blueprint-update-flow/blueprint-update-badge.tsx rename libs/domains/services/feature/src/lib/{service-update-flow/blueprint => service-blueprint-update-flow}/blueprint-update-context.tsx (94%) rename libs/domains/services/feature/src/lib/{service-update-flow/blueprint => service-blueprint-update-flow}/blueprint-update-flow-shell.spec.tsx (100%) rename libs/domains/services/feature/src/lib/{service-update-flow/blueprint => service-blueprint-update-flow}/blueprint-update-flow-shell.tsx (100%) create mode 100644 libs/domains/services/feature/src/lib/service-blueprint-update-flow/blueprint-update-flow.spec.tsx rename libs/domains/services/feature/src/lib/{service-update-flow/blueprint => service-blueprint-update-flow}/blueprint-update-flow.tsx (79%) create mode 100644 libs/domains/services/feature/src/lib/service-blueprint-update-flow/blueprint-update-no-input-confirmation-modal.tsx rename libs/domains/services/feature/src/lib/{service-update-flow/blueprint => service-blueprint-update-flow}/blueprint-update-preview-step.spec.tsx (77%) rename libs/domains/services/feature/src/lib/{service-update-flow/blueprint => service-blueprint-update-flow}/blueprint-update-preview-step.tsx (75%) rename libs/domains/services/feature/src/lib/{service-update-flow/blueprint => service-blueprint-update-flow}/blueprint-update-review-step.tsx (98%) rename libs/domains/services/feature/src/lib/{service-update-flow/blueprint => service-blueprint-update-flow}/blueprint-update-utils.spec.ts (88%) rename libs/domains/services/feature/src/lib/{service-update-flow/blueprint => service-blueprint-update-flow}/blueprint-update-utils.ts (92%) create mode 100644 libs/domains/services/feature/src/lib/service-list/service-list-cells/service-version-cell.spec.tsx diff --git a/apps/console/src/routes/_authenticated/organization/$organizationId/project/$projectId/environment/$environmentId/service/new.tsx b/apps/console/src/routes/_authenticated/organization/$organizationId/project/$projectId/environment/$environmentId/service/new.tsx index 55d214fe513..dc20d022e51 100644 --- a/apps/console/src/routes/_authenticated/organization/$organizationId/project/$projectId/environment/$environmentId/service/new.tsx +++ b/apps/console/src/routes/_authenticated/organization/$organizationId/project/$projectId/environment/$environmentId/service/new.tsx @@ -21,28 +21,28 @@ function ServiceNewContent() { return (
- - - Back to services list - -
- Create new service -

- Step into the Qovery service and embrace the power of collaboration to kickstart your next project. -

+
+
+ + + Back to services list + + Create new service +
+
-
) } diff --git a/libs/domains/organizations/feature/src/lib/git-repository-service-settings/edit-git-repository-settings/edit-git-repository-settings.tsx b/libs/domains/organizations/feature/src/lib/git-repository-service-settings/edit-git-repository-settings/edit-git-repository-settings.tsx index 5caf135b8c2..2224c38c51f 100644 --- a/libs/domains/organizations/feature/src/lib/git-repository-service-settings/edit-git-repository-settings/edit-git-repository-settings.tsx +++ b/libs/domains/organizations/feature/src/lib/git-repository-service-settings/edit-git-repository-settings/edit-git-repository-settings.tsx @@ -8,6 +8,7 @@ interface EditGitRepositorySettingsProps { gitRepository?: ApplicationGitRepository rootPathLabel?: string rootPathHint?: string + showEditAction?: boolean } export function EditGitRepositorySettings({ @@ -15,6 +16,7 @@ export function EditGitRepositorySettings({ gitRepository, rootPathHint, rootPathLabel, + showEditAction = true, }: EditGitRepositorySettingsProps) { const { setValue } = useFormContext<{ provider: GitProviderEnum | undefined @@ -52,7 +54,7 @@ export function EditGitRepositorySettings({ return ( ({ - EditGitRepositorySettings: () => null, + EditGitRepositorySettings: (props: unknown) => { + mockEditGitRepositorySettings(props) + return null + }, })) jest.mock('@qovery/domains/services/feature', () => ({ @@ -31,4 +36,20 @@ describe('TerraformGeneralSettings', () => { expect(screen.getByText('Source')).toBeInTheDocument() expect(screen.getByText('Build and deploy')).toBeInTheDocument() }) + + it('hides the source edit action for blueprint services', () => { + renderWithProviders( + wrapWithReactHookForm( + , + { + defaultValues: { + name: service.name, + terraform_action: TerraformAutoDeployConfigTerraformActionEnum.DEFAULT, + }, + } + ) + ) + + expect(mockEditGitRepositorySettings).toHaveBeenLastCalledWith(expect.objectContaining({ showEditAction: false })) + }) }) diff --git a/libs/domains/service-settings/feature/src/lib/service-general-settings/terraform-general-settings/terraform-general-settings.tsx b/libs/domains/service-settings/feature/src/lib/service-general-settings/terraform-general-settings/terraform-general-settings.tsx index 4a48089813b..bb15c301d36 100644 --- a/libs/domains/service-settings/feature/src/lib/service-general-settings/terraform-general-settings/terraform-general-settings.tsx +++ b/libs/domains/service-settings/feature/src/lib/service-general-settings/terraform-general-settings/terraform-general-settings.tsx @@ -33,6 +33,7 @@ export function TerraformGeneralSettings({ service, organization }: TerraformGen gitRepository={service.terraform_files_source?.git?.git_repository} rootPathLabel="Terraform root folder path" rootPathHint="Provide the folder path where the Terraform code is located in the repository." + showEditAction={!service.blueprint_id} /> 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 835ee0ff981..de59538e01e 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 @@ -192,6 +192,10 @@ export type ArgoCd = _ArgoCd & { serviceType: ArgoCdType } export type AnyService = Application | Database | Container | Job | Helm | Terraform | ArgoCd +export type BlueprintService = AnyService & { + blueprint_id: string + tag?: string +} export type ReadOnlyService = ArgoCd export type EditableService = Exclude export type EditableServiceType = Exclude @@ -225,6 +229,10 @@ export function isHelm(service: AnyService): service is Helm { return service.service_type === 'HELM' } +export function isBlueprintService(service: AnyService): service is BlueprintService { + return 'blueprint_id' in service && Boolean(service.blueprint_id) +} + export function isArgoCd(service?: AnyService): service is ArgoCd { return service?.service_type === 'ARGOCD_APP' } @@ -780,6 +788,10 @@ type UpdateBlueprintRequest = { payload: BlueprintUpdateRequest } +type DeployBlueprintRequest = { + blueprintId: string +} + type EditServiceRequest = { serviceId: string payload: @@ -1061,6 +1073,10 @@ export const mutations = { const response = await blueprintApi.updateBlueprint(blueprintId, payload) return response.data }, + async deployBlueprint({ blueprintId }: DeployBlueprintRequest) { + const response = await blueprintApi.deployBlueprint(blueprintId) + 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 c461f4b9f7e..f798adcc252 100644 --- a/libs/domains/services/feature/src/index.ts +++ b/libs/domains/services/feature/src/index.ts @@ -105,7 +105,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/service-blueprint-update-flow/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/blueprint-details-panel/blueprint-details-panel.spec.tsx b/libs/domains/services/feature/src/lib/blueprint-details-panel/blueprint-details-panel.spec.tsx index f19c9f549b1..6902b2195b1 100644 --- a/libs/domains/services/feature/src/lib/blueprint-details-panel/blueprint-details-panel.spec.tsx +++ b/libs/domains/services/feature/src/lib/blueprint-details-panel/blueprint-details-panel.spec.tsx @@ -89,6 +89,7 @@ describe('BlueprintDetailsPanel', () => { expect(within(dialog).getByText('Object storage with server-side encryption.')).toBeInTheDocument() expect(within(dialog).getByText('AWS')).toBeInTheDocument() expect(within(dialog).getByText('v1')).toBeInTheDocument() + expect(within(dialog).getAllByText('AWS S3 Bucket')).toHaveLength(1) expect(dialog).toHaveTextContent('Blueprint documentation') expect(dialog).toHaveTextContent('Versioning') expect(within(dialog).getByRole('link', { name: /qovery-blueprints\/s3/i })).toHaveAttribute( diff --git a/libs/domains/services/feature/src/lib/blueprint-details-panel/blueprint-details-panel.tsx b/libs/domains/services/feature/src/lib/blueprint-details-panel/blueprint-details-panel.tsx index 387a062f3da..6455ecb14e8 100644 --- a/libs/domains/services/feature/src/lib/blueprint-details-panel/blueprint-details-panel.tsx +++ b/libs/domains/services/feature/src/lib/blueprint-details-panel/blueprint-details-panel.tsx @@ -4,9 +4,10 @@ import { type BlueprintItem, type BlueprintReadmeResponse } from 'qovery-typescr import Markdown from 'react-markdown' import remarkGfm from 'remark-gfm' import { formatCloudProvider } from '@qovery/domains/clusters/data-access' -import { Badge, Button, ExternalLink, Icon, Link, Sheet } from '@qovery/shared/ui' +import { Badge, Button, ExternalLink, Heading, Icon, Link, Sheet } from '@qovery/shared/ui' import { twMerge } from '@qovery/shared/util-js' import { BlueprintQueryBoundary } from '../blueprint-query-boundary/blueprint-query-boundary' +import { formatBlueprintName } from '../blueprint-utils/blueprint-utils' import { useBlueprintCatalogServiceReadme } from '../hooks/use-blueprint-catalog-service-readme/use-blueprint-catalog-service-readme' import { ServiceAvatar } from '../service-avatar/service-avatar' @@ -19,24 +20,26 @@ interface BlueprintReadmeContentProps { } function BlueprintReadmeContent({ content }: BlueprintReadmeContentProps) { + const contentWithoutTitle = content.replace(/^#\s+[^\r\n]+(?:\r?\n)*/, '') + return ( ( -

+ {children} -

+ ), h2: ({ node, children, ...props }) => ( -

+ {children} -

+ ), h3: ({ node, children, ...props }) => ( -

+ {children} -

+ ), p: ({ node, ...props }) =>

, a: ({ node, children, ...props }) => ( @@ -64,12 +67,14 @@ function BlueprintReadmeContent({ content }: BlueprintReadmeContentProps) { ), th: ({ node, ...props }) => ( - + + ), + td: ({ node, ...props }) => ( + ), - td: ({ node, ...props }) => , }} > - {content} + {contentWithoutTitle} ) } @@ -156,6 +161,7 @@ function BlueprintDetailsPanelContent({ const canDeploy = footerMode === 'deploy' && Boolean(blueprint.serviceFamily) const provider = formatCloudProvider(blueprint.provider) + const blueprintName = formatBlueprintName(blueprint.name) return ( @@ -171,7 +177,7 @@ function BlueprintDetailsPanelContent({

- {blueprint.name} + {blueprintName} {blueprint.description} @@ -200,14 +206,12 @@ function BlueprintDetailsPanelContent({
-
- - - -
+ + + 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 2d210cfa0d8..b0db8c2c714 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 @@ -11,6 +11,7 @@ import { getDefaultContextFieldValue, getDefaultFieldValue, getFieldLengthValidationError, + getFieldNumberValidationError, getFieldValidationError, getStringFieldValue, getSummaryFieldValue, @@ -166,6 +167,20 @@ describe('field validation', () => { expect(getFieldLengthValidationError(rangeField, false)).toBeUndefined() }) + it('validates numeric minimum and maximum values', () => { + const rangeField = createVariableField({ type: { type: 'number', min: 20, max: 65536 } }) + const minField = createVariableField({ type: { type: 'number', min: 20 } }) + const maxField = createVariableField({ type: { type: 'number', max: 65536 } }) + + expect(getFieldNumberValidationError(rangeField, '19')).toBe('Value must be between 20 and 65536.') + expect(getFieldNumberValidationError(rangeField, '65537')).toBe('Value must be between 20 and 65536.') + expect(getFieldNumberValidationError(rangeField, '20')).toBeUndefined() + expect(getFieldNumberValidationError(rangeField, '65536')).toBeUndefined() + expect(getFieldNumberValidationError(minField, '19')).toBe('Value must be at least 20.') + expect(getFieldNumberValidationError(maxField, '65537')).toBe('Value must be at most 65536.') + expect(getFieldNumberValidationError(rangeField, 'not-a-number')).toBe('Value must be a number.') + }) + it('prioritizes length errors before pattern errors', () => { const field = createVariableField({ type: { type: 'string', min_length: 2, pattern: '^value$' } }) @@ -174,6 +189,12 @@ describe('field validation', () => { expect(getFieldValidationError(field, 'value')).toBeUndefined() }) + it('returns numeric validation errors', () => { + const field = createVariableField({ type: { type: 'number', min: 20, max: 65536 } }) + + expect(getFieldValidationError(field, '19')).toBe('Value must be between 20 and 65536.') + }) + it('requires a fulfilled value only for required fields', () => { const requiredField = createVariableField({ required: true }) const optionalField = createVariableField({ required: false }) 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 index 761bda23046..4ca6a882b9c 100644 --- 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 @@ -90,7 +90,33 @@ export function getFieldLengthValidationError( return undefined } +export function getFieldNumberValidationError( + field: BlueprintManifestVariableField, + value: BlueprintFieldValue | undefined +) { + if (field.type.type !== 'number' || typeof value !== 'string' || !value.trim()) return undefined + + const numberValue = Number(value) + if (!Number.isFinite(numberValue)) return 'Value must be a number.' + + const { min, max } = field.type + const hasMin = typeof min === 'number' + const hasMax = typeof max === 'number' + + if (hasMin && hasMax && (numberValue < min || numberValue > max)) { + return `Value must be between ${min} and ${max}.` + } + + if (hasMin && numberValue < min) return `Value must be at least ${min}.` + if (hasMax && numberValue > max) return `Value must be at most ${max}.` + + return undefined +} + export function getFieldValidationError(field: BlueprintManifestVariableField, value: BlueprintFieldValue | undefined) { + const numberValidationError = getFieldNumberValidationError(field, value) + if (numberValidationError) return numberValidationError + const lengthValidationError = getFieldLengthValidationError(field, value) if (lengthValidationError) return lengthValidationError diff --git a/libs/domains/services/feature/src/lib/blueprint-utils/blueprint-utils.spec.ts b/libs/domains/services/feature/src/lib/blueprint-utils/blueprint-utils.spec.ts new file mode 100644 index 00000000000..74eac8583c5 --- /dev/null +++ b/libs/domains/services/feature/src/lib/blueprint-utils/blueprint-utils.spec.ts @@ -0,0 +1,22 @@ +import { formatBlueprintName, isBlueprintCompatibleWithCluster } from './blueprint-utils' + +describe('formatBlueprintName', () => { + it.each([ + ['aws-rds-mysql', 'AWS RDS MySQL'], + ['scaleway-managed-postgresql', 'Scaleway Managed PostgreSQL'], + ['AWS S3 Bucket', 'AWS S3 Bucket'], + ])('formats %s as %s', (name, expectedName) => { + expect(formatBlueprintName(name)).toBe(expectedName) + }) +}) + +describe('isBlueprintCompatibleWithCluster', () => { + it.each([ + ['AWS', 'AWS', true], + ['SCW', 'AWS', false], + ['HELM', 'AWS', true], + ['SCW', undefined, true], + ])('returns %s for a %s cluster as %s', (blueprintProvider, clusterCloudProvider, expected) => { + expect(isBlueprintCompatibleWithCluster(blueprintProvider, clusterCloudProvider)).toBe(expected) + }) +}) diff --git a/libs/domains/services/feature/src/lib/blueprint-utils/blueprint-utils.ts b/libs/domains/services/feature/src/lib/blueprint-utils/blueprint-utils.ts new file mode 100644 index 00000000000..b97e612ff3b --- /dev/null +++ b/libs/domains/services/feature/src/lib/blueprint-utils/blueprint-utils.ts @@ -0,0 +1,34 @@ +const BLUEPRINT_NAME_PARTS: Record = { + aws: 'AWS', + gcp: 'GCP', + mysql: 'MySQL', + postgresql: 'PostgreSQL', + rabbitmq: 'RabbitMQ', + rds: 'RDS', + s3: 'S3', +} + +const CLUSTER_AGNOSTIC_BLUEPRINT_PROVIDERS = new Set(['HELM']) + +export function formatBlueprintName(name: string): string { + return name + .split(/[-_]/) + .filter(Boolean) + .map((part) => { + const normalizedPart = part.toLowerCase() + + return BLUEPRINT_NAME_PARTS[normalizedPart] ?? `${part.charAt(0).toUpperCase()}${part.slice(1)}` + }) + .join(' ') +} + +export function isBlueprintCompatibleWithCluster(blueprintProvider: string, clusterCloudProvider?: string): boolean { + if (!clusterCloudProvider) return true + + const normalizedBlueprintProvider = blueprintProvider.toUpperCase() + + return ( + CLUSTER_AGNOSTIC_BLUEPRINT_PROVIDERS.has(normalizedBlueprintProvider) || + normalizedBlueprintProvider === clusterCloudProvider.toUpperCase() + ) +} diff --git a/libs/domains/services/feature/src/lib/hooks/use-deploy-blueprint/use-deploy-blueprint.ts b/libs/domains/services/feature/src/lib/hooks/use-deploy-blueprint/use-deploy-blueprint.ts new file mode 100644 index 00000000000..c64a36737ed --- /dev/null +++ b/libs/domains/services/feature/src/lib/hooks/use-deploy-blueprint/use-deploy-blueprint.ts @@ -0,0 +1,34 @@ +import { useMutation, useQueryClient } from '@tanstack/react-query' +import { type AnyService, mutations } from '@qovery/domains/services/data-access' +import { queries } from '@qovery/state/util-queries' + +export function useDeployBlueprint({ + environmentId, + serviceId, + serviceType, +}: { + environmentId: string + serviceId: string + serviceType: AnyService['serviceType'] +}) { + const queryClient = useQueryClient() + + return useMutation(mutations.deployBlueprint, { + onSuccess(_, { blueprintId }) { + 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 useDeployBlueprint 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 index 4cef8e0f902..1c4f8f5828a 100644 --- 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 @@ -14,16 +14,13 @@ export function useUpdateBlueprint({ const queryClient = useQueryClient() return useMutation(mutations.updateBlueprint, { - onSuccess(response, { blueprintId }) { + onSuccess() { queryClient.invalidateQueries({ - queryKey: queries.services.list(response.environment_id).queryKey, + queryKey: queries.services.list(environmentId).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, }) diff --git a/libs/domains/services/feature/src/lib/service-blueprint-update-flow/blueprint-update-badge.spec.tsx b/libs/domains/services/feature/src/lib/service-blueprint-update-flow/blueprint-update-badge.spec.tsx new file mode 100644 index 00000000000..1094a11ebd5 --- /dev/null +++ b/libs/domains/services/feature/src/lib/service-blueprint-update-flow/blueprint-update-badge.spec.tsx @@ -0,0 +1,89 @@ +import { useNavigate } from '@tanstack/react-router' +import { type BlueprintUpdateResponse } from 'qovery-typescript-axios' +import { type AnyService } from '@qovery/domains/services/data-access' +import { renderWithProviders, screen } from '@qovery/shared/util-tests' +import { BlueprintUpdateBadge } from './blueprint-update-badge' + +const mockNavigate = jest.fn() + +jest.mock('@tanstack/react-router', () => ({ + ...jest.requireActual('@tanstack/react-router'), + useNavigate: jest.fn(), +})) + +const service = { + id: 'service-id', + name: 'AWS MySQL', + environment: { id: 'environment-id' }, + service_type: 'TERRAFORM', + serviceType: 'TERRAFORM', +} as AnyService + +const upToDateBlueprintUpdate = { + is_up_to_date: true, + current_tag: 'AWS/mysql/8/1.2.3', + latest_tag: 'AWS/mysql/8/1.2.3', + new_required_values: [], + new_optional_values: [], + now_required_values: [], + updated_values: [], + removed_values: [], + engine_diff: { updated_values: [] }, +} satisfies BlueprintUpdateResponse + +describe('BlueprintUpdateBadge', () => { + beforeEach(() => { + jest.clearAllMocks() + jest.mocked(useNavigate).mockReturnValue(mockNavigate) + }) + + it('renders the up-to-date state', () => { + renderWithProviders( + + ) + + expect(screen.getByText('Up to date')).toBeInTheDocument() + }) + + it('navigates to the review flow using the service identifiers', async () => { + const { userEvent } = renderWithProviders( + + ) + + await userEvent.click(screen.getByRole('button', { name: 'Update available' })) + + expect(mockNavigate).toHaveBeenCalledWith({ + to: '/organization/$organizationId/project/$projectId/environment/$environmentId/service/$serviceId/update/blueprint', + params: { + organizationId: 'organization-id', + projectId: 'project-id', + environmentId: 'environment-id', + serviceId: 'service-id', + }, + }) + }) +}) diff --git a/libs/domains/services/feature/src/lib/service-blueprint-update-flow/blueprint-update-badge.tsx b/libs/domains/services/feature/src/lib/service-blueprint-update-flow/blueprint-update-badge.tsx new file mode 100644 index 00000000000..32a0cda4355 --- /dev/null +++ b/libs/domains/services/feature/src/lib/service-blueprint-update-flow/blueprint-update-badge.tsx @@ -0,0 +1,73 @@ +import { useNavigate } from '@tanstack/react-router' +import { type BlueprintUpdateResponse } from 'qovery-typescript-axios' +import { type AnyService } from '@qovery/domains/services/data-access' +import { Badge, Icon, useModal } from '@qovery/shared/ui' +import { BlueprintUpdateNoInputConfirmationModal } from './blueprint-update-no-input-confirmation-modal' +import { getBlueprintUpdateTitle, hasBlueprintUpdateReviewSections } from './blueprint-update-utils' + +export interface BlueprintUpdateBadgeProps { + blueprintUpdate: BlueprintUpdateResponse + organizationId: string + projectId: string + service: AnyService +} + +export function BlueprintUpdateBadge({ + blueprintUpdate, + organizationId, + projectId, + service, +}: BlueprintUpdateBadgeProps) { + const navigate = useNavigate() + const { openModal } = useModal() + + const openUpdateFlow = (step?: 'preview') => { + navigate({ + 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: service.environment.id, serviceId: service.id }, + }) + } + + if (blueprintUpdate.is_up_to_date) { + return ( + + + Up to date + + ) + } + + return ( + + ) +} 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-blueprint-update-flow/blueprint-update-context.tsx similarity index 94% rename from libs/domains/services/feature/src/lib/service-update-flow/blueprint/blueprint-update-context.tsx rename to libs/domains/services/feature/src/lib/service-blueprint-update-flow/blueprint-update-context.tsx index 3e5e91097db..28a35be1660 100644 --- a/libs/domains/services/feature/src/lib/service-update-flow/blueprint/blueprint-update-context.tsx +++ b/libs/domains/services/feature/src/lib/service-blueprint-update-flow/blueprint-update-context.tsx @@ -5,7 +5,7 @@ import { } 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' +import { type BlueprintFieldValue, type BlueprintFieldValues } from '../blueprint-field-utils/blueprint-field-utils' import { type BlueprintUpdateEditableValue, type BlueprintUpdateReviewSection, @@ -21,12 +21,14 @@ export interface BlueprintUpdateFlowContextValue { completedSections: BlueprintUpdateSection[] handleUpdate: () => Promise isPreviewLoading: boolean + previewError: boolean isRequiredValid: boolean isUpdateLoading: boolean onChange: (name: string, value: BlueprintFieldValue) => void previewId?: string removedValues: BlueprintUpdateRemovedValue[] requestPreview: () => Promise + retryPreview: () => Promise requiredValues: BlueprintUpdateNewRequiredValue[] reviewSections: BlueprintUpdateReviewSection[] service: AnyService diff --git a/libs/domains/services/feature/src/lib/service-update-flow/blueprint/blueprint-update-flow-shell.spec.tsx b/libs/domains/services/feature/src/lib/service-blueprint-update-flow/blueprint-update-flow-shell.spec.tsx similarity index 100% rename from libs/domains/services/feature/src/lib/service-update-flow/blueprint/blueprint-update-flow-shell.spec.tsx rename to libs/domains/services/feature/src/lib/service-blueprint-update-flow/blueprint-update-flow-shell.spec.tsx 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-blueprint-update-flow/blueprint-update-flow-shell.tsx similarity index 100% rename from libs/domains/services/feature/src/lib/service-update-flow/blueprint/blueprint-update-flow-shell.tsx rename to libs/domains/services/feature/src/lib/service-blueprint-update-flow/blueprint-update-flow-shell.tsx diff --git a/libs/domains/services/feature/src/lib/service-blueprint-update-flow/blueprint-update-flow.spec.tsx b/libs/domains/services/feature/src/lib/service-blueprint-update-flow/blueprint-update-flow.spec.tsx new file mode 100644 index 00000000000..2473da687fa --- /dev/null +++ b/libs/domains/services/feature/src/lib/service-blueprint-update-flow/blueprint-update-flow.spec.tsx @@ -0,0 +1,127 @@ +import { type BlueprintUpdateResponse } from 'qovery-typescript-axios' +import { useEffect } from 'react' +import { type AnyService } from '@qovery/domains/services/data-access' +import { renderWithProviders, screen, waitFor } from '@qovery/shared/util-tests' +import { useBlueprintUpdateFlowContext } from './blueprint-update-context' +import { BlueprintUpdateFlow } from './blueprint-update-flow' + +const mockDeployBlueprint = jest.fn() +const mockPreviewBlueprintUpdate = jest.fn() +const mockUpdateBlueprint = jest.fn() + +jest.mock('../hooks/use-blueprint-update/use-blueprint-update', () => ({ + useBlueprintUpdate: () => ({ + data: { + is_up_to_date: false, + current_tag: 'AWS/mysql/8/1.2.3', + latest_tag: 'AWS/mysql/8/1.3.0', + new_required_values: [], + new_optional_values: [], + now_required_values: [], + updated_values: [], + removed_values: [], + engine_diff: { updated_values: [] }, + } satisfies BlueprintUpdateResponse, + }), +})) + +jest.mock('../hooks/use-deploy-blueprint/use-deploy-blueprint', () => ({ + useDeployBlueprint: () => ({ mutateAsync: mockDeployBlueprint, isLoading: false }), +})) + +jest.mock('../hooks/use-preview-blueprint-update/use-preview-blueprint-update', () => ({ + usePreviewBlueprintUpdate: () => ({ mutateAsync: mockPreviewBlueprintUpdate, isLoading: false }), +})) + +jest.mock('../hooks/use-update-blueprint/use-update-blueprint', () => ({ + useUpdateBlueprint: () => ({ mutateAsync: mockUpdateBlueprint, isLoading: false }), +})) + +jest.mock('./blueprint-update-flow-shell', () => ({ + BlueprintUpdateFlowShell: ({ children }: { children: React.ReactNode }) => children, +})) + +function ConfirmUpdateButton() { + const { handleUpdate } = useBlueprintUpdateFlowContext() + + return ( + + ) +} + +function PreviewRequester() { + const { previewError, requestPreview } = useBlueprintUpdateFlowContext() + + useEffect(() => { + void requestPreview() + }, [requestPreview]) + + return previewError ? Preview failed : null +} + +describe('BlueprintUpdateFlow', () => { + const service = { + id: 'service-id', + name: 'mysql', + icon_uri: null, + service_type: 'TERRAFORM', + serviceType: 'TERRAFORM', + } as AnyService + + beforeEach(() => { + mockUpdateBlueprint.mockResolvedValue({}) + mockDeployBlueprint.mockResolvedValue({}) + mockPreviewBlueprintUpdate.mockResolvedValue({ preview_id: 'preview-id' }) + }) + + it('deploys the saved blueprint update before exiting', async () => { + const onExit = jest.fn() + const { userEvent } = renderWithProviders( + + + + ) + + await userEvent.click(screen.getByRole('button', { name: 'Confirm' })) + + await waitFor(() => { + expect(mockUpdateBlueprint).toHaveBeenCalledWith({ + blueprintId: 'blueprint-id', + payload: expect.objectContaining({ tag: 'AWS/mysql/8/1.3.0' }), + }) + expect(mockDeployBlueprint).toHaveBeenCalledWith({ blueprintId: 'blueprint-id' }) + expect(onExit).toHaveBeenCalled() + }) + expect(mockUpdateBlueprint.mock.invocationCallOrder[0]).toBeLessThan( + mockDeployBlueprint.mock.invocationCallOrder[0] + ) + }) + + it('does not retry a failed preview automatically', async () => { + mockPreviewBlueprintUpdate.mockRejectedValue(new Error('Preview failed')) + + renderWithProviders( + + + + ) + + await waitFor(() => expect(screen.getByText('Preview failed')).toBeInTheDocument()) + + expect(mockPreviewBlueprintUpdate).toHaveBeenCalledTimes(1) + }) +}) 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-blueprint-update-flow/blueprint-update-flow.tsx similarity index 79% rename from libs/domains/services/feature/src/lib/service-update-flow/blueprint/blueprint-update-flow.tsx rename to libs/domains/services/feature/src/lib/service-blueprint-update-flow/blueprint-update-flow.tsx index e0e974832df..e7904cbe520 100644 --- a/libs/domains/services/feature/src/lib/service-update-flow/blueprint/blueprint-update-flow.tsx +++ b/libs/domains/services/feature/src/lib/service-blueprint-update-flow/blueprint-update-flow.tsx @@ -1,15 +1,16 @@ import { type BlueprintUpdateResponse } from 'qovery-typescript-axios' -import { type PropsWithChildren, useCallback, useEffect, useMemo, useState } from 'react' +import { type PropsWithChildren, useCallback, useEffect, useMemo, useRef, useState } from 'react' import { type AnyService } from '@qovery/domains/services/data-access' import { toast } from '@qovery/shared/ui' import { type BlueprintFieldValue, type BlueprintFieldValues, isFieldValid, -} from '../../blueprint-field-utils/blueprint-field-utils' -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' +} from '../blueprint-field-utils/blueprint-field-utils' +import { useBlueprintUpdate } from '../hooks/use-blueprint-update/use-blueprint-update' +import { useDeployBlueprint } from '../hooks/use-deploy-blueprint/use-deploy-blueprint' +import { usePreviewBlueprintUpdate } from '../hooks/use-preview-blueprint-update/use-preview-blueprint-update' +import { useUpdateBlueprint } from '../hooks/use-update-blueprint/use-update-blueprint' import { BlueprintUpdateFlowProvider } from './blueprint-update-context' import { BlueprintUpdateFlowShell } from './blueprint-update-flow-shell' import { @@ -17,8 +18,8 @@ import { type BlueprintUpdateSection, buildBlueprintUpdatePayload, getBlueprintUpdateFieldValue, + getBlueprintUpdateTitle, getBlueprintUpdateVariableField, - getBlueprintUpdateVersion, getFallbackServiceIcon, getFirstAvailableUpdateSection, updateSections, @@ -55,6 +56,11 @@ export function BlueprintUpdateFlow({ }: BlueprintUpdateFlowProps) { const { data: blueprintUpdate } = useBlueprintUpdate({ blueprintId, suspense: true }) const { mutateAsync: previewBlueprintUpdate, isLoading: isPreviewLoading } = usePreviewBlueprintUpdate() + const { mutateAsync: deployBlueprint, isLoading: isDeployLoading } = useDeployBlueprint({ + environmentId, + serviceId: service.id, + serviceType: service.service_type, + }) const { mutateAsync: updateBlueprint, isLoading: isUpdateLoading } = useUpdateBlueprint({ environmentId, serviceId: service.id, @@ -65,7 +71,8 @@ export function BlueprintUpdateFlow({ ) const [completedSections, setCompletedSections] = useState([]) const [previewId, setPreviewId] = useState() - const [previewPayloadKey, setPreviewPayloadKey] = useState() + const previewPayloadKeyRef = useRef() + const [previewError, setPreviewError] = useState(false) const [values, setValues] = useState({}) const [initializedBlueprintId, setInitializedBlueprintId] = useState() @@ -81,6 +88,8 @@ export function BlueprintUpdateFlow({ ) setActiveSection(getFirstAvailableUpdateSection(blueprintUpdate)) setInitializedBlueprintId(blueprintId) + previewPayloadKeyRef.current = undefined + setPreviewError(false) } }, [blueprintId, blueprintUpdate, initializedBlueprintId]) @@ -108,8 +117,11 @@ export function BlueprintUpdateFlow({ 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 title = getBlueprintUpdateTitle({ + serviceName: service.name, + currentTag: blueprintUpdateData.current_tag, + latestTag: blueprintUpdateData.latest_tag, + }) const payload = useMemo( () => buildBlueprintUpdatePayload({ @@ -143,26 +155,34 @@ export function BlueprintUpdateFlow({ }, [activeSection, activeSectionIndex, completedSections, isRequiredValid, reviewSections]) const requestPreview = useCallback(async () => { + if (initializedBlueprintId !== blueprintId) return + const payloadKey = JSON.stringify(payload) - if (previewPayloadKey === payloadKey) return + if (previewPayloadKeyRef.current === payloadKey) return - setPreviewPayloadKey(payloadKey) + previewPayloadKeyRef.current = payloadKey + setPreviewError(false) setPreviewId(undefined) try { const preview = await previewBlueprintUpdate({ blueprintId, payload }) setPreviewId(preview?.preview_id) - } catch (error) { - setPreviewPayloadKey(undefined) - throw error + } catch { + setPreviewError(true) } - }, [blueprintId, payload, previewBlueprintUpdate, previewPayloadKey]) + }, [blueprintId, initializedBlueprintId, payload, previewBlueprintUpdate]) + + const retryPreview = useCallback(async () => { + previewPayloadKeyRef.current = undefined + await requestPreview() + }, [requestPreview]) const handleUpdate = useCallback(async () => { await updateBlueprint({ blueprintId, payload }) + await deployBlueprint({ blueprintId }) toast('success', 'Blueprint update started') onExit() - }, [blueprintId, onExit, payload, updateBlueprint]) + }, [blueprintId, deployBlueprint, onExit, payload, updateBlueprint]) const contextValue = { activeSection, @@ -173,13 +193,15 @@ export function BlueprintUpdateFlow({ completedSections, handleUpdate, isPreviewLoading, + previewError, isRequiredValid, - isUpdateLoading, + isUpdateLoading: isUpdateLoading || isDeployLoading, onChange: (name: string, value: BlueprintFieldValue) => setValues((currentValues) => ({ ...currentValues, [name]: value })), previewId, removedValues: blueprintUpdateData.removed_values, requestPreview, + retryPreview, requiredValues, reviewSections, service, diff --git a/libs/domains/services/feature/src/lib/service-blueprint-update-flow/blueprint-update-no-input-confirmation-modal.tsx b/libs/domains/services/feature/src/lib/service-blueprint-update-flow/blueprint-update-no-input-confirmation-modal.tsx new file mode 100644 index 00000000000..93335c79120 --- /dev/null +++ b/libs/domains/services/feature/src/lib/service-blueprint-update-flow/blueprint-update-no-input-confirmation-modal.tsx @@ -0,0 +1,45 @@ +import * as Dialog from '@radix-ui/react-dialog' +import { Button, ExternalLink, useModal } from '@qovery/shared/ui' +import { BLUEPRINT_RELEASE_NOTES_URL } from './blueprint-update-utils' + +export interface BlueprintUpdateNoInputConfirmationModalProps { + onConfirm: () => void + title: string +} + +export function BlueprintUpdateNoInputConfirmationModal({ + onConfirm, + title, +}: BlueprintUpdateNoInputConfirmationModalProps) { + const { closeModal } = useModal() + + return ( +
+
+ +

{title}

+
+
+
+ + Release notes + +
+ + +
+
+
+ ) +} 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-blueprint-update-flow/blueprint-update-preview-step.spec.tsx similarity index 77% rename from libs/domains/services/feature/src/lib/service-update-flow/blueprint/blueprint-update-preview-step.spec.tsx rename to libs/domains/services/feature/src/lib/service-blueprint-update-flow/blueprint-update-preview-step.spec.tsx index c4c2b977252..3ed4d71cd8a 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-blueprint-update-flow/blueprint-update-preview-step.spec.tsx @@ -1,11 +1,11 @@ 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 { 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', () => ({ +jest.mock('../hooks/use-blueprint-update-preview-socket/use-blueprint-update-preview-socket', () => ({ useBlueprintUpdatePreviewSocket: jest.fn(), })) @@ -78,6 +78,30 @@ describe('BlueprintUpdatePreviewStep', () => { expect(screen.getByRole('button', { name: /Confirm & deploy update/i })).toBeEnabled() }) + it('shows an explicit retry action when preview generation fails', async () => { + const retryPreview = jest.fn() + + jest.mocked(useBlueprintUpdateFlowContext).mockReturnValue({ + clusterId: 'cluster-id', + handleUpdate: jest.fn(), + isUpdateLoading: false, + previewError: true, + previewId: undefined, + requestPreview: jest.fn(), + retryPreview, + service: { name: 'AWS S3 Bucket' }, + } as ReturnType) + + const { userEvent } = renderWithProviders() + + expect(screen.getByRole('alert')).toHaveTextContent('Unable to generate the preview.') + + await userEvent.click(screen.getByRole('button', { name: 'Retry preview' })) + + expect(retryPreview).toHaveBeenCalledTimes(1) + expect(screen.getByRole('button', { name: /Confirm & deploy update/i })).toBeDisabled() + }) + it('updates the loading message while waiting for preview output', () => { jest.useFakeTimers() jest.mocked(useBlueprintUpdateFlowContext).mockReturnValue({ 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-blueprint-update-flow/blueprint-update-preview-step.tsx similarity index 75% rename from libs/domains/services/feature/src/lib/service-update-flow/blueprint/blueprint-update-preview-step.tsx rename to libs/domains/services/feature/src/lib/service-blueprint-update-flow/blueprint-update-preview-step.tsx index 269b495f5a1..417767c739a 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-blueprint-update-flow/blueprint-update-preview-step.tsx @@ -1,7 +1,7 @@ import { useParams } from '@tanstack/react-router' 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 { 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' @@ -14,7 +14,8 @@ const PREVIEW_LOADING_MESSAGES = [ const PREVIEW_LOADING_MESSAGE_INTERVAL = 8_000 export function BlueprintUpdatePreviewStep({ onBack }: { onBack: () => void }) { - const { clusterId, handleUpdate, isUpdateLoading, previewId, requestPreview } = useBlueprintUpdateFlowContext() + const { clusterId, handleUpdate, isUpdateLoading, previewError, previewId, requestPreview, retryPreview } = + useBlueprintUpdateFlowContext() useEffect(() => { requestPreview() @@ -26,7 +27,9 @@ export function BlueprintUpdatePreviewStep({ onBack }: { onBack: () => void }) { previewId={previewId} onBack={onBack} onConfirm={handleUpdate} + onRetry={retryPreview} loading={isUpdateLoading} + previewError={previewError} /> ) } @@ -36,12 +39,16 @@ function BlueprintUpdatePreview({ loading, onBack, onConfirm, + onRetry, + previewError, previewId, }: { clusterId?: string loading: boolean onBack: () => void onConfirm: () => Promise + onRetry: () => Promise + previewError: boolean previewId?: string }) { if (!clusterId || !previewId) { @@ -52,6 +59,8 @@ function BlueprintUpdatePreview({ loading={loading} onBack={onBack} onConfirm={onConfirm} + onRetry={onRetry} + previewError={previewError} rawOutput="" /> ) @@ -63,6 +72,8 @@ function BlueprintUpdatePreview({ loading={loading} onBack={onBack} onConfirm={onConfirm} + onRetry={onRetry} + previewError={previewError} previewId={previewId} /> ) @@ -73,12 +84,16 @@ function BlueprintUpdatePreviewWithSocket({ loading, onBack, onConfirm, + onRetry, + previewError, previewId, }: { clusterId: string loading: boolean onBack: () => void onConfirm: () => Promise + onRetry: () => Promise + previewError: boolean previewId: string }) { const { organizationId = '' } = useParams({ strict: false }) @@ -95,6 +110,8 @@ function BlueprintUpdatePreviewWithSocket({ loading={loading} onBack={onBack} onConfirm={onConfirm} + onRetry={onRetry} + previewError={previewError} rawOutput={rawOutput} /> ) @@ -106,6 +123,8 @@ function BlueprintUpdatePreviewContent({ loading, onBack, onConfirm, + onRetry, + previewError, rawOutput, }: { hasReceivedPreviewMessage: boolean @@ -113,28 +132,44 @@ function BlueprintUpdatePreviewContent({ loading: boolean onBack: () => void onConfirm: () => Promise + onRetry: () => Promise + previewError: boolean rawOutput: string }) { const rawOutputContainerHeightClassName = rawOutput - ? 'h-[min(75vh,calc(100vh-260px))] min-h-[260px]' + ? 'h-[min(75vh,calc(100vh-320px))] min-h-[260px]' : 'min-h-[180px]' return ( - -
+ +
Preview changes -
Raw output
- {rawOutput ? : } + {previewError ? ( +
+ Unable to generate the preview. + +
+ ) : rawOutput ? ( + + ) : ( + + )}
-
+ ) } diff --git a/libs/domains/services/feature/src/lib/service-creation-flow/blueprint/blueprint-configuration-view/blueprint-creation-components/overrides-section-card/overrides-section-card.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 index bb026ebc65b..a5bb52e213d 100644 --- a/libs/domains/services/feature/src/lib/service-creation-flow/blueprint/blueprint-configuration-view/blueprint-creation-components/overrides-section-card/overrides-section-card.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 @@ -1,5 +1,5 @@ import { type ReactNode } from 'react' -import { Heading, Icon, Section } from '@qovery/shared/ui' +import { Button, Heading, Icon, Section } from '@qovery/shared/ui' import { BlueprintSection } from '../blueprint-section/blueprint-section' export interface OverridesSectionCardProps { @@ -17,7 +17,14 @@ export function OverridesSectionCard({ active, children, disabled = false, onCli iconName="code" title="Overrides" description="For advanced users" - onClick={onClick} + headerClassName={disabled ? undefined : 'py-3'} + action={ + !disabled ? ( + + ) : undefined + } /> ) } 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 8f2de1ab73d..6a7751024bc 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 @@ -292,10 +292,12 @@ describe('BlueprintCreationFlow', () => { const { userEvent } = renderWithProviders() await userEvent.click(screen.getByRole('button', { name: /continue/i })) + expect(screen.queryByRole('button', { name: 'Configure' })).not.toBeInTheDocument() + 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') - await userEvent.click(screen.getByRole('button', { name: /overrides/i })) + await userEvent.click(screen.getByRole('button', { name: 'Configure' })) expect(await screen.findByRole('switch', { name: 'Skip final snapshot' })).toHaveFocus() }) @@ -473,7 +475,7 @@ describe('BlueprintCreationFlow', () => { expect(screen.getByText('17')).toBeInTheDocument() expect(mockUseBlueprintCatalogServiceManifest).not.toHaveBeenCalled() - await selectEvent.select(screen.getByLabelText('Blueprint version'), '16') + await selectEvent.select(screen.getByLabelText('Version'), '16') expect(mockUseBlueprintCatalogServiceManifest).not.toHaveBeenCalled() await userEvent.click(screen.getByRole('button', { name: /continue/i })) 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 a234cdaf065..e59474b2420 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 @@ -3,6 +3,7 @@ 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 { formatBlueprintName } from '../../blueprint-utils/blueprint-utils' import { BlueprintCreateContext, type BlueprintCreateFormData, @@ -42,7 +43,7 @@ export function BlueprintCreationFlow({ blueprint, children, onExit }: Blueprint const defaultVersionTag = defaultBlueprintVersion?.latestTag ?? '' const form = useForm({ defaultValues: { - serviceName: blueprint.name, + serviceName: formatBlueprintName(blueprint.name), versionTag: defaultVersionTag, fields: {}, }, 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 415777910c4..dce760b604d 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 @@ -9,6 +9,7 @@ import { getSummaryFieldValue, isFieldValid, } from '../../../blueprint-field-utils/blueprint-field-utils' +import { formatBlueprintName } from '../../../blueprint-utils/blueprint-utils' import { useBlueprintCreationLogs } from '../../../hooks/use-blueprint-creation-logs/use-blueprint-creation-logs' import { useBlueprintServiceCreatedSocket } from '../../../hooks/use-blueprint-service-created-socket/use-blueprint-service-created-socket' import { useCreateBlueprint } from '../../../hooks/use-create-blueprint/use-create-blueprint' @@ -251,7 +252,7 @@ export function BlueprintStepSummary() { return ( <> - +
Ready to create your blueprint service @@ -278,8 +279,8 @@ export function BlueprintStepSummary() {
    - - + +
diff --git a/libs/domains/services/feature/src/lib/service-list/service-list-cells/service-version-cell.spec.tsx b/libs/domains/services/feature/src/lib/service-list/service-list-cells/service-version-cell.spec.tsx new file mode 100644 index 00000000000..fe4f07ac7ee --- /dev/null +++ b/libs/domains/services/feature/src/lib/service-list/service-list-cells/service-version-cell.spec.tsx @@ -0,0 +1,87 @@ +import { type Terraform } from '@qovery/domains/services/data-access' +import { renderWithProviders, screen } from '@qovery/shared/util-tests' +import { useBlueprintUpdate } from '../../hooks/use-blueprint-update/use-blueprint-update' +import { ServiceVersionCell } from './service-version-cell' + +jest.mock('../../hooks/use-blueprint-update/use-blueprint-update', () => ({ + useBlueprintUpdate: jest.fn(), +})) + +const blueprintService = { + id: 'service-id', + name: 'AWS S3 Bucket', + service_type: 'TERRAFORM', + serviceType: 'TERRAFORM', + icon_uri: 'app://qovery-console/s3', + environment: { id: 'environment-id' }, + blueprint_id: 'blueprint-id', + tag: 'AWS/s3/1.2.3', + terraform_files_source: { + git: { + git_repository: { + provider: 'GITHUB', + url: 'https://github.com/qovery-blueprints/s3.git', + name: 'qovery-blueprints/s3', + }, + }, + }, +} as Terraform + +describe('ServiceVersionCell', () => { + it.each([ + { isUpToDate: true, status: 'Up to date' }, + { isUpToDate: false, status: 'Update available' }, + ])('renders the $status state for a blueprint service', ({ isUpToDate, status }) => { + jest.mocked(useBlueprintUpdate).mockReturnValue({ + data: { is_up_to_date: isUpToDate, current_tag: 'AWS/mysql/8/2.3.4' }, + isLoading: false, + } as ReturnType) + + renderWithProviders() + + expect(useBlueprintUpdate).toHaveBeenCalledWith({ blueprintId: 'blueprint-id' }) + expect(screen.getByText('qovery-blueprints/s3')).toBeInTheDocument() + expect(screen.getByText('v8')).toBeInTheDocument() + expect(screen.getByText(status)).toBeInTheDocument() + }) + + it('does not render the default service version', () => { + jest.mocked(useBlueprintUpdate).mockReturnValue({ + data: { is_up_to_date: true, current_tag: 'AWS/mysql/default/2.3.4' }, + isLoading: false, + } as ReturnType) + + renderWithProviders() + + expect(screen.queryByText('vdefault')).not.toBeInTheDocument() + }) + + it('opens the blueprint update confirmation modal when no input is required', async () => { + jest.mocked(useBlueprintUpdate).mockReturnValue({ + data: { + is_up_to_date: false, + current_tag: 'AWS/s3/1.2.3', + latest_tag: 'AWS/s3/2.0.0', + new_required_values: [], + new_optional_values: [], + now_required_values: [], + updated_values: [], + removed_values: [], + engine_diff: { updated_values: [] }, + }, + isLoading: false, + } as ReturnType) + + const { userEvent } = renderWithProviders() + + await userEvent.click(screen.getByRole('button', { name: 'Update available' })) + + expect( + await screen.findByRole('heading', { name: 'AWS S3 Bucket blueprint update from 1.2.3 to 2.0.0' }) + ).toBeInTheDocument() + expect( + screen.queryByText('No configuration input is required. Continue to preview the update.') + ).not.toBeInTheDocument() + expect(screen.getByRole('button', { name: 'Preview changes' })).toBeInTheDocument() + }) +}) diff --git a/libs/domains/services/feature/src/lib/service-list/service-list-cells/service-version-cell.tsx b/libs/domains/services/feature/src/lib/service-list/service-list-cells/service-version-cell.tsx index 5b831ce06d6..f1b830bee7f 100644 --- a/libs/domains/services/feature/src/lib/service-list/service-list-cells/service-version-cell.tsx +++ b/libs/domains/services/feature/src/lib/service-list/service-list-cells/service-version-cell.tsx @@ -1,3 +1,4 @@ +import { useParams } from '@tanstack/react-router' import { type ApplicationGitRepository, type ContainerResponse, @@ -7,10 +8,12 @@ import { P, match } from 'ts-pattern' import { type AnyService, type Application, + type BlueprintService, type Database, type Helm, type Job, type Terraform, + isBlueprintService, } from '@qovery/domains/services/data-access' import { IconEnum, @@ -19,21 +22,92 @@ import { isJobContainerSource, isJobGitSource, } from '@qovery/shared/enums' -import { ExternalLink, Icon, Tooltip } from '@qovery/shared/ui' +import { ExternalLink, Icon, Skeleton, Tooltip } from '@qovery/shared/ui' import { buildGitProviderUrl } from '@qovery/shared/util-git' import { containerRegistryKindToIcon } from '@qovery/shared/util-js' +import { useBlueprintUpdate } from '../../hooks/use-blueprint-update/use-blueprint-update' import LastCommit from '../../last-commit/last-commit' import LastVersion from '../../last-version/last-version' +import { ServiceAvatar } from '../../service-avatar/service-avatar' +import { BlueprintUpdateBadge } from '../../service-blueprint-update-flow/blueprint-update-badge' +import { getBlueprintServiceVersion } from '../../service-blueprint-update-flow/blueprint-update-utils' type ServiceVersionCellProps = { service: AnyService - organizationId: string - projectId: string } -export function ServiceVersionCell({ service, organizationId, projectId }: ServiceVersionCellProps) { - const gitInfo = (service: Application | Job | Helm | Terraform, gitRepository?: ApplicationGitRepository) => - gitRepository && ( +function BlueprintVersionInfo({ + service, + gitRepository, +}: { + service: BlueprintService + gitRepository: ApplicationGitRepository +}) { + const { organizationId = '', projectId = '' } = useParams({ strict: false }) ?? {} + const { data: blueprintUpdate, isLoading } = useBlueprintUpdate({ blueprintId: service.blueprint_id }) + const version = blueprintUpdate?.current_tag ? getBlueprintServiceVersion(blueprintUpdate.current_tag) : undefined + + return ( +
+
+
+ + event.stopPropagation()} + > + + {gitRepository.name} + + +
+ {version && version !== 'default' && ( +
+ + + v{version} + +
+ )} +
+ {isLoading ? ( + + ) : blueprintUpdate ? ( +
event.stopPropagation()}> + +
+ ) : null} +
+ ) +} + +export function ServiceVersionCell({ service }: ServiceVersionCellProps) { + const { organizationId = '', projectId = '' } = useParams({ strict: false }) ?? {} + + const gitInfo = (service: Application | Job | Helm | Terraform, gitRepository?: ApplicationGitRepository) => { + if (!gitRepository) return null + + if (isBlueprintService(service)) { + return + } + + return (
e.stopPropagation()}>
@@ -88,6 +162,7 @@ export function ServiceVersionCell({ service, organizationId, projectId }: Servi
) + } const containerInfo = (containerImage?: Pick) => containerImage && (
e.stopPropagation()}> diff --git a/libs/domains/services/feature/src/lib/service-list/service-list.tsx b/libs/domains/services/feature/src/lib/service-list/service-list.tsx index 6519267dc6b..47961bf25d5 100644 --- a/libs/domains/services/feature/src/lib/service-list/service-list.tsx +++ b/libs/domains/services/feature/src/lib/service-list/service-list.tsx @@ -258,9 +258,7 @@ export function ServiceList({ className, containerClassName, environment, ...pro enableColumnFilter: false, enableSorting: false, cell: (info) => { - return ( - - ) + return }, }), columnHelper.display({ diff --git a/libs/domains/services/feature/src/lib/service-new/blueprint-card/blueprint-card.tsx b/libs/domains/services/feature/src/lib/service-new/blueprint-card/blueprint-card.tsx index 2667dd1d94e..b808b49b1e2 100644 --- a/libs/domains/services/feature/src/lib/service-new/blueprint-card/blueprint-card.tsx +++ b/libs/domains/services/feature/src/lib/service-new/blueprint-card/blueprint-card.tsx @@ -1,6 +1,7 @@ import { useParams } from '@tanstack/react-router' import { type BlueprintItem } from 'qovery-typescript-axios' -import { Button, Link } from '@qovery/shared/ui' +import { Button, Heading, Link } from '@qovery/shared/ui' +import { formatBlueprintName } from '../../blueprint-utils/blueprint-utils' import { ServiceAvatar } from '../../service-avatar/service-avatar' export function BlueprintCard({ @@ -23,7 +24,7 @@ export function BlueprintCard({ size="custom" />
-

{blueprint.name}

+ {formatBlueprintName(blueprint.name)}

{blueprint.description}

diff --git a/libs/domains/services/feature/src/lib/service-new/service-card/service-card.tsx b/libs/domains/services/feature/src/lib/service-new/service-card/service-card.tsx index 3ca46d5e9e4..08d067dbbe3 100644 --- a/libs/domains/services/feature/src/lib/service-new/service-card/service-card.tsx +++ b/libs/domains/services/feature/src/lib/service-new/service-card/service-card.tsx @@ -2,7 +2,7 @@ import clsx from 'clsx' import posthog from 'posthog-js' import { type CloudProviderEnum, type LifecycleTemplateListResponseResultsInner } from 'qovery-typescript-axios' import { type ReactElement, cloneElement, useState } from 'react' -import { Badge, Button, Heading, Icon, Link, Section } from '@qovery/shared/ui' +import { Badge, Button, Heading, Icon, Link, Section, Tooltip } from '@qovery/shared/ui' import { twMerge } from '@qovery/shared/util-js' import { TemplateIds } from '@qovery/shared/util-services' import { ServiceIcons } from '../../service-icon/service-icon' @@ -25,6 +25,46 @@ export type ServiceBlock = { badge?: string } +export function BaseServiceCard({ title, description, icon, link, onClick }: ServiceBlock) { + const className = + 'flex h-14 w-full items-center justify-between gap-3 rounded-lg border border-neutral bg-surface-neutral p-4 text-left transition [box-shadow:0px_0px_4px_0px_rgba(0,0,0,0.01),0px_2px_3px_0px_rgba(0,0,0,0.02)] hover:bg-surface-neutral-subtle' + const content = ( + <> + + {cloneElement(icon as ReactElement, { width: 20, height: 20, className: 'h-5 w-5 shrink-0' })} + {title} + {(title === 'Lifecycle Job' || title === 'Cron Job') && ( + + + + + + )} + + + + ) + + if (onClick) { + return ( + + ) + } + + if (!link) { + return
{content}
+ } + + return ( + // @ts-expect-error-next-line TODO new-nav : Route strings need to be updated using the next typed routes + + {content} + + ) +} + export function Card({ title, description, icon, link, onClick, disabledCTA, badge }: ServiceBlock) { const Wrapper = ({ children }: { children: ReactElement }) => { const className = twMerge( diff --git a/libs/domains/services/feature/src/lib/service-new/service-new.spec.tsx b/libs/domains/services/feature/src/lib/service-new/service-new.spec.tsx index dc625b2e853..da887bce673 100644 --- a/libs/domains/services/feature/src/lib/service-new/service-new.spec.tsx +++ b/libs/domains/services/feature/src/lib/service-new/service-new.spec.tsx @@ -112,31 +112,44 @@ describe('ServiceNew', () => { expect(baseElement).toBeTruthy() }) - it('should render search input and documentation link', () => { + it('should not render a page-level service search', () => { renderWithProviders( ) - expect(screen.getByPlaceholderText('Search…')).toBeInTheDocument() - expect(screen.getByText('See documentation')).toBeInTheDocument() + expect(screen.queryByPlaceholderText('Search…')).not.toBeInTheDocument() + expect(screen.queryByText('See documentation')).not.toBeInTheDocument() }) - it('should render Default Qovery services section with main service types', () => { + it('should render Base services section with main service types', () => { renderWithProviders( ) - expect(screen.getByRole('heading', { name: 'Default Qovery services' })).toBeInTheDocument() + expect(screen.getByRole('heading', { name: 'Base services' })).toBeInTheDocument() expect(screen.getByText('Application')).toBeInTheDocument() - expect(screen.getByText('Database')).toBeInTheDocument() + expect(screen.getByText('Container Database')).toBeInTheDocument() expect(screen.getByText('Lifecycle Job')).toBeInTheDocument() expect(screen.getByText('Cron Job')).toBeInTheDocument() expect(screen.getByText('Helm')).toBeInTheDocument() expect(screen.getAllByText('Terraform').length).toBeGreaterThanOrEqual(1) }) - it('should render template sections by tag', () => { + it('should show base service descriptions in info tooltips', async () => { + const { userEvent } = renderWithProviders( + + ) + + await userEvent.hover(screen.getByRole('img', { name: 'Lifecycle Job details' })) + + expect( + await screen.findAllByText('Execute any type of script coming from Git or a Container Registry.') + ).not.toHaveLength(0) + }) + + it('should render legacy template sections when the service catalog is disabled', () => { renderWithProviders( ) + expect(screen.getByRole('heading', { name: 'Data & Storage' })).toBeInTheDocument() expect(screen.getByRole('heading', { name: 'Back-end' })).toBeInTheDocument() expect(screen.getByRole('heading', { name: 'Front-end' })).toBeInTheDocument() @@ -144,6 +157,20 @@ describe('ServiceNew', () => { expect(screen.getByRole('heading', { name: 'More template' })).toBeInTheDocument() }) + it('should hide legacy template sections when the service catalog is enabled', () => { + mockUseFeatureFlagEnabled.mockImplementation((flag: string) => flag === 'service-catalog') + + renderWithProviders( + + ) + + expect(screen.queryByRole('heading', { name: 'Data & Storage' })).not.toBeInTheDocument() + expect(screen.queryByRole('heading', { name: 'Back-end' })).not.toBeInTheDocument() + expect(screen.queryByRole('heading', { name: 'Front-end' })).not.toBeInTheDocument() + expect(screen.queryByRole('heading', { name: 'IAC' })).not.toBeInTheDocument() + expect(screen.queryByRole('heading', { name: 'More template' })).not.toBeInTheDocument() + }) + it('should render blueprint cards from the catalog', async () => { mockUseFeatureFlagEnabled.mockImplementation((flag: string) => flag === 'service-catalog') mockUseBlueprintCatalog.mockReturnValue({ data: { blueprints } }) @@ -172,6 +199,64 @@ describe('ServiceNew', () => { expect(blueprintsSectionScreen.getByText('Redis')).toBeInTheDocument() }) + it('should show an empty state when the blueprint search has no results', async () => { + mockUseFeatureFlagEnabled.mockImplementation((flag: string) => flag === 'service-catalog') + mockUseBlueprintCatalog.mockReturnValue({ data: { blueprints } }) + + const { userEvent } = renderWithProviders( + + ) + + const blueprintsSection = screen.getByRole('heading', { name: 'Blueprints' }).closest('section') + const blueprintsSectionScreen = within(blueprintsSection as HTMLElement) + + await userEvent.type(screen.getByPlaceholderText('Search blueprints...'), 'does-not-exist') + + expect(screen.getByRole('heading', { name: 'Blueprints' })).toBeInTheDocument() + expect(blueprintsSectionScreen.getByText('No blueprints found')).toBeInTheDocument() + expect(blueprintsSectionScreen.queryByText('AWS S3 Bucket')).not.toBeInTheDocument() + }) + + it('should format slug blueprint names', () => { + mockUseFeatureFlagEnabled.mockImplementation((flag: string) => flag === 'service-catalog') + mockUseBlueprintCatalog.mockReturnValue({ + data: { blueprints: [{ ...blueprints[0], name: 'aws-rds-mysql' }] }, + }) + + renderWithProviders( + + ) + + expect(screen.getByText('AWS RDS MySQL')).toBeInTheDocument() + }) + + it('should only display blueprints compatible with the environment cluster', () => { + mockUseFeatureFlagEnabled.mockImplementation((flag: string) => flag === 'service-catalog') + mockUseBlueprintCatalog.mockReturnValue({ + data: { + blueprints: [ + { ...blueprints[0], name: 'AWS S3', provider: 'AWS' }, + { ...blueprints[0], name: 'Scaleway Object Storage', provider: 'SCW' }, + { ...blueprints[1], name: 'Helm Redis', provider: 'HELM' }, + ], + }, + }) + + renderWithProviders( + + ) + + expect(screen.getByText('AWS S3')).toBeInTheDocument() + expect(screen.getByText('Helm Redis')).toBeInTheDocument() + expect(screen.queryByText('Scaleway Object Storage')).not.toBeInTheDocument() + }) + it('should not render deploy actions for blueprints without a service family', async () => { mockUseFeatureFlagEnabled.mockImplementation((flag: string) => flag === 'service-catalog') mockUseBlueprintCatalog.mockReturnValue({ @@ -301,13 +386,11 @@ describe('ServiceNew', () => { }) }) - it('should keep default service links valid when rendered from search results', async () => { - const { container, userEvent } = renderWithProviders( + it('should keep default service links valid', () => { + const { container } = renderWithProviders( ) - await userEvent.type(screen.getByPlaceholderText('Search…'), 'application') - expect( container.querySelector( 'a[href="/organization/org-1/project/project-1/environment/env-1/service/create/application"]' diff --git a/libs/domains/services/feature/src/lib/service-new/service-new.tsx b/libs/domains/services/feature/src/lib/service-new/service-new.tsx index 964dfba5fa3..2a790b6be0b 100644 --- a/libs/domains/services/feature/src/lib/service-new/service-new.tsx +++ b/libs/domains/services/feature/src/lib/service-new/service-new.tsx @@ -1,5 +1,4 @@ import { useParams } from '@tanstack/react-router' -import posthog from 'posthog-js' import { useFeatureFlagEnabled } from 'posthog-js/react' import { type BlueprintItem, @@ -7,18 +6,17 @@ import { type LifecycleTemplateListResponseResultsInner, } from 'qovery-typescript-axios' import { type ReactNode, useMemo, useState } from 'react' -import { Button, ExternalLink, Heading, Icon, InputSearch, Section, Skeleton } from '@qovery/shared/ui' +import { Button, Heading, Icon, InputSearch, Section, Skeleton } from '@qovery/shared/ui' import { useSupportChat } from '@qovery/shared/util-hooks' import { BlueprintDetailsPanel } from '../blueprint-details-panel/blueprint-details-panel' import { BlueprintQueryBoundary } from '../blueprint-query-boundary/blueprint-query-boundary' +import { isBlueprintCompatibleWithCluster } from '../blueprint-utils/blueprint-utils' import { useBlueprintCatalog } from '../hooks/use-blueprint-catalog/use-blueprint-catalog' import { BlueprintCard } from './blueprint-card/blueprint-card' -import { Card, CardService, SectionByTag, type ServiceBlock } from './service-card/service-card' +import { BaseServiceCard, Card, CardService, SectionByTag, type ServiceBlock } from './service-card/service-card' import { buildCreateFlowPathForType, getCreateFlowPath, getServicesPath } from './service-new-utils/service-new-utils' import { serviceTemplates } from './service-templates' -const CloudFormationIcon = '/assets/devicon/cloudformation.svg' - export interface ServiceNewProps { organizationId: string projectId: string @@ -30,10 +28,12 @@ export interface ServiceNewProps { function BlueprintSectionHeader({ action }: { action?: ReactNode }) { return ( -
-
- Blueprints -

Qovery managed blueprints that you can deploy in a few clicks.

+
+
+ Blueprints +

+ Qovery managed blueprints that you can deploy in a few clicks. +

{action}
@@ -42,9 +42,9 @@ function BlueprintSectionHeader({ action }: { action?: ReactNode }) { function BlueprintSectionFallback() { return ( -
+
} /> -
+
{[0, 1, 2].map((item) => (
+
Unable to load {title}. @@ -92,10 +92,12 @@ function BlueprintSectionErrorFallback({ function BlueprintSection({ blueprintSearchInput, + cloudProvider, onBlueprintSearchInputChange, onViewDetails, }: { blueprintSearchInput: string + cloudProvider?: CloudProviderEnum | string onBlueprintSearchInputChange: (value: string) => void onViewDetails: (blueprint: BlueprintItem) => void }) { @@ -107,24 +109,27 @@ function BlueprintSection({ const blueprints = blueprintCatalog?.blueprints ?? [] const filterBlueprint = ({ name, description, categories }: BlueprintItem) => `${name} ${description} ${categories?.join(' ')}`.toLowerCase().includes(blueprintSearchInput.toLowerCase()) - const filteredBlueprints = blueprints.filter(filterBlueprint) + const compatibleBlueprints = blueprints.filter((blueprint) => + isBlueprintCompatibleWithCluster(blueprint.provider, cloudProvider) + ) + const filteredBlueprints = compatibleBlueprints.filter(filterBlueprint) - if (blueprints.length === 0) return null + if (compatibleBlueprints.length === 0) return null return ( -
+
} /> {filteredBlueprints.length > 0 ? ( -
+
{filteredBlueprints.map((blueprint) => ( ) : ( -
+
-

No blueprint found

+

No blueprints found

)}
@@ -164,7 +169,7 @@ export function ServiceNew({ cloud_provider: cloudProvider, }, { - title: 'Database', + title: 'Container Database', description: 'Easy and fastest way to deploy the most popular databases.', icon: , link: getServicesPath( @@ -226,7 +231,6 @@ export function ServiceNew({ [cloudProvider, organizationId, projectId, environmentId, isTerraformFeatureFlag, showPylonForm] ) - const [searchInput, setSearchInput] = useState('') const [blueprintSearchInput, setBlueprintSearchInput] = useState('') const [selectedBlueprint, setSelectedBlueprint] = useState(null) const [isBlueprintDetailsOpen, setIsBlueprintDetailsOpen] = useState(false) @@ -236,98 +240,39 @@ export function ServiceNew({ setIsBlueprintDetailsOpen(true) } - const filterService = ({ title }: { title: string }) => title.toLowerCase().includes(searchInput.toLowerCase()) - - const handleSearchInputChange = (value: string) => { - if ([...serviceEmpty, ...serviceTemplates].filter(filterService).length === 0) { - posthog.capture('search-service', { - qoveryServiceType: 'INPUT_SEARCH', - searchValue: value, - }) - } - - setSearchInput(value) - } - - const emptyState = ( -
- You didn't find what you want? -

Use one of those options below.

- -
- {[ - ...serviceEmpty, - ...[ - { - title: 'CloudFormation', - description: - 'AWS CloudFormation is a service provided by Amazon Web Services that enables users to model and manage infrastructure resources in an automated and secure manner.', - icon: ( - CloudFormation - ), - link: getServicesPath( - organizationId, - projectId, - environmentId, - buildCreateFlowPathForType('LIFECYCLE_JOB', 'cloudformation', 'current') ?? - '/service/create/lifecycle-job' - ), - cloud_provider: cloudProvider, - }, - ], - ].map((service) => ( - - ))} -
-
- ) - return ( <> -
- - - See documentation - -
-
- {searchInput.length === 0 ? ( +
+
+
+ Base services +

+ Services without pre-configuration. These are the basic blocks to deploy any technical stack. +

+
+
+ {serviceEmpty.map((service) => ( + + ))} +
+
+ {isServiceCatalogEnabled && ( + } + resetKeys={[organizationId, isServiceCatalogEnabled]} + title="blueprint catalog" + > + + + )} + {!isServiceCatalogEnabled && ( <> -
- Default Qovery services -

- Services without pre-configuration. These are the basic blocks to deploy any technical stack. -

-
- {serviceEmpty.map((service) => ( - - ))} -
-
- {isServiceCatalogEnabled && ( - } - resetKeys={[organizationId, isServiceCatalogEnabled]} - title="blueprint catalog" - > - - - )} showPylonForm('request-upgrade-plan')} /> - ) : [...serviceEmpty, ...serviceTemplates] - .filter((c) => c.cloud_provider === cloudProvider || !c.cloud_provider) - .filter(filterService).length > 0 ? ( -
- Search results -

- Find the service you need to kickstart your next project. -

-
- {[...serviceEmpty, ...serviceTemplates] - .filter((c) => c.cloud_provider === cloudProvider || !c.cloud_provider) - .filter(filterService) - .map((service) => ( - showPylonForm('request-upgrade-plan')} - {...service} - /> - ))} -
-
- ) : ( - emptyState )}
{ mockUseBlueprintUpdate.mockReturnValue({ data: { is_up_to_date: true, - latest_tag: 'aws/s3/1.0.0', + current_tag: 'AWS/mysql/8/1.0.0', + latest_tag: 'AWS/mysql/8/1.0.0', new_required_values: [], new_optional_values: [], now_required_values: [], @@ -428,14 +429,42 @@ describe('ServiceHeader', () => { renderServiceHeader('terraform-mock') expect(mockUseBlueprintUpdate).toHaveBeenCalledWith({ blueprintId: 'blueprint-id', suspense: true }) + expect(screen.getByText('v8')).toBeInTheDocument() + expect(screen.getByRole('link', { name: /qovery-blueprints/ })).toHaveAttribute( + 'href', + 'https://github.com/qovery-blueprints/s3' + ) + expect(screen.queryByText('GitHub')).not.toBeInTheDocument() + expect(screen.queryByText('main')).not.toBeInTheDocument() expect(screen.getByText('Up to date')).toBeInTheDocument() expect(screen.queryByText('Update available')).not.toBeInTheDocument() }) + it('does not render the default service version', () => { + mockUseBlueprintUpdate.mockReturnValue({ + data: { + is_up_to_date: true, + current_tag: 'AWS/mysql/default/1.0.0', + latest_tag: 'AWS/mysql/default/1.0.0', + new_required_values: [], + new_optional_values: [], + now_required_values: [], + updated_values: [], + removed_values: [], + engine_diff: { updated_values: [] }, + }, + }) + + renderServiceHeader('terraform-mock') + + expect(screen.queryByText('default')).not.toBeInTheDocument() + }) + it('opens the blueprint update review flow from the update available badge when values require review', async () => { mockUseBlueprintUpdate.mockReturnValue({ data: { is_up_to_date: false, + current_tag: 'aws/s3/1.0', latest_tag: 'aws/s3/2.0', new_required_values: [], new_optional_values: [], @@ -478,6 +507,7 @@ describe('ServiceHeader', () => { mockUseBlueprintUpdate.mockReturnValue({ data: { is_up_to_date: false, + current_tag: 'aws/s3/1.0', latest_tag: 'aws/s3/2.0', new_required_values: [], new_optional_values: [], @@ -495,10 +525,14 @@ describe('ServiceHeader', () => { 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() + expect( + await screen.findByRole('heading', { name: 'aws-s3-bucket blueprint update from 1.0 to 2.0' }) + ).toBeInTheDocument() + expect( + screen.queryByText('No configuration input is required. Continue to preview the update.') + ).not.toBeInTheDocument() - await userEvent.click(screen.getByRole('button', { name: /continue/i })) + await userEvent.click(screen.getByRole('button', { name: /preview changes/i })) expect(mockNavigate).toHaveBeenCalledWith({ to: '/organization/$organizationId/project/$projectId/environment/$environmentId/service/$serviceId/update/blueprint/preview', @@ -511,14 +545,14 @@ describe('ServiceHeader', () => { }) }) - it('renders a skeleton while the blueprint update badge is loading', () => { + it('renders metadata skeletons while the blueprint update is loading', () => { mockUseBlueprintUpdate.mockImplementation(() => { throw new Promise(() => undefined) }) renderServiceHeader('terraform-mock') - expect(screen.getByRole('generic', { busy: true })).toBeInTheDocument() + expect(screen.getAllByRole('generic', { busy: true })).toHaveLength(2) }) it('does not check blueprint update availability for non-blueprint services', () => { diff --git a/libs/domains/services/feature/src/lib/service-overview/service-header/service-header.tsx b/libs/domains/services/feature/src/lib/service-overview/service-header/service-header.tsx index 298732ce6a2..35da9b144e4 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,4 @@ -import * as Dialog from '@radix-ui/react-dialog' -import { Link, useNavigate, useParams } from '@tanstack/react-router' +import { Link, 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' @@ -29,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' @@ -38,17 +36,13 @@ 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' +import { BlueprintUpdateBadge } from '../../service-blueprint-update-flow/blueprint-update-badge' +import { getBlueprintServiceVersion } from '../../service-blueprint-update-flow/blueprint-update-utils' 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 ( @@ -171,94 +165,71 @@ function BlueprintUpdateBadgeSkeleton() { return } -function BlueprintUpdateBadge({ blueprintId }: { blueprintId: string }) { - 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() - const { openModal } = useModal() - const openUpdateFlow = (step?: 'preview') => { - navigate({ - 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 }, - }) - } - - if (!blueprintUpdate) { +function BlueprintRepository({ gitRepository }: { gitRepository: ApplicationGitRepository }) { + if (!gitRepository.url || !gitRepository.name) { return null } - return blueprintUpdate.is_up_to_date ? ( - - - Up to date - - ) : ( - + {gitRepository.provider && } + + + ) +} + +function BlueprintMetadataSkeleton({ gitRepository }: { gitRepository?: ApplicationGitRepository }) { + return ( + <> + + {gitRepository && } + + ) } -function BlueprintUpdateNoInputConfirmationModal({ onConfirm, title }: { onConfirm: () => void; title: string }) { - const { closeModal } = useModal() +function BlueprintMetadata({ + blueprintId, + gitRepository, + service, +}: { + blueprintId: string + gitRepository?: ApplicationGitRepository + service: AnyService +}) { + const { organizationId = '', projectId = '' } = useParams({ strict: false }) + const { data: blueprintUpdate } = useBlueprintUpdate({ blueprintId, suspense: true }) + const currentVersion = blueprintUpdate?.current_tag + ? getBlueprintServiceVersion(blueprintUpdate.current_tag) + : undefined return ( -
-
- -

{title}

-
- - Release notes - -
- - No configuration input is required. Continue to preview the update. - -
- - -
-
+ <> + {currentVersion && currentVersion !== 'default' && ( + + + v{currentVersion} + + )} + {gitRepository && } + {blueprintUpdate && ( + + )} + ) } @@ -270,6 +241,13 @@ function ServiceHeaderMetadata({ service }: ServiceHeaderMetadataProps) { }) const [, copyToClipboard] = useCopyToClipboard() const blueprintId = 'blueprint_id' in service ? service.blueprint_id : undefined + const gitRepository = match(service) + .with({ serviceType: 'APPLICATION' }, ({ git_repository }) => git_repository) + .with({ serviceType: 'JOB', source: P.when(isJobGitSource) }, ({ source }) => source.docker?.git_repository) + .with({ serviceType: 'HELM', source: P.when(isHelmGitSource) }, ({ source }) => source.git?.git_repository) + .with({ serviceType: 'TERRAFORM' }, ({ terraform_files_source }) => terraform_files_source?.git?.git_repository) + .with({ serviceType: 'ARGOCD_APP' }, ({ git_repository }) => git_repository) + .otherwise(() => undefined) const containerImage = match(service) .with({ serviceType: ServiceTypeEnum.JOB, source: P.when(isJobContainerSource) }, ({ source }) => source.image) @@ -307,41 +285,14 @@ function ServiceHeaderMetadata({ service }: ServiceHeaderMetadataProps) { return (
- {match(service) - .with( - { serviceType: 'APPLICATION' }, - { - serviceType: 'JOB', - source: P.when(isJobGitSource), - }, - { - serviceType: 'TERRAFORM', - }, - { - serviceType: 'HELM', - source: P.when(isHelmGitSource), - }, - { serviceType: 'ARGOCD_APP' }, - (service) => { - const gitRepository = match(service) - .with({ serviceType: 'APPLICATION' }, ({ git_repository }) => git_repository) - .with({ serviceType: 'JOB' }, ({ source }) => source.docker?.git_repository) - .with({ serviceType: 'HELM' }, ({ source }) => source.git?.git_repository) - .with( - { serviceType: 'TERRAFORM' }, - ({ terraform_files_source }) => terraform_files_source?.git?.git_repository - ) - .with({ serviceType: 'ARGOCD_APP' }, ({ git_repository }) => git_repository) - .exhaustive() - - if (!gitRepository) { - return null - } - - return - } - ) - .otherwise(() => undefined)} + {gitRepository && + (blueprintId ? ( + }> + + + ) : ( + + ))} {isArgoCdService && 'manifest_revision' in service && service.manifest_revision && (