diff --git a/apps/console-v5/src/routes/_authenticated/organization/$organizationId/settings/argocd-integration.tsx b/apps/console-v5/src/routes/_authenticated/organization/$organizationId/settings/argocd-integration.tsx
new file mode 100644
index 00000000000..2db2918e3a1
--- /dev/null
+++ b/apps/console-v5/src/routes/_authenticated/organization/$organizationId/settings/argocd-integration.tsx
@@ -0,0 +1,25 @@
+import { createFileRoute } from '@tanstack/react-router'
+import { SettingsArgoCdIntegration, type SettingsArgoCdIntegrationUseCase } from '@qovery/domains/organizations/feature'
+import { useUseCasePage } from '../../../../../app/components/use-cases/use-case-context'
+
+const PAGE_ID = 'org-settings-argocd-integration'
+const USE_CASE_OPTIONS: { id: SettingsArgoCdIntegrationUseCase; label: string }[] = [
+ { id: 'empty-state', label: 'Empty state' },
+ { id: 'loading-integration', label: 'Importing' },
+ { id: 'loaded', label: 'Loaded' },
+ { id: 'loaded-single-cluster', label: 'Loaded (1 cluster)' },
+]
+
+export const Route = createFileRoute('/_authenticated/organization/$organizationId/settings/argocd-integration')({
+ component: RouteComponent,
+})
+
+function RouteComponent() {
+ const { selectedCaseId } = useUseCasePage({
+ pageId: PAGE_ID,
+ options: USE_CASE_OPTIONS,
+ defaultCaseId: 'empty-state',
+ })
+
+ return
+}
diff --git a/apps/console-v5/src/routes/_authenticated/organization/$organizationId/settings/route.tsx b/apps/console-v5/src/routes/_authenticated/organization/$organizationId/settings/route.tsx
index be2b95252e3..ed46f416f7e 100644
--- a/apps/console-v5/src/routes/_authenticated/organization/$organizationId/settings/route.tsx
+++ b/apps/console-v5/src/routes/_authenticated/organization/$organizationId/settings/route.tsx
@@ -55,6 +55,12 @@ function RouteComponent() {
icon: 'box' as const,
}
+ const argoCdIntegrationLink = {
+ title: 'ArgoCD integration',
+ to: `${pathSettings}/argocd-integration`,
+ icon: 'link' as const,
+ }
+
const helmRepositoriesLink = {
title: 'Helm repositories',
to: `${pathSettings}/helm-repositories`,
@@ -103,6 +109,7 @@ function RouteComponent() {
teamLink,
billingPlansLink,
labelsAnnotationsLink,
+ argoCdIntegrationLink,
containerRegistriesLink,
helmRepositoriesLink,
cloudCredentialsLink,
diff --git a/apps/console-v5/src/routes/_authenticated/organization/route.tsx b/apps/console-v5/src/routes/_authenticated/organization/route.tsx
index 89aa1572e87..0ea88536637 100644
--- a/apps/console-v5/src/routes/_authenticated/organization/route.tsx
+++ b/apps/console-v5/src/routes/_authenticated/organization/route.tsx
@@ -2,6 +2,7 @@ import { type IconName } from '@fortawesome/fontawesome-common-types'
import { Outlet, createFileRoute, useLocation, useMatches, useParams } from '@tanstack/react-router'
import posthog from 'posthog-js'
import { Suspense, useEffect, useLayoutEffect, useRef, useState } from 'react'
+import { isFakeArgoCdService } from '@qovery/domains/environments/feature'
import { useServiceSummary } from '@qovery/domains/services/feature'
import { DevopsCopilotContext } from '@qovery/shared/devops-copilot/context'
import { DevopsCopilotTrigger } from '@qovery/shared/devops-copilot/feature'
@@ -202,6 +203,44 @@ const SERVICE_TABS: NavigationTab[] = [
},
]
+const SERVICE_TABS_ARGO: NavigationTab[] = [
+ {
+ id: 'overview',
+ label: 'Overview',
+ iconName: 'table-layout',
+ routeId:
+ '/_authenticated/organization/$organizationId/project/$projectId/environment/$environmentId/service/$serviceId/overview',
+ },
+ {
+ id: 'monitoring',
+ label: 'Monitoring',
+ iconName: 'chart-column',
+ routeId:
+ '/_authenticated/organization/$organizationId/project/$projectId/environment/$environmentId/service/$serviceId/monitoring',
+ },
+ {
+ id: 'service-logs',
+ label: 'Service logs',
+ iconName: 'scroll',
+ routeId:
+ '/_authenticated/organization/$organizationId/project/$projectId/environment/$environmentId/service/$serviceId/service-logs',
+ },
+ {
+ id: 'cloud-shell',
+ label: 'Cloud shell',
+ iconName: 'terminal',
+ routeId:
+ '/_authenticated/organization/$organizationId/project/$projectId/environment/$environmentId/service/$serviceId/cloud-shell',
+ },
+ {
+ id: 'manifest',
+ label: 'Manifest',
+ iconName: 'file-lines',
+ routeId:
+ '/_authenticated/organization/$organizationId/project/$projectId/environment/$environmentId/service/$serviceId/manifest',
+ },
+]
+
function createRoutePatternRegex(routeIdPattern: string): RegExp {
const patternPath = routeIdPattern.replace('/_authenticated/organization', '/organization')
return new RegExp('^' + patternPath.replace(/\$(\w+)/g, '[^/]+') + '(/.*)?$')
@@ -266,6 +305,10 @@ function useNavigationContext(): NavigationContext | null {
serviceId: params.serviceId,
enabled: Boolean(params.environmentId) && Boolean(params.serviceId),
})
+ const isArgoCdService =
+ typeof params.environmentId === 'string' &&
+ typeof params.serviceId === 'string' &&
+ isFakeArgoCdService({ environmentId: params.environmentId, serviceId: params.serviceId })
for (const context of NAVIGATION_CONTEXTS) {
const patternRegex = createRoutePatternRegex(context.routeIdPattern)
@@ -291,12 +334,13 @@ function useNavigationContext(): NavigationContext | null {
// Managed databases should not have cloud shell access.
// Databases should not expose the variables tab.
+ const serviceTabs = isArgoCdService ? SERVICE_TABS_ARGO : context.tabs
const tabs =
context.type === 'service'
- ? context.tabs.filter(
+ ? serviceTabs.filter(
(tab) => !(isDatabase && tab.id === 'variables') && !(isManagedDatabase && tab.id === 'cloud-shell')
)
- : context.tabs
+ : serviceTabs
return {
type: context.type,
@@ -400,6 +444,7 @@ const fullWidthRouteIds: FileRouteTypes['id'][] = [
'/_authenticated/organization/$organizationId/settings',
'/_authenticated/organization/$organizationId/audit-logs',
'/_authenticated/organization/$organizationId/project/$projectId/environment/$environmentId/service/$serviceId/monitoring',
+ '/_authenticated/organization/$organizationId/project/$projectId/environment/$environmentId/service/$serviceId/manifest',
'/_authenticated/organization/$organizationId/project/$projectId/environment/$environmentId/service/$serviceId/settings',
'/_authenticated/organization/$organizationId/project/$projectId/environment/$environmentId/service/$serviceId/service-logs',
'/_authenticated/organization/$organizationId/project/$projectId/environment/$environmentId/service/$serviceId/deployments/logs/$executionId',
diff --git a/apps/console-v5/vite.config.ts b/apps/console-v5/vite.config.ts
index d7685a6f7bb..7411db3243e 100644
--- a/apps/console-v5/vite.config.ts
+++ b/apps/console-v5/vite.config.ts
@@ -3,12 +3,39 @@ import { nxCopyAssetsPlugin } from '@nx/vite/plugins/nx-copy-assets.plugin'
import { nxViteTsPaths } from '@nx/vite/plugins/nx-tsconfig-paths.plugin'
import { tanstackRouter } from '@tanstack/router-plugin/vite'
import react from '@vitejs/plugin-react'
+import { execSync } from 'child_process'
import { join } from 'path'
import { defineConfig, loadEnv } from 'vite'
import { viteStaticCopy } from 'vite-plugin-static-copy'
+const readGitValue = (command: string): string | undefined => {
+ try {
+ const value = execSync(command, {
+ cwd: process.cwd(),
+ stdio: ['ignore', 'pipe', 'ignore'],
+ })
+ .toString()
+ .trim()
+
+ return value || undefined
+ } catch {
+ return undefined
+ }
+}
+
export default defineConfig(({ mode }) => {
const clientEnv = loadEnv(mode, process.cwd(), '')
+ const gitBranch =
+ clientEnv.NX_PUBLIC_GIT_BRANCH || clientEnv.NX_BRANCH || readGitValue('git rev-parse --abbrev-ref HEAD')
+ const gitSha = clientEnv.NX_PUBLIC_GIT_SHA || readGitValue('git rev-parse --short HEAD')
+
+ if (gitBranch) {
+ clientEnv.NX_PUBLIC_GIT_BRANCH = gitBranch
+ }
+
+ if (gitSha) {
+ clientEnv.NX_PUBLIC_GIT_SHA = gitSha
+ }
return {
root: __dirname,
diff --git a/libs/domains/environments/feature/src/index.ts b/libs/domains/environments/feature/src/index.ts
index 60b40294cc5..2a2ba6c0ee0 100644
--- a/libs/domains/environments/feature/src/index.ts
+++ b/libs/domains/environments/feature/src/index.ts
@@ -36,3 +36,4 @@ export * from './lib/settings-preview-environments/settings-preview-environments
export * from './lib/settings-danger-zone/settings-danger-zone'
export * from './lib/environment-deployment-list/environment-deployment-list-skeleton'
export * from './lib/environment-last-deployment-section/environment-last-deployment-section'
+export * from './lib/fake-argocd-mode/fake-argocd-mode'
diff --git a/libs/domains/environments/feature/src/lib/create-clone-environment-modal/create-clone-environment-modal.spec.tsx b/libs/domains/environments/feature/src/lib/create-clone-environment-modal/create-clone-environment-modal.spec.tsx
index e1eef8c7091..f7a72272f35 100644
--- a/libs/domains/environments/feature/src/lib/create-clone-environment-modal/create-clone-environment-modal.spec.tsx
+++ b/libs/domains/environments/feature/src/lib/create-clone-environment-modal/create-clone-environment-modal.spec.tsx
@@ -83,6 +83,14 @@ describe('CreateCloneEnvironmentModal', () => {
})
describe('cloning mode', function () {
+ it('should display ArgoCD hybrid callout', () => {
+ const mockEnv = environmentFactoryMock(1)[0]
+
+ renderWithProviders(
)
+
+ expect(screen.getByText('ArgoCD imported services will not be cloned.')).toBeInTheDocument()
+ })
+
it('should submit form on click on button', async () => {
const mockEnv = environmentFactoryMock(1)[0]
const { userEvent } = renderWithProviders(
)
diff --git a/libs/domains/environments/feature/src/lib/create-clone-environment-modal/create-clone-environment-modal.tsx b/libs/domains/environments/feature/src/lib/create-clone-environment-modal/create-clone-environment-modal.tsx
index fc0d9b52804..17a2fc07bde 100644
--- a/libs/domains/environments/feature/src/lib/create-clone-environment-modal/create-clone-environment-modal.tsx
+++ b/libs/domains/environments/feature/src/lib/create-clone-environment-modal/create-clone-environment-modal.tsx
@@ -10,7 +10,7 @@ import { Controller, FormProvider, useForm } from 'react-hook-form'
import { P, match } from 'ts-pattern'
import { useClusters } from '@qovery/domains/clusters/feature'
import { useProjects } from '@qovery/domains/projects/feature'
-import { ExternalLink, Icon, InputSelect, InputText, ModalCrud, useModal } from '@qovery/shared/ui'
+import { Callout, ExternalLink, Icon, InputSelect, InputText, ModalCrud, useModal } from '@qovery/shared/ui'
import { EnvironmentMode } from '../environment-mode/environment-mode'
import { useCloneEnvironment } from '../hooks/use-clone-environment/use-clone-environment'
import { useCreateEnvironment } from '../hooks/use-create-environment/use-create-environment'
@@ -19,6 +19,7 @@ export interface CreateCloneEnvironmentModalProps {
projectId: string
organizationId: string
environmentToClone?: Environment
+ isArgoCdHybrid?: boolean
onClose: () => void
type?: EnvironmentModeEnum
}
@@ -27,6 +28,7 @@ export function CreateCloneEnvironmentModal({
projectId,
organizationId,
environmentToClone,
+ isArgoCdHybrid = false,
onClose,
type,
}: CreateCloneEnvironmentModalProps) {
@@ -271,6 +273,14 @@ export function CreateCloneEnvironmentModal({
/>
)}
/>
+ {environmentToClone && isArgoCdHybrid && (
+
+
+
+
+ ArgoCD imported services will not be cloned.
+
+ )}
)
diff --git a/libs/domains/environments/feature/src/lib/environment-action-toolbar/environment-action-toolbar.tsx b/libs/domains/environments/feature/src/lib/environment-action-toolbar/environment-action-toolbar.tsx
index d779d7a2624..0ea95d51f62 100644
--- a/libs/domains/environments/feature/src/lib/environment-action-toolbar/environment-action-toolbar.tsx
+++ b/libs/domains/environments/feature/src/lib/environment-action-toolbar/environment-action-toolbar.tsx
@@ -30,13 +30,34 @@ import { UpdateAllModal } from '../update-all-modal/update-all-modal'
type ActionToolbarVariant = 'default' | 'header'
+function ArgoCdHybridDeployInfoModal({ onUnderstood }: { onUnderstood: () => void }) {
+ return (
+
+
Environment actions will only affect Qovery services
+
+ This environment contains both Qovery and ArgoCD services. Bulk actions such as deploy, redeploy, or restart
+ only apply to Qovery services. ArgoCD services are managed and deployed through ArgoCD.
+
+
+
+ Understood!
+
+
+
+ )
+}
+
export function MenuManageDeployment({
environment,
deploymentStatus,
+ redeployTooltip,
+ requireArgoCdHybridAck = false,
variant = 'default',
}: {
environment: Environment
deploymentStatus: EnvironmentStatus
+ redeployTooltip?: string
+ requireArgoCdHybridAck?: boolean
variant?: ActionToolbarVariant
}) {
const state = deploymentStatus.state
@@ -48,11 +69,16 @@ export function MenuManageDeployment({
)
+ const tooltipInfo = (content: string) => (
+
+
+
+ )
const tooltipEnvironmentNeedUpdate =
displayYellowColor && tooltipService('Environment has changed and needs to be applied')
- const { openModal } = useModal()
+ const { openModal, closeModal } = useModal()
const { openModalConfirmation } = useModalConfirmation()
const logsLink =
@@ -72,21 +98,48 @@ export function MenuManageDeployment({
// https://qovery.atlassian.net/jira/software/projects/FRT/boards/23?selectedIssue=FRT-1416
const { data: services = [] } = useServices({ environmentId: environment.id })
- const mutationDeploy = () =>
+ const executeDeploy = () =>
deployEnvironment({
environmentId: environment.id,
})
- const mutationRedeploy = () => {
- openModalConfirmation({
- mode: environment.mode,
- title: 'Confirm redeploy',
- description: 'To confirm the redeploy of your environment, please type the name:',
- name: environment.name,
- action: () => deployEnvironment({ environmentId: environment.id }),
+ const withArgoCdHybridInfoModal = (action: () => void) => {
+ if (!requireArgoCdHybridAck) {
+ action()
+ return
+ }
+
+ openModal({
+ content: (
+
{
+ closeModal()
+ action()
+ }}
+ />
+ ),
+ options: {
+ width: 488,
+ },
})
}
+ const mutationDeploy = () => {
+ withArgoCdHybridInfoModal(executeDeploy)
+ }
+
+ const mutationRedeploy = () => {
+ withArgoCdHybridInfoModal(() =>
+ openModalConfirmation({
+ mode: environment.mode,
+ title: 'Confirm redeploy',
+ description: 'To confirm the redeploy of your environment, please type the name:',
+ name: environment.name,
+ action: () => deployEnvironment({ environmentId: environment.id }),
+ })
+ )
+ }
+
const mutationStop = () => {
const hasDatabase = services.some(
(service) =>
@@ -95,27 +148,31 @@ export function MenuManageDeployment({
(service.type === 'POSTGRESQL' || service.type === 'MYSQL')
)
- openModalConfirmation({
- mode: environment.mode,
- title: 'Confirm stop',
- description: 'To confirm the stopping of your environment, please type the name:',
- warning: hasDatabase
- ? "RDS instances are automatically restarted by AWS after 7 days. After 7 days, Qovery won't pause it again for you."
- : null,
- name: environment.name,
- action: () => stopEnvironment({ environmentId: environment.id }),
- })
+ withArgoCdHybridInfoModal(() =>
+ openModalConfirmation({
+ mode: environment.mode,
+ title: 'Confirm stop',
+ description: 'To confirm the stopping of your environment, please type the name:',
+ warning: hasDatabase
+ ? "RDS instances are automatically restarted by AWS after 7 days. After 7 days, Qovery won't pause it again for you."
+ : null,
+ name: environment.name,
+ action: () => stopEnvironment({ environmentId: environment.id }),
+ })
+ )
}
const mutationUninstall = () => {
- openModalConfirmation({
- mode: 'PRODUCTION',
- title: 'Confirm uninstall',
- description: 'To confirm the uninstall of your environment, please type the name:',
- warning: 'Uninstall delete all compute and data of your service',
- name: environment.name,
- action: () => uninstallEnvironment({ environmentId: environment.id }),
- })
+ withArgoCdHybridInfoModal(() =>
+ openModalConfirmation({
+ mode: 'PRODUCTION',
+ title: 'Confirm uninstall',
+ description: 'To confirm the uninstall of your environment, please type the name:',
+ warning: 'Uninstall delete all compute and data of your service',
+ name: environment.name,
+ action: () => uninstallEnvironment({ environmentId: environment.id }),
+ })
+ )
}
const mutationCancelDeployment = () => {
@@ -130,12 +187,14 @@ export function MenuManageDeployment({
}
const openUpdateAllModal = () => {
- openModal({
- content: ,
- options: {
- width: 676,
- },
- })
+ withArgoCdHybridInfoModal(() =>
+ openModal({
+ content: ,
+ options: {
+ width: 676,
+ },
+ })
+ )
}
return (
@@ -196,7 +255,7 @@ export function MenuManageDeployment({
>
Redeploy
- {tooltipEnvironmentNeedUpdate}
+ {redeployTooltip ? tooltipInfo(redeployTooltip) : tooltipEnvironmentNeedUpdate}
)}
@@ -246,10 +305,12 @@ export function MenuManageDeployment({
export function MenuOtherActions({
state,
environment,
+ isArgoCdHybrid = false,
variant = 'default',
}: {
state: StateEnum
environment: Environment
+ isArgoCdHybrid?: boolean
variant?: ActionToolbarVariant
}) {
const { openModal, closeModal } = useModal()
@@ -281,6 +342,7 @@ export function MenuOtherActions({
projectId={environment.project.id}
organizationId={environment.organization.id}
environmentToClone={environment}
+ isArgoCdHybrid={isArgoCdHybrid}
/>
),
options: {
@@ -345,6 +407,82 @@ export function MenuOtherActions({
)
}
+export function MenuArgoCdOnlyActions({
+ environment,
+ variant = 'default',
+}: {
+ environment: Environment
+ variant?: ActionToolbarVariant
+}) {
+ const { openModal } = useModal()
+ const [, copyToClipboard] = useCopyToClipboard()
+ const copyContent = `Cluster ID: ${environment.cluster_id}\nOrganization ID: ${environment.organization.id}\nProject ID: ${environment.project.id}\nEnvironment ID: ${environment.id}`
+
+ const openTerraformExportModal = () => {
+ openModal({
+ content: ,
+ })
+ }
+
+ return (
+
+
+
+
+
+
+
+
+
+
+
+ } asChild>
+
+ See audit logs
+
+
+ } onSelect={() => copyToClipboard(copyContent)}>
+ Copy identifier
+
+ } onSelect={openTerraformExportModal}>
+ Export as Terraform
+
+
+ }
+ color="neutral"
+ disabled
+ className="cursor-not-allowed data-[highlighted]:bg-transparent"
+ >
+
+ Delete environment
+
+
+
+
+
+
+
+ )
+}
+
export interface EnvironmentActionToolbarProps {
environment: Environment
variant?: ActionToolbarVariant
diff --git a/libs/domains/environments/feature/src/lib/environments-table/environment-section/environment-section.spec.tsx b/libs/domains/environments/feature/src/lib/environments-table/environment-section/environment-section.spec.tsx
index 05227c201d8..df99313d934 100644
--- a/libs/domains/environments/feature/src/lib/environments-table/environment-section/environment-section.spec.tsx
+++ b/libs/domains/environments/feature/src/lib/environments-table/environment-section/environment-section.spec.tsx
@@ -32,9 +32,19 @@ jest.mock('../../hooks/use-environments/use-environments', () => ({
}),
}))
+jest.mock('../../fake-argocd-mode/fake-argocd-mode', () => ({
+ getFakeArgoCdMode: () => 'hybrid',
+}))
+
jest.mock('../../environment-action-toolbar/environment-action-toolbar', () => ({
MenuManageDeployment: () => Manage deployment ,
MenuOtherActions: () => Delete environment ,
+ MenuArgoCdOnlyActions: () => ArgoCD other actions ,
+}))
+
+jest.mock('../../environment-state-chip/environment-state-chip', () => ({
+ __esModule: true,
+ default: () => Status ,
}))
const overview: EnvironmentOverviewResponse = {
diff --git a/libs/domains/environments/feature/src/lib/environments-table/environment-section/environment-section.tsx b/libs/domains/environments/feature/src/lib/environments-table/environment-section/environment-section.tsx
index 5452de238a2..ba79639c4ea 100644
--- a/libs/domains/environments/feature/src/lib/environments-table/environment-section/environment-section.tsx
+++ b/libs/domains/environments/feature/src/lib/environments-table/environment-section/environment-section.tsx
@@ -4,18 +4,24 @@ import { type KeyboardEvent, type MouseEvent } from 'react'
import { useMediaQuery } from 'react-responsive'
import { match } from 'ts-pattern'
import { ClusterAvatar } from '@qovery/domains/clusters/feature'
-import { Button, DeploymentAction, Heading, Icon, Section, TablePrimitives, Truncate } from '@qovery/shared/ui'
+import { Button, DeploymentAction, Heading, Icon, Section, TablePrimitives, Tooltip, Truncate } from '@qovery/shared/ui'
import { timeAgo } from '@qovery/shared/util-dates'
import { pluralize, twMerge } from '@qovery/shared/util-js'
-import { MenuManageDeployment, MenuOtherActions } from '../../environment-action-toolbar/environment-action-toolbar'
+import {
+ MenuArgoCdOnlyActions,
+ MenuManageDeployment,
+ MenuOtherActions,
+} from '../../environment-action-toolbar/environment-action-toolbar'
import EnvironmentMode from '../../environment-mode/environment-mode'
import EnvironmentStateChip from '../../environment-state-chip/environment-state-chip'
+import { getFakeArgoCdMode } from '../../fake-argocd-mode/fake-argocd-mode'
import useEnvironments from '../../hooks/use-environments/use-environments'
const { Table } = TablePrimitives
const gridLayoutClassName =
'grid w-full grid-cols-[minmax(280px,2fr)_minmax(220px,1.4fr)_minmax(240px,1.2fr)_minmax(140px,1fr)_96px]'
+const ARGOCD_HYBRID_REDEPLOY_TOOLTIP = 'Redeploy will only target Qovery created services and not ArgoCD imported ones.'
function EnvRow({ overview }: { overview: EnvironmentOverviewResponse }) {
const navigate = useNavigate()
@@ -29,6 +35,10 @@ function EnvRow({ overview }: { overview: EnvironmentOverviewResponse }) {
const isVeryLargeScreen = useMediaQuery({
query: '(min-width: 1536px)',
})
+ const argoCdMode = getFakeArgoCdMode(overview.id)
+ const shouldDisplayArgoCdTag = argoCdMode !== 'none'
+ const isArgoCdOnly = argoCdMode === 'argocd-only'
+ const isArgoCdHybrid = argoCdMode === 'hybrid'
const stopRowNavigation = (event: MouseEvent | KeyboardEvent) => {
event.stopPropagation()
@@ -57,9 +67,16 @@ function EnvRow({ overview }: { overview: EnvironmentOverviewResponse }) {
>
-
-
-
+
+
+
+
+ {shouldDisplayArgoCdTag && (
+
+ ARGOCD
+
+ )}
+
{overview.service_count} {pluralize(overview.service_count, 'service')}
@@ -70,13 +87,19 @@ function EnvRow({ overview }: { overview: EnvironmentOverviewResponse }) {
-
-
-
- {timeAgo(new Date(overview.deployment_status?.last_deployment_date ?? Date.now()))} ago
-
-
-
+ {isArgoCdOnly ? (
+
No operation detected
+ ) : (
+ <>
+
+
+
+ {timeAgo(new Date(overview.deployment_status?.last_deployment_date ?? Date.now()))} ago
+
+
+
+ >
+ )}
@@ -106,10 +129,31 @@ function EnvRow({ overview }: { overview: EnvironmentOverviewResponse }) {
onClick={stopRowNavigation}
onKeyDown={stopRowNavigation}
>
- {environment && overview.deployment_status && overview.service_count > 0 && (
+ {environment && overview.deployment_status && overview.service_count > 0 && isArgoCdOnly && (
+ <>
+
+
+
+
+
+
+
+
+ >
+ )}
+ {environment && overview.deployment_status && overview.service_count > 0 && !isArgoCdOnly && (
<>
-
-
+
+
>
)}
diff --git a/libs/domains/environments/feature/src/lib/fake-argocd-mode/fake-argocd-mode.spec.ts b/libs/domains/environments/feature/src/lib/fake-argocd-mode/fake-argocd-mode.spec.ts
new file mode 100644
index 00000000000..f3eb947abee
--- /dev/null
+++ b/libs/domains/environments/feature/src/lib/fake-argocd-mode/fake-argocd-mode.spec.ts
@@ -0,0 +1,55 @@
+import { getFakeArgoCdMode, isFakeArgoCdService } from './fake-argocd-mode'
+
+describe('getFakeArgoCdMode', () => {
+ it('should return a deterministic mode for the same seed', () => {
+ expect(getFakeArgoCdMode('env-1')).toBe(getFakeArgoCdMode('env-1'))
+ })
+
+ it('should return only supported modes', () => {
+ const mode = getFakeArgoCdMode('env-2')
+
+ expect(['none', 'argocd-only', 'hybrid']).toContain(mode)
+ })
+
+ it('should roughly match target probabilities', () => {
+ const seeds = Array.from({ length: 10000 }, (_, index) => `env-${index + 1}`)
+ const modes = seeds.map((seed) => getFakeArgoCdMode(seed))
+ const displayedModes = modes.filter((mode) => mode !== 'none')
+ const displayedRate = displayedModes.length / modes.length
+ const onlyRate = displayedModes.filter((mode) => mode === 'argocd-only').length / displayedModes.length
+
+ expect(displayedRate).toBeGreaterThan(0.66)
+ expect(displayedRate).toBeLessThan(0.74)
+ expect(onlyRate).toBeGreaterThan(0.66)
+ expect(onlyRate).toBeLessThan(0.74)
+ })
+})
+
+describe('isFakeArgoCdService', () => {
+ const findSeedByMode = (mode: ReturnType
) => {
+ const match = Array.from({ length: 10000 }, (_, index) => `env-${index + 1}`).find(
+ (seed) => getFakeArgoCdMode(seed) === mode
+ )
+ if (!match) {
+ throw new Error(`No seed found for mode: ${mode}`)
+ }
+ return match
+ }
+
+ it('should be deterministic for the same environment and service', () => {
+ expect(isFakeArgoCdService({ environmentId: 'env-1', serviceId: 'svc-1' })).toBe(
+ isFakeArgoCdService({ environmentId: 'env-1', serviceId: 'svc-1' })
+ )
+ })
+
+ it('should return false when env mode is none', () => {
+ const environmentId = findSeedByMode('none')
+ expect(isFakeArgoCdService({ environmentId, serviceId: 'svc-1' })).toBe(false)
+ })
+
+ it('should return true when env mode is argocd-only', () => {
+ const environmentId = findSeedByMode('argocd-only')
+ expect(isFakeArgoCdService({ environmentId, serviceId: 'svc-1' })).toBe(true)
+ expect(isFakeArgoCdService({ environmentId, serviceId: 'svc-2' })).toBe(true)
+ })
+})
diff --git a/libs/domains/environments/feature/src/lib/fake-argocd-mode/fake-argocd-mode.ts b/libs/domains/environments/feature/src/lib/fake-argocd-mode/fake-argocd-mode.ts
new file mode 100644
index 00000000000..1f070576635
--- /dev/null
+++ b/libs/domains/environments/feature/src/lib/fake-argocd-mode/fake-argocd-mode.ts
@@ -0,0 +1,49 @@
+export type FakeArgoCdMode = 'none' | 'argocd-only' | 'hybrid'
+
+const ARGO_CD_TAG_DISPLAY_THRESHOLD = 0.7
+const ARGO_CD_ONLY_THRESHOLD = 0.7
+const ARGO_CD_HYBRID_SERVICE_THRESHOLD = 0.5
+
+function seededRandom(seed: string): number {
+ // FNV-1a hash to keep pseudo-random values stable for a given seed.
+ let hash = 2166136261
+
+ for (let i = 0; i < seed.length; i++) {
+ hash ^= seed.charCodeAt(i)
+ hash = Math.imul(hash, 16777619)
+ }
+
+ return (hash >>> 0) / 4294967295
+}
+
+export function getFakeArgoCdMode(seed: string): FakeArgoCdMode {
+ const normalizedSeed = seed.trim() || 'default'
+ const shouldDisplayArgoCdTag = seededRandom(`${normalizedSeed}:display`) < ARGO_CD_TAG_DISPLAY_THRESHOLD
+
+ if (!shouldDisplayArgoCdTag) {
+ return 'none'
+ }
+
+ return seededRandom(`${normalizedSeed}:mode`) < ARGO_CD_ONLY_THRESHOLD ? 'argocd-only' : 'hybrid'
+}
+
+export function isFakeArgoCdService({
+ environmentId,
+ serviceId,
+}: {
+ environmentId: string
+ serviceId: string
+}): boolean {
+ const normalizedEnvironmentId = environmentId.trim() || 'default'
+ const mode = getFakeArgoCdMode(normalizedEnvironmentId)
+
+ if (mode === 'none') {
+ return false
+ }
+
+ if (mode === 'argocd-only') {
+ return true
+ }
+
+ return seededRandom(`${normalizedEnvironmentId}:${serviceId}:bucket`) < ARGO_CD_HYBRID_SERVICE_THRESHOLD
+}
diff --git a/libs/domains/environments/feature/src/lib/settings-preview-environments/settings-preview-environments.tsx b/libs/domains/environments/feature/src/lib/settings-preview-environments/settings-preview-environments.tsx
index 835dd3cac97..6824cffa383 100644
--- a/libs/domains/environments/feature/src/lib/settings-preview-environments/settings-preview-environments.tsx
+++ b/libs/domains/environments/feature/src/lib/settings-preview-environments/settings-preview-environments.tsx
@@ -7,8 +7,9 @@ import { type AnyService } from '@qovery/domains/services/data-access'
import { useEditService, useServices } from '@qovery/domains/services/feature'
import { SettingsHeading } from '@qovery/shared/console-shared'
import { IconEnum } from '@qovery/shared/enums'
-import { BlockContent, Button, Icon, InputToggle, Section } from '@qovery/shared/ui'
+import { BlockContent, Button, Callout, Icon, InputToggle, Section } from '@qovery/shared/ui'
import { buildEditServicePayload } from '@qovery/shared/util-services'
+import { getFakeArgoCdMode } from '../fake-argocd-mode/fake-argocd-mode'
import { useDeploymentRule } from '../hooks/use-deployment-rule/use-deployment-rule'
import { useEditDeploymentRule } from '../hooks/use-edit-deployment-rule/use-edit-deployment-rule'
@@ -18,10 +19,11 @@ interface PageSettingsPreviewEnvironmentsProps {
loading: boolean
toggleAll: (value: boolean) => void
toggleEnablePreview: (value: boolean) => void
+ showArgoCdCallout?: boolean
}
export function PageSettingsPreviewEnvironments(props: PageSettingsPreviewEnvironmentsProps) {
- const { onSubmit, services, loading, toggleAll, toggleEnablePreview } = props
+ const { onSubmit, services, loading, toggleAll, toggleEnablePreview, showArgoCdCallout } = props
const { control, formState } = useFormContext()
const getIconName = (service: AnyService) =>
@@ -36,6 +38,16 @@ export function PageSettingsPreviewEnvironments(props: PageSettingsPreviewEnviro
@@ -488,7 +485,7 @@ function MenuManageDeployment({
icon={ }
onSelect={() => restartService({ serviceId: service.id, serviceType: service.serviceType })}
>
- Restart Service
+ {isArgoCdService ? 'Rollout' : 'Restart Service'}
)}
{service.serviceType === 'JOB' &&
@@ -907,10 +904,12 @@ export function ServiceActions({
environment,
serviceId,
variant = 'default',
+ isArgoCdService = false,
}: {
environment: Environment
serviceId: string
variant?: ActionToolbarVariant
+ isArgoCdService?: boolean
}) {
const { data: service } = useService({ environmentId: environment.id, serviceId })
const { data: deploymentStatus } = useDeploymentStatus({ environmentId: environment.id, serviceId })
@@ -925,6 +924,7 @@ export function ServiceActions({
environment={environment}
service={service}
variant={variant}
+ isArgoCdService={isArgoCdService}
/>
{variant === 'default' && (
diff --git a/libs/domains/services/feature/src/lib/service-icon/service-icon.tsx b/libs/domains/services/feature/src/lib/service-icon/service-icon.tsx
index 06b39298323..9ec171c8dfd 100644
--- a/libs/domains/services/feature/src/lib/service-icon/service-icon.tsx
+++ b/libs/domains/services/feature/src/lib/service-icon/service-icon.tsx
@@ -65,6 +65,7 @@ const serviceIcons = {
'app://qovery-console/container': { icon: '/assets/services/application.svg', title: 'Container' },
'app://qovery-console/database': { icon: '/assets/services/database.svg', title: 'Database' },
'app://qovery-console/helm': { icon: '/assets/services/helm.svg', title: 'Helm' },
+ 'app://qovery-console/argocd': { icon: '/assets/services/argocd.svg', title: 'ArgoCD' },
'app://qovery-console/application': {
icon: '/assets/services/application.svg',
title: 'Application',
diff --git a/libs/domains/services/feature/src/lib/service-list/service-list-cells/service-last-deployment-cell.tsx b/libs/domains/services/feature/src/lib/service-list/service-list-cells/service-last-deployment-cell.tsx
index 9964d9d8588..a78c38de071 100644
--- a/libs/domains/services/feature/src/lib/service-list/service-list-cells/service-last-deployment-cell.tsx
+++ b/libs/domains/services/feature/src/lib/service-list/service-list-cells/service-last-deployment-cell.tsx
@@ -8,6 +8,7 @@ type ServiceLastDeploymentCellProps = {
organizationId: string
projectId: string
environmentId: string
+ timeLabelOverride?: string
}
export function ServiceLastDeploymentCell({
@@ -15,10 +16,19 @@ export function ServiceLastDeploymentCell({
organizationId,
projectId,
environmentId,
+ timeLabelOverride,
}: ServiceLastDeploymentCellProps) {
- const { data: deploymentStatus } = useDeploymentStatus({ environmentId: environmentId, serviceId: service.id })
+ const hasTimeOverride = Boolean(timeLabelOverride)
+ const { data: deploymentStatus } = useDeploymentStatus({
+ environmentId: hasTimeOverride ? undefined : environmentId,
+ serviceId: hasTimeOverride ? undefined : service.id,
+ })
const date = deploymentStatus?.last_deployment_date
+ if (timeLabelOverride) {
+ return {timeLabelOverride}
+ }
+
return date ? (
{
@@ -58,6 +74,102 @@ export function ServiceNameCell({ service, environment }: { service: AnyService;
.otherwise(() => null)
}
+ if (argocdOperationLabelOverride) {
+ const isOutOfSync = argocdStatusLabelOverride === 'Out of sync'
+ const serviceAvatar = {
+ ...service,
+ icon_uri: 'app://qovery-console/argocd',
+ }
+
+ return (
+
+
+
+
+
+
+ {service.name}
+
+
+
+
+ e.stopPropagation()}>
+
+
+
+
+ e.stopPropagation()}
+ >
+
+
+
+
+
+ e.stopPropagation()}
+ >
+
+
+
+ e.stopPropagation()}>
+ {isOutOfSync && }>Force sync}
+ } asChild>
+
+ See audit logs
+
+
+ } onSelect={() => copyToClipboard(service.id)}>
+ Copy identifier
+
+ } asChild>
+
+ See manifest
+
+
+
+
+
+
+ )
+ }
+
return (
diff --git a/libs/domains/services/feature/src/lib/service-list/service-list-cells/service-running-status-cell.tsx b/libs/domains/services/feature/src/lib/service-list/service-list-cells/service-running-status-cell.tsx
index 8f857e57df6..77c3d94688f 100644
--- a/libs/domains/services/feature/src/lib/service-list/service-list-cells/service-running-status-cell.tsx
+++ b/libs/domains/services/feature/src/lib/service-list/service-list-cells/service-running-status-cell.tsx
@@ -14,6 +14,7 @@ type ServiceRunningStatusCellProps = {
projectId: string
environment: Environment
clusterId: string
+ statusLabelOverride?: 'Synced' | 'Out of sync'
}
export function ServiceRunningStatusCell({
@@ -22,16 +23,32 @@ export function ServiceRunningStatusCell({
projectId,
environment,
clusterId,
+ statusLabelOverride,
}: ServiceRunningStatusCellProps) {
- const { data } = useServiceDeploymentAndRunningStatuses({ environmentId: environment.id, service })
+ const hasStatusOverride = Boolean(statusLabelOverride)
+ const { data } = useServiceDeploymentAndRunningStatuses({
+ environmentId: hasStatusOverride ? '' : environment.id,
+ service: hasStatusOverride ? undefined : service,
+ })
const { runningStatus, deploymentStatus } = data
const { setDevopsCopilotOpen, sendMessageRef } = useContext(DevopsCopilotContext)
const { data: checkRunningStatusClosed } = useCheckRunningStatusClosed({
- clusterId,
- environmentId: environment.id,
+ clusterId: hasStatusOverride ? '' : clusterId,
+ environmentId: hasStatusOverride ? '' : environment.id,
})
+ if (statusLabelOverride) {
+ return (
+
+
+
+ {statusLabelOverride}
+
+
+ )
+ }
+
const Wrapper = ({ children }: PropsWithChildren) => {children}
const value = match(runningStatus?.triggered_action)
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 0cd96651c06..c84492952f5 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
@@ -29,9 +29,19 @@ type ServiceVersionCellProps = {
service: AnyService
organizationId: string
projectId: string
+ versionOverride?: { primary: string; secondary: string }
}
-export function ServiceVersionCell({ service, organizationId, projectId }: ServiceVersionCellProps) {
+export function ServiceVersionCell({ service, organizationId, projectId, versionOverride }: ServiceVersionCellProps) {
+ if (versionOverride) {
+ return (
+
+ {versionOverride.primary}
+ {versionOverride.secondary}
+
+ )
+ }
+
const gitInfo = (service: Application | Job | Helm | Terraform, gitRepository?: ApplicationGitRepository) =>
gitRepository && (
e.stopPropagation()}>
diff --git a/libs/domains/services/feature/src/lib/service-list/service-list.spec.tsx b/libs/domains/services/feature/src/lib/service-list/service-list.spec.tsx
index f1e4097988a..bf1b202a509 100644
--- a/libs/domains/services/feature/src/lib/service-list/service-list.spec.tsx
+++ b/libs/domains/services/feature/src/lib/service-list/service-list.spec.tsx
@@ -1,3 +1,4 @@
+import { within } from '@testing-library/react'
import type { ReactNode } from 'react'
import { renderWithProviders, screen } from '@qovery/shared/util-tests'
import { ServiceList, type ServiceListProps } from './service-list'
@@ -485,4 +486,45 @@ describe('ServiceList', () => {
mockDeploymentStagesData = undefined
})
+
+ it('should hide selection checkboxes when selection is disabled', () => {
+ renderWithProviders(
)
+ expect(screen.queryByRole('checkbox')).not.toBeInTheDocument()
+ })
+
+ it('should display force sync action for out of sync argocd services', async () => {
+ const { userEvent } = renderWithProviders(
+
+ )
+
+ const serviceRow = screen.getByText(/front-end/i).closest('tr')
+ expect(serviceRow).toBeTruthy()
+ if (!serviceRow) return
+
+ await userEvent.click(within(serviceRow).getByLabelText(/more actions/i))
+
+ expect(await screen.findByRole('menuitem', { name: /force sync/i })).toBeInTheDocument()
+ })
+
+ it('should not display force sync action for synced argocd services', async () => {
+ const { userEvent } = renderWithProviders(
+
+ )
+
+ const serviceRow = screen.getByText(/front-end/i).closest('tr')
+ expect(serviceRow).toBeTruthy()
+ if (!serviceRow) return
+
+ await userEvent.click(within(serviceRow).getByLabelText(/more actions/i))
+
+ expect(screen.queryByRole('menuitem', { name: /force sync/i })).not.toBeInTheDocument()
+ })
})
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 30f608fa392..03d19b90f4f 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
@@ -39,12 +39,32 @@ import {
} from './service-list-cells'
const { Table } = TablePrimitives
+type ServiceListRows = ReturnType
['data']
+type ServiceListRow = ServiceListRows[number]
+type ArgoCdServiceStatus = 'Synced' | 'Out of sync'
export interface ServiceListProps extends ComponentProps {
environment: Environment
+ enableSelection?: boolean
+ servicesOverride?: ServiceListRows
+ argocdStatusByServiceId?: Record
+ argocdOperationByServiceId?: Record
+ argocdTargetVersionByServiceId?: Record
+ argocdLastDeploymentByServiceId?: Record
}
-export function ServiceList({ className, containerClassName, environment, ...props }: ServiceListProps) {
+export function ServiceList({
+ className,
+ containerClassName,
+ environment,
+ enableSelection = true,
+ servicesOverride,
+ argocdStatusByServiceId,
+ argocdOperationByServiceId,
+ argocdTargetVersionByServiceId,
+ argocdLastDeploymentByServiceId,
+ ...props
+}: ServiceListProps) {
const clusterId = environment.cluster_id || ''
const environmentId = environment.id || ''
const organizationId = environment.organization.id || ''
@@ -70,8 +90,11 @@ export function ServiceList({ className, containerClassName, environment, ...pro
return map
}, [deploymentStages])
+ const sourceServices: ServiceListRows = servicesOverride ?? services
+ const hasSelectionColumn = enableSelection
+
const sortedServices = useMemo(() => {
- return [...services].sort((a, b) => {
+ return [...sourceServices].sort((a, b) => {
const aIsSkipped = skippedServicesMap.get(a.id) || false
const bIsSkipped = skippedServicesMap.get(b.id) || false
@@ -81,63 +104,70 @@ export function ServiceList({ className, containerClassName, environment, ...pro
return 0
})
- }, [services, skippedServicesMap])
+ }, [sourceServices, skippedServicesMap])
- const columnHelper = createColumnHelper<(typeof services)[number]>()
+ const columnHelper = createColumnHelper()
const columns = useMemo(
() => [
- columnHelper.display({
- id: 'select',
- enableColumnFilter: false,
- enableSorting: false,
- header: ({ table }) => (
-
- {/** XXX: fix css weird 1px vertical shift when checked/unchecked **/}
- {
- if (checked === 'indeterminate') {
- return
- }
- table.toggleAllRowsSelected(checked)
- }}
- />
-
- ),
- cell: ({ row }) => {
- const isDisabled = !row.getCanSelect()
- const checkbox = (
- {
- if (checked === 'indeterminate') {
- return
- }
- row.toggleSelected(checked)
- }}
- />
- )
+ ...(enableSelection
+ ? [
+ columnHelper.display({
+ id: 'select',
+ enableColumnFilter: false,
+ enableSorting: false,
+ header: ({ table }) => (
+
+ {/** XXX: fix css weird 1px vertical shift when checked/unchecked **/}
+ {
+ if (checked === 'indeterminate') {
+ return
+ }
+ table.toggleAllRowsSelected(checked)
+ }}
+ />
+
+ ),
+ cell: ({ row }) => {
+ const isDisabled = !row.getCanSelect()
+ const checkbox = (
+ {
+ if (checked === 'indeterminate') {
+ return
+ }
+ row.toggleSelected(checked)
+ }}
+ />
+ )
- return (
- e.stopPropagation()}>
- {isDisabled ? (
-
- {checkbox}
-
- ) : (
- checkbox
- )}
-
- )
- },
- }),
+ return (
+ e.stopPropagation()}
+ >
+ {isDisabled ? (
+
+ {checkbox}
+
+ ) : (
+ checkbox
+ )}
+
+ )
+ },
+ }),
+ ]
+ : []),
columnHelper.accessor('name', {
header: 'Service',
enableColumnFilter: true,
@@ -162,7 +192,12 @@ export function ServiceList({ className, containerClassName, environment, ...pro
cell: (info) => {
return (
-
+
)
},
@@ -174,15 +209,18 @@ export function ServiceList({ className, containerClassName, environment, ...pro
enableSorting: false,
filterFn: 'arrIncludesSome',
size: 15,
- cell: (info) => (
-
- ),
+ cell: (info) => {
+ return (
+
+ )
+ },
}),
columnHelper.accessor('version', {
header: 'Target version',
@@ -191,7 +229,12 @@ export function ServiceList({ className, containerClassName, environment, ...pro
size: 30,
cell: (info) => {
return (
-
+
)
},
}),
@@ -207,24 +250,39 @@ export function ServiceList({ className, containerClassName, environment, ...pro
organizationId={organizationId}
projectId={projectId}
environmentId={environmentId}
+ timeLabelOverride={argocdLastDeploymentByServiceId?.[info.row.original.id]}
/>
)
},
}),
],
- [columnHelper, environment, clusterId, organizationId, projectId, environmentId]
+ [
+ columnHelper,
+ environment,
+ clusterId,
+ organizationId,
+ projectId,
+ environmentId,
+ argocdStatusByServiceId,
+ argocdOperationByServiceId,
+ argocdLastDeploymentByServiceId,
+ argocdTargetVersionByServiceId,
+ enableSelection,
+ ]
)
- const table = useReactTable({
+ const table = useReactTable({
data: sortedServices,
columns,
state: {
sorting,
rowSelection,
},
- enableRowSelection: (row) => {
- return !skippedServicesMap.get(row.original.id)
- },
+ enableRowSelection: enableSelection
+ ? (row) => {
+ return !skippedServicesMap.get(row.original.id)
+ }
+ : false,
onSortingChange: setSorting,
onRowSelectionChange: setRowSelection,
getCoreRowModel: getCoreRowModel(),
@@ -246,7 +304,7 @@ export function ServiceList({ className, containerClassName, environment, ...pro
table.getColumn('runningStatus')?.getFacetedUniqueValues().entries() ?? []
)
- if (services.length === 0) {
+ if (sourceServices.length === 0) {
return (
{headerGroup.headers.map((header, i) => (
{header.column.getCanFilter() ? (
@@ -339,8 +401,12 @@ export function ServiceList({ className, containerClassName, environment, ...pro
{row.getVisibleCells().map((cell, i) => (
{flexRender(cell.column.columnDef.cell, cell.getContext())}
@@ -350,11 +416,13 @@ export function ServiceList({ className, containerClassName, environment, ...pro
))}
- table.resetRowSelection()}
- />
+ {enableSelection ? (
+ table.resetRowSelection()}
+ />
+ ) : null}
)
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 c18af4666aa..95153f62b0b 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
@@ -2,6 +2,9 @@ import type { ReactNode } from 'react'
import { renderWithProviders, screen, waitFor } from '@qovery/shared/util-tests'
import { ServiceNew } from './service-new'
+const mockShowPylonForm = jest.fn()
+const mockShowChat = jest.fn()
+
jest.mock('@tanstack/react-router', () => ({
...jest.requireActual('@tanstack/react-router'),
}))
@@ -31,10 +34,14 @@ jest.mock('@qovery/shared/ui', () => {
jest.mock('@qovery/shared/util-hooks', () => ({
...jest.requireActual('@qovery/shared/util-hooks'),
- useSupportChat: () => ({ showPylonForm: jest.fn() }),
+ useSupportChat: () => ({ showPylonForm: mockShowPylonForm, showChat: mockShowChat }),
}))
describe('ServiceNew', () => {
+ beforeEach(() => {
+ jest.clearAllMocks()
+ })
+
it('should render successfully', () => {
const { baseElement } = renderWithProviders(
@@ -67,6 +74,9 @@ describe('ServiceNew', () => {
renderWithProviders(
)
+ expect(screen.getByRole('heading', { name: 'Integrations' })).toBeInTheDocument()
+ expect(screen.getByText('Want more integrations?')).toBeInTheDocument()
+ expect(screen.getByText('Tell us about which integration you would like to see in the future')).toBeInTheDocument()
expect(screen.getByRole('heading', { name: 'Data & Storage' })).toBeInTheDocument()
expect(screen.getByRole('heading', { name: 'Back-end' })).toBeInTheDocument()
expect(screen.getByRole('heading', { name: 'Front-end' })).toBeInTheDocument()
@@ -74,6 +84,43 @@ describe('ServiceNew', () => {
expect(screen.getByRole('heading', { name: 'More template' })).toBeInTheDocument()
})
+ it('should link the ArgoCD integration card with the environment cluster context', () => {
+ const { container } = renderWithProviders(
+
+ )
+
+ expect(screen.getByText('ArgoCD')).toBeInTheDocument()
+ expect(
+ container.querySelector('a[href="/organization/org-1/settings/argocd-integration?clusterId=cluster-1"]')
+ ).toBeInTheDocument()
+ })
+
+ it('should open support chat when clicking on Want more integrations card', async () => {
+ const { userEvent } = renderWithProviders(
+
+ )
+
+ await userEvent.click(screen.getByText('Want more integrations?'))
+
+ expect(mockShowChat).toHaveBeenCalledTimes(1)
+ })
+
+ it('should exclude Want more integrations card from search results', async () => {
+ const { userEvent } = renderWithProviders(
+
+ )
+
+ await userEvent.type(screen.getByPlaceholderText('Search…'), 'integrations')
+
+ expect(screen.queryByText('Want more integrations?')).not.toBeInTheDocument()
+ })
+
it('should link database entries to the database create flow', async () => {
const { container, userEvent } = renderWithProviders(
`${getEnvironmentBasePath(organizationId, projectId, environmentId)}${subPath}`
+const getArgoCdIntegrationsPath = (organizationId: string, clusterId?: string) => {
+ const path = `/organization/${organizationId}/settings/argocd-integration`
+
+ if (!clusterId) return path
+
+ return `${path}?clusterId=${encodeURIComponent(clusterId)}`
+}
+
const CREATE_FLOW_SLUG_BY_TYPE: Partial> = {
APPLICATION: 'application',
CONTAINER: 'container',
@@ -102,19 +110,22 @@ function Card({
onClick,
disabledCTA,
badge,
+ cardClassName,
}: {
title: string
description: string
- icon: ReactElement
+ icon?: ReactElement
link?: string
onClick?: () => void
disabledCTA?: ReactElement
badge?: string
+ cardClassName?: string
}) {
const Wrapper = ({ children }: { children: ReactElement }) => {
const className = twMerge(
'flex cursor-pointer items-center justify-between gap-5 rounded border border-neutral px-5 py-4 transition [box-shadow:0px_2px_8px_-1px_rgba(27,36,44,0.08),0px_2px_2px_-1px_rgba(27,36,44,0.04)]',
- disabledCTA ? 'border-neutral bg-surface-neutral-subtle' : 'hover:bg-surface-neutral-subtle'
+ disabledCTA ? 'border-neutral bg-surface-neutral-subtle' : 'hover:bg-surface-neutral-subtle',
+ cardClassName
)
if (onClick) {
@@ -130,7 +141,7 @@ function Card({
}
return (
- // @ts-expect-error-next-line TODO new-nav : Route strings need to be updated using the next typed routes
+ // @ts-ignore TODO new-nav: Route strings need to be updated using the next typed routes
{children}
@@ -160,7 +171,7 @@ function Card({
{disabledCTA}